- Add apps/api/tests/routes/setup.test.ts with it.todo() scaffolds for: SETUP-01 (GET /api/setup/status), SETUP-02 (validate/vapid + validate/oidc), SETUP-01 (POST /api/setup/credential), SETUP-04 (POST /api/setup/complete × 2 → first 200, second 423), D-10 effective-config 423 guard. All 15 cases RED (it.todo) so Plan 02 implements against real failing tests. - Extend apps/api/tests/auth/user.test.ts with D-08 first-login-claims describe block (5 it.todo() cases): unclaimed user bind, is_admin preservation, setup_complete=false fallthrough, no unclaimed fallthrough, no email lookup (D-10) - Suite collects clean: 375 passed | 20 todo — no import errors
441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
/**
|
|
* 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<typeof vi.fn>;
|
|
insert: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
// ── Plan 12-01: D-08 first-login-claims scaffold (Wave-0 RED placeholders) ──
|
|
//
|
|
// These tests cover the first-login-claims flow that Plan 12-02 implements in
|
|
// upsertUser. When setup_complete='true', the first OIDC login from an unknown
|
|
// iss+sub should "claim" 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
|
|
|
|
describe('upsertUser — D-08 first-login-claims (Wave-0 scaffold, Plan 12-02 implements)', () => {
|
|
it.todo(
|
|
'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',
|
|
);
|
|
|
|
it.todo(
|
|
'when setup_complete is true and claimed user row is found, ' +
|
|
'preserves is_admin on the claimed user (admin flag not overwritten)',
|
|
);
|
|
|
|
it.todo(
|
|
'when setup_complete is false (or unset), does NOT check for unclaimed rows — ' +
|
|
'falls through to normal insert path',
|
|
);
|
|
|
|
it.todo(
|
|
'when setup_complete is true but NO unclaimed local user exists, ' +
|
|
'falls through to normal insert path (new user row created)',
|
|
);
|
|
|
|
it.todo(
|
|
'first-login-claims NEVER uses email as a lookup key — ' +
|
|
'identity is strictly oidcIss IS NULL AND claimed=false (D-10)',
|
|
);
|
|
});
|