Files
TREK/server/tests/unit/nest/webauthn-config.service.test.ts
T
Maurice 03493a2edd refactor(server): fold webauthn, account erasure, oauth and the wiki out of services/ (#1840)
* refactor(server): move the webauthn config and the account erasure into nest/auth

webauthnConfig resolved the RP ID through its own getSetting() on the db
singleton; it is a WebauthnConfigService over DatabaseService now, injected into
AuthService (passkey_configured) and PasskeyService (four ceremonies). readEnv()
is still read per call, so an operator flipping the RP ID at runtime takes effect
on the next ceremony instead of after a restart.

userCleanupService follows as UserCleanupService. It keeps taking
removeUserFromBudgetItems from budget.bridge rather than injecting BudgetService:
BudgetModule imports AuthModule for the demo guard in BudgetMcp, so AuthModule
cannot import BudgetModule back, and breaking that edge the other way drags
BudgetController, BudgetMcp and its trips.bridge chain into every graph that
imports AuthModule.

The legacy module had no tests of its own - the erasure only ever ran through
AdminService.deleteUser and TripsService.deleteGuest - so USER-CLEANUP-001..009
pin both halves, including the orphan data dir that no permissions row can
describe and the all-or-nothing deletion transaction.

* refactor(server): fold the oauth 2.1 server out of services/

694 lines move into OauthService over DatabaseService, AddonsService and
AuditService. The thin wrapper that only forwarded is gone, and so is the second
path to the MCP addon flag - validateAuthorizeRequest read it through
addons.bridge while the wrapper read it through the injected service, which would
have let the two drift.

The pending authorization codes stay a module singleton in oauth.pending-codes.ts
instead of becoming instance state. The SDK still serves /oauth/authorize,
/oauth/register and /oauth/consent from its own router, mounted on Express before
app.init(), so it reaches the domain through the new oauth.bridge.ts - two maps
would break the authorization-code flow silently, since the SDK only ever answers
"Authorization grant is invalid.". The import-time sweep keeps its .unref() and is
a named function now so a test can drive it.

scopes and sessionManager are imported deep, never through the src/mcp barrel:
the barrel re-exports revokeUserSessionsForClient and pulls the domain bridges
back in, which is the nest -> mcp -> nest cycle. Same comment as admin.service and
auth.service carry.

The 70 cases of the legacy suite moved over unchanged, bound to a hand-built
service. Fifteen more cover what free functions never let anyone reach: the code
store at capacity, an expired code, an expired access token, refresh against a
public and an unknown client, the RFC 8707 resource checks, and that a code
written through the container is read back through the bridge.

* refactor(server): move the wiki reader into the help module

wikiService had exactly one consumer, HelpController, and touches no database -
it stays a plain module rather than becoming a provider, the way
auth/ephemeral-tokens.ts does, so the text and asset caches keep being one per
process without any ceremony around it.

The WIKI_DIR anchor needed one more level: src/services sits three below the repo
root, src/nest/help sits four. The existing path-resolution case caught it, which
is what it is there for - a wrong anchor makes help degrade to the GitHub fallback
without anybody noticing.

Also brings the nest README back in line: services/ is down to five top-level
files, and the passkey and budget entries no longer point at paths that were
deleted since they were written.
2026-08-08 11:14:24 +02:00

118 lines
4.5 KiB
TypeScript

/**
* webauthn-config.service.test.ts
*
* The RP-ID / allowed-origin resolver is the single highest-risk piece of the
* passkey feature: a wrong RP ID permanently bricks every enrolled credential.
* These tests pin the security-relevant rules — config wins over APP_URL, bare
* IPs are rejected, localhost dev uses the browser (Vite) origin, and the
* resolver NEVER reads request headers.
*/
const { settingsStore, appUrlRef } = vi.hoisted(() => ({
settingsStore: new Map<string, string>(),
appUrlRef: { value: '' },
}));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return { ...actual, getAppUrl: () => appUrlRef.value };
});
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
// Injected instead of vi.mocking src/db/database: the service reads exactly one
// row shape (app_settings.value), so a stub connection keeps the suite free of
// a schema and states the only DB contact the resolver has.
const conn = {
prepare: (_sql: string) => ({
get: (key: string) => {
const v = settingsStore.get(key);
return v === undefined ? undefined : { value: v };
},
}),
} as unknown as ConstructorParameters<typeof DatabaseService>[0];
const svc = new WebauthnConfigService(new DatabaseService(conn));
beforeEach(() => {
settingsStore.clear();
appUrlRef.value = '';
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('WebauthnConfigService.resolve', () => {
it('WAC-001: derives the RP ID and single origin from a real APP_URL domain', () => {
appUrlRef.value = 'https://trek.example.org';
const cfg = svc.resolve();
expect(cfg).not.toBeNull();
expect(cfg!.rpID).toBe('trek.example.org');
expect(cfg!.origins).toEqual(['https://trek.example.org']);
expect(svc.isConfigured()).toBe(true);
});
it('WAC-002: returns null for a bare-IP host (IPs are not valid RP IDs)', () => {
appUrlRef.value = 'http://192.168.1.50:3001';
expect(svc.resolve()).toBeNull();
expect(svc.isConfigured()).toBe(false);
});
it('WAC-003: returns null when nothing is configured', () => {
expect(svc.resolve()).toBeNull();
expect(svc.isConfigured()).toBe(false);
});
it('WAC-004: localhost dev uses the browser (Vite :5173) origin, not just the API port', () => {
appUrlRef.value = 'http://localhost:3001';
const cfg = svc.resolve();
expect(cfg!.rpID).toBe('localhost');
expect(cfg!.origins).toContain('http://localhost:5173');
expect(cfg!.origins).toContain('http://localhost:3001');
});
it('WAC-005: an explicit webauthn_rp_id app-setting overrides APP_URL', () => {
appUrlRef.value = 'https://internal.example.org';
settingsStore.set('webauthn_rp_id', 'public.example.org');
settingsStore.set('webauthn_origins', 'https://public.example.org');
const cfg = svc.resolve();
expect(cfg!.rpID).toBe('public.example.org');
expect(cfg!.origins).toEqual(['https://public.example.org']);
});
it('WAC-006: webauthn_origins is parsed as a comma-separated, trimmed list', () => {
settingsStore.set('webauthn_rp_id', 'example.org');
settingsStore.set('webauthn_origins', 'https://a.example.org , https://b.example.org/');
const cfg = svc.resolve();
expect(cfg!.origins).toEqual(['https://a.example.org', 'https://b.example.org']);
});
it('WAC-007: the WEBAUTHN_RP_ID env var takes priority', () => {
vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org');
vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org');
appUrlRef.value = 'https://ignored.example.org';
const cfg = svc.resolve();
expect(cfg!.rpID).toBe('env.example.org');
expect(cfg!.origins).toEqual(['https://env.example.org']);
});
it('WAC-008: a configured RP ID with no origins falls back to the APP_URL origin', () => {
appUrlRef.value = 'https://trek.example.org';
settingsStore.set('webauthn_rp_id', 'trek.example.org');
const cfg = svc.resolve();
expect(cfg!.origins).toEqual(['https://trek.example.org']);
});
it('WAC-009: an operator flipping the RP ID at runtime takes effect on the next resolve', () => {
appUrlRef.value = 'https://trek.example.org';
expect(svc.resolve()!.rpID).toBe('trek.example.org');
settingsStore.set('webauthn_rp_id', 'moved.example.org');
expect(svc.resolve()!.rpID).toBe('moved.example.org');
});
});