Phase 10 — Admin Role & Settings (ADMIN-01/02/03) #17
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user