diff --git a/server/tests/unit/db/repositories/Users.repository.test.ts b/server/tests/unit/db/repositories/Users.repository.test.ts index b8e4685d3..2fc8e67e6 100644 --- a/server/tests/unit/db/repositories/Users.repository.test.ts +++ b/server/tests/unit/db/repositories/Users.repository.test.ts @@ -4,6 +4,8 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser, createAdmin, type TestUser } from '../../../helpers/factories'; import { Users } from '../../../../src/db/entities/Users.entity'; +import { findRow, updateRows } from '../../../helpers/factories/rows'; +import { readUser } from '../../../helpers/factories/users'; import type { UsersRepository } from '../../../../src/db/repositories/Users.repository'; import { UserIdentityTakenError } from '../../../../src/db/repositories/Users.repository'; @@ -39,7 +41,7 @@ describe('UsersRepository', () => { it('USERSREPO-001: getEmail reads the stored email, matching SELECT email FROM users WHERE id = ?', async () => { const { user } = createUser(testDb, { email: 'someone@example.com' }); expect(await users.getEmail(user.id)).toBe('someone@example.com'); - const raw = testDb.prepare('SELECT email FROM users WHERE id = ?').get(user.id) as { email: string }; + const raw = await findRow(t, Users, { id: user.id }) as { email: string }; expect(await users.getEmail(user.id)).toBe(raw.email); }); @@ -49,20 +51,19 @@ describe('UsersRepository', () => { it('USERSREPO-003: getApiKeyColumn reads maps_api_key, matching SELECT maps_api_key FROM users WHERE id = ?', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('encrypted-maps-key', user.id); + await updateRows(t, Users, { id: user.id }, { maps_api_key: 'encrypted-maps-key' }); expect(await users.getApiKeyColumn(user.id, 'maps_api_key')).toBe('encrypted-maps-key'); }); it('USERSREPO-004: getApiKeyColumn reads unsplash_api_key, not the other two columns', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ?, unsplash_api_key = ?, amap_api_key = ? WHERE id = ?') - .run('maps-value', 'unsplash-value', 'amap-value', user.id); + await updateRows(t, Users, { id: user.id }, { maps_api_key: 'maps-value', unsplash_api_key: 'unsplash-value', amap_api_key: 'amap-value' }); expect(await users.getApiKeyColumn(user.id, 'unsplash_api_key')).toBe('unsplash-value'); }); it('USERSREPO-005: getApiKeyColumn reads amap_api_key', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET amap_api_key = ? WHERE id = ?').run('amap-value', user.id); + await updateRows(t, Users, { id: user.id }, { amap_api_key: 'amap-value' }); expect(await users.getApiKeyColumn(user.id, 'amap_api_key')).toBe('amap-value'); }); @@ -82,7 +83,7 @@ describe('UsersRepository', () => { it('USERSREPO-007: a raw UPDATE users SET email then getEmail reads the new email, in one query', async () => { const { user } = createUser(testDb, { email: 'old@example.com' }); expect(await users.getEmail(user.id)).toBe('old@example.com'); // populate the identity map - testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('new@example.com', user.id); + await updateRows(t, Users, { id: user.id }, { email: 'new@example.com' }); const { value, queries } = await withQueryCount(() => users.getEmail(user.id)); expect(value).toBe('new@example.com'); expect(queries).toBe(1); @@ -90,9 +91,9 @@ describe('UsersRepository', () => { it('USERSREPO-008: a raw UPDATE users SET maps_api_key then getApiKeyColumn reads the new value, in one query', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('old-key', user.id); + await updateRows(t, Users, { id: user.id }, { maps_api_key: 'old-key' }); expect(await users.getApiKeyColumn(user.id, 'maps_api_key')).toBe('old-key'); // populate the identity map - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('new-key', user.id); + await updateRows(t, Users, { id: user.id }, { maps_api_key: 'new-key' }); const { value, queries } = await withQueryCount(() => users.getApiKeyColumn(user.id, 'maps_api_key')); expect(value).toBe('new-key'); expect(queries).toBe(1); @@ -107,7 +108,7 @@ describe('UsersRepository', () => { it('USERSREPO-009: findByIdWithPasswordVersion (JV1) reads id/username/email/role/password_version', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET password_version = 3 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { password_version: 3 }); const row = await users.findByIdWithPasswordVersion(user.id); expect(row).toEqual({ id: user.id, username: user.username, email: user.email, role: 'user', password_version: 3 }); expect(await users.findByIdWithPasswordVersion(999999)).toBeNull(); @@ -115,7 +116,7 @@ describe('UsersRepository', () => { it('USERSREPO-009b: findForWsHandshake (Plan 4 Task 1, realtime.gateway.ts) reads id/username/email/role/mfa_enabled/password_version', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET password_version = 3, mfa_enabled = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { password_version: 3, mfa_enabled: 1 }); const row = await users.findForWsHandshake(user.id); expect(row).toEqual({ id: user.id, username: user.username, email: user.email, role: 'user', mfa_enabled: 1, password_version: 3 }); expect(await users.findForWsHandshake(999999)).toBeNull(); @@ -123,14 +124,14 @@ describe('UsersRepository', () => { it('USERSREPO-009c: findForWsHandshake reports mfa_enabled as null, not 0, for a user who never had it set (Plan 4 Task 1)', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET mfa_enabled = NULL WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { mfa_enabled: null }); expect((await users.findForWsHandshake(user.id))?.mfa_enabled).toBeNull(); }); it('USERSREPO-010: getPasswordVersion reads password_version, null for a missing user', async () => { const { user } = createUser(testDb); expect(await users.getPasswordVersion(user.id)).toBe(0); - testDb.prepare('UPDATE users SET password_version = 7 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { password_version: 7 }); expect(await users.getPasswordVersion(user.id)).toBe(7); expect(await users.getPasswordVersion(999999)).toBeNull(); }); @@ -140,14 +141,17 @@ describe('UsersRepository', () => { createUser(testDb); createUser(testDb); const { user: guest } = createUser(testDb); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); + await updateRows(t, Users, { id: guest.id }, { is_guest: 1 }); expect(await users.countNonGuest()).toBe(2); }); it('USERSREPO-012: a NULL is_guest row cannot exist — the column is NOT NULL DEFAULT 0 (verified against the baseline + guest-members migrations), so a direct INSERT that omits the column, or an explicit NULL, both resolve to 0, never leaving a row this filter would miscount', () => { + // test-sql-allow: probes the column's NOT NULL DEFAULT 0 itself, below the ORM and its entity defaults. testDb.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run('nog', 'nog@test.example.com', 'x', 'user'); + // test-sql-allow: probes the column's NOT NULL DEFAULT 0 itself, below the ORM and its entity defaults. const stored = testDb.prepare("SELECT is_guest FROM users WHERE username = 'nog'").get() as { is_guest: number }; expect(stored.is_guest).toBe(0); // the DEFAULT 0, not a nullable column silently coercing + // test-sql-allow: probes the column's NOT NULL DEFAULT 0 itself, below the ORM and its entity defaults. expect(() => testDb.prepare('UPDATE users SET is_guest = NULL WHERE username = ?').run('nog')).toThrow(/NOT NULL constraint failed/); }); }); @@ -157,9 +161,9 @@ describe('UsersRepository', () => { const { user: plainAdmin } = createAdmin(testDb); expect(await users.findAdminNeedingPasswordChange()).toBeNull(); const { user: flaggedUser } = createUser(testDb); - testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(flaggedUser.id); + await updateRows(t, Users, { id: flaggedUser.id }, { must_change_password: 1 }); expect(await users.findAdminNeedingPasswordChange()).toBeNull(); // not an admin - testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(plainAdmin.id); + await updateRows(t, Users, { id: plainAdmin.id }, { must_change_password: 1 }); expect(await users.findAdminNeedingPasswordChange()).toBe(plainAdmin.id); }); @@ -173,7 +177,7 @@ describe('UsersRepository', () => { it('USERSREPO-015: excludes a guest even on an exact match', async () => { const { user } = createUser(testDb, { email: 'guest1@example.com', username: 'guest1' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { is_guest: 1 }); expect(await users.findIdByEmailOrUsernameCI('guest1@example.com', 'guest1')).toBeNull(); }); @@ -210,7 +214,7 @@ describe('UsersRepository', () => { expect(row.oidc_issuer).toBeNull(); expect(row.avatar).toBeNull(); expect(row.created_at).not.toBeNull(); - const raw = testDb.prepare('SELECT * FROM users WHERE id = ?').get(row.id); + const raw = await findRow(t, Users, { id: row.id }); expect(raw).toMatchObject({ username: 'localuser', email: 'local@example.com', role: 'user', login_count: 0 }); }); @@ -255,8 +259,10 @@ describe('UsersRepository', () => { first_seen_version: '1.2.3', }); + // test-sql-allow: reads the table directly, so no EntityManager can flush the pending entity first. const pendingRow = testDb.prepare('SELECT * FROM users WHERE username = ?').get('pending-unflushed'); expect(pendingRow).toBeUndefined(); // insertUser must not have flushed it + // test-sql-allow: reads the table directly, so no EntityManager can flush the pending entity first. const freshRow = testDb.prepare('SELECT * FROM users WHERE username = ?').get('freshuser'); expect(freshRow).toBeDefined(); // but the intended row is there @@ -287,7 +293,7 @@ describe('UsersRepository', () => { it('USERSREPO-019: a guest with a matching email is not returned', async () => { const { user } = createUser(testDb, { email: 'guest2@example.com' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { is_guest: 1 }); expect(await users.findByEmailCI('guest2@example.com')).toBeNull(); }); @@ -316,7 +322,7 @@ describe('UsersRepository', () => { it('USERSREPO-018d: a guest with a matching email is not returned', async () => { const { user } = createUser(testDb, { email: 'guest2b@example.com' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { is_guest: 1 }); expect(await users.findByEmailLoweredBind('guest2b@example.com')).toBeNull(); }); @@ -348,7 +354,7 @@ describe('UsersRepository', () => { it('USERSREPO-057C: a guest row IS returned — no guest filter, unlike findByEmailCI/findForPasswordReset', async () => { const { user } = createUser(testDb, { email: 'demoguest@example.com' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { is_guest: 1 }); const row = await users.findByEmailExact('demoguest@example.com'); expect(row?.id).toBe(user.id); }); @@ -357,25 +363,25 @@ describe('UsersRepository', () => { describe('touchLastLogin (AU13/AU34/AU35/PK12/O16)', () => { it('USERSREPO-020: bumps login_count by exactly 1 and stamps last_login, in one statement', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { login_count: 5 }); await users.touchLastLogin(user.id); - 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 findRow(t, Users, { id: user.id }) as { login_count: number; last_login: string }; expect(row.login_count).toBe(6); expect(row.last_login).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); }); it('USERSREPO-021: two concurrent calls both land — +2, never +1 (no read-modify-write)', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 10 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { login_count: 10 }); await Promise.all([users.touchLastLogin(user.id), users.touchLastLogin(user.id)]); - const row = testDb.prepare('SELECT login_count FROM users WHERE id = ?').get(user.id) as { login_count: number }; + const row = await findRow(t, Users, { id: user.id }) as { login_count: number }; expect(row.login_count).toBe(12); }); }); it('USERSREPO-022: findMeRow (AU14) reads the /api/auth/me projection', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET avatar = ?, mfa_enabled = 1, must_change_password = 1 WHERE id = ?').run('pic.png', user.id); + await updateRows(t, Users, { id: user.id }, { avatar: 'pic.png', mfa_enabled: 1, must_change_password: 1 }); const row = await users.findMeRow(user.id); expect(row).toMatchObject({ id: user.id, username: user.username, email: user.email, role: 'user', avatar: 'pic.png', mfa_enabled: 1, must_change_password: 1 }); expect(row?.created_at).not.toBeNull(); @@ -384,7 +390,7 @@ describe('UsersRepository', () => { it('USERSREPO-022b: findMeRow — every nullable column comes back null, not undefined, when NULL in the row (coverage: rule 16)', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET avatar = NULL, oidc_issuer = NULL, created_at = NULL, mfa_enabled = NULL, must_change_password = NULL WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { avatar: null, oidc_issuer: null, created_at: null, mfa_enabled: null, must_change_password: null }); const row = await users.findMeRow(user.id); expect(row?.avatar).toBeNull(); expect(row?.oidc_issuer).toBeNull(); @@ -395,7 +401,7 @@ describe('UsersRepository', () => { it('USERSREPO-023: getPasswordHashAndVersion (AU15) reads both columns together', async () => { const { user, password: _password } = createUser(testDb); - testDb.prepare('UPDATE users SET password_version = 2 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { password_version: 2 }); const row = await users.getPasswordHashAndVersion(user.id); expect(row?.password_hash).toBe(user.password_hash); expect(row?.password_version).toBe(2); @@ -407,9 +413,9 @@ describe('UsersRepository', () => { it('USERSREPO-024: setPassword (AU16/AU43) writes hash+version, clears must_change_password, stamps updated_at', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { must_change_password: 1 }); await users.setPassword(user.id, 'new-hash', 5); - const row = testDb.prepare('SELECT password_hash, password_version, must_change_password, updated_at FROM users WHERE id = ?').get(user.id) as { + const row = await findRow(t, Users, { id: user.id }) as { password_hash: string; password_version: number; must_change_password: number; updated_at: string; }; expect(row.password_hash).toBe('new-hash'); @@ -435,7 +441,7 @@ describe('UsersRepository', () => { it('USERSREPO-027: returns the row wrapper when the user exists', async () => { const { user } = createUser(testDb); expect(await users.getMfaEnabled(user.id)).toEqual({ mfa_enabled: 0 }); - testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { mfa_enabled: 1 }); expect(await users.getMfaEnabled(user.id)).toEqual({ mfa_enabled: 1 }); }); @@ -445,7 +451,7 @@ describe('UsersRepository', () => { it('USERSREPO-028b: mfa_enabled NULL on the row comes back null on the wrapper (coverage: rule 16)', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET mfa_enabled = NULL WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { mfa_enabled: null }); expect(await users.getMfaEnabled(user.id)).toEqual({ mfa_enabled: null }); }); }); @@ -453,7 +459,7 @@ describe('UsersRepository', () => { it('USERSREPO-029: enableMfa (AU28) sets mfa_enabled=1, writes the already-encrypted secret and codes, stamps updated_at', async () => { const { user } = createUser(testDb); await users.enableMfa(user.id, 'encrypted-secret', '["hash1","hash2"]'); - const row = testDb.prepare('SELECT mfa_enabled, mfa_secret, mfa_backup_codes, updated_at FROM users WHERE id = ?').get(user.id) as { + const row = await findRow(t, Users, { id: user.id }) as { mfa_enabled: number; mfa_secret: string; mfa_backup_codes: string; updated_at: string; }; expect(row.mfa_enabled).toBe(1); @@ -475,7 +481,7 @@ describe('UsersRepository', () => { it('USERSREPO-031: sees a raw UPDATE on the same id in the same request (disableIdentityMap regression)', async () => { const { user } = createUser(testDb, { username: 'before' }); await users.findById(user.id); // populate the identity map - testDb.prepare('UPDATE users SET username = ? WHERE id = ?').run('after', user.id); + await updateRows(t, Users, { id: user.id }, { username: 'after' }); const { value, queries } = await withQueryCount(() => users.findById(user.id)); expect(value?.username).toBe('after'); expect(queries).toBe(1); @@ -484,9 +490,9 @@ describe('UsersRepository', () => { it('USERSREPO-032: disableMfa (AU31) clears mfa_enabled/secret/codes, stamps updated_at', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?').run('s', 'c', user.id); + await updateRows(t, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 's', mfa_backup_codes: 'c' }); await users.disableMfa(user.id); - const row = testDb.prepare('SELECT mfa_enabled, mfa_secret, mfa_backup_codes, updated_at FROM users WHERE id = ?').get(user.id) as { + const row = await findRow(t, Users, { id: user.id }) as { mfa_enabled: number; mfa_secret: string | null; mfa_backup_codes: string | null; updated_at: string; }; expect(row.mfa_enabled).toBe(0); @@ -498,18 +504,18 @@ describe('UsersRepository', () => { describe('setBackupCodesAndTouch (AU33) vs setBackupCodes (AU44) — one stamps updated_at, the other does not', () => { it('USERSREPO-033: setBackupCodesAndTouch writes codes and stamps updated_at', async () => { const { user } = createUser(testDb); - testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id); + await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' }); await users.setBackupCodesAndTouch(user.id, '["a"]'); - 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 findRow(t, Users, { id: user.id }) as { mfa_backup_codes: string; updated_at: string }; expect(row.mfa_backup_codes).toBe('["a"]'); expect(row.updated_at).not.toBe('2020-01-01 00:00:00'); }); it('USERSREPO-034: setBackupCodes writes codes WITHOUT touching updated_at', async () => { const { user } = createUser(testDb); - testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id); + await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' }); await users.setBackupCodes(user.id, '["b"]'); - 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 findRow(t, Users, { id: user.id }) as { mfa_backup_codes: string; updated_at: string }; expect(row.mfa_backup_codes).toBe('["b"]'); expect(row.updated_at).toBe('2020-01-01 00:00:00'); }); @@ -525,13 +531,13 @@ describe('UsersRepository', () => { it('USERSREPO-036: excludes a guest', async () => { const { user } = createUser(testDb, { email: 'guest3@example.com' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { is_guest: 1 }); expect(await users.findForPasswordReset('guest3@example.com')).toBeNull(); }); it('USERSREPO-036b: oidc_sub NULL on the row comes back null, not undefined (coverage: rule 16)', async () => { const { user } = createUser(testDb, { email: 'reset-null@example.com' }); - testDb.prepare('UPDATE users SET oidc_sub = NULL WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { oidc_sub: null }); const row = await users.findForPasswordReset('reset-null@example.com'); expect(row?.oidc_sub).toBeNull(); }); @@ -539,14 +545,14 @@ describe('UsersRepository', () => { it('USERSREPO-037: findResetTarget (AU40) reads the reset-branch projection', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, password_version = 4 WHERE id = ?').run('s', 'c', user.id); + await updateRows(t, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 's', mfa_backup_codes: 'c', password_version: 4 }); const row = await users.findResetTarget(user.id); expect(row).toEqual({ id: user.id, email: user.email, mfa_enabled: 1, mfa_secret: 's', mfa_backup_codes: 'c', password_version: 4 }); }); it('USERSREPO-037b: findResetTarget — mfa_enabled/mfa_secret/mfa_backup_codes NULL come back null, not undefined (coverage: rule 16)', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET mfa_enabled = NULL, mfa_secret = NULL, mfa_backup_codes = NULL WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { mfa_enabled: null, mfa_secret: null, mfa_backup_codes: null }); const row = await users.findResetTarget(user.id); expect(row?.mfa_enabled).toBeNull(); expect(row?.mfa_secret).toBeNull(); @@ -571,7 +577,7 @@ describe('UsersRepository', () => { describe('findByOidcIdentity (O3)', () => { it('USERSREPO-040: matches on the (sub, issuer) pair', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run('sub-1', 'https://idp.example.com', user.id); + await updateRows(t, Users, { id: user.id }, { oidc_sub: 'sub-1', oidc_issuer: 'https://idp.example.com' }); const row = await users.findByOidcIdentity('sub-1', 'https://idp.example.com'); expect(row?.id).toBe(user.id); expect(await users.findByOidcIdentity('sub-1', 'https://other-idp.example.com')).toBeNull(); @@ -580,9 +586,9 @@ describe('UsersRepository', () => { it('USERSREPO-041: linkOidcIdentity (O5/O6) writes sub+issuer WITHOUT touching updated_at', async () => { const { user } = createUser(testDb); - testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id); + await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' }); await users.linkOidcIdentity(user.id, 'sub-2', 'https://idp2.example.com'); - const row = testDb.prepare('SELECT oidc_sub, oidc_issuer, updated_at FROM users WHERE id = ?').get(user.id) as { + const row = await findRow(t, Users, { id: user.id }) as { oidc_sub: string; oidc_issuer: string; updated_at: string; }; expect(row.oidc_sub).toBe('sub-2'); @@ -592,18 +598,18 @@ describe('UsersRepository', () => { it('USERSREPO-042: setRole (O8) writes role WITHOUT touching updated_at', async () => { const { user } = createUser(testDb); - testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id); + await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' }); await users.setRole(user.id, 'admin'); - const row = testDb.prepare('SELECT role, updated_at FROM users WHERE id = ?').get(user.id) as { role: string; updated_at: string }; + const row = await findRow(t, Users, { id: user.id }) as { role: string; updated_at: string }; expect(row.role).toBe('admin'); expect(row.updated_at).toBe('2020-01-01 00:00:00'); }); it('USERSREPO-043: setAvatarRaw (O9) writes avatar WITHOUT touching updated_at, unlike setAvatar', async () => { const { user } = createUser(testDb); - testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id); + await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' }); await users.setAvatarRaw(user.id, 'https://idp.example.com/pic.png'); - const row = testDb.prepare('SELECT avatar, updated_at FROM users WHERE id = ?').get(user.id) as { avatar: string; updated_at: string }; + const row = await findRow(t, Users, { id: user.id }) as { avatar: string; updated_at: string }; expect(row.avatar).toBe('https://idp.example.com/pic.png'); expect(row.updated_at).toBe('2020-01-01 00:00:00'); }); @@ -614,13 +620,13 @@ describe('UsersRepository', () => { const { user: b } = createUser(testDb, { username: 'other' }); expect(await users.findIdByUsernameCI('dupename', b.id)).toBe(a.id); expect(await users.findIdByUsernameCI('dupename', a.id)).toBeNull(); // excludes self - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(a.id); + await updateRows(t, Users, { id: a.id }, { is_guest: 1 }); expect(await users.findIdByUsernameCI('dupename', b.id)).toBeNull(); // guest excluded }); it('USERSREPO-045: findIdByUsernameCIAny has no exclude and no guest filter', async () => { const { user } = createUser(testDb, { username: 'AnyName' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { is_guest: 1 }); expect(await users.findIdByUsernameCIAny('anyname')).toBe(user.id); // a guest still matches here }); @@ -643,8 +649,7 @@ describe('UsersRepository', () => { it('USERSREPO-046: getApiKeyColumns (UP1) reads role + the four key columns', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, amap_api_key = ? WHERE id = ?') - .run('m', 'o', 'u', 'a', user.id); + await updateRows(t, Users, { id: user.id }, { maps_api_key: 'm', openweather_api_key: 'o', unsplash_api_key: 'u', amap_api_key: 'a' }); expect(await users.getApiKeyColumns(user.id)).toEqual({ role: 'admin', maps_api_key: 'm', openweather_api_key: 'o', unsplash_api_key: 'u', amap_api_key: 'a' }); }); @@ -658,7 +663,7 @@ describe('UsersRepository', () => { it('USERSREPO-047: updateMapsKey (UP2) writes maps_api_key + updated_at', async () => { const { user } = createUser(testDb); await users.updateMapsKey(user.id, 'encrypted'); - const row = testDb.prepare('SELECT maps_api_key, updated_at FROM users WHERE id = ?').get(user.id) as { maps_api_key: string; updated_at: string }; + const row = await findRow(t, Users, { id: user.id }) as { maps_api_key: string; updated_at: string }; expect(row.maps_api_key).toBe('encrypted'); expect(row.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}/); }); @@ -666,14 +671,14 @@ describe('UsersRepository', () => { it('USERSREPO-048: updateApiKeys (UP3) writes all four columns + updated_at in one statement', async () => { const { user } = createUser(testDb); await users.updateApiKeys(user.id, { maps_api_key: 'm2', openweather_api_key: 'o2', unsplash_api_key: 'u2', amap_api_key: null }); - const row = testDb.prepare('SELECT maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, updated_at FROM users WHERE id = ?').get(user.id) as Record; + const row = await findRow(t, Users, { id: user.id }) as Record; expect(row).toMatchObject({ maps_api_key: 'm2', openweather_api_key: 'o2', unsplash_api_key: 'u2', amap_api_key: null }); expect(row.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}/); }); it('USERSREPO-049: findProfileWithKeys (UP4/UP8) reads the profile-with-keys projection', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ?, mfa_enabled = 1 WHERE id = ?').run('m', user.id); + await updateRows(t, Users, { id: user.id }, { maps_api_key: 'm', mfa_enabled: 1 }); const row = await users.findProfileWithKeys(user.id); expect(row).toMatchObject({ id: user.id, username: user.username, email: user.email, role: 'user', maps_api_key: 'm', mfa_enabled: 1 }); }); @@ -681,7 +686,7 @@ describe('UsersRepository', () => { it('USERSREPO-049b: findProfileWithKeys returns null for a missing user; every nullable column comes back null (coverage: rule 16)', async () => { expect(await users.findProfileWithKeys(999999)).toBeNull(); const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET mfa_enabled = NULL WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { mfa_enabled: null }); const row = await users.findProfileWithKeys(user.id); expect(row).toMatchObject({ maps_api_key: null, openweather_api_key: null, unsplash_api_key: null, amap_api_key: null, avatar: null, mfa_enabled: null, @@ -708,7 +713,7 @@ describe('UsersRepository', () => { it('USERSREPO-051: patchProfile (UP7) writes only the given bounded columns + updated_at, in one statement', async () => { const { user } = createUser(testDb, { username: 'patchme', email: 'patchme@example.com' }); await users.patchProfile(user.id, { username: 'patched', maps_api_key: 'newkey' }); - const row = testDb.prepare('SELECT username, email, maps_api_key, updated_at FROM users WHERE id = ?').get(user.id) as Record; + const row = await findRow(t, Users, { id: user.id }) as Record; expect(row.username).toBe('patched'); expect(row.email).toBe('patchme@example.com'); // untouched — not in `changes` expect(row.maps_api_key).toBe('newkey'); @@ -719,20 +724,20 @@ describe('UsersRepository', () => { it('USERSREPO-052: getAvatar reads the avatar column', async () => { const { user } = createUser(testDb); expect(await users.getAvatar(user.id)).toBeNull(); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('pic.png', user.id); + await updateRows(t, Users, { id: user.id }, { avatar: 'pic.png' }); expect(await users.getAvatar(user.id)).toBe('pic.png'); }); it('USERSREPO-053: setAvatar writes avatar + updated_at (both a filename and NULL for delete)', async () => { const { user } = createUser(testDb); await users.setAvatar(user.id, 'new.png'); - let row = testDb.prepare('SELECT avatar, updated_at FROM users WHERE id = ?').get(user.id) as { avatar: string; updated_at: string }; + let row = await findRow(t, Users, { id: user.id }) as { avatar: string; updated_at: string }; expect(row.avatar).toBe('new.png'); expect(row.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}/); - testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id); + await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' }); await users.setAvatar(user.id, null); - row = testDb.prepare('SELECT avatar, updated_at FROM users WHERE id = ?').get(user.id) as { avatar: string | null; updated_at: string }; + row = await findRow(t, Users, { id: user.id }) as { avatar: string | null; updated_at: string }; expect(row.avatar).toBeNull(); expect(row.updated_at).not.toBe('2020-01-01 00:00:00'); }); @@ -740,7 +745,7 @@ describe('UsersRepository', () => { it('USERSREPO-054: findProfileBasic (UP11) reads id/username/email/role/avatar', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('a.png', user.id); + await updateRows(t, Users, { id: user.id }, { avatar: 'a.png' }); expect(await users.findProfileBasic(user.id)).toEqual({ id: user.id, username: user.username, email: user.email, role: 'user', avatar: 'a.png' }); }); @@ -756,7 +761,7 @@ describe('UsersRepository', () => { const { user: bravo } = createUser(testDb, { username: 'bravo' }); const { user: alpha } = createUser(testDb, { username: 'alpha' }); const { user: guest } = createUser(testDb, { username: 'zzz-guest' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); + await updateRows(t, Users, { id: guest.id }, { is_guest: 1 }); const rows = await users.listOthersNonGuest(me.id); expect(rows.map((r) => r.id)).toEqual([alpha.id, bravo.id]); }); @@ -764,7 +769,7 @@ describe('UsersRepository', () => { it('USERSREPO-056: getRoleAndWeatherKey (UP15) reads role + openweather_api_key', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET openweather_api_key = ? WHERE id = ?').run('w', user.id); + await updateRows(t, Users, { id: user.id }, { openweather_api_key: 'w' }); expect(await users.getRoleAndWeatherKey(user.id)).toEqual({ role: 'admin', openweather_api_key: 'w' }); }); @@ -777,7 +782,7 @@ describe('UsersRepository', () => { it('USERSREPO-058: deleteById (UC11) deletes the row', async () => { const { user } = createUser(testDb); await users.deleteById(user.id); - expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id)).toBeUndefined(); + expect(await findRow(t, Users, { id: user.id })).toBeNull(); }); }); @@ -791,7 +796,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { describe('findOwnerSummary (TM3)', () => { it('USERSREPO-059: prefers display_name over username, matching the legacy COALESCE', async () => { const { user } = createUser(testDb, { username: 'raw-handle' }); - testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Displayed Name', user.id); + await updateRows(t, Users, { id: user.id }, { display_name: 'Displayed Name' }); expect(await users.findOwnerSummary(user.id)).toEqual({ id: user.id, username: 'Displayed Name', email: user.email, avatar: null, }); @@ -807,7 +812,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { it('USERSREPO-061: a NULL avatar comes back null, not undefined (rule 16)', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('me.png', user.id); + await updateRows(t, Users, { id: user.id }, { avatar: 'me.png' }); expect((await users.findOwnerSummary(user.id))?.avatar).toBe('me.png'); }); @@ -821,7 +826,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { it('USERSREPO-062 (fresh after a raw UPDATE, not D-shape): a display_name written after an unrelated identity-map read is visible in the FIRST wider projection', async () => { const { user } = createUser(testDb, { username: 'fresh-owner' }); await t.repo(Users).find({}, { disableIdentityMap: false }); // populate the identity map with an unrelated read - testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Fresh Name', user.id); + await updateRows(t, Users, { id: user.id }, { display_name: 'Fresh Name' }); expect(await users.findOwnerSummary(user.id)).toEqual({ id: user.id, username: 'Fresh Name', email: user.email, avatar: null, }); @@ -835,6 +840,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { // literal-object assertion below (rule 19; #1362 is security-relevant, // so this is one of the L7 reads the fix wave covers). const legacyInvitable = (identifier: string) => + // test-sql-allow: legacy statement run raw as the parity oracle for the repository read. testDb.prepare( 'SELECT id, username, email, avatar FROM users WHERE (email = ? OR username = ?) AND COALESCE(is_guest, 0) = 0', ).get(identifier, identifier) ?? null; @@ -857,7 +863,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { it('USERSREPO-064: excludes guests — a trip-scoped guest can never be re-invited through the box', async () => { const { user: guest } = createUser(testDb, { email: 'guest-look@example.test', username: 'guest-handle' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); + await updateRows(t, Users, { id: guest.id }, { is_guest: 1 }); expect(await users.findInvitableByEmailOrUsername('guest-look@example.test')).toBeNull(); expect(await users.findInvitableByEmailOrUsername('guest-handle')).toBeNull(); }); @@ -879,7 +885,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { it('USERSREPO-072 (fresh after a raw UPDATE, not D-shape): an is_guest flip written after an unrelated identity-map read is visible in the FIRST wider projection', async () => { const { user } = createUser(testDb, { email: 'flip@example.test', username: 'flip-handle' }); await t.repo(Users).find({}, { disableIdentityMap: false }); // populate the identity map with an unrelated read - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { is_guest: 1 }); expect(await users.findInvitableByEmailOrUsername('flip@example.test')).toBeNull(); }); }); @@ -888,7 +894,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { it('USERSREPO-066: reads id/email/is_guest; a missing user is null', async () => { const { user } = createUser(testDb, { email: 'target@example.test' }); expect(await users.findIdEmailGuest(user.id)).toEqual({ id: user.id, email: 'target@example.test', is_guest: 0 }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { is_guest: 1 }); expect(await users.findIdEmailGuest(user.id)).toEqual({ id: user.id, email: 'target@example.test', is_guest: 1 }); expect(await users.findIdEmailGuest(999999)).toBeNull(); }); @@ -906,7 +912,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { // (`is_guest`) — a PK-only `findOne` on `id` alone, the exact shape // `findIdEmailGuest` itself uses. await t.repo(Users).findOne({ id: user.id }, { disableIdentityMap: false }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { is_guest: 1 }); const { value, queries } = await withQueryCount(() => users.findIdEmailGuest(user.id)); expect(value).toEqual({ id: user.id, email: 'guest-gate@example.test', is_guest: 1 }); @@ -917,8 +923,8 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { describe('insertGuest (TM16, security-sensitive)', () => { it('USERSREPO-067: writes the fixed literal columns and the given display_name; returns the generated id', async () => { const id = await users.insertGuest({ username: 'guest-abc', email: 'guest-abc@guests.invalid', display_name: 'Ida' }); - const row = testDb.prepare('SELECT username, email, password_hash, role, is_guest, display_name FROM users WHERE id = ?').get(id); - expect(row).toEqual({ + const row = await findRow(t, Users, { id }); + expect(row).toMatchObject({ username: 'guest-abc', email: 'guest-abc@guests.invalid', password_hash: '', role: 'user', is_guest: 1, display_name: 'Ida', }); }); @@ -928,28 +934,28 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { it('USERSREPO-068: renameGuest updates display_name + updated_at only for an is_guest = 1 row', async () => { const id = await users.insertGuest({ username: 'guest-rn', email: 'guest-rn@guests.invalid', display_name: 'Old' }); await users.renameGuest(id, 'New Name'); - const row = testDb.prepare('SELECT display_name, updated_at FROM users WHERE id = ?').get(id) as { display_name: string; updated_at: string | null }; + const row = await findRow(t, Users, { id }) as { display_name: string; updated_at: string | null }; expect(row.display_name).toBe('New Name'); expect(row.updated_at).not.toBeNull(); }); it('USERSREPO-069: renameGuest is a no-op on a real (non-guest) user id — the belt-and-braces predicate, pinned', async () => { const { user } = createUser(testDb, { username: 'real-user' }); - testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Untouched', user.id); + await updateRows(t, Users, { id: user.id }, { display_name: 'Untouched' }); await users.renameGuest(user.id, 'Attempted Rename'); - expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(user.id) as { display_name: string }).display_name).toBe('Untouched'); + expect((await findRow(t, Users, { id: user.id }) as { display_name: string }).display_name).toBe('Untouched'); }); it('USERSREPO-070: deleteGuest removes only an is_guest = 1 row', async () => { const id = await users.insertGuest({ username: 'guest-del', email: 'guest-del@guests.invalid', display_name: 'Gone' }); await users.deleteGuest(id); - expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(id)).toBeUndefined(); + expect(await findRow(t, Users, { id })).toBeNull(); }); it('USERSREPO-071: deleteGuest is a no-op on a real (non-guest) user id — the belt-and-braces predicate, pinned', async () => { const { user } = createUser(testDb, { username: 'real-user-2' }); await users.deleteGuest(user.id); - expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id)).toBeDefined(); + expect(await findRow(t, Users, { id: user.id })).not.toBeNull(); }); }); }); @@ -960,8 +966,8 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => { describe('UsersRepository — feed tokens (Plan 3d Task 5, FD5-FD8/FD10)', () => { it('USERSREPO-072: getFeedToken reads the stored token, matching SELECT feed_token FROM users WHERE id = ?', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run('tok-abc', user.id); - const legacy = testDb.prepare('SELECT feed_token FROM users WHERE id = ?').get(user.id) as { feed_token: string | null }; + await updateRows(t, Users, { id: user.id }, { feed_token: 'tok-abc' }); + const legacy = await findRow(t, Users, { id: user.id }) as { feed_token: string | null }; expect(await users.getFeedToken(user.id)).toBe(legacy.feed_token); expect(await users.getFeedToken(user.id)).toBe('tok-abc'); }); @@ -975,19 +981,20 @@ describe('UsersRepository — feed tokens (Plan 3d Task 5, FD5-FD8/FD10)', () => it('USERSREPO-074: setFeedToken (FD6/FD7) writes a fresh token verbatim', async () => { const { user } = createUser(testDb); await users.setFeedToken(user.id, 'tok-fresh'); - expect((testDb.prepare('SELECT feed_token FROM users WHERE id = ?').get(user.id) as { feed_token: string }).feed_token).toBe('tok-fresh'); + expect((await findRow(t, Users, { id: user.id }) as { feed_token: string }).feed_token).toBe('tok-fresh'); }); it('USERSREPO-075: setFeedToken (FD8) clears the column to NULL when the token is null', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run('tok-old', user.id); + await updateRows(t, Users, { id: user.id }, { feed_token: 'tok-old' }); await users.setFeedToken(user.id, null); - expect((testDb.prepare('SELECT feed_token FROM users WHERE id = ?').get(user.id) as { feed_token: string | null }).feed_token).toBeNull(); + expect((await findRow(t, Users, { id: user.id }) as { feed_token: string | null }).feed_token).toBeNull(); }); it('USERSREPO-076: findIdAndUsernameByFeedToken (FD10) — the anonymous credential lookup, exact projection', async () => { const { user } = createUser(testDb, { username: 'feed-holder' }); - testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run('tok-holder', user.id); + await updateRows(t, Users, { id: user.id }, { feed_token: 'tok-holder' }); + // test-sql-allow: legacy statement run raw as the parity oracle for the repository read. const legacy = testDb.prepare('SELECT id, username FROM users WHERE feed_token = ?').get('tok-holder'); expect(await users.findIdAndUsernameByFeedToken('tok-holder')).toEqual(legacy); expect(await users.findIdAndUsernameByFeedToken('tok-holder')).toEqual({ id: user.id, username: 'feed-holder' }); @@ -1003,7 +1010,7 @@ describe('UsersRepository — feed tokens (Plan 3d Task 5, FD5-FD8/FD10)', () => it('M1: getImmichAutoUpload / getSynologyUsername / findUsernameEmail return their column, and the missing-user branch, honestly', async () => { const { user } = createUser(testDb, { username: 'imm-user', email: 'imm@example.com' }); - testDb.prepare('UPDATE users SET immich_auto_upload = 1, synology_username = ? WHERE id = ?').run('syno-login', user.id); + await updateRows(t, Users, { id: user.id }, { immich_auto_upload: 1, synology_username: 'syno-login' }); expect(await users.getImmichAutoUpload(user.id)).toBe(1); expect(await users.getImmichAutoUpload(999999)).toBeNull(); @@ -1017,9 +1024,7 @@ describe('UsersRepository — feed tokens (Plan 3d Task 5, FD5-FD8/FD10)', () => it('M1b: getImmichCredentials / getImmichConnectionPrefs carry immich_allow_insecure_tls (#2475), and the missing-user branch', async () => { const { user } = createUser(testDb); - testDb - .prepare('UPDATE users SET immich_url = ?, immich_api_key = ?, immich_auto_upload = 1, immich_allow_insecure_tls = 1 WHERE id = ?') - .run('https://immich.test', 'key-1', user.id); + await updateRows(t, Users, { id: user.id }, { immich_url: 'https://immich.test', immich_api_key: 'key-1', immich_auto_upload: 1, immich_allow_insecure_tls: 1 }); expect(await users.getImmichCredentials(user.id)).toEqual({ immich_url: 'https://immich.test', @@ -1046,16 +1051,17 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', ( let plain: TestUser; let guest: TestUser; - beforeEach(() => { + beforeEach(async () => { admin = createAdmin(testDb, { username: 'ad-admin', email: 'ad-admin@example.com' }).user; plain = createUser(testDb, { username: 'ad-plain', email: 'ad-plain@example.com' }).user; const { user: guestRow } = createUser(testDb, { username: 'ad-guest', email: 'ad-guest@example.com' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guestRow.id); + await updateRows(t, Users, { id: guestRow.id }, { is_guest: 1 }); guest = { ...guestRow, } as TestUser; }); it('USERSREPO-078 (AD1): listForAdmin matches SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC, and excludes the guest row', async () => { const legacy = testDb + // test-sql-allow: legacy statement run raw as the parity oracle for the repository read. .prepare('SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC') .all(); const rows = await users.listForAdmin(); @@ -1075,7 +1081,7 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', ( it('USERSREPO-080 (AD4/AD5): insertAdminCreatedUser stores exactly the four named columns, every other column left to the entity default, then findAdminSummary re-selects it', async () => { const id = await users.insertAdminCreatedUser({ username: 'ad-new', email: 'ad-new@example.com', password_hash: 'hash-x', role: 'user' }); - const legacyRow = testDb.prepare('SELECT * FROM users WHERE id = ?').get(id) as Record; + const legacyRow = await findRow(t, Users, { id }) as Record; expect(legacyRow.username).toBe('ad-new'); expect(legacyRow.email).toBe('ad-new@example.com'); expect(legacyRow.password_hash).toBe('hash-x'); @@ -1087,12 +1093,14 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', ( expect(legacyRow.login_count).toBe(0); expect(legacyRow.is_guest).toBe(0); + // test-sql-allow: legacy statement run raw as the parity oracle for the repository read. const legacySummary = testDb.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?').get(id); expect(await users.findAdminSummary(id)).toEqual(legacySummary); }); it('USERSREPO-081 (AD14): findAdminSummary matches the byte-identical re-select text on an EXISTING (updated) row too', async () => { - testDb.prepare('UPDATE users SET username = ?, role = ? WHERE id = ?').run('ad-plain-renamed', 'admin', plain.id); + await updateRows(t, Users, { id: plain.id }, { username: 'ad-plain-renamed', role: 'admin' }); + // test-sql-allow: legacy statement run raw as the parity oracle for the repository read. const legacy = testDb.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?').get(plain.id); expect(await users.findAdminSummary(plain.id)).toEqual(legacy); expect(await users.findAdminSummary(999999)).toBeNull(); @@ -1110,12 +1118,12 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', ( }); it('USERSREPO-083 (AD11): applyAdminEdit only touches the keys supplied (coalesceParam semantics) and always stamps updated_at', async () => { - const before = testDb.prepare('SELECT email, updated_at FROM users WHERE id = ?').get(plain.id) as { email: string; updated_at: string }; + const before = await findRow(t, Users, { id: plain.id }) as { email: string; updated_at: string }; // CURRENT_TIMESTAMP has 1-second resolution — wait past a tick so a // genuine "always stamped" pass is distinguishable from a no-op. await new Promise((r) => setTimeout(r, 1100)); await users.applyAdminEdit(plain.id, { username: 'ad-plain-2' }); - const after = testDb.prepare('SELECT username, email, updated_at FROM users WHERE id = ?').get(plain.id) as { username: string; email: string; updated_at: string }; + const after = await findRow(t, Users, { id: plain.id }) as { username: string; email: string; updated_at: string }; expect(after.username).toBe('ad-plain-2'); // email was NOT in the patch, so it is unchanged — the coalesceParam // "new value wins when supplied, existing column wins when omitted" shape. @@ -1125,7 +1133,8 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', ( }); it('USERSREPO-084 (AD16): findIdEmailMfaEnabled matches SELECT id, email, mfa_enabled FROM users WHERE id = ?', async () => { - testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(admin.id); + await updateRows(t, Users, { id: admin.id }, { mfa_enabled: 1 }); + // test-sql-allow: legacy statement run raw as the parity oracle for the repository read. const legacy = testDb.prepare('SELECT id, email, mfa_enabled FROM users WHERE id = ?').get(admin.id); expect(await users.findIdEmailMfaEnabled(admin.id)).toEqual(legacy); expect(await users.findIdEmailMfaEnabled(999999)).toBeNull(); @@ -1138,41 +1147,43 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', ( // the legacy statement's own text, and that the write stays one statement — // a read-then-write would be an interleaving window the legacy SQL never had. describe('UsersRepository — Immich settings write (IM4/IM5)', () => { - const read = (id: number) => - testDb.prepare('SELECT immich_url, immich_api_key, immich_allow_insecure_tls FROM users WHERE id = ?').get(id) as { - immich_url: string | null; immich_api_key: string | null; immich_allow_insecure_tls: number; + const read = async (id: number) => { + const row = await readUser(t, id); + return { + immich_url: row.immich_url, immich_api_key: row.immich_api_key, immich_allow_insecure_tls: row.immich_allow_insecure_tls, }; + }; it('USERSREPO-085 (IM4): setImmichSettings keeps the stored switch on a null value while the URL stays the same, and starts a new URL off', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = 1 WHERE id = ?').run('https://nas.local', 'enc-old', user.id); + await updateRows(t, Users, { id: user.id }, { immich_url: 'https://nas.local', immich_api_key: 'enc-old', immich_allow_insecure_tls: 1 }); // An older client that does not know the switch cannot clear it by saving. await users.setImmichSettings(user.id, 'https://nas.local', 'enc-new', null); - expect(read(user.id)).toEqual({ immich_url: 'https://nas.local', immich_api_key: 'enc-new', immich_allow_insecure_tls: 1 }); + expect(await read(user.id)).toEqual({ immich_url: 'https://nas.local', immich_api_key: 'enc-new', immich_allow_insecure_tls: 1 }); // Sent explicitly, the value wins over the stored one. await users.setImmichSettings(user.id, 'https://nas.local', 'enc-new', 0); - expect(read(user.id).immich_allow_insecure_tls).toBe(0); + expect((await read(user.id)).immich_allow_insecure_tls).toBe(0); await users.setImmichSettings(user.id, 'https://nas.local', 'enc-new', 1); - expect(read(user.id).immich_allow_insecure_tls).toBe(1); + expect((await read(user.id)).immich_allow_insecure_tls).toBe(1); // The switch trusts one server: another URL without it starts off ... await users.setImmichSettings(user.id, 'https://photos.example.com', 'enc-2', null); - expect(read(user.id)).toEqual({ immich_url: 'https://photos.example.com', immich_api_key: 'enc-2', immich_allow_insecure_tls: 0 }); + expect(await read(user.id)).toEqual({ immich_url: 'https://photos.example.com', immich_api_key: 'enc-2', immich_allow_insecure_tls: 0 }); // ... and with it, holds for that server. await users.setImmichSettings(user.id, 'https://other.example.com', 'enc-3', 1); - expect(read(user.id)).toEqual({ immich_url: 'https://other.example.com', immich_api_key: 'enc-3', immich_allow_insecure_tls: 1 }); + expect(await read(user.id)).toEqual({ immich_url: 'https://other.example.com', immich_api_key: 'enc-3', immich_allow_insecure_tls: 1 }); // A first connection (no stored URL) without the switch starts off too. - testDb.prepare('UPDATE users SET immich_url = NULL, immich_allow_insecure_tls = 1 WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { immich_url: null, immich_allow_insecure_tls: 1 }); await users.setImmichSettings(user.id, 'https://nas.local', 'enc-4', null); - expect(read(user.id)).toEqual({ immich_url: 'https://nas.local', immich_api_key: 'enc-4', immich_allow_insecure_tls: 0 }); + expect(await read(user.id)).toEqual({ immich_url: 'https://nas.local', immich_api_key: 'enc-4', immich_allow_insecure_tls: 0 }); }); it('USERSREPO-086 (IM4): setImmichSettings matches the legacy CASE statement across every (stored url, stored switch, new url, value) combination, in ONE statement', async () => { const { user } = createUser(testDb); - const seed = testDb.prepare('UPDATE users SET immich_url = ?, immich_api_key = NULL, immich_allow_insecure_tls = ? WHERE id = ?'); + // test-sql-allow: legacy statement run raw as the parity oracle for the repository read. const legacy = testDb.prepare( `UPDATE users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = CASE WHEN immich_url IS ? THEN COALESCE(?, immich_allow_insecure_tls) ELSE COALESCE(?, 0) END @@ -1182,14 +1193,14 @@ describe('UsersRepository — Immich settings write (IM4/IM5)', () => { for (const storedFlag of [0, 1]) { for (const newUrl of ['https://nas.local', 'https://photos.example.com']) { for (const value of [null, 0, 1]) { - seed.run(storedUrl, storedFlag, user.id); + await updateRows(t, Users, { id: user.id }, { immich_url: storedUrl, immich_api_key: null, immich_allow_insecure_tls: storedFlag }); legacy.run(newUrl, 'enc-key', newUrl, value, value, user.id); - const expected = read(user.id); + const expected = await read(user.id); - seed.run(storedUrl, storedFlag, user.id); + await updateRows(t, Users, { id: user.id }, { immich_url: storedUrl, immich_api_key: null, immich_allow_insecure_tls: storedFlag }); const { queries } = await withQueryCount(() => users.setImmichSettings(user.id, newUrl, 'enc-key', value)); expect(queries).toBe(1); - expect(read(user.id)).toEqual(expected); + expect(await read(user.id)).toEqual(expected); } } } @@ -1198,9 +1209,9 @@ describe('UsersRepository — Immich settings write (IM4/IM5)', () => { it('USERSREPO-087 (IM5): clearImmichSettings nulls the URL, stores the key it is handed and always turns the switch off', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = 1 WHERE id = ?').run('https://nas.local', 'enc-old', user.id); + await updateRows(t, Users, { id: user.id }, { immich_url: 'https://nas.local', immich_api_key: 'enc-old', immich_allow_insecure_tls: 1 }); await users.clearImmichSettings(user.id, null); - expect(read(user.id)).toEqual({ immich_url: null, immich_api_key: null, immich_allow_insecure_tls: 0 }); + expect(await read(user.id)).toEqual({ immich_url: null, immich_api_key: null, immich_allow_insecure_tls: 0 }); }); });