/** * Tests for SlackClientImpl. * * Validates the SlackResponse discriminated union contract: * - ok:false from Slack → SlackErrorResponse (no throw) * - ok:true from Slack → Zod-validated success data * - ok:true but schema mismatch → RestApiValidationError (thrown) * - Body validation failures still throw RestApiValidationError */ import { describe, it, expect, vi } from "vitest"; import { z } from "zod"; import { RestApiValidationError } from "../../errors.js"; import { REDACTED_BINARY_RESPONSE_DATA } from "../base/decode-worker-binary-response.js"; import type { IntegrationConfig } from "../types.js"; import { SlackClientImpl } from "./client.js"; import type { SlackErrorResponse } from "./types.js"; const TEST_CONFIG: IntegrationConfig = { id: "slack-test-id", name: "Test Slack", pluginId: "slack", configuration: {}, }; function createClient(mockResult: unknown) { const executeQuery = vi.fn().mockResolvedValue(mockResult); const client = new SlackClientImpl(TEST_CONFIG, executeQuery); return { client, executeQuery }; } const ChannelsSchema = z.object({ channels: z.array(z.object({ id: z.string(), name: z.string() })), }); const PostMessageSchema = z.object({ channel: z.string(), ts: z.string(), }); describe("SlackClientImpl", () => { // ── Success responses ────────────────────────────────────────── describe("success responses (ok: true)", () => { it("returns validated data with ok: true", async () => { const { client } = createClient({ ok: true, channels: [{ id: "C123", name: "general" }], }); const result = await client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ); expect(result.ok).toBe(true); if (result.ok) { expect(result.channels).toHaveLength(1); expect(result.channels[0]).toEqual({ id: "C123", name: "general" }); } }); it("strips unknown keys via Zod strip mode", async () => { const { client } = createClient({ ok: true, channels: [{ id: "C1", name: "test", extra: true }], response_metadata: { next_cursor: "" }, }); const result = await client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ); expect(result.ok).toBe(true); if (result.ok) { expect(result).not.toHaveProperty("response_metadata"); expect(result.channels[0]).not.toHaveProperty("extra"); } }); it("validates a post message success response", async () => { const { client } = createClient({ ok: true, channel: "C123", ts: "1234567890.123456", }); const result = await client.apiRequest( { method: "POST", path: "/chat.postMessage", body: { channel: "C123", text: "hello" }, }, { response: PostMessageSchema }, ); expect(result.ok).toBe(true); if (result.ok) { expect(result.ts).toBe("1234567890.123456"); expect(result.channel).toBe("C123"); } }); }); // ── Error responses (ok: false) ──────────────────────────────── describe("error responses (ok: false)", () => { it("returns SlackErrorResponse instead of throwing", async () => { const { client } = createClient({ ok: false, error: "channel_not_found", }); const result = await client.apiRequest( { method: "POST", path: "/chat.postMessage", body: { channel: "C000", text: "hi" }, }, { response: PostMessageSchema }, ); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toBe("channel_not_found"); } }); it("includes needed scopes on missing_scope errors", async () => { const { client } = createClient({ ok: false, error: "missing_scope", needed: "channels:read", provided: "identify,bot", }); const result = await client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toBe("missing_scope"); expect(result.needed).toBe("channels:read"); expect(result.provided).toBe("identify,bot"); } }); it("throws when ok: false but no error string (invalid Slack error)", async () => { const { client } = createClient({ ok: false }); // { ok: false } without an error string doesn't match SlackErrorSchema, // and also doesn't match the success schema → RestApiValidationError. await expect( client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ), ).rejects.toThrow(RestApiValidationError); }); it.each(["not_authed", "invalid_auth", "ratelimited"])( "handles %s error", async (errorCode) => { const { client } = createClient({ ok: false, error: errorCode }); const result = await client.apiRequest( { method: "GET", path: "/auth.test" }, { response: z.object({}) }, ); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toBe(errorCode); } }, ); it("omits needed/provided when not present in Slack response", async () => { const { client } = createClient({ ok: false, error: "channel_not_found", }); const result = await client.apiRequest( { method: "POST", path: "/chat.postMessage" }, { response: PostMessageSchema }, ); expect(result.ok).toBe(false); if (!result.ok) { expect(result).not.toHaveProperty("needed"); expect(result).not.toHaveProperty("provided"); } }); it("throws when needed/provided are non-string (invalid Slack error)", async () => { const { client } = createClient({ ok: false, error: "missing_scope", needed: 123, provided: true, }); // Zod rejects non-string needed/provided. Since raw response has ok:false, // SlackClient rejects it before success payload validation. await expect( client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ), ).rejects.toThrow(RestApiValidationError); }); }); // ── Validation errors (still thrown) ─────────────────────────── describe("validation errors", () => { it("throws RestApiValidationError when ok:true but schema does not match", async () => { // Slack says ok:true but response is missing required fields const { client } = createClient({ ok: true, // Missing 'channels' field }); await expect( client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ), ).rejects.toThrow(RestApiValidationError); }); it("includes raw data and Zod error in validation error details", async () => { const badResponse = { ok: true }; const { client } = createClient(badResponse); try { await client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ); expect.fail("Expected RestApiValidationError"); } catch (e) { expect(e).toBeInstanceOf(RestApiValidationError); const err = e as RestApiValidationError; expect(err.details.data).toEqual(badResponse); expect(err.details.zodError).toBeDefined(); } }); it("throws RestApiValidationError when request body fails validation", async () => { const { client } = createClient({ ok: true }); const BodySchema = z.object({ channel: z.string(), text: z.string(), }); await expect( client.apiRequest( { method: "POST", path: "/chat.postMessage", body: { channel: 123 as unknown as string, text: "hello" }, }, { body: BodySchema, response: PostMessageSchema }, ), ).rejects.toThrow(RestApiValidationError); }); }); // ── Request building ─────────────────────────────────────────── describe("request building", () => { it("sends correct proto structure to executeQuery", async () => { const { client, executeQuery } = createClient({ ok: true, channel: "C123", ts: "1234567890.123456", }); await client.apiRequest( { method: "POST", path: "/chat.postMessage", body: { channel: "#general", text: "hello" }, headers: { "X-Custom": "value" }, params: { unfurl_links: false }, }, { response: PostMessageSchema }, ); expect(executeQuery).toHaveBeenCalledOnce(); const request = executeQuery.mock.calls[0][0]; expect(request.openApiAction).toBe("genericHttpRequest"); expect(request.httpMethod).toBe("POST"); expect(request.urlPath).toBe("/chat.postMessage"); expect(request.responseType).toBe("json"); expect(request.body).toBe( JSON.stringify({ channel: "#general", text: "hello" }), ); expect(request.bodyType).toBe("jsonBody"); expect(request.headers).toEqual([{ key: "X-Custom", value: "value" }]); expect(request.params).toEqual([{ key: "unfurl_links", value: "false" }]); }); it("passes trace metadata to executeQuery", async () => { const { client, executeQuery } = createClient({ ok: true, channel: "C1", ts: "1.1", }); await client.apiRequest( { method: "POST", path: "/chat.postMessage" }, { response: PostMessageSchema }, { label: "slack.postMessage", description: "Post a message" }, ); expect(executeQuery).toHaveBeenCalledWith(expect.any(Object), undefined, { label: "slack.postMessage", description: "Post a message", }); }); it("propagates executeQuery rejections as transport errors", async () => { const executeQuery = vi .fn() .mockRejectedValue(new Error("network timeout")); const client = new SlackClientImpl(TEST_CONFIG, executeQuery); await expect( client.apiRequest( { method: "GET", path: "/auth.test" }, { response: z.object({}) }, ), ).rejects.toThrow("network timeout"); }); }); // ── Edge cases ───────────────────────────────────────────────── describe("edge cases", () => { it("falls through to schema validation when response has no ok field", async () => { const { client } = createClient({ data: [1, 2, 3] }); // No ok field → not Slack error, and missing success envelope. await expect( client.apiRequest( { method: "GET", path: "/some.endpoint" }, { response: ChannelsSchema }, ), ).rejects.toThrow(RestApiValidationError); }); it("falls through to schema validation when ok is not a boolean", async () => { const { client } = createClient({ ok: "false", error: "nope" }); // ok is a string, so it is neither a valid Slack error envelope // nor a valid success envelope. await expect( client.apiRequest( { method: "GET", path: "/some.endpoint" }, { response: ChannelsSchema }, ), ).rejects.toThrow(RestApiValidationError); }); it("handles response surviving JSON round-trip (protobuf simulation)", async () => { const original = { ok: true, channels: [{ id: "C123", name: "general" }], nested: { count: 42 }, }; const roundTripped = JSON.parse(JSON.stringify(original)); const { client } = createClient(roundTripped); const result = await client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ); expect(result.ok).toBe(true); if (result.ok) { expect(result.channels[0].id).toBe("C123"); } }); it("injects ok: true when schema omits the ok field", async () => { const NoOkSchema = z.object({ channels: z.array(z.object({ id: z.string(), name: z.string() })), }); const { client } = createClient({ ok: true, channels: [{ id: "C1", name: "test" }], }); const result = await client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: NoOkSchema }, ); expect(result.ok).toBe(true); if (result.ok) { expect(result.channels).toHaveLength(1); } }); it("validates payload schema after removing envelope field for strict schemas", async () => { const StrictSchema = z .object({ channels: z.array(z.object({ id: z.string(), name: z.string() })), }) .strict(); const { client } = createClient({ ok: true, channels: [{ id: "C1", name: "test" }], }); const result = await client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: StrictSchema }, ); expect(result.ok).toBe(true); if (result.ok) { expect(result.channels).toHaveLength(1); } }); it("supports passthrough schemas and still enforces literal ok discriminant", async () => { const PassthroughSchema = z .object({ channels: z.array(z.object({ id: z.string(), name: z.string() })), }) .passthrough(); const { client } = createClient({ ok: true, channels: [{ id: "C1", name: "test" }], response_metadata: { next_cursor: "abc" }, }); const result = await client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: PassthroughSchema }, ); expect(result.ok).toBe(true); if (result.ok) { expect(result.channels).toHaveLength(1); expect(result).toHaveProperty("response_metadata"); } }); it("throws when ok:false and loose payload schema would otherwise accept", async () => { const LooseSchema = z.object({}); const { client } = createClient({ ok: false }); await expect( client.apiRequest( { method: "GET", path: "/auth.test" }, { response: LooseSchema }, ), ).rejects.toThrow(RestApiValidationError); }); it("routes to error branch when response matches both schemas", async () => { // Response matches SlackErrorSchema AND a loose success schema. // Error branch must win because it's checked first. const FlexibleSchema = z.object({ error: z.string().optional(), }); const { client } = createClient({ ok: false, error: "channel_not_found", }); const result = await client.apiRequest( { method: "POST", path: "/chat.postMessage" }, { response: FlexibleSchema }, ); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toBe("channel_not_found"); } }); it("does not treat ok: 0 as a Slack error (only strict false)", async () => { // ok: 0 is not === false, so it is NOT detected as a Slack error. // It fails success envelope validation because ok must be literal true. const { client } = createClient({ ok: 0, channels: [{ id: "C1", name: "test" }], }); await expect( client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ), ).rejects.toThrow(RestApiValidationError); }); }); describe("responseType binary", () => { const pdfBytes = [0x25, 0x50, 0x44, 0x46]; const workerPayload = { type: "Buffer", data: pdfBytes }; it("decodes worker Buffer JSON and redacts it from validation errors", async () => { const { client, executeQuery } = createClient(workerPayload); try { await client.apiRequest( { method: "GET", path: "/files.download", responseType: "binary", }, { response: ChannelsSchema }, ); } catch (error) { expect(error).toBeInstanceOf(RestApiValidationError); if (!(error instanceof RestApiValidationError)) { throw error; } expect(executeQuery).toHaveBeenCalledWith( expect.objectContaining({ responseType: "binary" }), undefined, undefined, ); expect(error.details.data).toEqual(REDACTED_BINARY_RESPONSE_DATA); expect(error.details.data).not.toEqual(workerPayload); return; } throw new Error( "Expected Slack binary apiRequest to fail envelope validation", ); }); }); describe("type narrowing", () => { it("narrows to error response fields when ok is false", async () => { const { client } = createClient({ ok: false, error: "missing_scope", needed: "channels:read", }); const result = await client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ); // This block exercises the SlackErrorResponse branch if (!result.ok) { const errorResult: SlackErrorResponse = result; expect(errorResult.error).toBe("missing_scope"); expect(errorResult.needed).toBe("channels:read"); } }); it("narrows to success response fields when ok is true", async () => { const { client } = createClient({ ok: true, channels: [{ id: "C1", name: "general" }], }); const result = await client.apiRequest( { method: "GET", path: "/conversations.list" }, { response: ChannelsSchema }, ); // This block exercises the T & { ok: true } branch if (result.ok) { // TypeScript narrows: result.channels is accessible expect(result.channels).toHaveLength(1); expect(result.channels[0].name).toBe("general"); } }); }); });