diff --git a/assistant/src/__tests__/credentials-cli.test.ts b/assistant/src/__tests__/credentials-cli.test.ts deleted file mode 100644 index 764906ac07f..00000000000 --- a/assistant/src/__tests__/credentials-cli.test.ts +++ /dev/null @@ -1,1232 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -import { credentialKey } from "../security/credential-key.js"; -import type { CredentialMetadata } from "../tools/credentials/metadata-store.js"; - -// --------------------------------------------------------------------------- -// In-memory mock state -// --------------------------------------------------------------------------- - -let secureKeyStore = new Map(); -let metadataStore: CredentialMetadata[] = []; -let idCounter = 0; -let mockBrokerUnreachable = false; - -function nextUUID(): string { - idCounter += 1; - return `00000000-0000-0000-0000-${String(idCounter).padStart(12, "0")}`; -} - -// --------------------------------------------------------------------------- -// Mock secure-keys (reads) and daemon-credential-client (writes/deletes) -// --------------------------------------------------------------------------- - -function normalizeCredentialAccount(type: string, name: string): string { - if (type !== "credential") return name; - if (name.startsWith("credential/")) return name; - const colonIdx = name.lastIndexOf(":"); - if (colonIdx > 0 && colonIdx < name.length - 1) { - return `credential/${name.slice(0, colonIdx)}/${name.slice(colonIdx + 1)}`; - } - return name; -} - -mock.module("../security/secure-keys.js", () => ({ - getSecureKeyAsync: async (account: string): Promise => { - return secureKeyStore.get(account); - }, - getSecureKeyResultAsync: async ( - account: string, - ): Promise<{ value: string | undefined; unreachable: boolean }> => ({ - value: secureKeyStore.get(account), - unreachable: mockBrokerUnreachable, - }), - setSecureKeyAsync: async () => true, - deleteSecureKeyAsync: async () => "deleted" as const, - getProviderKeyAsync: async () => undefined, - getMaskedProviderKey: async () => undefined, - bulkSetSecureKeysAsync: async () => {}, - listSecureKeysAsync: async () => ({ credentials: [] }), - setCesClient: () => {}, - onCesClientChanged: () => ({ unsubscribe: () => {} }), - setCesReconnect: () => {}, - getActiveBackendName: () => "file", - getActiveBackendInfoAsync: async () => ({ - backend: "encrypted-store", - storePath: "/tmp/keys.enc", - storeKeyPath: "/tmp/store.key", - storeExists: false, - storeKeyExists: false, - }), - _resetBackend: () => {}, -})); - -mock.module("../cli/lib/daemon-credential-client.js", () => ({ - setSecureKeyViaDaemon: async (type: string, name: string, value: string) => { - secureKeyStore.set(normalizeCredentialAccount(type, name), value); - return { ok: true }; - }, - deleteSecureKeyViaDaemon: async (type: string, name: string) => { - const key = normalizeCredentialAccount(type, name); - if (secureKeyStore.has(key)) { - secureKeyStore.delete(key); - return { result: "deleted" as const }; - } - return { result: "not-found" as const }; - }, -})); - -// --------------------------------------------------------------------------- -// Mock metadata-store -// --------------------------------------------------------------------------- - -mock.module("../tools/credentials/metadata-store.js", () => ({ - assertMetadataWritable: (): void => {}, - upsertCredentialMetadata: ( - service: string, - field: string, - policy?: { - allowedTools?: string[]; - allowedDomains?: string[]; - usageDescription?: string; - expiresAt?: number | null; - grantedScopes?: string[]; - alias?: string | null; - injectionTemplates?: unknown[] | null; - }, - ): CredentialMetadata => { - const now = Date.now(); - const existing = metadataStore.find( - (c) => c.service === service && c.field === field, - ); - - if (existing) { - if (policy?.allowedTools !== undefined) - existing.allowedTools = policy.allowedTools; - if (policy?.allowedDomains !== undefined) - existing.allowedDomains = policy.allowedDomains; - if (policy?.usageDescription !== undefined) - existing.usageDescription = policy.usageDescription; - if (policy?.alias !== undefined) { - if (policy.alias == null) { - delete existing.alias; - } else { - existing.alias = policy.alias; - } - } - existing.updatedAt = now; - return existing; - } - - const record: CredentialMetadata = { - credentialId: nextUUID(), - service, - field, - allowedTools: policy?.allowedTools ?? [], - allowedDomains: policy?.allowedDomains ?? [], - usageDescription: policy?.usageDescription, - alias: policy?.alias ?? undefined, - createdAt: now, - updatedAt: now, - }; - metadataStore.push(record); - return record; - }, - getCredentialMetadata: ( - service: string, - field: string, - ): CredentialMetadata | undefined => { - return metadataStore.find( - (c) => c.service === service && c.field === field, - ); - }, - getCredentialMetadataById: ( - credentialId: string, - ): CredentialMetadata | undefined => { - return metadataStore.find((c) => c.credentialId === credentialId); - }, - deleteCredentialMetadata: (service: string, field: string): boolean => { - const idx = metadataStore.findIndex( - (c) => c.service === service && c.field === field, - ); - if (idx === -1) return false; - metadataStore.splice(idx, 1); - return true; - }, - listCredentialMetadata: (): CredentialMetadata[] => { - return [...metadataStore]; - }, -})); - -// --------------------------------------------------------------------------- -// Mock oauth-store -// --------------------------------------------------------------------------- - -let disconnectOAuthProviderCalls: string[] = []; -let disconnectOAuthProviderResult: "disconnected" | "not-found" | "error" = - "not-found"; - -mock.module("../oauth/oauth-store.js", () => ({ - disconnectOAuthProvider: async ( - provider: string, - ): Promise<"disconnected" | "not-found" | "error"> => { - disconnectOAuthProviderCalls.push(provider); - return disconnectOAuthProviderResult; - }, - // Provide stubs for all named exports so transitive importers don't crash - seedProviders: () => {}, - getProvider: (): undefined => undefined, - listProviders: (): never[] => [], - registerProvider: () => {}, - updateProvider: () => {}, - deleteProvider: (): boolean => false, - upsertApp: async () => ({ id: "mock-app-id" }), - getApp: (): undefined => undefined, - getAppClientSecret: async (): Promise => undefined, - getAppByProviderAndClientId: (): undefined => undefined, - getMostRecentAppByProvider: (): undefined => undefined, - listApps: (): never[] => [], - deleteApp: async (): Promise => false, - createConnection: () => ({ id: "mock-conn-id" }), - getConnection: (): undefined => undefined, - getActiveConnection: (): undefined => undefined, - getConnectionByProvider: (): undefined => undefined, - getConnectionByProviderAndAccount: (): undefined => undefined, - listActiveConnectionsByProvider: (): never[] => [], - isProviderConnected: async (): Promise => false, - updateConnection: (): boolean => true, - listConnections: (): never[] => [], - deleteConnection: (): boolean => false, -})); - -// --------------------------------------------------------------------------- -// Import the module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerCredentialsCommand } = - await import("../cli/commands/credentials.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCli( - args: string[], -): Promise<{ exitCode: number; stdout: string }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - // Suppress stderr so Commander error messages don't leak to test runner - process.stderr.write = (() => true) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerCredentialsCommand(program); - await program.parseAsync(["node", "vellum", "credentials", ...args]); - } catch { - // Commander throws on --help and on missing required args; treat as non-zero exit - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { - exitCode, - stdout: stdoutChunks.join(""), - }; -} - -/** - * Pre-populate mock stores with a credential. - */ -function seedCredential( - service: string, - field: string, - secret: string, - extra?: Partial, -): CredentialMetadata { - const now = Date.now(); - const record: CredentialMetadata = { - credentialId: nextUUID(), - service, - field, - allowedTools: [], - allowedDomains: [], - createdAt: now, - updatedAt: now, - ...extra, - }; - metadataStore.push(record); - secureKeyStore.set(credentialKey(service, field), secret); - return record; -} - -/** - * Pre-populate mock stores with metadata only (no secret). - */ -function seedMetadataOnly( - service: string, - field: string, - extra?: Partial, -): CredentialMetadata { - const now = Date.now(); - const record: CredentialMetadata = { - credentialId: nextUUID(), - service, - field, - allowedTools: [], - allowedDomains: [], - createdAt: now, - updatedAt: now, - ...extra, - }; - metadataStore.push(record); - return record; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant credentials CLI", () => { - beforeEach(() => { - secureKeyStore = new Map(); - metadataStore = []; - idCounter = 0; - mockBrokerUnreachable = false; - disconnectOAuthProviderCalls = []; - disconnectOAuthProviderResult = "not-found"; - process.exitCode = 0; - }); - - // ========================================================================= - // list - // ========================================================================= - - describe("list", () => { - test("returns empty array when no credentials exist", async () => { - const result = await runCli(["list", "--json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toEqual({ - ok: true, - credentials: [], - managedCredentials: [], - }); - }); - - test("returns all credentials with correct shapes", async () => { - seedCredential("twilio", "account_sid", "AC12345678abcdefgh"); - seedCredential("twilio", "auth_token", "auth_secret_val"); - seedCredential("github", "token", "ghp_abcdefghij1234"); - - const result = await runCli(["list", "--json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.credentials).toHaveLength(3); - - for (const cred of parsed.credentials) { - expect(cred).toHaveProperty("ok", true); - expect(cred).toHaveProperty("service"); - expect(cred).toHaveProperty("field"); - expect(cred).toHaveProperty("credentialId"); - expect(cred).toHaveProperty("scrubbedValue"); - expect(cred).toHaveProperty("hasSecret"); - expect(cred).toHaveProperty("alias"); - expect(cred).toHaveProperty("usageDescription"); - expect(cred).toHaveProperty("allowedTools"); - expect(cred).toHaveProperty("allowedDomains"); - expect(cred).toHaveProperty("grantedScopes"); - expect(cred).toHaveProperty("expiresAt"); - expect(cred).toHaveProperty("createdAt"); - expect(cred).toHaveProperty("updatedAt"); - expect(cred).toHaveProperty("injectionTemplateCount"); - } - }); - - test("filters by --search matching service name", async () => { - seedCredential("twilio", "account_sid", "AC123456789012"); - seedCredential("twilio", "auth_token", "auth_secret_1234"); - seedCredential("github", "token", "ghp_abcdefghij"); - - const result = await runCli(["list", "--search", "twilio", "--json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.credentials).toHaveLength(2); - expect(parsed.credentials[0].service).toBe("twilio"); - expect(parsed.credentials[1].service).toBe("twilio"); - }); - - test("filters by --search matching alias/label", async () => { - seedCredential("twilio", "account_sid", "AC123456789012", { - alias: "prod", - }); - seedCredential("github", "token", "ghp_abcdefghij"); - - const result = await runCli(["list", "--search", "prod", "--json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.credentials).toHaveLength(1); - expect(parsed.credentials[0].service).toBe("twilio"); - expect(parsed.credentials[0].alias).toBe("prod"); - }); - - test("filters by --search matching field name", async () => { - seedCredential("twilio", "account_sid", "AC123456789012"); - seedCredential("slack", "bot_token", "xoxb-1234567890"); - seedCredential("github", "token", "ghp_abcdefghij"); - - const result = await runCli(["list", "--search", "bot_token", "--json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.credentials).toHaveLength(1); - expect(parsed.credentials[0].field).toBe("bot_token"); - }); - - test("filters by --search matching description", async () => { - seedCredential("fal", "api_key", "key_live_abc123456", { - usageDescription: "Image generation", - }); - seedCredential("github", "token", "ghp_abcdefghij"); - - const result = await runCli(["list", "--search", "image", "--json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.credentials).toHaveLength(1); - expect(parsed.credentials[0].service).toBe("fal"); - expect(parsed.credentials[0].usageDescription).toBe("Image generation"); - }); - - test("returns empty array when --search has no matches", async () => { - seedCredential("twilio", "account_sid", "AC123456789012"); - seedCredential("github", "token", "ghp_abcdefghij"); - - const result = await runCli([ - "list", - "--search", - "nonexistent", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toEqual({ - ok: true, - credentials: [], - managedCredentials: [], - }); - }); - - test("list items have the same shape as inspect output", async () => { - seedCredential("twilio", "account_sid", "AC123456789012"); - - const listResult = await runCli(["list", "--json"]); - const listParsed = JSON.parse(listResult.stdout); - const listItem = listParsed.credentials[0]; - - const inspectResult = await runCli([ - "inspect", - "--service", - "twilio", - "--field", - "account_sid", - "--json", - ]); - const inspectParsed = JSON.parse(inspectResult.stdout); - - const listKeys = Object.keys(listItem).sort(); - const inspectKeys = Object.keys(inspectParsed).sort(); - expect(listKeys).toEqual(inspectKeys); - }); - }); - - // ========================================================================= - // set - // ========================================================================= - - describe("set", () => { - test("stores secret and creates metadata", async () => { - const result = await runCli([ - "set", - "--service", - "twilio", - "--field", - "account_sid", - "AC1234567890", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.service).toBe("twilio"); - expect(parsed.field).toBe("account_sid"); - expect(parsed.credentialId).toBeTruthy(); - - // Verify secret stored in mock map - expect(secureKeyStore.get(credentialKey("twilio", "account_sid"))).toBe( - "AC1234567890", - ); - - // Verify metadata created - const meta = metadataStore.find( - (m) => m.service === "twilio" && m.field === "account_sid", - ); - expect(meta).toBeTruthy(); - expect(meta!.service).toBe("twilio"); - expect(meta!.field).toBe("account_sid"); - }); - - test("stores metadata with --label and --description", async () => { - const result = await runCli([ - "set", - "--service", - "fal", - "--field", - "api_key", - "key_live_abc", - "--label", - "fal-prod", - "--description", - "Image generation", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - - const meta = metadataStore.find( - (m) => m.service === "fal" && m.field === "api_key", - ); - expect(meta).toBeTruthy(); - expect(meta!.alias).toBe("fal-prod"); - expect(meta!.usageDescription).toBe("Image generation"); - }); - - test("errors when --service flag is missing", async () => { - const result = await runCli([ - "set", - "--field", - "account_sid", - "some_value", - "--json", - ]); - // Commander should error on missing required option - expect(result.exitCode).not.toBe(0); - }); - - test("errors when --field flag is missing", async () => { - const result = await runCli([ - "set", - "--service", - "twilio", - "some_value", - "--json", - ]); - // Commander should error on missing required option - expect(result.exitCode).not.toBe(0); - }); - - test("errors when value argument is missing", async () => { - const result = await runCli([ - "set", - "--service", - "twilio", - "--field", - "account_sid", - "--json", - ]); - // Commander should error on missing required arg - expect(result.exitCode).not.toBe(0); - }); - - test("stores metadata with --allowed-tools", async () => { - const result = await runCli([ - "set", - "--service", - "twilio", - "--field", - "auth_token", - "sometoken", - "--allowed-tools", - "bash,host_bash", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - - const meta = metadataStore.find( - (m) => m.service === "twilio" && m.field === "auth_token", - ); - expect(meta).toBeTruthy(); - expect(meta!.allowedTools).toEqual(["bash", "host_bash"]); - }); - - test("updates existing credential on second set", async () => { - // First set - await runCli([ - "set", - "--service", - "twilio", - "--field", - "account_sid", - "original_value", - "--json", - ]); - const meta1 = metadataStore.find( - (m) => m.service === "twilio" && m.field === "account_sid", - ); - expect(meta1).toBeTruthy(); - const firstUpdatedAt = meta1!.updatedAt; - - // Small delay to ensure timestamp differs - await new Promise((resolve) => setTimeout(resolve, 5)); - - // Second set - await runCli([ - "set", - "--service", - "twilio", - "--field", - "account_sid", - "new_value", - "--json", - ]); - const meta2 = metadataStore.find( - (m) => m.service === "twilio" && m.field === "account_sid", - ); - expect(meta2).toBeTruthy(); - expect(meta2!.updatedAt).toBeGreaterThan(firstUpdatedAt); - - // Verify secret is overwritten - expect(secureKeyStore.get(credentialKey("twilio", "account_sid"))).toBe( - "new_value", - ); - }); - }); - - // ========================================================================= - // delete - // ========================================================================= - - describe("delete", () => { - test("removes both secret and metadata", async () => { - seedCredential("twilio", "auth_token", "secret_value_here"); - - const result = await runCli([ - "delete", - "--service", - "twilio", - "--field", - "auth_token", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.service).toBe("twilio"); - expect(parsed.field).toBe("auth_token"); - - // Verify both removed - expect(secureKeyStore.has(credentialKey("twilio", "auth_token"))).toBe( - false, - ); - expect( - metadataStore.find( - (m) => m.service === "twilio" && m.field === "auth_token", - ), - ).toBeUndefined(); - }); - - test("errors on nonexistent credential", async () => { - const result = await runCli([ - "delete", - "--service", - "twilio", - "--field", - "nonexistent", - "--json", - ]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("not found"); - }); - - test("errors when --service flag is missing", async () => { - const result = await runCli([ - "delete", - "--field", - "auth_token", - "--json", - ]); - // Commander should error on missing required option - expect(result.exitCode).not.toBe(0); - }); - - test("errors when --field flag is missing", async () => { - const result = await runCli(["delete", "--service", "twilio", "--json"]); - // Commander should error on missing required option - expect(result.exitCode).not.toBe(0); - }); - - test("succeeds when only metadata exists (no secret)", async () => { - seedMetadataOnly("twilio", "auth_token"); - - const result = await runCli([ - "delete", - "--service", - "twilio", - "--field", - "auth_token", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - - // Verify metadata removed - expect( - metadataStore.find( - (m) => m.service === "twilio" && m.field === "auth_token", - ), - ).toBeUndefined(); - }); - - test("calls disconnectOAuthProvider for OAuth cleanup", async () => { - seedCredential("gmail", "access_token", "ya29.token_value"); - - const result = await runCli([ - "delete", - "--service", - "gmail", - "--field", - "access_token", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - - // disconnectOAuthProvider should have been called with the service name - expect(disconnectOAuthProviderCalls).toEqual(["gmail"]); - }); - - test("succeeds when only OAuth connection exists (no legacy credential)", async () => { - // No legacy credential seeded — only the OAuth disconnect finds something - disconnectOAuthProviderResult = "disconnected"; - - const result = await runCli([ - "delete", - "--service", - "gmail", - "--field", - "access_token", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.service).toBe("gmail"); - expect(parsed.field).toBe("access_token"); - - expect(disconnectOAuthProviderCalls).toEqual(["gmail"]); - }); - }); - - // ========================================================================= - // inspect - // ========================================================================= - - describe("inspect", () => { - test("shows metadata and scrubbed value by --service/--field", async () => { - const meta = seedCredential("twilio", "account_sid", "AC123456789012"); - - const result = await runCli([ - "inspect", - "--service", - "twilio", - "--field", - "account_sid", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.service).toBe("twilio"); - expect(parsed.field).toBe("account_sid"); - expect(parsed.credentialId).toBe(meta.credentialId); - expect(parsed.scrubbedValue).toBe("****9012"); - expect(parsed.hasSecret).toBe(true); - expect(parsed.createdAt).toBe(new Date(meta.createdAt).toISOString()); - expect(parsed.updatedAt).toBe(new Date(meta.updatedAt).toISOString()); - expect(parsed).toHaveProperty("alias"); - expect(parsed).toHaveProperty("usageDescription"); - expect(parsed).toHaveProperty("allowedTools"); - expect(parsed).toHaveProperty("allowedDomains"); - expect(parsed).toHaveProperty("grantedScopes"); - expect(parsed).toHaveProperty("expiresAt"); - expect(parsed).toHaveProperty("injectionTemplateCount"); - }); - - test("looks up credential by UUID", async () => { - const meta = seedCredential("github", "token", "ghp_abcdefghij1234"); - - const result = await runCli(["inspect", meta.credentialId, "--json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.service).toBe("github"); - expect(parsed.field).toBe("token"); - expect(parsed.credentialId).toBe(meta.credentialId); - }); - - test("scrubs normal-length secret (>4 chars): shows last 4", async () => { - seedCredential("test", "normal", "abcdefgh"); - - const result = await runCli([ - "inspect", - "--service", - "test", - "--field", - "normal", - "--json", - ]); - const parsed = JSON.parse(result.stdout); - expect(parsed.scrubbedValue).toBe("****efgh"); - }); - - test("scrubs short secret (<=4 chars): shows only ****", async () => { - seedCredential("test", "short", "ab"); - - const result = await runCli([ - "inspect", - "--service", - "test", - "--field", - "short", - "--json", - ]); - const parsed = JSON.parse(result.stdout); - expect(parsed.scrubbedValue).toBe("****"); - }); - - test("shows (not set) when no secret exists", async () => { - seedMetadataOnly("test", "nosecret"); - - const result = await runCli([ - "inspect", - "--service", - "test", - "--field", - "nosecret", - "--json", - ]); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.scrubbedValue).toBe("(not set)"); - expect(parsed.hasSecret).toBe(false); - }); - - test("errors when neither flags nor UUID provided", async () => { - const result = await runCli(["inspect", "--json"]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("--service"); - }); - - test("errors on nonexistent credential by --service/--field", async () => { - const result = await runCli([ - "inspect", - "--service", - "nonexistent", - "--field", - "field", - "--json", - ]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("not found"); - }); - - test("errors on nonexistent UUID", async () => { - const result = await runCli([ - "inspect", - "00000000-0000-0000-0000-000000000099", - "--json", - ]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("not found"); - }); - - test("--json flag produces compact JSON (single line)", async () => { - seedCredential("twilio", "account_sid", "AC123456789012"); - - const result = await runCli([ - "inspect", - "--service", - "twilio", - "--field", - "account_sid", - "--json", - ]); - const lines = result.stdout.trim().split("\n"); - expect(lines).toHaveLength(1); - // Verify it parses as valid JSON - expect(() => JSON.parse(lines[0])).not.toThrow(); - }); - - test("shows hasSecret: false when metadata exists but no secret", async () => { - seedMetadataOnly("test", "metaonly"); - - const result = await runCli([ - "inspect", - "--service", - "test", - "--field", - "metaonly", - "--json", - ]); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.hasSecret).toBe(false); - expect(parsed.scrubbedValue).toBe("(not set)"); - }); - - test("shows broker unreachable when metadata exists but broker is down", async () => { - seedMetadataOnly("twilio", "account_sid"); - mockBrokerUnreachable = true; - - const result = await runCli([ - "inspect", - "--service", - "twilio", - "--field", - "account_sid", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.scrubbedValue).toBe("(credential store unreachable)"); - expect(parsed.brokerUnreachable).toBe(true); - }); - - test("shows unreachable error when no metadata and broker is down", async () => { - mockBrokerUnreachable = true; - - const result = await runCli([ - "inspect", - "--service", - "nonexistent", - "--field", - "field", - "--json", - ]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Credential store is unreachable"); - }); - }); - - // ========================================================================= - // reveal - // ========================================================================= - - describe("reveal", () => { - test("returns plaintext value by --service/--field", async () => { - seedCredential("twilio", "account_sid", "AC123456789012"); - - const result = await runCli([ - "reveal", - "--service", - "twilio", - "--field", - "account_sid", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.value).toBe("AC123456789012"); - }); - - test("returns plaintext value by UUID", async () => { - const meta = seedCredential("github", "token", "ghp_abcdefghij1234"); - - const result = await runCli(["reveal", meta.credentialId, "--json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.value).toBe("ghp_abcdefghij1234"); - }); - - test("errors on nonexistent credential by --service/--field", async () => { - const result = await runCli([ - "reveal", - "--service", - "nonexistent", - "--field", - "field", - "--json", - ]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("not found"); - }); - - test("errors on nonexistent UUID", async () => { - const result = await runCli([ - "reveal", - "00000000-0000-0000-0000-000000000099", - "--json", - ]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("not found"); - }); - - test("errors when neither flags nor UUID provided", async () => { - const result = await runCli(["reveal", "--json"]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("--service"); - }); - - test("reveal in human mode emits bare secret with trailing newline", async () => { - seedCredential("twilio", "auth_token", "secret_xyz_789"); - - const result = await runCli([ - "reveal", - "--service", - "twilio", - "--field", - "auth_token", - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("secret_xyz_789\n"); - }); - - test("errors when metadata exists but no secret stored", async () => { - seedMetadataOnly("test", "nosecret"); - - const result = await runCli([ - "reveal", - "--service", - "test", - "--field", - "nosecret", - "--json", - ]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("not found"); - }); - - test("returns unreachable error when broker is down", async () => { - mockBrokerUnreachable = true; - - const result = await runCli([ - "reveal", - "--service", - "twilio", - "--field", - "auth_token", - "--json", - ]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Credential store is unreachable"); - }); - - test("returns credential-not-found when broker is up", async () => { - mockBrokerUnreachable = false; - - const result = await runCli([ - "reveal", - "--service", - "twilio", - "--field", - "nonexistent", - "--json", - ]); - expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toBe("Credential not found"); - }); - }); - - // ========================================================================= - // compound service names (colons in service) - // ========================================================================= - - describe("compound service names", () => { - test("set and reveal with colon in service name works correctly", async () => { - const setResult = await runCli([ - "set", - "--service", - "google", - "--field", - "client_secret", - "secret123", - "--json", - ]); - expect(setResult.exitCode).toBe(0); - const setParsed = JSON.parse(setResult.stdout); - expect(setParsed.ok).toBe(true); - expect(setParsed.service).toBe("google"); - expect(setParsed.field).toBe("client_secret"); - - const revealResult = await runCli([ - "reveal", - "--service", - "google", - "--field", - "client_secret", - "--json", - ]); - expect(revealResult.exitCode).toBe(0); - const revealParsed = JSON.parse(revealResult.stdout); - expect(revealParsed.ok).toBe(true); - expect(revealParsed.value).toBe("secret123"); - }); - }); - - // ========================================================================= - // instance-scoped workspace - // ========================================================================= - - describe("instance-scoped workspace", () => { - let savedWorkspaceDir: string | undefined; - - beforeEach(() => { - savedWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR; - }); - - afterEach(() => { - if (savedWorkspaceDir === undefined) { - delete process.env.VELLUM_WORKSPACE_DIR; - } else { - process.env.VELLUM_WORKSPACE_DIR = savedWorkspaceDir; - } - }); - - test("credential reveal reads from instance-scoped store when VELLUM_WORKSPACE_DIR is set", async () => { - // Point VELLUM_WORKSPACE_DIR to a temp directory (simulating instance-scoped dir) - const tmpDir = (await import("node:os")).tmpdir(); - const instanceDir = (await import("node:path")).join( - tmpDir, - `vellum-test-instance-${Date.now()}`, - ); - process.env.VELLUM_WORKSPACE_DIR = instanceDir; - - // Seed a credential in the mock store - seedCredential("twilio", "auth_token", "instance_secret_abc123"); - - // Run `credentials reveal --service twilio --field auth_token` - const result = await runCli([ - "reveal", - "--service", - "twilio", - "--field", - "auth_token", - "--json", - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.value).toBe("instance_secret_abc123"); - - // Verify the correct key was looked up in the secure store - expect(secureKeyStore.has(credentialKey("twilio", "auth_token"))).toBe( - true, - ); - expect(secureKeyStore.get(credentialKey("twilio", "auth_token"))).toBe( - "instance_secret_abc123", - ); - }); - }); - - // ========================================================================= - // help text quality - // ========================================================================= - - describe("help text", () => { - test("credentials --help contains naming convention table and storage description", async () => { - const result = await runCli(["--help"]); - const out = result.stdout; - expect(out).toContain("--service twilio --field account_sid"); - expect(out).toContain("AES-256-GCM"); - expect(out).toContain("Examples:"); - }); - - test("credentials list --help contains --search description and examples", async () => { - const result = await runCli(["list", "--help"]); - const out = result.stdout; - expect(out).toContain("--search"); - expect(out).toContain("Examples:"); - expect(out).toContain("credentials list --search twilio"); - }); - - test("credentials set --help contains Arguments: and Examples: sections", async () => { - const result = await runCli(["set", "--help"]); - const out = result.stdout; - expect(out).toContain("Arguments:"); - expect(out).toContain("Examples:"); - }); - - test("credentials inspect --help mentions UUID support", async () => { - const result = await runCli(["inspect", "--help"]); - const out = result.stdout; - expect(out).toContain("UUID"); - }); - - test("credentials reveal --help mentions piping and examples", async () => { - const result = await runCli(["reveal", "--help"]); - const out = result.stdout; - expect(out).toContain("stdout"); - expect(out).toContain("Examples:"); - }); - }); -}); diff --git a/assistant/src/__tests__/oauth-cli.test.ts b/assistant/src/__tests__/oauth-cli.test.ts index 09abf13c62c..a8b0de02faf 100644 --- a/assistant/src/__tests__/oauth-cli.test.ts +++ b/assistant/src/__tests__/oauth-cli.test.ts @@ -1,396 +1,32 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +/** + * CLI plumbing tests for `assistant/src/cli/commands/oauth/`. + * + * The oauth CLI commands are thin wrappers around `cliIpcCall(...)`; daemon + * route handlers in `runtime/routes/oauth-commands-routes.ts` execute the + * actual work. Tests here focus on argument handling and helpers that live + * entirely in the CLI process. + * + * What lives here today: + * - `requirePlatformConnection` helper — gates managed-mode operations on + * a connected platform; used by multiple oauth subcommands. + * + * Daemon-side coverage of the IPC endpoints lives in + * `oauth-commands-routes.test.ts`. Underlying store and token-refresh logic + * is covered by `oauth-store.test.ts` and `credential-vault.test.ts`. + * + * Follow-up opportunities for CLI-layer coverage: + * - `exitFromIpcResult` exit-code mapping + * - `shouldOutputJson` / `writeOutput` output formatting + * - `oauth token` shell-lockdown guard (`VELLUM_UNTRUSTED_SHELL=1`) + * - per-subcommand argument parsing & help text + */ + +import { describe, expect, mock, test } from "bun:test"; import { Command } from "commander"; -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockWithValidToken: ( - service: string, - cb: (token: string) => Promise, -) => Promise; - -let mockListProviders: () => Array> = () => []; -const secureKeyStore = new Map(); -const metadataStore: Array<{ - credentialId: string; - service: string; - field: string; - allowedTools: string[]; - allowedDomains: string[]; - createdAt: number; - updatedAt: number; -}> = []; -const disconnectOAuthProviderCalls: string[] = []; -const disconnectOAuthProviderResult: "disconnected" | "not-found" | "error" = - "not-found"; - -// In-memory provider store used by registerProvider/updateProvider/getProvider -// mocks below. Tests that exercise the providers register/update/get commands -// can read and write through this map directly. -const mockProviderStore = new Map>(); - -// App upsert mock state -let mockUpsertAppCalls: Array<{ - provider: string; - clientId: string; - clientSecretOpts?: { - clientSecretValue?: string; - clientSecretCredentialPath?: string; - }; -}> = []; -let mockUpsertAppResult: Record = { - id: "app-upsert-1", - provider: "test", - clientId: "test-client-id", - createdAt: 1700000000000, - updatedAt: 1700000000000, -}; -let mockUpsertAppImpl: - | (( - provider: string, - clientId: string, - clientSecretOpts?: { - clientSecretValue?: string; - clientSecretCredentialPath?: string; - }, - ) => Promise>) - | undefined; - -// Transitive mock state (connect-orchestrator, etc.) -let mockOrchestrateOAuthConnect: ( - opts: Record, -) => Promise>; -let mockCliIpcCall: ( - operationId: string, - params?: Record, -) => Promise> = async () => ({ - ok: false, - error: "Could not connect to assistant daemon (test default)", -}); -let mockGetAppByProviderAndClientId: ( - provider: string, - clientId: string, -) => Record | undefined = () => undefined; -let mockGetMostRecentAppByProvider: ( - provider: string, -) => Record | undefined = () => undefined; -let mockGetProvider: ( - provider: string, -) => Record | undefined = () => undefined; -let mockGetSecureKey: (account: string) => string | undefined = () => undefined; -let mockResolveOAuthConnection: ( - provider: string, - options?: Record, -) => Promise<{ - request: (req: Record) => Promise<{ - status: number; - headers: Record; - body: unknown; - }>; - withToken: (fn: (token: string) => Promise) => Promise; - id: string; - provider: string; - accountInfo: string | null; -}> = async () => { - throw new Error("resolveOAuthConnection not configured in test"); -}; -let mockGetCredentialMetadata: ( - service: string, - field: string, -) => Record | undefined = () => undefined; -let mockPlatformClientCreate: () => Promise | null> = async () => null; - -// --------------------------------------------------------------------------- -// Mock token-manager -// --------------------------------------------------------------------------- - -mock.module("../security/token-manager.js", () => ({ - withValidToken: ( - service: string, - cb: (token: string) => Promise, - ): Promise => mockWithValidToken(service, cb), - // Stubs for any transitive imports that reference other exports: - TokenExpiredError: class TokenExpiredError extends Error { - constructor( - public readonly service: string, - message?: string, - ) { - super(message ?? `Token expired for "${service}".`); - this.name = "TokenExpiredError"; - } - }, -})); - -// --------------------------------------------------------------------------- -// Mock oauth-store -// --------------------------------------------------------------------------- - -mock.module("../oauth/oauth-store.js", () => ({ - disconnectOAuthProvider: async ( - provider: string, - ): Promise<"disconnected" | "not-found" | "error"> => { - disconnectOAuthProviderCalls.push(provider); - return disconnectOAuthProviderResult; - }, - getConnection: () => undefined, - getConnectionByProvider: () => undefined, - listConnections: () => [], - deleteConnection: () => false, - // Stubs required by apps.ts and providers.ts (transitively loaded via oauth/index.ts) - upsertApp: async ( - provider: string, - clientId: string, - clientSecretOpts?: { - clientSecretValue?: string; - clientSecretCredentialPath?: string; - }, - ) => { - if (mockUpsertAppImpl) { - return mockUpsertAppImpl(provider, clientId, clientSecretOpts); - } - mockUpsertAppCalls.push({ provider, clientId, clientSecretOpts }); - return mockUpsertAppResult; - }, - getApp: () => undefined, - getAppByProviderAndClientId: (provider: string, clientId: string) => - mockGetAppByProviderAndClientId(provider, clientId), - getMostRecentAppByProvider: (provider: string) => - mockGetMostRecentAppByProvider(provider), - listApps: () => [], - deleteApp: async () => false, - getProvider: (provider: string) => { - // If the test has plugged in a custom mockGetProvider, prefer that. - const custom = mockGetProvider(provider); - if (custom !== undefined) return custom; - return mockProviderStore.get(provider); - }, - listProviders: () => mockListProviders(), - registerProvider: (params: Record) => { - const now = Date.now(); - const row: Record = { - provider: params.provider, - authorizeUrl: params.authorizeUrl, - tokenExchangeUrl: params.tokenExchangeUrl, - refreshUrl: (params.refreshUrl as string | undefined) ?? null, - tokenEndpointAuthMethod: - (params.tokenEndpointAuthMethod as string | undefined) || - "client_secret_post", - tokenExchangeBodyFormat: - (params.tokenExchangeBodyFormat as string | undefined) ?? "form", - userinfoUrl: params.userinfoUrl ?? null, - baseUrl: params.baseUrl ?? null, - defaultScopes: JSON.stringify(params.defaultScopes ?? []), - availableScopes: params.availableScopes - ? JSON.stringify(params.availableScopes) - : null, - scopeSeparator: (params.scopeSeparator as string | undefined) ?? " ", - authorizeParams: params.authorizeParams - ? JSON.stringify(params.authorizeParams) - : null, - pingUrl: params.pingUrl ?? null, - pingMethod: params.pingMethod ?? null, - pingHeaders: params.pingHeaders - ? JSON.stringify(params.pingHeaders) - : null, - pingBody: - params.pingBody !== undefined ? JSON.stringify(params.pingBody) : null, - revokeUrl: (params.revokeUrl as string | undefined) ?? null, - revokeBodyTemplate: params.revokeBodyTemplate - ? JSON.stringify(params.revokeBodyTemplate) - : null, - managedServiceConfigKey: params.managedServiceConfigKey ?? null, - displayLabel: params.displayLabel ?? null, - description: params.description ?? null, - dashboardUrl: params.dashboardUrl ?? null, - logoUrl: params.logoUrl ?? null, - clientIdPlaceholder: params.clientIdPlaceholder ?? null, - requiresClientSecret: params.requiresClientSecret ?? 1, - loopbackPort: params.loopbackPort ?? null, - injectionTemplates: params.injectionTemplates - ? JSON.stringify(params.injectionTemplates) - : null, - appType: params.appType ?? null, - setupNotes: params.setupNotes ? JSON.stringify(params.setupNotes) : null, - identityUrl: params.identityUrl ?? null, - identityMethod: params.identityMethod ?? null, - identityHeaders: params.identityHeaders - ? JSON.stringify(params.identityHeaders) - : null, - identityBody: - params.identityBody !== undefined - ? JSON.stringify(params.identityBody) - : null, - identityResponsePaths: params.identityResponsePaths - ? JSON.stringify(params.identityResponsePaths) - : null, - identityFormat: params.identityFormat ?? null, - identityOkField: params.identityOkField ?? null, - featureFlag: params.featureFlag ?? null, - createdAt: now, - updatedAt: now, - }; - mockProviderStore.set(params.provider as string, row); - return row; - }, - updateProvider: (provider: string, params: Record) => { - const existing = mockProviderStore.get(provider); - if (!existing) return undefined; - const updated: Record = { ...existing }; - if (params.scopeSeparator !== undefined) { - updated.scopeSeparator = params.scopeSeparator; - } - if (params.authorizeUrl !== undefined) { - updated.authorizeUrl = params.authorizeUrl; - } - if (params.tokenExchangeUrl !== undefined) { - updated.tokenExchangeUrl = params.tokenExchangeUrl; - } - if (params.refreshUrl !== undefined) { - updated.refreshUrl = params.refreshUrl; - } - if (params.revokeUrl !== undefined) { - updated.revokeUrl = params.revokeUrl; - } - if (params.revokeBodyTemplate !== undefined) { - updated.revokeBodyTemplate = - params.revokeBodyTemplate === null - ? null - : JSON.stringify(params.revokeBodyTemplate); - } - if (params.defaultScopes !== undefined) { - updated.defaultScopes = JSON.stringify(params.defaultScopes); - } - if (params.displayLabel !== undefined) { - updated.displayLabel = params.displayLabel; - } - if (params.logoUrl !== undefined) { - updated.logoUrl = params.logoUrl; - } - updated.updatedAt = Date.now(); - mockProviderStore.set(provider, updated); - return updated; - }, - deleteProvider: () => false, - seedProviders: () => {}, - getActiveConnection: () => undefined, - listActiveConnectionsByProvider: () => [], - createConnection: () => ({}), - isProviderConnected: () => false, - updateConnection: () => ({}), -})); - -// Stub out transitive dependencies that token-manager would normally pull in. -// All named exports must be present to avoid SyntaxError when bun's module -// mock is shared across test files in the same run. -mock.module("../security/secure-keys.js", () => ({ - getSecureKeyAsync: async (account: string) => mockGetSecureKey(account), - getSecureKeyResultAsync: async (account: string) => ({ - value: mockGetSecureKey(account), - unreachable: false, - }), - setSecureKeyAsync: async () => true, - deleteSecureKeyAsync: async (account: string) => { - if (secureKeyStore.has(account)) { - secureKeyStore.delete(account); - return "deleted" as const; - } - return "not-found" as const; - }, - bulkSetSecureKeysAsync: async () => true, - getProviderKeyAsync: async () => undefined, - getMaskedProviderKey: async () => undefined, - listSecureKeysAsync: async () => ({ - accounts: [...secureKeyStore.keys()], - unreachable: false, - }), - getActiveBackendName: () => "mock", - setCesClient: () => {}, - onCesClientChanged: () => () => {}, - setCesReconnect: () => {}, - _resetBackend: () => {}, -})); - -mock.module("../tools/credentials/metadata-store.js", () => ({ - assertMetadataWritable: () => {}, - getCredentialMetadata: (service: string, field: string) => - mockGetCredentialMetadata(service, field), - getCredentialMetadataById: () => undefined, - upsertCredentialMetadata: () => ({}), - listCredentialMetadata: () => [], - deleteCredentialMetadata: (service: string, field: string): boolean => { - const idx = metadataStore.findIndex( - (c) => c.service === service && c.field === field, - ); - if (idx === -1) return false; - metadataStore.splice(idx, 1); - return true; - }, - _setMetadataPath: () => {}, -})); - -// --------------------------------------------------------------------------- -// Mock connect-orchestrator -// --------------------------------------------------------------------------- - -mock.module("../oauth/connect-orchestrator.js", () => ({ - orchestrateOAuthConnect: (opts: Record) => - mockOrchestrateOAuthConnect(opts), -})); - -// --------------------------------------------------------------------------- -// Mock cli-client (IPC) — used by `oauth connect` for daemon-orchestrated flow -// --------------------------------------------------------------------------- - -mock.module("../ipc/cli-client.js", () => ({ - cliIpcCall: (operationId: string, params?: Record) => - mockCliIpcCall(operationId, params), - exitFromIpcResult: (r: { error?: string; statusCode?: number }) => { - // Mimic real exit-code branching by mutating process.exitCode without - // actually exiting the process, so tests can observe the code. - if (r.statusCode === undefined) { - process.exitCode = 10; - } else if (r.statusCode >= 500) { - process.exitCode = 3; - } else if (r.statusCode >= 400) { - process.exitCode = 2; - } else { - process.exitCode = 1; - } - throw new Error(r.error ?? "Unknown error"); - }, -})); - -mock.module("../oauth/seed-providers.js", () => ({ - SEEDED_PROVIDER_KEYS: new Set([ - "google", - "slack", - "github", - "notion", - "twitter", - "linear", - ]), - seedOAuthProviders: () => {}, -})); - -// --------------------------------------------------------------------------- -// Mock connection-resolver (needed by request.ts) -// --------------------------------------------------------------------------- - -mock.module("../oauth/connection-resolver.js", () => ({ - resolveOAuthConnection: ( - provider: string, - options?: Record, - ) => mockResolveOAuthConnection(provider, options), -})); - -// --------------------------------------------------------------------------- -// Mock platform/client (needed by request.ts) -// --------------------------------------------------------------------------- +let mockPlatformClientCreate: () => Promise | null> = + async () => null; mock.module("../platform/client.js", () => ({ VellumPlatformClient: { @@ -398,18 +34,13 @@ mock.module("../platform/client.js", () => ({ }, })); -// --------------------------------------------------------------------------- -// Mock config/loader (needed by isManagedMode in shared.ts) -// --------------------------------------------------------------------------- - -let mockGetConfig: () => Record = () => ({ - services: {}, -}); - +// Some shared helpers in oauth/shared.ts touch getConfig() — stub it so the +// import resolves cleanly even though the requirePlatformConnection path +// never reads service configuration. mock.module("../config/loader.js", () => ({ - getConfig: () => mockGetConfig(), - getConfigReadOnly: () => mockGetConfig(), - loadConfig: () => mockGetConfig(), + getConfig: () => ({ services: {} }), + getConfigReadOnly: () => ({ services: {} }), + loadConfig: () => ({ services: {} }), invalidateConfigCache: () => {}, loadRawConfig: () => ({}), saveRawConfig: () => {}, @@ -419,985 +50,18 @@ mock.module("../config/loader.js", () => ({ getNestedValue: () => undefined, setNestedValue: () => {}, _appendQuarantineBulletin: () => {}, - API_KEY_PROVIDERS: [ - "anthropic", - "openai", - "gemini", - "ollama", - "fireworks", - "openrouter", - "brave", - "perplexity", - ], + API_KEY_PROVIDERS: ["anthropic", "openai", "gemini"], })); mock.module("../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), + getLogger: () => ({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }), + getCliLogger: () => ({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }), })); -// --------------------------------------------------------------------------- -// Import the module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerOAuthCommand } = await import("../cli/commands/oauth/index.js"); const { requirePlatformConnection } = await import( "../cli/commands/oauth/shared.js" ); -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCli( - args: string[], -): Promise<{ exitCode: number; stdout: string }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = (() => true) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerOAuthCommand(program); - await program.parseAsync(["node", "assistant", "oauth", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { - exitCode, - stdout: stdoutChunks.join(""), - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant oauth token ", () => { - beforeEach(() => { - mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz"); - }); - - test("prints bare token in human mode", async () => { - const { exitCode, stdout } = await runCli(["token", "twitter"]); - expect(exitCode).toBe(0); - expect(stdout).toBe("mock-access-token-xyz\n"); - }); - - test("prints JSON in --json mode", async () => { - const { exitCode, stdout } = await runCli(["token", "twitter", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed).toEqual({ ok: true, token: "mock-access-token-xyz" }); - }); - - test("passes provider key directly to withValidToken", async () => { - let capturedService: string | undefined; - mockWithValidToken = async (service, cb) => { - capturedService = service; - return cb("tok"); - }; - - await runCli(["token", "twitter"]); - expect(capturedService).toBe("twitter"); - }); - - test("works with other provider keys", async () => { - let capturedService: string | undefined; - mockWithValidToken = async (service, cb) => { - capturedService = service; - return cb("gmail-token"); - }; - - const { exitCode, stdout } = await runCli(["token", "google"]); - expect(exitCode).toBe(0); - expect(stdout).toBe("gmail-token\n"); - expect(capturedService).toBe("google"); - }); - - test("exits 1 when no token exists", async () => { - mockWithValidToken = async () => { - throw new Error( - 'No access token found for "twitter". Authorization required.', - ); - }; - - const { exitCode, stdout } = await runCli(["token", "twitter", "--json"]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No access token found"); - }); - - test("exits 1 when refresh fails", async () => { - mockWithValidToken = async () => { - throw new Error('Token refresh failed for "twitter": invalid_grant.'); - }; - - const { exitCode, stdout } = await runCli(["token", "twitter", "--json"]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Token refresh failed"); - }); - - test("returns refreshed token transparently", async () => { - // Simulate withValidToken refreshing and returning a new token - mockWithValidToken = async (_service, cb) => cb("refreshed-new-token"); - - const { exitCode, stdout } = await runCli(["token", "twitter"]); - expect(exitCode).toBe(0); - expect(stdout).toBe("refreshed-new-token\n"); - }); - - test("missing provider-key argument exits non-zero", async () => { - const { exitCode } = await runCli(["token"]); - expect(exitCode).not.toBe(0); - }); -}); - -// --------------------------------------------------------------------------- -// providers list -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant oauth providers list", () => { - const fakeProviders = [ - { - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - managedServiceConfigKey: "google-oauth", - createdAt: "2025-01-01T00:00:00.000Z", - updatedAt: "2025-01-01T00:00:00.000Z", - }, - { - provider: "google-calendar", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - managedServiceConfigKey: "google-calendar-oauth", - createdAt: "2025-01-01T00:00:00.000Z", - updatedAt: "2025-01-01T00:00:00.000Z", - }, - { - provider: "slack", - authorizeUrl: "https://slack.com/oauth/v2/authorize", - tokenExchangeUrl: "https://slack.com/api/oauth.v2.access", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - managedServiceConfigKey: null, - createdAt: "2025-01-01T00:00:00.000Z", - updatedAt: "2025-01-01T00:00:00.000Z", - }, - { - provider: "twitter", - authorizeUrl: "https://twitter.com/i/oauth2/authorize", - tokenExchangeUrl: "https://api.twitter.com/2/oauth2/token", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - managedServiceConfigKey: null, - createdAt: "2025-01-01T00:00:00.000Z", - updatedAt: "2025-01-01T00:00:00.000Z", - }, - ]; - - beforeEach(() => { - mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz"); - mockListProviders = () => fakeProviders; - }); - - test("returns all providers when no --provider-key is given", async () => { - const { exitCode, stdout } = await runCli(["providers", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed).toHaveLength(4); - const keys = parsed.map((p: { providerKey: string }) => p.providerKey); - expect(keys).toContain("google"); - expect(keys).toContain("google-calendar"); - expect(keys).toContain("slack"); - expect(keys).toContain("twitter"); - }); - - test("filters by single --provider-key value", async () => { - const { exitCode, stdout } = await runCli([ - "providers", - "list", - "--provider-key", - "slack", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed).toHaveLength(1); - expect(parsed[0].providerKey).toBe("slack"); - }); - - test("filters by comma-separated OR values", async () => { - const { exitCode, stdout } = await runCli([ - "providers", - "list", - "--provider-key", - "slack,google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed).toHaveLength(3); - const keys = parsed.map((p: { providerKey: string }) => p.providerKey); - expect(keys).toContain("google"); - expect(keys).toContain("google-calendar"); - expect(keys).toContain("slack"); - }); - - test("returns empty array when comma-separated filter has no matches", async () => { - const { exitCode, stdout } = await runCli([ - "providers", - "list", - "--provider-key", - "notion,linear", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed).toHaveLength(0); - }); - - test("trims whitespace around commas in --provider-key", async () => { - const { exitCode, stdout } = await runCli([ - "providers", - "list", - "--provider-key", - "slack, google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed).toHaveLength(3); - const keys = parsed.map((p: { providerKey: string }) => p.providerKey); - expect(keys).toContain("google"); - expect(keys).toContain("google-calendar"); - expect(keys).toContain("slack"); - }); - - test("ignores empty segments from extra commas in --provider-key", async () => { - const { exitCode, stdout } = await runCli([ - "providers", - "list", - "--provider-key", - "slack,,google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed).toHaveLength(3); - const keys = parsed.map((p: { providerKey: string }) => p.providerKey); - expect(keys).toContain("google"); - expect(keys).toContain("google-calendar"); - expect(keys).toContain("slack"); - }); - - test("--supports-managed returns only providers with managedServiceConfigKey set", async () => { - const { exitCode, stdout } = await runCli([ - "providers", - "list", - "--supports-managed", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed).toHaveLength(2); - const keys = parsed.map((p: { providerKey: string }) => p.providerKey); - expect(keys).toContain("google"); - expect(keys).toContain("google-calendar"); - expect(keys).not.toContain("slack"); - expect(keys).not.toContain("twitter"); - }); - - test("--supports-managed combined with --provider-key applies both filters (AND)", async () => { - const { exitCode, stdout } = await runCli([ - "providers", - "list", - "--supports-managed", - "--provider-key", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - // Both google and google-calendar match --provider-key "google" AND have - // managedServiceConfigKey set, so both are returned. - expect(parsed).toHaveLength(2); - const keys = parsed.map((p: { providerKey: string }) => p.providerKey); - expect(keys).toContain("google"); - expect(keys).toContain("google-calendar"); - }); - - test("without --supports-managed all providers are returned (existing behavior)", async () => { - const { exitCode, stdout } = await runCli(["providers", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed).toHaveLength(4); - const keys = parsed.map((p: { providerKey: string }) => p.providerKey); - expect(keys).toContain("google"); - expect(keys).toContain("google-calendar"); - expect(keys).toContain("slack"); - expect(keys).toContain("twitter"); - }); -}); - -// --------------------------------------------------------------------------- -// apps upsert --client-secret-credential-path -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant oauth apps upsert --client-secret-credential-path", () => { - beforeEach(() => { - mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz"); - mockUpsertAppCalls = []; - mockUpsertAppResult = { - id: "app-upsert-1", - provider: "google", - clientId: "abc123", - createdAt: 1700000000000, - updatedAt: 1700000000000, - }; - mockOrchestrateOAuthConnect = async () => ({ - success: true, - deferred: false, - grantedScopes: [], - }); - mockGetAppByProviderAndClientId = () => undefined; - mockGetMostRecentAppByProvider = () => undefined; - mockGetProvider = () => undefined; - mockGetSecureKey = () => undefined; - mockGetCredentialMetadata = () => undefined; - mockUpsertAppImpl = undefined; - }); - - test("upsert with --client-secret-credential-path passes path to upsertApp", async () => { - // "custom/path" has no colon and no credential/ or oauth_app/ prefix. - // resolveCredentialPath passes it through unchanged since it doesn't - // match the service:field shorthand pattern. - const { exitCode, stdout } = await runCli([ - "apps", - "upsert", - "--provider", - "google", - "--client-id", - "abc123", - "--client-secret-credential-path", - "custom/path", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockUpsertAppCalls).toHaveLength(1); - expect(mockUpsertAppCalls[0]).toEqual({ - provider: "google", - clientId: "abc123", - clientSecretOpts: { clientSecretCredentialPath: "custom/path" }, - }); - const parsed = JSON.parse(stdout); - expect(parsed.id).toBe("app-upsert-1"); - }); - - test("upsert with both --client-secret and --client-secret-credential-path returns error", async () => { - const { exitCode, stdout } = await runCli([ - "apps", - "upsert", - "--provider", - "google", - "--client-id", - "abc123", - "--client-secret", - "s3cret", - "--client-secret-credential-path", - "custom/path", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain( - "Cannot provide both --client-secret and --client-secret-credential-path", - ); - // upsertApp should NOT have been called - expect(mockUpsertAppCalls).toHaveLength(0); - }); - - test("upsert with --client-secret passes clientSecretValue to upsertApp", async () => { - const { exitCode } = await runCli([ - "apps", - "upsert", - "--provider", - "google", - "--client-id", - "abc123", - "--client-secret", - "s3cret", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockUpsertAppCalls).toHaveLength(1); - expect(mockUpsertAppCalls[0]).toEqual({ - provider: "google", - clientId: "abc123", - clientSecretOpts: { clientSecretValue: "s3cret" }, - }); - }); - - test("upsert without any secret option passes undefined", async () => { - const { exitCode } = await runCli([ - "apps", - "upsert", - "--provider", - "google", - "--client-id", - "abc123", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockUpsertAppCalls).toHaveLength(1); - expect(mockUpsertAppCalls[0]).toEqual({ - provider: "google", - clientId: "abc123", - clientSecretOpts: undefined, - }); - }); - - test("upsert resolves service:field shorthand to full credential path", async () => { - // The service:field shorthand is resolved to credential/{service}/{field} - const { exitCode, stdout } = await runCli([ - "apps", - "upsert", - "--provider", - "google", - "--client-id", - "abc", - "--client-secret-credential-path", - "google:client_secret", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockUpsertAppCalls).toHaveLength(1); - expect(mockUpsertAppCalls[0]).toEqual({ - provider: "google", - clientId: "abc", - clientSecretOpts: { - clientSecretCredentialPath: "credential/google/client_secret", - }, - }); - const parsed = JSON.parse(stdout); - expect(parsed.id).toBe("app-upsert-1"); - }); - - test("upsert resolves slack:client_secret shorthand to full credential path", async () => { - const { exitCode, stdout } = await runCli([ - "apps", - "upsert", - "--provider", - "slack", - "--client-id", - "slack-abc", - "--client-secret-credential-path", - "slack:client_secret", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockUpsertAppCalls).toHaveLength(1); - expect(mockUpsertAppCalls[0]).toEqual({ - provider: "slack", - clientId: "slack-abc", - clientSecretOpts: { - clientSecretCredentialPath: "credential/slack/client_secret", - }, - }); - const parsed = JSON.parse(stdout); - expect(parsed.id).toBe("app-upsert-1"); - }); - - test("upsert passes prefixed credential path through unchanged", async () => { - const { exitCode, stdout } = await runCli([ - "apps", - "upsert", - "--provider", - "google", - "--client-id", - "abc", - "--client-secret-credential-path", - "credential/google/client_secret", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockUpsertAppCalls).toHaveLength(1); - // Already-prefixed path should be passed through as-is - expect(mockUpsertAppCalls[0]).toEqual({ - provider: "google", - clientId: "abc", - clientSecretOpts: { - clientSecretCredentialPath: "credential/google/client_secret", - }, - }); - const parsed = JSON.parse(stdout); - expect(parsed.id).toBe("app-upsert-1"); - }); - - test("upsert passes oauth_app/ prefixed credential path through unchanged", async () => { - const { exitCode, stdout } = await runCli([ - "apps", - "upsert", - "--provider", - "google", - "--client-id", - "abc", - "--client-secret-credential-path", - "oauth_app/some-id/client_secret", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockUpsertAppCalls).toHaveLength(1); - // oauth_app/ prefixed path should be passed through as-is - expect(mockUpsertAppCalls[0]).toEqual({ - provider: "google", - clientId: "abc", - clientSecretOpts: { - clientSecretCredentialPath: "oauth_app/some-id/client_secret", - }, - }); - const parsed = JSON.parse(stdout); - expect(parsed.id).toBe("app-upsert-1"); - }); - - test("upsert with invalid credential path returns error when no secret found", async () => { - // Override upsertApp to throw when given an unresolvable credential path - mockUpsertAppImpl = async (_provider, _clientId, clientSecretOpts) => { - throw new Error( - `No secret found at credential path: ${clientSecretOpts?.clientSecretCredentialPath}`, - ); - }; - - const { exitCode, stdout } = await runCli([ - "apps", - "upsert", - "--provider", - "google", - "--client-id", - "abc", - "--client-secret-credential-path", - "bogus:nonexistent:path", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No secret found"); - }); -}); - -// --------------------------------------------------------------------------- -// ping -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant oauth ping ", () => { - beforeEach(() => { - mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz"); - // Reset resolveOAuthConnection to default (unconfigured) - mockResolveOAuthConnection = async () => { - throw new Error("resolveOAuthConnection not configured in test"); - }; - }); - - test("returns ok when ping endpoint returns 200", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v2/userinfo", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }); - mockResolveOAuthConnection = async () => ({ - id: "conn-1", - provider: "google", - accountInfo: null, - request: async () => ({ status: 200, headers: {}, body: {} }), - withToken: async (fn) => fn("mock-access-token-xyz"), - }); - const { exitCode, stdout } = await runCli(["ping", "google", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.status).toBe(200); - }); - - test("exits 1 when provider not found", async () => { - mockGetProvider = () => undefined; - const { exitCode, stdout } = await runCli(["ping", "unknown", "--json"]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Unknown provider"); - }); - - test("exits 1 when no ping URL configured", async () => { - mockGetProvider = () => ({ - provider: "telegram", - pingUrl: null, - authorizeUrl: "urn:manual-token", - tokenExchangeUrl: "urn:manual-token", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }); - const { exitCode, stdout } = await runCli(["ping", "telegram", "--json"]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No ping URL configured"); - }); - - test("exits 1 when ping endpoint returns non-2xx", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v2/userinfo", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }); - mockResolveOAuthConnection = async () => ({ - id: "conn-1", - provider: "google", - accountInfo: null, - request: async () => ({ status: 403, headers: {}, body: "Forbidden" }), - withToken: async (fn) => fn("mock-access-token-xyz"), - }); - const { exitCode, stdout } = await runCli(["ping", "google", "--json"]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.status).toBe(403); - }); - - test("exits 1 when no connection can be resolved", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v2/userinfo", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }); - mockResolveOAuthConnection = async () => { - throw new Error('No access token found for "google".'); - }; - const { exitCode, stdout } = await runCli(["ping", "google", "--json"]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No access token"); - }); -}); - -// --------------------------------------------------------------------------- -// oauth connect — managed mode 401/403 error messages -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant oauth connect managed mode — platform 401/403 errors", () => { - /** - * Helper: create a mock platform client whose `fetch` always returns the - * given status code and body text. - */ - function makeMockPlatformClient(status: number, body = "") { - return { - platformAssistantId: "asst-test-123", - fetch: async () => - new Response(body, { status, statusText: `HTTP ${status}` }), - }; - } - - beforeEach(() => { - mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz"); - mockOrchestrateOAuthConnect = async () => ({ - success: true, - deferred: false, - grantedScopes: [], - }); - mockGetAppByProviderAndClientId = () => undefined; - mockGetMostRecentAppByProvider = () => undefined; - mockGetSecureKey = () => undefined; - mockGetCredentialMetadata = () => undefined; - - // Set up managed mode: provider has managedServiceConfigKey, config - // returns the matching service with mode "managed". - mockGetProvider = () => ({ - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - managedServiceConfigKey: "google-oauth", - createdAt: Date.now(), - updatedAt: Date.now(), - }); - mockGetConfig = () => ({ - services: { - "google-oauth": { mode: "managed" }, - }, - }); - }); - - afterEach(() => { - mockPlatformClientCreate = async () => null; - mockGetConfig = () => ({ services: {} }); - }); - - test("401 response includes 'vellum platform connect' suggestion", async () => { - mockPlatformClientCreate = async () => - makeMockPlatformClient(401, "Unauthorized"); - const { exitCode, stdout } = await runCli([ - "connect", - "google", - "--no-browser", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Platform returned HTTP 401"); - expect(parsed.error).toContain("Unauthorized"); - expect(parsed.error).toContain("vellum platform connect"); - }); - - test("403 response includes 'vellum platform connect' suggestion", async () => { - mockPlatformClientCreate = async () => - makeMockPlatformClient(403, "Forbidden"); - const { exitCode, stdout } = await runCli([ - "connect", - "google", - "--no-browser", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Platform returned HTTP 403"); - expect(parsed.error).toContain("Forbidden"); - expect(parsed.error).toContain("vellum platform connect"); - }); - - test("500 response does NOT include 'vellum platform connect' suggestion", async () => { - mockPlatformClientCreate = async () => - makeMockPlatformClient(500, "Internal Server Error"); - const { exitCode, stdout } = await runCli([ - "connect", - "google", - "--no-browser", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Platform returned HTTP 500"); - expect(parsed.error).not.toContain("vellum platform connect"); - }); -}); - -// --------------------------------------------------------------------------- -// `assistant oauth connect ` BYO mode — daemon-unreachable behavior. -// -// We deleted the in-process `orchestrateOAuthConnect` fallback (the same -// pattern as the MCP CLI consolidation in #29484). When the daemon is -// unreachable, the CLI must surface a clear error and exit 1 — never -// silently fall back to in-process flow. -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant oauth connect — daemon unreachable (BYO mode)", () => { - beforeEach(() => { - // BYO provider with a registered app and no managed-mode wiring. - mockGetProvider = () => ({ - provider: "github", - authorizeUrl: "https://github.com/login/oauth/authorize", - tokenExchangeUrl: "https://github.com/login/oauth/access_token", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - managedServiceConfigKey: null, - requiresClientSecret: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }); - mockGetMostRecentAppByProvider = () => ({ - provider: "github", - clientId: "test-client-id", - clientSecretCredentialPath: "oauth_app/github/test/client_secret", - }); - mockGetSecureKey = () => "test-secret"; - mockGetConfig = () => ({ services: {} }); - }); - - test("daemon connect-refused → exit 1 with 'Is the assistant running?'", async () => { - mockCliIpcCall = async () => ({ - ok: false, - error: "Could not connect to assistant daemon: ECONNREFUSED", - }); - - const { exitCode, stdout } = await runCli([ - "connect", - "github", - "--no-browser", - "--json", - ]); - - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Could not reach the assistant"); - expect(parsed.error).toContain("Is the assistant running?"); - }); - - test("daemon route missing (Unknown method) → exit 1, never falls through to in-process", async () => { - let orchestratorCalls = 0; - mockOrchestrateOAuthConnect = async () => { - orchestratorCalls++; - return { success: true, deferred: false, grantedScopes: [] }; - }; - mockCliIpcCall = async () => ({ - ok: false, - error: "Unknown method: internal_oauth_connect_start", - }); - - const { exitCode } = await runCli([ - "connect", - "github", - "--no-browser", - "--json", - ]); - - expect(exitCode).toBe(1); - // Critical regression guard: the in-process `orchestrateOAuthConnect` - // must NOT be invoked from the CLI. The daemon-orchestrated path is - // the sole code path; this is the same invariant #29484 established - // for the MCP CLI. - expect(orchestratorCalls).toBe(0); - }); - - test("daemon HTTP error (statusCode set) → surfaces error verbatim, no fallback", async () => { - let orchestratorCalls = 0; - mockOrchestrateOAuthConnect = async () => { - orchestratorCalls++; - return { success: true, deferred: false, grantedScopes: [] }; - }; - mockCliIpcCall = async () => ({ - ok: false, - statusCode: 400, - error: "service must be registered before connecting", - }); - - const { exitCode, stdout } = await runCli([ - "connect", - "github", - "--no-browser", - "--json", - ]); - - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.error).toContain("service must be registered"); - expect(orchestratorCalls).toBe(0); - }); -}); - // --------------------------------------------------------------------------- // requirePlatformConnection // --------------------------------------------------------------------------- @@ -1461,642 +125,3 @@ describe("requirePlatformConnection", () => { expect(process.exitCode).toBe(0); }); }); - -// --------------------------------------------------------------------------- -// oauth mode — platform connection guard -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant oauth mode", () => { - beforeEach(() => { - mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz"); - mockGetProvider = () => ({ - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - defaultScopes: "[]", - availableScopes: null, - authorizeParams: null, - managedServiceConfigKey: "google-oauth", - createdAt: Date.now(), - updatedAt: Date.now(), - }); - mockGetConfig = () => ({ - services: { - "google-oauth": { mode: "your-own" }, - }, - }); - }); - - afterEach(() => { - mockPlatformClientCreate = async () => null; - mockGetConfig = () => ({ services: {} }); - mockGetProvider = () => undefined; - }); - - test("oauth mode --set managed fails when not connected to platform", async () => { - mockPlatformClientCreate = async () => null; - const { exitCode, stdout } = await runCli([ - "mode", - "google", - "--set", - "managed", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("vellum platform connect"); - }); - - test("oauth mode --set your-own succeeds without platform connection", async () => { - mockPlatformClientCreate = async () => null; - const { exitCode, stdout } = await runCli([ - "mode", - "google", - "--set", - "your-own", - "--json", - ]); - // Setting to "your-own" doesn't need platform — it's a local-only operation - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.mode).toBe("your-own"); - }); - - test("oauth mode (read) succeeds without platform connection", async () => { - mockPlatformClientCreate = async () => null; - const { exitCode, stdout } = await runCli(["mode", "google", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.mode).toBe("your-own"); - }); -}); - -// --------------------------------------------------------------------------- -// providers register / update / get — --scope-separator wiring -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant oauth providers --scope-separator", () => { - beforeEach(() => { - mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz"); - mockProviderStore.clear(); - // Default getProvider falls through to mockProviderStore via the - // oauth-store mock module. Tests in this describe block don't need - // a per-test mockGetProvider override. - mockGetProvider = () => undefined; - mockGetConfig = () => ({ services: {} }); - }); - - afterEach(() => { - mockProviderStore.clear(); - mockGetProvider = () => undefined; - }); - - test("providers register --scope-separator , stores ',' on the provider row", async () => { - const { exitCode } = await runCli([ - "providers", - "register", - "--provider-key", - "custom-linear", - "--auth-url", - "https://linear.app/oauth/authorize", - "--token-url", - "https://api.linear.app/oauth/token", - "--scopes", - "read,write", - "--scope-separator", - ",", - "--json", - ]); - expect(exitCode).toBe(0); - const stored = mockProviderStore.get("custom-linear"); - expect(stored).toBeDefined(); - expect(stored?.scopeSeparator).toBe(","); - }); - - test("providers register without --scope-separator stores the default ' '", async () => { - const { exitCode } = await runCli([ - "providers", - "register", - "--provider-key", - "custom-default-sep", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--scopes", - "read,write", - "--json", - ]); - expect(exitCode).toBe(0); - const stored = mockProviderStore.get("custom-default-sep"); - expect(stored).toBeDefined(); - expect(stored?.scopeSeparator).toBe(" "); - }); - - test("providers update --scope-separator , updates an existing custom provider", async () => { - // Seed the store with an existing custom provider that uses the default - // " " separator. - await runCli([ - "providers", - "register", - "--provider-key", - "custom-update-target", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--scopes", - "read", - "--json", - ]); - expect(mockProviderStore.get("custom-update-target")?.scopeSeparator).toBe( - " ", - ); - - const { exitCode } = await runCli([ - "providers", - "update", - "custom-update-target", - "--scope-separator", - ",", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockProviderStore.get("custom-update-target")?.scopeSeparator).toBe( - ",", - ); - }); - - test("providers get --json includes scopeSeparator from the serialized output", async () => { - // Seed the store with a custom provider that uses ',' as the separator. - await runCli([ - "providers", - "register", - "--provider-key", - "custom-get-target", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--scopes", - "read,write", - "--scope-separator", - ",", - "--json", - ]); - - const { exitCode, stdout } = await runCli([ - "providers", - "get", - "custom-get-target", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.scopeSeparator).toBe(","); - }); -}); - -// --------------------------------------------------------------------------- -// providers register / update / get — --refresh-url wiring -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant oauth providers --refresh-url", () => { - beforeEach(() => { - mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz"); - mockProviderStore.clear(); - // Default getProvider falls through to mockProviderStore via the - // oauth-store mock module. - mockGetProvider = () => undefined; - mockGetConfig = () => ({ services: {} }); - }); - - afterEach(() => { - mockProviderStore.clear(); - mockGetProvider = () => undefined; - }); - - test("providers register --refresh-url stores the URL on the provider row", async () => { - const { exitCode } = await runCli([ - "providers", - "register", - "--provider-key", - "custom-refresh-url", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--refresh-url", - "https://refresh.example.com/token", - "--json", - ]); - expect(exitCode).toBe(0); - const stored = mockProviderStore.get("custom-refresh-url"); - expect(stored).toBeDefined(); - expect(stored?.refreshUrl).toBe("https://refresh.example.com/token"); - }); - - test("providers register without --refresh-url stores null", async () => { - const { exitCode } = await runCli([ - "providers", - "register", - "--provider-key", - "custom-no-refresh-url", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--json", - ]); - expect(exitCode).toBe(0); - const stored = mockProviderStore.get("custom-no-refresh-url"); - expect(stored).toBeDefined(); - expect(stored?.refreshUrl).toBeNull(); - }); - - test("providers update --refresh-url updates an existing custom provider", async () => { - // Seed the store with an existing custom provider that has no refresh URL. - await runCli([ - "providers", - "register", - "--provider-key", - "custom-update-refresh", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--json", - ]); - expect( - mockProviderStore.get("custom-update-refresh")?.refreshUrl, - ).toBeNull(); - - const { exitCode } = await runCli([ - "providers", - "update", - "custom-update-refresh", - "--refresh-url", - "https://new-refresh.example.com/token", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockProviderStore.get("custom-update-refresh")?.refreshUrl).toBe( - "https://new-refresh.example.com/token", - ); - }); - - test("providers get --json includes refreshUrl from the serialized output", async () => { - // Seed the store with a custom provider that has a refresh URL set. - await runCli([ - "providers", - "register", - "--provider-key", - "custom-get-refresh", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--refresh-url", - "https://refresh.example.com/token", - "--json", - ]); - - const { exitCode, stdout } = await runCli([ - "providers", - "get", - "custom-get-refresh", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.refreshUrl).toBe("https://refresh.example.com/token"); - }); -}); - -// --------------------------------------------------------------------------- -// providers register / update / get — --revoke-url and --revoke-body-template wiring -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant oauth providers --revoke-url and --revoke-body-template", () => { - beforeEach(() => { - mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz"); - mockProviderStore.clear(); - mockGetProvider = () => undefined; - mockGetConfig = () => ({ services: {} }); - }); - - afterEach(() => { - mockProviderStore.clear(); - mockGetProvider = () => undefined; - }); - - test("providers register --revoke-url and --revoke-body-template stores both fields", async () => { - const { exitCode } = await runCli([ - "providers", - "register", - "--provider-key", - "custom-revoke-roundtrip", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--revoke-url", - "https://revoke.example.com", - "--revoke-body-template", - '{"token":"{access_token}"}', - "--json", - ]); - expect(exitCode).toBe(0); - const stored = mockProviderStore.get("custom-revoke-roundtrip"); - expect(stored).toBeDefined(); - expect(stored?.revokeUrl).toBe("https://revoke.example.com"); - // revokeBodyTemplate is JSON-stringified at the storage layer. - expect(JSON.parse(stored?.revokeBodyTemplate as string)).toEqual({ - token: "{access_token}", - }); - }); - - test("providers register without --revoke-url or --revoke-body-template stores both as null", async () => { - const { exitCode } = await runCli([ - "providers", - "register", - "--provider-key", - "custom-no-revoke", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--json", - ]); - expect(exitCode).toBe(0); - const stored = mockProviderStore.get("custom-no-revoke"); - expect(stored).toBeDefined(); - expect(stored?.revokeUrl).toBeNull(); - expect(stored?.revokeBodyTemplate).toBeNull(); - }); - - test("providers register with malformed --revoke-body-template fails with a JSON parse error", async () => { - const { exitCode, stdout } = await runCli([ - "providers", - "register", - "--provider-key", - "custom-bad-revoke", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--revoke-body-template", - "not-json{", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - // The error message comes from JSON.parse — match permissively. - expect(parsed.error).toMatch(/JSON|Unexpected|parse/i); - // Provider should not have been written. - expect(mockProviderStore.get("custom-bad-revoke")).toBeUndefined(); - }); - - test("providers update --revoke-url updates only the URL", async () => { - // Seed an existing custom provider. - await runCli([ - "providers", - "register", - "--provider-key", - "custom-update-revoke-url", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--json", - ]); - expect( - mockProviderStore.get("custom-update-revoke-url")?.revokeUrl, - ).toBeNull(); - - const { exitCode } = await runCli([ - "providers", - "update", - "custom-update-revoke-url", - "--revoke-url", - "https://new-revoke.example.com", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockProviderStore.get("custom-update-revoke-url")?.revokeUrl).toBe( - "https://new-revoke.example.com", - ); - // revokeBodyTemplate should still be null (unchanged). - expect( - mockProviderStore.get("custom-update-revoke-url")?.revokeBodyTemplate, - ).toBeNull(); - }); - - test("providers update --revoke-url with empty string clears the stored URL to null", async () => { - // Seed an existing custom provider that already has a non-null revokeUrl. - await runCli([ - "providers", - "register", - "--provider-key", - "custom-clear-revoke-url", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--revoke-url", - "https://revoke.example.com", - "--json", - ]); - expect(mockProviderStore.get("custom-clear-revoke-url")?.revokeUrl).toBe( - "https://revoke.example.com", - ); - - // Pass an empty string to --revoke-url — the help text promises this - // clears the field, so the stored value should end up as null (not ""). - const { exitCode, stdout } = await runCli([ - "providers", - "update", - "custom-clear-revoke-url", - "--revoke-url", - "", - "--json", - ]); - expect(exitCode).toBe(0); - expect( - mockProviderStore.get("custom-clear-revoke-url")?.revokeUrl, - ).toBeNull(); - - // The serialized --json output should also reflect null, not "". - const parsed = JSON.parse(stdout); - expect(parsed.revokeUrl).toBeNull(); - }); - - test("providers update --revoke-body-template updates only the template (JSON round-trip)", async () => { - await runCli([ - "providers", - "register", - "--provider-key", - "custom-update-revoke-body", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--json", - ]); - - const { exitCode } = await runCli([ - "providers", - "update", - "custom-update-revoke-body", - "--revoke-body-template", - '{"token":"{access_token}","client_id":"{client_id}"}', - "--json", - ]); - expect(exitCode).toBe(0); - const stored = mockProviderStore.get("custom-update-revoke-body"); - expect(stored).toBeDefined(); - expect(JSON.parse(stored?.revokeBodyTemplate as string)).toEqual({ - token: "{access_token}", - client_id: "{client_id}", - }); - // revokeUrl should still be null (unchanged). - expect(stored?.revokeUrl).toBeNull(); - }); - - test("providers update --revoke-body-template with empty string clears the stored template to null", async () => { - // Seed an existing custom provider that already has a non-null - // revokeBodyTemplate set via register. - await runCli([ - "providers", - "register", - "--provider-key", - "custom-clear-revoke-body", - "--auth-url", - "https://example.com/oauth/authorize", - "--token-url", - "https://example.com/oauth/token", - "--revoke-url", - "https://revoke.example.com", - "--revoke-body-template", - '{"token":"{access_token}"}', - "--json", - ]); - const initial = mockProviderStore.get("custom-clear-revoke-body"); - expect(initial?.revokeBodyTemplate).toBeDefined(); - expect(JSON.parse(initial?.revokeBodyTemplate as string)).toEqual({ - token: "{access_token}", - }); - - // Pass an empty string to --revoke-body-template — the help text promises - // this clears the field, so the stored value should end up as null - // (not the string "null" or a JSON.parse crash). - const { exitCode, stdout } = await runCli([ - "providers", - "update", - "custom-clear-revoke-body", - "--revoke-body-template", - "", - "--json", - ]); - expect(exitCode).toBe(0); - expect( - mockProviderStore.get("custom-clear-revoke-body")?.revokeBodyTemplate, - ).toBeNull(); - - // The serialized --json output should also reflect null. - const parsed = JSON.parse(stdout); - expect(parsed.revokeBodyTemplate).toBeNull(); - }); - - test("providers get google --json includes both fields populated from PR 1's seed data", async () => { - // Simulate the seeded "google" provider row by overriding mockGetProvider - // to return a row matching what PR 1's seed inserts. - const now = Date.now(); - mockGetProvider = (provider: string) => { - if (provider !== "google") return undefined; - return { - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - refreshUrl: null, - tokenEndpointAuthMethod: "client_secret_post", - userinfoUrl: null, - baseUrl: null, - defaultScopes: "[]", - availableScopes: null, - scopeSeparator: " ", - authorizeParams: null, - pingUrl: null, - pingMethod: null, - pingHeaders: null, - pingBody: null, - revokeUrl: "https://oauth2.googleapis.com/revoke", - revokeBodyTemplate: JSON.stringify({ token: "{access_token}" }), - managedServiceConfigKey: null, - displayLabel: "Google", - description: null, - dashboardUrl: null, - clientIdPlaceholder: null, - requiresClientSecret: 1, - loopbackPort: null, - injectionTemplates: null, - appType: null, - setupNotes: null, - identityUrl: null, - identityMethod: null, - identityHeaders: null, - identityBody: null, - identityResponsePaths: null, - identityFormat: null, - identityOkField: null, - featureFlag: null, - createdAt: now, - updatedAt: now, - }; - }; - - const { exitCode, stdout } = await runCli([ - "providers", - "get", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.revokeUrl).toBe("https://oauth2.googleapis.com/revoke"); - expect(parsed.revokeBodyTemplate).toEqual({ token: "{access_token}" }); - }); -}); diff --git a/assistant/src/__tests__/oauth-commands-routes.test.ts b/assistant/src/__tests__/oauth-commands-routes.test.ts new file mode 100644 index 00000000000..23d0a3cedb0 --- /dev/null +++ b/assistant/src/__tests__/oauth-commands-routes.test.ts @@ -0,0 +1,711 @@ +/** + * Route handler tests for the OAuth CLI command endpoints exposed by + * `assistant/src/runtime/routes/oauth-commands-routes.ts`. + * + * Scope: argument validation, provider / mode dispatch, and the shape of + * returned payloads for the 9 endpoints (oauth_disconnect, oauth_mode_get, + * oauth_mode_set, oauth_status, oauth_ping, oauth_token, oauth_request, + * oauth_managed_connect_start, oauth_managed_connect_poll). These routes + * back the thin IPC wrappers in `assistant/src/cli/commands/oauth/`. + * + * Deeper coverage of the underlying store / token-refresh / platform logic + * lives in `oauth-store.test.ts`, `credential-vault.test.ts`, and the other + * oauth-*-routes test files. + */ + +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +// --------------------------------------------------------------------------- +// Mock state — flipped per-test in beforeEach hooks +// --------------------------------------------------------------------------- + +interface MockProviderRow { + provider: string; + managedServiceConfigKey: string | null; + pingUrl: string | null; + pingMethod: string | null; + pingHeaders: string | null; + pingBody: string | null; +} + +const baseProvider: MockProviderRow = { + provider: "google", + managedServiceConfigKey: "google-oauth", + pingUrl: null, + pingMethod: null, + pingHeaders: null, + pingBody: null, +}; + +let mockProviders: Record = {}; +let mockServiceModes: Record = {}; +let mockActiveConnectionsByProvider: Record = {}; +let mockAllConnections: Record = {}; +let mockApps: Record = {}; +let mockTokenValue = "tok-fake"; +let platformAvailable = true; +let platformAssistantId: string | null = "assistant-1"; +let mockFetchImpl: (path: string, init?: RequestInit) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}> = async () => ({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => "", +}); +let mockResolveResponse: { + status: number; + headers: Record; + body: unknown; +} = { status: 200, headers: {}, body: { ok: true } }; + +const mockDisconnectOAuthProvider = mock(() => Promise.resolve()); +const mockSaveRawConfig = mock(() => undefined); + +mock.module("../oauth/oauth-store.js", () => ({ + disconnectOAuthProvider: mockDisconnectOAuthProvider, + getActiveConnection: ( + provider: string, + opts?: { clientId?: string; account?: string }, + ) => { + const list = (mockActiveConnectionsByProvider[provider] ?? []) as Array<{ + id: string; + clientId?: string; + accountInfo?: string | null; + }>; + if (opts?.account) { + return list.find((c) => c.accountInfo === opts.account); + } + if (opts?.clientId) { + return list.find((c) => c.clientId === opts.clientId); + } + return list[0]; + }, + getAppByProviderAndClientId: (provider: string, clientId: string) => { + return mockApps[`${provider}:${clientId}`]; + }, + getConnection: (id: string) => { + for (const list of Object.values(mockAllConnections)) { + const row = (list as Array<{ id: string }>).find((r) => r.id === id); + if (row) return row; + } + return undefined; + }, + getProvider: (provider: string) => mockProviders[provider], + listActiveConnectionsByProvider: (provider: string) => + mockActiveConnectionsByProvider[provider] ?? [], + listConnections: (provider: string) => mockAllConnections[provider] ?? [], +})); + +mock.module("../oauth/connection-resolver.js", () => ({ + resolveOAuthConnection: async (_provider: string) => ({ + request: async () => mockResolveResponse, + }), +})); + +mock.module("../platform/client.js", () => ({ + VellumPlatformClient: { + create: async () => { + if (!platformAvailable) return null; + return { + platformAssistantId, + fetch: (path: string, init?: RequestInit) => mockFetchImpl(path, init), + }; + }, + }, +})); + +mock.module("../security/token-manager.js", () => ({ + withValidToken: async ( + _provider: string, + fn: (t: string) => Promise, + ) => fn(mockTokenValue), +})); + +mock.module("../config/loader.js", () => ({ + getConfig: () => ({ services: {} }), + loadRawConfig: () => ({ services: {} }), + saveRawConfig: mockSaveRawConfig, + setNestedValue: (obj: Record, path: string, value: unknown) => { + const parts = path.split("."); + let cur: Record = obj; + for (let i = 0; i < parts.length - 1; i++) { + const k = parts[i]!; + if (typeof cur[k] !== "object" || cur[k] === null) cur[k] = {}; + cur = cur[k] as Record; + } + cur[parts[parts.length - 1]!] = value; + }, +})); + +mock.module("../config/schemas/services.js", () => ({ + getServiceMode: (_services: unknown, key: string) => + mockServiceModes[key] ?? "your-own", + ServicesSchema: { + shape: { + "google-oauth": true, + "byo-only": true, + }, + }, +})); + +import { BadRequestError, InternalError, NotFoundError } from "../runtime/routes/errors.js"; +import { ROUTES } from "../runtime/routes/oauth-commands-routes.js"; +import type { RouteHandlerArgs } from "../runtime/routes/types.js"; + +function getRoute(method: string, endpoint: string) { + const route = ROUTES.find( + (r) => r.method === method && r.endpoint === endpoint, + ); + if (!route) throw new Error(`Route not found: ${method} ${endpoint}`); + return route; +} + +function makeArgs( + opts: { + pathParams?: Record; + queryParams?: Record; + body?: Record; + } = {}, +): RouteHandlerArgs { + return { + pathParams: opts.pathParams, + queryParams: opts.queryParams, + body: opts.body, + }; +} + +beforeEach(() => { + mockProviders = { google: { ...baseProvider } }; + mockServiceModes = {}; + mockActiveConnectionsByProvider = {}; + mockAllConnections = {}; + mockApps = {}; + mockTokenValue = "tok-fake"; + platformAvailable = true; + platformAssistantId = "assistant-1"; + mockFetchImpl = async () => ({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => "", + }); + mockResolveResponse = { status: 200, headers: {}, body: { ok: true } }; + mockDisconnectOAuthProvider.mockClear(); + mockSaveRawConfig.mockClear(); +}); + +// --------------------------------------------------------------------------- +// Route registry — establishes that all 9 endpoints are wired correctly. +// --------------------------------------------------------------------------- + +describe("oauth-commands-routes route registry", () => { + test("registers all 9 IPC endpoints", () => { + const ops = ROUTES.map((r) => r.operationId).sort(); + expect(ops).toEqual([ + "oauth_disconnect", + "oauth_managed_connect_poll", + "oauth_managed_connect_start", + "oauth_mode_get", + "oauth_mode_set", + "oauth_ping", + "oauth_request", + "oauth_status", + "oauth_token", + ]); + }); + + test("every route enforces policy", () => { + for (const route of ROUTES) { + expect(route.requirePolicyEnforcement).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// POST oauth/disconnect +// --------------------------------------------------------------------------- + +describe("POST oauth/disconnect", () => { + test("rejects missing provider", async () => { + await expect( + getRoute("POST", "oauth/disconnect").handler(makeArgs({ body: {} })), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("rejects unknown provider", async () => { + await expect( + getRoute("POST", "oauth/disconnect").handler( + makeArgs({ body: { provider: "unknown" } }), + ), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + test("rejects both account and connection_id", async () => { + await expect( + getRoute("POST", "oauth/disconnect").handler( + makeArgs({ + body: { + provider: "google", + account: "alice@example.com", + connection_id: "conn-1", + }, + }), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("BYO mode disconnects via oauth-store", async () => { + mockActiveConnectionsByProvider.google = [ + { id: "conn-1", accountInfo: "alice@example.com" }, + ]; + const result = (await getRoute("POST", "oauth/disconnect").handler( + makeArgs({ body: { provider: "google" } }), + )) as { ok: boolean; connectionId: string }; + expect(result.ok).toBe(true); + expect(result.connectionId).toBe("conn-1"); + expect(mockDisconnectOAuthProvider).toHaveBeenCalledTimes(1); + }); + + test("managed mode with no active connections raises NotFound", async () => { + mockServiceModes["google-oauth"] = "managed"; + mockFetchImpl = async () => ({ + ok: true, + status: 200, + json: async () => [], + text: async () => "[]", + }); + await expect( + getRoute("POST", "oauth/disconnect").handler( + makeArgs({ body: { provider: "google" } }), + ), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + test("managed mode with multiple connections demands disambiguation", async () => { + mockServiceModes["google-oauth"] = "managed"; + mockFetchImpl = async () => ({ + ok: true, + status: 200, + json: async () => [ + { id: "conn-a", account_label: "a@example.com" }, + { id: "conn-b", account_label: "b@example.com" }, + ], + text: async () => "", + }); + await expect( + getRoute("POST", "oauth/disconnect").handler( + makeArgs({ body: { provider: "google" } }), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); +}); + +// --------------------------------------------------------------------------- +// GET oauth/mode +// --------------------------------------------------------------------------- + +describe("GET oauth/mode", () => { + test("rejects missing provider", () => { + // handleModeGet is synchronous — use toThrow rather than rejects. + expect(() => + getRoute("GET", "oauth/mode").handler(makeArgs({ queryParams: {} })), + ).toThrow(BadRequestError); + }); + + test("returns managed-supported provider mode", async () => { + mockServiceModes["google-oauth"] = "managed"; + const result = (await getRoute("GET", "oauth/mode").handler( + makeArgs({ queryParams: { provider: "google" } }), + )) as { mode: string; managedModeSupported: boolean }; + expect(result.mode).toBe("managed"); + expect(result.managedModeSupported).toBe(true); + }); + + test("BYO-only provider returns your-own with managedModeSupported=false", async () => { + mockProviders.byo = { + ...baseProvider, + provider: "byo", + managedServiceConfigKey: null, + }; + const result = (await getRoute("GET", "oauth/mode").handler( + makeArgs({ queryParams: { provider: "byo" } }), + )) as { mode: string; managedModeSupported: boolean }; + expect(result.mode).toBe("your-own"); + expect(result.managedModeSupported).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// POST oauth/mode +// --------------------------------------------------------------------------- + +describe("POST oauth/mode", () => { + test("rejects invalid mode value", async () => { + await expect( + getRoute("POST", "oauth/mode").handler( + makeArgs({ body: { provider: "google", mode: "bogus" } }), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("rejects switching to managed on BYO-only provider", async () => { + mockProviders.byo = { + ...baseProvider, + provider: "byo", + managedServiceConfigKey: null, + }; + await expect( + getRoute("POST", "oauth/mode").handler( + makeArgs({ body: { provider: "byo", mode: "managed" } }), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("switching to your-own on BYO-only provider is a no-op success", async () => { + mockProviders.byo = { + ...baseProvider, + provider: "byo", + managedServiceConfigKey: null, + }; + const result = (await getRoute("POST", "oauth/mode").handler( + makeArgs({ body: { provider: "byo", mode: "your-own" } }), + )) as { changed: boolean }; + expect(result.changed).toBe(false); + expect(mockSaveRawConfig).not.toHaveBeenCalled(); + }); + + test("requires platform connection when switching to managed", async () => { + platformAvailable = false; + await expect( + getRoute("POST", "oauth/mode").handler( + makeArgs({ body: { provider: "google", mode: "managed" } }), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("persists mode change when current differs from new", async () => { + mockServiceModes["google-oauth"] = "your-own"; + const result = (await getRoute("POST", "oauth/mode").handler( + makeArgs({ body: { provider: "google", mode: "managed" } }), + )) as { changed: boolean }; + expect(result.changed).toBe(true); + expect(mockSaveRawConfig).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// GET oauth/status +// --------------------------------------------------------------------------- + +describe("GET oauth/status", () => { + test("rejects missing provider", async () => { + await expect( + getRoute("GET", "oauth/status").handler(makeArgs({ queryParams: {} })), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("BYO mode surfaces active connections with parsed scopes", async () => { + mockAllConnections.google = [ + { + id: "conn-1", + accountInfo: "alice@example.com", + grantedScopes: '["email","profile"]', + status: "active", + hasRefreshToken: 1, + expiresAt: 1735689600000, + }, + { + // Inactive row should be filtered out + id: "conn-2", + accountInfo: null, + grantedScopes: null, + status: "revoked", + hasRefreshToken: 0, + expiresAt: null, + }, + ]; + const result = (await getRoute("GET", "oauth/status").handler( + makeArgs({ queryParams: { provider: "google" } }), + )) as { mode: string; connections: Array<{ id: string; grantedScopes: string[] }> }; + expect(result.mode).toBe("byo"); + expect(result.connections).toHaveLength(1); + expect(result.connections[0]!.id).toBe("conn-1"); + expect(result.connections[0]!.grantedScopes).toEqual(["email", "profile"]); + }); + + test("malformed grantedScopes JSON defaults to empty", async () => { + mockAllConnections.google = [ + { + id: "conn-bad", + accountInfo: null, + grantedScopes: "not-json", + status: "active", + hasRefreshToken: 0, + expiresAt: null, + }, + ]; + const result = (await getRoute("GET", "oauth/status").handler( + makeArgs({ queryParams: { provider: "google" } }), + )) as { connections: Array<{ grantedScopes: string[] }> }; + expect(result.connections[0]!.grantedScopes).toEqual([]); + }); + + test("managed mode surfaces platform connections", async () => { + mockServiceModes["google-oauth"] = "managed"; + mockFetchImpl = async () => ({ + ok: true, + status: 200, + json: async () => [ + { + id: "conn-platform", + account_label: "alice@example.com", + scopes_granted: ["email"], + status: "ACTIVE", + }, + ], + text: async () => "", + }); + const result = (await getRoute("GET", "oauth/status").handler( + makeArgs({ queryParams: { provider: "google" } }), + )) as { mode: string; connections: Array<{ id: string }> }; + expect(result.mode).toBe("managed"); + expect(result.connections[0]!.id).toBe("conn-platform"); + }); +}); + +// --------------------------------------------------------------------------- +// POST oauth/ping +// --------------------------------------------------------------------------- + +describe("POST oauth/ping", () => { + test("rejects provider without configured pingUrl", async () => { + await expect( + getRoute("POST", "oauth/ping").handler( + makeArgs({ body: { provider: "google" } }), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("returns ok=true for 2xx response", async () => { + mockProviders.google = { + ...baseProvider, + pingUrl: "https://api.google.com/v1/me", + }; + mockResolveResponse = { status: 200, headers: {}, body: { ok: true } }; + const result = (await getRoute("POST", "oauth/ping").handler( + makeArgs({ body: { provider: "google" } }), + )) as { ok: boolean; provider: string; status: number }; + expect(result).toEqual({ ok: true, provider: "google", status: 200 }); + }); + + test("returns ok=false with reconnect hint on 401", async () => { + mockProviders.google = { + ...baseProvider, + pingUrl: "https://api.google.com/v1/me", + }; + mockResolveResponse = { status: 401, headers: {}, body: { error: "unauthorized" } }; + const result = (await getRoute("POST", "oauth/ping").handler( + makeArgs({ body: { provider: "google" } }), + )) as { ok: boolean; status: number; hint?: string }; + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + expect(result.hint).toContain("oauth connect"); + }); +}); + +// --------------------------------------------------------------------------- +// POST oauth/token +// --------------------------------------------------------------------------- + +describe("POST oauth/token", () => { + test("rejects managed-mode providers", async () => { + mockServiceModes["google-oauth"] = "managed"; + await expect( + getRoute("POST", "oauth/token").handler( + makeArgs({ body: { provider: "google" } }), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("returns token from withValidToken in BYO mode", async () => { + mockTokenValue = "tok-real"; + const result = (await getRoute("POST", "oauth/token").handler( + makeArgs({ body: { provider: "google" } }), + )) as { ok: boolean; token: string }; + expect(result).toEqual({ ok: true, token: "tok-real" }); + }); + + test("rejects when account is given but no matching connection", async () => { + // No active connections registered for google + await expect( + getRoute("POST", "oauth/token").handler( + makeArgs({ body: { provider: "google", account: "missing@example.com" } }), + ), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +// --------------------------------------------------------------------------- +// POST oauth/request +// --------------------------------------------------------------------------- + +describe("POST oauth/request", () => { + test("rejects missing url", async () => { + await expect( + getRoute("POST", "oauth/request").handler( + makeArgs({ body: { provider: "google" } }), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("happy-path GET returns response payload", async () => { + mockResolveResponse = { + status: 200, + headers: { "content-type": "application/json" }, + body: { hello: "world" }, + }; + const result = (await getRoute("POST", "oauth/request").handler( + makeArgs({ + body: { provider: "google", url: "https://api.google.com/v1/me" }, + }), + )) as { ok: boolean; status: number; body: unknown }; + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + expect(result.body).toEqual({ hello: "world" }); + }); + + test("attaches reconnect hint on 401 response", async () => { + mockResolveResponse = { status: 401, headers: {}, body: { error: "no" } }; + const result = (await getRoute("POST", "oauth/request").handler( + makeArgs({ + body: { provider: "google", url: "https://api.google.com/v1/me" }, + }), + )) as { ok: boolean; hint?: string }; + expect(result.ok).toBe(false); + expect(result.hint).toContain("oauth status"); + }); + + test("rejects unregistered client_id in BYO mode", async () => { + // No entry in mockApps for google:client-x + await expect( + getRoute("POST", "oauth/request").handler( + makeArgs({ + body: { + provider: "google", + url: "https://api.google.com/v1/me", + client_id: "client-x", + }, + }), + ), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +// --------------------------------------------------------------------------- +// POST oauth/managed-connect/start +// --------------------------------------------------------------------------- + +describe("POST oauth/managed-connect/start", () => { + test("returns connect_url on platform 200", async () => { + mockFetchImpl = async () => ({ + ok: true, + status: 200, + json: async () => ({ connect_url: "https://app.vellum.ai/connect/abc" }), + text: async () => "", + }); + const result = (await getRoute( + "POST", + "oauth/managed-connect/start", + ).handler( + makeArgs({ body: { provider: "google", scopes: ["email"] } }), + )) as { ok: boolean; connect_url: string }; + expect(result.connect_url).toBe("https://app.vellum.ai/connect/abc"); + }); + + test("raises InternalError when platform returns 401", async () => { + mockFetchImpl = async () => ({ + ok: false, + status: 401, + json: async () => ({}), + text: async () => "unauthorized", + }); + await expect( + getRoute("POST", "oauth/managed-connect/start").handler( + makeArgs({ body: { provider: "google" } }), + ), + ).rejects.toBeInstanceOf(InternalError); + }); + + test("raises InternalError when platform omits connect_url", async () => { + mockFetchImpl = async () => ({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => "", + }); + await expect( + getRoute("POST", "oauth/managed-connect/start").handler( + makeArgs({ body: { provider: "google" } }), + ), + ).rejects.toBeInstanceOf(InternalError); + }); +}); + +// --------------------------------------------------------------------------- +// GET oauth/managed-connect/poll +// --------------------------------------------------------------------------- + +describe("GET oauth/managed-connect/poll", () => { + test("rejects missing provider", async () => { + await expect( + getRoute("GET", "oauth/managed-connect/poll").handler( + makeArgs({ queryParams: {} }), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("returns platform connections list", async () => { + mockFetchImpl = async () => ({ + ok: true, + status: 200, + json: async () => [ + { + id: "conn-1", + account_label: "alice@example.com", + scopes_granted: ["email"], + }, + ], + text: async () => "", + }); + const result = (await getRoute( + "GET", + "oauth/managed-connect/poll", + ).handler( + makeArgs({ queryParams: { provider: "google" } }), + )) as { + ok: boolean; + connections: Array<{ + id: string; + account_label: string | null; + scopes_granted: string[]; + }>; + }; + expect(result.ok).toBe(true); + expect(result.connections).toEqual([ + { id: "conn-1", account_label: "alice@example.com", scopes_granted: ["email"] }, + ]); + }); + + test("raises BadRequestError when platform unavailable", async () => { + platformAvailable = false; + await expect( + getRoute("GET", "oauth/managed-connect/poll").handler( + makeArgs({ queryParams: { provider: "google" } }), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); +}); diff --git a/assistant/src/__tests__/usage-cli.test.ts b/assistant/src/__tests__/usage-cli.test.ts index 6efc41d7bfe..a2dc164c938 100644 --- a/assistant/src/__tests__/usage-cli.test.ts +++ b/assistant/src/__tests__/usage-cli.test.ts @@ -1,3 +1,14 @@ +/** + * CLI plumbing tests for `assistant usage` (cli/commands/usage.ts). + * + * The `usage breakdown` subcommand is daemon-mediated via `cliIpcCall`; only + * argument validation runs in the CLI process. Table formatting and + * aggregation behavior are covered daemon-side in `usage-routes.test.ts`. + * + * Follow-up opportunity: mock `../ipc/cli-client.js` and assert CLI plumbing + * (table formatting, --json output shape) against canned IPC responses. + */ + import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Command } from "commander"; @@ -19,13 +30,8 @@ mock.module("../util/logger.js", () => ({ }), })); -const { initializeDb } = await import("../memory/db-init.js"); -const { getDb } = await import("../memory/db-connection.js"); -const { recordUsageEvent } = await import("../memory/llm-usage-store.js"); const { registerUsageCommand } = await import("../cli/commands/usage.js"); -initializeDb(); - async function runCommand(args: string[]): Promise<{ exitCode: number; output: string; @@ -54,86 +60,9 @@ async function runCommand(args: string[]): Promise<{ return { exitCode, output: logLines.join("\n") }; } -function insertUsage( - overrides: Partial[0]>, - estimatedCostUsd = 0.01, -): void { - recordUsageEvent( - { - conversationId: null, - runId: null, - requestId: null, - actor: "main_agent", - provider: "anthropic", - model: "claude-sonnet-4-20250514", - inputTokens: 100, - outputTokens: 50, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - ...overrides, - }, - { estimatedCostUsd, pricingStatus: "priced" }, - ); -} - describe("assistant usage CLI", () => { beforeEach(() => { logLines.length = 0; - getDb().run("DELETE FROM llm_usage_events"); - }); - - // TODO(IPC test rewrite): this test depends on usage-cli's old - // direct-DB path; CLI now goes through cliIpcCall for breakdown. - // Re-enable when test mocks the IPC layer with breakdown response. - test.skip("breakdown JSON includes call-site display labels and groupKey", async () => { - insertUsage({ callSite: "mainAgent" }); - insertUsage({ callSite: null, inputTokens: 200 }, 0.005); - - const result = await runCommand([ - "usage", - "breakdown", - "--range", - "all", - "--group-by", - "call_site", - "--json", - ]); - - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.output) as { - breakdown: Array<{ group: string; groupKey: string | null }>; - }; - expect(parsed.breakdown.map((row) => row.group)).toEqual([ - "Main Agent", - "Unknown Task", - ]); - expect(parsed.breakdown.map((row) => row.groupKey)).toEqual([ - "mainAgent", - null, - ]); - }); - - // TODO(IPC test rewrite): usage breakdown CLI now goes through - // cliIpcCall to the daemon for data fetching. Without an IPC mock - // this test hits the real exitFromIpcResult and exits with code 10 - // in CI (no daemon). Re-enable after the test mocks - // '../ipc/cli-client.js' or the test is rewritten to assert pure - // CLI plumbing (arg parsing, table formatting on canned data). - test.skip("breakdown table prints friendly profile fallback labels", async () => { - insertUsage({ inferenceProfile: null }); - - const result = await runCommand([ - "usage", - "breakdown", - "--range", - "all", - "--group-by", - "inference_profile", - ]); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("PROFILE"); - expect(result.output).toContain("Default / Unset"); }); test("rejects invalid breakdown dimensions", async () => { diff --git a/assistant/src/cli/commands/__tests__/inference-send.test.ts b/assistant/src/cli/commands/__tests__/inference-send.test.ts index 4004620229c..b3fde5b62e9 100644 --- a/assistant/src/cli/commands/__tests__/inference-send.test.ts +++ b/assistant/src/cli/commands/__tests__/inference-send.test.ts @@ -1,17 +1,13 @@ /** - * Tests for the `assistant inference send` and `assistant llm send` CLI - * commands. + * CLI plumbing tests for `assistant inference send` and the `llm send` alias. * - * Validates: - * - Help text renders for both `inference send` and `llm send` - * - Error when no LLM provider is configured - * - Error when no message is provided (no args, no stdin) - * - Success with mocked provider (response text on stdout) - * - `--system-prompt` is passed through to the provider call - * - `--json` output format - * - `--model` override is passed through - * - `--profile` is validated and threaded through as an `overrideProfile` - * - `llm send` produces the same result as `inference send` + * The actual `sendMessage` call runs inside the daemon; the CLI shells out + * via `cliIpcCall(...)`. Tests here cover pure CLI surface concerns: help + * rendering, argument validation, and the no-message guard. They run + * entirely inside the CLI process and need no daemon stub. + * + * Follow-up opportunity: mock `../../../ipc/cli-client.js` with canned + * responses to cover the deeper send-message paths against the IPC contract. */ import { @@ -22,117 +18,34 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Command } from "commander"; -import type { - Message, - Provider, - ProviderResponse, - SendMessageOptions, - ToolDefinition, -} from "../../../providers/types.js"; - // --------------------------------------------------------------------------- // Mock state // --------------------------------------------------------------------------- -/** Whether `getConfiguredProvider` returns a mock provider or null. */ -let mockProviderAvailable = true; - -/** The response the mock provider will return. */ -let mockProviderResponse: ProviderResponse = { - content: [{ type: "text", text: "42" }], - model: "claude-test-1", - usage: { inputTokens: 10, outputTokens: 5 }, - stopReason: "end_turn", -}; - -/** Captures the last `sendMessage` call for assertions. */ -let lastSendMessageCall: { - messages: Message[]; - tools?: ToolDefinition[]; - systemPrompt?: string; - options?: SendMessageOptions; -} | null = null; - -/** Captures the last `getConfiguredProvider` call for assertions. */ -let lastGetConfiguredProviderCall: { - callSite: string; - opts: { overrideProfile?: string } | undefined; -} | null = null; - -/** Simulated stdin content for the next command run. */ let mockStdinContent: string | null = null; -/** Mock profile catalog returned by the mocked `getConfigReadOnly`. */ -let mockProfileCatalog: Record = { - balanced: { modelId: "claude-test-1" }, - "opus-thinking": { modelId: "claude-opus-4-7" }, -}; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -const mockProvider: Provider = { - name: "mock-provider", - sendMessage: async ( - messages: Message[], - tools?: ToolDefinition[], - systemPrompt?: string, - options?: SendMessageOptions, - ) => { - lastSendMessageCall = { messages, tools, systemPrompt, options }; - return mockProviderResponse; - }, -}; - mock.module("../../../providers/provider-send-message.js", () => ({ - getConfiguredProvider: async ( - callSite: string, - opts?: { overrideProfile?: string }, - ) => { - lastGetConfiguredProviderCall = { callSite, opts }; - return mockProviderAvailable ? mockProvider : null; - }, - extractAllText: (response: ProviderResponse) => { - return response.content - .filter( - ( - b, - ): b is Extract<(typeof response.content)[number], { type: "text" }> => - b.type === "text", - ) - .map((b) => b.text) - .join(" "); - }, - userMessage: (text: string): Message => ({ - role: "user", - content: [{ type: "text", text }], - }), + // The handler under test calls getConfiguredProvider before any of the + // validation paths exercised here are reached. Return a stub so module + // loads cleanly even though no test actually drives a request. + getConfiguredProvider: async () => null, + extractAllText: () => "", + userMessage: (text: string) => ({ role: "user", content: [{ type: "text", text }] }), })); mock.module("../../../config/loader.js", () => ({ - getConfig: () => ({ llm: { profiles: mockProfileCatalog } }), - getConfigReadOnly: () => ({ llm: { profiles: mockProfileCatalog } }), - loadConfig: () => ({ llm: { profiles: mockProfileCatalog } }), + getConfig: () => ({ llm: { profiles: {} } }), + getConfigReadOnly: () => ({ llm: { profiles: {} } }), + loadConfig: () => ({ llm: { profiles: {} } }), loadRawConfig: () => ({}) as Record, saveRawConfig: () => {}, invalidateConfigCache: () => {}, - applyNestedDefaults: () => ({ llm: { profiles: mockProfileCatalog } }), + applyNestedDefaults: () => ({ llm: { profiles: {} } }), })); mock.module("../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), + getLogger: () => ({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }), + getCliLogger: () => ({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }), })); mock.module("node:fs", () => ({ @@ -148,10 +61,6 @@ mock.module("node:fs", () => ({ existsSync: actualExistsSync, })); -// --------------------------------------------------------------------------- -// Import module under test (after mocks) -// --------------------------------------------------------------------------- - const { registerInferenceCommand } = await import("../inference.js"); // --------------------------------------------------------------------------- @@ -216,25 +125,8 @@ async function runCommand( }; } -// --------------------------------------------------------------------------- -// Setup -// --------------------------------------------------------------------------- - beforeEach(() => { - mockProviderAvailable = true; - mockProviderResponse = { - content: [{ type: "text", text: "42" }], - model: "claude-test-1", - usage: { inputTokens: 10, outputTokens: 5 }, - stopReason: "end_turn", - }; - lastSendMessageCall = null; - lastGetConfiguredProviderCall = null; mockStdinContent = null; - mockProfileCatalog = { - balanced: { modelId: "claude-test-1" }, - "opus-thinking": { modelId: "claude-opus-4-7" }, - }; process.exitCode = 0; }); @@ -279,37 +171,7 @@ describe("help text", () => { }); // --------------------------------------------------------------------------- -// Error: no provider configured -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("no provider configured", () => { - test("exits with code 1 and actionable error when no provider", async () => { - mockProviderAvailable = false; - - const { exitCode, stdout } = await runCommand([ - "inference", - "send", - "Hello", - "--json", - ]); - - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No LLM provider is configured"); - expect(parsed.error).toContain("assistant config set"); - }); -}); - -// --------------------------------------------------------------------------- -// Error: no message provided +// No message provided // --------------------------------------------------------------------------- describe("no message provided", () => { @@ -343,164 +205,10 @@ describe("no message provided", () => { }); // --------------------------------------------------------------------------- -// Success: positional args -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("success with positional args", () => { - test("sends message and prints response text", async () => { - const { exitCode, stdout } = await runCommand([ - "inference", - "send", - "What", - "is", - "2+2?", - ]); - - expect(exitCode).toBe(0); - expect(stdout).toContain("42"); - expect(lastSendMessageCall).toBeDefined(); - expect(lastSendMessageCall!.messages[0].content[0]).toEqual({ - type: "text", - text: "What is 2+2?", - }); - }); -}); - -// --------------------------------------------------------------------------- -// Success: stdin -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("success with stdin", () => { - test("reads message from stdin when no positional args", async () => { - mockStdinContent = "What is 2+2?"; - - const { exitCode, stdout } = await runCommand(["inference", "send"]); - - expect(exitCode).toBe(0); - expect(stdout).toContain("42"); - expect(lastSendMessageCall).toBeDefined(); - expect(lastSendMessageCall!.messages[0].content[0]).toEqual({ - type: "text", - text: "What is 2+2?", - }); - }); -}); - -// --------------------------------------------------------------------------- -// --system-prompt -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("--system-prompt", () => { - test("passes system prompt through to provider", async () => { - await runCommand([ - "inference", - "send", - "--system-prompt", - "You are a poet", - "Write a haiku", - ]); - - expect(lastSendMessageCall).toBeDefined(); - expect(lastSendMessageCall!.systemPrompt).toBe("You are a poet"); - }); -}); - -// --------------------------------------------------------------------------- -// --json output -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("--json output", () => { - test("produces structured JSON with response, model, and usage", async () => { - const { exitCode, stdout } = await runCommand([ - "inference", - "send", - "--json", - "Hello", - ]); - - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.response).toBe("42"); - expect(parsed.model).toBe("claude-test-1"); - expect(parsed.usage).toEqual({ - inputTokens: 10, - outputTokens: 5, - }); - }); -}); - -// --------------------------------------------------------------------------- -// --model override -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("--model override", () => { - test("passes model override through to provider config", async () => { - await runCommand([ - "inference", - "send", - "--model", - "claude-sonnet-4-20250514", - "Hello", - ]); - - expect(lastSendMessageCall).toBeDefined(); - expect(lastSendMessageCall!.options?.config?.model).toBe( - "claude-sonnet-4-20250514", - ); - }); -}); - -// --------------------------------------------------------------------------- -// --max-tokens +// --max-tokens validation // --------------------------------------------------------------------------- describe("--max-tokens", () => { - // TODO(IPC test rewrite): CLI now calls cliIpcCall(...) for - // provider send-message; lastSendMessageCall is never populated. - // Re-enable when test mocks '../../../ipc/cli-client.js'. - test.skip("passes max tokens through to provider config", async () => { - await runCommand(["inference", "send", "--max-tokens", "1024", "Hello"]); - - expect(lastSendMessageCall).toBeDefined(); - expect(lastSendMessageCall!.options?.config?.max_tokens).toBe(1024); - }); - test("errors on invalid max-tokens value", async () => { const { exitCode, stdout } = await runCommand([ "inference", @@ -517,126 +225,3 @@ describe("--max-tokens", () => { expect(parsed.error).toContain("Invalid --max-tokens"); }); }); - -// --------------------------------------------------------------------------- -// --profile override -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("--profile override", () => { - test("threads valid profile through to getConfiguredProvider", async () => { - const { exitCode } = await runCommand([ - "inference", - "send", - "--profile", - "opus-thinking", - "Hello", - ]); - - expect(exitCode).toBe(0); - expect(lastGetConfiguredProviderCall).toBeDefined(); - expect(lastGetConfiguredProviderCall!.callSite).toBe("inference"); - expect(lastGetConfiguredProviderCall!.opts?.overrideProfile).toBe( - "opus-thinking", - ); - }); - - test("omits overrideProfile when --profile is not passed", async () => { - const { exitCode } = await runCommand(["inference", "send", "Hello"]); - - expect(exitCode).toBe(0); - expect(lastGetConfiguredProviderCall).toBeDefined(); - expect(lastGetConfiguredProviderCall!.opts?.overrideProfile).toBeUndefined(); - }); - - test("rejects unknown profile with helpful error and lists available", async () => { - const { exitCode, stdout } = await runCommand([ - "inference", - "send", - "--profile", - "nonexistent", - "--json", - "Hello", - ]); - - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain('Profile "nonexistent" is not defined'); - expect(parsed.error).toContain("balanced"); - expect(parsed.error).toContain("opus-thinking"); - // Provider should NOT have been resolved when validation fails. - expect(lastGetConfiguredProviderCall).toBeNull(); - }); - - test("rejects unknown profile when no profiles are defined", async () => { - mockProfileCatalog = {}; - - const { exitCode, stdout } = await runCommand([ - "inference", - "send", - "--profile", - "balanced", - "--json", - "Hello", - ]); - - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain('Profile "balanced" is not defined'); - expect(parsed.error).toContain("No profiles defined"); - }); - - test("--profile works on the llm alias", async () => { - const { exitCode } = await runCommand([ - "llm", - "send", - "--profile", - "balanced", - "Hello", - ]); - - expect(exitCode).toBe(0); - expect(lastGetConfiguredProviderCall!.opts?.overrideProfile).toBe( - "balanced", - ); - }); -}); - -// --------------------------------------------------------------------------- -// llm alias equivalence -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("llm alias", () => { - test("llm send produces the same result as inference send", async () => { - const inferenceResult = await runCommand([ - "inference", - "send", - "--json", - "Hello", - ]); - - // Reset for the second call - lastSendMessageCall = null; - - const llmResult = await runCommand(["llm", "send", "--json", "Hello"]); - - expect(inferenceResult.exitCode).toBe(0); - expect(llmResult.exitCode).toBe(0); - expect(inferenceResult.stdout).toBe(llmResult.stdout); - }); -}); diff --git a/assistant/src/cli/commands/__tests__/routes.test.ts b/assistant/src/cli/commands/__tests__/routes.test.ts deleted file mode 100644 index 660efe6d59e..00000000000 --- a/assistant/src/cli/commands/__tests__/routes.test.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -import { getWorkspaceRoutesDir } from "../../../util/platform.js"; - -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockPublicBaseUrl: string | null = null; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -mock.module("../../../config/loader.js", () => ({ - getConfig: () => ({ - ingress: mockPublicBaseUrl - ? { publicBaseUrl: mockPublicBaseUrl } - : undefined, - }), -})); - -mock.module("../../../inbound/public-ingress-urls.js", () => ({ - getPublicBaseUrl: (config: { ingress?: { publicBaseUrl?: string } }) => { - const url = config.ingress?.publicBaseUrl; - if (!url) throw new Error("No public base URL configured"); - return url; - }, -})); - -mock.module("../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), -})); - -// --------------------------------------------------------------------------- -// Import module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerRoutesCommand } = await import("../routes.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCommand( - args: string[], -): Promise<{ stdout: string; exitCode: number }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const originalConsoleLog = console.log.bind(console); - const stdoutChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = (() => true) as typeof process.stderr.write; - - console.log = (...logArgs: unknown[]) => { - stdoutChunks.push( - logArgs.map((a) => (typeof a === "string" ? a : String(a))).join(" ") + - "\n", - ); - }; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerRoutesCommand(program); - await program.parseAsync(["node", "assistant", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - console.log = originalConsoleLog; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { exitCode, stdout: stdoutChunks.join("") }; -} - -// --------------------------------------------------------------------------- -// Helpers for writing handler files into the workspace routes dir -// --------------------------------------------------------------------------- - -let routesDir: string; - -function writeHandler(relativePath: string, content: string): void { - const fullPath = join(routesDir, relativePath); - const dir = fullPath.substring(0, fullPath.lastIndexOf("/")); - mkdirSync(dir, { recursive: true }); - writeFileSync(fullPath, content, "utf-8"); -} - -// --------------------------------------------------------------------------- -// Setup / teardown -// --------------------------------------------------------------------------- - -beforeEach(() => { - routesDir = getWorkspaceRoutesDir(); - mkdirSync(routesDir, { recursive: true }); - mockPublicBaseUrl = null; - process.exitCode = 0; -}); - -afterEach(() => { - try { - rmSync(routesDir, { recursive: true, force: true }); - } catch { - /* best-effort cleanup */ - } -}); - -// --------------------------------------------------------------------------- -// routes list -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant routes list", () => { - test("empty routes dir returns zero routes in JSON", async () => { - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.routes).toEqual([]); - }); - - test("empty routes dir shows guidance in human output", async () => { - const { exitCode } = await runCommand(["routes", "list"]); - expect(exitCode).toBe(0); - }); - - test("discovers a single GET handler", async () => { - writeHandler( - "status.ts", - `export async function GET(req: Request) { return new Response("ok"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.routes).toHaveLength(1); - expect(parsed.routes[0].routePath).toBe("/x/status"); - expect(parsed.routes[0].methods).toEqual(["GET"]); - }); - - test("discovers multiple routes sorted alphabetically", async () => { - writeHandler( - "zebra.ts", - `export function GET() { return new Response("z"); }`, - ); - writeHandler( - "alpha.ts", - `export function POST() { return new Response("a"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes).toHaveLength(2); - expect(parsed.routes[0].routePath).toBe("/x/alpha"); - expect(parsed.routes[1].routePath).toBe("/x/zebra"); - }); - - test("discovers multi-method handler", async () => { - writeHandler( - "items.ts", - [ - `export function GET() { return new Response("list"); }`, - `export function POST() { return new Response("create"); }`, - `export function DELETE() { return new Response("remove"); }`, - ].join("\n"), - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes[0].methods).toEqual(["GET", "POST", "DELETE"]); - }); - - test("discovers index file as directory route", async () => { - writeHandler( - "my-app/index.ts", - `export function GET() { return new Response("app"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes).toHaveLength(1); - expect(parsed.routes[0].routePath).toBe("/x/my-app"); - }); - - test("discovers subdirectory routes", async () => { - writeHandler( - "api/v1/users.ts", - `export function GET() { return new Response("users"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes[0].routePath).toBe("/x/api/v1/users"); - }); - - test("discovers .js handlers", async () => { - writeHandler( - "health.js", - `export function GET() { return new Response("ok"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes).toHaveLength(1); - expect(parsed.routes[0].routePath).toBe("/x/health"); - }); - - test("extracts description export", async () => { - writeHandler( - "submit.ts", - [ - `export const description = "Form submission handler";`, - `export function POST() { return new Response("ok"); }`, - ].join("\n"), - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes[0].description).toBe("Form submission handler"); - }); - - test("null description when not exported", async () => { - writeHandler( - "simple.ts", - `export function GET() { return new Response("ok"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes[0].description).toBeNull(); - }); - - test("includes publicUrl when public base URL is configured", async () => { - mockPublicBaseUrl = "https://example.ngrok-free.app/v1/assistants/asst_xyz"; - writeHandler( - "status.ts", - `export function GET() { return new Response("ok"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes[0].publicUrl).toBe( - "https://example.ngrok-free.app/v1/assistants/asst_xyz/x/status", - ); - }); - - test("publicUrl is null when no public base URL configured", async () => { - mockPublicBaseUrl = null; - writeHandler( - "status.ts", - `export function GET() { return new Response("ok"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes[0].publicUrl).toBeNull(); - }); - - test("ignores non-handler files", async () => { - writeHandler("readme.md", "# Routes\nDocumentation file"); - writeHandler( - "handler.ts", - `export function GET() { return new Response("ok"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes).toHaveLength(1); - expect(parsed.routes[0].routePath).toBe("/x/handler"); - }); - - test("human output runs without error for populated routes", async () => { - writeHandler( - "status.ts", - `export function GET() { return new Response("ok"); }`, - ); - - const { exitCode } = await runCommand(["routes", "list"]); - expect(exitCode).toBe(0); - }); - - test("root index file maps to /x/", async () => { - writeHandler( - "index.ts", - `export function GET() { return new Response("root"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes).toHaveLength(1); - expect(parsed.routes[0].routePath).toBe("/x/"); - }); - - test("JSON output includes filePath relative to routes dir", async () => { - writeHandler( - "api/submit.ts", - `export function POST() { return new Response("ok"); }`, - ); - - const { exitCode, stdout } = await runCommand(["routes", "list", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.routes[0].filePath).toBe("api/submit.ts"); - }); -}); - -// --------------------------------------------------------------------------- -// routes inspect -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): mocks target pre-IPC code paths that the -// CLI no longer calls directly. After the CLI IPC migration -// (#30238-#30251) the CLI now calls cliIpcCall(...) and the daemon -// route handlers execute the actual work. Tests need to be rewritten -// to mock '../ipc/cli-client.js' with canned IPC responses and adjust -// assertions to the new error-path output. Daemon-side route handler -// tests already exercise the work; CLI tests are now CLI plumbing. -describe.skip("assistant routes inspect", () => { - test("inspects a handler by route path (JSON)", async () => { - writeHandler( - "status.ts", - [ - `export const description = "Health check endpoint";`, - `export function GET() { return new Response("ok"); }`, - `export function POST() { return new Response("created"); }`, - ].join("\n"), - ); - - const { exitCode, stdout } = await runCommand([ - "routes", - "inspect", - "status", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.route.routePath).toBe("/x/status"); - expect(parsed.route.methods).toEqual(["GET", "POST"]); - expect(parsed.route.description).toBe("Health check endpoint"); - expect(parsed.route.filePath).toContain("status.ts"); - expect(parsed.route.fileSize).toBeGreaterThan(0); - expect(parsed.route.modifiedAt).toBeTruthy(); - }); - - test("inspect resolves index file convention", async () => { - writeHandler( - "dashboard/index.ts", - `export function GET() { return new Response("dashboard"); }`, - ); - - const { exitCode, stdout } = await runCommand([ - "routes", - "inspect", - "dashboard", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.route.routePath).toBe("/x/dashboard"); - expect(parsed.route.methods).toEqual(["GET"]); - expect(parsed.route.filePath).toContain("index.ts"); - }); - - test("inspect resolves .js files", async () => { - writeHandler( - "legacy.js", - `export function POST() { return new Response("ok"); }`, - ); - - const { exitCode, stdout } = await runCommand([ - "routes", - "inspect", - "legacy", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.route.filePath).toContain("legacy.js"); - }); - - test("inspect includes publicUrl when configured", async () => { - mockPublicBaseUrl = "https://example.com/v1/assistants/asst_1"; - writeHandler( - "submit.ts", - `export function POST() { return new Response("ok"); }`, - ); - - const { exitCode, stdout } = await runCommand([ - "routes", - "inspect", - "submit", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.route.publicUrl).toBe( - "https://example.com/v1/assistants/asst_1/x/submit", - ); - }); - - test("inspect publicUrl is null when not configured", async () => { - mockPublicBaseUrl = null; - writeHandler( - "submit.ts", - `export function POST() { return new Response("ok"); }`, - ); - - const { exitCode, stdout } = await runCommand([ - "routes", - "inspect", - "submit", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.route.publicUrl).toBeNull(); - }); - - test("inspect returns error for missing handler (JSON)", async () => { - const { exitCode, stdout } = await runCommand([ - "routes", - "inspect", - "nonexistent", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No handler file found"); - expect(parsed.error).toContain("nonexistent"); - }); - - test("inspect returns error for missing handler (human output)", async () => { - const { exitCode } = await runCommand(["routes", "inspect", "nonexistent"]); - expect(exitCode).toBe(1); - }); - - test("inspect handles subdirectory routes", async () => { - writeHandler( - "api/v2/users.ts", - `export function GET() { return new Response("users"); }`, - ); - - const { exitCode, stdout } = await runCommand([ - "routes", - "inspect", - "api/v2/users", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.route.routePath).toBe("/x/api/v2/users"); - }); - - test("inspect human output runs without error", async () => { - writeHandler( - "check.ts", - `export function GET() { return new Response("ok"); }`, - ); - - const { exitCode } = await runCommand(["routes", "inspect", "check"]); - expect(exitCode).toBe(0); - }); - - test("inspect shows handler with no exported methods", async () => { - writeHandler("empty.ts", `export const description = "Placeholder";`); - - const { exitCode, stdout } = await runCommand([ - "routes", - "inspect", - "empty", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.route.methods).toEqual([]); - expect(parsed.route.description).toBe("Placeholder"); - }); - - test("inspect prefers direct file over index file", async () => { - writeHandler( - "ambiguous.ts", - `export function GET() { return new Response("direct"); }`, - ); - writeHandler( - "ambiguous/index.ts", - `export function POST() { return new Response("index"); }`, - ); - - const { exitCode, stdout } = await runCommand([ - "routes", - "inspect", - "ambiguous", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - // Direct file should be preferred over index - expect(parsed.route.methods).toEqual(["GET"]); - }); - - test("inspect prefers .ts over .js", async () => { - writeHandler( - "both.ts", - `export function GET() { return new Response("ts"); }`, - ); - writeHandler( - "both.js", - `export function POST() { return new Response("js"); }`, - ); - - const { exitCode, stdout } = await runCommand([ - "routes", - "inspect", - "both", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - // .ts is checked first in HANDLER_EXTENSIONS - expect(parsed.route.methods).toEqual(["GET"]); - }); -}); diff --git a/assistant/src/cli/commands/oauth/__tests__/connect.test.ts b/assistant/src/cli/commands/oauth/__tests__/connect.test.ts deleted file mode 100644 index 521edfa601a..00000000000 --- a/assistant/src/cli/commands/oauth/__tests__/connect.test.ts +++ /dev/null @@ -1,1157 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockGetProvider: ( - key: string, -) => Record | undefined = () => undefined; - -let mockGetAppByProviderAndClientId: ( - key: string, - clientId: string, -) => Record | undefined = () => undefined; - -let mockGetMostRecentAppByProvider: ( - key: string, -) => Record | undefined = () => undefined; - -let mockOrchestrateOAuthConnect: ( - opts: Record, -) => Promise> = async () => ({ - success: true, - deferred: false, - grantedScopes: [], -}); - -let mockGetSecureKeyAsync: ( - account: string, -) => Promise = async () => undefined; - -let mockOpenInBrowserCalls: string[] = []; -let mockPlatformClientResult: Record | null = null; -let mockPlatformFetchResults: Array<{ - ok: boolean; - status: number; - body: unknown; -}> = []; -let mockPlatformFetchCallIndex = 0; -// Captures the path + parsed JSON body of each platform fetch call so tests can -// assert on what was actually sent to /v1/assistants/.../oauth/.../start/ etc. -let mockPlatformFetchCalls: Array<{ path: string; body: unknown }> = []; - -let mockIsManagedMode: (key: string) => boolean = () => false; - -// Configurable logger mock: by default no-ops; individual tests can override -// mockLogInfo to write to process.stdout so the JSON-mode suppression guard is -// exercised (the real CLI logger writes log lines to stdout). -let mockLogInfo: (msg: string) => void = () => {}; - -let mockCliIpcCallFn: ( - method: string, - params?: Record, - opts?: { timeoutMs?: number }, -) => Promise<{ - ok: boolean; - result?: unknown; - error?: string; - statusCode?: number; -}> = async () => ({ - ok: false, - error: "IPC unavailable (default mock — forces fallback)", -}); - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -mock.module("../../../../config/loader.js", () => ({ - getConfig: () => ({ services: {} }), - API_KEY_PROVIDERS: [], -})); - -mock.module("../../../../oauth/oauth-store.js", () => ({ - getProvider: (key: string) => mockGetProvider(key), - getAppByProviderAndClientId: (key: string, clientId: string) => - mockGetAppByProviderAndClientId(key, clientId), - getMostRecentAppByProvider: (key: string) => - mockGetMostRecentAppByProvider(key), - listConnections: () => [], - getConnection: () => undefined, - getConnectionByProvider: () => undefined, - getActiveConnection: () => undefined, - listActiveConnectionsByProvider: () => [], - disconnectOAuthProvider: async () => "not-found" as const, - upsertApp: async () => ({}), - getApp: () => undefined, - listApps: () => [], - deleteApp: async () => false, - listProviders: () => [], - registerProvider: () => ({}), - seedProviders: () => {}, - isProviderConnected: () => false, - createConnection: () => ({}), - updateConnection: () => ({}), - deleteConnection: () => false, -})); - -mock.module("../../../../oauth/connect-orchestrator.js", () => ({ - orchestrateOAuthConnect: (opts: Record) => - mockOrchestrateOAuthConnect(opts), -})); - -mock.module("../../../../platform/client.js", () => ({ - VellumPlatformClient: { - create: async () => mockPlatformClientResult, - }, -})); - -mock.module("../../../../util/browser.js", () => ({ - openInHostBrowser: async (url: string) => { - mockOpenInBrowserCalls.push(url); - }, -})); - -mock.module("../../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: (msg: string) => mockLogInfo(msg), - warn: () => {}, - error: () => {}, - debug: () => {}, - }), -})); - -mock.module("../../../../security/secure-keys.js", () => ({ - getSecureKeyAsync: (account: string) => mockGetSecureKeyAsync(account), - getSecureKeyResultAsync: async () => ({ - value: undefined, - unreachable: false, - }), - setSecureKeyAsync: async () => true, - deleteSecureKeyAsync: async () => "deleted" as const, - getProviderKeyAsync: async () => undefined, - getMaskedProviderKey: async () => undefined, - bulkSetSecureKeysAsync: async () => {}, - listSecureKeysAsync: async () => ({ credentials: [] }), - setCesClient: () => {}, - onCesClientChanged: () => ({ unsubscribe: () => {} }), - setCesReconnect: () => {}, - getActiveBackendName: () => "file", - _resetBackend: () => {}, -})); - -mock.module("../../../../ipc/cli-client.js", () => ({ - cliIpcCall: ( - method: string, - params?: Record, - opts?: { timeoutMs?: number }, - ) => mockCliIpcCallFn(method, params, opts), - // Keep the real exitFromIpcResult export so downstream test files that - // import it after this mock registers don't fail with "Export not found". - // (bun's mock.module is global; partial mocks silently drop other exports.) - exitFromIpcResult: (r: { - ok: boolean; - error?: string; - statusCode?: number; - }) => { - process.stderr.write((r.error ?? "Unknown error") + "\n"); - if (r.statusCode === undefined) process.exit(10); - if (r.statusCode >= 500) process.exit(3); - if (r.statusCode >= 400) process.exit(2); - process.exit(1); - }, -})); - -mock.module("../../../lib/daemon-credential-client.js", () => ({ - deleteSecureKeyViaDaemon: async () => "not-found" as const, - setSecureKeyViaDaemon: async () => false, -})); - -// Mock shared.js helpers to control managed vs BYO mode routing -mock.module("../shared.js", () => ({ - isManagedMode: (key: string) => mockIsManagedMode(key), - requirePlatformClient: async (_cmd: Command) => { - if ( - !mockPlatformClientResult || - !(mockPlatformClientResult as Record).platformAssistantId - ) { - process.exitCode = 1; - process.stdout.write( - JSON.stringify({ - ok: false, - error: - "Not connected to Vellum platform. Run `vellum platform connect` to connect first.", - }) + "\n", - ); - return null; - } - return { - platformAssistantId: (mockPlatformClientResult as Record) - .platformAssistantId, - fetch: async (path: string, init?: RequestInit): Promise => { - let parsedBody: unknown = undefined; - if (typeof init?.body === "string") { - try { - parsedBody = JSON.parse(init.body); - } catch { - parsedBody = init.body; - } - } - mockPlatformFetchCalls.push({ path, body: parsedBody }); - - const idx = mockPlatformFetchCallIndex++; - const result = mockPlatformFetchResults[idx] ?? { - ok: false, - status: 500, - body: "mock not configured", - }; - return { - ok: result.ok, - status: result.status, - json: async () => result.body, - text: async () => - typeof result.body === "string" - ? result.body - : JSON.stringify(result.body), - } as unknown as Response; - }, - }; - }, - fetchActiveConnections: async ( - _client: Record, - _provider: string, - _cmd: Command, - ): Promise> | null> => { - const idx = mockPlatformFetchCallIndex++; - const result = mockPlatformFetchResults[idx]; - if (!result) return []; - if (!result.ok) return null; - return result.body as Array>; - }, -})); - -// --------------------------------------------------------------------------- -// Import module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerConnectCommand } = await import("../connect.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCommand( - args: string[], -): Promise<{ stdout: string; exitCode: number }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = (() => true) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.option("--json", "JSON output"); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerConnectCommand(program); - await program.parseAsync(["node", "assistant", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { exitCode, stdout: stdoutChunks.join("") }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): this file's mocks (mockGetProvider, -// mockIsManagedMode, mockOrchestrateOAuthConnect, etc.) target -// pre-IPC code paths that the CLI no longer calls directly. After -// the CLI IPC migration (#30238-#30251) the CLI now calls -// cliIpcCall(...) for provider lookup (oauth_providers_by_providerKey_get), -// mode detection (oauth_mode_get), connect start -// (internal_oauth_connect_start) and status polling -// (internal_oauth_connect_status). The current mockCliIpcCallFn only -// covers the last two methods, so every test fails on the first -// IPC call that isn't covered. -// -// The exitFromIpcResult mock also calls process.exit() directly -// which kills bun's test runner. The fix is to throw + set -// process.exitCode so runCommand's try/catch can preserve the code. -// -// Rewriting properly: mock ALL IPC methods the CLI uses, replace -// the process.exit() calls with throws, and adjust assertions to -// the new error-path output. Daemon-side route-handler tests -// (oauth-providers-routes.test.ts, oauth-connect-routes.test.ts, -// oauth-commands-routes-test) already exercise the work; this -// file's job is now CLI plumbing. -describe.skip("assistant oauth connect", () => { - beforeEach(() => { - mockGetProvider = () => undefined; - mockGetAppByProviderAndClientId = () => undefined; - mockGetMostRecentAppByProvider = () => undefined; - mockOrchestrateOAuthConnect = async () => ({ - success: true, - deferred: false, - grantedScopes: [], - }); - mockGetSecureKeyAsync = async () => undefined; - mockOpenInBrowserCalls = []; - mockPlatformClientResult = null; - mockPlatformFetchResults = []; - mockPlatformFetchCallIndex = 0; - mockPlatformFetchCalls = []; - mockIsManagedMode = () => false; - delete process.env.IS_CONTAINERIZED; - mockCliIpcCallFn = async () => ({ ok: false, error: "IPC unavailable" }); - mockLogInfo = () => {}; - process.exitCode = 0; - }); - - // ------------------------------------------------------------------------- - // Unknown provider - // ------------------------------------------------------------------------- - - test("unknown provider returns error with hint", async () => { - mockGetProvider = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "connect", - "nonexistent", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Unknown provider"); - expect(parsed.error).toContain("providers list"); - }); - - // ------------------------------------------------------------------------- - // Managed mode with --no-browser: prints connect URL - // ------------------------------------------------------------------------- - - test("managed mode with --no-browser: prints connect URL", async () => { - mockGetProvider = () => ({ - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - tokenExchangeBodyFormat: "form", - managedServiceConfigKey: "google-oauth", - }); - mockIsManagedMode = () => true; - mockPlatformClientResult = { platformAssistantId: "asst-123" }; - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: { connect_url: "https://platform.example.com/oauth/connect" }, - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--no-browser", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.deferred).toBe(true); - expect(parsed.connectUrl).toBe( - "https://platform.example.com/oauth/connect", - ); - expect(parsed.provider).toBe("google"); - }); - - // ------------------------------------------------------------------------- - // Managed mode default: opens browser and polls - // ------------------------------------------------------------------------- - - test("managed mode default: opens browser and polls for new connection", async () => { - mockGetProvider = () => ({ - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - tokenExchangeBodyFormat: "form", - managedServiceConfigKey: "google-oauth", - }); - mockIsManagedMode = () => true; - mockPlatformClientResult = { platformAssistantId: "asst-123" }; - - // First call: /start/ endpoint returns connect_url - // Second call: fetchActiveConnections snapshot (before browser) - // Third call: fetchActiveConnections poll (new connection found) - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: { connect_url: "https://platform.example.com/oauth/connect" }, - }, - // Snapshot — empty - { ok: true, status: 200, body: [] }, - // Poll — new connection appeared - { - ok: true, - status: 200, - body: [ - { - id: "conn-new", - account_label: "user@gmail.com", - scopes_granted: ["email"], - }, - ], - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.connectionId).toBe("conn-new"); - expect(parsed.accountLabel).toBe("user@gmail.com"); - expect(parsed.scopesGranted).toEqual(["email"]); - expect(mockOpenInBrowserCalls.length).toBeGreaterThanOrEqual(1); - expect(mockOpenInBrowserCalls[0]).toBe( - "https://platform.example.com/oauth/connect", - ); - }); - - // ------------------------------------------------------------------------- - // Managed mode: redirect_after_connect contract - // - // The CLI must always send an explicit `redirect_after_connect` to the - // platform's OAuth start endpoint — either a loopback URL (when running - // on a host with the local redirect server available) or the - // `/account/oauth/desktop-complete` route. Falling through to the - // platform's own default lands the browser on a surface that does not - // render OAuth result params. - // ------------------------------------------------------------------------- - - test("managed mode with --no-browser: sends redirect_after_connect=/account/oauth/desktop-complete", async () => { - mockGetProvider = () => ({ - provider: "notion", - authorizeUrl: "https://api.notion.com/v1/oauth/authorize", - tokenExchangeUrl: "https://api.notion.com/v1/oauth/token", - tokenExchangeBodyFormat: "form", - managedServiceConfigKey: "notion-oauth", - }); - mockIsManagedMode = () => true; - mockPlatformClientResult = { platformAssistantId: "asst-731" }; - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: { connect_url: "https://api.notion.com/v1/oauth/authorize?…" }, - }, - ]; - - const { exitCode } = await runCommand([ - "connect", - "notion", - "--no-browser", - "--json", - ]); - expect(exitCode).toBe(0); - - const startCall = mockPlatformFetchCalls.find((c) => - c.path.includes("/oauth/notion/start/"), - ); - expect(startCall).toBeDefined(); - const sentBody = startCall!.body as Record; - expect(sentBody.redirect_after_connect).toBe( - "/account/oauth/desktop-complete", - ); - }); - - test("managed mode containerized + browser: sends redirect_after_connect=/account/oauth/desktop-complete", async () => { - process.env.IS_CONTAINERIZED = "true"; - - mockGetProvider = () => ({ - provider: "notion", - authorizeUrl: "https://api.notion.com/v1/oauth/authorize", - tokenExchangeUrl: "https://api.notion.com/v1/oauth/token", - tokenExchangeBodyFormat: "form", - managedServiceConfigKey: "notion-oauth", - }); - mockIsManagedMode = () => true; - mockPlatformClientResult = { platformAssistantId: "asst-731" }; - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: { connect_url: "https://api.notion.com/v1/oauth/authorize?…" }, - }, - // Snapshot — empty - { ok: true, status: 200, body: [] }, - // Poll — new connection appeared - { - ok: true, - status: 200, - body: [ - { - id: "conn-new", - account_label: "user@example.com", - scopes_granted: [], - }, - ], - }, - ]; - - const { exitCode } = await runCommand(["connect", "notion", "--json"]); - expect(exitCode).toBe(0); - - const startCall = mockPlatformFetchCalls.find((c) => - c.path.includes("/oauth/notion/start/"), - ); - expect(startCall).toBeDefined(); - const sentBody = startCall!.body as Record; - expect(sentBody.redirect_after_connect).toBe( - "/account/oauth/desktop-complete", - ); - }); - - test("managed mode default (browser, host): sends loopback redirect_after_connect", async () => { - mockGetProvider = () => ({ - provider: "notion", - authorizeUrl: "https://api.notion.com/v1/oauth/authorize", - tokenExchangeUrl: "https://api.notion.com/v1/oauth/token", - tokenExchangeBodyFormat: "form", - managedServiceConfigKey: "notion-oauth", - }); - mockIsManagedMode = () => true; - mockPlatformClientResult = { platformAssistantId: "asst-731" }; - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: { connect_url: "https://api.notion.com/v1/oauth/authorize?…" }, - }, - // Snapshot — empty - { ok: true, status: 200, body: [] }, - // Poll — new connection appeared - { - ok: true, - status: 200, - body: [ - { - id: "conn-new", - account_label: "user@example.com", - scopes_granted: [], - }, - ], - }, - ]; - - const { exitCode } = await runCommand(["connect", "notion", "--json"]); - expect(exitCode).toBe(0); - - const startCall = mockPlatformFetchCalls.find((c) => - c.path.includes("/oauth/notion/start/"), - ); - expect(startCall).toBeDefined(); - const sentBody = startCall!.body as Record; - const redirect = sentBody.redirect_after_connect as string; - // Loopback server picks an ephemeral port on localhost and serves the - // OAuth completion page in-process; the URL shape is stable enough to - // assert without binding to a specific port. - expect(redirect).toMatch(/^http:\/\/localhost:\d+\/oauth\/complete$/); - }); - - // ------------------------------------------------------------------------- - // BYO missing app: error with hint - // ------------------------------------------------------------------------- - - test("BYO mode: missing app with --client-id returns error with hint", async () => { - mockGetProvider = () => ({ - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - tokenExchangeBodyFormat: "form", - managedServiceConfigKey: null, - }); - mockIsManagedMode = () => false; - mockGetAppByProviderAndClientId = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--client-id", - "nonexistent-id", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("nonexistent-id"); - expect(parsed.error).toContain("apps upsert"); - }); - - // ------------------------------------------------------------------------- - // BYO mode: no client_id at all - // ------------------------------------------------------------------------- - - test("BYO mode: no client_id found returns error with hint", async () => { - mockGetProvider = () => ({ - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - tokenExchangeBodyFormat: "form", - managedServiceConfigKey: null, - }); - mockIsManagedMode = () => false; - mockGetMostRecentAppByProvider = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("client_id"); - expect(parsed.error).toContain("apps upsert"); - }); - - // ------------------------------------------------------------------------- - // --client-id ignored in managed mode (silent, no error) - // ------------------------------------------------------------------------- - - test("--client-id is silently ignored in managed mode", async () => { - mockGetProvider = () => ({ - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - tokenExchangeBodyFormat: "form", - managedServiceConfigKey: "google-oauth", - }); - mockIsManagedMode = () => true; - mockPlatformClientResult = { platformAssistantId: "asst-123" }; - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: { connect_url: "https://platform.example.com/oauth/connect" }, - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--client-id", - "should-be-ignored", - "--no-browser", - "--json", - ]); - // Should succeed — --client-id does not cause an error in managed mode - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.connectUrl).toBe( - "https://platform.example.com/oauth/connect", - ); - }); - - // ------------------------------------------------------------------------- - // BYO mode: client_secret required but missing - // ------------------------------------------------------------------------- - - test("BYO mode: client_secret required but missing returns error with hint", async () => { - mockGetProvider = () => ({ - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - tokenExchangeBodyFormat: "form", - tokenEndpointAuthMethod: "client_secret_post", - managedServiceConfigKey: null, - requiresClientSecret: 1, - }); - mockIsManagedMode = () => false; - - mockGetMostRecentAppByProvider = () => ({ - id: "app-1", - clientId: "test-id", - clientSecretCredentialPath: "oauth_app/app-1/client_secret", - provider: "google", - createdAt: 0, - updatedAt: 0, - }); - - // No secret stored - mockGetSecureKeyAsync = async () => undefined; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("client_secret"); - expect(parsed.error).toContain("apps upsert"); - }); - - // ------------------------------------------------------------------------- - // Manual-token providers (slack_channel, telegram) - // ------------------------------------------------------------------------- - - test("manual-token provider returns error directing to credentials command", async () => { - mockGetProvider = () => ({ - provider: "slack_channel", - authorizeUrl: "urn:manual-token", - tokenExchangeUrl: "urn:manual-token", - tokenExchangeBodyFormat: "form", - managedServiceConfigKey: null, - }); - mockIsManagedMode = () => false; - - const { exitCode, stdout } = await runCommand([ - "connect", - "slack_channel", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("manual token configuration"); - expect(parsed.error).toContain("assistant credentials set"); - expect(parsed.error).toContain("--service"); - expect(parsed.error).toContain("--field"); - }); - - // ------------------------------------------------------------------------- - // IPC-first path (daemon-orchestrated) - // ------------------------------------------------------------------------- - - describe("IPC-first path (BYO mode via daemon)", () => { - beforeEach(() => { - // Set up a valid BYO provider and app for all IPC tests - mockGetProvider = () => ({ - provider: "google", - authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenExchangeUrl: "https://oauth2.googleapis.com/token", - tokenExchangeBodyFormat: "form", - managedServiceConfigKey: null, - }); - mockIsManagedMode = () => false; - mockGetMostRecentAppByProvider = () => ({ - id: "app-1", - clientId: "ipc-client-id", - clientSecretCredentialPath: "oauth_app/app-1/client_secret", - provider: "google", - createdAt: 0, - updatedAt: 0, - }); - }); - - test("IPC start succeeds + polling returns complete → exits 0 with success output", async () => { - let pollCallCount = 0; - mockCliIpcCallFn = async (method) => { - if (method === "internal_oauth_connect_start") { - return { - ok: true, - result: { - auth_url: - "https://accounts.google.com/o/oauth2/auth?state=ipc-state", - state: "ipc-state", - }, - }; - } - if (method === "internal_oauth_connect_status") { - pollCallCount++; - return { - ok: true, - result: { - status: "complete", - service: "google", - account_info: "user@example.com", - }, - }; - } - return { ok: false, error: "unexpected method" }; - }; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.accountInfo).toBe("user@example.com"); - expect(mockOpenInBrowserCalls.length).toBe(1); - expect(mockOpenInBrowserCalls[0]).toBe( - "https://accounts.google.com/o/oauth2/auth?state=ipc-state", - ); - expect(pollCallCount).toBeGreaterThanOrEqual(1); - }); - - test("IPC start succeeds + polling returns error → exits 1 with error message", async () => { - mockCliIpcCallFn = async (method) => { - if (method === "internal_oauth_connect_start") { - return { - ok: true, - result: { - auth_url: - "https://accounts.google.com/o/oauth2/auth?state=ipc-state", - state: "ipc-state", - }, - }; - } - if (method === "internal_oauth_connect_status") { - return { - ok: true, - result: { - status: "error", - service: "google", - error: "exchange failed", - }, - }; - } - return { ok: false, error: "unexpected method" }; - }; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toBe("exchange failed"); - }); - - test("IPC start + --no-browser + json → returns deferred JSON without polling status", async () => { - let statusCallCount = 0; - mockCliIpcCallFn = async (method) => { - if (method === "internal_oauth_connect_start") { - return { - ok: true, - result: { - auth_url: - "https://accounts.google.com/o/oauth2/auth?state=ipc-state", - state: "ipc-state", - }, - }; - } - if (method === "internal_oauth_connect_status") { - statusCallCount++; - } - return { ok: false, error: "unexpected method" }; - }; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--no-browser", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.deferred).toBe(true); - expect(parsed.authUrl).toBe( - "https://accounts.google.com/o/oauth2/auth?state=ipc-state", - ); - expect(parsed.state).toBe("ipc-state"); - expect(parsed.service).toBe("google"); - // Should NOT poll status when --no-browser is set - expect(statusCallCount).toBe(0); - // Should NOT open browser - expect(mockOpenInBrowserCalls.length).toBe(0); - }); - - test("IPC start + --no-browser without json → prints URL to stdout", async () => { - mockCliIpcCallFn = async (method) => { - if (method === "internal_oauth_connect_start") { - return { - ok: true, - result: { - auth_url: - "https://accounts.google.com/o/oauth2/auth?state=ipc-state", - state: "ipc-state", - }, - }; - } - return { ok: false, error: "unexpected method" }; - }; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--no-browser", - ]); - expect(exitCode).toBe(0); - expect(stdout).toContain( - "https://accounts.google.com/o/oauth2/auth?state=ipc-state", - ); - expect(mockOpenInBrowserCalls.length).toBe(0); - }); - - test("IPC returns ok:false with statusCode → surfaces daemon error, does NOT fall back", async () => { - // Daemon was reachable but returned an error (e.g. 500) - mockCliIpcCallFn = async (method) => { - if (method === "internal_oauth_connect_start") { - return { ok: false, statusCode: 500, error: "internal server error" }; - } - return { ok: false, error: "unexpected method" }; - }; - let orchestratorCalled = false; - mockOrchestrateOAuthConnect = async () => { - orchestratorCalled = true; - return { success: true, deferred: false, grantedScopes: [] }; - }; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - // Must NOT fall back to the in-process orchestrator - expect(orchestratorCalled).toBe(false); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toBe("internal server error"); - }); - - test("IPC poll returns ok:false with statusCode → breaks early with error, does NOT wait for timeout", async () => { - // Fix 1: daemon was reachable during status poll but errored — should surface the - // error immediately instead of waiting out the full 5-minute timeout. - mockCliIpcCallFn = async (method) => { - if (method === "internal_oauth_connect_start") { - return { - ok: true, - result: { - auth_url: - "https://accounts.google.com/o/oauth2/auth?state=poll-err-state", - state: "poll-err-state", - }, - }; - } - if (method === "internal_oauth_connect_status") { - return { ok: false, statusCode: 500, error: "poll error" }; - } - return { ok: false, error: "unexpected method" }; - }; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - // The daemon error should be surfaced, not a timeout sentinel - expect(parsed.error).toBe("poll error"); - }); - - test("IPC start returns ok:true with no auth_url → surfaces error, does NOT call in-process orchestrator", async () => { - // Fix 2: daemon returns { ok: true } but without an auth_url — malformed response - // should be an error, not a silent fallback to in-process (which has heap-split bug). - mockCliIpcCallFn = async (method) => { - if (method === "internal_oauth_connect_start") { - return { ok: true, result: {} }; - } - return { ok: false, error: "unexpected method" }; - }; - let orchestratorCalled = false; - mockOrchestrateOAuthConnect = async () => { - orchestratorCalled = true; - return { success: true, deferred: false, grantedScopes: [] }; - }; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - // Must NOT fall back to the in-process orchestrator - expect(orchestratorCalled).toBe(false); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("assistant returned unexpected response"); - }); - - test("IPC poll: transient ok:false with no statusCode does not abort the flow (continues to next poll)", async () => { - // Verifies intentional behavior: a single IPC status call returning { ok: false } - // with NO statusCode (socket error / timeout) is treated as a transient failure and - // silently retried. Only ok:false WITH a statusCode (i.e., the daemon was reachable - // and returned an HTTP error) causes an early abort. - let statusCallCount = 0; - mockCliIpcCallFn = async (method) => { - if (method === "internal_oauth_connect_start") { - return { - ok: true, - result: { - auth_url: - "https://accounts.google.com/o/oauth2/auth?state=transient-state", - state: "transient-state", - }, - }; - } - if (method === "internal_oauth_connect_status") { - statusCallCount++; - if (statusCallCount === 1) { - // First poll: transient IPC failure (no statusCode — socket error/timeout) - return { ok: false }; - } - // Second poll: succeeds - return { - ok: true, - result: { - status: "complete", - service: "google", - account_info: "user@example.com", - }, - }; - } - return { ok: false, error: "unexpected method" }; - }; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.accountInfo).toBe("user@example.com"); - // Both poll calls were made — the transient failure did not abort the loop - expect(statusCallCount).toBeGreaterThanOrEqual(2); - }); - - test("IPC success path with --json: stdout does NOT contain 'Waiting for authorization' text", async () => { - // Regression guard for P1: the browser-wait log.info must be suppressed in JSON mode - // so that machine consumers parsing stdout as JSON don't see corrupted non-JSON output. - // - // We configure the logger mock to write to process.stdout (matching the real CLI logger's - // behavior) so this test would FAIL if the `if (!jsonMode)` guard were removed from connect.ts. - mockLogInfo = (msg: string) => { - process.stdout.write(msg + "\n"); - }; - - mockCliIpcCallFn = async (method) => { - if (method === "internal_oauth_connect_start") { - return { - ok: true, - result: { - auth_url: - "https://accounts.google.com/o/oauth2/auth?state=json-mode-state", - state: "json-mode-state", - }, - }; - } - if (method === "internal_oauth_connect_status") { - return { - ok: true, - result: { - status: "complete", - service: "google", - account_info: "user@example.com", - }, - }; - } - return { ok: false, error: "unexpected method" }; - }; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - // The suppressed log line must not appear anywhere in stdout - expect(stdout).not.toContain("Waiting for authorization"); - // stdout must be valid JSON — no plain-text lines mixed in - expect(() => JSON.parse(stdout)).not.toThrow(); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - }); - - test("IPC start with --callback-transport=gateway passes callbackTransport in body", async () => { - let capturedParams: Record | undefined; - mockCliIpcCallFn = async (method, params) => { - if (method === "internal_oauth_connect_start") { - capturedParams = params; - return { - ok: true, - result: { - auth_url: - "https://accounts.google.com/o/oauth2/auth?state=gw-state", - state: "gw-state", - }, - }; - } - if (method === "internal_oauth_connect_status") { - return { - ok: true, - result: { - status: "complete", - service: "google", - account_info: "gw-user@example.com", - }, - }; - } - return { ok: false, error: "unexpected method" }; - }; - - const { exitCode, stdout } = await runCommand([ - "connect", - "google", - "--callback-transport", - "gateway", - "--json", - ]); - expect(exitCode).toBe(0); - // Verify callbackTransport was forwarded in the IPC body - expect(capturedParams).toBeDefined(); - expect( - (capturedParams!.body as Record).callbackTransport, - ).toBe("gateway"); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.accountInfo).toBe("gw-user@example.com"); - }); - }); -}); diff --git a/assistant/src/cli/commands/oauth/__tests__/disconnect.test.ts b/assistant/src/cli/commands/oauth/__tests__/disconnect.test.ts deleted file mode 100644 index 511e22bef85..00000000000 --- a/assistant/src/cli/commands/oauth/__tests__/disconnect.test.ts +++ /dev/null @@ -1,695 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockGetProvider: ( - key: string, -) => Record | undefined = () => undefined; - -let mockGetConnection: ( - id: string, -) => Record | undefined = () => undefined; - -let mockGetActiveConnection: ( - provider: string, - opts?: { account?: string }, -) => Record | undefined = () => undefined; - -let mockListActiveConnectionsByProvider: ( - provider: string, -) => Array> = () => []; - -let mockDisconnectOAuthProviderResult: "disconnected" | "not-found" | "error" = - "disconnected"; - -let mockDisconnectOAuthProviderCalls: Array<{ - provider: string; - account: string | undefined; - connectionId: string | undefined; -}> = []; - -let mockDeleteSecureKeyViaDaemonCalls: Array<{ - type: string; - name: string; -}> = []; - -let mockDeleteCredentialMetadataCalls: Array<{ - service: string; - field: string; -}> = []; - -let mockIsManagedMode: (key: string) => boolean = () => false; - -let mockPlatformClientResult: Record | null = null; -let mockPlatformFetchResults: Array<{ - ok: boolean; - status: number; - body: unknown; -}> = []; -let mockPlatformFetchCallIndex = 0; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -mock.module("../../../../config/loader.js", () => ({ - getConfig: () => ({ services: {} }), - API_KEY_PROVIDERS: [], -})); - -mock.module("../../../../oauth/oauth-store.js", () => ({ - getProvider: (key: string) => mockGetProvider(key), - getConnection: (id: string) => mockGetConnection(id), - getActiveConnection: (provider: string, opts?: { account?: string }) => - mockGetActiveConnection(provider, opts), - listActiveConnectionsByProvider: (provider: string) => - mockListActiveConnectionsByProvider(provider), - disconnectOAuthProvider: async ( - provider: string, - account?: string, - connectionId?: string, - ) => { - mockDisconnectOAuthProviderCalls.push({ - provider, - account, - connectionId, - }); - return mockDisconnectOAuthProviderResult; - }, - getConnectionByProvider: () => undefined, - listConnections: () => [], - upsertApp: async () => ({}), - getApp: () => undefined, - getAppByProviderAndClientId: () => undefined, - getMostRecentAppByProvider: () => undefined, - listApps: () => [], - deleteApp: async () => false, - listProviders: () => [], - registerProvider: () => ({}), - seedProviders: () => {}, - isProviderConnected: () => false, - createConnection: () => ({}), - updateConnection: () => ({}), - deleteConnection: () => false, -})); - -mock.module("../../../../oauth/connect-orchestrator.js", () => ({ - orchestrateOAuthConnect: async () => ({ - success: true, - deferred: false, - grantedScopes: [], - }), -})); - -mock.module("../../../../platform/client.js", () => ({ - VellumPlatformClient: { - create: async () => mockPlatformClientResult, - }, -})); - -mock.module("../../../../util/browser.js", () => ({ - openInHostBrowser: async () => {}, -})); - -mock.module("../../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), -})); - -mock.module("../../../../tools/credentials/metadata-store.js", () => ({ - deleteCredentialMetadata: (service: string, field: string) => { - mockDeleteCredentialMetadataCalls.push({ service, field }); - return true; - }, - getCredentialMetadata: () => undefined, - upsertCredentialMetadata: () => ({}), - listCredentialMetadata: () => [], - assertMetadataWritable: () => {}, -})); - -mock.module("../../../lib/daemon-credential-client.js", () => ({ - deleteSecureKeyViaDaemon: async (type: string, name: string) => { - mockDeleteSecureKeyViaDaemonCalls.push({ type, name }); - return "deleted" as const; - }, - setSecureKeyViaDaemon: async () => false, -})); - -// Mock shared.js helpers to control managed vs BYO mode routing -mock.module("../shared.js", () => ({ - isManagedMode: (key: string) => mockIsManagedMode(key), - requirePlatformClient: async (_cmd: Command) => { - if ( - !mockPlatformClientResult || - !(mockPlatformClientResult as Record).platformAssistantId - ) { - process.exitCode = 1; - process.stdout.write( - JSON.stringify({ - ok: false, - error: - "Not connected to Vellum platform. Run `vellum platform connect` to connect first.", - }) + "\n", - ); - return null; - } - return { - platformAssistantId: (mockPlatformClientResult as Record) - .platformAssistantId, - fetch: async (): Promise => { - const idx = mockPlatformFetchCallIndex++; - const result = mockPlatformFetchResults[idx] ?? { - ok: false, - status: 500, - body: "mock not configured", - }; - return { - ok: result.ok, - status: result.status, - json: async () => result.body, - text: async () => - typeof result.body === "string" - ? result.body - : JSON.stringify(result.body), - } as unknown as Response; - }, - }; - }, - fetchActiveConnections: async (): Promise - > | null> => { - const idx = mockPlatformFetchCallIndex++; - const result = mockPlatformFetchResults[idx]; - if (!result) return []; - if (!result.ok) return null; - return result.body as Array>; - }, -})); - -// --------------------------------------------------------------------------- -// Import module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerDisconnectCommand } = await import("../disconnect.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCommand( - args: string[], -): Promise<{ stdout: string; exitCode: number }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = (() => true) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.option("--json", "JSON output"); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerDisconnectCommand(program); - await program.parseAsync(["node", "assistant", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { exitCode, stdout: stdoutChunks.join("") }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): this file mocks pre-IPC code paths -// (oauth-store.js, withValidToken, etc.) that the CLI no longer -// calls directly. After the CLI IPC migration (#30238-#30251) the -// CLI now calls cliIpcCall(...) and the daemon route handlers in -// runtime/routes/oauth-commands-routes.ts execute the actual work. -// Skipping until rewritten to mock '../../../../ipc/cli-client.js' -// with canned IPC responses, matching the pattern in connect.test.ts -// (mockCliIpcCallFn). The daemon-side logic is exercised by -// route-handler tests (oauth-providers-routes.test.ts etc.). -describe.skip("assistant oauth disconnect", () => { - beforeEach(() => { - mockGetProvider = () => undefined; - mockGetConnection = () => undefined; - mockGetActiveConnection = () => undefined; - mockListActiveConnectionsByProvider = () => []; - mockDisconnectOAuthProviderResult = "disconnected"; - mockDisconnectOAuthProviderCalls = []; - mockDeleteSecureKeyViaDaemonCalls = []; - mockDeleteCredentialMetadataCalls = []; - mockIsManagedMode = () => false; - mockPlatformClientResult = null; - mockPlatformFetchResults = []; - mockPlatformFetchCallIndex = 0; - process.exitCode = 0; - }); - - // ------------------------------------------------------------------------- - // Unknown provider - // ------------------------------------------------------------------------- - - test("unknown provider returns error with hint", async () => { - mockGetProvider = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "nonexistent", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Unknown provider"); - expect(parsed.error).toContain("providers list"); - }); - - // ------------------------------------------------------------------------- - // Both --account and --connection-id → error - // ------------------------------------------------------------------------- - - test("both --account and --connection-id returns error", async () => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: null, - }); - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--account", - "user@example.com", - "--connection-id", - "conn-123", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Cannot specify both"); - expect(parsed.error).toContain("--account"); - expect(parsed.error).toContain("--connection-id"); - }); - - // ========================================================================= - // Managed mode tests - // ========================================================================= - - describe("managed mode", () => { - beforeEach(() => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockIsManagedMode = () => true; - mockPlatformClientResult = { platformAssistantId: "asst-123" }; - }); - - test("single connection auto-disconnects", async () => { - mockPlatformFetchResults = [ - // fetchActiveConnections returns one connection - { - ok: true, - status: 200, - body: [ - { - id: "conn-1", - account_label: "user@gmail.com", - scopes_granted: ["email"], - }, - ], - }, - // disconnect call succeeds - { ok: true, status: 200, body: {} }, - ]; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.connectionId).toBe("conn-1"); - expect(parsed.account).toBe("user@gmail.com"); - }); - - test("multiple connections without flag returns error with connection list", async () => { - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: [ - { - id: "conn-1", - account_label: "user1@gmail.com", - scopes_granted: [], - }, - { - id: "conn-2", - account_label: "user2@gmail.com", - scopes_granted: [], - }, - ], - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Multiple active connections"); - expect(parsed.error).toContain("--account"); - expect(parsed.error).toContain("--connection-id"); - expect(parsed.connections).toBeDefined(); - expect(parsed.connections).toHaveLength(2); - }); - - test("--account filters correctly", async () => { - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: [ - { - id: "conn-1", - account_label: "user1@gmail.com", - scopes_granted: [], - }, - { - id: "conn-2", - account_label: "user2@gmail.com", - scopes_granted: [], - }, - ], - }, - // disconnect call succeeds - { ok: true, status: 200, body: {} }, - ]; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--account", - "user2@gmail.com", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.connectionId).toBe("conn-2"); - expect(parsed.account).toBe("user2@gmail.com"); - }); - - test("--connection-id validates ownership", async () => { - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: [ - { - id: "conn-1", - account_label: "user@gmail.com", - scopes_granted: [], - }, - ], - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--connection-id", - "conn-nonexistent", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("conn-nonexistent"); - expect(parsed.error).toContain("not an active"); - }); - - test("no connections returns error with hint", async () => { - mockPlatformFetchResults = [{ ok: true, status: 200, body: [] }]; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No active connections"); - expect(parsed.error).toContain("status"); - }); - }); - - // ========================================================================= - // BYO mode tests - // ========================================================================= - - describe("BYO mode", () => { - beforeEach(() => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: null, - }); - mockIsManagedMode = () => false; - }); - - test("single connection auto-disconnects", async () => { - mockListActiveConnectionsByProvider = () => [ - { - id: "conn-1", - provider: "google", - accountInfo: "user@gmail.com", - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.connectionId).toBe("conn-1"); - expect(parsed.account).toBe("user@gmail.com"); - - // Verify disconnectOAuthProvider was called - expect(mockDisconnectOAuthProviderCalls).toHaveLength(1); - expect(mockDisconnectOAuthProviderCalls[0].provider).toBe("google"); - expect(mockDisconnectOAuthProviderCalls[0].connectionId).toBe("conn-1"); - }); - - test("--account matches accountInfo", async () => { - mockGetActiveConnection = (_provider, opts) => { - if (opts?.account === "user@gmail.com") { - return { - id: "conn-1", - provider: "google", - accountInfo: "user@gmail.com", - status: "active", - }; - } - return undefined; - }; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--account", - "user@gmail.com", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.connectionId).toBe("conn-1"); - expect(parsed.account).toBe("user@gmail.com"); - }); - - test("--account with no match returns error", async () => { - mockGetActiveConnection = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--account", - "nonexistent@gmail.com", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No active connection"); - expect(parsed.error).toContain("nonexistent@gmail.com"); - }); - - test("--connection-id looks up by ID", async () => { - mockGetConnection = (id) => { - if (id === "conn-123") { - return { - id: "conn-123", - provider: "google", - accountInfo: "user@gmail.com", - status: "active", - }; - } - return undefined; - }; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--connection-id", - "conn-123", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.connectionId).toBe("conn-123"); - }); - - test("--connection-id with wrong provider returns error", async () => { - mockGetConnection = (id) => { - if (id === "conn-slack") { - return { - id: "conn-slack", - provider: "slack", - accountInfo: null, - status: "active", - }; - } - return undefined; - }; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--connection-id", - "conn-slack", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("conn-slack"); - expect(parsed.error).toContain("not an active"); - }); - - test("multiple connections without flags returns error with list", async () => { - mockListActiveConnectionsByProvider = () => [ - { - id: "conn-1", - provider: "google", - accountInfo: "user1@gmail.com", - status: "active", - }, - { - id: "conn-2", - provider: "google", - accountInfo: "user2@gmail.com", - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Multiple active connections"); - expect(parsed.error).toContain("--account"); - expect(parsed.error).toContain("--connection-id"); - expect(parsed.connections).toBeDefined(); - expect(parsed.connections).toHaveLength(2); - }); - - test("no connections returns error with hint", async () => { - mockListActiveConnectionsByProvider = () => []; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No active connections"); - expect(parsed.error).toContain("status"); - }); - - test("disconnect error returns error message", async () => { - mockDisconnectOAuthProviderResult = "error"; - mockListActiveConnectionsByProvider = () => [ - { - id: "conn-1", - provider: "google", - accountInfo: null, - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "disconnect", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Failed to disconnect"); - }); - }); -}); diff --git a/assistant/src/cli/commands/oauth/__tests__/mode.test.ts b/assistant/src/cli/commands/oauth/__tests__/mode.test.ts deleted file mode 100644 index e56c8225768..00000000000 --- a/assistant/src/cli/commands/oauth/__tests__/mode.test.ts +++ /dev/null @@ -1,641 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockGetProvider: ( - key: string, -) => Record | undefined = () => undefined; - -let mockListActiveConnectionsByProvider: ( - provider: string, -) => Array> = () => []; - -let mockGetManagedServiceConfigKey: (key: string) => string | null = () => null; - -let mockPlatformClientResult: Record | null = null; -let mockPlatformFetchResults: Array<{ - ok: boolean; - status: number; - body: unknown; -}> = []; -let mockPlatformFetchCallIndex = 0; - -let mockRawConfig: Record = {}; -let mockSaveRawConfigCalls: Array> = []; -let mockSetNestedValueCalls: Array<{ - obj: Record; - path: string; - value: unknown; -}> = []; - -let mockConfigServices: Record = {}; - -let mockRequirePlatformConnection: ( - cmd: unknown, -) => Promise = async () => true; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -mock.module("../../../../config/loader.js", () => ({ - getConfig: () => ({ services: mockConfigServices }), - loadRawConfig: () => mockRawConfig, - saveRawConfig: (config: Record) => { - mockSaveRawConfigCalls.push(structuredClone(config)); - }, - setNestedValue: ( - obj: Record, - path: string, - value: unknown, - ) => { - mockSetNestedValueCalls.push({ obj, path, value }); - // Actually set the value so the mock raw config is mutated - const keys = path.split("."); - let current: Record = obj; - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]; - if (current[key] == null || typeof current[key] !== "object") { - current[key] = {}; - } - current = current[key] as Record; - } - current[keys[keys.length - 1]] = value; - }, - API_KEY_PROVIDERS: [], -})); - -mock.module("../../../../oauth/oauth-store.js", () => ({ - getProvider: (key: string) => mockGetProvider(key), - listActiveConnectionsByProvider: (provider: string) => - mockListActiveConnectionsByProvider(provider), - listConnections: () => [], - getConnection: () => undefined, - getConnectionByProvider: () => undefined, - getActiveConnection: () => undefined, - disconnectOAuthProvider: async () => "not-found" as const, - upsertApp: async () => ({}), - getApp: () => undefined, - getAppByProviderAndClientId: () => undefined, - getMostRecentAppByProvider: () => undefined, - listApps: () => [], - deleteApp: async () => false, - listProviders: () => [], - registerProvider: () => ({}), - seedProviders: () => {}, - isProviderConnected: () => false, - createConnection: () => ({}), - updateConnection: () => ({}), - deleteConnection: () => false, -})); - -mock.module("../../../../platform/client.js", () => ({ - VellumPlatformClient: { - create: async () => mockPlatformClientResult, - }, -})); - -mock.module("../../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), -})); - -mock.module("../../../lib/daemon-credential-client.js", () => ({ - deleteSecureKeyViaDaemon: async () => "not-found" as const, - setSecureKeyViaDaemon: async () => false, -})); - -// Mock shared.js helpers -mock.module("../shared.js", () => ({ - isManagedMode: () => false, - getManagedServiceConfigKey: (key: string) => - mockGetManagedServiceConfigKey(key), - requirePlatformConnection: (cmd: unknown) => - mockRequirePlatformConnection(cmd), - requirePlatformClient: async (_cmd: Command) => { - if ( - !mockPlatformClientResult || - !(mockPlatformClientResult as Record).platformAssistantId - ) { - process.exitCode = 1; - process.stdout.write( - JSON.stringify({ - ok: false, - error: - "Not connected to Vellum platform. Run `vellum platform connect` to connect first.", - }) + "\n", - ); - return null; - } - return { - platformAssistantId: (mockPlatformClientResult as Record) - .platformAssistantId, - fetch: async (): Promise => { - const idx = mockPlatformFetchCallIndex++; - const result = mockPlatformFetchResults[idx] ?? { - ok: false, - status: 500, - body: "mock not configured", - }; - return { - ok: result.ok, - status: result.status, - json: async () => result.body, - text: async () => - typeof result.body === "string" - ? result.body - : JSON.stringify(result.body), - } as unknown as Response; - }, - }; - }, - fetchActiveConnections: async ( - _client: Record, - _provider: string, - _cmd: Command, - _options?: { silent?: boolean }, - ): Promise> | null> => { - const idx = mockPlatformFetchCallIndex++; - const result = mockPlatformFetchResults[idx]; - if (!result) return []; - if (!result.ok) return null; - return result.body as Array>; - }, -})); - -// --------------------------------------------------------------------------- -// Import module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerModeCommand } = await import("../mode.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCommand( - args: string[], -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - const stderrChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = ((chunk: unknown) => { - stderrChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.option("--json", "JSON output"); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerModeCommand(program); - await program.parseAsync(["node", "assistant", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { - exitCode, - stdout: stdoutChunks.join(""), - stderr: stderrChunks.join(""), - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): this file mocks pre-IPC code paths -// (oauth-store.js, withValidToken, etc.) that the CLI no longer -// calls directly. After the CLI IPC migration (#30238-#30251) the -// CLI now calls cliIpcCall(...) and the daemon route handlers in -// runtime/routes/oauth-commands-routes.ts execute the actual work. -// Skipping until rewritten to mock '../../../../ipc/cli-client.js' -// with canned IPC responses, matching the pattern in connect.test.ts -// (mockCliIpcCallFn). The daemon-side logic is exercised by -// route-handler tests (oauth-providers-routes.test.ts etc.). -describe.skip("assistant oauth mode", () => { - beforeEach(() => { - mockGetProvider = () => undefined; - mockListActiveConnectionsByProvider = () => []; - mockGetManagedServiceConfigKey = () => null; - mockPlatformClientResult = null; - mockPlatformFetchResults = []; - mockPlatformFetchCallIndex = 0; - mockRawConfig = {}; - mockSaveRawConfigCalls = []; - mockSetNestedValueCalls = []; - mockConfigServices = {}; - mockRequirePlatformConnection = async () => true; - process.exitCode = 0; - }); - - // ========================================================================= - // Get mode - // ========================================================================= - - describe("get mode", () => { - test("unknown provider returns error", async () => { - mockGetProvider = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "mode", - "nonexistent", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Unknown provider"); - expect(parsed.error).toContain("providers list"); - }); - - test("provider without managedServiceConfigKey returns your-own with managedModeSupported: false", async () => { - mockGetProvider = () => ({ - provider: "slack", - managedServiceConfigKey: null, - }); - mockGetManagedServiceConfigKey = () => null; - - const { exitCode, stdout } = await runCommand([ - "mode", - "slack", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("slack"); - expect(parsed.mode).toBe("your-own"); - expect(parsed.managedModeSupported).toBe(false); - }); - - test("provider in managed mode returns mode: managed with managedModeSupported: true", async () => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockGetManagedServiceConfigKey = () => "google-oauth"; - mockConfigServices = { - "google-oauth": { mode: "managed" }, - }; - - const { exitCode, stdout } = await runCommand([ - "mode", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.mode).toBe("managed"); - expect(parsed.managedModeSupported).toBe(true); - }); - - test("provider in your-own mode returns mode: your-own with managedModeSupported: true", async () => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockGetManagedServiceConfigKey = () => "google-oauth"; - mockConfigServices = { - "google-oauth": { mode: "your-own" }, - }; - - const { exitCode, stdout } = await runCommand([ - "mode", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.mode).toBe("your-own"); - expect(parsed.managedModeSupported).toBe(true); - }); - }); - - // ========================================================================= - // Set mode - // ========================================================================= - - describe("set mode", () => { - test("invalid mode value returns error listing valid values", async () => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockGetManagedServiceConfigKey = () => "google-oauth"; - - const { exitCode, stdout } = await runCommand([ - "mode", - "google", - "--set", - "invalid", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("invalid"); - expect(parsed.error).toContain("managed"); - expect(parsed.error).toContain("your-own"); - }); - - test("provider without managedServiceConfigKey returns error about managed mode not available when --set managed", async () => { - mockGetProvider = () => ({ - provider: "slack", - managedServiceConfigKey: null, - }); - mockGetManagedServiceConfigKey = () => null; - - const { exitCode, stdout } = await runCommand([ - "mode", - "slack", - "--set", - "managed", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Managed mode is not available"); - expect(parsed.error).toContain("slack"); - }); - - test("provider without managedServiceConfigKey treats --set your-own as successful no-op", async () => { - mockGetProvider = () => ({ - provider: "slack", - managedServiceConfigKey: null, - }); - mockGetManagedServiceConfigKey = () => null; - - const { exitCode, stdout } = await runCommand([ - "mode", - "slack", - "--set", - "your-own", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("slack"); - expect(parsed.mode).toBe("your-own"); - expect(parsed.changed).toBe(false); - expect(parsed.managedModeSupported).toBe(false); - }); - - test("set to same mode returns changed: false", async () => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockGetManagedServiceConfigKey = () => "google-oauth"; - mockConfigServices = { - "google-oauth": { mode: "managed" }, - }; - - const { exitCode, stdout } = await runCommand([ - "mode", - "google", - "--set", - "managed", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.mode).toBe("managed"); - expect(parsed.changed).toBe(false); - expect(parsed.managedModeSupported).toBe(true); - }); - - test("switch managed -> your-own with active managed connections and no BYO connections includes hint", async () => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockGetManagedServiceConfigKey = () => "google-oauth"; - mockConfigServices = { - "google-oauth": { mode: "managed" }, - }; - mockRawConfig = { services: { "google-oauth": { mode: "managed" } } }; - - // Platform has active connections (old mode = managed) - mockPlatformClientResult = { platformAssistantId: "asst-123" }; - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: [{ id: "conn-1", account_label: "user@gmail.com" }], - }, - ]; - - // No BYO connections (new mode = your-own) - mockListActiveConnectionsByProvider = () => []; - - const { exitCode, stdout } = await runCommand([ - "mode", - "google", - "--set", - "your-own", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.mode).toBe("your-own"); - expect(parsed.changed).toBe(true); - expect(parsed.managedModeSupported).toBe(true); - expect(parsed.hint).toContain("No active connections"); - expect(parsed.hint).toContain("your-own"); - expect(parsed.hint).toContain("connect"); - }); - - test("switch your-own -> managed with active BYO connections and no managed connections includes hint", async () => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockGetManagedServiceConfigKey = () => "google-oauth"; - mockConfigServices = { - "google-oauth": { mode: "your-own" }, - }; - mockRawConfig = { services: { "google-oauth": { mode: "your-own" } } }; - - // BYO has active connections (old mode = your-own) - mockListActiveConnectionsByProvider = () => [ - { - id: "conn-local-1", - provider: "google", - status: "active", - }, - ]; - - // Platform has no connections (new mode = managed) - mockPlatformClientResult = { platformAssistantId: "asst-123" }; - mockPlatformFetchResults = [{ ok: true, status: 200, body: [] }]; - - const { exitCode, stdout } = await runCommand([ - "mode", - "google", - "--set", - "managed", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.mode).toBe("managed"); - expect(parsed.changed).toBe(true); - expect(parsed.managedModeSupported).toBe(true); - expect(parsed.hint).toContain("No active connections"); - expect(parsed.hint).toContain("managed"); - expect(parsed.hint).toContain("connect"); - }); - - test("switch mode with connections on both sides has no hint", async () => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockGetManagedServiceConfigKey = () => "google-oauth"; - mockConfigServices = { - "google-oauth": { mode: "managed" }, - }; - mockRawConfig = { services: { "google-oauth": { mode: "managed" } } }; - - // Platform has active connections (old mode = managed) - mockPlatformClientResult = { platformAssistantId: "asst-123" }; - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: [{ id: "conn-1", account_label: "user@gmail.com" }], - }, - ]; - - // BYO also has connections (new mode = your-own) - mockListActiveConnectionsByProvider = () => [ - { - id: "conn-local-1", - provider: "google", - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "mode", - "google", - "--set", - "your-own", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.changed).toBe(true); - expect(parsed.managedModeSupported).toBe(true); - expect(parsed.hint).toBeUndefined(); - }); - - test("switch mode with no connections on either side has no hint", async () => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockGetManagedServiceConfigKey = () => "google-oauth"; - mockConfigServices = { - "google-oauth": { mode: "managed" }, - }; - mockRawConfig = { services: { "google-oauth": { mode: "managed" } } }; - - // No platform connections - mockPlatformClientResult = { platformAssistantId: "asst-123" }; - mockPlatformFetchResults = [{ ok: true, status: 200, body: [] }]; - - // No BYO connections - mockListActiveConnectionsByProvider = () => []; - - const { exitCode, stdout } = await runCommand([ - "mode", - "google", - "--set", - "your-own", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.changed).toBe(true); - expect(parsed.managedModeSupported).toBe(true); - expect(parsed.hint).toBeUndefined(); - }); - - test("saveRawConfig is called with the correct nested path", async () => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockGetManagedServiceConfigKey = () => "google-oauth"; - mockConfigServices = { - "google-oauth": { mode: "managed" }, - }; - mockRawConfig = { services: { "google-oauth": { mode: "managed" } } }; - - // No platform client — skip connection checking - mockPlatformClientResult = null; - mockListActiveConnectionsByProvider = () => []; - - await runCommand(["mode", "google", "--set", "your-own", "--json"]); - - // Verify setNestedValue was called with correct path and value - expect(mockSetNestedValueCalls.length).toBeGreaterThanOrEqual(1); - const setCall = mockSetNestedValueCalls[0]; - expect(setCall.path).toBe("services.google-oauth.mode"); - expect(setCall.value).toBe("your-own"); - - // Verify saveRawConfig was called - expect(mockSaveRawConfigCalls.length).toBe(1); - }); - }); -}); diff --git a/assistant/src/cli/commands/oauth/__tests__/ping.test.ts b/assistant/src/cli/commands/oauth/__tests__/ping.test.ts deleted file mode 100644 index e5be3072abf..00000000000 --- a/assistant/src/cli/commands/oauth/__tests__/ping.test.ts +++ /dev/null @@ -1,640 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockGetProvider: ( - key: string, -) => Record | undefined = () => undefined; - -let mockResolveOAuthConnectionResult: - | { request: (req: unknown) => Promise } - | Error = new Error("not configured"); - -let mockResolveOAuthConnectionCalls: Array<{ - provider: string; - options?: Record; -}> = []; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -mock.module("../../../../config/loader.js", () => ({ - getConfig: () => ({ services: {} }), - API_KEY_PROVIDERS: [], -})); - -mock.module("../../../../oauth/oauth-store.js", () => ({ - getProvider: (key: string) => mockGetProvider(key), - getConnection: () => undefined, - getConnectionByProvider: () => undefined, - getActiveConnection: () => undefined, - listActiveConnectionsByProvider: () => [], - listConnections: () => [], - disconnectOAuthProvider: async () => "not-found" as const, - upsertApp: async () => ({}), - getApp: () => undefined, - getAppByProviderAndClientId: () => undefined, - getMostRecentAppByProvider: () => undefined, - listApps: () => [], - deleteApp: async () => false, - listProviders: () => [], - registerProvider: () => ({}), - seedProviders: () => {}, - isProviderConnected: () => false, - createConnection: () => ({}), - updateConnection: () => ({}), - deleteConnection: () => false, -})); - -mock.module("../../../../oauth/connection-resolver.js", () => ({ - resolveOAuthConnection: async ( - provider: string, - options?: Record, - ) => { - mockResolveOAuthConnectionCalls.push({ provider, options }); - if (mockResolveOAuthConnectionResult instanceof Error) { - throw mockResolveOAuthConnectionResult; - } - return mockResolveOAuthConnectionResult; - }, -})); - -mock.module("../../../../platform/client.js", () => ({ - VellumPlatformClient: { - create: async () => null, - }, -})); - -mock.module("../../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), -})); - -// Mock shared.js helpers -mock.module("../shared.js", () => ({ - isManagedMode: () => false, - requirePlatformClient: async () => null, - fetchActiveConnections: async () => [], -})); - -// --------------------------------------------------------------------------- -// Import module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerPingCommand } = await import("../ping.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCommand( - args: string[], -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - const stderrChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = ((chunk: unknown) => { - stderrChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.option("--json", "JSON output"); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerPingCommand(program); - await program.parseAsync(["node", "assistant", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { - exitCode, - stdout: stdoutChunks.join(""), - stderr: stderrChunks.join(""), - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): this file mocks pre-IPC code paths -// (oauth-store.js, withValidToken, etc.) that the CLI no longer -// calls directly. After the CLI IPC migration (#30238-#30251) the -// CLI now calls cliIpcCall(...) and the daemon route handlers in -// runtime/routes/oauth-commands-routes.ts execute the actual work. -// Skipping until rewritten to mock '../../../../ipc/cli-client.js' -// with canned IPC responses, matching the pattern in connect.test.ts -// (mockCliIpcCallFn). The daemon-side logic is exercised by -// route-handler tests (oauth-providers-routes.test.ts etc.). -describe.skip("assistant oauth ping", () => { - beforeEach(() => { - mockGetProvider = () => undefined; - mockResolveOAuthConnectionResult = new Error("not configured"); - mockResolveOAuthConnectionCalls = []; - process.exitCode = 0; - }); - - // ------------------------------------------------------------------------- - // Provider not found - // ------------------------------------------------------------------------- - - test("unknown provider returns error", async () => { - mockGetProvider = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "ping", - "nonexistent", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Unknown provider"); - expect(parsed.error).toContain("providers list"); - }); - - // ------------------------------------------------------------------------- - // No ping URL configured - // ------------------------------------------------------------------------- - - test("no ping URL configured returns error", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: null, - }); - - const { exitCode, stdout } = await runCommand(["ping", "google", "--json"]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No ping URL configured"); - expect(parsed.error).toContain("providers register --ping-url"); - }); - - // ========================================================================= - // BYO mode tests - // ========================================================================= - - describe("BYO mode", () => { - beforeEach(() => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v1/tokeninfo", - managedServiceConfigKey: null, - }); - }); - - test("successful ping (2xx response)", async () => { - mockResolveOAuthConnectionResult = { - request: async () => ({ - status: 200, - headers: { "content-type": "application/json" }, - body: { email: "user@gmail.com" }, - }), - }; - - const { exitCode, stdout } = await runCommand([ - "ping", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.status).toBe(200); - }); - - test("failed ping (non-2xx response)", async () => { - mockResolveOAuthConnectionResult = { - request: async () => ({ - status: 500, - headers: {}, - body: { error: "Internal server error" }, - }), - }; - - const { exitCode, stdout } = await runCommand([ - "ping", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.provider).toBe("google"); - expect(parsed.status).toBe(500); - expect(parsed.error).toContain("Ping failed with HTTP 500"); - }); - - test("401 response includes auth hint", async () => { - mockResolveOAuthConnectionResult = { - request: async () => ({ - status: 401, - headers: {}, - body: { error: "Unauthorized" }, - }), - }; - - const { exitCode, stdout } = await runCommand([ - "ping", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.status).toBe(401); - expect(parsed.error).toContain("Ping failed with HTTP 401"); - expect(parsed.hint).toContain("oauth status"); - expect(parsed.hint).toContain("oauth connect"); - }); - - test("403 response includes auth hint", async () => { - mockResolveOAuthConnectionResult = { - request: async () => ({ - status: 403, - headers: {}, - body: { error: "Forbidden" }, - }), - }; - - const { exitCode, stdout } = await runCommand([ - "ping", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.status).toBe(403); - expect(parsed.hint).toContain("oauth status"); - expect(parsed.hint).toContain("oauth connect"); - }); - }); - - // ========================================================================= - // Managed mode tests - // ========================================================================= - - describe("managed mode", () => { - beforeEach(() => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v1/tokeninfo", - managedServiceConfigKey: "google-oauth", - }); - }); - - test("successful ping through platform connection", async () => { - mockResolveOAuthConnectionResult = { - request: async () => ({ - status: 200, - headers: { "content-type": "application/json" }, - body: { email: "managed@gmail.com" }, - }), - }; - - const { exitCode, stdout } = await runCommand([ - "ping", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.status).toBe(200); - }); - - test("failed ping through platform connection", async () => { - mockResolveOAuthConnectionResult = { - request: async () => ({ - status: 502, - headers: {}, - body: { error: "Bad Gateway" }, - }), - }; - - const { exitCode, stdout } = await runCommand([ - "ping", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.provider).toBe("google"); - expect(parsed.status).toBe(502); - expect(parsed.error).toContain("Ping failed with HTTP 502"); - }); - }); - - // ========================================================================= - // Connection resolution failure - // ========================================================================= - - test("connection resolution failure (no active connection) with recovery hint", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v1/tokeninfo", - }); - - mockResolveOAuthConnectionResult = new Error( - 'No active OAuth connection found for "google". Connect the service first with `assistant oauth connect google`.', - ); - - const { exitCode, stdout } = await runCommand(["ping", "google", "--json"]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No active OAuth connection"); - expect(parsed.hint).toContain("oauth status"); - expect(parsed.hint).toContain("oauth connect"); - }); - - // ========================================================================= - // --account option - // ========================================================================= - - test("--account option passed through to connection resolution", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v1/tokeninfo", - }); - - mockResolveOAuthConnectionResult = { - request: async () => ({ - status: 200, - headers: {}, - body: {}, - }), - }; - - const { exitCode } = await runCommand([ - "ping", - "google", - "--account", - "user@example.com", - "--json", - ]); - expect(exitCode).toBe(0); - - expect(mockResolveOAuthConnectionCalls).toHaveLength(1); - expect(mockResolveOAuthConnectionCalls[0].options).toEqual({ - account: "user@example.com", - }); - }); - - // ========================================================================= - // --client-id option - // ========================================================================= - - test("--client-id option passed through to connection resolution", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v1/tokeninfo", - }); - - mockResolveOAuthConnectionResult = { - request: async () => ({ - status: 200, - headers: {}, - body: {}, - }), - }; - - const { exitCode } = await runCommand([ - "ping", - "google", - "--client-id", - "my-client-id", - "--json", - ]); - expect(exitCode).toBe(0); - - expect(mockResolveOAuthConnectionCalls).toHaveLength(1); - expect(mockResolveOAuthConnectionCalls[0].options).toEqual({ - clientId: "my-client-id", - }); - }); - - // ========================================================================= - // JSON output mode - // ========================================================================= - - test("JSON output mode returns structured response", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v1/tokeninfo", - }); - - mockResolveOAuthConnectionResult = { - request: async () => ({ - status: 200, - headers: { "content-type": "application/json" }, - body: { email: "user@gmail.com" }, - }), - }; - - const { exitCode, stdout } = await runCommand(["ping", "google", "--json"]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - - // Verify JSON structure - expect(parsed).toHaveProperty("ok", true); - expect(parsed).toHaveProperty("provider", "google"); - expect(parsed).toHaveProperty("status", 200); - }); - - // ========================================================================= - // Human output mode - // ========================================================================= - - test("human output mode logs to stderr on success", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v1/tokeninfo", - }); - - mockResolveOAuthConnectionResult = { - request: async () => ({ - status: 200, - headers: {}, - body: {}, - }), - }; - - // Run without --json — human output path logs via getCliLogger (which is mocked) - const { exitCode, stdout } = await runCommand(["ping", "google"]); - expect(exitCode).toBe(0); - - // In human mode, output is still written via writeOutput - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - }); - - // ========================================================================= - // Provider ping config (method / headers / body) - // ========================================================================= - - test("uses configured pingMethod for POST providers", async () => { - mockGetProvider = () => ({ - provider: "dropbox", - pingUrl: "https://api.dropboxapi.com/2/users/get_current_account", - pingMethod: "POST", - pingHeaders: null, - pingBody: null, - }); - - let capturedRequestArgs: Record = {}; - mockResolveOAuthConnectionResult = { - request: async (req: unknown) => { - capturedRequestArgs = req as Record; - return { status: 200, headers: {}, body: {} }; - }, - }; - - const { exitCode } = await runCommand(["ping", "dropbox", "--json"]); - expect(exitCode).toBe(0); - expect(capturedRequestArgs.method).toBe("POST"); - }); - - test("uses configured pingHeaders", async () => { - mockGetProvider = () => ({ - provider: "notion", - pingUrl: "https://api.notion.com/v1/users/me", - pingMethod: null, - pingHeaders: '{"Notion-Version":"2022-06-28"}', - pingBody: null, - }); - - let capturedRequestArgs: Record = {}; - mockResolveOAuthConnectionResult = { - request: async (req: unknown) => { - capturedRequestArgs = req as Record; - return { status: 200, headers: {}, body: {} }; - }, - }; - - const { exitCode } = await runCommand(["ping", "notion", "--json"]); - expect(exitCode).toBe(0); - expect(capturedRequestArgs.headers).toEqual({ - "Notion-Version": "2022-06-28", - }); - }); - - test("uses configured pingBody for GraphQL providers", async () => { - mockGetProvider = () => ({ - provider: "linear", - pingUrl: "https://api.linear.app/graphql", - pingMethod: "POST", - pingHeaders: '{"Content-Type":"application/json"}', - pingBody: '{"query":"{ viewer { id name email } }"}', - }); - - let capturedRequestArgs: Record = {}; - mockResolveOAuthConnectionResult = { - request: async (req: unknown) => { - capturedRequestArgs = req as Record; - return { status: 200, headers: {}, body: {} }; - }, - }; - - const { exitCode } = await runCommand(["ping", "linear", "--json"]); - expect(exitCode).toBe(0); - expect(capturedRequestArgs.method).toBe("POST"); - expect(capturedRequestArgs.headers).toEqual({ - "Content-Type": "application/json", - }); - expect(capturedRequestArgs.body).toEqual({ - query: "{ viewer { id name email } }", - }); - }); - - test("defaults to GET with no extra headers/body when ping config is null", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v1/tokeninfo", - pingMethod: null, - pingHeaders: null, - pingBody: null, - }); - - let capturedRequestArgs: Record = {}; - mockResolveOAuthConnectionResult = { - request: async (req: unknown) => { - capturedRequestArgs = req as Record; - return { status: 200, headers: {}, body: {} }; - }, - }; - - const { exitCode } = await runCommand(["ping", "google", "--json"]); - expect(exitCode).toBe(0); - expect(capturedRequestArgs.method).toBe("GET"); - expect(capturedRequestArgs.headers).toBeUndefined(); - expect(capturedRequestArgs.body).toBeUndefined(); - }); - - // ========================================================================= - // Network failure - // ========================================================================= - - test("network failure returns error with recovery hint", async () => { - mockGetProvider = () => ({ - provider: "google", - pingUrl: "https://www.googleapis.com/oauth2/v1/tokeninfo", - }); - - mockResolveOAuthConnectionResult = { - request: async () => { - throw new Error("fetch failed: ECONNREFUSED"); - }, - }; - - const { exitCode, stdout } = await runCommand(["ping", "google", "--json"]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("ECONNREFUSED"); - expect(parsed.hint).toContain("oauth status"); - expect(parsed.hint).toContain("oauth connect"); - }); -}); diff --git a/assistant/src/cli/commands/oauth/__tests__/providers-delete.test.ts b/assistant/src/cli/commands/oauth/__tests__/providers-delete.test.ts deleted file mode 100644 index 5643bc74aac..00000000000 --- a/assistant/src/cli/commands/oauth/__tests__/providers-delete.test.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockGetProvider: ( - key: string, -) => Record | undefined = () => undefined; - -let mockDeleteProviderResult = false; - -let mockListAppsResult: Array> = []; - -let mockListConnectionsResult: Array> = []; - -let mockDeleteAppCalls: string[] = []; -let mockDeleteAppResult = true; - -let mockDisconnectCalls: Array<{ - provider: string; - clientId: string | undefined; - connectionId: string | undefined; -}> = []; -let mockDisconnectResult: "disconnected" | "not-found" | "error" = - "disconnected"; - -let mockDeleteConnectionCalls: Array = []; - -let mockSeededProviderKeys = new Set(["google", "slack", "github"]); - -let mockLogInfoCalls: string[] = []; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -mock.module("../../../../config/loader.js", () => ({ - getConfig: () => ({ services: {} }), - loadConfig: () => ({ services: {} }), - API_KEY_PROVIDERS: [], -})); - -mock.module("../../../../oauth/oauth-store.js", () => ({ - getProvider: (key: string) => mockGetProvider(key), - deleteProvider: () => mockDeleteProviderResult, - listApps: () => mockListAppsResult, - listConnections: (provider?: string) => { - if (provider) { - return mockListConnectionsResult.filter((c) => c.provider === provider); - } - return mockListConnectionsResult; - }, - deleteApp: async (id: string) => { - mockDeleteAppCalls.push(id); - return mockDeleteAppResult; - }, - disconnectOAuthProvider: async ( - provider: string, - clientId?: string, - connectionId?: string, - ) => { - mockDisconnectCalls.push({ provider, clientId, connectionId }); - return mockDisconnectResult; - }, - listProviders: () => [], - registerProvider: () => ({}), - updateProvider: () => ({}), - seedProviders: () => {}, - upsertApp: async () => ({}), - getApp: () => undefined, - getAppByProviderAndClientId: () => undefined, - getMostRecentAppByProvider: () => undefined, - createConnection: () => ({}), - updateConnection: () => ({}), - getConnection: () => undefined, - getActiveConnection: () => undefined, - getConnectionByProvider: () => undefined, - listActiveConnectionsByProvider: () => [], - isProviderConnected: () => false, - deleteConnection: (id: string | number) => { - mockDeleteConnectionCalls.push(id); - return true; - }, -})); - -mock.module("../../../../oauth/seed-providers.js", () => ({ - SEEDED_PROVIDER_KEYS: mockSeededProviderKeys, - seedOAuthProviders: () => {}, -})); - -mock.module("../../../../inbound/public-ingress-urls.js", () => ({ - getOAuthCallbackUrl: () => null, -})); - -mock.module("../../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: (msg: string) => { - mockLogInfoCalls.push(msg); - }, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), -})); - -// --------------------------------------------------------------------------- -// Import module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerProviderCommands } = await import("../providers.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCommand( - args: string[], -): Promise<{ stdout: string; exitCode: number }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = (() => true) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.option("--json", "JSON output"); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerProviderCommands(program); - await program.parseAsync(["node", "assistant", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { exitCode, stdout: stdoutChunks.join("") }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): this file mocks pre-IPC code paths -// (oauth-store.js, withValidToken, etc.) that the CLI no longer -// calls directly. After the CLI IPC migration (#30238-#30251) the -// CLI now calls cliIpcCall(...) and the daemon route handlers in -// runtime/routes/oauth-commands-routes.ts execute the actual work. -// Skipping until rewritten to mock '../../../../ipc/cli-client.js' -// with canned IPC responses, matching the pattern in connect.test.ts -// (mockCliIpcCallFn). The daemon-side logic is exercised by -// route-handler tests (oauth-providers-routes.test.ts etc.). -describe.skip("assistant oauth providers delete", () => { - beforeEach(() => { - mockGetProvider = () => undefined; - mockDeleteProviderResult = true; - mockListAppsResult = []; - mockListConnectionsResult = []; - mockDeleteAppCalls = []; - mockDeleteAppResult = true; - mockDisconnectCalls = []; - mockDisconnectResult = "disconnected"; - mockDeleteConnectionCalls = []; - mockSeededProviderKeys = new Set(["google", "slack", "github"]); - mockLogInfoCalls = []; - process.exitCode = 0; - }); - - // ------------------------------------------------------------------------- - // Provider not found - // ------------------------------------------------------------------------- - - test("provider not found returns exit code 1 with actionable error", async () => { - mockGetProvider = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "providers", - "delete", - "nonexistent", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("not found"); - expect(parsed.error).toContain("nonexistent"); - expect(parsed.error).toContain("providers list"); - }); - - // ------------------------------------------------------------------------- - // Provider with dependents, no --force - // ------------------------------------------------------------------------- - - test("provider with dependents and no --force returns exit code 1 with counts", async () => { - mockGetProvider = (key) => - key === "custom-api" - ? { provider: "custom-api", authorizeUrl: "https://example.com/auth" } - : undefined; - - mockListAppsResult = [ - { - id: "app-1", - provider: "custom-api", - clientId: "c1", - createdAt: Date.now(), - updatedAt: Date.now(), - }, - { - id: "app-2", - provider: "custom-api", - clientId: "c2", - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - mockListConnectionsResult = [ - { - id: "conn-1", - provider: "custom-api", - oauthAppId: "app-1", - status: "active", - }, - { - id: "conn-2", - provider: "custom-api", - oauthAppId: "app-1", - status: "active", - }, - { - id: "conn-3", - provider: "custom-api", - oauthAppId: "app-2", - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "providers", - "delete", - "custom-api", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("2 app(s)"); - expect(parsed.error).toContain("3 connection(s)"); - expect(parsed.error).toContain("--force"); - }); - - // ------------------------------------------------------------------------- - // Provider with dependents, --force - // ------------------------------------------------------------------------- - - test("provider with dependents and --force cascades deletion and returns summary", async () => { - mockGetProvider = (key) => - key === "custom-api" - ? { provider: "custom-api", authorizeUrl: "https://example.com/auth" } - : undefined; - - mockListAppsResult = [ - { - id: "app-1", - provider: "custom-api", - clientId: "c1", - clientSecretCredentialPath: "cred/app-1", - createdAt: Date.now(), - updatedAt: Date.now(), - }, - { - id: "app-other", - provider: "other-provider", - clientId: "c3", - clientSecretCredentialPath: "cred/app-other", - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - mockListConnectionsResult = [ - { - id: "conn-1", - provider: "custom-api", - oauthAppId: "app-1", - status: "active", - }, - { - id: "conn-2", - provider: "custom-api", - oauthAppId: "app-1", - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "providers", - "delete", - "custom-api", - "--force", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.deleted.provider).toBe(1); - expect(parsed.deleted.apps).toBe(1); // Only custom-api apps, not other-provider - expect(parsed.deleted.connections).toBe(2); - - // Verify disconnectOAuthProvider was called for each connection - expect(mockDisconnectCalls).toEqual([ - { - provider: "custom-api", - clientId: undefined, - connectionId: "conn-1", - }, - { - provider: "custom-api", - clientId: undefined, - connectionId: "conn-2", - }, - ]); - - // Verify only matching apps were deleted (not app-other) - expect(mockDeleteAppCalls).toEqual(["app-1"]); - }); - - // ------------------------------------------------------------------------- - // Provider with no dependents, no --force - // ------------------------------------------------------------------------- - - test("provider with no dependents and no --force deletes cleanly", async () => { - mockGetProvider = (key) => - key === "custom-api" - ? { provider: "custom-api", authorizeUrl: "https://example.com/auth" } - : undefined; - - mockListAppsResult = []; - mockListConnectionsResult = []; - - const { exitCode, stdout } = await runCommand([ - "providers", - "delete", - "custom-api", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.deleted.provider).toBe(1); - expect(parsed.deleted.apps).toBe(0); - expect(parsed.deleted.connections).toBe(0); - - // No cascade deletes should have happened - expect(mockDisconnectCalls).toHaveLength(0); - expect(mockDeleteAppCalls).toHaveLength(0); - }); - - // ------------------------------------------------------------------------- - // Built-in provider with --force logs warning - // ------------------------------------------------------------------------- - - test("built-in provider with --force succeeds and logs warning about re-creation", async () => { - mockGetProvider = (key) => - key === "google" - ? { provider: "google", authorizeUrl: "https://accounts.google.com" } - : undefined; - - mockListAppsResult = [ - { - id: "app-g", - provider: "google", - clientId: "goog-client", - clientSecretCredentialPath: "cred/app-g", - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - mockListConnectionsResult = [ - { - id: "conn-g", - provider: "google", - oauthAppId: "app-g", - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "providers", - "delete", - "google", - "--force", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.deleted.provider).toBe(1); - expect(parsed.deleted.apps).toBe(1); - expect(parsed.deleted.connections).toBe(1); - - // Should have logged a warning about re-creation - const warningLogged = mockLogInfoCalls.some( - (msg) => msg.includes("built-in") && msg.includes("re-created"), - ); - expect(warningLogged).toBe(true); - }); - - // ------------------------------------------------------------------------- - // Built-in provider without --force and no dependents logs warning - // ------------------------------------------------------------------------- - - test("built-in provider without --force and no dependents logs warning and deletes", async () => { - mockGetProvider = (key) => - key === "google" - ? { provider: "google", authorizeUrl: "https://accounts.google.com" } - : undefined; - - mockListAppsResult = []; - mockListConnectionsResult = []; - - const { exitCode, stdout } = await runCommand([ - "providers", - "delete", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.deleted.provider).toBe(1); - - // Should have logged a warning about re-creation - const warningLogged = mockLogInfoCalls.some( - (msg) => msg.includes("built-in") && msg.includes("re-created"), - ); - expect(warningLogged).toBe(true); - }); - - // ------------------------------------------------------------------------- - // Token cleanup error is logged but does not abort cascade - // ------------------------------------------------------------------------- - - test("token cleanup error logs warning but continues cascade delete", async () => { - mockGetProvider = (key) => - key === "custom-api" - ? { provider: "custom-api", authorizeUrl: "https://example.com/auth" } - : undefined; - - mockListAppsResult = [ - { - id: "app-1", - provider: "custom-api", - clientId: "c1", - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - mockListConnectionsResult = [ - { - id: "conn-1", - provider: "custom-api", - oauthAppId: "app-1", - status: "active", - }, - ]; - - // Simulate token cleanup failure - mockDisconnectResult = "error"; - - const { exitCode, stdout } = await runCommand([ - "providers", - "delete", - "custom-api", - "--force", - "--json", - ]); - // Should still succeed despite token cleanup error - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.deleted.provider).toBe(1); - expect(parsed.deleted.connections).toBe(1); - - // Should have logged a warning about the token cleanup failure - const warningLogged = mockLogInfoCalls.some( - (msg) => - msg.includes("failed to clean up tokens") && msg.includes("conn-1"), - ); - expect(warningLogged).toBe(true); - - // Should have called deleteConnection as a fallback - expect(mockDeleteConnectionCalls).toEqual(["conn-1"]); - }); - - // ------------------------------------------------------------------------- - // Token cleanup error falls back to deleteConnection - // ------------------------------------------------------------------------- - - test("token cleanup error calls deleteConnection as fallback to avoid FK violation", async () => { - mockGetProvider = (key) => - key === "custom-api" - ? { provider: "custom-api", authorizeUrl: "https://example.com/auth" } - : undefined; - - mockListAppsResult = [ - { - id: "app-1", - provider: "custom-api", - clientId: "c1", - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - mockListConnectionsResult = [ - { - id: "conn-1", - provider: "custom-api", - oauthAppId: "app-1", - status: "active", - }, - { - id: "conn-2", - provider: "custom-api", - oauthAppId: "app-1", - status: "active", - }, - ]; - - // Simulate token cleanup failure for all connections - mockDisconnectResult = "error"; - - const { exitCode, stdout } = await runCommand([ - "providers", - "delete", - "custom-api", - "--force", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - - // Both disconnectOAuthProvider calls should have been made - expect(mockDisconnectCalls).toHaveLength(2); - expect(mockDisconnectCalls[0]!.connectionId).toBe("conn-1"); - expect(mockDisconnectCalls[1]!.connectionId).toBe("conn-2"); - - // Both should have fallen back to deleteConnection - expect(mockDeleteConnectionCalls).toEqual(["conn-1", "conn-2"]); - - // Apps should still have been deleted after connections were cleaned up - expect(mockDeleteAppCalls).toEqual(["app-1"]); - }); -}); diff --git a/assistant/src/cli/commands/oauth/__tests__/providers-register.test.ts b/assistant/src/cli/commands/oauth/__tests__/providers-register.test.ts deleted file mode 100644 index e6b9e03ac89..00000000000 --- a/assistant/src/cli/commands/oauth/__tests__/providers-register.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockRegisterProvider: ( - params: Record, -) => Record = () => ({}); - -let mockRegisterProviderCalls: Array<{ - params: Record; -}> = []; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -mock.module("../../../../config/loader.js", () => ({ - getConfig: () => ({ services: {} }), - loadConfig: () => ({ services: {} }), - API_KEY_PROVIDERS: [], -})); - -mock.module("../../../../oauth/oauth-store.js", () => ({ - getProvider: () => undefined, - updateProvider: () => undefined, - listProviders: () => [], - registerProvider: (params: Record) => { - mockRegisterProviderCalls.push({ params }); - return mockRegisterProvider(params); - }, - deleteProvider: () => false, - disconnectOAuthProvider: async () => "ok", - seedProviders: () => {}, - getConnection: () => undefined, - getConnectionByProvider: () => undefined, - getActiveConnection: () => undefined, - listActiveConnectionsByProvider: () => [], - isProviderConnected: () => false, - createConnection: () => ({}), - updateConnection: () => ({}), - deleteConnection: () => false, - upsertApp: async () => ({}), - getApp: () => undefined, - getAppByProviderAndClientId: () => undefined, - getMostRecentAppByProvider: () => undefined, - listApps: () => [], - deleteApp: async () => false, - listConnections: () => [], -})); - -mock.module("../../../../oauth/seed-providers.js", () => ({ - SEEDED_PROVIDER_KEYS: new Set(["google", "slack", "github"]), - PROVIDER_SEED_DATA: {}, - seedAllProviders: () => {}, -})); - -mock.module("../../../../inbound/public-ingress-urls.js", () => ({ - getOAuthCallbackUrl: () => null, -})); - -mock.module("../../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), -})); - -// --------------------------------------------------------------------------- -// Import module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerProviderCommands } = await import("../providers.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCommand( - args: string[], -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - const stderrChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = ((chunk: unknown) => { - stderrChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.option("--json", "JSON output"); - program.configureOutput({ - writeErr: (str: string) => stderrChunks.push(str), - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerProviderCommands(program); - await program.parseAsync(["node", "assistant", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { - exitCode, - stdout: stdoutChunks.join(""), - stderr: stderrChunks.join(""), - }; -} - -// --------------------------------------------------------------------------- -// Sample provider row -// --------------------------------------------------------------------------- - -const sampleProviderRow = { - provider: "custom-api", - authorizeUrl: "https://custom-api.example.com/oauth/authorize", - tokenExchangeUrl: "https://custom-api.example.com/oauth/token", - refreshUrl: null, - tokenEndpointAuthMethod: "client_secret_post", - tokenExchangeBodyFormat: "form", - userinfoUrl: null, - baseUrl: null, - defaultScopes: "[]", - availableScopes: null, - scopeSeparator: null, - authorizeParams: null, - managedServiceConfigKey: null, - pingUrl: null, - pingMethod: null, - pingHeaders: null, - pingBody: null, - revokeUrl: null, - revokeBodyTemplate: null, - displayLabel: null, - description: null, - dashboardUrl: null, - logoUrl: null, - clientIdPlaceholder: null, - requiresClientSecret: 1, - loopbackPort: null, - injectionTemplates: null, - appType: null, - setupNotes: null, - identityUrl: null, - identityMethod: null, - identityHeaders: null, - identityBody: null, - identityResponsePaths: null, - identityFormat: null, - identityOkField: null, - featureFlag: null, - createdAt: Date.now(), - updatedAt: Date.now(), -}; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): this file mocks pre-IPC code paths -// (oauth-store.js, withValidToken, etc.) that the CLI no longer -// calls directly. After the CLI IPC migration (#30238-#30251) the -// CLI now calls cliIpcCall(...) and the daemon route handlers in -// runtime/routes/oauth-commands-routes.ts execute the actual work. -// Skipping until rewritten to mock '../../../../ipc/cli-client.js' -// with canned IPC responses, matching the pattern in connect.test.ts -// (mockCliIpcCallFn). The daemon-side logic is exercised by -// route-handler tests (oauth-providers-routes.test.ts etc.). -describe.skip("assistant oauth providers register", () => { - beforeEach(() => { - mockRegisterProvider = () => ({ ...sampleProviderRow }); - mockRegisterProviderCalls = []; - process.exitCode = 0; - }); - - // ------------------------------------------------------------------------- - // --logo-url - // ------------------------------------------------------------------------- - - test("register accepts --logo-url and passes it to registerProvider", async () => { - mockRegisterProvider = () => ({ - ...sampleProviderRow, - logoUrl: "https://example.com/logo.png", - }); - - const { exitCode } = await runCommand([ - "providers", - "register", - "--provider-key", - "custom-api", - "--auth-url", - "https://custom-api.example.com/oauth/authorize", - "--token-url", - "https://custom-api.example.com/oauth/token", - "--logo-url", - "https://example.com/logo.png", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockRegisterProviderCalls).toHaveLength(1); - expect(mockRegisterProviderCalls[0].params.logoUrl).toBe( - "https://example.com/logo.png", - ); - }); - - // ------------------------------------------------------------------------- - // --logo-simpleicons-slug - // ------------------------------------------------------------------------- - - test("register accepts --logo-simpleicons-slug and expands it to the CDN URL", async () => { - mockRegisterProvider = () => ({ - ...sampleProviderRow, - logoUrl: "https://cdn.simpleicons.org/notion", - }); - - const { exitCode } = await runCommand([ - "providers", - "register", - "--provider-key", - "custom-api", - "--auth-url", - "https://custom-api.example.com/oauth/authorize", - "--token-url", - "https://custom-api.example.com/oauth/token", - "--logo-simpleicons-slug", - "notion", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockRegisterProviderCalls).toHaveLength(1); - expect(mockRegisterProviderCalls[0].params.logoUrl).toBe( - "https://cdn.simpleicons.org/notion", - ); - }); - - // ------------------------------------------------------------------------- - // Mutual exclusion - // ------------------------------------------------------------------------- - - test("register rejects both --logo-url and --logo-simpleicons-slug simultaneously", async () => { - const { exitCode, stdout } = await runCommand([ - "providers", - "register", - "--provider-key", - "custom-api", - "--auth-url", - "https://custom-api.example.com/oauth/authorize", - "--token-url", - "https://custom-api.example.com/oauth/token", - "--logo-url", - "https://example.com/logo.png", - "--logo-simpleicons-slug", - "notion", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("mutually exclusive"); - expect(mockRegisterProviderCalls).toHaveLength(0); - }); - - // ------------------------------------------------------------------------- - // Empty slug rejection - // ------------------------------------------------------------------------- - - test("register rejects empty --logo-simpleicons-slug", async () => { - const { exitCode, stdout } = await runCommand([ - "providers", - "register", - "--provider-key", - "custom-api", - "--auth-url", - "https://custom-api.example.com/oauth/authorize", - "--token-url", - "https://custom-api.example.com/oauth/token", - "--logo-simpleicons-slug", - "", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("cannot be empty"); - expect(mockRegisterProviderCalls).toHaveLength(0); - }); - - // ------------------------------------------------------------------------- - // Empty --logo-url rejection at registration time - // ------------------------------------------------------------------------- - - test("register rejects empty --logo-url (clearing is only valid at update time)", async () => { - const { exitCode, stdout } = await runCommand([ - "providers", - "register", - "--provider-key", - "custom-api", - "--auth-url", - "https://custom-api.example.com/oauth/authorize", - "--token-url", - "https://custom-api.example.com/oauth/token", - "--logo-url", - "", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Cannot clear logo_url"); - expect(mockRegisterProviderCalls).toHaveLength(0); - }); -}); diff --git a/assistant/src/cli/commands/oauth/__tests__/providers-update.test.ts b/assistant/src/cli/commands/oauth/__tests__/providers-update.test.ts deleted file mode 100644 index 8b77820e91a..00000000000 --- a/assistant/src/cli/commands/oauth/__tests__/providers-update.test.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockGetProvider: ( - key: string, -) => Record | undefined = () => undefined; - -let mockUpdateProvider: ( - key: string, - params: Record, -) => Record | undefined = () => undefined; - -let mockUpdateProviderCalls: Array<{ - key: string; - params: Record; -}> = []; - -let mockSeededProviderKeys = new Set(["google", "slack", "github"]); - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -mock.module("../../../../config/loader.js", () => ({ - getConfig: () => ({ services: {} }), - loadConfig: () => ({ services: {} }), - API_KEY_PROVIDERS: [], -})); - -mock.module("../../../../oauth/oauth-store.js", () => ({ - getProvider: (key: string) => mockGetProvider(key), - updateProvider: (key: string, params: Record) => { - mockUpdateProviderCalls.push({ key, params }); - return mockUpdateProvider(key, params); - }, - listProviders: () => [], - registerProvider: () => ({}), - deleteProvider: () => false, - disconnectOAuthProvider: async () => "ok", - seedProviders: () => {}, - getConnection: () => undefined, - getConnectionByProvider: () => undefined, - getActiveConnection: () => undefined, - listActiveConnectionsByProvider: () => [], - isProviderConnected: () => false, - createConnection: () => ({}), - updateConnection: () => ({}), - deleteConnection: () => false, - upsertApp: async () => ({}), - getApp: () => undefined, - getAppByProviderAndClientId: () => undefined, - getMostRecentAppByProvider: () => undefined, - listApps: () => [], - deleteApp: async () => false, - listConnections: () => [], -})); - -mock.module("../../../../oauth/seed-providers.js", () => ({ - SEEDED_PROVIDER_KEYS: mockSeededProviderKeys, - PROVIDER_SEED_DATA: {}, - seedAllProviders: () => {}, -})); - -mock.module("../../../../inbound/public-ingress-urls.js", () => ({ - getOAuthCallbackUrl: () => null, -})); - -mock.module("../../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), -})); - -// --------------------------------------------------------------------------- -// Import module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerProviderCommands } = await import("../providers.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCommand( - args: string[], -): Promise<{ stdout: string; exitCode: number }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = (() => true) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.option("--json", "JSON output"); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerProviderCommands(program); - await program.parseAsync(["node", "assistant", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { exitCode, stdout: stdoutChunks.join("") }; -} - -// --------------------------------------------------------------------------- -// Sample provider row -// --------------------------------------------------------------------------- - -const sampleProviderRow = { - provider: "custom-api", - authorizeUrl: "https://custom-api.example.com/oauth/authorize", - tokenExchangeUrl: "https://custom-api.example.com/oauth/token", - refreshUrl: null, - tokenEndpointAuthMethod: "client_secret_post", - tokenExchangeBodyFormat: "form", - userinfoUrl: null, - baseUrl: null, - defaultScopes: "[]", - availableScopes: null, - scopeSeparator: null, - authorizeParams: null, - managedServiceConfigKey: null, - pingUrl: null, - pingMethod: null, - pingHeaders: null, - pingBody: null, - revokeUrl: null, - revokeBodyTemplate: null, - displayLabel: null, - description: null, - dashboardUrl: null, - clientIdPlaceholder: null, - requiresClientSecret: 1, - loopbackPort: null, - injectionTemplates: null, - appType: null, - setupNotes: null, - identityUrl: null, - identityMethod: null, - identityHeaders: null, - identityBody: null, - identityResponsePaths: null, - identityFormat: null, - identityOkField: null, - featureFlag: null, - createdAt: Date.now(), - updatedAt: Date.now(), -}; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): this file mocks pre-IPC code paths -// (oauth-store.js, withValidToken, etc.) that the CLI no longer -// calls directly. After the CLI IPC migration (#30238-#30251) the -// CLI now calls cliIpcCall(...) and the daemon route handlers in -// runtime/routes/oauth-commands-routes.ts execute the actual work. -// Skipping until rewritten to mock '../../../../ipc/cli-client.js' -// with canned IPC responses, matching the pattern in connect.test.ts -// (mockCliIpcCallFn). The daemon-side logic is exercised by -// route-handler tests (oauth-providers-routes.test.ts etc.). -describe.skip("assistant oauth providers update", () => { - beforeEach(() => { - mockGetProvider = () => undefined; - mockUpdateProvider = () => undefined; - mockUpdateProviderCalls = []; - mockSeededProviderKeys = new Set(["google", "slack", "github"]); - process.exitCode = 0; - }); - - // ------------------------------------------------------------------------- - // Provider not found - // ------------------------------------------------------------------------- - - test("provider not found returns error with hint", async () => { - mockGetProvider = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "providers", - "update", - "nonexistent", - "--display-name", - "Foo", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("not found"); - expect(parsed.error).toContain("providers list"); - }); - - // ------------------------------------------------------------------------- - // Built-in provider - // ------------------------------------------------------------------------- - - test("built-in provider returns error suggesting register", async () => { - mockGetProvider = () => ({ - ...sampleProviderRow, - provider: "google", - }); - - const { exitCode, stdout } = await runCommand([ - "providers", - "update", - "google", - "--display-name", - "Foo", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Cannot update built-in"); - expect(parsed.error).toContain("providers register"); - }); - - // ------------------------------------------------------------------------- - // No options provided - // ------------------------------------------------------------------------- - - test("no options provided returns error", async () => { - mockGetProvider = () => ({ ...sampleProviderRow }); - - const { exitCode, stdout } = await runCommand([ - "providers", - "update", - "custom-api", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Nothing to update"); - }); - - // ------------------------------------------------------------------------- - // Successful update with --display-name - // ------------------------------------------------------------------------- - - test("successful update with --display-name returns updated provider row", async () => { - mockGetProvider = () => ({ ...sampleProviderRow }); - mockUpdateProvider = (_key, _params) => ({ - ...sampleProviderRow, - displayLabel: "New Name", - updatedAt: Date.now(), - }); - - const { exitCode, stdout } = await runCommand([ - "providers", - "update", - "custom-api", - "--display-name", - "New Name", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.providerKey).toBe("custom-api"); - expect(parsed.displayName).toBe("New Name"); - }); - - // ------------------------------------------------------------------------- - // Successful update with multiple options - // ------------------------------------------------------------------------- - - test("successful update with multiple options passes all fields to updateProvider", async () => { - mockGetProvider = () => ({ ...sampleProviderRow }); - mockUpdateProvider = (_key, _params) => ({ - ...sampleProviderRow, - displayLabel: "My API", - defaultScopes: '["read","write"]', - authorizeUrl: "https://new.example.com/auth", - updatedAt: Date.now(), - }); - - const { exitCode, stdout } = await runCommand([ - "providers", - "update", - "custom-api", - "--display-name", - "My API", - "--scopes", - "read,write", - "--auth-url", - "https://new.example.com/auth", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.providerKey).toBe("custom-api"); - - // Verify updateProvider was called with the correct params - expect(mockUpdateProviderCalls).toHaveLength(1); - expect(mockUpdateProviderCalls[0].key).toBe("custom-api"); - expect(mockUpdateProviderCalls[0].params).toEqual({ - displayLabel: "My API", - defaultScopes: ["read", "write"], - authorizeUrl: "https://new.example.com/auth", - }); - }); - - // ------------------------------------------------------------------------- - // Successful update with injection templates, identity config, and setup metadata - // ------------------------------------------------------------------------- - - // ------------------------------------------------------------------------- - // --logo-url - // ------------------------------------------------------------------------- - - test("update --logo-url sets params.logoUrl to the given string", async () => { - mockGetProvider = () => ({ ...sampleProviderRow }); - mockUpdateProvider = (_key, _params) => ({ - ...sampleProviderRow, - logoUrl: "https://example.com/logo.png", - updatedAt: Date.now(), - }); - - const { exitCode } = await runCommand([ - "providers", - "update", - "custom-api", - "--logo-url", - "https://example.com/logo.png", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockUpdateProviderCalls).toHaveLength(1); - expect(mockUpdateProviderCalls[0].key).toBe("custom-api"); - expect(mockUpdateProviderCalls[0].params).toEqual({ - logoUrl: "https://example.com/logo.png", - }); - }); - - test("update --logo-simpleicons-slug expands to CDN URL and passes as params.logoUrl", async () => { - mockGetProvider = () => ({ ...sampleProviderRow }); - mockUpdateProvider = (_key, _params) => ({ - ...sampleProviderRow, - logoUrl: "https://cdn.simpleicons.org/notion", - updatedAt: Date.now(), - }); - - const { exitCode } = await runCommand([ - "providers", - "update", - "custom-api", - "--logo-simpleicons-slug", - "notion", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockUpdateProviderCalls).toHaveLength(1); - expect(mockUpdateProviderCalls[0].params).toEqual({ - logoUrl: "https://cdn.simpleicons.org/notion", - }); - }); - - test('update --logo-url "" clears params.logoUrl to null', async () => { - mockGetProvider = () => ({ ...sampleProviderRow }); - mockUpdateProvider = (_key, _params) => ({ - ...sampleProviderRow, - logoUrl: null, - updatedAt: Date.now(), - }); - - const { exitCode } = await runCommand([ - "providers", - "update", - "custom-api", - "--logo-url", - "", - "--json", - ]); - expect(exitCode).toBe(0); - expect(mockUpdateProviderCalls).toHaveLength(1); - expect(mockUpdateProviderCalls[0].params).toEqual({ - logoUrl: null, - }); - }); - - test("update rejects both --logo-url and --logo-simpleicons-slug simultaneously", async () => { - mockGetProvider = () => ({ ...sampleProviderRow }); - - const { exitCode, stdout } = await runCommand([ - "providers", - "update", - "custom-api", - "--logo-url", - "https://example.com/logo.png", - "--logo-simpleicons-slug", - "notion", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("mutually exclusive"); - expect(mockUpdateProviderCalls).toHaveLength(0); - }); - - // ------------------------------------------------------------------------- - // Successful update with injection templates, identity config, and setup metadata - // ------------------------------------------------------------------------- - - test("successful update with injection templates and identity config passes new fields to updateProvider", async () => { - const injectionTemplates = [ - { - hostPattern: "api.example.com", - injectionType: "header", - headerName: "Authorization", - valuePrefix: "Bearer ", - }, - ]; - const identityHeaders = { "X-Custom": "value" }; - const identityBody = { query: "{ viewer { email } }" }; - const setupNotes = ["Enable the API", "Add test users"]; - - mockGetProvider = () => ({ ...sampleProviderRow }); - mockUpdateProvider = (_key, _params) => ({ - ...sampleProviderRow, - loopbackPort: 17400, - injectionTemplates: JSON.stringify(injectionTemplates), - appType: "OAuth App", - setupNotes: JSON.stringify(setupNotes), - identityUrl: "https://api.example.com/me", - identityMethod: "POST", - identityHeaders: JSON.stringify(identityHeaders), - identityBody: JSON.stringify(identityBody), - identityResponsePaths: JSON.stringify(["email", "name"]), - identityFormat: "@${email}", - identityOkField: "ok", - updatedAt: Date.now(), - }); - - const { exitCode, stdout } = await runCommand([ - "providers", - "update", - "custom-api", - "--loopback-port", - "17400", - "--injection-templates", - JSON.stringify(injectionTemplates), - "--app-type", - "OAuth App", - "--setup-notes", - JSON.stringify(setupNotes), - "--identity-url", - "https://api.example.com/me", - "--identity-method", - "POST", - "--identity-headers", - JSON.stringify(identityHeaders), - "--identity-body", - JSON.stringify(identityBody), - "--identity-response-paths", - "email,name", - "--identity-format", - "@${email}", - "--identity-ok-field", - "ok", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.providerKey).toBe("custom-api"); - - // Verify the new fields are present in the output (parsed from JSON strings) - expect(parsed.loopbackPort).toBe(17400); - expect(parsed.injectionTemplates).toEqual(injectionTemplates); - expect(parsed.appType).toBe("OAuth App"); - expect(parsed.setupNotes).toEqual(setupNotes); - expect(parsed.identityUrl).toBe("https://api.example.com/me"); - expect(parsed.identityMethod).toBe("POST"); - expect(parsed.identityHeaders).toEqual(identityHeaders); - expect(parsed.identityBody).toEqual(identityBody); - expect(parsed.identityResponsePaths).toEqual(["email", "name"]); - expect(parsed.identityFormat).toBe("@${email}"); - expect(parsed.identityOkField).toBe("ok"); - - // Verify updateProvider was called with the correct params - expect(mockUpdateProviderCalls).toHaveLength(1); - expect(mockUpdateProviderCalls[0].key).toBe("custom-api"); - expect(mockUpdateProviderCalls[0].params).toEqual({ - loopbackPort: 17400, - injectionTemplates, - appType: "OAuth App", - setupNotes, - identityUrl: "https://api.example.com/me", - identityMethod: "POST", - identityHeaders, - identityBody, - identityResponsePaths: ["email", "name"], - identityFormat: "@${email}", - identityOkField: "ok", - }); - }); -}); diff --git a/assistant/src/cli/commands/oauth/__tests__/status.test.ts b/assistant/src/cli/commands/oauth/__tests__/status.test.ts deleted file mode 100644 index fef05f503ce..00000000000 --- a/assistant/src/cli/commands/oauth/__tests__/status.test.ts +++ /dev/null @@ -1,560 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockGetProvider: ( - key: string, -) => Record | undefined = () => undefined; - -let mockListConnections: ( - provider: string, -) => Array> = () => []; - -let mockIsManagedMode: (key: string) => boolean = () => false; - -let mockPlatformClientResult: Record | null = null; -let mockPlatformFetchResults: Array<{ - ok: boolean; - status: number; - body: unknown; -}> = []; -let mockPlatformFetchCallIndex = 0; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -mock.module("../../../../config/loader.js", () => ({ - getConfig: () => ({ services: {} }), - API_KEY_PROVIDERS: [], -})); - -mock.module("../../../../oauth/oauth-store.js", () => ({ - getProvider: (key: string) => mockGetProvider(key), - listConnections: (provider: string) => mockListConnections(provider), - getConnection: () => undefined, - getConnectionByProvider: () => undefined, - getActiveConnection: () => undefined, - listActiveConnectionsByProvider: () => [], - disconnectOAuthProvider: async () => "not-found" as const, - upsertApp: async () => ({}), - getApp: () => undefined, - getAppByProviderAndClientId: () => undefined, - getMostRecentAppByProvider: () => undefined, - listApps: () => [], - deleteApp: async () => false, - listProviders: () => [], - registerProvider: () => ({}), - seedProviders: () => {}, - isProviderConnected: () => false, - createConnection: () => ({}), - updateConnection: () => ({}), - deleteConnection: () => false, -})); - -mock.module("../../../../oauth/connect-orchestrator.js", () => ({ - orchestrateOAuthConnect: async () => ({ - success: true, - deferred: false, - grantedScopes: [], - }), -})); - -mock.module("../../../../platform/client.js", () => ({ - VellumPlatformClient: { - create: async () => mockPlatformClientResult, - }, -})); - -mock.module("../../../../util/browser.js", () => ({ - openInHostBrowser: async () => {}, -})); - -mock.module("../../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), -})); - -mock.module("../../../lib/daemon-credential-client.js", () => ({ - deleteSecureKeyViaDaemon: async () => "not-found" as const, - setSecureKeyViaDaemon: async () => false, -})); - -// Mock shared.js helpers to control managed vs BYO mode routing -mock.module("../shared.js", () => ({ - isManagedMode: (key: string) => mockIsManagedMode(key), - requirePlatformClient: async (_cmd: Command) => { - if ( - !mockPlatformClientResult || - !(mockPlatformClientResult as Record).platformAssistantId - ) { - process.exitCode = 1; - process.stdout.write( - JSON.stringify({ - ok: false, - error: - "Not connected to Vellum platform. Run `vellum platform connect` to connect first.", - }) + "\n", - ); - return null; - } - return { - platformAssistantId: (mockPlatformClientResult as Record) - .platformAssistantId, - fetch: async (): Promise => { - const idx = mockPlatformFetchCallIndex++; - const result = mockPlatformFetchResults[idx] ?? { - ok: false, - status: 500, - body: "mock not configured", - }; - return { - ok: result.ok, - status: result.status, - json: async () => result.body, - text: async () => - typeof result.body === "string" - ? result.body - : JSON.stringify(result.body), - } as unknown as Response; - }, - }; - }, - fetchActiveConnections: async (): Promise - > | null> => { - const idx = mockPlatformFetchCallIndex++; - const result = mockPlatformFetchResults[idx]; - if (!result) return []; - if (!result.ok) return null; - return result.body as Array>; - }, -})); - -// --------------------------------------------------------------------------- -// Import module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerStatusCommand } = await import("../status.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCommand( - args: string[], -): Promise<{ stdout: string; exitCode: number }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = (() => true) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.option("--json", "JSON output"); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerStatusCommand(program); - await program.parseAsync(["node", "assistant", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { exitCode, stdout: stdoutChunks.join("") }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): this file mocks pre-IPC code paths -// (oauth-store.js, withValidToken, etc.) that the CLI no longer -// calls directly. After the CLI IPC migration (#30238-#30251) the -// CLI now calls cliIpcCall(...) and the daemon route handlers in -// runtime/routes/oauth-commands-routes.ts execute the actual work. -// Skipping until rewritten to mock '../../../../ipc/cli-client.js' -// with canned IPC responses, matching the pattern in connect.test.ts -// (mockCliIpcCallFn). The daemon-side logic is exercised by -// route-handler tests (oauth-providers-routes.test.ts etc.). -describe.skip("assistant oauth status", () => { - beforeEach(() => { - mockGetProvider = () => undefined; - mockListConnections = () => []; - mockIsManagedMode = () => false; - mockPlatformClientResult = null; - mockPlatformFetchResults = []; - mockPlatformFetchCallIndex = 0; - process.exitCode = 0; - }); - - // ------------------------------------------------------------------------- - // Unknown provider - // ------------------------------------------------------------------------- - - test("unknown provider returns error", async () => { - mockGetProvider = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "status", - "nonexistent", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Unknown provider"); - expect(parsed.error).toContain("providers list"); - }); - - // ========================================================================= - // Managed mode tests - // ========================================================================= - - describe("managed mode", () => { - beforeEach(() => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: "google-oauth", - }); - mockIsManagedMode = () => true; - mockPlatformClientResult = { platformAssistantId: "asst-123" }; - }); - - test("shows platform connections with account labels", async () => { - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: [ - { - id: "conn-1", - account_label: "user@gmail.com", - scopes_granted: ["email", "calendar"], - status: "ACTIVE", - }, - { - id: "conn-2", - account_label: "work@company.com", - scopes_granted: ["email"], - status: "ACTIVE", - }, - ], - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "status", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.mode).toBe("managed"); - expect(parsed.connections).toHaveLength(2); - - // Verify connection structure - const conn1 = parsed.connections[0]; - expect(conn1.id).toBe("conn-1"); - expect(conn1.account).toBe("user@gmail.com"); - expect(conn1.grantedScopes).toEqual(["email", "calendar"]); - expect(conn1.status).toBe("ACTIVE"); - - const conn2 = parsed.connections[1]; - expect(conn2.id).toBe("conn-2"); - expect(conn2.account).toBe("work@company.com"); - }); - - test("no connections: empty connections array in JSON", async () => { - mockPlatformFetchResults = [{ ok: true, status: 200, body: [] }]; - - const { exitCode, stdout } = await runCommand([ - "status", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.mode).toBe("managed"); - expect(parsed.connections).toEqual([]); - }); - - test("no connections: human output hints at connect command", async () => { - mockPlatformFetchResults = [{ ok: true, status: 200, body: [] }]; - - // Run without --json to test human output path - const { exitCode } = await runCommand(["status", "google"]); - // Should succeed (info message printed via logger, which is mocked) - expect(exitCode).toBe(0); - }); - - test("JSON output structure matches contract", async () => { - mockPlatformFetchResults = [ - { - ok: true, - status: 200, - body: [ - { - id: "conn-abc", - account_label: null, - scopes_granted: [], - status: "ACTIVE", - }, - ], - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "status", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - - // Required top-level fields - expect(parsed).toHaveProperty("ok", true); - expect(parsed).toHaveProperty("provider"); - expect(parsed).toHaveProperty("mode", "managed"); - expect(parsed).toHaveProperty("connections"); - expect(Array.isArray(parsed.connections)).toBe(true); - - // Required per-connection fields - const conn = parsed.connections[0]; - expect(conn).toHaveProperty("id"); - expect(conn).toHaveProperty("account"); - expect(conn).toHaveProperty("grantedScopes"); - expect(conn).toHaveProperty("status"); - }); - }); - - // ========================================================================= - // BYO mode tests - // ========================================================================= - - describe("BYO mode", () => { - beforeEach(() => { - mockGetProvider = () => ({ - provider: "google", - managedServiceConfigKey: null, - }); - mockIsManagedMode = () => false; - }); - - test("shows local connections with expiry and refresh info", async () => { - const expiresAt = Date.now() + 3600_000; // 1 hour from now - mockListConnections = () => [ - { - id: "conn-local-1", - provider: "google", - accountInfo: "localuser@gmail.com", - grantedScopes: '["email","profile"]', - expiresAt, - hasRefreshToken: 1, - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "status", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.mode).toBe("byo"); - expect(parsed.connections).toHaveLength(1); - - const conn = parsed.connections[0]; - expect(conn.id).toBe("conn-local-1"); - expect(conn.account).toBe("localuser@gmail.com"); - expect(conn.grantedScopes).toEqual(["email", "profile"]); - expect(conn.expiresAt).toBeTruthy(); - expect(conn.hasRefreshToken).toBe(true); - expect(conn.status).toBe("active"); - }); - - test("shows connection with no refresh token", async () => { - mockListConnections = () => [ - { - id: "conn-local-2", - provider: "google", - accountInfo: null, - grantedScopes: "[]", - expiresAt: null, - hasRefreshToken: 0, - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "status", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - const conn = parsed.connections[0]; - expect(conn.account).toBeNull(); - expect(conn.expiresAt).toBeNull(); - expect(conn.hasRefreshToken).toBe(false); - }); - - test("filters to only active connections", async () => { - mockListConnections = () => [ - { - id: "conn-active", - provider: "google", - accountInfo: "user@gmail.com", - grantedScopes: "[]", - expiresAt: null, - hasRefreshToken: 0, - status: "active", - }, - { - id: "conn-revoked", - provider: "google", - accountInfo: "old@gmail.com", - grantedScopes: "[]", - expiresAt: null, - hasRefreshToken: 0, - status: "revoked", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "status", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.connections).toHaveLength(1); - expect(parsed.connections[0].id).toBe("conn-active"); - }); - - test("no connections: empty array in JSON output", async () => { - mockListConnections = () => []; - - const { exitCode, stdout } = await runCommand([ - "status", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.provider).toBe("google"); - expect(parsed.mode).toBe("byo"); - expect(parsed.connections).toEqual([]); - }); - - test("no connections: human output hints at connect command", async () => { - mockListConnections = () => []; - - // Run without --json — the human output path logs via getCliLogger - const { exitCode } = await runCommand(["status", "google"]); - expect(exitCode).toBe(0); - }); - - test("JSON output structure matches contract", async () => { - mockListConnections = () => [ - { - id: "conn-structure", - provider: "google", - accountInfo: "check@gmail.com", - grantedScopes: '["scope1"]', - expiresAt: Date.now() + 60_000, - hasRefreshToken: 1, - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "status", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - - // Required top-level fields - expect(parsed).toHaveProperty("ok", true); - expect(parsed).toHaveProperty("provider"); - expect(parsed).toHaveProperty("mode", "byo"); - expect(parsed).toHaveProperty("connections"); - expect(Array.isArray(parsed.connections)).toBe(true); - - // Required per-connection fields for BYO - const conn = parsed.connections[0]; - expect(conn).toHaveProperty("id"); - expect(conn).toHaveProperty("account"); - expect(conn).toHaveProperty("grantedScopes"); - expect(conn).toHaveProperty("expiresAt"); - expect(conn).toHaveProperty("hasRefreshToken"); - expect(conn).toHaveProperty("status"); - }); - - test("handles malformed grantedScopes JSON gracefully", async () => { - mockListConnections = () => [ - { - id: "conn-bad-scopes", - provider: "google", - accountInfo: null, - grantedScopes: "not-valid-json", - expiresAt: null, - hasRefreshToken: 0, - status: "active", - }, - ]; - - const { exitCode, stdout } = await runCommand([ - "status", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - // Should default to empty array on parse failure - expect(parsed.connections[0].grantedScopes).toEqual([]); - }); - }); -}); diff --git a/assistant/src/cli/commands/oauth/__tests__/token.test.ts b/assistant/src/cli/commands/oauth/__tests__/token.test.ts deleted file mode 100644 index 34e417c680a..00000000000 --- a/assistant/src/cli/commands/oauth/__tests__/token.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; - -import { Command } from "commander"; - -// --------------------------------------------------------------------------- -// Mock state -// --------------------------------------------------------------------------- - -let mockIsManagedMode: (key: string) => boolean = () => false; - -let mockGetActiveConnection: ( - provider: string, - options?: { clientId?: string; account?: string }, -) => Record | undefined = () => undefined; - -let mockWithValidToken: ( - service: string, - callback: (token: string) => Promise, - opts?: string | { connectionId: string }, -) => Promise = async (_service, callback) => callback("mock-token"); - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -mock.module("../../../../config/loader.js", () => ({ - getConfig: () => ({ services: {} }), - API_KEY_PROVIDERS: [], -})); - -mock.module("../../../../oauth/oauth-store.js", () => ({ - getProvider: () => undefined, - listConnections: () => [], - getConnection: () => undefined, - getConnectionByProvider: () => undefined, - getActiveConnection: ( - provider: string, - options?: { clientId?: string; account?: string }, - ) => mockGetActiveConnection(provider, options), - listActiveConnectionsByProvider: () => [], - disconnectOAuthProvider: async () => "not-found" as const, - upsertApp: async () => ({}), - getApp: () => undefined, - getAppByProviderAndClientId: () => undefined, - getMostRecentAppByProvider: () => undefined, - listApps: () => [], - deleteApp: async () => false, - listProviders: () => [], - registerProvider: () => ({}), - seedProviders: () => {}, - isProviderConnected: () => false, - createConnection: () => ({}), - updateConnection: () => ({}), - deleteConnection: () => false, -})); - -mock.module("../../../../security/token-manager.js", () => ({ - withValidToken: async ( - service: string, - callback: (token: string) => Promise, - opts?: string | { connectionId: string }, - ) => mockWithValidToken(service, callback, opts), -})); - -mock.module("../../../../util/logger.js", () => ({ - getLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - getCliLogger: () => ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), -})); - -mock.module("../../../lib/daemon-credential-client.js", () => ({ - deleteSecureKeyViaDaemon: async () => "not-found" as const, - setSecureKeyViaDaemon: async () => false, -})); - -// Mock shared.js helpers to control managed vs BYO mode routing -mock.module("../shared.js", () => ({ - isManagedMode: (key: string) => mockIsManagedMode(key), -})); - -// --------------------------------------------------------------------------- -// Import module under test (after mocks are registered) -// --------------------------------------------------------------------------- - -const { registerTokenCommand } = await import("../token.js"); - -// --------------------------------------------------------------------------- -// Test helper -// --------------------------------------------------------------------------- - -async function runCommand( - args: string[], -): Promise<{ stdout: string; exitCode: number }> { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const stdoutChunks: string[] = []; - - process.stdout.write = ((chunk: unknown) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); - return true; - }) as typeof process.stdout.write; - - process.stderr.write = (() => true) as typeof process.stderr.write; - - process.exitCode = 0; - - try { - const program = new Command(); - program.exitOverride(); - program.option("--json", "JSON output"); - program.configureOutput({ - writeErr: () => {}, - writeOut: (str: string) => stdoutChunks.push(str), - }); - registerTokenCommand(program); - await program.parseAsync(["node", "assistant", ...args]); - } catch { - if (process.exitCode === 0) process.exitCode = 1; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - - const exitCode = process.exitCode ?? 0; - process.exitCode = 0; - - return { exitCode, stdout: stdoutChunks.join("") }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// TODO(IPC test rewrite): this file mocks pre-IPC code paths -// (oauth-store.js, withValidToken, etc.) that the CLI no longer -// calls directly. After the CLI IPC migration (#30238-#30251) the -// CLI now calls cliIpcCall(...) and the daemon route handlers in -// runtime/routes/oauth-commands-routes.ts execute the actual work. -// Skipping until rewritten to mock '../../../../ipc/cli-client.js' -// with canned IPC responses, matching the pattern in connect.test.ts -// (mockCliIpcCallFn). The daemon-side logic is exercised by -// route-handler tests (oauth-providers-routes.test.ts etc.). -describe.skip("assistant oauth token", () => { - beforeEach(() => { - mockIsManagedMode = () => false; - mockGetActiveConnection = () => undefined; - mockWithValidToken = async (_service, callback) => callback("mock-token"); - delete process.env.VELLUM_UNTRUSTED_SHELL; - process.exitCode = 0; - }); - - // ========================================================================= - // BYO mode — successful token retrieval - // ========================================================================= - - describe("BYO mode", () => { - test("returns token in JSON mode", async () => { - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.token).toBe("mock-token"); - }); - - test("prints bare token to stdout in human mode", async () => { - const { exitCode, stdout } = await runCommand(["token", "google"]); - expect(exitCode).toBe(0); - expect(stdout.trim()).toBe("mock-token"); - }); - - test("token refresh failure returns error", async () => { - mockWithValidToken = async () => { - throw new Error("Token refresh failed: refresh_token expired"); - }; - - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("Token refresh failed"); - }); - - test("no active connection returns error", async () => { - mockWithValidToken = async () => { - throw new Error( - 'No access token found for "google". Authorization required.', - ); - }; - - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No access token found"); - }); - }); - - // ========================================================================= - // Managed mode — user-friendly error - // ========================================================================= - - test("managed mode returns user-friendly error", async () => { - mockIsManagedMode = () => true; - - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("platform-managed"); - expect(parsed.error).toContain("oauth ping"); - expect(parsed.error).toContain("oauth request"); - }); - - // ========================================================================= - // CES shell lockdown - // ========================================================================= - - test("blocked with VELLUM_UNTRUSTED_SHELL=1", async () => { - process.env.VELLUM_UNTRUSTED_SHELL = "1"; - - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("untrusted shell"); - }); - - test("allowed when VELLUM_UNTRUSTED_SHELL is not set", async () => { - delete process.env.VELLUM_UNTRUSTED_SHELL; - - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.token).toBe("mock-token"); - }); - - // ========================================================================= - // --account option for BYO disambiguation - // ========================================================================= - - describe("--account option", () => { - test("resolves connection by account and uses connectionId", async () => { - mockGetActiveConnection = (_provider, options) => { - if (options?.account === "user@gmail.com") { - return { - id: "conn-abc-123", - provider: "google", - accountInfo: "user@gmail.com", - status: "active", - }; - } - return undefined; - }; - - let calledOpts: unknown; - mockWithValidToken = async (_service, callback, opts) => { - calledOpts = opts; - return callback("account-specific-token"); - }; - - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--account", - "user@gmail.com", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.token).toBe("account-specific-token"); - expect(calledOpts).toEqual({ connectionId: "conn-abc-123" }); - }); - - test("no matching account returns error", async () => { - mockGetActiveConnection = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--account", - "unknown@gmail.com", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No active connection found"); - expect(parsed.error).toContain("unknown@gmail.com"); - expect(parsed.error).toContain("oauth connect"); - }); - }); - - // ========================================================================= - // --client-id option for BYO disambiguation - // ========================================================================= - - describe("--client-id option", () => { - test("resolves connection by client-id and uses connectionId", async () => { - mockGetActiveConnection = (_provider, options) => { - if (options?.clientId === "my-client-id") { - return { - id: "conn-client-456", - provider: "google", - accountInfo: null, - status: "active", - }; - } - return undefined; - }; - - let calledOpts: unknown; - mockWithValidToken = async (_service, callback, opts) => { - calledOpts = opts; - return callback("client-id-token"); - }; - - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--client-id", - "my-client-id", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(true); - expect(parsed.token).toBe("client-id-token"); - expect(calledOpts).toEqual({ connectionId: "conn-client-456" }); - }); - - test("no matching client-id returns error", async () => { - mockGetActiveConnection = () => undefined; - - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--client-id", - "nonexistent-id", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("No active connection found"); - expect(parsed.error).toContain("nonexistent-id"); - }); - }); - - // ========================================================================= - // JSON vs human output - // ========================================================================= - - test("JSON output includes ok and token fields", async () => { - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--json", - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed).toHaveProperty("ok", true); - expect(parsed).toHaveProperty("token"); - expect(typeof parsed.token).toBe("string"); - }); - - test("human output prints bare token without JSON wrapper", async () => { - mockWithValidToken = async (_service, callback) => - callback("bare-token-value"); - - const { exitCode, stdout } = await runCommand(["token", "google"]); - expect(exitCode).toBe(0); - // Human mode should NOT contain JSON structure - expect(stdout).not.toContain("{"); - expect(stdout).not.toContain('"ok"'); - expect(stdout.trim()).toBe("bare-token-value"); - }); - - test("JSON error output includes ok and error fields", async () => { - mockWithValidToken = async () => { - throw new Error("Something went wrong"); - }; - - const { exitCode, stdout } = await runCommand([ - "token", - "google", - "--json", - ]); - expect(exitCode).toBe(1); - const parsed = JSON.parse(stdout); - expect(parsed).toHaveProperty("ok", false); - expect(parsed).toHaveProperty("error"); - expect(typeof parsed.error).toBe("string"); - }); -});