mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
90 lines
6.8 KiB
TypeScript
90 lines
6.8 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { assertSlackBotOAuthActor, exchangeSlackBotCode, slackBotAuthorizationUrl, slackBotOAuthBinding, slackBotOAuthStatus, slackDmPilotRequestAllowed, SLACK_CEO_DM_SCOPES } from "./slack-bot-oauth.js";
|
|
|
|
const config = { COMPANY_ID: "company", USER_ID: "user", AGENT_ID: "ceo", APP_ID: "ATEST", TEAM_ID: "TTEST", CLIENT_ID: "client", CLIENT_SECRET: "synthetic-secret", SIGNING_SECRET: "synthetic-signing-secret", callbackUrl: "https://pilot.example/api/chat-endpoints/endpoint/slack/oauth/callback" };
|
|
const installation = { ok: true, app_id: "ATEST", team: { id: "TTEST" }, authed_user: { id: "UOWNER" }, access_token: "xoxb-synthetic", token_type: "bot", bot_user_id: "UBOT", scope: SLACK_CEO_DM_SCOPES.join(",") };
|
|
function exchange(body: unknown, status = 200) {
|
|
const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response(JSON.stringify(body), { status }));
|
|
return { fetch, result: exchangeSlackBotCode(config, "synthetic-code", fetch) };
|
|
}
|
|
|
|
describe("Slack CEO DM OAuth boundary", () => {
|
|
it("binds threaded scopes explicitly, never infers an upgrade from a token", async () => {
|
|
const profile = "ceo-dm-threaded-v2";
|
|
const upgraded = { ...installation, scope: `${installation.scope},reactions:write` };
|
|
const request = vi.fn<typeof globalThis.fetch>().mockImplementation(async () => Response.json(upgraded));
|
|
expect(new URL(slackBotAuthorizationUrl(config, "nonce", profile)).searchParams.get("scope")).toBe(upgraded.scope);
|
|
await expect(exchangeSlackBotCode(config, "code", request)).rejects.toThrow(/exactly/);
|
|
await expect(exchangeSlackBotCode(config, "code", request, profile)).resolves.toMatchObject({ scopes: [...SLACK_CEO_DM_SCOPES, "reactions:write"] });
|
|
});
|
|
it("is disabled by default and does not expose environment values", () => {
|
|
expect(slackBotOAuthStatus("http://localhost:3210", "id", {})).toMatchObject({ enabled: false, configured: false, callbackUrl: null });
|
|
const env = Object.fromEntries(Object.entries(config).map(([key, value]) => [`PAPERCLIP_SLACK_CEO_POC_${key}`, value]));
|
|
env.PAPERCLIP_SLACK_CEO_POC_ENABLED = "true";
|
|
const status = slackBotOAuthStatus("https://pilot.example", "endpoint", env);
|
|
expect(status).toMatchObject({ configured: true, profile: "ceo-dm-v1", callbackUrl: config.callbackUrl });
|
|
expect(JSON.stringify(status)).not.toContain(config.CLIENT_SECRET);
|
|
expect(slackBotOAuthStatus("https://user:pass@pilot.example", "endpoint", env).configured).toBe(false);
|
|
});
|
|
it("uses only the DM bot profile, an exact callback, and pinned workspace", () => {
|
|
const url = new URL(slackBotAuthorizationUrl(config, "nonce"));
|
|
expect(url.origin + url.pathname).toBe("https://slack.com/oauth/v2/authorize");
|
|
expect(url.searchParams.get("scope")?.split(",")).toEqual([...SLACK_CEO_DM_SCOPES]);
|
|
expect(url.searchParams.get("user_scope")).toBeNull();
|
|
expect(url.searchParams.get("redirect_uri")).toBe(config.callbackUrl);
|
|
expect(url.searchParams.get("team")).toBe("TTEST");
|
|
expect(url.searchParams.get("state")).toBe("nonce");
|
|
expect(url.href).not.toContain(config.CLIENT_SECRET);
|
|
expect(slackBotOAuthBinding(config)).not.toBe(slackBotOAuthBinding({ ...config, TEAM_ID: "TOTHER" }));
|
|
});
|
|
it("requires the initiating signed-in user AND session", () => {
|
|
const state = { createdByActorType: "user", createdByActorId: "user", createdBySessionId: "session" };
|
|
expect(() => assertSlackBotOAuthActor(state, { userId: "user", sessionId: "session" })).not.toThrow();
|
|
for (const actor of [{ userId: "other", sessionId: "session" }, { userId: "user", sessionId: "other" }]) {
|
|
expect(() => assertSlackBotOAuthActor(state, actor)).toThrow(/same signed-in/);
|
|
}
|
|
});
|
|
it("normalizes bot and installer identity; never follows token endpoint redirects", async () => {
|
|
const { fetch, result } = exchange(installation);
|
|
await expect(result).resolves.toMatchObject({ botUserId: "UBOT", installingUserId: "UOWNER", scopes: [...SLACK_CEO_DM_SCOPES] });
|
|
expect(fetch).toHaveBeenCalledWith("https://slack.com/api/oauth.v2.access", expect.objectContaining({ method: "POST", redirect: "error" }));
|
|
});
|
|
it.each([
|
|
{ ...installation, app_id: "AOTHER" }, { ...installation, team: { id: "TOTHER" } },
|
|
{ ...installation, is_enterprise_install: true }, { ...installation, token_type: "user" },
|
|
{ ...installation, access_token: "xoxp-user" }, { ...installation, authed_user: {} },
|
|
{ ...installation, scope: "chat:write" }, { ...installation, scope: `${installation.scope},files:read` },
|
|
{ ...installation, refresh_token: "synthetic-refresh" }, { ...installation, expires_in: 3600 },
|
|
])("rejects an incompatible installation %#", async body => {
|
|
await expect(exchange(body).result).rejects.toThrow();
|
|
});
|
|
it("does not reflect provider errors or secrets", async () => {
|
|
await expect(exchange({ ok: false, error: "synthetic-secret synthetic-code xoxb-private" }).result).rejects.toThrow("Slack could not complete authorization. Try connecting again.");
|
|
});
|
|
});
|
|
|
|
describe("Slack CEO DM ingress profile", () => {
|
|
const message = { api_app_id: "ATEST", event: { type: "message", channel: "D123", channel_type: "im", user: "UOWNER" } };
|
|
const allowed = (body: unknown) => slackDmPilotRequestAllowed(JSON.stringify(body), "application/json", "ATEST", "UOWNER");
|
|
it("accepts only the installing person's bot DM", () => {
|
|
expect(allowed(message)).toBe(true);
|
|
expect(allowed({ ...message, api_app_id: "AOTHER" })).toBe(false);
|
|
expect(allowed({ ...message, event: { ...message.event, user: "UOTHER" } })).toBe(false);
|
|
expect(allowed({ ...message, event: { ...message.event, channel: "C123", channel_type: "channel" } })).toBe(false);
|
|
expect(allowed({ ...message, event: { ...message.event, channel_type: "mpim" } })).toBe(false);
|
|
expect(slackDmPilotRequestAllowed("invalid", "application/json", "ATEST", "UOWNER")).toBe(false);
|
|
});
|
|
it("permits signed setup and revocation but no reactions or assistant events", () => {
|
|
expect(allowed({ type: "url_verification" })).toBe(true);
|
|
expect(allowed({ event: { type: "app_uninstalled" } })).toBe(true);
|
|
expect(allowed({ event: { type: "tokens_revoked" } })).toBe(true);
|
|
expect(allowed({ event: { type: "reaction_added", user: "UOWNER" } })).toBe(false);
|
|
});
|
|
it("keeps slash commands and interactive replies in the same private surface", () => {
|
|
expect(slackDmPilotRequestAllowed("channel_id=D123&user_id=UOWNER", "application/x-www-form-urlencoded", "ATEST", "UOWNER")).toBe(true);
|
|
expect(slackDmPilotRequestAllowed("channel_id=C123&user_id=UOWNER", "application/x-www-form-urlencoded", "ATEST", "UOWNER")).toBe(false);
|
|
expect(allowed({ type: "block_actions", user: { id: "UOWNER" }, channel: { id: "D123" } })).toBe(true);
|
|
expect(allowed({ type: "block_actions", user: { id: "UOTHER" }, channel: { id: "D123" } })).toBe(false);
|
|
});
|
|
});
|