mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
1092 lines
50 KiB
TypeScript
1092 lines
50 KiB
TypeScript
/**
|
||
* Unit tests for the DI-native PasskeyService — PASSKEY-SVC-001 through
|
||
* PASSKEY-SVC-041. Written fresh with the DI fold: the legacy
|
||
* services/passkeyService had no service-level suite, so these characterize
|
||
* the relocated behavior (challenge store semantics, WebAuthn ceremony error
|
||
* paths, clone detection, management CRUD) over a real in-memory SQLite DB;
|
||
* 031–032 pin the post-fold `fix(server)` quirk fix (the transactional
|
||
* dup-check → INSERT with its 409-vs-400 sentinel split); 033 to 041 pin the
|
||
* #2147 origin hardening (pre-ceremony Origin gate for the derived localhost
|
||
* fallback, scheme/port widening of expectedOrigin for derived configs, and
|
||
* that the host itself is never widened).
|
||
* The @simplewebauthn/server ceremony functions are mocked — the library's
|
||
* crypto is not under test, the service's handling of its verdicts is.
|
||
* Constructed directly (no TestingModule, repo convention).
|
||
*/
|
||
import { db as testDb } from '../../../src/db/database';
|
||
import { Users } from '../../../src/db/entities/Users.entity';
|
||
import { WebauthnChallenges } from '../../../src/db/entities/WebauthnChallenges.entity';
|
||
import { WebauthnCredentials } from '../../../src/db/entities/WebauthnCredentials.entity';
|
||
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
|
||
import { EphemeralTokenService } from '../../../src/nest/auth-core/ephemeral-token.service';
|
||
import { AuthService } from '../../../src/nest/auth/auth.service';
|
||
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
|
||
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
||
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
|
||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
|
||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||
import { budgetRepoArgs } from '../../helpers/budget-repos';
|
||
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
|
||
import { asLegacyResult } from '../../helpers/domain-error';
|
||
import { createUser } from '../../helpers/factories';
|
||
import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
|
||
import { readUser } from '../../helpers/factories/users';
|
||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||
import {
|
||
createTestJourneysRepo,
|
||
createTestJourneyEntriesRepo,
|
||
createTestJourneyContributorsRepo,
|
||
} from '../../helpers/journey-repos';
|
||
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
|
||
import { createTestPushSubscriptionsRepo } from '../../helpers/notifications-repos';
|
||
import { createTestSessionsService } from '../../helpers/sessions';
|
||
import {
|
||
createTestShareTokensRepo,
|
||
createTestPluginsRepo,
|
||
createTestPluginUserErasureQueueRepo,
|
||
} from '../../helpers/share-repos';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||
import {
|
||
createTestUnitOfWork,
|
||
createTestAppSettingsRepo,
|
||
sharedTestOrm,
|
||
createTestUsersRepo,
|
||
createTestWebauthnCredentialsRepo,
|
||
createTestWebauthnChallengesRepo,
|
||
createTestInviteTokensRepo,
|
||
createTestMcpTokensRepo,
|
||
createTestOauthTokensRepo,
|
||
createTestPasswordResetTokensRepo,
|
||
createTestTripsRepo,
|
||
createTestTripMembersRepo,
|
||
} from '../../helpers/test-uow';
|
||
|
||
import jwtLib from 'jsonwebtoken';
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
|
||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||
|
||
vi.mock('../../../src/db/database', async () => {
|
||
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
||
const db = createSnapshotTestDb();
|
||
// Passkeys are trip-free, so the trip-access helpers are inert stubs.
|
||
const mock = {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
getPlaceWithTags: () => null,
|
||
canAccessTrip: () => undefined,
|
||
isOwner: () => false,
|
||
};
|
||
return mock;
|
||
});
|
||
|
||
// Construction insurance for the injected AuthService's import graph (same set
|
||
// as oidc.service.test.ts / auth.service.test.ts).
|
||
vi.mock('../../../src/nest/common/crypto/mfaCrypto', () => ({
|
||
encryptMfaSecret: vi.fn((s) => `enc:${s}`),
|
||
decryptMfaSecret: vi.fn((s: string) => s.replace('enc:', '')),
|
||
}));
|
||
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||
decrypt_api_key: vi.fn((v) => v),
|
||
maybe_encrypt_api_key: vi.fn((v) => v),
|
||
mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)),
|
||
encrypt_api_key: vi.fn((v) => v),
|
||
}));
|
||
vi.mock('../../../src/nest/auth-core/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() }));
|
||
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
|
||
|
||
// The WebAuthn ceremony verdicts are the mock boundary — the service must
|
||
// persist ONLY what the verifier vouches for, so the tests hand it verdicts.
|
||
const { swMock } = vi.hoisted(() => ({
|
||
swMock: {
|
||
generateRegistrationOptions: vi.fn(),
|
||
verifyRegistrationResponse: vi.fn(),
|
||
generateAuthenticationOptions: vi.fn(),
|
||
verifyAuthenticationResponse: vi.fn(),
|
||
},
|
||
}));
|
||
vi.mock('@simplewebauthn/server', () => swMock);
|
||
|
||
// MailerService is injected since the notifications fold — a stub instead of a
|
||
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
|
||
const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService;
|
||
|
||
// The RP config resolver has its own suite (webauthn-config.service.test.ts);
|
||
// here it is a switch for the configured / not-configured branches, handed in
|
||
// as a stub now that PasskeyService injects it.
|
||
const resolveWebauthnConfigMock = vi.fn();
|
||
const webauthn = { resolve: resolveWebauthnConfigMock } as unknown as WebauthnConfigService;
|
||
|
||
// Positional, and previously shifted by one: an AtlasService sat in the
|
||
// membership slot, which AuthService no longer takes at all, so
|
||
// webauthn/userCleanup/mailer each landed one place too late and the
|
||
// EphemeralTokenService was missing entirely. Nothing failed, because the only
|
||
// thing PasskeyService asks AuthService for is generateToken, which reads the
|
||
// database and nothing else. The collaborators are real here rather than
|
||
// undefined so a future case that does reach one gets a working object; each
|
||
// takes only the DatabaseService. AuthService's own webauthn is the real
|
||
// resolver, separate from the `webauthn` switch PasskeyService is handed.
|
||
|
||
let auth: AuthService;
|
||
let svc: PasskeyService;
|
||
beforeAll(async () => {
|
||
auth = new AuthService(
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
new TripMembershipService(await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb)),
|
||
new WebauthnConfigService(await createTestAppSettingsRepo(testDb)),
|
||
new UserCleanupService(
|
||
new MaintenanceRepository((await sharedTestOrm(testDb)).em),
|
||
new BudgetService(
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
new ExchangeRatesService(),
|
||
new RealtimeService(),
|
||
await createTestUnitOfWork(testDb),
|
||
...(await budgetRepoArgs(testDb)),
|
||
),
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestUsersRepo(testDb),
|
||
await createTestTripMembersRepo(testDb),
|
||
await createTestBudgetItemsRepo(testDb),
|
||
await createTestBudgetSettlementsRepo(testDb),
|
||
await createTestJourneyShareTokensRepo(testDb),
|
||
await createTestJourneysRepo(testDb),
|
||
await createTestJourneyEntriesRepo(testDb),
|
||
await createTestJourneyContributorsRepo(testDb),
|
||
await createTestShareTokensRepo(testDb),
|
||
await createTestPluginsRepo(testDb),
|
||
await createTestPluginUserErasureQueueRepo(testDb),
|
||
),
|
||
mailerStub,
|
||
new EphemeralTokenService(),
|
||
new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)),
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestAppSettingsRepo(testDb),
|
||
await createTestUsersRepo(testDb),
|
||
await createTestInviteTokensRepo(testDb),
|
||
await createTestMcpTokensRepo(testDb),
|
||
await createTestOauthTokensRepo(testDb),
|
||
await createTestWebauthnCredentialsRepo(testDb),
|
||
await createTestPasswordResetTokensRepo(testDb),
|
||
await createTestPushSubscriptionsRepo(testDb),
|
||
await createTestSessionsService(testDb),
|
||
);
|
||
svc = new PasskeyService(
|
||
auth,
|
||
webauthn,
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestWebauthnCredentialsRepo(testDb),
|
||
await createTestWebauthnChallengesRepo(testDb),
|
||
await createTestUsersRepo(testDb),
|
||
);
|
||
});
|
||
|
||
const CFG = { rpID: 'trek.example.com', rpName: 'TREK', origins: ['https://trek.example.com'], explicitOrigins: false };
|
||
// The unconfigured fallback resolve() yields when APP_URL is unset (#2147).
|
||
const LOCALHOST_CFG = {
|
||
rpID: 'localhost',
|
||
rpName: 'TREK',
|
||
origins: ['http://localhost:5173', 'http://localhost:3001'],
|
||
explicitOrigins: false,
|
||
};
|
||
const NOT_CONFIGURED_ERROR = 'Passkey login is not configured for this server.';
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
resolveWebauthnConfigMock.mockReturnValue(CFG);
|
||
resetTestDb(testDb);
|
||
});
|
||
|
||
let orm: TestOrm;
|
||
|
||
beforeAll(async () => {
|
||
orm = await createTestOrm(testDb);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await orm.close();
|
||
testDb.close();
|
||
});
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
/** A response payload whose clientDataJSON echoes the given challenge (plus optional extra client data, e.g. origin). */
|
||
function ceremonyResponse(
|
||
challenge: string,
|
||
extra: Record<string, unknown> = {},
|
||
clientData: Record<string, unknown> = {},
|
||
) {
|
||
return {
|
||
response: {
|
||
clientDataJSON: Buffer.from(JSON.stringify({ challenge, ...clientData }), 'utf8').toString('base64url'),
|
||
},
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
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(): Promise<number> {
|
||
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;
|
||
async function insertCredential(userId: number, overrides: CredentialOverrides = {}) {
|
||
_credSeq++;
|
||
const row = {
|
||
credential_id: `cred-${_credSeq}`,
|
||
public_key: Buffer.from([1, 2, 3]),
|
||
counter: 0,
|
||
transports: null,
|
||
device_type: 'singleDevice',
|
||
backed_up: 0,
|
||
name: `Passkey ${_credSeq}`,
|
||
aaguid: null,
|
||
...overrides,
|
||
};
|
||
const id = await insertRow(orm, WebauthnCredentials, { user: userId, ...row });
|
||
return { id, ...row };
|
||
}
|
||
|
||
function registrationVerdict(
|
||
overrides: Record<string, unknown> = {},
|
||
credentialOverrides: Record<string, unknown> = {},
|
||
) {
|
||
return {
|
||
verified: true,
|
||
registrationInfo: {
|
||
credential: {
|
||
id: 'new-cred',
|
||
publicKey: new Uint8Array([9, 9, 9]),
|
||
counter: 0,
|
||
transports: ['internal'],
|
||
...credentialOverrides,
|
||
},
|
||
credentialDeviceType: 'singleDevice',
|
||
credentialBackedUp: false,
|
||
aaguid: 'aaguid-1',
|
||
...overrides,
|
||
},
|
||
};
|
||
}
|
||
|
||
// ── passkeyRegisterOptions ────────────────────────────────────────────────────
|
||
|
||
describe('passkeyRegisterOptions', () => {
|
||
it('PASSKEY-SVC-001: returns the not-configured 400 when no RP config resolves', async () => {
|
||
resolveWebauthnConfigMock.mockReturnValue(null);
|
||
expect(await asLegacyResult(svc.passkeyRegisterOptions(1, 'pw'))).toEqual({
|
||
error: NOT_CONFIGURED_ERROR,
|
||
status: 400,
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-002: 404s for an unknown user', async () => {
|
||
expect(await asLegacyResult(svc.passkeyRegisterOptions(999_999, 'pw'))).toEqual({
|
||
error: 'User not found',
|
||
status: 404,
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-003: requires the current password (missing and wrong both 401)', async () => {
|
||
const { user, password } = createUser(testDb);
|
||
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, undefined))).toEqual({
|
||
error: 'Incorrect password',
|
||
status: 401,
|
||
});
|
||
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, `${password}x`))).toEqual({
|
||
error: 'Incorrect password',
|
||
status: 401,
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-004: generates options excluding existing credentials and stores the challenge', async () => {
|
||
const { user, password } = createUser(testDb);
|
||
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 asLegacyResult(svc.passkeyRegisterOptions(user.id, password));
|
||
expect(result).toEqual({ options: { challenge: 'reg-chal' } });
|
||
|
||
const call = swMock.generateRegistrationOptions.mock.calls[0][0];
|
||
expect(call.rpID).toBe(CFG.rpID);
|
||
expect(call.rpName).toBe(CFG.rpName);
|
||
expect(call.userName).toBe(user.email);
|
||
expect(call.attestationType).toBe('none');
|
||
expect(call.supportedAlgorithmIDs).toEqual([-8, -7, -257]);
|
||
expect(call.authenticatorSelection).toEqual({ residentKey: 'preferred', userVerification: 'required' });
|
||
expect(call.excludeCredentials).toEqual([
|
||
{ id: 'cred-a', transports: ['usb', 'nfc'] },
|
||
{ id: 'cred-b', transports: undefined }, // unparseable transports degrade to undefined
|
||
]);
|
||
|
||
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());
|
||
});
|
||
|
||
it('PASSKEY-SVC-033: the derived localhost fallback fails fast for a foreign request origin (#2147)', async () => {
|
||
// APP_URL unset → resolve() yields the localhost phantom config. A browser
|
||
// on a real domain can never finish that ceremony, so the actionable
|
||
// not-configured 400 comes BEFORE the ceremony, not as a cryptic verify 400.
|
||
resolveWebauthnConfigMock.mockReturnValue(LOCALHOST_CFG);
|
||
const { user, password } = createUser(testDb);
|
||
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, 'https://trip.example.org'))).toEqual({
|
||
error: NOT_CONFIGURED_ERROR,
|
||
status: 400,
|
||
});
|
||
expect(swMock.generateRegistrationOptions).not.toHaveBeenCalled();
|
||
expect(await challengeCount()).toBe(0);
|
||
});
|
||
|
||
it('PASSKEY-SVC-034: localhost origins and headerless requests keep working against the dev fallback', async () => {
|
||
resolveWebauthnConfigMock.mockReturnValue(LOCALHOST_CFG);
|
||
const { user, password } = createUser(testDb);
|
||
let seq = 0; // distinct challenges — the store has a UNIQUE constraint
|
||
swMock.generateRegistrationOptions.mockImplementation(async () => ({ challenge: `reg-chal-${++seq}` }));
|
||
|
||
// Listed origin, in-scope origin on another port, no header (curl) — all pass.
|
||
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, 'http://localhost:5173'))).toEqual({
|
||
options: { challenge: 'reg-chal-1' },
|
||
});
|
||
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, 'http://localhost:8080'))).toEqual({
|
||
options: { challenge: 'reg-chal-2' },
|
||
});
|
||
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, undefined))).toEqual({
|
||
options: { challenge: 'reg-chal-3' },
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-035: a mismatching origin never blocks a real or explicit config (proxy tolerance)', async () => {
|
||
// Proxies may rewrite the Origin header to an internal value while
|
||
// clientDataJSON still carries the real one — options must not hard-fail
|
||
// for a config that verify can still satisfy.
|
||
const { user, password } = createUser(testDb);
|
||
let seq = 0;
|
||
swMock.generateRegistrationOptions.mockImplementation(async () => ({ challenge: `reg-chal-${++seq}` }));
|
||
|
||
resolveWebauthnConfigMock.mockReturnValue(CFG); // derived from a real APP_URL
|
||
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, 'http://10.0.0.5:3001'))).toEqual({
|
||
options: { challenge: 'reg-chal-1' },
|
||
});
|
||
|
||
resolveWebauthnConfigMock.mockReturnValue({ ...LOCALHOST_CFG, explicitOrigins: true }); // operator contract
|
||
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, 'https://trip.example.org'))).toEqual({
|
||
options: { challenge: 'reg-chal-2' },
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── passkeyRegisterVerify ─────────────────────────────────────────────────────
|
||
|
||
describe('passkeyRegisterVerify', () => {
|
||
it('PASSKEY-SVC-005: returns the not-configured 400 when no RP config resolves', async () => {
|
||
resolveWebauthnConfigMock.mockReturnValue(null);
|
||
expect(await asLegacyResult(svc.passkeyRegisterVerify(1, { attestationResponse: {} }))).toEqual({
|
||
error: NOT_CONFIGURED_ERROR,
|
||
status: 400,
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-006: 400s on a missing or undecodable attestation response', async () => {
|
||
expect(await asLegacyResult(svc.passkeyRegisterVerify(1, {}))).toEqual({
|
||
error: 'Invalid registration response',
|
||
status: 400,
|
||
});
|
||
expect(
|
||
await asLegacyResult(
|
||
svc.passkeyRegisterVerify(1, { attestationResponse: { response: { clientDataJSON: '!!!not-base64-json' } } }),
|
||
),
|
||
).toEqual({ error: 'Invalid registration response', status: 400 });
|
||
});
|
||
|
||
it('PASSKEY-SVC-007: 400s on an unknown or expired challenge', async () => {
|
||
const { user } = createUser(testDb);
|
||
expect(
|
||
await asLegacyResult(
|
||
svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('never-stored') }),
|
||
),
|
||
).toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 });
|
||
await seedChallenge('expired', user.id, 'registration', Date.now() - 1);
|
||
expect(
|
||
await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('expired') })),
|
||
).toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 });
|
||
});
|
||
|
||
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);
|
||
await seedChallenge('cross', alice.id, 'registration');
|
||
expect(
|
||
await asLegacyResult(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(await challengeCount()).toBe(0);
|
||
expect(
|
||
await asLegacyResult(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);
|
||
await seedChallenge('c1', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockRejectedValue(new Error('boom'));
|
||
expect(
|
||
await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c1') })),
|
||
).toEqual({ error: 'Could not register this passkey.', status: 400 });
|
||
|
||
await seedChallenge('c2', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockResolvedValue({ verified: false });
|
||
expect(
|
||
await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c2') })),
|
||
).toEqual({ error: 'Could not register this passkey.', status: 400 });
|
||
});
|
||
|
||
it('PASSKEY-SVC-010: 409s when the credential id is already registered', async () => {
|
||
const { user } = createUser(testDb);
|
||
await insertCredential(user.id, { credential_id: 'dup-cred' });
|
||
await seedChallenge('c3', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'dup-cred' }));
|
||
expect(
|
||
await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c3') })),
|
||
).toEqual({ error: 'This passkey is already registered.', status: 409 });
|
||
});
|
||
|
||
it('PASSKEY-SVC-011: persists the verifier-vouched credential and returns it re-selected', async () => {
|
||
const { user } = createUser(testDb);
|
||
await seedChallenge('c4', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { counter: 7 }));
|
||
|
||
const result = await asLegacyResult(
|
||
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 = 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]));
|
||
});
|
||
|
||
it('PASSKEY-SVC-012: defaults a blank name by device type and caps a long one at 60 chars', async () => {
|
||
const { user } = createUser(testDb);
|
||
|
||
await seedChallenge('c5', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockResolvedValue(
|
||
registrationVerdict({ credentialDeviceType: 'multiDevice', credentialBackedUp: true }, { id: 'synced-cred' }),
|
||
);
|
||
const synced = await asLegacyResult(
|
||
svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c5'), name: ' ' }),
|
||
);
|
||
expect(synced.credential).toMatchObject({ name: 'Passkey (synced)', backed_up: true });
|
||
|
||
await seedChallenge('c6', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'named-cred' }));
|
||
const long = await asLegacyResult(
|
||
svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c6'), name: 'x'.repeat(80) }),
|
||
);
|
||
expect(long.credential).toMatchObject({ name: 'x'.repeat(60) });
|
||
});
|
||
|
||
it('PASSKEY-SVC-013: an INSERT failure maps to the generic 400 (FK violation stand-in)', async () => {
|
||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
|
||
// 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');
|
||
await seedChallenge('c7', 999_999, 'registration');
|
||
testDb.exec('PRAGMA foreign_keys = ON');
|
||
expect(
|
||
await asLegacyResult(svc.passkeyRegisterVerify(999_999, { attestationResponse: ceremonyResponse('c7') })),
|
||
).toEqual({ error: 'Could not register this passkey.', status: 400 });
|
||
});
|
||
|
||
// 031/032 pin the post-fold quirk fix: the dup check + INSERT run in one
|
||
// transaction, keeping the 409-vs-400 split and rolling back cleanly.
|
||
it('PASSKEY-SVC-031: a failed INSERT rolls back without partial credential state', async () => {
|
||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
|
||
testDb.exec('PRAGMA foreign_keys = OFF');
|
||
await seedChallenge('c8', 999_999, 'registration');
|
||
testDb.exec('PRAGMA foreign_keys = ON');
|
||
await asLegacyResult(svc.passkeyRegisterVerify(999_999, { attestationResponse: ceremonyResponse('c8') }));
|
||
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);
|
||
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 asLegacyResult(
|
||
svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c9'), name: 'Impostor' }),
|
||
),
|
||
).toEqual({ error: 'This passkey is already registered.', status: 409 });
|
||
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 () => {
|
||
// ALLOWED_ORIGINS-derived configs carry the wrong scheme behind a
|
||
// TLS-terminating proxy (http://… while the browser is on https://…). The
|
||
// browser-asserted origin is added because it falls within the RP scope;
|
||
// 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);
|
||
await seedChallenge('w1', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
|
||
|
||
const result = await asLegacyResult(
|
||
svc.passkeyRegisterVerify(user.id, {
|
||
attestationResponse: ceremonyResponse('w1', {}, { origin: 'https://trek.example.org' }),
|
||
}),
|
||
);
|
||
expect(result.success).toBe(true);
|
||
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin).toEqual([
|
||
'http://trek.example.org',
|
||
'https://trek.example.org',
|
||
]);
|
||
});
|
||
|
||
it('PASSKEY-SVC-037: never widens for an explicit origins list or an out-of-scope origin', async () => {
|
||
const { user } = createUser(testDb);
|
||
|
||
// 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 });
|
||
await seedChallenge('w2', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
|
||
await asLegacyResult(
|
||
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);
|
||
await seedChallenge('w3', user.id, 'registration');
|
||
await asLegacyResult(
|
||
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']);
|
||
});
|
||
|
||
it('PASSKEY-SVC-040: a derived config heals scheme and port, never the host (#2147)', async () => {
|
||
// With the RP ID on an apex domain the browser runs a ceremony from any
|
||
// subdomain of it, so a taken-over sibling could hand over an assertion
|
||
// nothing else in the chain rejects. The exact-origin list is the only
|
||
// defence there, so only scheme/port drift on the RP ID's host is healed.
|
||
resolveWebauthnConfigMock.mockReturnValue({
|
||
rpID: 'example.com',
|
||
rpName: 'TREK',
|
||
origins: ['https://example.com'],
|
||
explicitOrigins: false,
|
||
});
|
||
const { user } = createUser(testDb);
|
||
|
||
await seedChallenge('w4', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockResolvedValueOnce(registrationVerdict({}, { id: 'sibling-cred' }));
|
||
await asLegacyResult(
|
||
svc.passkeyRegisterVerify(user.id, {
|
||
attestationResponse: ceremonyResponse('w4', {}, { origin: 'https://blog.example.com' }),
|
||
}),
|
||
);
|
||
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://example.com']);
|
||
|
||
await seedChallenge('w5', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockResolvedValueOnce(registrationVerdict({}, { id: 'port-cred' }));
|
||
await asLegacyResult(
|
||
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',
|
||
]);
|
||
});
|
||
});
|
||
|
||
// ── passkeyLoginOptions ───────────────────────────────────────────────────────
|
||
|
||
describe('passkeyLoginOptions', () => {
|
||
it('PASSKEY-SVC-014: returns the not-configured 400 when no RP config resolves', async () => {
|
||
resolveWebauthnConfigMock.mockReturnValue(null);
|
||
expect(await asLegacyResult(svc.passkeyLoginOptions())).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
||
});
|
||
|
||
it('PASSKEY-SVC-015: stores an anonymous authentication challenge (discoverable flow)', async () => {
|
||
await seedChallenge('stale', null, 'authentication', Date.now() - 1000);
|
||
swMock.generateAuthenticationOptions.mockResolvedValue({ challenge: 'auth-chal' });
|
||
|
||
expect(await asLegacyResult(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 = 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' });
|
||
});
|
||
|
||
it('PASSKEY-SVC-038: the derived localhost fallback fails fast for a foreign request origin (#2147)', async () => {
|
||
resolveWebauthnConfigMock.mockReturnValue(LOCALHOST_CFG);
|
||
expect(await asLegacyResult(svc.passkeyLoginOptions('https://trip.example.org'))).toEqual({
|
||
error: NOT_CONFIGURED_ERROR,
|
||
status: 400,
|
||
});
|
||
expect(swMock.generateAuthenticationOptions).not.toHaveBeenCalled();
|
||
|
||
// No Origin header (non-browser client) → unchanged legacy behavior.
|
||
swMock.generateAuthenticationOptions.mockResolvedValue({ challenge: 'auth-chal' });
|
||
expect(await asLegacyResult(svc.passkeyLoginOptions())).toEqual({ options: { challenge: 'auth-chal' } });
|
||
});
|
||
});
|
||
|
||
// ── passkeyLoginVerify ────────────────────────────────────────────────────────
|
||
|
||
describe('passkeyLoginVerify', () => {
|
||
it('PASSKEY-SVC-016: returns the not-configured 400, not the generic 401', async () => {
|
||
resolveWebauthnConfigMock.mockReturnValue(null);
|
||
expect(await svc.passkeyLoginVerify({ assertionResponse: {} })).toEqual({
|
||
error: NOT_CONFIGURED_ERROR,
|
||
status: 400,
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-017: every malformed-input path collapses into the uniform 401', async () => {
|
||
expect(await svc.passkeyLoginVerify({})).toEqual({ error: 'Authentication failed', status: 401 });
|
||
expect(await svc.passkeyLoginVerify({ assertionResponse: { response: { clientDataJSON: 42 } } })).toEqual({
|
||
error: 'Authentication failed',
|
||
status: 401,
|
||
});
|
||
// Unknown challenge
|
||
expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('never-stored') })).toEqual({
|
||
error: 'Authentication failed',
|
||
status: 401,
|
||
});
|
||
// Claimed challenge but no usable credential id
|
||
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);
|
||
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(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,
|
||
});
|
||
expect(swMock.verifyAuthenticationResponse).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('PASSKEY-SVC-019: unknown credential and unverified assertion both yield the uniform 401', async () => {
|
||
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);
|
||
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,
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-020: falls back to rawId when id is absent', async () => {
|
||
const { user } = createUser(testDb);
|
||
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' }) });
|
||
expect(result.token).toBeTruthy();
|
||
});
|
||
|
||
it('PASSKEY-SVC-021: flags a counter regression as a suspected clone and audits it', async () => {
|
||
const { user } = createUser(testDb);
|
||
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({
|
||
error: 'Authentication failed',
|
||
status: 401,
|
||
auditUserId: user.id,
|
||
auditAction: 'user.passkey_clone_suspected',
|
||
});
|
||
// Rejects THIS assertion only — the credential is not disabled or touched.
|
||
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);
|
||
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' }) });
|
||
expect(result.error).toBeUndefined();
|
||
expect(result.token).toBeTruthy();
|
||
});
|
||
|
||
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');
|
||
await insertCredential(424_242, { credential_id: 'orphan' });
|
||
testDb.exec('PRAGMA foreign_keys = ON');
|
||
await seedChallenge('a8', null, 'authentication');
|
||
swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 1 } });
|
||
|
||
expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a8', { id: 'orphan' }) })).toEqual({
|
||
error: 'Authentication failed',
|
||
status: 401,
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-024: success mints a real session, strips the user and bumps the bookkeeping', async () => {
|
||
const { user } = createUser(testDb);
|
||
// A `users` column outside the `User` contract type (Plan 3b Task 3
|
||
// review, F5) — the legacy `stripUserForClient(user)` ran on the FULL
|
||
// `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.
|
||
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' }) });
|
||
expect(result.auditUserId).toBe(user.id);
|
||
expect(result.user).toMatchObject({ id: user.id, email: user.email, display_name: 'Legacy Passthrough' });
|
||
expect(result.user).toHaveProperty('avatar_url');
|
||
expect(result.user).not.toHaveProperty('password_hash');
|
||
// The token is the SAME session shape password login mints ({ id, pv }).
|
||
const decoded = jwtLib.verify(result.token!, 'test-jwt-secret-for-trek-testing-only') as Record<string, unknown>;
|
||
expect(decoded.id).toBe(user.id);
|
||
expect(decoded).toHaveProperty('pv');
|
||
|
||
const credRow = await findRow(orm, WebauthnCredentials, { id: cred.id });
|
||
expect(credRow).not.toBeNull();
|
||
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);
|
||
});
|
||
|
||
it('PASSKEY-SVC-039: widens expectedOrigin for a derived config on login too (#2147)', async () => {
|
||
// Same derivation chain as registration: an instance whose APP_URL changes
|
||
// 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);
|
||
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({
|
||
assertionResponse: ceremonyResponse('a10', { id: 'scheme-heal' }, { origin: 'https://trek.example.org' }),
|
||
});
|
||
expect(result.token).toBeTruthy();
|
||
expect(swMock.verifyAuthenticationResponse.mock.calls[0][0].expectedOrigin).toEqual([
|
||
'http://trek.example.org',
|
||
'https://trek.example.org',
|
||
]);
|
||
});
|
||
|
||
it('PASSKEY-SVC-041: login never widens to a sibling subdomain of the RP ID (#2147)', async () => {
|
||
// The relay path the exact-origin list exists for: a challenge fetched from
|
||
// the public options endpoint, spent by a ceremony a compromised sibling
|
||
// subdomain ran against the apex RP ID.
|
||
resolveWebauthnConfigMock.mockReturnValue({
|
||
rpID: 'example.com',
|
||
rpName: 'TREK',
|
||
origins: ['https://example.com'],
|
||
explicitOrigins: false,
|
||
});
|
||
const { user } = createUser(testDb);
|
||
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' }),
|
||
});
|
||
expect(swMock.verifyAuthenticationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://example.com']);
|
||
});
|
||
});
|
||
|
||
// ── listPasskeys ──────────────────────────────────────────────────────────────
|
||
|
||
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);
|
||
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']);
|
||
expect(list[0]).toEqual({
|
||
id: expect.any(Number),
|
||
name: 'New',
|
||
device_type: null,
|
||
backed_up: true,
|
||
created_at: '2026-02-01 00:00:00',
|
||
last_used_at: null,
|
||
});
|
||
expect(list[1].backed_up).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ── renamePasskey ─────────────────────────────────────────────────────────────
|
||
|
||
describe('renamePasskey', () => {
|
||
it('PASSKEY-SVC-026: rejects a missing, non-string or whitespace-only name', async () => {
|
||
const { user } = createUser(testDb);
|
||
const cred = await insertCredential(user.id);
|
||
expect(await asLegacyResult(svc.renamePasskey(user.id, String(cred.id), undefined))).toEqual({
|
||
error: 'Name is required',
|
||
status: 400,
|
||
});
|
||
expect(await asLegacyResult(svc.renamePasskey(user.id, String(cred.id), 42))).toEqual({
|
||
error: 'Name is required',
|
||
status: 400,
|
||
});
|
||
expect(await asLegacyResult(svc.renamePasskey(user.id, String(cred.id), ' '))).toEqual({
|
||
error: 'Name is required',
|
||
status: 400,
|
||
});
|
||
});
|
||
|
||
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 = await insertCredential(user.id);
|
||
|
||
expect(await asLegacyResult(svc.renamePasskey(user.id, String(cred.id), ` ${'n'.repeat(80)} `))).toEqual({
|
||
success: true,
|
||
});
|
||
const row = await findRow(orm, WebauthnCredentials, { id: cred.id });
|
||
expect(row?.name).toBe('n'.repeat(60));
|
||
|
||
expect(await asLegacyResult(svc.renamePasskey(other.id, String(cred.id), 'Steal'))).toEqual({
|
||
error: 'Passkey not found',
|
||
status: 404,
|
||
});
|
||
expect(await asLegacyResult(svc.renamePasskey(user.id, '999999', 'Ghost'))).toEqual({
|
||
error: 'Passkey not found',
|
||
status: 404,
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-042: 404s (not a 500) on a non-numeric id — Plan 3b Task 3 review, F1', async () => {
|
||
const { user } = createUser(testDb);
|
||
expect(await asLegacyResult(svc.renamePasskey(user.id, 'abc', 'Ghost'))).toEqual({
|
||
error: 'Passkey not found',
|
||
status: 404,
|
||
});
|
||
expect(await asLegacyResult(svc.renamePasskey(user.id, '1abc', 'Ghost'))).toEqual({
|
||
error: 'Passkey not found',
|
||
status: 404,
|
||
});
|
||
});
|
||
|
||
// task-3-rereview.md R1: the legacy `renamePasskey` bound `Number(id)`
|
||
// (unlike the invite route's raw-string bind), so `Number('0x10') === 16`
|
||
// — the legacy statement would have ACTED on credential 16, not 404'd.
|
||
// This pins the deliberate `toRowId` narrowing the fix round chose
|
||
// (accepted deviation, not parity), not "the legacy 404".
|
||
it('PASSKEY-SVC-042B: refuses a prefixed-numeric id (0x10) via the toRowId narrowing', async () => {
|
||
const { user } = createUser(testDb);
|
||
expect(await asLegacyResult(svc.renamePasskey(user.id, '0x10', 'Ghost'))).toEqual({
|
||
error: 'Passkey not found',
|
||
status: 404,
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── deletePasskey ─────────────────────────────────────────────────────────────
|
||
|
||
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 = await insertCredential(user.id);
|
||
expect(await asLegacyResult(svc.deletePasskey(user.id, String(cred.id), undefined))).toEqual({
|
||
error: 'Incorrect password',
|
||
status: 401,
|
||
});
|
||
expect(await asLegacyResult(svc.deletePasskey(user.id, String(cred.id), `${password}x`))).toEqual({
|
||
error: 'Incorrect password',
|
||
status: 401,
|
||
});
|
||
await updateRows(orm, Users, { id: user.id }, { password_hash: '' });
|
||
expect(await asLegacyResult(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 = await insertCredential(user.id);
|
||
|
||
expect(await asLegacyResult(svc.deletePasskey(other.id, String(cred.id), otherPassword))).toEqual({
|
||
error: 'Passkey not found',
|
||
status: 404,
|
||
});
|
||
expect(await asLegacyResult(svc.deletePasskey(user.id, String(cred.id), password))).toEqual({ success: true });
|
||
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 () => {
|
||
const { user, password } = createUser(testDb);
|
||
expect(await asLegacyResult(svc.deletePasskey(user.id, 'abc', password))).toEqual({
|
||
error: 'Passkey not found',
|
||
status: 404,
|
||
});
|
||
});
|
||
|
||
// task-3-rereview.md R1: same distinction as PASSKEY-SVC-042B —
|
||
// `deletePasskey`'s legacy bind was `Number(id)`, so `'0x10'` would have
|
||
// acted on credential 16, not 404'd. This pins the `toRowId` narrowing.
|
||
it('PASSKEY-SVC-043B: refuses a prefixed-numeric id (0x10) via the toRowId narrowing, once the password check passes', async () => {
|
||
const { user, password } = createUser(testDb);
|
||
expect(await asLegacyResult(svc.deletePasskey(user.id, '0x10', password))).toEqual({
|
||
error: 'Passkey not found',
|
||
status: 404,
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-044: a wrong password still wins over a non-numeric id — 401, not 404 (legacy statement order)', async () => {
|
||
const { user, password } = createUser(testDb);
|
||
expect(await asLegacyResult(svc.deletePasskey(user.id, 'abc', `${password}x`))).toEqual({
|
||
error: 'Incorrect password',
|
||
status: 401,
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── adminResetPasskeys ────────────────────────────────────────────────────────
|
||
|
||
describe('adminResetPasskeys', () => {
|
||
it('PASSKEY-SVC-030: 404s on an unknown user, else clears all credentials and reports the count', async () => {
|
||
expect(await asLegacyResult(svc.adminResetPasskeys(999_999))).toEqual({ error: 'User not found', status: 404 });
|
||
|
||
const { user } = createUser(testDb);
|
||
const { user: other } = createUser(testDb);
|
||
await insertCredential(user.id);
|
||
await insertCredential(user.id);
|
||
const kept = await insertCredential(other.id);
|
||
|
||
expect(await asLegacyResult(svc.adminResetPasskeys(user.id))).toEqual({
|
||
success: true,
|
||
deleted: 2,
|
||
email: user.email,
|
||
});
|
||
expect(await asLegacyResult(svc.adminResetPasskeys(user.id))).toEqual({
|
||
success: true,
|
||
deleted: 0,
|
||
email: user.email,
|
||
});
|
||
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 () => {
|
||
expect(await asLegacyResult(svc.adminResetPasskeys(Number('abc')))).toEqual({
|
||
error: 'User not found',
|
||
status: 404,
|
||
});
|
||
});
|
||
});
|