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:
Nexus Dev 2026-04-03 00:35:53 +00:00
parent 526acbe8aa
commit dbba43cb3c

View 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);
});
});