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
211 changes: 209 additions & 2 deletions lib/daemon/__tests__/secrets-handler.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { dirname, join } from "path";
import { createSecretsHandlers } from "../handlers/secrets.ts";
import { createSecretsHandlers, loadBoardSecrets, type BoardSecretsData, type ReadSecretFn } from "../handlers/secrets.ts";
import { teamSettingsPath } from "../../rt-paths.ts";
import { setSetting } from "../../settings/write.ts";
import { loadRepoTracking } from "../../repo-tracking.ts";
Expand DownExpand Up@@ -118,11 +118,13 @@ describe("secrets:forge-token default tracking reader is machine-only", () => {
function readHandler(opts: {
extensionSecrets?: () => Promise<{ linearApiKey?: string; gitlabToken?: string }>;
deckSecrets?: () => Promise<{ cfApiToken?: string; cfZoneId?: string }>;
boardSecrets?: () => Promise<BoardSecretsData>;
apiToken?: string;
}) {
const h = createSecretsHandlers(fakeCtx, {
extensionSecrets: opts.extensionSecrets ?? (async () => ({})),
deckSecrets: opts.deckSecrets ?? (async () => ({})),
boardSecrets: opts.boardSecrets ?? (async () => ({})),
apiToken: () => opts.apiToken ?? "test-token",
});
return h["secrets:read"];
Expand DownExpand Up@@ -230,19 +232,22 @@ describe("secrets:read scope", () => {
expect(res).toEqual({ ok: true, data: { linearApiKey: "lin_api_x", gitlabToken: "glpat-x" } });
});

test("bad scope is refused before either reader runs", async () => {
test("bad scope is refused before any reader runs", async () => {
let extensionCalled = false;
let deckCalled = false;
let boardCalled = false;
const h = readHandler({
extensionSecrets: async () => { extensionCalled = true; return {}; },
deckSecrets: async () => { deckCalled = true; return {}; },
boardSecrets: async () => { boardCalled = true; return {}; },
});

const res = await h({ token: "test-token", scope: "bitbucket" as any });

expect(res).toEqual({ ok: false, error: "bad-scope" });
expect(extensionCalled).toBe(false);
expect(deckCalled).toBe(false);
expect(boardCalled).toBe(false);
});

test("the token gate applies to the deck scope exactly as it does to extension", async () => {
Expand All@@ -268,19 +273,32 @@ describe("secrets:read scope", () => {
expect(deckCalled).toBe(false);
});

test("a case-variant scope (\"Board\") is refused, not treated as \"board\"", async () => {
let boardCalled = false;
const h = readHandler({ boardSecrets: async () => { boardCalled = true; return {}; } });

const res = await h({ token: "test-token", scope: "Board" as any });

expect(res).toEqual({ ok: false, error: "bad-scope" });
expect(boardCalled).toBe(false);
});

test("a non-string scope (array) is refused, not coerced into a match", async () => {
let extensionCalled = false;
let deckCalled = false;
let boardCalled = false;
const h = readHandler({
extensionSecrets: async () => { extensionCalled = true; return {}; },
deckSecrets: async () => { deckCalled = true; return {}; },
boardSecrets: async () => { boardCalled = true; return {}; },
});

const res = await h({ token: "test-token", scope: ["deck"] as any });

expect(res).toEqual({ ok: false, error: "bad-scope" });
expect(extensionCalled).toBe(false);
expect(deckCalled).toBe(false);
expect(boardCalled).toBe(false);
});

test("a deck-reader throw surfaces as a rejected promise (transport error), never a partial ok", async () => {
Expand All@@ -289,3 +307,192 @@ describe("secrets:read scope", () => {
await expect(h({ token: "test-token", scope: "deck" })).rejects.toThrow("sops -d exploded");
});
});

// scope: "board" is CROSS-DOMAIN — slackToken/slackClientSecret/
// slackSigningSecret come from the `board` domain, gitlabToken/
// switchboardToken/switchboardAdminToken from the `rt` domain. A value
// seeded for extension/deck must never leak into a board read, and a
// board-seeded value must never leak into extension/deck (gitlabToken
// appears in both extension's and board's whitelist, but each scope reads
// its OWN loader — this is what the isolation tests below pin down).
describe("secrets:read scope \"board\"", () => {
test("returns exactly the six whitelisted keys when all are set", async () => {
const h = readHandler({
boardSecrets: async () => ({
slackToken: "xoxb-1",
slackClientSecret: "slack-cs",
slackSigningSecret: "slack-ss",
gitlabToken: "glpat-board",
switchboardToken: "sb-tok",
switchboardAdminToken: "sb-admin",
}),
});

const res = await h({ token: "test-token", scope: "board" });

expect(res).toEqual({
ok: true,
data: {
slackToken: "xoxb-1",
slackClientSecret: "slack-cs",
slackSigningSecret: "slack-ss",
gitlabToken: "glpat-board",
switchboardToken: "sb-tok",
switchboardAdminToken: "sb-admin",
},
});
});

test("omits keys entirely (never a blank string) when they aren't set", async () => {
const h = readHandler({ boardSecrets: async () => ({ slackToken: "xoxb-1" }) });

const res = await h({ token: "test-token", scope: "board" });

expect(res).toEqual({ ok: true, data: { slackToken: "xoxb-1" } });
expect("slackClientSecret" in (res as any).data).toBe(false);
expect("gitlabToken" in (res as any).data).toBe(false);
});

test("carries no other field even if the loader returns one — e.g. linearApiKey never leaks into board", async () => {
const h = readHandler({
boardSecrets: async () => ({ slackToken: "xoxb-1", linearApiKey: "leak" } as any),
});

const res = await h({ token: "test-token", scope: "board" });

expect(Object.keys((res as any).data)).toEqual(["slackToken"]);
});

test("board scope never leaks an rt-domain value seeded only for extension scope (e.g. linearApiKey, or a differently-sourced gitlabToken)", async () => {
const h = readHandler({
extensionSecrets: async () => ({ linearApiKey: "lin_api_x", gitlabToken: "glpat-extension" }),
boardSecrets: async () => ({ slackToken: "xoxb-1" }),
});

const res = await h({ token: "test-token", scope: "board" });

expect(res).toEqual({ ok: true, data: { slackToken: "xoxb-1" } });
expect("linearApiKey" in (res as any).data).toBe(false);
expect("gitlabToken" in (res as any).data).toBe(false);
});

test("board scope never leaks a deck-domain value", async () => {
const h = readHandler({
deckSecrets: async () => ({ cfApiToken: "cf-tok", cfZoneId: "zone-1" }),
boardSecrets: async () => ({ slackToken: "xoxb-1" }),
});

const res = await h({ token: "test-token", scope: "board" });

expect(res).toEqual({ ok: true, data: { slackToken: "xoxb-1" } });
expect("cfApiToken" in (res as any).data).toBe(false);
expect("cfZoneId" in (res as any).data).toBe(false);
});

test("extension and deck scopes never leak a board-seeded value (slack*/switchboard* stay out of both)", async () => {
const boardSecrets = async () => ({
slackToken: "xoxb-1",
slackClientSecret: "slack-cs",
slackSigningSecret: "slack-ss",
gitlabToken: "glpat-board",
switchboardToken: "sb-tok",
switchboardAdminToken: "sb-admin",
});

const extensionRes = await readHandler({
extensionSecrets: async () => ({ linearApiKey: "lin_api_x", gitlabToken: "glpat-extension" }),
boardSecrets,
})({ token: "test-token" });
expect(extensionRes).toEqual({ ok: true, data: { linearApiKey: "lin_api_x", gitlabToken: "glpat-extension" } });
expect("slackToken" in (extensionRes as any).data).toBe(false);
expect("switchboardToken" in (extensionRes as any).data).toBe(false);

const deckRes = await readHandler({
deckSecrets: async () => ({ cfApiToken: "cf-tok", cfZoneId: "zone-1" }),
boardSecrets,
})({ token: "test-token", scope: "deck" });
expect(deckRes).toEqual({ ok: true, data: { cfApiToken: "cf-tok", cfZoneId: "zone-1" } });
expect("slackToken" in (deckRes as any).data).toBe(false);
expect("switchboardAdminToken" in (deckRes as any).data).toBe(false);
});

test("the token gate applies to the board scope exactly as it does to extension/deck", async () => {
let boardCalled = false;
const h = readHandler({ boardSecrets: async () => { boardCalled = true; return {}; } });

const missing = await h({ scope: "board" });
expect(missing).toEqual({ ok: false, error: "missing-token" });

const wrong = await h({ token: "wrong", scope: "board" });
expect(wrong).toEqual({ ok: false, error: "bad-token" });

expect(boardCalled).toBe(false);
});

test("a board-domain readSecret throw propagates as a rejected promise (transport error), never a partial ok", async () => {
const h = readHandler({ boardSecrets: async () => { throw new Error("sops -d exploded"); } });

await expect(h({ token: "test-token", scope: "board" })).rejects.toThrow("sops -d exploded");
});
});

// Exercises the real `loadBoardSecrets` (not the handler's `boardSecrets`
// override) with an injected `ReadSecretFn`, so a future whitelist edit or
// domain typo in BOARD_SECRET_ENTRIES fails this suite even though the
// handler-level tests above only stub the loader's return shape.
describe("loadBoardSecrets", () => {
test("reads exactly the six (domain, key) entries, in order", async () => {
const calls: Array<[string, string]> = [];
const fake: ReadSecretFn = async (domain, key) => {
calls.push([domain, key]);
return `${domain}:${key}`;
};

const data = await loadBoardSecrets(fake);

expect(calls).toEqual([
["board", "slackToken"],
["board", "slackClientSecret"],
["board", "slackSigningSecret"],
["rt", "gitlabToken"],
["rt", "switchboardToken"],
["rt", "switchboardAdminToken"],
]);
const expected: BoardSecretsData = {
slackToken: "board:slackToken",
slackClientSecret: "board:slackClientSecret",
slackSigningSecret: "board:slackSigningSecret",
gitlabToken: "rt:gitlabToken",
switchboardToken: "rt:switchboardToken",
switchboardAdminToken: "rt:switchboardAdminToken",
};
expect(data).toEqual(expected);
});

test("omits an entry whose read returns null", async () => {
const fake: ReadSecretFn = async (domain, key) =>
domain === "board" && key === "slackClientSecret" ? null : `${domain}:${key}`;

const data = await loadBoardSecrets(fake);

expect("slackClientSecret" in data).toBe(false);
expect(data.slackToken).toBe("board:slackToken");
});

test("a throw on the first rt-domain read (after all three board-domain reads succeed) rejects the whole call — nothing partial, and later entries are never attempted", async () => {
const calls: Array<[string, string]> = [];
const fake: ReadSecretFn = async (domain, key) => {
calls.push([domain, key]);
if (domain === "rt") throw new Error("sops -d exploded");
return `${domain}:${key}`;
};

await expect(loadBoardSecrets(fake)).rejects.toThrow("sops -d exploded");
expect(calls).toEqual([
["board", "slackToken"],
["board", "slackClientSecret"],
["board", "slackSigningSecret"],
["rt", "gitlabToken"],
]);
});
});
Loading
Loading