Files
Lucas BergerandClaude Sonnet 4.6 687f9dc9fa fix(12): WR-01 narrow TOCTOU guard and set claimed=true for OIDC inserts
- apps/api/src/auth/user.ts: upsertUser step-5 insert now sets claimed=true
  for all OIDC-created users. An identity-bound OIDC user is never a pending
  wizard bootstrap user; explicit claimed=true prevents ambiguity with the
  (oidcIss IS NULL AND claimed=false) sentinel used by the TOCTOU guard and
  isSetupLocked. First-login-claims path is unaffected (it updates a
  pre-existing oidcIss=null row; this change only touches the fresh insert).

- apps/api/src/routes/setup.ts: TOCTOU guard in POST /credential now queries
  WHERE oidc_iss IS NULL AND claimed = false FOR UPDATE, matching the exact
  definition of a pending wizard bootstrap user. This provides defense-in-depth
  against any future path that could produce claimed=false OIDC rows.

- apps/api/tests/auth/user.test.ts: new WR-01 test asserts that the fresh
  OIDC insert sets claimed=true in the values passed to db.insert().

- apps/api/tests/routes/setup.test.ts: new WR-01 integration test seeds an
  OIDC user with claimed=false (oidcIss NOT NULL) and verifies POST /credential
  still succeeds (guard ignores the OIDC row, only counts local wizard rows).

All 402 API tests, 253 PWA tests, and typecheck pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:46:41 -04:00

761 lines
26 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 + 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<typeof vi.fn>;
insert: ReturnType<typeof vi.fn>;
update: 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;
}
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);
},
);
});