diff --git a/apps/api/tests/auth/user.test.ts b/apps/api/tests/auth/user.test.ts index 9bf197d..dfaf314 100644 --- a/apps/api/tests/auth/user.test.ts +++ b/apps/api/tests/auth/user.test.ts @@ -1,7 +1,15 @@ /** - * Auth: upsertUser color round-robin + identity stability + * Auth: upsertUser color round-robin + identity stability + first-login-wins is_admin * - * Tests for apps/api/src/auth/user.ts (Plan 02) + * Tests for apps/api/src/auth/user.ts (Plan 02 + Plan 10-02) + * + * 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). */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -67,9 +75,11 @@ describe('upsertUser', () => { const iss = 'https://auth.example.com'; const sub = 'user-sub-001'; - // First select: no existing user - // Second select (used colors): no existing users → no colors in use → palette[0] - // Third select (re-fetch after insert): return the inserted row + // Select call order (new user, post Plan 10-02): + // 1. Lookup by iss+sub — not found + // 2. Used-colors query — no existing users → palette[0] + // 3. Zero-admin COUNT check — 0 admins → shouldBeAdmin=true + // 4. Re-fetch after insert — return the inserted row let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; @@ -83,6 +93,10 @@ describe('upsertUser', () => { from: vi.fn().mockResolvedValue([]), }; } + if (selectCallCount === 3) { + // Zero-admin COUNT check — 0 admins → first user becomes admin + return makeSelectChain([{ count: 0 }]); + } // Re-fetch after insert return makeSelectChain([ { @@ -91,6 +105,7 @@ describe('upsertUser', () => { oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], + isAdmin: true, createdAt: new Date(), }, ]); @@ -122,6 +137,10 @@ describe('upsertUser', () => { from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]), }; } + if (selectCallCount === 3) { + // Zero-admin COUNT check — 1 admin already exists → shouldBeAdmin=false + return makeSelectChain([{ count: 1 }]); + } return makeSelectChain([ { id: 2, @@ -129,6 +148,7 @@ describe('upsertUser', () => { oidcSub: sub2, displayName: null, color: COLOR_PALETTE[1], + isAdmin: false, createdAt: new Date(), }, ]); @@ -161,6 +181,10 @@ describe('upsertUser', () => { .mockResolvedValue([{ color: COLOR_PALETTE[0] }, { color: COLOR_PALETTE[2] }]), }; } + if (selectCallCount === 3) { + // Zero-admin COUNT check — admin exists → shouldBeAdmin=false + return makeSelectChain([{ count: 1 }]); + } return makeSelectChain([ { id: 5, @@ -168,6 +192,7 @@ describe('upsertUser', () => { oidcSub: sub, displayName: null, color: COLOR_PALETTE[1], + isAdmin: false, createdAt: new Date(), }, ]); @@ -213,7 +238,12 @@ describe('upsertUser', () => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); if (selectCallCount === 2) { - return { from: vi.fn().mockResolvedValue([{ count: 0 }]) }; + // Used-colors query — no existing users + return { from: vi.fn().mockResolvedValue([]) }; + } + if (selectCallCount === 3) { + // Zero-admin COUNT check + return makeSelectChain([{ count: 0 }]); } return makeSelectChain([ { @@ -222,6 +252,7 @@ describe('upsertUser', () => { oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], + isAdmin: true, createdAt: new Date(), }, ]); @@ -249,6 +280,7 @@ describe('upsertUser', () => { oidcSub: sub, displayName: 'Alice', color: '#9B6DC5', + isAdmin: false, createdAt: new Date(), }; @@ -261,4 +293,93 @@ describe('upsertUser', () => { 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) { + // Used-colors query — empty table + return { from: vi.fn().mockResolvedValue([]) }; + } + if (selectCallCount === 3) { + // 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) { + // Used-colors query — one existing user + return { from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]) }; + } + if (selectCallCount === 3) { + // 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); + }); });