/** * 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) * * 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'; // 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(), }, })); // 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; }; // 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; } 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 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++; if (selectCallCount === 1) { // Lookup by iss+sub — not found return makeSelectChain([]); } if (selectCallCount === 2) { // Used-colors query — no existing users return { 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([ { 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) { // 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 === 3) { // 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) { // 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 === 3) { // 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) { // Used-colors query — no existing users return { from: vi.fn().mockResolvedValue([]) }; } if (selectCallCount === 3) { // 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) { // 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); }); });