test(31-02): add 11 unit tests for Google OAuth service and routes
- Test 1-2: PKCE generation (verifier/challenge format, auth URL params)
- Test 3: token exchange posts correct body to Google token endpoint
- Test 4-5: storeTokens create and rotate paths
- Test 6: authorize returns {url, stateId} with no companyId in pendingPkce
- Test 7: callback exchanges code and redirects with google_oauth=success
- Test 8: callback with invalid state returns 400
- Test 9: full authorize->callback->claim flow stores tokens by companyId
- Test 10: claim with missing stateId returns 404
- Test 11: api-keys/store upserts via secretService
This commit is contained in:
parent
526acbe8aa
commit
dbba43cb3c
1 changed files with 368 additions and 0 deletions
368
server/src/__tests__/31-google-oauth.test.ts
Normal file
368
server/src/__tests__/31-google-oauth.test.ts
Normal file
|
|
@ -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<string, { id: string; companyId: string; name: string; latestVersion: number; value: string }> = {};
|
||||
|
||||
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<typeof googleOAuthRoutes>[0]));
|
||||
return app;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// googleOAuthService unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("googleOAuthService", () => {
|
||||
const mockDb = {} as Parameters<typeof googleOAuthService>[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<typeof googleOAuthRoutes>[0];
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue