import { describe, it, expect, vi, beforeEach } from "vitest"; import request from "supertest"; import express from "express"; // --------------------------------------------------------------------------- // Mock fetch globally for Google token endpoint calls // --------------------------------------------------------------------------- const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); // --------------------------------------------------------------------------- // Mock secretService behavior via a mock db object // --------------------------------------------------------------------------- let mockSecretStore: Record = {}; function createMockDb() { const secrets = { getByName: vi.fn(async (companyId: string, name: string) => { const key = `${companyId}:${name}`; return mockSecretStore[key] ?? null; }), create: vi.fn(async (companyId: string, input: { name: string; provider: string; value: string; description?: string }) => { const key = `${companyId}:${input.name}`; const secret = { id: `secret-${Date.now()}`, companyId, name: input.name, latestVersion: 1, value: input.value }; mockSecretStore[key] = secret; return secret; }), rotate: vi.fn(async (secretId: string, input: { value: string }) => { for (const key of Object.keys(mockSecretStore)) { if (mockSecretStore[key]!.id === secretId) { mockSecretStore[key]!.value = input.value; mockSecretStore[key]!.latestVersion += 1; return mockSecretStore[key]; } } throw new Error("Secret not found"); }), resolveSecretValue: vi.fn(async (_companyId: string, secretId: string, _version: string) => { for (const key of Object.keys(mockSecretStore)) { if (mockSecretStore[key]!.id === secretId) { return mockSecretStore[key]!.value; } } throw new Error("Secret not found"); }), }; // The db object is used via secretService(db) — we need to mock the underlying drizzle calls // Instead, we mock the secrets service module itself return { _secrets: secrets }; } // --------------------------------------------------------------------------- // Mock the secretService to use our in-memory mock // --------------------------------------------------------------------------- vi.mock("../services/secrets.js", () => ({ secretService: vi.fn((_db: unknown) => ({ getByName: vi.fn(async (companyId: string, name: string) => { const key = `${companyId}:${name}`; return mockSecretStore[key] ?? null; }), create: vi.fn(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => { const key = `${companyId}:${input.name}`; const secret = { id: `secret-${Date.now()}`, companyId, name: input.name, latestVersion: 1, value: input.value }; mockSecretStore[key] = secret; return secret; }), rotate: vi.fn(async (secretId: string, input: { value: string }) => { for (const key of Object.keys(mockSecretStore)) { if (mockSecretStore[key]!.id === secretId) { mockSecretStore[key]!.value = input.value; mockSecretStore[key]!.latestVersion += 1; return mockSecretStore[key]; } } throw new Error("Secret not found"); }), resolveSecretValue: vi.fn(async (companyId: string, secretId: string, _version: string) => { for (const key of Object.keys(mockSecretStore)) { const s = mockSecretStore[key]!; if (s.id === secretId && s.companyId === companyId) { return s.value; } } throw new Error("Secret not found"); }), })), })); // --------------------------------------------------------------------------- // Import after mocks // --------------------------------------------------------------------------- import { googleOAuthService } from "../services/google-oauth.js"; import { googleOAuthRoutes } from "../routes/google-oauth.js"; // --------------------------------------------------------------------------- // Helper: create a minimal Express app with board auth stubbed // --------------------------------------------------------------------------- function createTestApp(db: unknown) { const app = express(); app.use(express.json()); // Stub actor middleware — sets board actor on all requests app.use((req, _res, next) => { (req as unknown as { actor: { type: string; source: string; isInstanceAdmin: boolean; companyIds: string[]; userId: string } }).actor = { type: "board", source: "local_implicit", isInstanceAdmin: true, companyIds: ["company-1"], userId: "test-user", }; next(); }); app.use(googleOAuthRoutes(db as Parameters[0])); return app; } // --------------------------------------------------------------------------- // googleOAuthService unit tests // --------------------------------------------------------------------------- describe("googleOAuthService", () => { const mockDb = {} as Parameters[0]; beforeEach(() => { mockSecretStore = {}; vi.clearAllMocks(); }); it("Test 1: generatePkce produces verifier (43 chars base64url) and challenge (43 chars base64url)", () => { const svc = googleOAuthService(mockDb); const { verifier, challenge } = svc.generatePkce(); // base64url of 32 bytes = ceil(32 * 4/3) = 43 chars (no padding) expect(verifier).toHaveLength(43); expect(challenge).toHaveLength(43); // Both should be valid base64url (only A-Z, a-z, 0-9, -, _) expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); // Verifier and challenge should be different expect(verifier).not.toBe(challenge); }); it("Test 2: generateAuthUrl returns URL containing client_id, code_challenge, code_challenge_method=S256, state", () => { const svc = googleOAuthService(mockDb); const state = "test-state-uuid"; const { url, verifier } = svc.generateAuthUrl("https://example.com/callback", state); expect(url).toContain("accounts.google.com/o/oauth2/v2/auth"); expect(url).toContain("client_id=812546505895"); expect(url).toContain("code_challenge="); expect(url).toContain("code_challenge_method=S256"); expect(url).toContain(`state=${state}`); expect(url).toContain("response_type=code"); expect(url).toContain("access_type=offline"); expect(url).toContain("prompt=consent"); expect(typeof verifier).toBe("string"); expect(verifier.length).toBeGreaterThan(0); }); it("Test 3: exchangeCode POSTs to Google token endpoint with correct body (grant_type, code_verifier)", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: "ya29.test-access-token", refresh_token: "1//test-refresh-token", expires_in: 3600, }), }); const svc = googleOAuthService(mockDb); const result = await svc.exchangeCode( "auth-code-123", "https://example.com/callback", "pkce-verifier-abc", ); expect(mockFetch).toHaveBeenCalledTimes(1); const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; expect(url).toBe("https://oauth2.googleapis.com/token"); expect(init.method).toBe("POST"); const bodyStr = init.body as string; expect(bodyStr).toContain("grant_type=authorization_code"); expect(bodyStr).toContain("code=auth-code-123"); expect(bodyStr).toContain("code_verifier=pkce-verifier-abc"); expect(bodyStr).toContain("812546505895"); expect(result.accessToken).toBe("ya29.test-access-token"); expect(result.refreshToken).toBe("1//test-refresh-token"); expect(result.expiresIn).toBe(3600); }); it("Test 4: storeTokens creates a new secret when none exists (name: google_gemini_oauth_token)", async () => { const svc = googleOAuthService(mockDb); await svc.storeTokens("company-1", { accessToken: "ya29.new-token", refreshToken: "1//new-refresh", }); const stored = mockSecretStore["company-1:google_gemini_oauth_token"]; expect(stored).toBeDefined(); const parsed = JSON.parse(stored!.value); expect(parsed.accessToken).toBe("ya29.new-token"); expect(parsed.refreshToken).toBe("1//new-refresh"); }); it("Test 5: storeTokens rotates when secret already exists", async () => { // Pre-populate a secret mockSecretStore["company-1:google_gemini_oauth_token"] = { id: "existing-secret-id", companyId: "company-1", name: "google_gemini_oauth_token", latestVersion: 1, value: JSON.stringify({ accessToken: "old-token", refreshToken: "old-refresh" }), }; const svc = googleOAuthService(mockDb); await svc.storeTokens("company-1", { accessToken: "ya29.rotated-token", refreshToken: "1//rotated-refresh", }); const stored = mockSecretStore["company-1:google_gemini_oauth_token"]; expect(stored).toBeDefined(); const parsed = JSON.parse(stored!.value); expect(parsed.accessToken).toBe("ya29.rotated-token"); expect(stored!.latestVersion).toBe(2); }); }); // --------------------------------------------------------------------------- // googleOAuthRoutes integration tests // --------------------------------------------------------------------------- describe("googleOAuthRoutes", () => { const mockDb = {} as Parameters[0]; let app: ReturnType; beforeEach(() => { mockSecretStore = {}; vi.clearAllMocks(); mockFetch.mockReset(); app = createTestApp(mockDb); }); it("Test 6: POST /oauth/google/authorize returns { url, stateId } and stores verifier in pendingPkce (no companyId)", async () => { const res = await request(app) .post("/oauth/google/authorize") .set("Host", "localhost:3000") .expect(200); expect(res.body).toHaveProperty("url"); expect(res.body).toHaveProperty("stateId"); expect(typeof res.body.url).toBe("string"); expect(typeof res.body.stateId).toBe("string"); expect(res.body.url).toContain("accounts.google.com"); expect(res.body.url).toContain("812546505895"); // stateId should be a UUID expect(res.body.stateId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, ); }); it("Test 7: GET /oauth/google/callback with valid state exchanges code, stores tokens in pendingTokens, redirects to /?google_oauth=success", async () => { // First get a valid stateId via authorize const authRes = await request(app) .post("/oauth/google/authorize") .set("Host", "localhost:3000") .expect(200); const { stateId } = authRes.body as { stateId: string }; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: "ya29.callback-token", refresh_token: "1//callback-refresh", expires_in: 3600, }), }); const callbackRes = await request(app) .get(`/oauth/google/callback?code=auth-code&state=${stateId}`) .set("Host", "localhost:3000") .expect(302); expect(callbackRes.headers["location"]).toContain("google_oauth=success"); expect(callbackRes.headers["location"]).toContain(`state=${stateId}`); }); it("Test 8: GET /oauth/google/callback with invalid state returns 400", async () => { const res = await request(app) .get("/oauth/google/callback?code=some-code&state=invalid-state-does-not-exist") .set("Host", "localhost:3000") .expect(400); expect(res.text).toContain("Invalid or expired OAuth state"); }); it("Test 9: POST /oauth/google/claim with valid stateId moves tokens to secretService and returns { ok: true }", async () => { // Full flow: authorize -> callback -> claim const authRes = await request(app) .post("/oauth/google/authorize") .set("Host", "localhost:3000") .expect(200); const { stateId } = authRes.body as { stateId: string }; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: "ya29.claim-token", refresh_token: "1//claim-refresh", expires_in: 3600, }), }); await request(app) .get(`/oauth/google/callback?code=auth-code&state=${stateId}`) .set("Host", "localhost:3000") .expect(302); const claimRes = await request(app) .post("/oauth/google/claim") .send({ stateId, companyId: "company-1" }) .expect(200); expect(claimRes.body).toEqual({ ok: true }); // Verify tokens were stored in secretService const stored = mockSecretStore["company-1:google_gemini_oauth_token"]; expect(stored).toBeDefined(); const parsed = JSON.parse(stored!.value); expect(parsed.accessToken).toBe("ya29.claim-token"); }); it("Test 10: POST /oauth/google/claim with expired/missing stateId returns 404", async () => { const res = await request(app) .post("/oauth/google/claim") .send({ stateId: "nonexistent-state-id", companyId: "company-1" }) .expect(404); expect(res.body).toHaveProperty("error"); expect(res.body.error).toContain("expired or not found"); }); it("Test 11: POST /api-keys/store stores key via secretService upsert pattern", async () => { const res = await request(app) .post("/api-keys/store") .send({ companyId: "company-1", provider: "openai", apiKey: "sk-test-key-12345" }) .expect(200); expect(res.body).toEqual({ ok: true }); // Verify the key was stored const stored = mockSecretStore["company-1:openai_api_key"]; expect(stored).toBeDefined(); expect(stored!.value).toBe("sk-test-key-12345"); // Test upsert (rotate) when key already exists const res2 = await request(app) .post("/api-keys/store") .send({ companyId: "company-1", provider: "openai", apiKey: "sk-new-key-67890" }) .expect(200); expect(res2.body).toEqual({ ok: true }); const stored2 = mockSecretStore["company-1:openai_api_key"]; expect(stored2!.value).toBe("sk-new-key-67890"); expect(stored2!.latestVersion).toBe(2); }); });