Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/channels/__tests__/slack-http-receiver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,71 @@ describe("lifecycle and bot user discovery", () => {
});
});

// ----- synthetic first DM on connect ---------------------------------------

describe("synthetic first DM on connect", () => {
test("opens a DM with the installer and posts the introduction text", async () => {
const channel = new SlackHttpChannel(baseConfig);
channel.setPhantomName("Maple");
await channel.connect();

// installerUserId is U_INSTALLER per baseConfig; conversations.open is
// called once for the introduction. The post then hits D_DM_OPEN.
expect(mockConversationsOpen).toHaveBeenCalledWith({ users: "U_INSTALLER" });
const calls = mockPostMessage.mock.calls as unknown as Array<[{ channel?: string; text?: string }]>;
const introCall = calls.find((c) => {
const arg = c[0];
return arg.channel === "D_DM_OPEN" && typeof arg.text === "string" && arg.text.includes("I'm Maple");
});
expect(introCall).toBeDefined();
});

test("does not re-introduce on a reconnect after disconnect (firstDmSent gates)", async () => {
const channel = new SlackHttpChannel(baseConfig);
await channel.connect();
const calls1 = mockPostMessage.mock.calls as unknown as Array<[{ channel?: string }]>;
const introCallsAfterFirst = calls1.filter((c) => c[0].channel === "D_DM_OPEN").length;
await channel.disconnect();

// A reconnect after a transient drop should NOT re-introduce. The
// firstDmSent flag is instance-level and persists across the
// connect/disconnect/connect cycle on the same channel object.
await channel.connect();
const calls2 = mockPostMessage.mock.calls as unknown as Array<[{ channel?: string }]>;
const introCallsAfterReconnect = calls2.filter((c) => c[0].channel === "D_DM_OPEN").length;
expect(introCallsAfterReconnect).toBe(introCallsAfterFirst);
});

test("connect still resolves successfully when the introduction DM fails", async () => {
// Simulate a Slack rate-limit by rejecting chat.postMessage. The
// channel must still finish connect() so the user can receive
// channel messages even when their first DM was rate-limited.
mockPostMessage.mockImplementation(() => Promise.reject(new Error("ratelimited")));
const channel = new SlackHttpChannel(baseConfig);
await expect(channel.connect()).resolves.toBeUndefined();
expect(channel.getConnectionState()).toBe("connected");
// Restore for subsequent tests.
mockPostMessage.mockImplementation(() => Promise.resolve({ ts: "1234567890.123456" }));
});

test("retries the introduction on a fresh connect after first send returned null", async () => {
// First connect: chat.postMessage returns no ts (rate-limit, archived
// channel). firstDmSent stays false; the caller's failed_first_dm
// path is what surfaces this externally. On a fresh connect (after
// the operator restarts the channel), the introduction should fire
// again so the user eventually gets their DM.
mockPostMessage.mockImplementationOnce(() => Promise.resolve({ ts: "" } as { ts: string }));
const channel = new SlackHttpChannel(baseConfig);
await channel.connect();
await channel.disconnect();
await channel.connect();
const calls = mockPostMessage.mock.calls as unknown as Array<[{ channel?: string }]>;
const introCalls = calls.filter((c) => c[0].channel === "D_DM_OPEN").length;
// One failed attempt + one successful retry on the second connect.
expect(introCalls).toBe(2);
});
});

// ----- send and outbound API ----------------------------------------------

describe("send / outbound", () => {
Expand All @@ -642,6 +707,10 @@ describe("send / outbound", () => {
test("postToChannel chunks long messages but keeps one chat.postMessage per chunk", async () => {
const channel = new SlackHttpChannel(baseConfig);
await channel.connect();
// connect() fires the synthetic introduction DM which hits
// chat.postMessage once. Clear the count here so this test
// asserts only the explicit postToChannel call.
mockPostMessage.mockClear();
await channel.postToChannel("C1", "short");
expect(mockPostMessage).toHaveBeenCalledTimes(1);
});
Expand Down
309 changes: 309 additions & 0 deletions src/channels/__tests__/slack-introduction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { composeIntroductionText, sendIntroductionDm } from "../slack-introduction.ts";

// Module-mock the heartbeat dependency before importing slack-introduction
// so its `await reportFirstDmSent(...)` call lands on our recorder. This
// pattern mirrors slack-http-receiver.test.ts's @slack/bolt mock: the
// behaviour-under-test is sendIntroductionDm itself; the heartbeat is a
// boundary we observe rather than a unit we exercise.
type HeartbeatCall = { metadataBaseUrl: string; slackMessageTs: string };
const heartbeatCalls: HeartbeatCall[] = [];
let heartbeatThrows: Error | null = null;

mock.module("../../tenancy/heartbeat.ts", () => ({
reportFirstDmSent: mock(async (opts: { metadataBaseUrl: string; slackMessageTs: string }) => {
heartbeatCalls.push({ metadataBaseUrl: opts.metadataBaseUrl, slackMessageTs: opts.slackMessageTs });
if (heartbeatThrows) {
const err = heartbeatThrows;
heartbeatThrows = null;
throw err;
}
}),
}));

const ORIGINAL_METADATA = process.env.METADATA_BASE_URL;
const ORIGINAL_TRANSPORT = process.env.SLACK_TRANSPORT;
const ORIGINAL_DASHBOARD = process.env.PHANTOM_DASHBOARD_URL;

beforeEach(() => {
heartbeatCalls.length = 0;
heartbeatThrows = null;
process.env.METADATA_BASE_URL = "http://169.254.169.254";
process.env.SLACK_TRANSPORT = "http";
process.env.PHANTOM_DASHBOARD_URL = undefined;
});

afterEach(() => {
if (ORIGINAL_METADATA === undefined) {
process.env.METADATA_BASE_URL = undefined;
} else {
process.env.METADATA_BASE_URL = ORIGINAL_METADATA;
}
if (ORIGINAL_TRANSPORT === undefined) {
process.env.SLACK_TRANSPORT = undefined;
} else {
process.env.SLACK_TRANSPORT = ORIGINAL_TRANSPORT;
}
if (ORIGINAL_DASHBOARD === undefined) {
process.env.PHANTOM_DASHBOARD_URL = undefined;
} else {
process.env.PHANTOM_DASHBOARD_URL = ORIGINAL_DASHBOARD;
}
});

describe("composeIntroductionText", () => {
test("includes the phantom name and team name in the greeting", () => {
const text = composeIntroductionText("Maple", "Acme Corp");
expect(text).toContain("Hi! I'm Maple.");
expect(text).toContain("connected to Acme Corp");
});

test("instructs the user how to interact with the agent", () => {
const text = composeIntroductionText("Phantom", "Workspace");
expect(text).toContain("Reply to this DM");
expect(text).toContain("@-mention me");
});

test("omits the manage-me line when no dashboard URL is provided", () => {
const text = composeIntroductionText("Phantom", "Workspace");
expect(text).not.toContain("manage me");
});

test("includes the dashboard URL in the manage-me line when provided", () => {
const text = composeIntroductionText("Phantom", "Workspace", "https://example.test/dashboard");
expect(text).toContain("You can manage me at https://example.test/dashboard.");
});
});

describe("sendIntroductionDm", () => {
test("calls sendDm with the installer user id and the composed text", async () => {
const sendDm = mock(async (_userId: string, _text: string) => "1715000000.000123" as string | null);
const result = await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme Corp",
installerUserId: "U_INSTALLER",
sendDm,
});

expect(sendDm).toHaveBeenCalledTimes(1);
const args = sendDm.mock.calls[0];
if (!args) throw new Error("no call");
expect(args[0]).toBe("U_INSTALLER");
expect(args[1]).toContain("I'm Maple");
expect(result.sent).toBe(true);
expect(result.messageTs).toBe("1715000000.000123");
});

test("includes the dashboard URL when PHANTOM_DASHBOARD_URL is set to a valid URL", async () => {
process.env.PHANTOM_DASHBOARD_URL = "https://example.test/manage";
let captured = "";
const sendDm = mock(async (_u: string, text: string) => {
captured = text;
return "1715000000.000111" as string | null;
});
await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});
expect(captured).toContain("You can manage me at https://example.test/manage.");
});

test("omits the manage-me line when PHANTOM_DASHBOARD_URL is unset (self-host)", async () => {
let captured = "";
const sendDm = mock(async (_u: string, text: string) => {
captured = text;
return "1715000000.000222" as string | null;
});
await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});
expect(captured).not.toContain("manage me");
});

test("omits the manage-me line when PHANTOM_DASHBOARD_URL is malformed", async () => {
process.env.PHANTOM_DASHBOARD_URL = "not a url";
let captured = "";
const sendDm = mock(async (_u: string, text: string) => {
captured = text;
return "1715000000.000333" as string | null;
});
await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});
expect(captured).not.toContain("manage me");
});

test("acks first_dm_sent with the returned message_ts when SLACK_TRANSPORT=http", async () => {
const sendDm = mock(async () => "1715000000.000456" as string | null);
const result = await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});

expect(result.sent).toBe(true);
expect(heartbeatCalls.length).toBe(1);
const call = heartbeatCalls[0];
if (!call) throw new Error("no heartbeat");
expect(call.metadataBaseUrl).toBe("http://169.254.169.254");
expect(call.slackMessageTs).toBe("1715000000.000456");
});

test("acks first_dm_sent with the default metadata URL when METADATA_BASE_URL is unset but SLACK_TRANSPORT=http", async () => {
// SLACK_TRANSPORT=http is the actual signal that the agent is in
// an operator-managed deployment. METADATA_BASE_URL may be unset
// in that deployment because the channel factory defaults to the
// link-local address; the heartbeat must follow the same
// fallback.
process.env.METADATA_BASE_URL = undefined;
const sendDm = mock(async () => "1715000000.000789" as string | null);
const result = await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});

expect(result.sent).toBe(true);
expect(heartbeatCalls.length).toBe(1);
const call = heartbeatCalls[0];
if (!call) throw new Error("no heartbeat");
expect(call.metadataBaseUrl).toBe("http://169.254.169.254");
});

test("skips the first_dm_sent ack when SLACK_TRANSPORT is unset (self-host Socket Mode default)", async () => {
process.env.SLACK_TRANSPORT = undefined;
const sendDm = mock(async () => "1715000000.000900" as string | null);
const result = await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});

// The DM still sends; only the heartbeat is gated on the
// transport mode. Self-hosters never run inside an
// operator-managed VM, so there is no listener for the signal.
expect(result.sent).toBe(true);
expect(heartbeatCalls.length).toBe(0);
});

test("skips the first_dm_sent ack when SLACK_TRANSPORT=socket (explicit self-host)", async () => {
process.env.SLACK_TRANSPORT = "socket";
const sendDm = mock(async () => "1715000000.000901" as string | null);
const result = await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});

expect(result.sent).toBe(true);
expect(heartbeatCalls.length).toBe(0);
});

test("acks first_dm_sent when SLACK_TRANSPORT is whitespace-padded http (trim parity with factory)", async () => {
// readSlackTransportFromEnv() trims before deciding transport, so
// SLACK_TRANSPORT=" http " selects the HTTP receiver. The gate
// here must use the same normalization or the heartbeat is
// skipped while the receiver runs, leaving activation pending.
process.env.SLACK_TRANSPORT = " http ";
const sendDm = mock(async () => "1715000000.000902" as string | null);
const result = await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});

expect(result.sent).toBe(true);
expect(heartbeatCalls.length).toBe(1);
const call = heartbeatCalls[0];
if (!call) throw new Error("no heartbeat");
expect(call.slackMessageTs).toBe("1715000000.000902");
});

test("skips first_dm_sent when SLACK_TRANSPORT is empty string (treated as socket default)", async () => {
process.env.SLACK_TRANSPORT = "";
const sendDm = mock(async () => "1715000000.000903" as string | null);
const result = await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});

expect(result.sent).toBe(true);
expect(heartbeatCalls.length).toBe(0);
});

test("returns sent:false and skips heartbeat when sendDm returns null (Slack rate limit)", async () => {
const sendDm = mock(async () => null);
const result = await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});

expect(result.sent).toBe(false);
expect(result.messageTs).toBeNull();
// No ts means no audit signal the host gateway can record. The
// caller's failed_first_dm path picks up the timeout
// independently via the operator's poll loop.
expect(heartbeatCalls.length).toBe(0);
});

test("returns sent:false when sendDm throws (network down, token revoked)", async () => {
const sendDm = mock(async () => {
throw new Error("ECONNREFUSED");
});
const result = await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});

// Errors are swallowed so connect()'s caller stays successful. The
// log line surfaces the failure for operator triage.
expect(result.sent).toBe(false);
expect(heartbeatCalls.length).toBe(0);
});

test("redacts a leaked bot token if it appears in a thrown error message", async () => {
const sendDm = mock(async () => {
throw new Error("postMessage failed: xoxb-leaky-token-XXX in body");
});
const errors: string[] = [];
const original = console.error;
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(" "));
};
try {
await sendIntroductionDm({
phantomName: "Maple",
teamName: "Acme",
installerUserId: "U_INSTALLER",
sendDm,
});
} finally {
console.error = original;
}

// Defense in depth: the redactTokens helper that the connect path
// already trusts is the same one used here; confirm the contract.
const all = errors.join("\n");
expect(all).toContain("postMessage failed");
expect(all).not.toContain("xoxb-leaky-token-XXX");
});
});
Loading
Loading