/** * Admin + self-service credential surface — integration tests (Plan 10-03, TDD RED→GREEN). * * Covers: * Task 2 (admin routes): * - T-10-08: GET /api/admin/members returns 403 for non-admin, 200+list for admin * - T-10-09 (Pitfall 7): POST /api/admin/credentials with invalid password → 400, * no submitted password string in response body; same shape for createFastmailClient * throw and for network/PROPFIND failures * - T-10-09 (Pitfall 7): malformed/bad-email payload → 400 { error: 'Invalid request' } * - T-10-11: valid credential → 200, stored encrypted (encrypted_password != plaintext) * - T-10-08: POST /api/admin/credentials as non-admin → 403 * - ADMIN-02: PUT /api/admin/calendars/:id/shared → exactly one calendar is_shared=1 * - GET /api/admin/calendars as admin → 200 list; as non-admin → 403 * * Task 3 (self-service): * - T-10-12 (Pitfall 6): POST /api/me/credential with body userId for another user * → credential written to session user (currentUserId), NOT the body userId * - POST /api/me/credential with valid credential → 200, stored encrypted * - POST /api/me/credential with bad credential → 400 generic, no echoed password * - POST /api/me/credential does NOT require admin (normal member can use it) * * Architecture: * - Tests import `app` (NOT adminRouter directly — Pitfall 9) * - Real DB integration: dev MariaDB must be running; set DB_HOST=127.0.0.1 * - CalDAV (createFastmailClient) is mocked to avoid live Fastmail calls */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { db } from '../../src/db/client.js'; import { users, memberCredentials, calendars, appConfig, localCredentials } from '../../src/db/schema.js'; import { verifyPassword } from '../../src/auth/localCredentials.js'; // --------------------------------------------------------------------------- // CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail. // Tests that need PROPFIND-success inject a mock client with fetchCalendars resolving. // Tests that need PROPFIND-failure inject a mock client that throws on fetchCalendars. // Tests that need createFastmailClient itself to throw (e.g. bad email format) throw // before returning a client at all. // // Also mock loadClientForUser and triggerTargetedResync from outboxWorker to avoid // the initial-sync touching the real DB in tests. // --------------------------------------------------------------------------- type FetchCalendarsResult = { url: string; displayName: string }[]; let mockFetchCalendars: () => Promise = () => Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/user/', displayName: 'Test Cal' }]); let mockCreateClientShouldThrow = false; let mockCreateClientError: Error | null = null; vi.mock('../../src/broker/client.js', () => ({ createFastmailClient: vi.fn().mockImplementation(async () => { if (mockCreateClientShouldThrow) { throw mockCreateClientError ?? new Error('Mock CalDAV client creation error'); } return { fetchCalendars: mockFetchCalendars }; }), })); vi.mock('../../src/broker/outboxWorker.js', () => ({ loadClientForUser: vi.fn().mockResolvedValue({ fetchCalendars: () => Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/', displayName: 'Test' }]), }), triggerTargetedResync: vi.fn().mockResolvedValue(undefined), startOutboxWorker: vi.fn(), initOutboxTrigger: vi.fn(), scheduleOutboxDrain: vi.fn(), runOutboxDrain: vi.fn(), __resetDrainState: vi.fn(), assembleRruleString: vi.fn(), stopOutboxTrigger: vi.fn(), })); // Mock sync.js to avoid actual CalDAV sync during tests vi.mock('../../src/broker/sync.js', () => ({ syncCalendar: vi.fn().mockResolvedValue(undefined), })); // --------------------------------------------------------------------------- // Dev-bypass mock: allows us to simulate different users in tests. // currentDevUserId controls which user is "logged in" via the bypass. // --------------------------------------------------------------------------- let currentDevUserId = 1; vi.mock('../../src/auth/devBypass.js', () => ({ devAuthBypass: () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise) => { c.set('user', { id: currentDevUserId }); await next(); }, })); vi.mock('@hono/oidc-auth', () => ({ oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), getAuth: () => null, })); // --------------------------------------------------------------------------- // Seed helpers // --------------------------------------------------------------------------- async function seedUser(label: string, isAdmin = false): Promise { const [result] = await db .insert(users) .values({ oidcIss: 'https://auth.test', oidcSub: `sub-${label}-${randomUUID()}`, displayName: `User ${label}`, color: '#4A90D9', isAdmin, }) .$returningId(); return result.id; } async function seedCalendar(userId: number, label: string, isShared = false): Promise { const [result] = await db .insert(calendars) .values({ userId, url: `https://caldav.fastmail.com/cal/${label}-${randomUUID()}/`, displayName: `Calendar ${label}`, isShared, }) .$returningId(); return result.id; } // --------------------------------------------------------------------------- // Request helpers // --------------------------------------------------------------------------- function jsonRequest(method: string, path: string, body?: unknown): Request { return new Request(`http://localhost${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined, }); } // --------------------------------------------------------------------------- // Import `app` lazily (after mocks are registered) — Pitfall 9: must import // `app` not `adminRouter` directly so the route mount + guard are exercised. // --------------------------------------------------------------------------- async function getApp() { const { app } = await import('../../src/index.js'); return app; } // --------------------------------------------------------------------------- // Encryption key required for encryptPassword // --------------------------------------------------------------------------- beforeEach(async () => { process.env.APP_PASSWORD_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; // Reset CalDAV mock state to default (success path) mockCreateClientShouldThrow = false; mockCreateClientError = null; mockFetchCalendars = () => Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/user/', displayName: 'Test Cal' }]); }); afterEach(async () => { // Clean up seeded users and credentials between tests await db.delete(localCredentials); await db.delete(memberCredentials); await db.delete(calendars); await db.delete(users).where(eq(users.oidcIss, 'https://auth.test')); // Also clean up users created by POST /api/admin/members (no oidcIss) await db.delete(users).where(eq(users.oidcIss, '')); }); // =========================================================================== // GET /api/admin/members // =========================================================================== describe('GET /api/admin/members', () => { it('returns 403 for a non-admin authenticated user (Pitfall 9 / T-10-08)', async () => { const nonAdminId = await seedUser('non-admin', false); currentDevUserId = nonAdminId; const app = await getApp(); const res = await app.fetch(jsonRequest('GET', '/api/admin/members')); expect(res.status).toBe(403); const body = await res.json(); expect(body).toEqual({ error: 'Forbidden' }); }); it('returns 200 with member list for an admin user', async () => { const adminId = await seedUser('admin', true); const memberId = await seedUser('member', false); currentDevUserId = adminId; const app = await getApp(); const res = await app.fetch(jsonRequest('GET', '/api/admin/members')); expect(res.status).toBe(200); const body = (await res.json()) as { members: unknown[] }; expect(Array.isArray(body.members)).toBe(true); // Should include at least the two seeded users expect(body.members.length).toBeGreaterThanOrEqual(2); // Each member should have id, displayName, color, hasCredential const memberRow = (body.members as Array<{ id: number }>).find((m) => m.id === memberId); expect(memberRow).toBeDefined(); expect(typeof (memberRow as { hasCredential: boolean }).hasCredential).toBe('boolean'); }); }); // =========================================================================== // POST /api/admin/credentials // =========================================================================== describe('POST /api/admin/credentials', () => { it('returns 403 for a non-admin user (T-10-08)', async () => { const nonAdminId = await seedUser('non-admin-cred', false); const targetId = await seedUser('target', false); currentDevUserId = nonAdminId; const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/admin/credentials', { userId: targetId, providerType: 'caldav', fastmailEmail: 'user@fastmail.com', appPassword: 'test-secret-pass', }), ); expect(res.status).toBe(403); }); it('returns 400 with no echoed password when PROPFIND/auth fails (Pitfall 7 / T-10-09)', async () => { const adminId = await seedUser('admin-cred-fail', true); const targetId = await seedUser('target-fail', false); currentDevUserId = adminId; // Make fetchCalendars throw (simulates PROPFIND/auth failure) const submittedPassword = 'super-secret-app-password-12345'; mockFetchCalendars = () => Promise.reject(new Error('Authentication failed')); const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/admin/credentials', { userId: targetId, providerType: 'caldav', fastmailEmail: 'user@fastmail.com', appPassword: submittedPassword, }), ); expect(res.status).toBe(400); const bodyText = await res.text(); // Pitfall 7: submitted password MUST NOT appear in any 400 response expect(bodyText).not.toContain(submittedPassword); // No Zod error fields expect(bodyText).not.toContain('received'); expect(bodyText).not.toContain('issues'); // Should return generic error shape const body = JSON.parse(bodyText) as { error: string }; expect(body.error).toBe('Invalid request'); }); it('returns 400 when createFastmailClient itself throws (bad email / malformed input)', async () => { const adminId = await seedUser('admin-cred-throw', true); const targetId = await seedUser('target-throw', false); currentDevUserId = adminId; const submittedPassword = 'bad-email-secret-pass-99999'; mockCreateClientShouldThrow = true; mockCreateClientError = new Error('Invalid email format'); const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/admin/credentials', { userId: targetId, providerType: 'caldav', fastmailEmail: 'user@fastmail.com', appPassword: submittedPassword, }), ); expect(res.status).toBe(400); const bodyText = await res.text(); expect(bodyText).not.toContain(submittedPassword); const body = JSON.parse(bodyText) as { error: string }; expect(body.error).toBe('Invalid request'); }); it('returns 400 with no echo for a network error (all failures map to same generic 400)', async () => { const adminId = await seedUser('admin-network-err', true); const targetId = await seedUser('target-network', false); currentDevUserId = adminId; const submittedPassword = 'network-error-secret-abc123'; mockFetchCalendars = () => Promise.reject(new Error('Network connection refused')); const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/admin/credentials', { userId: targetId, providerType: 'caldav', fastmailEmail: 'user@fastmail.com', appPassword: submittedPassword, }), ); expect(res.status).toBe(400); const bodyText = await res.text(); expect(bodyText).not.toContain(submittedPassword); const body = JSON.parse(bodyText) as { error: string }; expect(body.error).toBe('Invalid request'); }); it('returns 400 with no Zod echo when schema validation fails (e.g. missing fields)', async () => { const adminId = await seedUser('admin-schema-fail', true); currentDevUserId = adminId; const submittedPassword = 'schema-fail-secret-zxcvbn'; const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/admin/credentials', { // Missing userId — schema validation should fail providerType: 'caldav', fastmailEmail: 'user@fastmail.com', appPassword: submittedPassword, }), ); expect(res.status).toBe(400); const bodyText = await res.text(); // Pitfall 7: even schema validation failure must not echo the password expect(bodyText).not.toContain(submittedPassword); expect(bodyText).not.toContain('received'); expect(bodyText).not.toContain('issues'); const body = JSON.parse(bodyText) as { error: string }; expect(body.error).toBe('Invalid request'); }); it('returns 200 on valid credential and stores encrypted password (T-10-11)', async () => { const adminId = await seedUser('admin-valid', true); const targetId = await seedUser('target-valid', false); currentDevUserId = adminId; const plainPassword = 'valid-app-password-abcxyz-9876'; // Mock fetchCalendars to succeed (default) const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/admin/credentials', { userId: targetId, providerType: 'caldav', fastmailEmail: 'target@fastmail.com', appPassword: plainPassword, }), ); expect(res.status).toBe(200); // Verify stored credential is NOT the plaintext (encrypted) const [stored] = await db .select({ encryptedPassword: memberCredentials.encryptedPassword }) .from(memberCredentials) .where(eq(memberCredentials.userId, targetId)) .limit(1); expect(stored).toBeDefined(); expect(stored.encryptedPassword).not.toBe(plainPassword); // Should be the JSON-encoded AES-256-GCM structure const parsed = JSON.parse(stored.encryptedPassword) as Record; expect(parsed.iv).toBeDefined(); expect(parsed.ciphertext).toBeDefined(); // Response body must not contain the password const bodyText = JSON.stringify(await res.clone().json()); expect(bodyText).not.toContain(plainPassword); }); }); // =========================================================================== // GET /api/admin/calendars // =========================================================================== describe('GET /api/admin/calendars', () => { it('returns 403 for non-admin', async () => { const nonAdminId = await seedUser('non-admin-cal', false); currentDevUserId = nonAdminId; const app = await getApp(); const res = await app.fetch(jsonRequest('GET', '/api/admin/calendars')); expect(res.status).toBe(403); }); it('returns 200 with calendar list for admin', async () => { const adminId = await seedUser('admin-cal-list', true); await seedCalendar(adminId, 'personal', false); await seedCalendar(adminId, 'family', true); currentDevUserId = adminId; const app = await getApp(); const res = await app.fetch(jsonRequest('GET', '/api/admin/calendars')); expect(res.status).toBe(200); const body = (await res.json()) as { calendars: unknown[] }; expect(Array.isArray(body.calendars)).toBe(true); // At least the two seeded calendars expect(body.calendars.length).toBeGreaterThanOrEqual(2); }); }); // =========================================================================== // PUT /api/admin/calendars/:id/shared (ADMIN-02, Pitfall 7-adjacent) // =========================================================================== describe('PUT /api/admin/calendars/:id/shared', () => { it('returns 403 for non-admin', async () => { const nonAdminId = await seedUser('non-admin-shared', false); currentDevUserId = nonAdminId; const app = await getApp(); const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/1/shared')); expect(res.status).toBe(403); }); it('sets exactly one calendar to is_shared=1 and clears prior shared calendar (ADMIN-02)', async () => { const adminId = await seedUser('admin-shared', true); const calA = await seedCalendar(adminId, 'cal-a', true); // initially shared const calB = await seedCalendar(adminId, 'cal-b', false); currentDevUserId = adminId; const app = await getApp(); const res = await app.fetch(jsonRequest('PUT', `/api/admin/calendars/${calB}/shared`)); expect(res.status).toBe(200); // calA should now be is_shared=false, calB should be is_shared=true const [rowA] = await db .select({ isShared: calendars.isShared }) .from(calendars) .where(eq(calendars.id, calA)) .limit(1); const [rowB] = await db .select({ isShared: calendars.isShared }) .from(calendars) .where(eq(calendars.id, calB)) .limit(1); expect(rowA.isShared).toBe(false); expect(rowB.isShared).toBe(true); // Verify exactly ONE calendar has is_shared=true after the update const sharedRows = await db .select({ id: calendars.id }) .from(calendars) .where(eq(calendars.isShared, true)); // Only calB should be shared (of the ones we seeded; other pre-existing rows excluded // by checking only our seeded IDs) const sharedIds = sharedRows.map((r) => r.id); expect(sharedIds).toContain(calB); expect(sharedIds).not.toContain(calA); }); it('returns 404 for a non-existent target and does NOT clear the existing shared calendar (CR-01)', async () => { const adminId = await seedUser('admin-shared-missing', true); const calA = await seedCalendar(adminId, 'cal-a-keep', true); // currently the shared family lane currentDevUserId = adminId; const app = await getApp(); // PUT a target id that does not exist. The handler must verify the target // exists BEFORE clearing the current shared lane, so a bad/stale id can // never silently wipe the family's shared calendar (BLOCKER CR-01). const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/99999999/shared')); expect(res.status).toBe(404); // calA must STILL be shared — the no-op target must not have cleared it. const [rowA] = await db .select({ isShared: calendars.isShared }) .from(calendars) .where(eq(calendars.id, calA)) .limit(1); expect(rowA.isShared).toBe(true); }); }); // =========================================================================== // POST /api/me/credential (Task 3 — member self-service, D-07, T-10-12) // =========================================================================== describe('POST /api/me/credential', () => { it('ignores body userId and writes only to session user (Pitfall 6 / T-10-12)', async () => { const userA = await seedUser('self-service-a', false); const userB = await seedUser('self-service-b', false); currentDevUserId = userA; // logged in as userA const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/me/credential', { userId: userB, // body says userB — must be IGNORED providerType: 'caldav', fastmailEmail: 'usera@fastmail.com', appPassword: 'self-service-pass-abcxyz', }), ); expect(res.status).toBe(200); // userA should have a credential const [credA] = await db .select({ id: memberCredentials.id }) .from(memberCredentials) .where(eq(memberCredentials.userId, userA)) .limit(1); expect(credA).toBeDefined(); // userB should NOT have a credential const [credB] = await db .select({ id: memberCredentials.id }) .from(memberCredentials) .where(eq(memberCredentials.userId, userB)) .limit(1); expect(credB).toBeUndefined(); }); it('returns 200 and stores encrypted password for valid credential', async () => { const userId = await seedUser('self-service-valid', false); currentDevUserId = userId; const plainPassword = 'self-service-valid-pass-qwerty9876'; const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/me/credential', { providerType: 'caldav', fastmailEmail: 'me@fastmail.com', appPassword: plainPassword, }), ); expect(res.status).toBe(200); const [stored] = await db .select({ encryptedPassword: memberCredentials.encryptedPassword }) .from(memberCredentials) .where(eq(memberCredentials.userId, userId)) .limit(1); expect(stored).toBeDefined(); expect(stored.encryptedPassword).not.toBe(plainPassword); const parsed = JSON.parse(stored.encryptedPassword) as Record; expect(parsed.iv).toBeDefined(); }); it('returns 400 with no echoed password when credential validation fails (Pitfall 7)', async () => { const userId = await seedUser('self-service-fail', false); currentDevUserId = userId; const submittedPassword = 'self-service-bad-pass-xyz9999'; mockFetchCalendars = () => Promise.reject(new Error('PROPFIND auth failure')); const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/me/credential', { providerType: 'caldav', fastmailEmail: 'me@fastmail.com', appPassword: submittedPassword, }), ); expect(res.status).toBe(400); const bodyText = await res.text(); expect(bodyText).not.toContain(submittedPassword); const body = JSON.parse(bodyText) as { error: string }; expect(body.error).toBe('Invalid request'); }); it('does NOT require admin — a normal member can set their own credential', async () => { const userId = await seedUser('non-admin-self-service', false); currentDevUserId = userId; const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/me/credential', { providerType: 'caldav', fastmailEmail: 'member@fastmail.com', appPassword: 'member-pass-abcabc123', }), ); // Should succeed (200) — no admin requirement on this endpoint expect(res.status).toBe(200); }); }); // =========================================================================== // admin timezone config (Plan 18-02: D-01, D-02, D-03, D-04) // =========================================================================== describe('admin timezone config', () => { // Clean up household_timezone between tests to avoid bleed afterEach(async () => { await db.delete(appConfig).where(eq(appConfig.key, 'household_timezone')); }); // ------------------------------------------------------------------------- // GET /api/admin/config/timezone — access control // ------------------------------------------------------------------------- it('GET returns 403 for a non-admin authenticated user (T-18-03)', async () => { const nonAdminId = await seedUser('tz-non-admin-get', false); currentDevUserId = nonAdminId; const app = await getApp(); const res = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone')); expect(res.status).toBe(403); }); // ------------------------------------------------------------------------- // PUT /api/admin/config/timezone — access control // ------------------------------------------------------------------------- it('PUT returns 403 for a non-admin authenticated user (T-18-03)', async () => { const nonAdminId = await seedUser('tz-non-admin-put', false); currentDevUserId = nonAdminId; const app = await getApp(); const res = await app.fetch( jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }), ); expect(res.status).toBe(403); }); // ------------------------------------------------------------------------- // GET /api/admin/config/timezone — admin reads when no row is stored // ------------------------------------------------------------------------- it('GET as admin with no stored row returns 200 with isExplicitlySet: false and a non-empty timezone', async () => { const adminId = await seedUser('tz-admin-get-default', true); currentDevUserId = adminId; const app = await getApp(); const res = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone')); expect(res.status).toBe(200); const body = (await res.json()) as { timezone: string; isExplicitlySet: boolean }; expect(typeof body.timezone).toBe('string'); expect(body.timezone.length).toBeGreaterThan(0); expect(body.isExplicitlySet).toBe(false); }); // ------------------------------------------------------------------------- // PUT then GET round-trip // ------------------------------------------------------------------------- it('PUT America/Chicago then GET returns that value with isExplicitlySet: true', async () => { const adminId = await seedUser('tz-admin-roundtrip', true); currentDevUserId = adminId; const app = await getApp(); const putRes = await app.fetch( jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }), ); expect(putRes.status).toBe(200); const getRes = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone')); expect(getRes.status).toBe(200); const body = (await getRes.json()) as { timezone: string; isExplicitlySet: boolean }; expect(body.timezone).toBe('America/Chicago'); expect(body.isExplicitlySet).toBe(true); }); // ------------------------------------------------------------------------- // PUT with UTC must succeed (Pitfall 2) // ------------------------------------------------------------------------- it('PUT UTC returns 200 (Pitfall 2 — UTC must be accepted)', async () => { const adminId = await seedUser('tz-admin-utc', true); currentDevUserId = adminId; const app = await getApp(); const res = await app.fetch( jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'UTC' }), ); expect(res.status).toBe(200); }); // ------------------------------------------------------------------------- // PUT with invalid IANA string returns 400 and writes nothing (T-18-04) // ------------------------------------------------------------------------- it('PUT Not/AZone returns 400 and does not store a value (T-18-04)', async () => { const adminId = await seedUser('tz-admin-invalid', true); currentDevUserId = adminId; const app = await getApp(); const res = await app.fetch( jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'Not/AZone' }), ); expect(res.status).toBe(400); // Verify no row was written to app_config const [row] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, 'household_timezone')) .limit(1); expect(row).toBeUndefined(); }); // ------------------------------------------------------------------------- // POST /api/admin/config/timezone/seed — seeds when unset (D-02) // ------------------------------------------------------------------------- // ------------------------------------------------------------------------- // POST /api/admin/config/timezone/seed — access control (WR-02) // ------------------------------------------------------------------------- it('POST seed returns 403 for a non-admin authenticated user (WR-02 access control)', async () => { const nonAdminId = await seedUser('tz-non-admin-seed', false); currentDevUserId = nonAdminId; const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'America/Chicago' }), ); expect(res.status).toBe(403); }); // ------------------------------------------------------------------------- // POST /api/admin/config/timezone/seed — seeds when unset (D-02) // ------------------------------------------------------------------------- it('POST seed when unset stores the value and returns { ok: true, seeded: true } (D-02, WR-02)', async () => { const adminId = await seedUser('tz-admin-seed-unset', true); currentDevUserId = adminId; const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Europe/London' }), ); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean; seeded: boolean }; // WR-02: seeded flag must be true when this request performed the seed expect(body.ok).toBe(true); expect(body.seeded).toBe(true); // Verify the value was stored const [row] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, 'household_timezone')) .limit(1); expect(row?.value).toBe('Europe/London'); }); // ------------------------------------------------------------------------- // POST /api/admin/config/timezone/seed — no-overwrite when already set (D-03) // ------------------------------------------------------------------------- it('POST seed when already set does NOT overwrite the existing value (D-03)', async () => { const adminId = await seedUser('tz-admin-seed-overwrite', true); currentDevUserId = adminId; const app = await getApp(); // First set a value via PUT const putRes = await app.fetch( jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }), ); expect(putRes.status).toBe(200); // Now attempt to seed a different value const seedRes = await app.fetch( jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Asia/Tokyo' }), ); expect(seedRes.status).toBe(200); // The stored value must still be America/Chicago (no overwrite, D-03) const [row] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, 'household_timezone')) .limit(1); expect(row?.value).toBe('America/Chicago'); }); // ------------------------------------------------------------------------- // POST /api/admin/config/timezone/seed — idempotent under concurrent race (WR-02) // ------------------------------------------------------------------------- it('POST seed when value already exists returns 200 with seeded:false and does NOT throw (WR-02 idempotent race)', async () => { const adminId = await seedUser('tz-admin-seed-race', true); currentDevUserId = adminId; const app = await getApp(); // First seed establishes the value (simulates the "winner" of the race) const firstRes = await app.fetch( jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'America/New_York' }), ); expect(firstRes.status).toBe(200); const firstBody = (await firstRes.json()) as { ok: boolean; seeded: boolean }; expect(firstBody.seeded).toBe(true); // Second seed with a different value — simulates the "loser" of the race that // arrives after the row already exists. Pre-fix this would throw a 500 due to // the PK constraint. Post-fix it must return 200 with seeded:false and preserve // the original value (D-03 no-overwrite). const secondRes = await app.fetch( jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Europe/Paris' }), ); expect(secondRes.status).toBe(200); const secondBody = (await secondRes.json()) as { ok: boolean; seeded: boolean }; expect(secondBody.ok).toBe(true); expect(secondBody.seeded).toBe(false); // The stored value must still be America/New_York (winner's value preserved, D-03) const [row] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, 'household_timezone')) .limit(1); expect(row?.value).toBe('America/New_York'); }); // ------------------------------------------------------------------------- // POST seed — `seeded` flag is derived from what the DB actually did (WR-02) // ------------------------------------------------------------------------- it('POST seed reports seeded:false when a row was pre-inserted directly, not via the endpoint (WR-02 accurate flag)', async () => { const adminId = await seedUser('tz-admin-seed-derived', true); currentDevUserId = adminId; const app = await getApp(); // Insert the row directly (bypassing the seed endpoint) so no request-scoped // pre-flight SELECT could have observed "unset". A correct implementation must // derive seeded from the INSERT result (affectedRows), so this returns false. await db.insert(appConfig).values({ key: 'household_timezone', value: 'America/Denver' }); const res = await app.fetch( jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Asia/Tokyo' }), ); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean; seeded: boolean }; expect(body.ok).toBe(true); expect(body.seeded).toBe(false); // D-03 preserved: the directly-inserted value is untouched. const [row] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, 'household_timezone')) .limit(1); expect(row?.value).toBe('America/Denver'); }); }); // =========================================================================== // POST /api/admin/members — admin create local member (AUTH-LOCAL-07, T-19-05, T-19-06, T-19-10) // =========================================================================== describe('POST /api/admin/members', () => { it('Test 1: creates a users row + local_credentials row, hash verifies against initialPassword', async () => { const adminId = await seedUser('admin-create-member', true); currentDevUserId = adminId; const app = await getApp(); const initialPassword = 'correct-horse-battery-staple1!'; const res = await app.fetch( jsonRequest('POST', '/api/admin/members', { displayName: 'New Member', username: `newmember-${randomUUID()}`, initialPassword, }), ); expect(res.status).toBe(201); const body = (await res.json()) as { id: number }; expect(typeof body.id).toBe('number'); // Verify users row was created const [userRow] = await db .select({ id: users.id, displayName: users.displayName }) .from(users) .where(eq(users.id, body.id)) .limit(1); expect(userRow).toBeDefined(); expect(userRow.displayName).toBe('New Member'); // Verify local_credentials row was created with a verifiable hash const [credRow] = await db .select({ passwordHash: localCredentials.passwordHash }) .from(localCredentials) .where(eq(localCredentials.userId, body.id)) .limit(1); expect(credRow).toBeDefined(); expect(verifyPassword(credRow.passwordHash, initialPassword)).toBe(true); }); it('Test 2: duplicate username returns 409 — transaction rolls back (no orphaned users row)', async () => { const adminId = await seedUser('admin-dup-username', true); currentDevUserId = adminId; const app = await getApp(); const uniqueUsername = `dupuser-${randomUUID()}`; // Create the first member successfully const firstRes = await app.fetch( jsonRequest('POST', '/api/admin/members', { displayName: 'First Member', username: uniqueUsername, initialPassword: 'first-password-abc123', }), ); expect(firstRes.status).toBe(201); const firstBody = (await firstRes.json()) as { id: number }; const countBefore = (await db.select({ id: users.id }).from(users)).length; // Try to create a second member with the same username const dupRes = await app.fetch( jsonRequest('POST', '/api/admin/members', { displayName: 'Duplicate Member', username: uniqueUsername, initialPassword: 'second-password-xyz789', }), ); expect(dupRes.status).toBe(409); // No new users row should have been created (transaction rolled back) const countAfter = (await db.select({ id: users.id }).from(users)).length; expect(countAfter).toBe(countBefore); // The first member's local_credentials must still exist const [credRow] = await db .select({ id: localCredentials.id }) .from(localCredentials) .where(eq(localCredentials.userId, firstBody.id)) .limit(1); expect(credRow).toBeDefined(); }); it('Test 3: admin can reset any member password without knowing the current one', async () => { const adminId = await seedUser('admin-reset-pw', true); const memberId = await seedUser('member-reset-target', false); currentDevUserId = adminId; const app = await getApp(); // First create a local_credentials row for the member const createRes = await app.fetch( jsonRequest('POST', '/api/admin/members', { displayName: 'Reset Target', username: `reset-target-${randomUUID()}`, initialPassword: 'old-password-123', }), ); expect(createRes.status).toBe(201); const { id: newMemberId } = (await createRes.json()) as { id: number }; // Admin resets the password const newPassword = 'new-password-xyz789-secure'; const resetRes = await app.fetch( jsonRequest('POST', `/api/admin/members/${newMemberId}/password`, { newPassword, }), ); expect(resetRes.status).toBe(200); // Verify the stored hash now verifies against the new password const [credRow] = await db .select({ passwordHash: localCredentials.passwordHash }) .from(localCredentials) .where(eq(localCredentials.userId, newMemberId)) .limit(1); expect(credRow).toBeDefined(); expect(verifyPassword(credRow.passwordHash, newPassword)).toBe(true); expect(verifyPassword(credRow.passwordHash, 'old-password-123')).toBe(false); }); it('Test 4: non-admin gets 403 on POST /members and POST /members/:id/password', async () => { const nonAdminId = await seedUser('non-admin-member-create', false); currentDevUserId = nonAdminId; const app = await getApp(); const createRes = await app.fetch( jsonRequest('POST', '/api/admin/members', { displayName: 'Should Fail', username: `fail-${randomUUID()}`, initialPassword: 'password-fail-123', }), ); expect(createRes.status).toBe(403); const resetRes = await app.fetch( jsonRequest('POST', '/api/admin/members/1/password', { newPassword: 'fail-new-password', }), ); expect(resetRes.status).toBe(403); }); it('Test 5: GET /api/admin/members returns hasLocalCredential:true for member with local_credentials row', async () => { const adminId = await seedUser('admin-haslocalcred', true); currentDevUserId = adminId; const app = await getApp(); // Create a member via the API (which creates a local_credentials row) const createRes = await app.fetch( jsonRequest('POST', '/api/admin/members', { displayName: 'Has Local Cred', username: `has-cred-${randomUUID()}`, initialPassword: 'has-cred-password-123', }), ); expect(createRes.status).toBe(201); const { id: newMemberId } = (await createRes.json()) as { id: number }; // GET /members should show hasLocalCredential:true for this member const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members')); expect(getRes.status).toBe(200); const body = (await getRes.json()) as { members: Array<{ id: number; hasLocalCredential: boolean }> }; const memberRow = body.members.find((m) => m.id === newMemberId); expect(memberRow).toBeDefined(); expect(memberRow!.hasLocalCredential).toBe(true); // The admin user (no local_credentials row) should have hasLocalCredential:false const adminRow = body.members.find((m) => m.id === adminId); expect(adminRow).toBeDefined(); expect(adminRow!.hasLocalCredential).toBe(false); }); });