From 8ced2d0a208f9e83b51ae73a0897e3162d0f1c72 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:35:19 -0400 Subject: [PATCH] test(19-02): add failing tests for linkOidcToUser and POST /api/me/link-oidc RED phase for Task 3: - Test 1: linkOidcToUser updates users.oidc_iss/sub and deletes local_credentials - Test 2: linkOidcToUser throws OidcLinkConflictError on conflict, no local_cred deletion - Test 3: POST /api/me/link-oidc returns initiation payload (state / authorizationUrl) --- apps/api/tests/routes/me.test.ts | 136 +++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/apps/api/tests/routes/me.test.ts b/apps/api/tests/routes/me.test.ts index 4891e63..7eb6e30 100644 --- a/apps/api/tests/routes/me.test.ts +++ b/apps/api/tests/routes/me.test.ts @@ -475,3 +475,139 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => { expect(body.user.hasLocalCredential).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// Plan 19-02: linkOidcToUser helper + POST /api/me/link-oidc (AUTH-LOCAL-10) +// --------------------------------------------------------------------------- + +describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => { + beforeEach(() => { + process.env.NODE_ENV = 'test'; + process.env.DEV_AUTH_BYPASS = 'true'; + process.env.LOCAL_SESSION_SECRET = 'test-secret-that-is-long-enough-32chars!!'; + }); + + it('Test 1: linkOidcToUser updates users.oidc_iss/sub and deletes local_credentials for userId', async () => { + const { db } = await import('../../src/db/client.js'); + const { linkOidcToUser } = await import('../../src/auth/linkOidc.js'); + + const iss = 'https://auth.example.com'; + const sub = 'user-sub-abc-123'; + let updatedUsers = false; + let deletedLocalCreds = false; + + // Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty) + let txSelectCount = 0; + const mockTx = { + select: vi.fn().mockImplementation(() => { + txSelectCount++; + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict + innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + }), + }; + }), + update: vi.fn().mockImplementation(() => ({ + set: vi.fn().mockImplementation(() => { + updatedUsers = true; + return { where: vi.fn().mockResolvedValue({ rowsAffected: 1 }) }; + }), + })), + delete: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => { + deletedLocalCreds = true; + return Promise.resolve({ rowsAffected: 1 }); + }), + })), + }; + + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict + innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise) => { + await fn(mockTx); + }); + + await linkOidcToUser(42, iss, sub); + + expect(updatedUsers).toBe(true); + expect(deletedLocalCreds).toBe(true); + }); + + it('Test 2: linkOidcToUser throws OidcLinkConflictError and does NOT delete local_credentials when iss+sub belongs to different user', async () => { + const { db } = await import('../../src/db/client.js'); + const { linkOidcToUser, OidcLinkConflictError } = await import('../../src/auth/linkOidc.js'); + + const iss = 'https://auth.example.com'; + const sub = 'already-taken-sub'; + const conflictingUserId = 99; // different from target userId 42 + let deletedLocalCreds = false; + + // Preflight SELECT finds a conflicting user (id=99, different from target=42) + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict! + }), + innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any)); + + // Transaction should NEVER be called on conflict + const mockTx = { + delete: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => { + deletedLocalCreds = true; + return Promise.resolve({ rowsAffected: 1 }); + }), + })), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise) => { + await fn(mockTx); + }); + + // Should throw OidcLinkConflictError, not proceed to transaction + await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError); + + // local_credentials row must NOT have been deleted (binding aborted before any write) + expect(deletedLocalCreds).toBe(false); + // Transaction must not have been called + expect(vi.mocked(db).transaction).not.toHaveBeenCalled(); + }); + + it('Test 3: POST /api/me/link-oidc returns response shape with authorization URL / initiation payload', async () => { + const { db } = await import('../../src/db/client.js'); + + // Mock db — not needed for route shape test but avoids errors + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }), + innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any)); + + const { app } = await import('../../src/index.js'); + const res = await app.request('/api/me/link-oidc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + // Must be 200 with an initiation payload (not the actual OIDC redirect — that's 19-03) + expect(res.status).toBe(200); + const body = (await res.json()) as { authorizationUrl?: string; state?: string }; + // The response must have at minimum a signedState field (or authorizationUrl) + // — the exact shape depends on implementation; assert it's an object with a useful field + const hasInitiationPayload = 'authorizationUrl' in body || 'state' in body || 'signedState' in body; + expect(hasInitiationPayload).toBe(true); + }); +});