/** * Auth: upsertUser color round-robin + identity stability + first-login-wins is_admin * * Tests for apps/api/src/auth/user.ts (Plan 02 + Plan 10-02 + Plan 12-03) * * Select call order for a NEW user insert (post Plan 10-02): * 1. Lookup by oidc_iss + oidc_sub (identity check) * 2. Used-colors query (color assignment) * 3. Zero-admin COUNT check (first-login-wins is_admin bootstrap — NEW) * 4. Re-fetch after insert (return full row) * * Existing-user (early-return) path remains at 1 select call (no change). * * Plan 12-03 additions — D-08 first-login-claims path: * When setup_complete='true', a new oidc identity triggers claim lookup (step 1.5): * 1. Lookup by oidc_iss + oidc_sub (no match for new identity) * 1.5. Read app_config.setup_complete * 1.6. If 'true': select unclaimed user (isNull(oidcIss) + claimed=false) → update + return * 2+. Otherwise fall through to normal color / admin count / insert path */ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock the db singleton at module level (Vitest hoisting — must be top-level) vi.mock('../../src/db/client.js', () => ({ db: { select: vi.fn(), insert: vi.fn(), update: vi.fn(), }, })); // Import after mock is set up import { db } from '../../src/db/client.js'; import { upsertUser, COLOR_PALETTE } from '../../src/auth/user.js'; const mockDb = db as { select: ReturnType; insert: ReturnType; update: ReturnType; }; // Chainable builder factory used in multiple tests function makeSelectChain(resolvedValue: unknown[]) { const chain = { from: vi.fn(), where: vi.fn(), limit: vi.fn().mockResolvedValue(resolvedValue), }; chain.from.mockReturnValue(chain); chain.where.mockReturnValue(chain); return chain; } function makeInsertChain(returningIdValue: { id: number }[]) { const chain = { values: vi.fn(), $returningId: vi.fn().mockResolvedValue(returningIdValue), }; chain.values.mockReturnValue(chain); return chain; } function makeUpdateChain() { const chain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; chain.set.mockReturnValue(chain); return chain; } describe('COLOR_PALETTE', () => { it('exports at least 4 distinct hex colors', () => { expect(COLOR_PALETTE).toBeDefined(); expect(COLOR_PALETTE.length).toBeGreaterThanOrEqual(4); for (const c of COLOR_PALETTE) { // Each entry must be a 7-char hex string like #4A90D9 expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/); } // All colors must be distinct const unique = new Set(COLOR_PALETTE); expect(unique.size).toBe(COLOR_PALETTE.length); }); }); describe('upsertUser', () => { beforeEach(() => { vi.clearAllMocks(); }); it('assigns palette[0] to the first user inserted', async () => { const iss = 'https://auth.example.com'; const sub = 'user-sub-001'; // Select call order (new user, post Plan 12-03): // 1. Lookup by iss+sub — not found // 2. app_config.setup_complete — not set (fallthrough to normal path) // 3. Used-colors query — no existing users → palette[0] // 4. Zero-admin COUNT check — 0 admins → shouldBeAdmin=true // 5. Re-fetch after insert — return the inserted row let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) { // Lookup by iss+sub — not found return makeSelectChain([]); } if (selectCallCount === 2) { // app_config.setup_complete — not set → normal insert path return makeSelectChain([]); } if (selectCallCount === 3) { // Used-colors query — no existing users return { from: vi.fn().mockResolvedValue([]), }; } if (selectCallCount === 4) { // Zero-admin COUNT check — 0 admins → first user becomes admin return makeSelectChain([{ count: 0 }]); } // Re-fetch after insert return makeSelectChain([ { id: 1, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], isAdmin: true, createdAt: new Date(), }, ]); }); mockDb.insert.mockReturnValue(makeInsertChain([{ id: 1 }])); const user = await upsertUser(iss, sub); expect(user).toBeDefined(); expect(user!.color).toBe(COLOR_PALETTE[0]); expect(user!.id).toBe(1); }); it('assigns palette[1] to the second distinct user', async () => { const iss = 'https://auth.example.com'; const sub2 = 'user-sub-002'; let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) { return makeSelectChain([]); // not found } if (selectCallCount === 2) { // app_config.setup_complete — not set → normal insert path return makeSelectChain([]); } if (selectCallCount === 3) { // Used-colors query — one existing user already holds palette[0], // so the next member must get the first unused color: palette[1]. return { from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]), }; } if (selectCallCount === 4) { // Zero-admin COUNT check — 1 admin already exists → shouldBeAdmin=false return makeSelectChain([{ count: 1 }]); } return makeSelectChain([ { id: 2, oidcIss: iss, oidcSub: sub2, displayName: null, color: COLOR_PALETTE[1], isAdmin: false, createdAt: new Date(), }, ]); }); mockDb.insert.mockReturnValue(makeInsertChain([{ id: 2 }])); const user = await upsertUser(iss, sub2); expect(user!.color).toBe(COLOR_PALETTE[1]); }); // Regression (Gate 2): a new member must get a color NOT already in use, even // after a deletion. The old COUNT(*) % palette logic reused an in-use slot // when the user count had shifted (two members both got #E8734A). With colors // [0] and [2] taken (slot [1] freed by a delete), the next member fills [1]. it('assigns the first UNUSED palette color (no collision after deletions)', async () => { const iss = 'https://auth.example.com'; const sub = 'user-sub-005'; let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); // not found if (selectCallCount === 2) { // app_config.setup_complete — not set → normal insert path return makeSelectChain([]); } if (selectCallCount === 3) { // palette[0] and palette[2] in use; palette[1] is free return { from: vi .fn() .mockResolvedValue([{ color: COLOR_PALETTE[0] }, { color: COLOR_PALETTE[2] }]), }; } if (selectCallCount === 4) { // Zero-admin COUNT check — admin exists → shouldBeAdmin=false return makeSelectChain([{ count: 1 }]); } return makeSelectChain([ { id: 5, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[1], isAdmin: false, createdAt: new Date(), }, ]); }); mockDb.insert.mockReturnValue(makeInsertChain([{ id: 5 }])); await upsertUser(iss, sub); // The inserted row's color must be the first unused palette entry (palette[1]). const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]; expect(insertValues.color).toBe(COLOR_PALETTE[1]); }); it('returns the same user row on re-upsert (idempotent — no duplicate insert)', async () => { const iss = 'https://auth.example.com'; const sub = 'user-sub-001'; const existingRow = { id: 1, oidcIss: iss, oidcSub: sub, displayName: 'Lucas', color: COLOR_PALETTE[0], createdAt: new Date(), }; // select returns existing row immediately mockDb.select.mockImplementation(() => makeSelectChain([existingRow])); const user = await upsertUser(iss, sub, 'Lucas'); // Must NOT call insert (idempotent path) expect(mockDb.insert).not.toHaveBeenCalled(); expect(user!.id).toBe(1); expect(user!.color).toBe(COLOR_PALETTE[0]); }); it('uses oidc_iss + oidc_sub as identity key, never email', async () => { const iss = 'https://auth.example.com'; const sub = 'user-sub-003'; let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); if (selectCallCount === 2) { // app_config.setup_complete — not set → normal insert path return makeSelectChain([]); } if (selectCallCount === 3) { // Used-colors query — no existing users return { from: vi.fn().mockResolvedValue([]) }; } if (selectCallCount === 4) { // Zero-admin COUNT check return makeSelectChain([{ count: 0 }]); } return makeSelectChain([ { id: 3, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], isAdmin: true, createdAt: new Date(), }, ]); }); mockDb.insert.mockReturnValue(makeInsertChain([{ id: 3 }])); // Pass a displayName (e.g. email) — identity still keyed on iss+sub await upsertUser(iss, sub, 'lucas@example.com'); // The insert values must include oidcIss and oidcSub, not email as key const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]; expect(insertValues).toBeDefined(); expect(insertValues.oidcIss).toBe(iss); expect(insertValues.oidcSub).toBe(sub); // No 'email' property should be used as an identity field expect(insertValues).not.toHaveProperty('email'); }); it('returns the full user row including id, color, displayName', async () => { const iss = 'https://auth.example.com'; const sub = 'user-sub-004'; const existingRow = { id: 42, oidcIss: iss, oidcSub: sub, displayName: 'Alice', color: '#9B6DC5', isAdmin: false, createdAt: new Date(), }; mockDb.select.mockImplementation(() => makeSelectChain([existingRow])); const user = await upsertUser(iss, sub, 'Alice'); expect(user).toBeDefined(); expect(user!.id).toBe(42); expect(user!.color).toBe('#9B6DC5'); expect(user!.displayName).toBe('Alice'); }); // ── Plan 10-02: first-login-wins is_admin bootstrap (D-01) ──────────────── it('inserts first user with is_admin=true when zero admins exist (first-login-wins, D-01)', async () => { const iss = 'https://auth.example.com'; const sub = 'sub-first-admin'; let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); // not found if (selectCallCount === 2) { // app_config.setup_complete — not set → normal insert path return makeSelectChain([]); } if (selectCallCount === 3) { // Used-colors query — empty table return { from: vi.fn().mockResolvedValue([]) }; } if (selectCallCount === 4) { // Zero-admin COUNT check — 0 admins → shouldBeAdmin=true return makeSelectChain([{ count: 0 }]); } // Re-fetch after insert return makeSelectChain([ { id: 10, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], isAdmin: true, createdAt: new Date(), }, ]); }); mockDb.insert.mockReturnValue(makeInsertChain([{ id: 10 }])); await upsertUser(iss, sub); // The inserted row must include isAdmin: true const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]; expect(insertValues).toBeDefined(); expect(insertValues.isAdmin).toBe(true); }); it('inserts subsequent user with is_admin=false when an admin already exists', async () => { const iss = 'https://auth.example.com'; const sub = 'sub-second-user'; let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); // not found if (selectCallCount === 2) { // app_config.setup_complete — not set → normal insert path return makeSelectChain([]); } if (selectCallCount === 3) { // Used-colors query — one existing user return { from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]) }; } if (selectCallCount === 4) { // Zero-admin COUNT check — 1 admin already exists → shouldBeAdmin=false return makeSelectChain([{ count: 1 }]); } return makeSelectChain([ { id: 11, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[1], isAdmin: false, createdAt: new Date(), }, ]); }); mockDb.insert.mockReturnValue(makeInsertChain([{ id: 11 }])); await upsertUser(iss, sub); // The inserted row must include isAdmin: false const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]; expect(insertValues).toBeDefined(); expect(insertValues.isAdmin).toBe(false); }); it('does NOT change is_admin on re-upsert of an existing user (early-return path unchanged)', async () => { const iss = 'https://auth.example.com'; const sub = 'sub-existing-member'; const existingRow = { id: 5, oidcIss: iss, oidcSub: sub, displayName: 'Member', color: COLOR_PALETTE[0], isAdmin: false, createdAt: new Date(), }; // Existing user found on first select — early return, no insert mockDb.select.mockImplementation(() => makeSelectChain([existingRow])); const user = await upsertUser(iss, sub, 'Member'); // Must NOT insert expect(mockDb.insert).not.toHaveBeenCalled(); // isAdmin must NOT be changed (returned as-is from DB row) expect(user!.isAdmin).toBe(false); // select must only have been called once (identity lookup, then early-return) expect(mockDb.select).toHaveBeenCalledTimes(1); }); // WR-01: upsertUser's fresh OIDC insert must set claimed=true. // An OIDC-created user is identity-bound at insert time and must NOT be born // with claimed=false, which would make it indistinguishable from a pending // wizard bootstrap user (oidcIss IS NULL AND claimed=false) in the TOCTOU guard. it('WR-01: fresh OIDC insert sets claimed=true (OIDC user is never a pending wizard user)', async () => { const iss = 'https://auth.example.com'; const sub = 'sub-wr01-claimed'; let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); // identity lookup — not found if (selectCallCount === 2) return makeSelectChain([]); // setup_complete — not set if (selectCallCount === 3) { // Used-colors query return { from: vi.fn().mockResolvedValue([]) }; } if (selectCallCount === 4) { // Admin COUNT return makeSelectChain([{ count: 0 }]); } // Re-fetch after insert return makeSelectChain([ { id: 20, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], isAdmin: true, claimed: true, createdAt: new Date(), }, ]); }); mockDb.insert.mockReturnValue(makeInsertChain([{ id: 20 }])); await upsertUser(iss, sub); // The insert values must include claimed: true const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]; expect(insertValues).toBeDefined(); expect(insertValues.claimed).toBe(true); // And it must carry a real oidcIss (not null — distinguishable from wizard row) expect(insertValues.oidcIss).toBe(iss); expect(insertValues.oidcSub).toBe(sub); }); }); // ── Plan 12-03: D-08 first-login-claims (Wave-2 GREEN — full test implementations) ── // // These tests cover the first-login-claims flow implemented in upsertUser. // When setup_complete='true', the first OIDC login from an unknown iss+sub // claims the single unclaimed local user row (oidcIss IS NULL AND claimed=false), // binding oidcIss/oidcSub and setting claimed=true. // // Key constraints (D-08 / D-10): // - NEVER look up by email — only oidcIss+oidcSub and claimed=false // - Preserve is_admin on the claimed row (operator pre-set it in the wizard) // - Only claim when setup_complete='true' in app_config // // Select call order for the claim path: // 1. Lookup by oidc_iss + oidc_sub (no match — new identity) // 2. Read app_config.setup_complete (returns 'true') // 3. Select unclaimed user (isNull(oidcIss) AND claimed=false) // → db.update() to bind identity + set claimed=true // → return merged row (is_admin preserved) describe('upsertUser — D-08 first-login-claims', () => { beforeEach(() => { vi.clearAllMocks(); }); it( 'when setup_complete is true and an unclaimed local user exists (oidcIss IS NULL, claimed=false), ' + 'binds oidcIss+oidcSub+claimed=true and returns the updated row', async () => { const iss = 'https://auth.example.com'; const sub = 'new-oidc-sub-001'; const unclaimedUser = { id: 99, oidcIss: null, oidcSub: null, displayName: 'Wizard User', color: COLOR_PALETTE[0], isAdmin: true, claimed: false, createdAt: new Date(), }; // Select call order for claim path: // 1. Identity lookup — no match (new iss+sub) // 2. app_config.setup_complete — returns 'true' // 3. Unclaimed user query — returns unclaimedUser let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); // identity lookup — no match if (selectCallCount === 2) return makeSelectChain([{ value: 'true' }]); // setup_complete return makeSelectChain([unclaimedUser]); // unclaimed user found }); mockDb.update.mockReturnValue(makeUpdateChain()); const result = await upsertUser(iss, sub, 'New User'); // Must call update (not insert) to claim the row expect(mockDb.update).toHaveBeenCalled(); expect(mockDb.insert).not.toHaveBeenCalled(); // Returned row has new oidcIss/oidcSub and claimed=true expect(result).toBeDefined(); expect(result!.oidcIss).toBe(iss); expect(result!.oidcSub).toBe(sub); expect(result!.claimed).toBe(true); // id matches the pre-existing unclaimed row expect(result!.id).toBe(99); }, ); it( 'when setup_complete is true and claimed user row is found, ' + 'preserves is_admin on the claimed user (admin flag not overwritten)', async () => { const iss = 'https://auth.example.com'; const sub = 'new-oidc-sub-002'; const unclaimedAdminUser = { id: 100, oidcIss: null, oidcSub: null, displayName: 'Admin Wizard', color: COLOR_PALETTE[0], isAdmin: true, // pre-set by wizard — must be preserved claimed: false, createdAt: new Date(), }; let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); // identity lookup — no match if (selectCallCount === 2) return makeSelectChain([{ value: 'true' }]); // setup_complete return makeSelectChain([unclaimedAdminUser]); // unclaimed admin user found }); mockDb.update.mockReturnValue(makeUpdateChain()); const result = await upsertUser(iss, sub); // is_admin must be preserved from the unclaimed row — not set to false expect(result!.isAdmin).toBe(true); // Must have been claimed expect(result!.claimed).toBe(true); expect(result!.oidcIss).toBe(iss); }, ); it( 'when setup_complete is false (or unset), does NOT check for unclaimed rows — ' + 'falls through to normal insert path', async () => { const iss = 'https://auth.example.com'; const sub = 'new-oidc-sub-fallthrough'; // Select call order for normal insert path (setup_complete NOT 'true'): // 1. Identity lookup — no match // 2. app_config.setup_complete — returns [] (no row → flagRow=undefined) // 3. Used-colors query // 4. Admin COUNT // 5. Re-fetch after insert let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); // identity lookup if (selectCallCount === 2) return makeSelectChain([]); // setup_complete — not set if (selectCallCount === 3) { // Used-colors query return { from: vi.fn().mockResolvedValue([]) }; } if (selectCallCount === 4) { // Admin COUNT — 0 → shouldBeAdmin=true return makeSelectChain([{ count: 0 }]); } // Re-fetch after insert return makeSelectChain([ { id: 50, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], isAdmin: true, claimed: false, createdAt: new Date(), }, ]); }); mockDb.update.mockReturnValue(makeUpdateChain()); mockDb.insert.mockReturnValue(makeInsertChain([{ id: 50 }])); const result = await upsertUser(iss, sub); // Normal insert path — should insert, not update (claim) expect(mockDb.insert).toHaveBeenCalled(); expect(result!.id).toBe(50); // setup_complete was false → claim path never checked for unclaimed rows // (update called at most once — could be called for displayName update on existing path, // but here it's a new user so update should NOT be called for claiming) // We simply confirm insert happened and a valid row returned expect(result!.oidcIss).toBe(iss); }, ); it( 'when setup_complete is true but NO unclaimed local user exists, ' + 'falls through to normal insert path (new user row created)', async () => { const iss = 'https://auth.example.com'; const sub = 'new-oidc-sub-no-unclaimed'; // Select call order (setup_complete=true, no unclaimed user): // 1. Identity lookup — no match // 2. app_config.setup_complete — returns 'true' // 3. Unclaimed user query — returns [] (none found) // 4. Used-colors query // 5. Admin COUNT — admin already exists (setup is complete, claimed user is admin) // 6. Re-fetch after insert let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); // identity lookup if (selectCallCount === 2) return makeSelectChain([{ value: 'true' }]); // setup_complete if (selectCallCount === 3) return makeSelectChain([]); // unclaimed user — none if (selectCallCount === 4) { // Used-colors query return { from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]) }; } if (selectCallCount === 5) { // Admin COUNT — 1 admin exists (setup complete → existing admin from claim) return makeSelectChain([{ count: 1 }]); } // Re-fetch after insert return makeSelectChain([ { id: 60, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[1], isAdmin: false, // NOT admin because setup_complete=true && count !== 0 claimed: false, createdAt: new Date(), }, ]); }); mockDb.update.mockReturnValue(makeUpdateChain()); mockDb.insert.mockReturnValue(makeInsertChain([{ id: 60 }])); const result = await upsertUser(iss, sub); // Fell through to normal insert (no unclaimed user to claim) expect(mockDb.insert).toHaveBeenCalled(); expect(result!.id).toBe(60); // setup_complete=true means shouldBeAdmin = false (even if count were 0) const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]; expect(insertValues.isAdmin).toBe(false); }, ); it( 'first-login-claims NEVER uses email as a lookup key — ' + 'identity is strictly oidcIss IS NULL AND claimed=false (D-10)', async () => { const iss = 'https://auth.example.com'; const sub = 'new-oidc-sub-no-email'; const unclaimedUser = { id: 101, oidcIss: null, oidcSub: null, displayName: 'No Email User', color: COLOR_PALETTE[0], isAdmin: true, claimed: false, createdAt: new Date(), }; let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); // identity lookup if (selectCallCount === 2) return makeSelectChain([{ value: 'true' }]); // setup_complete return makeSelectChain([unclaimedUser]); // unclaimed user }); mockDb.update.mockReturnValue(makeUpdateChain()); // Pass an email as displayName — it must NOT be used as a lookup key await upsertUser(iss, sub, 'user@example.com'); // The update call sets must NOT contain any email-based where clause // The update set must bind oidcIss + oidcSub; it must NOT set an email field const updateSetArgs = mockDb.update.mock.results[0]?.value?.set.mock.calls[0]?.[0]; expect(updateSetArgs).toBeDefined(); expect(updateSetArgs).not.toHaveProperty('email'); expect(updateSetArgs.oidcIss).toBe(iss); expect(updateSetArgs.oidcSub).toBe(sub); expect(updateSetArgs.claimed).toBe(true); }, ); });