diff --git a/server/tests/integration/share.test.ts b/server/tests/integration/share.test.ts index e0640eda1..31071dbd7 100644 --- a/server/tests/integration/share.test.ts +++ b/server/tests/integration/share.test.ts @@ -23,10 +23,22 @@ vi.mock('../../src/config', () => ({ vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; +import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { deleteRows, insertRow, updateRows } from '../helpers/factories/rows'; +import { setAppSetting } from '../helpers/factories/settings'; +import { makePackingItem } from '../helpers/factories/packing'; +import { makeCollabMessage } from '../helpers/factories/collab'; +import { AppSettings } from '../../src/db/entities/AppSettings.entity'; +import { DayAssignments } from '../../src/db/entities/DayAssignments.entity'; +import { ReservationDayPositions } from '../../src/db/entities/ReservationDayPositions.entity'; +import { Reservations } from '../../src/db/entities/Reservations.entity'; +import { Settings } from '../../src/db/entities/Settings.entity'; +import { Trips } from '../../src/db/entities/Trips.entity'; import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service'; import { db as sharedDb } from '../../src/db/database'; import { LocalDriver } from '../../src/nest/storage/drivers/local.driver'; @@ -61,10 +73,12 @@ import crypto from 'node:crypto'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); const t = await createTestOrm(sharedDb, { allowGlobalContext: true }); placePhotoCache = new PlacePhotoCacheService(testStorage, t.repo(GooglePlacePhotoMeta), t.repo(Places), t.repo(CollectionPlaces)); }); @@ -209,8 +223,8 @@ describe('Shared trip access', () => { it('SHARE-026 — hides private packing items from the public payload', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare("INSERT INTO packing_items (trip_id, name, category, checked, is_private, owner_id) VALUES (?, 'Private thing', 'Misc', 0, 1, ?)").run(trip.id, user.id); - testDb.prepare("INSERT INTO packing_items (trip_id, name, category, checked, is_private, owner_id) VALUES (?, 'Common thing', 'Misc', 0, 0, ?)").run(trip.id, user.id); + await makePackingItem(orm, trip.id, { name: 'Private thing', category: 'Misc', checked: 0, is_private: 1, owner: user.id }); + await makePackingItem(orm, trip.id, { name: 'Common thing', category: 'Misc', checked: 0, is_private: 0, owner: user.id }); const create = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) @@ -308,7 +322,7 @@ describe('Shared trip access', () => { await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(owner.id)).send({}); const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache'); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_share_manage', 'trip_member')").run(); + await setAppSetting(orm, 'perm_share_manage', 'trip_member'); await invalidatePermissionsCache(); try { const res = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(member.id)); @@ -316,7 +330,7 @@ describe('Shared trip access', () => { expect(res.body.token).toBeTruthy(); } finally { // Module-scoped cache: leaving it set would decide the next file's tests. - testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_share_manage'").run(); + await deleteRows(orm, AppSettings, { key: 'perm_share_manage' }); await invalidatePermissionsCache(); } }); @@ -369,7 +383,7 @@ describe('Shared trip — day assignments and notes', () => { it('SHARE-012 — share_collab=true includes collab messages in response', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare('INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, ?, 0)').run(trip.id, user.id, 'Hello team!'); + await makeCollabMessage(orm, trip.id, user.id, { text: 'Hello team!', deleted: 0 }); const create = await request(app) .post(`/api/trips/${trip.id}/share-link`) @@ -411,12 +425,8 @@ describe('Shared trip — ordering parity (issue #981)', () => { const place2 = createPlace(testDb, trip.id, { name: 'Second Created' }); // Both with order_index = 0 (schema default) but different created_at - testDb.prepare( - "INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')" - ).run(day.id, place1.id); - testDb.prepare( - "INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')" - ).run(day.id, place2.id); + await insertRow(orm, DayAssignments, { day: day.id, place: place1.id, order_index: 0, created_at: '2025-01-01T10:00:00' }); + await insertRow(orm, DayAssignments, { day: day.id, place: place2.id, order_index: 0, created_at: '2025-01-01T11:00:00' }); const { body: { token } } = await request(app) .post(`/api/trips/${trip.id}/share-link`) @@ -436,15 +446,12 @@ describe('Shared trip — ordering parity (issue #981)', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2025-09-01' }); - const res1 = testDb.prepare( - "INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, ?, ?, ?, ?)" - ).run(trip.id, 'Test Flight', 'flight', day.id, '2025-09-01T09:00:00'); - const reservationId = Number(res1.lastInsertRowid); + const reservationId = await insertRow(orm, Reservations, { + trip: trip.id, title: 'Test Flight', type: 'flight', day: day.id, reservation_time: '2025-09-01T09:00:00', + }); // Insert a per-day position - testDb.prepare( - 'INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)' - ).run(reservationId, day.id, 1.5); + await insertRow(orm, ReservationDayPositions, { reservation: reservationId, day: day.id, position: 1.5 }); const { body: { token } } = await request(app) .post(`/api/trips/${trip.id}/share-link`) @@ -465,8 +472,7 @@ describe('Shared trip — display currency (issue #1361)', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // Trip keeps the EUR default; the owner's Costs display currency is CAD. - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ?)") - .run(user.id, JSON.stringify('CAD')); + await insertRow(orm, Settings, { user: user.id, key: 'default_currency', value: JSON.stringify('CAD') }); const { body: { token } } = await request(app) .post(`/api/trips/${trip.id}/share-link`) @@ -481,7 +487,7 @@ describe('Shared trip — display currency (issue #1361)', () => { it('SHARE-022 — baseCurrency falls back to the trip currency when the owner has no setting', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('GBP', trip.id); + await updateRows(orm, Trips, { id: trip.id }, { currency: 'GBP' }); const { body: { token } } = await request(app) .post(`/api/trips/${trip.id}/share-link`) @@ -496,8 +502,7 @@ describe('Shared trip — display currency (issue #1361)', () => { it('SHARE-023 — baseCurrency uses the admin instance default when the owner has no per-user setting', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // EUR trip default, no user setting - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_default_currency', ?)") - .run(JSON.stringify('USD')); + await insertRow(orm, AppSettings, { key: 'default_user_setting_default_currency', value: JSON.stringify('USD') }); const { body: { token } } = await request(app) .post(`/api/trips/${trip.id}/share-link`) @@ -514,7 +519,7 @@ describe('Shared trip: CARTO tile key (issue #2054)', () => { it('SHARE-029: the payload carries the owner\'s carto_api_key, behind it the admin instance default', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run(); + await insertRow(orm, AppSettings, { key: 'default_user_setting_carto_api_key', value: 'instance-key' }); const { body: { token } } = await request(app) .post(`/api/trips/${trip.id}/share-link`) @@ -525,7 +530,7 @@ describe('Shared trip: CARTO tile key (issue #2054)', () => { expect(inherited.status).toBe(200); expect(inherited.body.cartoApiKey).toBe('instance-key'); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', 'owner-key')").run(user.id); + await insertRow(orm, Settings, { user: user.id, key: 'carto_api_key', value: 'owner-key' }); const own = await request(app).get(`/api/shared/${token}`); expect(own.body.cartoApiKey).toBe('owner-key'); }); @@ -543,7 +548,7 @@ describe('Shared trip — place photos in shared links (issue #1100)', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Photo Place' }); - testDb.prepare('UPDATE places SET image_url = ?, google_place_id = ? WHERE id = ?').run(PROXY_URL, PLACE_ID, place.id); + await updateRows(orm, Places, { id: place.id }, { image_url: PROXY_URL, google_place_id: PLACE_ID }); const { body: { token } } = await request(app) .post(`/api/trips/${trip.id}/share-link`) @@ -566,7 +571,7 @@ describe('Shared trip — place photos in shared links (issue #1100)', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2025-10-01' }); const place = createPlace(testDb, trip.id, { name: 'Assigned Photo Place' }); - testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run(PROXY_URL, place.id); + await updateRows(orm, Places, { id: place.id }, { image_url: PROXY_URL }); createDayAssignment(testDb, day.id, place.id, {}); const { body: { token } } = await request(app) @@ -618,7 +623,7 @@ describe('Shared trip — place photos in shared links (issue #1100)', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Hidden Photo Place' }); - testDb.prepare('UPDATE places SET image_url = ?, google_place_id = ? WHERE id = ?').run(PROXY_URL, PLACE_ID, place.id); + await updateRows(orm, Places, { id: place.id }, { image_url: PROXY_URL, google_place_id: PLACE_ID }); const { body: { token } } = await request(app) .post(`/api/trips/${trip.id}/share-link`) .set('Cookie', authCookie(user.id)) diff --git a/server/tests/unit/nest/passkey.service.test.ts b/server/tests/unit/nest/passkey.service.test.ts index 502250f3f..c30309566 100644 --- a/server/tests/unit/nest/passkey.service.test.ts +++ b/server/tests/unit/nest/passkey.service.test.ts @@ -108,6 +108,12 @@ import { createTestPushSubscriptionsRepo } from '../../helpers/notifications-rep import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos'; import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository'; import { createTestSessionsService } from '../../helpers/sessions'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows'; +import { readUser } from '../../helpers/factories/users'; +import { WebauthnChallenges } from '../../../src/db/entities/WebauthnChallenges.entity'; +import { WebauthnCredentials } from '../../../src/db/entities/WebauthnCredentials.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; // MailerService is injected since the notifications fold — a stub instead of a // module mock. sendPasswordResetEmail is the only thing auth reaches for. @@ -167,7 +173,14 @@ beforeEach(() => { resetTestDb(testDb); }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -181,17 +194,27 @@ function ceremonyResponse(challenge: string, extra: Record = {} }; } -function seedChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', expiresAt = Date.now() + 60_000) { - testDb.prepare('INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)') - .run(challenge, userId, type, expiresAt); +async function seedChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', expiresAt = Date.now() + 60_000) { + await insertRow(orm, WebauthnChallenges, { challenge, user: userId, type, expires_at: expiresAt }); } -function challengeCount(): number { - return (testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_challenges').get() as { n: number }).n; +function challengeCount(): Promise { + return countRows(orm, WebauthnChallenges); +} + +interface CredentialOverrides { + credential_id?: string; + public_key?: Buffer; + counter?: number; + transports?: string | null; + device_type?: string | null; + backed_up?: number; + name?: string | null; + aaguid?: string | null; } let _credSeq = 0; -function insertCredential(userId: number, overrides: Record = {}) { +async function insertCredential(userId: number, overrides: CredentialOverrides = {}) { _credSeq++; const row = { credential_id: `cred-${_credSeq}`, @@ -204,12 +227,8 @@ function insertCredential(userId: number, overrides: Record = { aaguid: null, ...overrides, }; - const result = testDb.prepare( - `INSERT INTO webauthn_credentials - (user_id, credential_id, public_key, counter, transports, device_type, backed_up, name, aaguid) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(userId, row.credential_id, row.public_key, row.counter, row.transports, row.device_type, row.backed_up, row.name, row.aaguid); - return { id: Number(result.lastInsertRowid), ...row }; + const id = await insertRow(orm, WebauthnCredentials, { user: userId, ...row }); + return { id, ...row }; } function registrationVerdict(overrides: Record = {}, credentialOverrides: Record = {}) { @@ -251,9 +270,9 @@ describe('passkeyRegisterOptions', () => { it('PASSKEY-SVC-004: generates options excluding existing credentials and stores the challenge', async () => { const { user, password } = createUser(testDb); - insertCredential(user.id, { credential_id: 'cred-a', transports: JSON.stringify(['usb', 'nfc']) }); - insertCredential(user.id, { credential_id: 'cred-b', transports: 'not-json' }); - seedChallenge('stale', user.id, 'registration', Date.now() - 1000); // purged on the way in + await insertCredential(user.id, { credential_id: 'cred-a', transports: JSON.stringify(['usb', 'nfc']) }); + await insertCredential(user.id, { credential_id: 'cred-b', transports: 'not-json' }); + await seedChallenge('stale', user.id, 'registration', Date.now() - 1000); // purged on the way in swMock.generateRegistrationOptions.mockResolvedValue({ challenge: 'reg-chal' }); const result = await svc.passkeyRegisterOptions(user.id, password); @@ -271,7 +290,7 @@ describe('passkeyRegisterOptions', () => { { id: 'cred-b', transports: undefined }, // unparseable transports degrade to undefined ]); - const stored = testDb.prepare('SELECT * FROM webauthn_challenges').all() as Array>; + const stored = await findRows(orm, WebauthnChallenges); expect(stored).toHaveLength(1); // the expired row was purged expect(stored[0]).toMatchObject({ challenge: 'reg-chal', user_id: user.id, type: 'registration' }); expect(Number(stored[0].expires_at)).toBeGreaterThan(Date.now()); @@ -286,7 +305,7 @@ describe('passkeyRegisterOptions', () => { expect(await svc.passkeyRegisterOptions(user.id, password, 'https://trip.example.org')) .toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 }); expect(swMock.generateRegistrationOptions).not.toHaveBeenCalled(); - expect(challengeCount()).toBe(0); + expect(await challengeCount()).toBe(0); }); it('PASSKEY-SVC-034: localhost origins and headerless requests keep working against the dev fallback', async () => { @@ -335,7 +354,7 @@ describe('passkeyRegisterVerify', () => { const { user } = createUser(testDb); expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('never-stored') })) .toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 }); - seedChallenge('expired', user.id, 'registration', Date.now() - 1); + await seedChallenge('expired', user.id, 'registration', Date.now() - 1); expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('expired') })) .toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 }); }); @@ -343,23 +362,23 @@ describe('passkeyRegisterVerify', () => { it("PASSKEY-SVC-008: a challenge claimed by a different user fails — and the claim is single-use", async () => { const { user: alice } = createUser(testDb); const { user: bob } = createUser(testDb); - seedChallenge('cross', alice.id, 'registration'); + await seedChallenge('cross', alice.id, 'registration'); expect(await svc.passkeyRegisterVerify(bob.id, { attestationResponse: ceremonyResponse('cross') })) .toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 }); // The mismatched claim still consumed the row: the rightful owner can't use it either. - expect(challengeCount()).toBe(0); + expect(await challengeCount()).toBe(0); expect(await svc.passkeyRegisterVerify(alice.id, { attestationResponse: ceremonyResponse('cross') })) .toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 }); }); it('PASSKEY-SVC-009: maps a throwing or unverified verifier to the generic 400', async () => { const { user } = createUser(testDb); - seedChallenge('c1', user.id, 'registration'); + await seedChallenge('c1', user.id, 'registration'); swMock.verifyRegistrationResponse.mockRejectedValue(new Error('boom')); expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c1') })) .toEqual({ error: 'Could not register this passkey.', status: 400 }); - seedChallenge('c2', user.id, 'registration'); + await seedChallenge('c2', user.id, 'registration'); swMock.verifyRegistrationResponse.mockResolvedValue({ verified: false }); expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c2') })) .toEqual({ error: 'Could not register this passkey.', status: 400 }); @@ -367,8 +386,8 @@ describe('passkeyRegisterVerify', () => { it('PASSKEY-SVC-010: 409s when the credential id is already registered', async () => { const { user } = createUser(testDb); - insertCredential(user.id, { credential_id: 'dup-cred' }); - seedChallenge('c3', user.id, 'registration'); + await insertCredential(user.id, { credential_id: 'dup-cred' }); + await seedChallenge('c3', user.id, 'registration'); swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'dup-cred' })); expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c3') })) .toEqual({ error: 'This passkey is already registered.', status: 409 }); @@ -376,27 +395,27 @@ describe('passkeyRegisterVerify', () => { it('PASSKEY-SVC-011: persists the verifier-vouched credential and returns it re-selected', async () => { const { user } = createUser(testDb); - seedChallenge('c4', user.id, 'registration'); + await seedChallenge('c4', user.id, 'registration'); swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { counter: 7 })); const result = await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c4'), name: ' My Key ' }); expect(result.success).toBe(true); expect(result.credential).toMatchObject({ name: 'My Key', device_type: 'singleDevice', backed_up: false, last_used_at: null }); - const row = testDb.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ?').get('new-cred') as Record; + const row = (await findRow(orm, WebauthnCredentials, { credential_id: 'new-cred' })); expect(row).toMatchObject({ user_id: user.id, counter: 7, transports: JSON.stringify(['internal']), backed_up: 0, aaguid: 'aaguid-1' }); - expect(Buffer.from(row.public_key as Buffer)).toEqual(Buffer.from([9, 9, 9])); + expect(Buffer.from(row?.public_key as Buffer)).toEqual(Buffer.from([9, 9, 9])); }); it('PASSKEY-SVC-012: defaults a blank name by device type and caps a long one at 60 chars', async () => { const { user } = createUser(testDb); - seedChallenge('c5', user.id, 'registration'); + await seedChallenge('c5', user.id, 'registration'); swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({ credentialDeviceType: 'multiDevice', credentialBackedUp: true }, { id: 'synced-cred' })); const synced = await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c5'), name: ' ' }); expect(synced.credential).toMatchObject({ name: 'Passkey (synced)', backed_up: true }); - seedChallenge('c6', user.id, 'registration'); + await seedChallenge('c6', user.id, 'registration'); swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'named-cred' })); const long = await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c6'), name: 'x'.repeat(80) }); expect(long.credential).toMatchObject({ name: 'x'.repeat(60) }); @@ -407,7 +426,7 @@ describe('passkeyRegisterVerify', () => { // Claim passes (the challenge belongs to the caller id we pass), but no // such user row exists — the credential INSERT's FK trips the catch path. testDb.exec('PRAGMA foreign_keys = OFF'); - seedChallenge('c7', 999_999, 'registration'); + await seedChallenge('c7', 999_999, 'registration'); testDb.exec('PRAGMA foreign_keys = ON'); expect(await svc.passkeyRegisterVerify(999_999, { attestationResponse: ceremonyResponse('c7') })) .toEqual({ error: 'Could not register this passkey.', status: 400 }); @@ -418,23 +437,23 @@ describe('passkeyRegisterVerify', () => { it('PASSKEY-SVC-031: a failed INSERT rolls back without partial credential state', async () => { swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict()); testDb.exec('PRAGMA foreign_keys = OFF'); - seedChallenge('c8', 999_999, 'registration'); + await seedChallenge('c8', 999_999, 'registration'); testDb.exec('PRAGMA foreign_keys = ON'); await svc.passkeyRegisterVerify(999_999, { attestationResponse: ceremonyResponse('c8') }); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials').get()).toEqual({ n: 0 }); - expect(challengeCount()).toBe(0); // the claim stays spent — the challenge is not resurrected + expect(await countRows(orm, WebauthnCredentials)).toBe(0); + expect(await challengeCount()).toBe(0); // the claim stays spent — the challenge is not resurrected }); it('PASSKEY-SVC-032: the in-transaction duplicate 409 leaves the original row untouched', async () => { const { user } = createUser(testDb); - insertCredential(user.id, { credential_id: 'dup-cred-2', name: 'Original' }); - seedChallenge('c9', user.id, 'registration'); + await insertCredential(user.id, { credential_id: 'dup-cred-2', name: 'Original' }); + await seedChallenge('c9', user.id, 'registration'); swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'dup-cred-2' })); expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c9'), name: 'Impostor' })) .toEqual({ error: 'This passkey is already registered.', status: 409 }); - const row = testDb.prepare('SELECT name, user_id FROM webauthn_credentials WHERE credential_id = ?').get('dup-cred-2') as Record; - expect(row).toEqual({ name: 'Original', user_id: user.id }); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials').get()).toEqual({ n: 1 }); + const row = (await findRow(orm, WebauthnCredentials, { credential_id: 'dup-cred-2' })); + expect(row).toMatchObject({ name: 'Original', user_id: user.id }); + expect(await countRows(orm, WebauthnCredentials)).toBe(1); }); it('PASSKEY-SVC-036: widens expectedOrigin with the in-scope browser origin for a derived config (#2147)', async () => { @@ -444,7 +463,7 @@ describe('passkeyRegisterVerify', () => { // rpIdHash/challenge/signature checks still run in the verifier. resolveWebauthnConfigMock.mockReturnValue({ rpID: 'trek.example.org', rpName: 'TREK', origins: ['http://trek.example.org'], explicitOrigins: false }); const { user } = createUser(testDb); - seedChallenge('w1', user.id, 'registration'); + await seedChallenge('w1', user.id, 'registration'); swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict()); const result = await svc.passkeyRegisterVerify(user.id, { @@ -461,14 +480,14 @@ describe('passkeyRegisterVerify', () => { // Operator-supplied list is verbatim — an in-scope but unlisted origin // still has to fail verify (documented contract, options let it through). resolveWebauthnConfigMock.mockReturnValue({ ...CFG, origins: ['https://trek.example.com'], explicitOrigins: true }); - seedChallenge('w2', user.id, 'registration'); + await seedChallenge('w2', user.id, 'registration'); swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict()); await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w2', {}, { origin: 'https://app.trek.example.com' }) }); expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://trek.example.com']); // Derived config, but the asserted origin is outside the RP scope. resolveWebauthnConfigMock.mockReturnValue(CFG); - seedChallenge('w3', user.id, 'registration'); + await seedChallenge('w3', user.id, 'registration'); await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w3', {}, { origin: 'https://evil.example.net' }) }); expect(swMock.verifyRegistrationResponse.mock.calls[1][0].expectedOrigin).toEqual(['https://trek.example.com']); }); @@ -481,12 +500,12 @@ describe('passkeyRegisterVerify', () => { resolveWebauthnConfigMock.mockReturnValue({ rpID: 'example.com', rpName: 'TREK', origins: ['https://example.com'], explicitOrigins: false }); const { user } = createUser(testDb); - seedChallenge('w4', user.id, 'registration'); + await seedChallenge('w4', user.id, 'registration'); swMock.verifyRegistrationResponse.mockResolvedValueOnce(registrationVerdict({}, { id: 'sibling-cred' })); await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w4', {}, { origin: 'https://blog.example.com' }) }); expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://example.com']); - seedChallenge('w5', user.id, 'registration'); + await seedChallenge('w5', user.id, 'registration'); swMock.verifyRegistrationResponse.mockResolvedValueOnce(registrationVerdict({}, { id: 'port-cred' })); await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w5', {}, { origin: 'https://example.com:8443' }) }); expect(swMock.verifyRegistrationResponse.mock.calls[1][0].expectedOrigin).toEqual(['https://example.com', 'https://example.com:8443']); @@ -502,14 +521,14 @@ describe('passkeyLoginOptions', () => { }); it('PASSKEY-SVC-015: stores an anonymous authentication challenge (discoverable flow)', async () => { - seedChallenge('stale', null, 'authentication', Date.now() - 1000); + await seedChallenge('stale', null, 'authentication', Date.now() - 1000); swMock.generateAuthenticationOptions.mockResolvedValue({ challenge: 'auth-chal' }); expect(await svc.passkeyLoginOptions()).toEqual({ options: { challenge: 'auth-chal' } }); // No allowCredentials → the endpoint can't enumerate accounts. expect(swMock.generateAuthenticationOptions.mock.calls[0][0]).toEqual({ rpID: CFG.rpID, userVerification: 'required' }); - const stored = testDb.prepare('SELECT * FROM webauthn_challenges').all() as Array>; + const stored = await findRows(orm, WebauthnChallenges); expect(stored).toHaveLength(1); // the expired row was purged expect(stored[0]).toMatchObject({ challenge: 'auth-chal', user_id: null, type: 'authentication' }); }); @@ -541,20 +560,20 @@ describe('passkeyLoginVerify', () => { expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('never-stored') })) .toEqual({ error: 'Authentication failed', status: 401 }); // Claimed challenge but no usable credential id - seedChallenge('a1', null, 'authentication'); + await seedChallenge('a1', null, 'authentication'); expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a1', { id: 123 }) })) .toEqual({ error: 'Authentication failed', status: 401 }); }); it('PASSKEY-SVC-018: the challenge is spent on first use, even when verification fails', async () => { const { user } = createUser(testDb); - insertCredential(user.id, { credential_id: 'known' }); - seedChallenge('a2', null, 'authentication'); + await insertCredential(user.id, { credential_id: 'known' }); + await seedChallenge('a2', null, 'authentication'); swMock.verifyAuthenticationResponse.mockRejectedValue(new Error('bad signature')); expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a2', { id: 'known' }) })) .toEqual({ error: 'Authentication failed', status: 401 }); - expect(challengeCount()).toBe(0); + expect(await challengeCount()).toBe(0); // A double-submit of the same assertion cannot spend the challenge twice. expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a2', { id: 'known' }) })) .toEqual({ error: 'Authentication failed', status: 401 }); @@ -562,13 +581,13 @@ describe('passkeyLoginVerify', () => { }); it('PASSKEY-SVC-019: unknown credential and unverified assertion both yield the uniform 401', async () => { - seedChallenge('a3', null, 'authentication'); + await seedChallenge('a3', null, 'authentication'); expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a3', { id: 'no-such-cred' }) })) .toEqual({ error: 'Authentication failed', status: 401 }); const { user } = createUser(testDb); - insertCredential(user.id, { credential_id: 'known-2' }); - seedChallenge('a4', null, 'authentication'); + await insertCredential(user.id, { credential_id: 'known-2' }); + await seedChallenge('a4', null, 'authentication'); swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: false }); expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a4', { id: 'known-2' }) })) .toEqual({ error: 'Authentication failed', status: 401 }); @@ -576,8 +595,8 @@ describe('passkeyLoginVerify', () => { it('PASSKEY-SVC-020: falls back to rawId when id is absent', async () => { const { user } = createUser(testDb); - insertCredential(user.id, { credential_id: 'raw-cred', counter: 0 }); - seedChallenge('a5', null, 'authentication'); + await insertCredential(user.id, { credential_id: 'raw-cred', counter: 0 }); + await seedChallenge('a5', null, 'authentication'); swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 0 } }); const result = await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a5', { rawId: 'raw-cred' }) }); @@ -586,8 +605,8 @@ describe('passkeyLoginVerify', () => { it('PASSKEY-SVC-021: flags a counter regression as a suspected clone and audits it', async () => { const { user } = createUser(testDb); - const cred = insertCredential(user.id, { credential_id: 'cloned', counter: 10 }); - seedChallenge('a6', null, 'authentication'); + const cred = await insertCredential(user.id, { credential_id: 'cloned', counter: 10 }); + await seedChallenge('a6', null, 'authentication'); swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 10 } }); expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a6', { id: 'cloned' }) })).toEqual({ @@ -597,14 +616,14 @@ describe('passkeyLoginVerify', () => { auditAction: 'user.passkey_clone_suspected', }); // Rejects THIS assertion only — the credential is not disabled or touched. - const row = testDb.prepare('SELECT counter, last_used_at FROM webauthn_credentials WHERE id = ?').get(cred.id) as Record; - expect(row).toEqual({ counter: 10, last_used_at: null }); + const row = (await findRow(orm, WebauthnCredentials, { id: cred.id })); + expect(row).toMatchObject({ counter: 10, last_used_at: null }); }); it('PASSKEY-SVC-022: never treats a synced passkey stuck at counter 0 as a clone', async () => { const { user } = createUser(testDb); - insertCredential(user.id, { credential_id: 'synced', counter: 0, device_type: 'multiDevice' }); - seedChallenge('a7', null, 'authentication'); + await insertCredential(user.id, { credential_id: 'synced', counter: 0, device_type: 'multiDevice' }); + await seedChallenge('a7', null, 'authentication'); swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 0 } }); const result = await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a7', { id: 'synced' }) }); @@ -615,9 +634,9 @@ describe('passkeyLoginVerify', () => { it('PASSKEY-SVC-023: a credential whose user row is gone yields the uniform 401', async () => { // Orphan credential (FK off for the seed only) — the user lookup misses. testDb.exec('PRAGMA foreign_keys = OFF'); - insertCredential(424_242, { credential_id: 'orphan' }); + await insertCredential(424_242, { credential_id: 'orphan' }); testDb.exec('PRAGMA foreign_keys = ON'); - seedChallenge('a8', null, 'authentication'); + await seedChallenge('a8', null, 'authentication'); swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 1 } }); expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a8', { id: 'orphan' }) })) @@ -631,9 +650,9 @@ describe('passkeyLoginVerify', () => { // `SELECT *` row at runtime regardless of its `User`-typed generic, so // this must still survive the repository-backed path's `toClientUser` // spread, not be silently dropped by a narrower field-by-field mapping. - testDb.prepare("UPDATE users SET display_name = 'Legacy Passthrough' WHERE id = ?").run(user.id); - const cred = insertCredential(user.id, { credential_id: 'good', counter: 5 }); - seedChallenge('a9', null, 'authentication'); + await updateRows(orm, Users, { id: user.id }, { display_name: 'Legacy Passthrough' }); + const cred = await insertCredential(user.id, { credential_id: 'good', counter: 5 }); + await seedChallenge('a9', null, 'authentication'); swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 6 } }); const result = await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a9', { id: 'good' }) }); @@ -646,10 +665,10 @@ describe('passkeyLoginVerify', () => { expect(decoded.id).toBe(user.id); expect(decoded).toHaveProperty('pv'); - const credRow = testDb.prepare('SELECT counter, last_used_at FROM webauthn_credentials WHERE id = ?').get(cred.id) as Record; - expect(credRow.counter).toBe(6); - expect(credRow.last_used_at).not.toBeNull(); - const userRow = testDb.prepare('SELECT last_login, login_count FROM users WHERE id = ?').get(user.id) as Record; + const credRow = (await findRow(orm, WebauthnCredentials, { id: cred.id })); + expect(credRow?.counter).toBe(6); + expect(credRow?.last_used_at).not.toBeNull(); + const userRow = await readUser(orm, user.id); expect(userRow.last_login).not.toBeNull(); expect(userRow.login_count).toBe(1); }); @@ -659,8 +678,8 @@ describe('passkeyLoginVerify', () => { // scheme after enrollment must not brick every passkey login. resolveWebauthnConfigMock.mockReturnValue({ rpID: 'trek.example.org', rpName: 'TREK', origins: ['http://trek.example.org'], explicitOrigins: false }); const { user } = createUser(testDb); - insertCredential(user.id, { credential_id: 'scheme-heal' }); - seedChallenge('a10', null, 'authentication'); + await insertCredential(user.id, { credential_id: 'scheme-heal' }); + await seedChallenge('a10', null, 'authentication'); swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 1 } }); const result = await svc.passkeyLoginVerify({ @@ -677,8 +696,8 @@ describe('passkeyLoginVerify', () => { // subdomain ran against the apex RP ID. resolveWebauthnConfigMock.mockReturnValue({ rpID: 'example.com', rpName: 'TREK', origins: ['https://example.com'], explicitOrigins: false }); const { user } = createUser(testDb); - insertCredential(user.id, { credential_id: 'apex-cred' }); - seedChallenge('a11', null, 'authentication'); + await insertCredential(user.id, { credential_id: 'apex-cred' }); + await seedChallenge('a11', null, 'authentication'); swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 1 } }); await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a11', { id: 'apex-cred' }, { origin: 'https://blog.example.com' }) }); @@ -692,11 +711,11 @@ describe('listPasskeys', () => { it('PASSKEY-SVC-025: lists newest-first with backed_up remapped to a boolean', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - testDb.prepare( - `INSERT INTO webauthn_credentials (user_id, credential_id, public_key, counter, backed_up, name, created_at) - VALUES (?, 'old', x'01', 0, 0, 'Old', '2026-01-01 00:00:00'), (?, 'new', x'02', 0, 1, 'New', '2026-02-01 00:00:00')`, - ).run(user.id, user.id); - insertCredential(other.id); + await insertRows(orm, WebauthnCredentials, [ + { user: user.id, credential_id: 'old', public_key: Buffer.from([1]), counter: 0, backed_up: 0, name: 'Old', created_at: '2026-01-01 00:00:00' }, + { user: user.id, credential_id: 'new', public_key: Buffer.from([2]), counter: 0, backed_up: 1, name: 'New', created_at: '2026-02-01 00:00:00' }, + ]); + await insertCredential(other.id); const list = await svc.listPasskeys(user.id); expect(list.map((c) => c.name)).toEqual(['New', 'Old']); @@ -717,7 +736,7 @@ describe('listPasskeys', () => { describe('renamePasskey', () => { it('PASSKEY-SVC-026: rejects a missing, non-string or whitespace-only name', async () => { const { user } = createUser(testDb); - const cred = insertCredential(user.id); + const cred = await insertCredential(user.id); expect(await svc.renamePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Name is required', status: 400 }); expect(await svc.renamePasskey(user.id, String(cred.id), 42)).toEqual({ error: 'Name is required', status: 400 }); expect(await svc.renamePasskey(user.id, String(cred.id), ' ')).toEqual({ error: 'Name is required', status: 400 }); @@ -726,11 +745,11 @@ describe('renamePasskey', () => { it('PASSKEY-SVC-027: renames (trimmed, capped at 60) and 404s on foreign or unknown ids', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - const cred = insertCredential(user.id); + const cred = await insertCredential(user.id); expect(await svc.renamePasskey(user.id, String(cred.id), ` ${'n'.repeat(80)} `)).toEqual({ success: true }); - const row = testDb.prepare('SELECT name FROM webauthn_credentials WHERE id = ?').get(cred.id) as { name: string }; - expect(row.name).toBe('n'.repeat(60)); + const row = (await findRow(orm, WebauthnCredentials, { id: cred.id })); + expect(row?.name).toBe('n'.repeat(60)); expect(await svc.renamePasskey(other.id, String(cred.id), 'Steal')).toEqual({ error: 'Passkey not found', status: 404 }); expect(await svc.renamePasskey(user.id, '999999', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 }); @@ -758,21 +777,21 @@ describe('renamePasskey', () => { describe('deletePasskey', () => { it('PASSKEY-SVC-028: requires the current password (missing, wrong, or no hash all 401)', async () => { const { user, password } = createUser(testDb); - const cred = insertCredential(user.id); + const cred = await insertCredential(user.id); expect(await svc.deletePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Incorrect password', status: 401 }); expect(await svc.deletePasskey(user.id, String(cred.id), `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 }); - testDb.prepare("UPDATE users SET password_hash = '' WHERE id = ?").run(user.id); + await updateRows(orm, Users, { id: user.id }, { password_hash: '' }); expect(await svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ error: 'Incorrect password', status: 401 }); }); it('PASSKEY-SVC-029: deletes own credentials only — foreign ids 404 without leaking', async () => { const { user, password } = createUser(testDb); const { user: other, password: otherPassword } = createUser(testDb); - const cred = insertCredential(user.id); + const cred = await insertCredential(user.id); expect(await svc.deletePasskey(other.id, String(cred.id), otherPassword)).toEqual({ error: 'Passkey not found', status: 404 }); expect(await svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ success: true }); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials').get()).toEqual({ n: 0 }); + expect(await countRows(orm, WebauthnCredentials)).toBe(0); }); it('PASSKEY-SVC-043: 404s (not a 500) on a non-numeric id once the password check passes — Plan 3b Task 3 review, F1', async () => { @@ -802,13 +821,13 @@ describe('adminResetPasskeys', () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - insertCredential(user.id); - insertCredential(user.id); - const kept = insertCredential(other.id); + await insertCredential(user.id); + await insertCredential(user.id); + const kept = await insertCredential(other.id); expect(await svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 2, email: user.email }); expect(await svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 0, email: user.email }); - expect(testDb.prepare('SELECT id FROM webauthn_credentials').all()).toEqual([{ id: kept.id }]); + expect((await findRows(orm, WebauthnCredentials)).map((r) => ({ id: r.id }))).toEqual([{ id: kept.id }]); }); it('PASSKEY-SVC-045: 404s (not a 500) when the caller hands in NaN — AdminService.resetUserPasskeys converts a non-numeric route id with a bare Number(id) before calling in (Plan 3b Task 3 review, F1)', async () => {