mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
1035 lines
48 KiB
TypeScript
1035 lines
48 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 { AuthService } from '../../../src/nest/auth/auth.service';
|
||
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.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 { createUser } from '../../helpers/factories';
|
||
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 {
|
||
createTestShareTokensRepo,
|
||
createTestPluginsRepo,
|
||
createTestPluginUserErasureQueueRepo,
|
||
} from '../../helpers/share-repos';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
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;
|
||
});
|
||
|
||
vi.mock('../../../src/config', () => ({
|
||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
SESSION_DURATION_SECONDS: 86400,
|
||
SESSION_DURATION_REMEMBER_SECONDS: 2592000,
|
||
updateJwtSecret: () => {},
|
||
}));
|
||
|
||
// 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/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(
|
||
(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 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),
|
||
);
|
||
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);
|
||
});
|
||
|
||
afterAll(() => {
|
||
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,
|
||
};
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function challengeCount(): number {
|
||
return (testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_challenges').get() as { n: number }).n;
|
||
}
|
||
|
||
let _credSeq = 0;
|
||
function insertCredential(userId: number, overrides: Record<string, unknown> = {}) {
|
||
_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 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 };
|
||
}
|
||
|
||
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 svc.passkeyRegisterOptions(1, 'pw')).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
||
});
|
||
|
||
it('PASSKEY-SVC-002: 404s for an unknown user', async () => {
|
||
expect(await 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 svc.passkeyRegisterOptions(user.id, undefined)).toEqual({ error: 'Incorrect password', status: 401 });
|
||
expect(await 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);
|
||
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
|
||
swMock.generateRegistrationOptions.mockResolvedValue({ challenge: 'reg-chal' });
|
||
|
||
const result = await 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 = testDb.prepare('SELECT * FROM webauthn_challenges').all() as Array<Record<string, unknown>>;
|
||
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 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);
|
||
});
|
||
|
||
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 svc.passkeyRegisterOptions(user.id, password, 'http://localhost:5173')).toEqual({
|
||
options: { challenge: 'reg-chal-1' },
|
||
});
|
||
expect(await svc.passkeyRegisterOptions(user.id, password, 'http://localhost:8080')).toEqual({
|
||
options: { challenge: 'reg-chal-2' },
|
||
});
|
||
expect(await 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 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 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 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 svc.passkeyRegisterVerify(1, {})).toEqual({ error: 'Invalid registration response', status: 400 });
|
||
expect(
|
||
await 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 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);
|
||
expect(await 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);
|
||
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 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');
|
||
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');
|
||
swMock.verifyRegistrationResponse.mockResolvedValue({ verified: false });
|
||
expect(await 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);
|
||
insertCredential(user.id, { credential_id: 'dup-cred' });
|
||
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,
|
||
});
|
||
});
|
||
|
||
it('PASSKEY-SVC-011: persists the verifier-vouched credential and returns it re-selected', async () => {
|
||
const { user } = createUser(testDb);
|
||
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<
|
||
string,
|
||
unknown
|
||
>;
|
||
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);
|
||
|
||
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');
|
||
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) });
|
||
});
|
||
|
||
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');
|
||
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,
|
||
});
|
||
});
|
||
|
||
// 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');
|
||
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
|
||
});
|
||
|
||
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');
|
||
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<string, unknown>;
|
||
expect(row).toEqual({ name: 'Original', user_id: user.id });
|
||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials').get()).toEqual({ n: 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);
|
||
seedChallenge('w1', user.id, 'registration');
|
||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
|
||
|
||
const result = await 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 });
|
||
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 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);
|
||
|
||
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');
|
||
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',
|
||
]);
|
||
});
|
||
});
|
||
|
||
// ── passkeyLoginOptions ───────────────────────────────────────────────────────
|
||
|
||
describe('passkeyLoginOptions', () => {
|
||
it('PASSKEY-SVC-014: returns the not-configured 400 when no RP config resolves', async () => {
|
||
resolveWebauthnConfigMock.mockReturnValue(null);
|
||
expect(await svc.passkeyLoginOptions()).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
||
});
|
||
|
||
it('PASSKEY-SVC-015: stores an anonymous authentication challenge (discoverable flow)', async () => {
|
||
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<Record<string, unknown>>;
|
||
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 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 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
|
||
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');
|
||
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);
|
||
// 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 () => {
|
||
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');
|
||
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);
|
||
insertCredential(user.id, { credential_id: 'raw-cred', counter: 0 });
|
||
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 = insertCredential(user.id, { credential_id: 'cloned', counter: 10 });
|
||
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 = testDb
|
||
.prepare('SELECT counter, last_used_at FROM webauthn_credentials WHERE id = ?')
|
||
.get(cred.id) as Record<string, unknown>;
|
||
expect(row).toEqual({ 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');
|
||
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');
|
||
insertCredential(424_242, { credential_id: 'orphan' });
|
||
testDb.exec('PRAGMA foreign_keys = ON');
|
||
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.
|
||
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');
|
||
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 = testDb
|
||
.prepare('SELECT counter, last_used_at FROM webauthn_credentials WHERE id = ?')
|
||
.get(cred.id) as Record<string, unknown>;
|
||
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<
|
||
string,
|
||
unknown
|
||
>;
|
||
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);
|
||
insertCredential(user.id, { credential_id: 'scheme-heal' });
|
||
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);
|
||
insertCredential(user.id, { credential_id: 'apex-cred' });
|
||
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);
|
||
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);
|
||
|
||
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 = 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,
|
||
});
|
||
});
|
||
|
||
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);
|
||
|
||
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));
|
||
|
||
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 });
|
||
});
|
||
|
||
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 svc.renamePasskey(user.id, 'abc', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 });
|
||
expect(await 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 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 = 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);
|
||
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);
|
||
|
||
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 });
|
||
});
|
||
|
||
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 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 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 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 svc.adminResetPasskeys(999_999)).toEqual({ error: 'User not found', status: 404 });
|
||
|
||
const { user } = createUser(testDb);
|
||
const { user: other } = createUser(testDb);
|
||
insertCredential(user.id);
|
||
insertCredential(user.id);
|
||
const kept = 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 }]);
|
||
});
|
||
|
||
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 svc.adminResetPasskeys(Number('abc'))).toEqual({ error: 'User not found', status: 404 });
|
||
});
|
||
});
|