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
949 changes: 56 additions & 893 deletions bun.lock

Large diffs are not rendered by default.

274 changes: 274 additions & 0 deletions packages/server/src/gateway/__tests__/slack-platform-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,73 @@
import { describe, expect, mock, test } from "bun:test";

// Stub the MCP OAuth flow so the home-tab "Connect" path doesn't hit the
// network for discovery. Must be registered before slack-platform-bridge is
// imported (it captures the `startAuthCodeFlow` binding at module eval).
const startAuthCodeFlowMock = mock(async (opts: any) => ({
authorizationUrl: `${opts?.staticOauth?.authUrl ?? "https://auth.example/authorize"}?state=test-state`,
state: "test-state",
}));
mock.module("../auth/mcp/oauth-flow.js", () => ({
startAuthCodeFlow: startAuthCodeFlowMock,
}));

import {
parseSlackTeamJoinEvent,
postSlackTeamJoinWelcome,
registerSlackAppHome,
registerSlackPlatformHandlers,
} from "../connections/slack-platform-bridge.js";

function blocksText(view: { blocks?: Array<Record<string, unknown>> }): string {
return JSON.stringify(view.blocks ?? []);
}

type HomeHandler = (event: {
userId: string;
adapter?: {
publishHomeView?: (u: string, v: Record<string, unknown>) => Promise<void>;
};
}) => Promise<void>;
type ActionHandler = (event: {
actionId: string;
value?: string;
user: { userId: string };
adapter?: {
publishHomeView?: (u: string, v: Record<string, unknown>) => Promise<void>;
};
raw?: unknown;
}) => Promise<void>;

function makeHomeChat() {
let homeHandler: HomeHandler | undefined;
let actionHandler: ActionHandler | undefined;
const chat = {
onAppHomeOpened: mock((h: HomeHandler) => {
homeHandler = h;
}),
onAction: mock((_ids: string[], h: ActionHandler) => {
actionHandler = h;
}),
};
return {
chat,
open: (userId: string, publishHomeView: ReturnType<typeof mock>) =>
homeHandler?.({ userId, adapter: { publishHomeView } }),
click: (
actionId: string,
value: string,
userId: string,
publishHomeView: ReturnType<typeof mock>
) =>
actionHandler?.({
actionId,
value,
user: { userId },
adapter: { publishHomeView },
}),
};
}

describe("Slack platform bridge", () => {
test("routes /lobu slash commands through the command dispatcher", async () => {
let slashHandler:
Expand Down Expand Up @@ -86,6 +149,217 @@ describe("Slack platform bridge", () => {
);
});

const connection = (over: Record<string, unknown> = {}) =>
({
id: "conn-1",
platform: "slack",
agentId: "agent-7",
metadata: { botUsername: "Lobster" },
settings: {},
...over,
}) as any;

const mcpStatus = [
{ id: "github", name: "github", requiresAuth: true, requiresInput: false },
{
id: "google-drive",
name: "google-drive",
requiresAuth: true,
requiresInput: false,
},
{
id: "weather",
name: "weather",
requiresAuth: false,
requiresInput: false,
},
{
id: "lobu-memory",
name: "lobu-memory",
requiresAuth: false,
requiresInput: false,
},
];

// Fake WritableSecretStore: GitHub has a stored credential, nothing else.
const fakeSecretStore = () => {
const del = mock(async (_name: string) => undefined);
return {
del,
store: {
get: mock(async (ref: string) =>
ref.includes("github") ? JSON.stringify({ accessToken: "x" }) : null
),
put: mock(async () => "secret://x"),
delete: del,
list: mock(async () => []),
} as any,
};
};

test("home tab shows per-user connect/disconnect status for integrations", async () => {
const h = makeHomeChat();
const { store } = fakeSecretStore();
registerSlackAppHome(h.chat, connection(), {
mcpConfigService: { getMcpStatus: mock(async () => mcpStatus) } as any,
secretStore: store,
publicGatewayUrl: "https://gw.example",
});

const publishHomeView = mock(async () => undefined);
await h.open("U123", publishHomeView);

expect(publishHomeView).toHaveBeenCalledTimes(1);
const view = publishHomeView.mock.calls[0]![1] as {
type: string;
blocks: Array<Record<string, any>>;
};
expect(view.type).toBe("home");
const text = blocksText(view);
expect(text).toContain("Lobster");
expect(text).toContain("Github");
expect(text).toContain("Google Drive");
expect(text).toContain("Weather");
// Internal plumbing MCP is hidden.
expect(text).not.toContain("Lobu Memory");

// GitHub has a credential → Disconnect button.
const githubSection = view.blocks.find(
(b) => b.accessory?.value === "github"
);
expect(githubSection?.accessory?.action_id).toBe("lobu_home_disconnect");
// Google Drive is not connected → Connect button.
const gdriveSection = view.blocks.find(
(b) => b.accessory?.value === "google-drive"
);
expect(gdriveSection?.accessory?.action_id).toBe("lobu_home_connect");
});

test("Disconnect button revokes the credential and re-publishes the home tab", async () => {
const h = makeHomeChat();
const { store, del } = fakeSecretStore();
registerSlackAppHome(h.chat, connection(), {
mcpConfigService: { getMcpStatus: mock(async () => mcpStatus) } as any,
secretStore: store,
publicGatewayUrl: "https://gw.example",
});

const publishHomeView = mock(async () => undefined);
await h.click("lobu_home_disconnect", "github", "U123", publishHomeView);

// deleteCredential removes both the credential and device-auth secret rows.
expect(del).toHaveBeenCalled();
expect(del.mock.calls.some(([name]) => String(name).includes("github"))).toBe(
true
);
expect(publishHomeView).toHaveBeenCalledTimes(1);
expect(publishHomeView.mock.calls[0]![0]).toBe("U123");
});

test("ignores a Disconnect action for an integration the agent doesn't have", async () => {
const h = makeHomeChat();
const { store, del } = fakeSecretStore();
registerSlackAppHome(h.chat, connection(), {
mcpConfigService: { getMcpStatus: mock(async () => mcpStatus) } as any,
secretStore: store,
publicGatewayUrl: "https://gw.example",
});

const publishHomeView = mock(async () => undefined);
await h.click(
"lobu_home_disconnect",
"../../other-agent/U999/github/credential",
"U123",
publishHomeView
);

// No secret deleted, no home re-publish — the crafted id is rejected.
expect(del).not.toHaveBeenCalled();
expect(publishHomeView).not.toHaveBeenCalled();
});

test("Connect button mints an auth URL and re-publishes with a sign-in link", async () => {
const h = makeHomeChat();
startAuthCodeFlowMock.mockClear();
const getHttpServer = mock(async () => ({
id: "github",
upstreamUrl: "https://github-mcp.example/mcp",
oauth: {
authUrl: "https://github-mcp.example/oauth/authorize",
tokenUrl: "https://github-mcp.example/oauth/token",
clientId: "client-123",
},
}));
registerSlackAppHome(h.chat, connection(), {
mcpConfigService: {
getMcpStatus: mock(async () => mcpStatus),
getHttpServer,
} as any,
secretStore: { get: mock(async () => null), put: mock(), delete: mock(), list: mock(async () => []) } as any,
publicGatewayUrl: "https://gw.example",
});

const publishHomeView = mock(async () => undefined);
await h.click("lobu_home_connect", "github", "U123", publishHomeView);

expect(getHttpServer).toHaveBeenCalled();
expect(startAuthCodeFlowMock).toHaveBeenCalledTimes(1);
const flowOpts = startAuthCodeFlowMock.mock.calls[0]![0] as any;
expect(flowOpts.mcpId).toBe("github");
expect(flowOpts.scopeKey).toBe("U123");
expect(flowOpts.platform).toBe("slack");

expect(publishHomeView).toHaveBeenCalledTimes(1);
const view = publishHomeView.mock.calls[0]![1] as {
blocks: Array<Record<string, any>>;
};
const githubSection = view.blocks.find((b) =>
String(b.text?.text ?? "").includes("Github")
);
expect(githubSection?.accessory?.url).toContain(
"https://github-mcp.example/oauth/authorize"
);
expect(githubSection?.accessory?.url).toContain("state=test-state");
});

test("home tab renders without the integrations section when the MCP config service is absent", async () => {
const h = makeHomeChat();
registerSlackAppHome(h.chat, connection(), {});
const publishHomeView = mock(async () => undefined);
await h.open("U123", publishHomeView);
const view = publishHomeView.mock.calls[0]![1] as {
blocks: Array<Record<string, unknown>>;
};
expect(blocksText(view)).toContain("Lobster");
expect(view.blocks.some((b) => b.type === "header")).toBe(false);
});

test("renders the preview-workspace home tab without touching the MCP config", async () => {
const h = makeHomeChat();
const getMcpStatus = mock(async () => mcpStatus);
registerSlackAppHome(
h.chat,
connection({ agentId: "placeholder", settings: { previewMode: true } }),
{
mcpConfigService: { getMcpStatus } as any,
secretStore: { getStoredCredential: mock(async () => null) } as any,
publicGatewayUrl: "https://gw.example",
}
);

const publishHomeView = mock(async () => undefined);
await h.open("U123", publishHomeView);

expect(getMcpStatus).not.toHaveBeenCalled();
const text = blocksText(
publishHomeView.mock.calls[0]![1] as {
blocks?: Array<Record<string, unknown>>;
}
);
expect(text).toContain("preview");
expect(text).toContain("/lobu link");
});

test("parses and welcomes Slack team_join users", async () => {
const parsed = parseSlackTeamJoinEvent(
JSON.stringify({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ import {
import { createGatewayStateAdapter } from "./state-adapter.js";
import { SlackConnectionCoordinator } from "./slack-connection-coordinator.js";
import { SlackInstructionProvider } from "./slack-instruction-provider.js";
import { registerSlackPlatformHandlers } from "./slack-platform-bridge.js";
import {
registerSlackAppHome,
registerSlackPlatformHandlers,
} from "./slack-platform-bridge.js";
import { registerInteractionBridge } from "./interaction-bridge.js";
import {
type MessageHandlerBridge,
Expand Down Expand Up @@ -582,6 +585,11 @@ export class ChatInstanceManager {
commandDispatcher
);
registerSlackPlatformHandlers(chat, connection, commandDispatcher);
registerSlackAppHome(chat, connection, {
mcpConfigService: this.services.getMcpConfigService(),
secretStore: this.services.getSecretStore(),
publicGatewayUrl: this.publicGatewayUrl,
});

chat.registerSingleton();

Expand Down
Loading
Loading