diff --git a/server/src/__tests__/31-google-oauth.test.ts b/server/src/__tests__/31-google-oauth.test.ts new file mode 100644 index 00000000..baf8c427 --- /dev/null +++ b/server/src/__tests__/31-google-oauth.test.ts @@ -0,0 +1,368 @@ +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); + }); +});