diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index f803b5987..ad144813f 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -21,14 +21,6 @@ vi.mock('../../../src/db/database', async () => { db, closeDb: () => {}, reinitialize: () => {}, - canAccessTrip: (tripId: any, userId: number) => - db - .prepare( - `SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)` - ) - .get(userId, tripId, userId), - isOwner: (tripId: any, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; return mock; }); @@ -95,6 +87,16 @@ import { createTestPasswordResetTokensRepo, } from '../../helpers/test-uow'; import { budgetRepoArgs } from '../../helpers/budget-repos'; +import type { TestOrm } from '../../helpers/test-orm'; +import { countRows, deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; +import { readAppSetting, setAppSetting } from '../../helpers/factories/settings'; +import { readUser } from '../../helpers/factories/users'; +import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; +import { InviteTokens } from '../../../src/db/entities/InviteTokens.entity'; +import { McpTokens } from '../../../src/db/entities/McpTokens.entity'; +import { PasswordResetTokens } from '../../../src/db/entities/PasswordResetTokens.entity'; +import { PushSubscriptions } from '../../../src/db/entities/PushSubscriptions.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; @@ -136,23 +138,27 @@ beforeAll(async () => { }); /** A Web Push device row; the password paths only care whose it is. */ -function addPushDevice(userId: number, endpoint: string): void { - testDb - .prepare( - "INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key) VALUES (?, ?, 'p', 'a', 'k')", - ) - .run(userId, endpoint); +async function addPushDevice(userId: number, endpoint: string): Promise { + await insertRow(orm, PushSubscriptions, { user: userId, endpoint, p256dh: 'p', auth: 'a', vapid_public_key: 'k' }); } -function pushDeviceCount(userId: number): number { - return (testDb.prepare('SELECT COUNT(*) c FROM push_subscriptions WHERE user_id = ?').get(userId) as { c: number }).c; +function pushDeviceCount(userId: number): Promise { + return countRows(orm, PushSubscriptions, { user: userId }); } +let orm: TestOrm; + +/** Writes an instance setting, replacing any earlier value. */ +const appSetting = (key: string, value: string) => setAppSetting(orm, key, value); + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- -beforeEach(() => resetTestDb(testDb)); +beforeEach(async () => { + resetTestDb(testDb); + orm = await sharedTestOrm(testDb); +}); afterAll(() => testDb.close()); @@ -165,15 +171,13 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => { const { user } = createUser(testDb); // OIDC users are created with a random bcrypt hash, so password_hash is set — // the old guard keyed off a missing hash and therefore let the reset through. - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') - .run('sub-1129', 'https://idp.example', user.id); + await updateRows(orm, Users, { id: user.id }, { oidc_sub: 'sub-1129', oidc_issuer: 'https://idp.example' }); const result = await svc.requestPasswordReset(user.email, null); expect(result.reason).toBe('oidc_only'); expect(result.tokenForDelivery).toBeNull(); - const { n } = testDb.prepare('SELECT COUNT(*) AS n FROM password_reset_tokens WHERE user_id = ?') - .get(user.id) as { n: number }; + const n = await countRows(orm, PasswordResetTokens, { user: user.id }); expect(n).toBe(0); }); @@ -188,8 +192,7 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => { const { user } = createUser(testDb); await svc.requestPasswordReset(user.email, null); await svc.requestPasswordReset(user.email, null); - const { n } = testDb.prepare('SELECT COUNT(*) AS n FROM password_reset_tokens WHERE user_id = ? AND consumed_at IS NULL') - .get(user.id) as { n: number }; + const n = await countRows(orm, PasswordResetTokens, { user: user.id, consumed_at: null }); expect(n).toBe(1); }); }); @@ -208,9 +211,7 @@ describe('getAppSettings', () => { it('AUTH-DB-014: returns settings object for admin with known key allow_registration', async () => { const { user } = createAdmin(testDb); - testDb - .prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')") - .run(); + await appSetting('allow_registration', 'true'); const result = await svc.getAppSettings(user.id); expect(result.status).toBeUndefined(); expect(result.data).toBeDefined(); @@ -251,9 +252,11 @@ describe('isOidcOnlyMode', () => { // --------------------------------------------------------------------------- describe('resolveAuthToggles', () => { - afterEach(() => { + afterEach(async () => { vi.unstubAllEnvs(); - testDb.prepare("DELETE FROM app_settings WHERE key IN ('password_login','password_registration','oidc_login','oidc_registration','oidc_only','allow_registration')").run(); + await deleteRows(orm, AppSettings, { + key: { $in: ['password_login', 'password_registration', 'oidc_login', 'oidc_registration', 'oidc_only', 'allow_registration'] }, + }); }); it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', async () => { @@ -278,7 +281,7 @@ describe('resolveAuthToggles', () => { it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', async () => { vi.stubEnv('OIDC_ONLY', ''); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); + await appSetting('allow_registration', 'false'); const t = await svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(false); @@ -288,8 +291,8 @@ describe('resolveAuthToggles', () => { it('AUTH-DB-022d: new granular keys take precedence over legacy keys', async () => { vi.stubEnv('OIDC_ONLY', ''); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run(); + await appSetting('allow_registration', 'false'); + await appSetting('password_registration', 'true'); const t = await svc.resolveAuthToggles(); // New key present → use new keys, allow_registration ignored expect(t.password_registration).toBe(true); @@ -298,8 +301,8 @@ describe('resolveAuthToggles', () => { it('AUTH-DB-022e: OIDC_ONLY env var overrides new granular keys for password toggles', async () => { vi.stubEnv('OIDC_ONLY', 'true'); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run(); + await appSetting('password_login', 'true'); + await appSetting('password_registration', 'true'); const t = await svc.resolveAuthToggles(); // OIDC_ONLY forces password toggles off even when DB says true expect(t.password_login).toBe(false); @@ -308,10 +311,10 @@ describe('resolveAuthToggles', () => { it('AUTH-DB-022f: individual granular keys can be set independently', async () => { vi.stubEnv('OIDC_ONLY', ''); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'false')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_login', 'true')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_registration', 'false')").run(); + await appSetting('password_login', 'true'); + await appSetting('password_registration', 'false'); + await appSetting('oidc_login', 'true'); + await appSetting('oidc_registration', 'false'); const t = await svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(false); @@ -336,7 +339,7 @@ describe('setupMfa', () => { it('AUTH-DB-023: returns 400 when MFA is already enabled', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1 }); const result = await svc.setupMfa(user.id, user.email); expect(result.status).toBe(400); expect(result.error).toMatch(/already enabled/i); @@ -427,7 +430,7 @@ describe('validateInviteToken', () => { // createInviteToken with used_count already at max const invite = createInviteToken(testDb, { max_uses: 1 }); // manually set used_count = 1 to simulate exhaustion - testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id); + await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 }); const result = await svc.validateInviteToken(invite.token); expect(result.status).toBe(410); }); @@ -446,9 +449,9 @@ describe('validateInviteToken', () => { describe('registerUser — OIDC-only / registration-disabled', () => { it('AUTH-DB-033: returns 403 when oidc_only=true and not first user', async () => { createUser(testDb); // ensure userCount > 0 - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run(); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); + await insertRow(orm, AppSettings, { key: 'oidc_only', value: 'true' }); + await insertRow(orm, AppSettings, { key: 'oidc_issuer', value: 'https://x' }); + await insertRow(orm, AppSettings, { key: 'oidc_client_id', value: 'id' }); const result = await svc.registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' }); expect(result.status).toBe(403); @@ -457,7 +460,7 @@ describe('registerUser — OIDC-only / registration-disabled', () => { it('AUTH-DB-034: returns 403 when registration is disabled and no invite', async () => { createUser(testDb); // ensure userCount > 0 - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); + await appSetting('allow_registration', 'false'); const result = await svc.registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' }); expect(result.status).toBe(403); @@ -471,9 +474,9 @@ describe('registerUser — OIDC-only / registration-disabled', () => { describe('loginUser — OIDC-only mode', () => { it('AUTH-DB-035: returns 403 when oidc_only=true', async () => { const { user, password } = createUser(testDb); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); + await appSetting('oidc_only', 'true'); + await appSetting('oidc_issuer', 'https://x'); + await appSetting('oidc_client_id', 'id'); const result = await svc.loginUser({ email: user.email, password }); expect(result.status).toBe(403); @@ -487,9 +490,9 @@ describe('loginUser — OIDC-only mode', () => { describe('changePassword — OIDC-only mode', () => { it('AUTH-DB-036: returns 403 when oidc_only=true', async () => { const { user, password } = createUser(testDb); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); + await appSetting('oidc_only', 'true'); + await appSetting('oidc_issuer', 'https://x'); + await appSetting('oidc_client_id', 'id'); const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(result.status).toBe(403); @@ -497,24 +500,22 @@ describe('changePassword — OIDC-only mode', () => { }); describe('changePassword — session invalidation', () => { - const pvOf = (id: number) => - (testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(id) as { password_version: number }).password_version; - const mcpCount = (id: number) => - (testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(id) as { c: number }).c; + const pvOf = async (id: number) => (await readUser(orm, id)).password_version; + const mcpCount = (id: number) => countRows(orm, McpTokens, { user: id }); it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', async () => { const { user, password } = createUser(testDb); await tokens.createMcpToken(user.id, 'cli'); - expect(pvOf(user.id)).toBe(0); - expect(mcpCount(user.id)).toBe(1); + expect(await pvOf(user.id)).toBe(0); + expect(await mcpCount(user.id)).toBe(1); const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(result.success).toBe(true); expect(typeof result.token).toBe('string'); // fresh session for the current device - expect(pvOf(user.id)).toBe(1); // old JWT/cookie sessions now rejected by the pv gate - expect(mcpCount(user.id)).toBe(0); // static MCP tokens revoked + expect(await pvOf(user.id)).toBe(1); // old JWT/cookie sessions now rejected by the pv gate + expect(await mcpCount(user.id)).toBe(0); // static MCP tokens revoked }); it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', async () => { @@ -556,16 +557,16 @@ describe('changePassword — session invalidation', () => { it('AUTH-DB-036e: the block is atomic — a failing mcp_tokens prune rolls the whole password change back (F2, task-5-review-security.md, USER-CLEANUP-009 pattern)', async () => { const { user, password } = createUser(testDb); await tokens.createMcpToken(user.id, 'cli'); - const hashBefore = (testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(user.id) as { password_hash: string }).password_hash; + const hashBefore = (await readUser(orm, user.id)).password_hash; const mcpTokensRepo = await createTestMcpTokensRepo(testDb); const deleteAllForUserSpy = vi.spyOn(mcpTokensRepo, 'deleteAllForUser').mockRejectedValueOnce(new Error('boom')); try { await expect(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' })).rejects.toThrow('boom'); - expect(pvOf(user.id)).toBe(0); // password_version unchanged - expect((testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(user.id) as { password_hash: string }).password_hash).toBe(hashBefore); // old hash unchanged - expect(mcpCount(user.id)).toBe(1); // the mcp_tokens row survives — the DELETE never committed + expect(await pvOf(user.id)).toBe(0); // password_version unchanged + expect((await readUser(orm, user.id)).password_hash).toBe(hashBefore); // old hash unchanged + expect(await mcpCount(user.id)).toBe(1); // the mcp_tokens row survives — the DELETE never committed } finally { deleteAllForUserSpy.mockRestore(); } @@ -574,31 +575,31 @@ describe('changePassword — session invalidation', () => { it('AUTH-DB-036f: forgets every push device of the user, and only of that user', async () => { const { user, password } = createUser(testDb); const { user: other } = createUser(testDb); - addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine-1'); - addPushDevice(user.id, 'https://web.push.apple.com/mine-2'); - addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs'); + await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine-1'); + await addPushDevice(user.id, 'https://web.push.apple.com/mine-2'); + await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs'); const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(result.success).toBe(true); - expect(pushDeviceCount(user.id)).toBe(0); - expect(pushDeviceCount(other.id)).toBe(1); + expect(await pushDeviceCount(user.id)).toBe(0); + expect(await pushDeviceCount(other.id)).toBe(1); }); it('AUTH-DB-036g: a refused change keeps the push devices', async () => { const { user } = createUser(testDb); - addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); + await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); const result = await svc.changePassword(user.id, user.email, { current_password: 'wrong', new_password: 'New1234!' }); expect(result.status).toBe(401); - expect(pushDeviceCount(user.id)).toBe(1); + expect(await pushDeviceCount(user.id)).toBe(1); }); it('AUTH-DB-036h: drops the push devices in the same transaction as the password change, so a failure changes nothing', async () => { const { user, password } = createUser(testDb); await tokens.createMcpToken(user.id, 'cli'); - addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); + await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END"); try { await expect( @@ -608,9 +609,9 @@ describe('changePassword — session invalidation', () => { testDb.exec('DROP TRIGGER boom'); } - expect(pvOf(user.id)).toBe(0); - expect(mcpCount(user.id)).toBe(1); - expect(pushDeviceCount(user.id)).toBe(1); + expect(await pvOf(user.id)).toBe(0); + expect(await mcpCount(user.id)).toBe(1); + expect(await pushDeviceCount(user.id)).toBe(1); // The new hash rolled back as well, so the old password is still the one that counts. expect((await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' })).success) .toBe(true); @@ -624,7 +625,7 @@ describe('changePassword — session invalidation', () => { describe('disableMfa — require_mfa policy', () => { it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); + await appSetting('require_mfa', 'true'); const result = await svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' }); expect(result.status).toBe(403); @@ -688,16 +689,16 @@ describe('getAppConfig', () => { // hardcoded copy dropped pkpass, pkpasses, md and markdown, so the client // greyed out types the server accepts. createUser(testDb); - testDb.prepare("DELETE FROM app_settings WHERE key = 'allowed_file_types'").run(); + await deleteRows(orm, AppSettings, { key: 'allowed_file_types' }); const cfg = await svc.getAppConfig(null); expect(cfg.allowed_file_types).toBe(DEFAULT_ALLOWED_EXTENSIONS); }); it('AUTH-DB-051: authenticated caller gets the permissions block; app_settings rows flow through', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('notification_channels', 'email,webhook')").run(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', 'jpg,png')").run(); + await appSetting('require_mfa', 'true'); + await appSetting('notification_channels', 'email,webhook'); + await appSetting('allowed_file_types', 'jpg,png'); const cfg = await svc.getAppConfig({ id: user.id } as never); expect(cfg.permissions).toBeDefined(); expect(cfg.require_mfa).toBe(true); @@ -754,7 +755,7 @@ describe('getAppConfig', () => { expect((await svc.getAppConfig(null)).passkey_configured).toBe(true); vi.unstubAllEnvs(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('webauthn_rp_id', 'localhost')").run(); + await appSetting('webauthn_rp_id', 'localhost'); expect((await svc.getAppConfig(null)).passkey_configured).toBe(true); }); @@ -769,17 +770,17 @@ describe('getAppConfig', () => { it('AUTH-DB-052g: place_shadow_enabled fails closed, on the unauthenticated payload', async () => { createUser(testDb); - const set = testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('place_shadow_enabled', ?)"); + const set = { run: (value: string) => appSetting('place_shadow_enabled', value) }; // No row is the state of every install that never touched the switch. expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false); - set.run('false'); + await set.run('false'); expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false); // Only the literal 'true' opens it, the mirror image of the fail-open // places_* flags beside it, which close only on the literal 'false'. - set.run('1'); + await set.run('1'); expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false); expect((await svc.getAppConfig(null)).places_enrich_enabled).toBe(true); - set.run('true'); + await set.run('true'); expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(true); }); }); @@ -835,13 +836,13 @@ describe('registerUser — success paths', () => { it('AUTH-DB-058: an invite bypasses disabled registration and bumps used_count', async () => { createUser(testDb); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); + await appSetting('allow_registration', 'false'); const invite = createInviteToken(testDb, { max_uses: 2 }); const result = await svc.registerUser({ username: 'invited', email: 'invited@x.com', password: 'Secure123!', invite_token: invite.token }); expect(result.error).toBeUndefined(); - const { used_count } = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number }; + const { used_count } = (await findRow(orm, InviteTokens, { id: invite.id })) ?? {}; expect(used_count).toBe(1); - testDb.prepare("DELETE FROM app_settings WHERE key = 'allow_registration'").run(); + await deleteRows(orm, AppSettings, { key: 'allow_registration' }); }); }); @@ -869,7 +870,7 @@ describe('loginUser — credential branches', () => { expect(typeof result.token).toBe('string'); expect(result.user).not.toHaveProperty('password_hash'); expect(result.auditAction).toBe('user.login'); - const row = testDb.prepare('SELECT login_count, last_login FROM users WHERE id = ?').get(user.id) as { login_count: number; last_login: string }; + const row = await readUser(orm, user.id); expect(row.login_count).toBe(1); expect(row.last_login).not.toBeNull(); }); @@ -887,7 +888,7 @@ describe('loginUser — credential branches', () => { it('AUTH-DB-063: an MFA-enabled account gets the interstitial mfa_token instead of a session', async () => { const { user } = createUser(testDb); - testDb.prepare("UPDATE users SET mfa_enabled = 1, mfa_secret = 'enc:JBSWY3DPEHPK3PXP' WHERE id = ?").run(user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:JBSWY3DPEHPK3PXP' }); const { password } = { password: undefined }; void password; const result = await svc.loginUser({ email: user.email, password: 'TestPass' }); @@ -923,7 +924,7 @@ describe('deleteAccount', () => { it('AUTH-DB-067: deletes a regular user row', async () => { const { user } = createUser(testDb); expect(await svc.deleteAccount(user.id, user.email, 'user')).toEqual({ success: true }); - expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id)).toBeUndefined(); + expect(await findRow(orm, Users, { id: user.id })).toBeNull(); }); }); @@ -942,11 +943,11 @@ describe('updateAppSettings', () => { it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1 }); const result = await svc.updateAppSettings(user.id, { require_mfa: true }); expect(result.success).toBe(true); expect(result.auditSummary).toMatchObject({ require_mfa: true }); - testDb.prepare("DELETE FROM app_settings WHERE key = 'require_mfa'").run(); + await deleteRows(orm, AppSettings, { key: 'require_mfa' }); }); it('AUTH-DB-074: lockout prevention refuses disabling every login method', async () => { @@ -959,13 +960,13 @@ describe('updateAppSettings', () => { // The reminder crons read their gates per tick now, so a settings change no // longer flags (or needs) a scheduler restart. const { user } = createAdmin(testDb); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('smtp_pass', 'stored')").run(); + await appSetting('smtp_pass', 'stored'); const result = await svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' }); expect(result.success).toBe(true); - const { value } = testDb.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as { value: string }; + const value = await readAppSetting(orm, 'smtp_pass'); expect(value).toBe('stored'); // sentinel never overwrites the secret expect(result.auditDebugDetails).not.toHaveProperty('smtp_pass', 'stored'); - testDb.prepare("DELETE FROM app_settings WHERE key IN ('smtp_pass','notification_channels')").run(); + await deleteRows(orm, AppSettings, { key: { $in: ['smtp_pass', 'notification_channels'] } }); }); }); @@ -977,7 +978,7 @@ describe('MFA success flows', () => { const result = await svc.enableMfa(user.id, code); expect(result.success).toBe(true); expect(result.backup_codes).toHaveLength(10); - const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string }; + const row = await readUser(orm, user.id); expect(row.mfa_enabled).toBe(1); expect(row.mfa_secret).toBe('enc:' + setup.secret); }); @@ -991,10 +992,10 @@ describe('MFA success flows', () => { it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', async () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret }); const result = await svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) }); expect(result).toEqual({ success: true, mfa_enabled: false }); - const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string | null }; + const row = await readUser(orm, user.id); expect(row.mfa_enabled).toBe(0); expect(row.mfa_secret).toBeNull(); }); @@ -1002,7 +1003,7 @@ describe('MFA success flows', () => { it('AUTH-DB-079: disableMfa answers 401 on a wrong password', async () => { const { user } = createUser(testDb); const secret = authenticator.generateSecret(); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret }); expect(await svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) })) .toEqual({ error: 'Incorrect password', status: 401 }); }); @@ -1010,7 +1011,7 @@ describe('MFA success flows', () => { it('AUTH-DB-080: verifyMfaLogin succeeds with a TOTP code from the interstitial token', async () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret }); const interstitial = await svc.loginUser({ email: user.email, password }); expect(interstitial.mfa_required).toBe(true); const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }); @@ -1023,12 +1024,13 @@ describe('MFA success flows', () => { const secret = authenticator.generateSecret(); const codes = ['AAAA-1111', 'BBBB-2222']; // hashBackupCode (legacy SHA-256) hashes still verify via matchBackupCode. - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = ? WHERE id = ?') - .run('enc:' + secret, JSON.stringify(codes.map(hashBackupCode)), '2000-01-01T00:00:00.000Z', user.id); + await updateRows(orm, Users, { id: user.id }, { + mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify(codes.map(hashBackupCode)), updated_at: '2000-01-01T00:00:00.000Z', + }); const interstitial = await svc.loginUser({ email: user.email, password }); const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' }); expect(typeof result.token).toBe('string'); - const row = testDb.prepare('SELECT mfa_backup_codes, updated_at FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string; updated_at: string }; + const row = await readUser(orm, user.id); expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(1); // used code spliced out // AU33 (setBackupCodesAndTouch, F3 task-5-review-security.md): the backup-code // login branch DOES stamp updated_at, unlike resetPassword's AU44 below. @@ -1046,7 +1048,7 @@ describe('resetPassword', () => { expect(issued.reason).toBe('issued'); const result = await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }); expect(result).toEqual({ success: true, userId: user.id }); - const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number }; + const row = await readUser(orm, user.id); expect(row.password_version).toBe(1); // token is burned — a second use answers the bespoke 400 expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' })) @@ -1057,21 +1059,21 @@ describe('resetPassword', () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); await tokens.createMcpToken(user.id, 'cli'); - addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); - addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs'); + await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); + await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs'); const issued = await svc.requestPasswordReset(user.email, null); expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) .toEqual({ success: true, userId: user.id }); - expect((testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(user.id) as { c: number }).c).toBe(0); - expect(pushDeviceCount(user.id)).toBe(0); - expect(pushDeviceCount(other.id)).toBe(1); + expect(await countRows(orm, McpTokens, { user: user.id })).toBe(0); + expect(await pushDeviceCount(user.id)).toBe(0); + expect(await pushDeviceCount(other.id)).toBe(1); }); it('AUTH-DB-082c: drops the push devices in the same transaction that burns the link, so a failure keeps the link', async () => { const { user } = createUser(testDb); - addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); + await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine'); const issued = await svc.requestPasswordReset(user.email, null); testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END"); try { @@ -1080,13 +1082,13 @@ describe('resetPassword', () => { testDb.exec('DROP TRIGGER boom'); } - expect(pushDeviceCount(user.id)).toBe(1); - const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number }; + expect(await pushDeviceCount(user.id)).toBe(1); + const row = await readUser(orm, user.id); expect(row.password_version).toBe(0); // Nothing was burned, so the same link still completes the reset. expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) .toEqual({ success: true, userId: user.id }); - expect(pushDeviceCount(user.id)).toBe(0); + expect(await pushDeviceCount(user.id)).toBe(0); }); it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', async () => { @@ -1097,7 +1099,7 @@ describe('resetPassword', () => { const { user } = createUser(testDb); const issued = await svc.requestPasswordReset(user.email, null); - testDb.prepare("UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00.000Z' WHERE user_id = ?").run(user.id); + await updateRows(orm, PasswordResetTokens, { user: user.id }, { expires_at: '2000-01-01T00:00:00.000Z' }); expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) .toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 }); }); @@ -1105,8 +1107,7 @@ describe('resetPassword', () => { it('AUTH-DB-084: an MFA-enabled account demands a code, then consumes a backup code', async () => { const { user } = createUser(testDb); const secret = authenticator.generateSecret(); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?') - .run('enc:' + secret, JSON.stringify([hashBackupCode('CCCC-3333')]), user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify([hashBackupCode('CCCC-3333')]) }); const issued = await svc.requestPasswordReset(user.email, null); // no code → mfa_required interstitial, token NOT burned expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) @@ -1136,24 +1137,24 @@ describe('resetPassword', () => { setBackupCodesAndTouchSpy.mockRestore(); } - const row = testDb.prepare('SELECT mfa_backup_codes FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string }; + const row = await readUser(orm, user.id); expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0); }); it('AUTH-DB-084b: the block is atomic — a failing mcp_tokens prune rolls the whole reset-password block back (F2, task-5-review-security.md, USER-CLEANUP-009 pattern)', async () => { const { user } = createUser(testDb); const issued = await svc.requestPasswordReset(user.email, null); - const hashBefore = (testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(user.id) as { password_hash: string }).password_hash; + const hashBefore = (await readUser(orm, user.id)).password_hash; const mcpTokensRepo = await createTestMcpTokensRepo(testDb); const deleteAllForUserSpy = vi.spyOn(mcpTokensRepo, 'deleteAllForUser').mockRejectedValueOnce(new Error('boom')); try { await expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).rejects.toThrow('boom'); - const row = testDb.prepare('SELECT password_hash, password_version FROM users WHERE id = ?').get(user.id) as { password_hash: string; password_version: number }; + const row = await readUser(orm, user.id); expect(row.password_hash).toBe(hashBefore); // old hash unchanged expect(row.password_version).toBe(0); - const tokenRow = testDb.prepare('SELECT consumed_at FROM password_reset_tokens WHERE user_id = ?').get(user.id) as { consumed_at: string | null }; + const tokenRow = await findRow(orm, PasswordResetTokens, { user: user.id }); expect(tokenRow.consumed_at).toBeNull(); // the reset token was never committed as consumed } finally { deleteAllForUserSpy.mockRestore(); @@ -1197,28 +1198,27 @@ describe('auth quirk fixes', () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const invite = createInviteToken(testDb, { max_uses: 5 }); - testDb.prepare('UPDATE invite_tokens SET trip_id = ? WHERE id = ?').run(trip.id, invite.id); + await updateRows(orm, InviteTokens, { id: invite.id }, { trip: trip.id }); joinTripAsMember.mockImplementationOnce(() => { throw new Error('boom'); }); const result = await svc.registerUser({ username: 'rollback', email: 'rollback@x.com', password: 'Secure123!', invite_token: invite.token }); expect(result).toEqual({ error: 'Error creating user', status: 500 }); - expect(testDb.prepare("SELECT id FROM users WHERE email = 'rollback@x.com'").get()).toBeUndefined(); - const { used_count } = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number }; + expect(await findRow(orm, Users, { email: 'rollback@x.com' })).toBeNull(); + const { used_count } = (await findRow(orm, InviteTokens, { id: invite.id })) ?? {}; expect(used_count).toBe(0); }); it('AUTH-DB-091: a backup-code login burns the code and records the login as one atomic pair', async () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?') - .run('enc:' + secret, JSON.stringify([hashBackupCode('DDDD-4444')]), user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify([hashBackupCode('DDDD-4444')]) }); const interstitial = await svc.loginUser({ email: user.email, password }); const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'DDDD-4444' }); expect(typeof result.token).toBe('string'); - const row = testDb.prepare('SELECT mfa_backup_codes, login_count, last_login FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string; login_count: number; last_login: string | null }; + const row = await readUser(orm, user.id); expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0); expect(row.login_count).toBe(1); expect(row.last_login).not.toBeNull();