import { beforeEach, describe, expect, mock, test } from "bun:test"; import { BadRequestError } from "../runtime/routes/errors.js"; import type { CredentialMetadata } from "../tools/credentials/metadata-store.js"; // --------------------------------------------------------------------------- // Mutable mock state (closed over by the mock factories below) // --------------------------------------------------------------------------- let secureStore: Map; let metadataStore: Map; let syncedServices: string[]; let syncRejects: boolean; let disconnectedProviders: string[]; let credentialIdCounter: number; let scrubbedValues: string[]; let scrubRejects: boolean; function metaKey(service: string, field: string): string { return `${service}:${field}`; } // --------------------------------------------------------------------------- // Mocks for the routes' collaborators // --------------------------------------------------------------------------- mock.module("../runtime/auth/route-policy.js", () => ({ ACTOR_PRINCIPALS: [], })); let chatCredentialRevealFlag = false; mock.module("../config/assistant-feature-flags.js", () => ({ isAssistantFeatureFlagEnabled: (key: string) => key === "chat-credential-reveal" && chatCredentialRevealFlag, })); mock.module("../config/loader.js", () => ({ getConfig: () => ({}), })); mock.module("../security/credential-key.js", () => ({ credentialKey: (service: string, field: string) => `${service}:${field}`, })); mock.module("../security/secure-keys.js", () => ({ setSecureKeyAsync: mock(async (key: string, value: string) => { secureStore.set(key, value); return true; }), getSecureKeyAsync: mock(async (key: string) => secureStore.get(key)), getSecureKeyResultAsync: mock(async (key: string) => ({ value: secureStore.get(key), unreachable: false, })), deleteSecureKeyAsync: mock(async (key: string) => secureStore.delete(key) ? "deleted" : "not-found", ), getActiveBackendName: () => "encrypted-store", getActiveBackendInfoAsync: mock(async () => ({ backend: "encrypted-store" })), })); mock.module("../tools/credentials/metadata-store.js", () => ({ assertMetadataWritable: () => {}, listCredentialMetadata: mock(() => Array.from(metadataStore.values())), getCredentialMetadata: mock((service: string, field: string) => metadataStore.get(metaKey(service, field)), ), getCredentialMetadataById: mock((id: string) => Array.from(metadataStore.values()).find((m) => m.credentialId === id), ), upsertCredentialMetadata: mock( ( service: string, field: string, policy?: { allowedTools?: string[]; usageDescription?: string; alias?: string | null; }, ): CredentialMetadata => { const key = metaKey(service, field); const existing = metadataStore.get(key); const now = Date.now(); const meta: CredentialMetadata = { credentialId: existing?.credentialId ?? `cred-${++credentialIdCounter}`, service, field, allowedTools: policy?.allowedTools ?? existing?.allowedTools ?? [], allowedDomains: existing?.allowedDomains ?? [], usageDescription: policy?.usageDescription ?? existing?.usageDescription, alias: policy?.alias ?? existing?.alias ?? undefined, createdAt: existing?.createdAt ?? now, updatedAt: now, }; metadataStore.set(key, meta); return meta; }, ), deleteCredentialMetadata: mock((service: string, field: string) => metadataStore.delete(metaKey(service, field)), ), })); mock.module("../oauth/manual-token-connection.js", () => ({ syncManualTokenConnection: mock(async (service: string) => { if (syncRejects) { throw new Error("simulated oauth-store failure"); } syncedServices.push(service); }), })); mock.module("../oauth/oauth-store.js", () => ({ listConnections: mock(() => []), getConnectionByProvider: mock(() => undefined), disconnectOAuthProvider: mock(async (provider: string) => { disconnectedProviders.push(provider); return "not-found"; }), })); mock.module("../credential-execution/managed-catalog.js", () => ({ fetchManagedCatalog: mock(async () => ({ ok: true, descriptors: [] })), })); mock.module("../daemon/credential-transcript-scrub.js", () => ({ scrubStoredCredentialFromTranscripts: mock(async (value: string) => { scrubbedValues.push(value); if (scrubRejects) { throw new Error("transcript sweep failed"); } return { dbMessagesScrubbed: 0, residentMessagesScrubbed: 0 }; }), })); // Stub the prompted-credential persist path's Slack + broker collaborators so // importing it exercises the ACP guard without loading their real dep chains. mock.module("../daemon/handlers/config-slack-channel.js", () => ({ setSlackChannelConfig: mock(async () => ({ success: true, connected: false, })), })); mock.module("../tools/credentials/broker.js", () => ({ credentialBroker: { injectTransient: mock(() => {}) }, })); import { persistPromptedCredential } from "../credential-execution/prompted-credential.js"; import { initializeDb } from "../persistence/db-init.js"; import { forChatMintsSince, resetForChatMintRegistryForTest, } from "../runtime/for-chat-mint-registry.js"; import { _resetRevealSuccessRegistryForTest, currentRevealSuccessWatermark, openRevealProofWindow, revealedValueSince, } from "../runtime/reveal-success-registry.js"; import { ROUTES } from "../runtime/routes/credential-routes.js"; // A delete looks up the provider connections that resolve their auth through // the credential, so the inference schema has to exist. No connection is // created here: the refusal is covered by // runtime/routes/__tests__/credential-delete-in-use.test.ts. await initializeDb(); const setRoute = ROUTES.find((r) => r.operationId === "credentials_set"); const listRoute = ROUTES.find((r) => r.operationId === "credentials_list"); const deleteRoute = ROUTES.find((r) => r.operationId === "credentials_delete"); const revealRoute = ROUTES.find((r) => r.operationId === "credentials_reveal"); type SetResponse = { credentialId: string; service: string; field: string }; type ListResponse = { credentials: Array<{ service: string; field: string; scrubbedValue: string; hasSecret: boolean; }>; managedCredentials: unknown[]; }; type DeleteResponse = { service: string; field: string; affectedConnections: string[]; }; const SECRET_VALUE = "super-secret-token-value"; describe("credentials routes", () => { beforeEach(() => { secureStore = new Map(); metadataStore = new Map(); syncedServices = []; syncRejects = false; disconnectedProviders = []; credentialIdCounter = 0; scrubbedValues = []; scrubRejects = false; _resetRevealSuccessRegistryForTest(); resetForChatMintRegistryForTest(); chatCredentialRevealFlag = false; }); describe("credentials_reveal", () => { test("a local-principal reveal returns the value and records proof", async () => { /** * A tool shell's `assistant credentials reveal` reaches this route as * the direct-IPC `local` principal; its success is the ground truth * the chat-credential persist seams use to promote staged refs. */ // GIVEN a stored credential, a staged tool reveal (open proof // window), and the staging watermark secureStore.set("vercel:api_token", SECRET_VALUE); openRevealProofWindow(); const watermark = currentRevealSuccessWatermark(); // WHEN revealed by the local principal with its tool-shell nonce const result = (await revealRoute!.handler({ body: { service: "vercel", field: "api_token", revealNonce: "nonce-A", }, headers: { "x-vellum-principal-type": "local" }, })) as { value: string }; // THEN the value is returned and the proof is recorded expect(result.value).toBe(SECRET_VALUE); expect( revealedValueSince(watermark, "vercel", "api_token", "nonce-A"), ).toBe(SECRET_VALUE); }); test("a web/gateway reveal returns the value but records no proof", async () => { /** * The Settings row and chat chips hit this same handler over HTTP or * the gateway proxy. Those reveals are not evidence any tool ran a * reveal — recording them would let a UI click promote a staged ref * in a concurrent turn whose command merely echoed the invocation. */ secureStore.set("vercel:api_token", SECRET_VALUE); openRevealProofWindow(); const watermark = currentRevealSuccessWatermark(); for (const principal of ["user", "svc_gateway"]) { const result = (await revealRoute!.handler({ body: { service: "vercel", field: "api_token", revealNonce: "nonce-A", }, headers: { "x-vellum-principal-type": principal }, })) as { value: string }; expect(result.value).toBe(SECRET_VALUE); } expect( revealedValueSince(watermark, "vercel", "api_token", "nonce-A"), ).toBeUndefined(); }); test("a gateway-proxied local-principal reveal records no proof", async () => { /** * In local mode the gateway derives the `local` principal from the * verified JWT for WEB calls too, but it always stamps * `x-vellum-proxy-server: ipc` — only a direct (unproxied) local call * is a tool shell's CLI and may become proof. */ secureStore.set("vercel:api_token", SECRET_VALUE); openRevealProofWindow(); const watermark = currentRevealSuccessWatermark(); const result = (await revealRoute!.handler({ body: { service: "vercel", field: "api_token", revealNonce: "nonce-A", }, headers: { "x-vellum-principal-type": "local", "x-vellum-proxy-server": "ipc", }, })) as { value: string }; expect(result.value).toBe(SECRET_VALUE); expect( revealedValueSince(watermark, "vercel", "api_token", "nonce-A"), ).toBeUndefined(); }); test("a local reveal with NO staged tool reveal records no proof", async () => { /** * A user's own terminal CLI reveal outside any assistant tool turn * has no pending proof to satisfy — the registry must not retain its * plaintext at all (recording is gated on an open proof window). */ secureStore.set("vercel:api_token", SECRET_VALUE); const watermark = currentRevealSuccessWatermark(); const result = (await revealRoute!.handler({ body: { service: "vercel", field: "api_token" }, headers: { "x-vellum-principal-type": "local" }, })) as { value: string }; expect(result.value).toBe(SECRET_VALUE); expect( revealedValueSince(watermark, "vercel", "api_token", "nonce-A"), ).toBeUndefined(); }); test("a reveal with no principal header records no proof (fails closed)", async () => { secureStore.set("vercel:api_token", SECRET_VALUE); openRevealProofWindow(); const watermark = currentRevealSuccessWatermark(); const result = (await revealRoute!.handler({ body: { service: "vercel", field: "api_token" }, })) as { value: string }; expect(result.value).toBe(SECRET_VALUE); expect( revealedValueSince(watermark, "vercel", "api_token", "nonce-A"), ).toBeUndefined(); }); }); describe("credentials_reveal --for-chat", () => { test("rejects forChat when the chat-credential-reveal flag is off", async () => { secureStore.set("vercel:api_token", SECRET_VALUE); await expect( revealRoute!.handler({ body: { service: "vercel", field: "api_token", forChat: true }, headers: { "x-vellum-principal-type": "local" }, }), ).rejects.toThrow("chat-credential-reveal feature flag"); }); test("a direct local forChat reveal returns the sentinel, records the mint, and no plaintext proof", async () => { chatCredentialRevealFlag = true; secureStore.set("vercel:api_token", SECRET_VALUE); openRevealProofWindow(); const watermark = currentRevealSuccessWatermark(); const result = (await revealRoute!.handler({ body: { service: "vercel", field: "api_token", forChat: true, revealNonce: "nonce-A", }, headers: { "x-vellum-principal-type": "local" }, })) as { value: string }; expect(result.value).toBe( "\u3014redacted:Credential:vercel:api_token\u3015", ); expect(result.value).not.toContain(SECRET_VALUE); expect(forChatMintsSince(0)).toEqual([ { service: "vercel", field: "api_token", sentinel: result.value, nonce: "nonce-A", }, ]); // The channel never returns plaintext to the tool, so the plaintext // proof registry must not retain the secret for it. expect( revealedValueSince(watermark, "vercel", "api_token", "nonce-A"), ).toBeUndefined(); }); test("a gateway-proxied forChat reveal returns the sentinel but records no mint", async () => { chatCredentialRevealFlag = true; secureStore.set("vercel:api_token", SECRET_VALUE); const result = (await revealRoute!.handler({ body: { service: "vercel", field: "api_token", forChat: true, revealNonce: "nonce-A", }, headers: { "x-vellum-principal-type": "local", "x-vellum-proxy-server": "ipc", }, })) as { value: string }; expect(result.value).toContain("\u3014redacted:"); expect(forChatMintsSince(0)).toEqual([]); }); test("a direct local reveal WITHOUT a nonce records no authority at all", async () => { // Direct terminal use (outside any conversation's tool shell) has no // nonce to forward. The reveal works, but neither registry records — // there is no conversation whose transcript could spend the record. chatCredentialRevealFlag = true; secureStore.set("vercel:api_token", SECRET_VALUE); openRevealProofWindow(); const watermark = currentRevealSuccessWatermark(); await revealRoute!.handler({ body: { service: "vercel", field: "api_token" }, headers: { "x-vellum-principal-type": "local" }, }); await revealRoute!.handler({ body: { service: "vercel", field: "api_token", forChat: true }, headers: { "x-vellum-principal-type": "local" }, }); expect( revealedValueSince(watermark, "vercel", "api_token", "nonce-A"), ).toBeUndefined(); expect(forChatMintsSince(0)).toEqual([]); }); test("a forChat reveal with no principal header records no mint (fails closed)", async () => { chatCredentialRevealFlag = true; secureStore.set("vercel:api_token", SECRET_VALUE); const result = (await revealRoute!.handler({ body: { service: "vercel", field: "api_token", forChat: true }, })) as { value: string }; expect(result.value).toContain("\u3014redacted:"); expect(forChatMintsSince(0)).toEqual([]); }); }); describe("credentials_set", () => { test("stores the secret and returns identifiers without leaking the value", async () => { /** * Storing a credential via the route persists the plaintext to secure * storage and creates metadata, but the response surfaces only the * credential identifiers — never the secret itself. */ // GIVEN the credentials_set route is registered expect(setRoute).toBeDefined(); // WHEN a credential is stored with a label, description, and allowed tools const result = (await setRoute!.handler({ body: { service: "vercel", field: "api_token", value: SECRET_VALUE, label: "Vercel API Token", description: "Used to deploy pages", allowedTools: ["publish_page"], }, })) as SetResponse; // THEN the response carries the credential identifiers only expect(result.service).toBe("vercel"); expect(result.field).toBe("api_token"); expect(result.credentialId).toBe("cred-1"); // AND the secret is persisted to secure storage expect(secureStore.get("vercel:api_token")).toBe(SECRET_VALUE); // AND a manual-token connection sync is triggered for the service expect(syncedServices).toContain("vercel"); // AND the plaintext value never appears in the response expect(JSON.stringify(result)).not.toContain(SECRET_VALUE); }); test("rejects a request missing the value", async () => { /** * The route validates required fields and rejects a store request that * omits the secret value before touching storage. */ // GIVEN a store request with no value const body = { service: "vercel", field: "api_token" }; // WHEN the handler runs const call = setRoute!.handler({ body }); // THEN it rejects with a BadRequestError and stores nothing await expect(call).rejects.toBeInstanceOf(BadRequestError); expect(secureStore.size).toBe(0); }); test("rejects an Anthropic API key written into the ACP OAuth-token field", async () => { /** * Pasting an `sk-ant-api…` key into `acp/claude_oauth_token` 401s at * runtime; the write seam rejects it with a clear 400-class error and * persists nothing. */ const call = setRoute!.handler({ body: { service: "acp", field: "claude_oauth_token", value: "sk-ant-api03-not-an-oauth-token", }, }); await expect(call).rejects.toBeInstanceOf(BadRequestError); await expect(call).rejects.toThrow("Claude OAuth token"); expect(secureStore.size).toBe(0); }); test("accepts a Claude OAuth token in the ACP OAuth-token field", async () => { const result = (await setRoute!.handler({ body: { service: "acp", field: "claude_oauth_token", value: "sk-ant-oat01-a-real-oauth-token", }, })) as SetResponse; expect(result.service).toBe("acp"); expect(result.field).toBe("claude_oauth_token"); expect(secureStore.get("acp:claude_oauth_token")).toBe( "sk-ant-oat01-a-real-oauth-token", ); }); test("a successful set scrubs the normalized value from transcripts exactly once", async () => { /** * The pasted plaintext may already sit in recent transcripts, so a * successful store triggers one retroactive scrub — with the * normalized (edge-trimmed) value that was actually persisted, not * the raw paste. */ // WHEN a credential is stored with paste-artifact edge whitespace await setRoute!.handler({ body: { service: "vercel", field: "api_token", value: ` ${SECRET_VALUE}\n`, }, }); // THEN the transcript scrub runs exactly once with the stored value expect(scrubbedValues).toEqual([SECRET_VALUE]); }); test("a validation-rejected set never triggers a transcript scrub", async () => { /** * Nothing was stored, so there is nothing to scrub — neither the * whitespace-only value guard nor the ACP token-format guard may * reach the scrub. */ // WHEN a whitespace-only value is rejected by normalization await expect( setRoute!.handler({ body: { service: "vercel", field: "api_token", value: " " }, }), ).rejects.toBeInstanceOf(BadRequestError); // AND an Anthropic API key is rejected by the ACP format guard await expect( setRoute!.handler({ body: { service: "acp", field: "claude_oauth_token", value: "sk-ant-api03-not-an-oauth-token", }, }), ).rejects.toBeInstanceOf(BadRequestError); // THEN the scrub never runs expect(scrubbedValues).toEqual([]); }); test("the scrub still runs when the post-store connection sync throws", async () => { /** * `syncManualTokenConnection` performs oauth-store work that can * throw. The secret is already in secure storage at that point, so * the transcript scrub must have run BEFORE the failing side effect * — a stored secret whose plaintext lingers in transcripts is the * leak this seam exists to close. */ // GIVEN a connection sync that throws after the store syncRejects = true; // WHEN the set is attempted await expect( setRoute!.handler({ body: { service: "vercel", field: "api_token", value: SECRET_VALUE }, }), ).rejects.toThrow("simulated oauth-store failure"); // THEN the secret was stored and the scrub ran despite the failure expect(secureStore.get("vercel:api_token")).toBe(SECRET_VALUE); expect(scrubbedValues).toEqual([SECRET_VALUE]); }); test("non-secret vellum platform fields are stored but never scrubbed", async () => { /** * Platform identity ids and the base URL are benign UUIDs/URLs that * legitimately appear in transcripts; scrubbing them would redact * ordinary conversation content. Shares the exemption with the * /v1/secrets credential branch via isNonSecretPlatformField. */ for (const field of [ "platform_assistant_id", "platform_organization_id", "platform_user_id", "platform_base_url", ]) { const result = (await setRoute!.handler({ body: { service: "vellum", field, value: "0198f4c2-1111-2222-3333-444455556666", }, })) as SetResponse; expect(result.service).toBe("vellum"); } expect(scrubbedValues).toEqual([]); }); test("the set still succeeds when the transcript scrub rejects", async () => { /** * The scrub is post-store hygiene: the credential IS stored, so a * scrub failure is logged and must stay invisible to the caller. */ // GIVEN a scrub that rejects scrubRejects = true; // WHEN a credential is stored const result = (await setRoute!.handler({ body: { service: "vercel", field: "api_token", value: SECRET_VALUE }, })) as SetResponse; // THEN the route still returns success and the secret is persisted expect(result.service).toBe("vercel"); expect(result.credentialId).toBe("cred-1"); expect(secureStore.get("vercel:api_token")).toBe(SECRET_VALUE); expect(scrubbedValues).toEqual([SECRET_VALUE]); }); test("leaves a non-ACP service unaffected by the OAuth-field guard", async () => { /** * The guard is scoped to the ACP service; the same field name on another * service stores whatever value it is given. */ const result = (await setRoute!.handler({ body: { service: "vercel", field: "claude_oauth_token", value: "sk-ant-api03-not-an-oauth-token", }, })) as SetResponse; expect(result.service).toBe("vercel"); expect(secureStore.get("vercel:claude_oauth_token")).toBe( "sk-ant-api03-not-an-oauth-token", ); }); }); describe("persistPromptedCredential ACP guard", () => { test("rejects an Anthropic API key prompted into the ACP OAuth-token field", async () => { /** * The secure-prompt persist path shares the same footgun as the route: * an `sk-ant-api…` key in `acp/claude_oauth_token` is surfaced through * the existing error channel and never written. */ const result = await persistPromptedCredential({ service: "acp", field: "claude_oauth_token", value: "sk-ant-api03-not-an-oauth-token", delivery: "store", policy: {}, }); expect(result.outcome).toBe("error"); if (result.outcome === "error") { expect(result.message).toContain("Claude OAuth token"); } expect(secureStore.size).toBe(0); }); test("stores a Claude OAuth token prompted into the ACP OAuth-token field", async () => { const result = await persistPromptedCredential({ service: "acp", field: "claude_oauth_token", value: "sk-ant-oat01-a-real-oauth-token", delivery: "store", policy: {}, }); expect(result.outcome).toBe("stored"); expect(secureStore.get("acp:claude_oauth_token")).toBe( "sk-ant-oat01-a-real-oauth-token", ); }); test("a stored prompted credential scrubs recent transcripts", async () => { /** * The prompt flow is routinely used to RE-collect a secret the user * already pasted into chat; the 06 rail promises the pasted message * is scrubbed after storage, so the store path must run the scrub. */ const result = await persistPromptedCredential({ service: "vercel", field: "api_token", value: SECRET_VALUE, delivery: "store", policy: {}, }); expect(result.outcome).toBe("stored"); expect(scrubbedValues).toEqual([SECRET_VALUE]); }); test("a rejected prompted credential never scrubs", async () => { const result = await persistPromptedCredential({ service: "acp", field: "claude_oauth_token", value: "sk-ant-api03-not-an-oauth-token", delivery: "store", policy: {}, }); expect(result.outcome).toBe("error"); expect(scrubbedValues).toEqual([]); }); }); describe("credentials_list", () => { test("returns masked metadata without exposing stored secrets", async () => { /** * Listing credentials returns metadata and a scrubbed preview for each * stored secret; the raw plaintext is never included in the output. */ // GIVEN a stored credential await setRoute!.handler({ body: { service: "vercel", field: "api_token", value: SECRET_VALUE, label: "Vercel API Token", }, }); // WHEN the credentials are listed const result = (await listRoute!.handler({ body: {} })) as ListResponse; // THEN the listing includes the credential with a masked value expect(result.credentials).toHaveLength(1); const entry = result.credentials[0]; expect(entry.service).toBe("vercel"); expect(entry.field).toBe("api_token"); expect(entry.hasSecret).toBe(true); expect(entry.scrubbedValue).toBe("****alue"); // AND the raw secret never appears anywhere in the response expect(JSON.stringify(result)).not.toContain(SECRET_VALUE); }); test("filters credentials by search substring", async () => { /** * The optional `search` filter restricts the listing to credentials * whose service, field, alias, or description match the query. */ // GIVEN two stored credentials in different services await setRoute!.handler({ body: { service: "vercel", field: "api_token", value: "v-secret" }, }); await setRoute!.handler({ body: { service: "github", field: "token", value: "g-secret" }, }); // WHEN the listing is filtered by a service substring const result = (await listRoute!.handler({ body: { search: "git" }, })) as ListResponse; // THEN only the matching credential is returned expect(result.credentials).toHaveLength(1); expect(result.credentials[0].service).toBe("github"); }); }); describe("credentials_delete", () => { test("removes the secret and metadata and echoes the identifiers", async () => { /** * Deleting a credential removes both the stored secret and its metadata, * returning the service and field that were deleted. */ // GIVEN a stored credential await setRoute!.handler({ body: { service: "vercel", field: "api_token", value: SECRET_VALUE }, }); expect(secureStore.has("vercel:api_token")).toBe(true); // WHEN the credential is deleted const result = (await deleteRoute!.handler({ body: { service: "vercel", field: "api_token" }, })) as DeleteResponse; // THEN the response echoes the identifiers expect(result).toEqual({ service: "vercel", field: "api_token", affectedConnections: [], }); // AND the secret and metadata are gone expect(secureStore.has("vercel:api_token")).toBe(false); expect(metadataStore.has("vercel:api_token")).toBe(false); }); test("deletes the Slack user_token surgically, preserving the OAuth connection", async () => { /** * The Slack user_token grants only read access to channels the bot isn't * a member of; Socket Mode runs on the bot + app tokens. Deleting just the * user_token removes that secret without disconnecting the provider, so * the integration's connected state does not flap. */ // GIVEN a connected Slack channel with bot, app, and user tokens secureStore.set("slack_channel:bot_token", "xoxb-bot"); secureStore.set("slack_channel:app_token", "xapp-app"); secureStore.set("slack_channel:user_token", "xoxp-user"); // WHEN only the user_token is deleted const result = (await deleteRoute!.handler({ body: { service: "slack_channel", field: "user_token" }, })) as DeleteResponse; // THEN the response echoes the identifiers expect(result).toEqual({ service: "slack_channel", field: "user_token", affectedConnections: [], }); // AND only the user_token is removed; bot + app tokens remain expect(secureStore.has("slack_channel:user_token")).toBe(false); expect(secureStore.has("slack_channel:bot_token")).toBe(true); expect(secureStore.has("slack_channel:app_token")).toBe(true); // AND the OAuth provider is never disconnected expect(disconnectedProviders).not.toContain("slack_channel"); }); test("rejects deleting an absent Slack user_token without disconnecting the provider", async () => { /** * Deleting a Slack user_token that was never stored surfaces the same * not-found rejection as any other missing credential — it must not be * reported as an internal storage error, and it must still skip the * OAuth teardown so a connected channel's bot + app tokens are untouched. */ // GIVEN a connected Slack channel with only bot + app tokens (no user_token) secureStore.set("slack_channel:bot_token", "xoxb-bot"); secureStore.set("slack_channel:app_token", "xapp-app"); // WHEN the absent user_token is deleted const call = deleteRoute!.handler({ body: { service: "slack_channel", field: "user_token" }, }); // THEN it rejects with a BadRequestError (not an InternalError) await expect(call).rejects.toBeInstanceOf(BadRequestError); // AND the bot + app tokens remain and the provider is never disconnected expect(secureStore.has("slack_channel:bot_token")).toBe(true); expect(secureStore.has("slack_channel:app_token")).toBe(true); expect(disconnectedProviders).not.toContain("slack_channel"); }); test("disconnects the provider when a connection-critical Slack token is deleted", async () => { /** * Deleting a token that powers the Socket Mode connection (bot_token) * follows the generic path, which tears down the OAuth connection. */ // GIVEN a stored Slack bot_token secureStore.set("slack_channel:bot_token", "xoxb-bot"); // WHEN the bot_token is deleted await deleteRoute!.handler({ body: { service: "slack_channel", field: "bot_token" }, }); // THEN the generic path disconnects the provider expect(disconnectedProviders).toContain("slack_channel"); }); test("rejects deleting a credential that does not exist", async () => { /** * Deleting a credential with no stored secret, metadata, or OAuth * connection rejects with a not-found error. */ // GIVEN no stored credential for the service+field const body = { service: "ghost", field: "token" }; // WHEN the handler runs const call = deleteRoute!.handler({ body }); // THEN it rejects with a BadRequestError await expect(call).rejects.toBeInstanceOf(BadRequestError); }); }); });