Files
TREK/server/tests/unit/nest/auth.service.test.ts
T
Maurice be016f180e test(server): seed the session, lease and rollback specs through the factories
The specs written alongside the session, transaction and lifecycle work
still seeded and read with raw statements. They go through the ORM
factories now; the few that test SQL below the ORM carry a test-sql-allow
reason. The raw SQL baseline holds 177 statements in 6 files.
2026-10-08 19:05:04 +02:00

1385 lines
68 KiB
TypeScript

/**
* auth.service.test.ts
*
* DB-centric unit tests for the DI-native AuthService using a real in-memory
* SQLite database, moved 1:1 from the legacy tests/unit/services/
* authServiceDb.test.ts (AUTH-DB-* case IDs preserved). Pure function tests
* live in auth.helpers.test.ts. Constructed directly (no TestingModule, repo
* convention); the trailing describe covers the auth.bridge.ts delegation
* exports for the src/nest coverage gate.
*/
// ---------------------------------------------------------------------------
// vi.hoisted: build the real in-memory DB and the module mock before any import
// ---------------------------------------------------------------------------
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
};
return mock;
});
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
SESSION_DURATION_SECONDS: 86400,
SESSION_DURATION_REMEMBER_SECONDS: 2592000,
updateJwtSecret: () => {},
}));
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() }));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import { db as testDb } from '../../../src/db/database';
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest';
import { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createInviteToken, createTrip, createPlace, createReservation } from '../../helpers/factories';
import { AuthService } from '../../../src/nest/auth/auth.service';
import { TokenService } from '../../../src/nest/tokens/token.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify';
import { authenticator } from 'otplib';
import { hashBackupCode } from '../../../src/nest/auth/auth.helpers';
import { createEphemeralToken } from '../../../src/nest/auth/ephemeral-tokens';
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
import { revokeUserSessions } from '../../../src/mcp/sessionManager';
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
import { DEFAULT_ALLOWED_EXTENSIONS } from '../../../src/nest/files/files.constants';
import { createTestPushSubscriptionsRepo } from '../../helpers/notifications-repos';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
sharedTestOrm,
createTestUsersRepo,
createTestTripMembersRepo,
createTestMcpTokensRepo,
createTestInviteTokensRepo,
createTestOauthTokensRepo,
createTestWebauthnCredentialsRepo,
createTestPasswordResetTokensRepo,
} from '../../helpers/test-uow';
import { budgetRepoArgs } from '../../helpers/budget-repos';
import type { TestOrm } from '../../helpers/test-orm';
import { countRows, deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
import { readAppSetting, setAppSetting } from '../../helpers/factories/settings';
import { readUser } from '../../helpers/factories/users';
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
import { InviteTokens } from '../../../src/db/entities/InviteTokens.entity';
import { McpTokens } from '../../../src/db/entities/McpTokens.entity';
import { PasswordResetTokens } from '../../../src/db/entities/PasswordResetTokens.entity';
import { PushSubscriptions } from '../../../src/db/entities/PushSubscriptions.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { UserSessions } from '../../../src/db/entities/UserSessions.entity';
import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import { createTestSessionsService, createTestUserSessionsRepo, sessionRows } from '../../helpers/sessions';
import { decode as decodeJwt } from 'jsonwebtoken';
// 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;
// Stubbed rather than real: these tests assert that the invite path CALLS the
// join, not what the join writes (TRIP-JOIN-* cover that).
const joinTripAsMember = vi.fn();
const membershipStub = { joinTripAsMember } as unknown as TripMembershipService;
// Tokens left AuthService for tokens/token.service.ts. The two cases below still
// need one as a fixture: changePassword prunes MCP tokens, and the bridge parity
// case verifies a token it just minted.
let tokens: TokenService;
let svc: AuthService;
beforeAll(async () => {
tokens = new TokenService(await createTestMcpTokensRepo(testDb), await createTestUsersRepo(testDb), new EphemeralTokenService());
svc = new AuthService(
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
membershipStub,
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),
);
});
/** A Web Push device row; the password paths only care whose it is. */
async function addPushDevice(userId: number, endpoint: string): Promise<void> {
await insertRow(orm, PushSubscriptions, { user: userId, endpoint, p256dh: 'p', auth: 'a', vapid_public_key: 'k' });
}
function pushDeviceCount(userId: number): Promise<number> {
return countRows(orm, PushSubscriptions, { user: userId });
}
let orm: TestOrm;
/** Writes an instance setting, replacing any earlier value. */
const appSetting = (key: string, value: string) => setAppSetting(orm, key, value);
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
beforeEach(async () => {
resetTestDb(testDb);
orm = await sharedTestOrm(testDb);
});
afterAll(() => testDb.close());
// ---------------------------------------------------------------------------
// requestPasswordReset — OIDC/SSO accounts (#1129)
// ---------------------------------------------------------------------------
describe('requestPasswordReset — OIDC/SSO accounts', () => {
it('AUTH-DB-PR1: refuses a reset for an OIDC-linked account that has a (random) password hash', async () => {
const { user } = createUser(testDb);
// OIDC users are created with a random bcrypt hash, so password_hash is set —
// the old guard keyed off a missing hash and therefore let the reset through.
await updateRows(orm, Users, { id: user.id }, { oidc_sub: 'sub-1129', oidc_issuer: 'https://idp.example' });
const result = await svc.requestPasswordReset(user.email, null);
expect(result.reason).toBe('oidc_only');
expect(result.tokenForDelivery).toBeNull();
const n = await countRows(orm, PasswordResetTokens, { user: user.id });
expect(n).toBe(0);
});
it('AUTH-DB-PR2: still issues a reset for a normal local (non-SSO) account', async () => {
const { user } = createUser(testDb);
const result = await svc.requestPasswordReset(user.email, null);
expect(result.reason).toBe('issued');
expect(result.tokenForDelivery).toBeTruthy();
});
it('AUTH-DB-PR3: a second request burns the prior live token — exactly one stays live (AU37, F3 task-5-review-security.md)', async () => {
const { user } = createUser(testDb);
await svc.requestPasswordReset(user.email, null);
await svc.requestPasswordReset(user.email, null);
const n = await countRows(orm, PasswordResetTokens, { user: user.id, consumed_at: null });
expect(n).toBe(1);
});
});
// ---------------------------------------------------------------------------
// getAppSettings
// ---------------------------------------------------------------------------
describe('getAppSettings', () => {
it('AUTH-DB-013: returns 403 for non-admin', async () => {
const { user } = createUser(testDb);
const result = await svc.getAppSettings(user.id);
expect(result.status).toBe(403);
expect(result.error).toMatch(/admin/i);
});
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', async () => {
const { user } = createAdmin(testDb);
await appSetting('allow_registration', 'true');
const result = await svc.getAppSettings(user.id);
expect(result.status).toBeUndefined();
expect(result.data).toBeDefined();
expect(result.data).toHaveProperty('allow_registration', 'true');
});
});
// ---------------------------------------------------------------------------
// isOidcOnlyMode
// ---------------------------------------------------------------------------
describe('isOidcOnlyMode', () => {
it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', async () => {
vi.stubEnv('OIDC_ONLY', '');
expect(await svc.isOidcOnlyMode()).toBe(false);
vi.unstubAllEnvs();
});
it('AUTH-DB-020: returns false when OIDC_ONLY=true but no OIDC_ISSUER configured', async () => {
vi.stubEnv('OIDC_ONLY', 'true');
vi.stubEnv('OIDC_ISSUER', '');
vi.stubEnv('OIDC_CLIENT_ID', '');
expect(await svc.isOidcOnlyMode()).toBe(false);
vi.unstubAllEnvs();
});
it('AUTH-DB-021: returns true when OIDC_ONLY=true AND OIDC_ISSUER AND OIDC_CLIENT_ID are set', async () => {
vi.stubEnv('OIDC_ONLY', 'true');
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
expect(await svc.isOidcOnlyMode()).toBe(true);
vi.unstubAllEnvs();
});
});
// ---------------------------------------------------------------------------
// resolveAuthToggles
// ---------------------------------------------------------------------------
describe('resolveAuthToggles', () => {
afterEach(async () => {
vi.unstubAllEnvs();
await deleteRows(orm, AppSettings, {
key: { $in: ['password_login', 'password_registration', 'oidc_login', 'oidc_registration', 'oidc_only', 'allow_registration'] },
});
});
it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', async () => {
vi.stubEnv('OIDC_ONLY', '');
const t = await svc.resolveAuthToggles();
expect(t.password_login).toBe(true);
expect(t.password_registration).toBe(true);
expect(t.oidc_login).toBe(true);
expect(t.oidc_registration).toBe(true);
});
it('AUTH-DB-022b: legacy — OIDC_ONLY=true with OIDC configured disables password_login and password_registration', async () => {
vi.stubEnv('OIDC_ONLY', 'true');
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
const t = await svc.resolveAuthToggles();
expect(t.password_login).toBe(false);
expect(t.password_registration).toBe(false);
expect(t.oidc_login).toBe(true);
expect(t.oidc_registration).toBe(true);
});
it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', async () => {
vi.stubEnv('OIDC_ONLY', '');
await appSetting('allow_registration', 'false');
const t = await svc.resolveAuthToggles();
expect(t.password_login).toBe(true);
expect(t.password_registration).toBe(false);
expect(t.oidc_login).toBe(true);
expect(t.oidc_registration).toBe(false);
});
it('AUTH-DB-022d: new granular keys take precedence over legacy keys', async () => {
vi.stubEnv('OIDC_ONLY', '');
await appSetting('allow_registration', 'false');
await appSetting('password_registration', 'true');
const t = await svc.resolveAuthToggles();
// New key present → use new keys, allow_registration ignored
expect(t.password_registration).toBe(true);
expect(t.oidc_registration).toBe(true); // defaults to true when key not set
});
it('AUTH-DB-022e: OIDC_ONLY env var overrides new granular keys for password toggles', async () => {
vi.stubEnv('OIDC_ONLY', 'true');
await appSetting('password_login', 'true');
await appSetting('password_registration', 'true');
const t = await svc.resolveAuthToggles();
// OIDC_ONLY forces password toggles off even when DB says true
expect(t.password_login).toBe(false);
expect(t.password_registration).toBe(false);
});
it('AUTH-DB-022f: individual granular keys can be set independently', async () => {
vi.stubEnv('OIDC_ONLY', '');
await appSetting('password_login', 'true');
await appSetting('password_registration', 'false');
await appSetting('oidc_login', 'true');
await appSetting('oidc_registration', 'false');
const t = await svc.resolveAuthToggles();
expect(t.password_login).toBe(true);
expect(t.password_registration).toBe(false);
expect(t.oidc_login).toBe(true);
expect(t.oidc_registration).toBe(false);
});
});
// ---------------------------------------------------------------------------
// setupMfa
// ---------------------------------------------------------------------------
describe('setupMfa', () => {
it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', async () => {
vi.stubEnv('DEMO_MODE', 'true');
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const result = await svc.setupMfa(user.id, 'demo@nomad.app');
expect(result.status).toBe(403);
expect(result.error).toMatch(/demo mode/i);
vi.unstubAllEnvs();
});
it('AUTH-DB-023: returns 400 when MFA is already enabled', async () => {
const { user } = createUser(testDb);
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1 });
const result = await svc.setupMfa(user.id, user.email);
expect(result.status).toBe(400);
expect(result.error).toMatch(/already enabled/i);
});
it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', async () => {
const { user } = createUser(testDb);
const result = await svc.setupMfa(user.id, user.email);
expect(result.error).toBeUndefined();
expect(typeof result.secret).toBe('string');
expect(result.secret!.length).toBeGreaterThan(0);
expect(typeof result.otpauth_url).toBe('string');
expect(result.otpauth_url).toMatch(/^otpauth:\/\/totp\//);
expect(result.qrPromise).toBeInstanceOf(Promise);
});
});
// ---------------------------------------------------------------------------
// enableMfa
// ---------------------------------------------------------------------------
describe('enableMfa', () => {
it('AUTH-DB-025: returns 400 when no verification code is provided', async () => {
const { user } = createUser(testDb);
const result = await svc.enableMfa(user.id, undefined);
expect(result.status).toBe(400);
expect(result.error).toMatch(/code is required/i);
});
it('AUTH-DB-026: returns 400 when there is no pending MFA setup', async () => {
const { user } = createUser(testDb);
// No setupMfa called first, so no pending entry exists
const result = await svc.enableMfa(user.id, '123456');
expect(result.status).toBe(400);
expect(result.error).toMatch(/no mfa setup in progress/i);
});
});
// ---------------------------------------------------------------------------
// disableMfa
// ---------------------------------------------------------------------------
describe('disableMfa', () => {
it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', async () => {
vi.stubEnv('DEMO_MODE', 'true');
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const result = await svc.disableMfa(user.id, 'demo@nomad.app', {
password: 'password123',
code: '000000',
});
expect(result.status).toBe(403);
expect(result.error).toMatch(/demo mode/i);
vi.unstubAllEnvs();
});
it('AUTH-DB-028: returns 400 when password or code is missing', async () => {
const { user } = createUser(testDb);
const missingCode = await svc.disableMfa(user.id, user.email, { password: 'pass', code: undefined });
expect(missingCode.status).toBe(400);
expect(missingCode.error).toMatch(/password and authenticator code/i);
const missingPassword = await svc.disableMfa(user.id, user.email, { password: undefined, code: '123456' });
expect(missingPassword.status).toBe(400);
expect(missingPassword.error).toMatch(/password and authenticator code/i);
});
it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', async () => {
const { user } = createUser(testDb);
// mfa_enabled defaults to 0 / not set
const result = await svc.disableMfa(user.id, user.email, { password: 'password123', code: '000000' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/not enabled/i);
});
});
// ---------------------------------------------------------------------------
// validateInviteToken
// ---------------------------------------------------------------------------
describe('validateInviteToken', () => {
it('AUTH-DB-030: returns 404 for unknown token', async () => {
const result = await svc.validateInviteToken('no-such-token');
expect(result.status).toBe(404);
});
it('AUTH-DB-031: returns 410 when max_uses exceeded', async () => {
// createInviteToken with used_count already at max
const invite = createInviteToken(testDb, { max_uses: 1 });
// manually set used_count = 1 to simulate exhaustion
await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 });
const result = await svc.validateInviteToken(invite.token);
expect(result.status).toBe(410);
});
it('AUTH-DB-032: returns 410 when expired', async () => {
const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' });
const result = await svc.validateInviteToken(invite.token);
expect(result.status).toBe(410);
});
});
// ---------------------------------------------------------------------------
// registerUser — OIDC-only / registration-disabled
// ---------------------------------------------------------------------------
describe('registerUser — OIDC-only / registration-disabled', () => {
it('AUTH-DB-033: returns 403 when oidc_only=true and not first user', async () => {
createUser(testDb); // ensure userCount > 0
await insertRow(orm, AppSettings, { key: 'oidc_only', value: 'true' });
await insertRow(orm, AppSettings, { key: 'oidc_issuer', value: 'https://x' });
await insertRow(orm, AppSettings, { key: 'oidc_client_id', value: 'id' });
const result = await svc.registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' });
expect(result.status).toBe(403);
expect(result.error).toMatch(/password registration is disabled/i);
});
it('AUTH-DB-034: returns 403 when registration is disabled and no invite', async () => {
createUser(testDb); // ensure userCount > 0
await appSetting('allow_registration', 'false');
const result = await svc.registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' });
expect(result.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// loginUser — OIDC-only mode
// ---------------------------------------------------------------------------
describe('loginUser — OIDC-only mode', () => {
it('AUTH-DB-035: returns 403 when oidc_only=true', async () => {
const { user, password } = createUser(testDb);
await appSetting('oidc_only', 'true');
await appSetting('oidc_issuer', 'https://x');
await appSetting('oidc_client_id', 'id');
const result = await svc.loginUser({ email: user.email, password });
expect(result.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// changePassword — OIDC-only mode
// ---------------------------------------------------------------------------
describe('changePassword — OIDC-only mode', () => {
it('AUTH-DB-036: returns 403 when oidc_only=true', async () => {
const { user, password } = createUser(testDb);
await appSetting('oidc_only', 'true');
await appSetting('oidc_issuer', 'https://x');
await appSetting('oidc_client_id', 'id');
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
expect(result.status).toBe(403);
});
});
describe('changePassword — session invalidation', () => {
const pvOf = async (id: number) => (await readUser(orm, id)).password_version;
const mcpCount = (id: number) => countRows(orm, McpTokens, { user: id });
it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', async () => {
const { user, password } = createUser(testDb);
await tokens.createMcpToken(user.id, 'cli');
expect(await pvOf(user.id)).toBe(0);
expect(await mcpCount(user.id)).toBe(1);
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
expect(result.success).toBe(true);
expect(typeof result.token).toBe('string'); // fresh session for the current device
expect(await pvOf(user.id)).toBe(1); // old JWT/cookie sessions now rejected by the pv gate
expect(await mcpCount(user.id)).toBe(0); // static MCP tokens revoked
});
it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', async () => {
const { user, password } = createUser(testDb);
const stolen = await svc.generateToken({ id: user.id }); // pv=0 at mint time
expect(await verifyJwtAndLoadUser(stolen, await createTestUsersRepo(testDb), await createTestUserSessionsRepo(testDb))).not.toBeNull();
await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
expect(await verifyJwtAndLoadUser(stolen, await createTestUsersRepo(testDb), await createTestUserSessionsRepo(testDb))).toBeNull(); // invalidated by the pv bump
});
it('AUTH-DB-036g: without issueSession it ends every session and starts none (a Bearer caller)', async () => {
const { user, password } = createUser(testDb);
await svc.generateToken({ id: user.id });
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, undefined, {}, false);
expect(result).toEqual({ success: true });
expect(await pvOf(user.id)).toBe(1);
const rows = await sessionRows(testDb, user.id);
expect(rows).toHaveLength(1);
expect(rows.every((row) => row.revoked_at !== null)).toBe(true);
});
it('AUTH-DB-036d: preserves the remember choice in the re-issued session (#1927)', async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jwt = require('jsonwebtoken');
const { user, password } = createUser(testDb);
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, true);
expect(result.success).toBe(true);
const decoded = jwt.decode(result.token!) as { remember?: boolean; iat: number; exp: number };
expect(decoded.remember).toBe(true);
expect(decoded.exp - decoded.iat).toBe(2592000); // remember window survives the change
});
it('AUTH-DB-036e: the block is atomic — a failing mcp_tokens prune rolls the whole password change back (F2, task-5-review-security.md, USER-CLEANUP-009 pattern)', async () => {
const { user, password } = createUser(testDb);
await tokens.createMcpToken(user.id, 'cli');
const hashBefore = (await readUser(orm, user.id)).password_hash;
const mcpTokensRepo = await createTestMcpTokensRepo(testDb);
const deleteAllForUserSpy = vi.spyOn(mcpTokensRepo, 'deleteAllForUser').mockRejectedValueOnce(new Error('boom'));
try {
await expect(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' })).rejects.toThrow('boom');
expect(await pvOf(user.id)).toBe(0); // password_version unchanged
expect((await readUser(orm, user.id)).password_hash).toBe(hashBefore); // old hash unchanged
expect(await mcpCount(user.id)).toBe(1); // the mcp_tokens row survives — the DELETE never committed
} finally {
deleteAllForUserSpy.mockRestore();
}
});
it('AUTH-DB-036f: forgets every push device of the user, and only of that user', async () => {
const { user, password } = createUser(testDb);
const { user: other } = createUser(testDb);
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine-1');
await addPushDevice(user.id, 'https://web.push.apple.com/mine-2');
await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
expect(result.success).toBe(true);
expect(await pushDeviceCount(user.id)).toBe(0);
expect(await pushDeviceCount(other.id)).toBe(1);
});
it('AUTH-DB-036g: a refused change keeps the push devices', async () => {
const { user } = createUser(testDb);
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
const result = await svc.changePassword(user.id, user.email, { current_password: 'wrong', new_password: 'New1234!' });
expect(result.status).toBe(401);
expect(await pushDeviceCount(user.id)).toBe(1);
});
it('AUTH-DB-036h: drops the push devices in the same transaction as the password change, so a failure changes nothing', async () => {
const { user, password } = createUser(testDb);
await tokens.createMcpToken(user.id, 'cli');
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
try {
await expect(
svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }),
).rejects.toThrow('boom');
} finally {
testDb.exec('DROP TRIGGER boom');
}
expect(await pvOf(user.id)).toBe(0);
expect(await mcpCount(user.id)).toBe(1);
expect(await pushDeviceCount(user.id)).toBe(1);
// The new hash rolled back as well, so the old password is still the one that counts.
expect((await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' })).success)
.toBe(true);
});
});
// ---------------------------------------------------------------------------
// disableMfa — require_mfa policy
// ---------------------------------------------------------------------------
describe('disableMfa — require_mfa policy', () => {
it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', async () => {
const { user } = createUser(testDb);
await appSetting('require_mfa', 'true');
const result = await svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' });
expect(result.status).toBe(403);
expect(result.error).toMatch(/cannot be disabled/i);
});
});
// ---------------------------------------------------------------------------
// verifyMfaLogin — validation
// ---------------------------------------------------------------------------
describe('verifyMfaLogin — validation', () => {
it('AUTH-DB-038: returns 400 when mfa_token or code is missing', async () => {
const result = await svc.verifyMfaLogin({ mfa_token: undefined, code: undefined });
expect(result.status).toBe(400);
});
it('AUTH-DB-039: returns 401 when mfa_token has wrong purpose', async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jwt = require('jsonwebtoken');
const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
const result = await svc.verifyMfaLogin({ mfa_token: tok, code: '123456' });
expect(result.status).toBe(401);
expect(result.error).toMatch(/invalid/i);
});
it('AUTH-DB-040: returns 401 when user not found for valid mfa_token', async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jwt = require('jsonwebtoken');
const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
const result = await svc.verifyMfaLogin({ mfa_token: tok, code: '123456' });
expect(result.status).toBe(401);
});
});
// ---------------------------------------------------------------------------
// Coverage added with the DI fold (AUTH-DB-050+): the fold moved ~1400 lines
// under the src/nest coverage gate, so the previously untested branches —
// app-config fan-out, login paths, MFA success flows, reset consumption,
// admin settings — get pinned here.
// ---------------------------------------------------------------------------
describe('getAppConfig', () => {
it('AUTH-DB-050: anonymous caller gets toggles/version and no permissions block', async () => {
vi.stubEnv('OIDC_ONLY', '');
createUser(testDb);
const cfg = await svc.getAppConfig(null);
expect(cfg.password_login).toBe(true);
expect(cfg.has_users).toBe(true);
expect(typeof cfg.version).toBe('string');
expect(cfg.permissions).toBeUndefined();
expect(cfg.notification_channel).toBe('none');
expect(cfg.available_channels.inapp).toBe(true);
expect(cfg.allowed_file_types).toContain('jpg');
vi.unstubAllEnvs();
});
it('AUTH-DB-050b: fresh-install fallback matches DEFAULT_ALLOWED_EXTENSIONS (pkpass/md included)', async () => {
// No allowed_file_types row: the config payload must advertise the same
// default list the upload filters actually enforce — the historical
// hardcoded copy dropped pkpass, pkpasses, md and markdown, so the client
// greyed out types the server accepts.
createUser(testDb);
await deleteRows(orm, AppSettings, { key: 'allowed_file_types' });
const cfg = await svc.getAppConfig(null);
expect(cfg.allowed_file_types).toBe(DEFAULT_ALLOWED_EXTENSIONS);
});
it('AUTH-DB-051: authenticated caller gets the permissions block; app_settings rows flow through', async () => {
const { user } = createUser(testDb);
await appSetting('require_mfa', 'true');
await appSetting('notification_channels', 'email,webhook');
await appSetting('allowed_file_types', 'jpg,png');
const cfg = await svc.getAppConfig({ id: user.id } as never);
expect(cfg.permissions).toBeDefined();
expect(cfg.require_mfa).toBe(true);
expect(cfg.notification_channels).toEqual(['email', 'webhook']);
expect(cfg.available_channels.webhook).toBe(true);
expect(cfg.allowed_file_types).toBe('jpg,png');
});
it('AUTH-DB-052: demo mode forces registration toggles off and surfaces the demo credentials', async () => {
vi.stubEnv('DEMO_MODE', 'true');
createUser(testDb);
const cfg = await svc.getAppConfig(null);
expect(cfg.demo_mode).toBe(true);
expect(cfg.password_registration).toBe(false);
expect(cfg.oidc_registration).toBe(false);
expect(cfg.demo_email).toBeDefined();
expect(cfg.demo_password).toBe('demo12345');
vi.unstubAllEnvs();
});
it('AUTH-DB-052b: managed is false unless the install says otherwise', async () => {
createUser(testDb);
expect((await svc.getAppConfig(null)).managed).toBe(false);
});
it('AUTH-DB-052c: managed reaches the client, so the UI can stop offering what the server refuses', async () => {
vi.stubEnv('TREK_MANAGED', 'true');
createUser(testDb);
const cfg = await svc.getAppConfig(null);
expect(cfg.managed).toBe(true);
// Additive only: the flag says who owns the configuration and changes
// nothing else about the instance.
expect(cfg.demo_mode).toBe(false);
expect(cfg.password_registration).toBe(true);
vi.unstubAllEnvs();
});
it('AUTH-DB-052d: an instance that declared nothing reports passkeys as not configured (#2147)', async () => {
// APP_URL unset, so getAppUrl() invents http://localhost:{PORT} and the
// resolver hands back a localhost RP. No browser on the real domain can
// finish a ceremony against it, and the options step says so, so the client
// must not advertise a button whose every click 400s.
createUser(testDb);
expect((await svc.getAppConfig(null)).passkey_configured).toBe(false);
});
it('AUTH-DB-052e: a localhost RP the operator declared themselves still counts', async () => {
createUser(testDb);
vi.stubEnv('APP_URL', 'http://localhost:5173');
expect((await svc.getAppConfig(null)).passkey_configured).toBe(true);
vi.unstubAllEnvs();
vi.stubEnv('ALLOWED_ORIGINS', 'http://localhost:5173');
expect((await svc.getAppConfig(null)).passkey_configured).toBe(true);
vi.unstubAllEnvs();
await appSetting('webauthn_rp_id', 'localhost');
expect((await svc.getAppConfig(null)).passkey_configured).toBe(true);
});
it('AUTH-DB-052f: a real domain is configured, a bare IP host is not', async () => {
createUser(testDb);
vi.stubEnv('APP_URL', 'https://trek.example.org');
expect((await svc.getAppConfig(null)).passkey_configured).toBe(true);
vi.stubEnv('APP_URL', 'http://192.168.1.50:3001');
expect((await svc.getAppConfig(null)).passkey_configured).toBe(false);
vi.unstubAllEnvs();
});
it('AUTH-DB-052g: place_shadow_enabled fails closed, on the unauthenticated payload', async () => {
createUser(testDb);
const set = { run: (value: string) => appSetting('place_shadow_enabled', value) };
// No row is the state of every install that never touched the switch.
expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false);
await set.run('false');
expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false);
// Only the literal 'true' opens it, the mirror image of the fail-open
// places_* flags beside it, which close only on the literal 'false'.
await set.run('1');
expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false);
expect((await svc.getAppConfig(null)).places_enrich_enabled).toBe(true);
await set.run('true');
expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(true);
});
});
describe('demoLogin', () => {
it('AUTH-DB-053: 404 outside demo mode', async () => {
vi.stubEnv('DEMO_MODE', '');
expect(await svc.demoLogin()).toEqual({ error: 'Not found', status: 404 });
vi.unstubAllEnvs();
});
it('AUTH-DB-054: 500 when the demo user row is missing; token + safe user when present', async () => {
vi.stubEnv('DEMO_MODE', 'true');
expect(await svc.demoLogin()).toEqual({ error: 'Demo user not found', status: 500 });
// demoLogin looks up DEMO_EMAIL_PRIMARY specifically (not any demo alias).
const { user } = createUser(testDb, { email: 'demo@trek.app' });
const result = await svc.demoLogin({ userAgent: 'Demo visitor' });
expect(typeof result.token).toBe('string');
expect(result.user).not.toHaveProperty('password_hash');
expect((await sessionRows(testDb, user.id)).map((r) => r.user_agent)).toEqual(['Demo visitor']);
vi.unstubAllEnvs();
});
});
describe('validateInviteToken — valid path', () => {
it('AUTH-DB-055: returns valid with usage metadata', async () => {
const invite = createInviteToken(testDb, { max_uses: 5 });
const result = await svc.validateInviteToken(invite.token);
expect(result.valid).toBe(true);
expect(result.max_uses).toBe(5);
expect(result.used_count).toBe(0);
});
});
describe('registerUser — success paths', () => {
it('AUTH-DB-056: first user becomes admin and gets a token', async () => {
const result = await svc.registerUser({ username: 'first', email: 'first@x.com', password: 'Secure123!' });
expect(result.error).toBeUndefined();
expect(typeof result.token).toBe('string');
expect((result.user as { role: string }).role).toBe('admin');
expect(result.auditDetails).toEqual({ username: 'first', email: 'first@x.com', role: 'admin' });
});
it('AUTH-DB-057: missing fields / bad email / duplicate answer their bespoke 400/409s', async () => {
createUser(testDb, { username: 'taken', email: 'taken@x.com' });
expect(await svc.registerUser({ username: '', email: 'a@x.com', password: 'Secure123!' }))
.toEqual({ error: 'Username, email and password are required', status: 400 });
expect(await svc.registerUser({ username: 'u', email: 'not-an-email', password: 'Secure123!' }))
.toEqual({ error: 'Invalid email format', status: 400 });
expect(await svc.registerUser({ username: 'TAKEN', email: 'other@x.com', password: 'Secure123!' }))
.toEqual({ error: 'Registration failed. Please try different credentials.', status: 409 });
});
it('AUTH-DB-058: an invite bypasses disabled registration and bumps used_count', async () => {
createUser(testDb);
await appSetting('allow_registration', 'false');
const invite = createInviteToken(testDb, { max_uses: 2 });
const result = await svc.registerUser({ username: 'invited', email: 'invited@x.com', password: 'Secure123!', invite_token: invite.token });
expect(result.error).toBeUndefined();
const { used_count } = (await findRow(orm, InviteTokens, { id: invite.id })) ?? {};
expect(used_count).toBe(1);
await deleteRows(orm, AppSettings, { key: 'allow_registration' });
});
});
describe('loginUser — credential branches', () => {
it('AUTH-DB-059: unknown email answers the generic 401', async () => {
const result = await svc.loginUser({ email: 'nobody@x.com', password: 'pw' });
expect(result).toMatchObject({ error: 'Invalid email or password', status: 401, auditUserId: null });
expect(result.auditDetails).toMatchObject({ reason: 'unknown_email' });
});
it('AUTH-DB-060: missing fields answer the bespoke 400', async () => {
expect(await svc.loginUser({ email: '', password: '' })).toEqual({ error: 'Email and password are required', status: 400 });
});
it('AUTH-DB-061: wrong password answers the generic 401 with the wrong_password audit reason', async () => {
const { user } = createUser(testDb);
const result = await svc.loginUser({ email: user.email, password: 'nope' });
expect(result.status).toBe(401);
expect(result.auditDetails).toMatchObject({ reason: 'wrong_password' });
});
it('AUTH-DB-062: success returns token + stripped user and bumps last_login/login_count', async () => {
const { user, password } = createUser(testDb);
const result = await svc.loginUser({ email: user.email, password });
expect(typeof result.token).toBe('string');
expect(result.user).not.toHaveProperty('password_hash');
expect(result.auditAction).toBe('user.login');
const row = await readUser(orm, user.id);
expect(row.login_count).toBe(1);
expect(row.last_login).not.toBeNull();
});
it('AUTH-DB-062b: forwards the caller\'s remember choice into the minted token (T4, task-5-review-template.md; unit-level pin, not only the e2e)', async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jwt = require('jsonwebtoken');
const { user, password } = createUser(testDb);
const result = await svc.loginUser({ email: user.email, password, remember_me: true });
expect(typeof result.token).toBe('string');
const decoded = jwt.decode(result.token!) as { remember?: boolean; iat: number; exp: number };
expect(decoded.remember).toBe(true);
expect(decoded.exp - decoded.iat).toBe(2592000);
});
it('AUTH-DB-063: an MFA-enabled account gets the interstitial mfa_token instead of a session', async () => {
const { user } = createUser(testDb);
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:JBSWY3DPEHPK3PXP' });
const { password } = { password: undefined };
void password;
const result = await svc.loginUser({ email: user.email, password: 'TestPass' });
// wrong password still 401s before the MFA branch
expect(result.status).toBe(401);
});
});
describe('getCurrentUser', () => {
it('AUTH-DB-064: returns the stripped row with avatar_url; null for a missing id', async () => {
const { user } = createUser(testDb);
const loaded = await svc.getCurrentUser(user.id);
expect(loaded?.id).toBe(user.id);
expect(loaded).not.toHaveProperty('password_hash');
expect(await svc.getCurrentUser(999999)).toBeNull();
});
});
describe('deleteAccount', () => {
it('AUTH-DB-065: refuses to delete the last admin', async () => {
const { user } = createAdmin(testDb);
expect(await svc.deleteAccount(user.id, user.email, 'admin'))
.toEqual({ error: 'Cannot delete the last admin account', status: 400 });
});
it('AUTH-DB-066: demo mode blocks deletion', async () => {
vi.stubEnv('DEMO_MODE', 'true');
expect(await svc.deleteAccount(1, 'demo@nomad.app', 'user'))
.toEqual({ error: 'Account deletion is disabled in demo mode.', status: 403 });
vi.unstubAllEnvs();
});
it('AUTH-DB-067: deletes a regular user row', async () => {
const { user } = createUser(testDb);
expect(await svc.deleteAccount(user.id, user.email, 'user')).toEqual({ success: true });
expect(await findRow(orm, Users, { id: user.id })).toBeNull();
});
});
describe('updateAppSettings', () => {
it('AUTH-DB-071: 403 for non-admin', async () => {
const { user } = createUser(testDb);
expect((await svc.updateAppSettings(user.id, {})).status).toBe(403);
});
it('AUTH-DB-072: require_mfa=true refuses when the admin has neither MFA nor a passkey', async () => {
const { user } = createAdmin(testDb);
const result = await svc.updateAppSettings(user.id, { require_mfa: true });
expect(result.status).toBe(400);
expect(result.error).toMatch(/secure your own account/i);
});
it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', async () => {
const { user } = createAdmin(testDb);
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1 });
const result = await svc.updateAppSettings(user.id, { require_mfa: true });
expect(result.success).toBe(true);
expect(result.auditSummary).toMatchObject({ require_mfa: true });
await deleteRows(orm, AppSettings, { key: 'require_mfa' });
});
it('AUTH-DB-074: lockout prevention refuses disabling every login method', async () => {
const { user } = createAdmin(testDb);
const result = await svc.updateAppSettings(user.id, { password_login: 'false', oidc_login: 'false' });
expect(result).toEqual({ error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 });
});
it('AUTH-DB-075: the smtp_pass masking sentinel is skipped on a notification-settings change', async () => {
// The reminder crons read their gates per tick now, so a settings change no
// longer flags (or needs) a scheduler restart.
const { user } = createAdmin(testDb);
await appSetting('smtp_pass', 'stored');
const result = await svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' });
expect(result.success).toBe(true);
const value = await readAppSetting(orm, 'smtp_pass');
expect(value).toBe('stored'); // sentinel never overwrites the secret
expect(result.auditDebugDetails).not.toHaveProperty('smtp_pass', 'stored');
await deleteRows(orm, AppSettings, { key: { $in: ['smtp_pass', 'notification_channels'] } });
});
});
describe('MFA success flows', () => {
it('AUTH-DB-076: setup → enable with a valid TOTP code returns backup codes and persists the encrypted secret', async () => {
const { user } = createUser(testDb);
const setup = await svc.setupMfa(user.id, user.email);
const code = authenticator.generate(setup.secret!);
const result = await svc.enableMfa(user.id, code);
expect(result.success).toBe(true);
expect(result.backup_codes).toHaveLength(10);
const row = await readUser(orm, user.id);
expect(row.mfa_enabled).toBe(1);
expect(row.mfa_secret).toBe('enc:' + setup.secret);
});
it('AUTH-DB-077: enable with a wrong code answers 401 and keeps the pending secret', async () => {
const { user } = createUser(testDb);
await svc.setupMfa(user.id, user.email);
expect(await svc.enableMfa(user.id, '000000')).toEqual({ error: 'Invalid verification code', status: 401 });
});
it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', async () => {
const { user, password } = createUser(testDb);
const secret = authenticator.generateSecret();
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
const result = await svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) });
expect(result).toEqual({ success: true, mfa_enabled: false });
const row = await readUser(orm, user.id);
expect(row.mfa_enabled).toBe(0);
expect(row.mfa_secret).toBeNull();
});
it('AUTH-DB-079: disableMfa answers 401 on a wrong password', async () => {
const { user } = createUser(testDb);
const secret = authenticator.generateSecret();
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
expect(await svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) }))
.toEqual({ error: 'Incorrect password', status: 401 });
});
it('AUTH-DB-080: verifyMfaLogin succeeds with a TOTP code from the interstitial token', async () => {
const { user, password } = createUser(testDb);
const secret = authenticator.generateSecret();
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
const interstitial = await svc.loginUser({ email: user.email, password });
expect(interstitial.mfa_required).toBe(true);
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) });
expect(typeof result.token).toBe('string');
expect(result.auditUserId).toBe(user.id);
});
it('AUTH-DB-081: verifyMfaLogin consumes a backup code when the TOTP fails', async () => {
const { user, password } = createUser(testDb);
const secret = authenticator.generateSecret();
const codes = ['AAAA-1111', 'BBBB-2222'];
// hashBackupCode (legacy SHA-256) hashes still verify via matchBackupCode.
await updateRows(orm, Users, { id: user.id }, {
mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify(codes.map(hashBackupCode)), updated_at: '2000-01-01T00:00:00.000Z',
});
const interstitial = await svc.loginUser({ email: user.email, password });
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' });
expect(typeof result.token).toBe('string');
const row = await readUser(orm, user.id);
expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(1); // used code spliced out
// AU33 (setBackupCodesAndTouch, F3 task-5-review-security.md): the backup-code
// login branch DOES stamp updated_at, unlike resetPassword's AU44 below.
expect(row.updated_at).not.toBe('2000-01-01T00:00:00.000Z');
// the spent code no longer verifies
const again = await svc.loginUser({ email: user.email, password });
expect((await svc.verifyMfaLogin({ mfa_token: again.mfa_token, code: 'AAAA-1111' })).status).toBe(401);
});
});
describe('resetPassword', () => {
it('AUTH-DB-082: consumes an issued token, bumps password_version and burns sibling tokens', async () => {
const { user } = createUser(testDb);
const issued = await svc.requestPasswordReset(user.email, '1.2.3.4');
expect(issued.reason).toBe('issued');
const result = await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' });
expect(result).toEqual({ success: true, userId: user.id });
const row = await readUser(orm, user.id);
expect(row.password_version).toBe(1);
// token is burned — a second use answers the bespoke 400
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' }))
.toEqual({ error: 'This reset link has already been used', status: 400 });
});
it('AUTH-DB-082b: forgets every push device of the user along with the MCP tokens', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
await tokens.createMcpToken(user.id, 'cli');
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
const issued = await svc.requestPasswordReset(user.email, null);
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
.toEqual({ success: true, userId: user.id });
expect(await countRows(orm, McpTokens, { user: user.id })).toBe(0);
expect(await pushDeviceCount(user.id)).toBe(0);
expect(await pushDeviceCount(other.id)).toBe(1);
});
it('AUTH-DB-082c: drops the push devices in the same transaction that burns the link, so a failure keeps the link', async () => {
const { user } = createUser(testDb);
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
const issued = await svc.requestPasswordReset(user.email, null);
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
try {
await expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).rejects.toThrow('boom');
} finally {
testDb.exec('DROP TRIGGER boom');
}
expect(await pushDeviceCount(user.id)).toBe(1);
const row = await readUser(orm, user.id);
expect(row.password_version).toBe(0);
// Nothing was burned, so the same link still completes the reset.
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
.toEqual({ success: true, userId: user.id });
expect(await pushDeviceCount(user.id)).toBe(0);
});
it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', async () => {
expect(await svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 });
expect(await svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 });
expect(await svc.resetPassword({ token: 'unknown-token', new_password: 'Fresh123!' }))
.toEqual({ error: 'Invalid or expired reset link', status: 400 });
const { user } = createUser(testDb);
const issued = await svc.requestPasswordReset(user.email, null);
await updateRows(orm, PasswordResetTokens, { user: user.id }, { expires_at: '2000-01-01T00:00:00.000Z' });
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
.toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 });
});
it('AUTH-DB-084: an MFA-enabled account demands a code, then consumes a backup code', async () => {
const { user } = createUser(testDb);
const secret = authenticator.generateSecret();
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify([hashBackupCode('CCCC-3333')]) });
const issued = await svc.requestPasswordReset(user.email, null);
// no code → mfa_required interstitial, token NOT burned
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
.toEqual({ mfa_required: true, status: 200 });
// wrong code → 401
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: '000000' }))
.toEqual({ error: 'Invalid MFA code', status: 401 });
// AU44 vs AU33 (F3, task-5-review-security.md): resetPassword's backup-code
// splice must call the untouched setBackupCodes (AU44), never
// setBackupCodesAndTouch (AU33, verifyMfaLogin's own branch, AUTH-DB-081) —
// the same transaction's own setPassword call (AU43) already stamps
// updated_at, so a raw before/after read on the row can't isolate AU44's
// no-touch behaviour here (that's USERSREPO-034, at the repository level);
// the call site is what's unlocked, so this pins the call site directly.
const usersRepo = await createTestUsersRepo(testDb);
const setBackupCodesSpy = vi.spyOn(usersRepo, 'setBackupCodes');
const setBackupCodesAndTouchSpy = vi.spyOn(usersRepo, 'setBackupCodesAndTouch');
try {
// backup code → success + code consumed
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: 'CCCC-3333' }))
.toEqual({ success: true, userId: user.id });
expect(setBackupCodesSpy).toHaveBeenCalledTimes(1);
expect(setBackupCodesAndTouchSpy).not.toHaveBeenCalled();
} finally {
setBackupCodesSpy.mockRestore();
setBackupCodesAndTouchSpy.mockRestore();
}
const row = await readUser(orm, user.id);
expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0);
});
it('AUTH-DB-084b: the block is atomic — a failing mcp_tokens prune rolls the whole reset-password block back (F2, task-5-review-security.md, USER-CLEANUP-009 pattern)', async () => {
const { user } = createUser(testDb);
const issued = await svc.requestPasswordReset(user.email, null);
const hashBefore = (await readUser(orm, user.id)).password_hash;
const mcpTokensRepo = await createTestMcpTokensRepo(testDb);
const deleteAllForUserSpy = vi.spyOn(mcpTokensRepo, 'deleteAllForUser').mockRejectedValueOnce(new Error('boom'));
try {
await expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).rejects.toThrow('boom');
const row = await readUser(orm, user.id);
expect(row.password_hash).toBe(hashBefore); // old hash unchanged
expect(row.password_version).toBe(0);
const tokenRow = await findRow(orm, PasswordResetTokens, { user: user.id });
expect(tokenRow.consumed_at).toBeNull(); // the reset token was never committed as consumed
} finally {
deleteAllForUserSpy.mockRestore();
}
});
it('AUTH-DB-085: password-login-disabled and per-email throttle short-circuit the request', async () => {
vi.stubEnv('OIDC_ONLY', 'true');
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
expect((await svc.requestPasswordReset('whoever@x.com', null)).reason).toBe('password_login_disabled');
vi.unstubAllEnvs();
const { user } = createUser(testDb);
await svc.requestPasswordReset(user.email, null);
await svc.requestPasswordReset(user.email, null);
await svc.requestPasswordReset(user.email, null);
expect((await svc.requestPasswordReset(user.email, null)).reason).toBe('throttled_per_email');
});
});
describe('ephemeral + demo helpers', () => {
it('AUTH-DB-088: isDemoUser is true only for the demo email in demo mode', async () => {
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const { user: other } = createUser(testDb);
expect(await svc.isDemoUser(user.id)).toBe(false); // demo mode off
vi.stubEnv('DEMO_MODE', 'true');
expect(await svc.isDemoUser(user.id)).toBe(true);
expect(await svc.isDemoUser(other.id)).toBe(false);
vi.unstubAllEnvs();
});
});
// ---------------------------------------------------------------------------
// Quirk fixes after the DI fold (trailing fix(server) commit): AUTH-DB-089+.
// The relocation carried these verbatim; the fixes land on top with a pin each.
// ---------------------------------------------------------------------------
describe('auth quirk fixes', () => {
it('AUTH-DB-090: a throw mid-registration rolls the whole signup back (user + invite bookkeeping)', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const invite = createInviteToken(testDb, { max_uses: 5 });
await updateRows(orm, InviteTokens, { id: invite.id }, { trip: trip.id });
joinTripAsMember.mockImplementationOnce(() => { throw new Error('boom'); });
const result = await svc.registerUser({ username: 'rollback', email: 'rollback@x.com', password: 'Secure123!', invite_token: invite.token });
expect(result).toEqual({ error: 'Error creating user', status: 500 });
expect(await findRow(orm, Users, { email: 'rollback@x.com' })).toBeNull();
const { used_count } = (await findRow(orm, InviteTokens, { id: invite.id })) ?? {};
expect(used_count).toBe(0);
});
it('AUTH-DB-091: a backup-code login burns the code and records the login as one atomic pair', async () => {
const { user, password } = createUser(testDb);
const secret = authenticator.generateSecret();
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify([hashBackupCode('DDDD-4444')]) });
const interstitial = await svc.loginUser({ email: user.email, password });
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'DDDD-4444' });
expect(typeof result.token).toBe('string');
const row = await readUser(orm, user.id);
expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0);
expect(row.login_count).toBe(1);
expect(row.last_login).not.toBeNull();
});
});
// ---------------------------------------------------------------------------
// MCP-token verification round-trips (was the auth.bridge delegation block;
// the MCP transport now injects TokenService/AuthService directly)
// ---------------------------------------------------------------------------
describe('MCP token verification round-trips', () => {
it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', async () => {
const { user } = createUser(testDb);
const created = await tokens.createMcpToken(user.id, 'bridge-case');
const raw = (created.token as { raw_token: string }).raw_token;
const resolved = await tokens.verifyMcpToken(raw);
expect(resolved?.id).toBe(user.id);
expect(await tokens.verifyMcpToken('trek_no_such_token')).toBeNull();
});
it('AUTH-BR-003: verifyJwtToken round-trips a service-minted token through the pv gate', async () => {
const { user } = createUser(testDb);
const token = await svc.generateToken({ id: user.id });
expect((await svc.verifyJwtToken(token))?.id).toBe(user.id);
expect(await svc.verifyJwtToken('not-a-jwt')).toBeNull();
});
});
describe('generateToken remember claim (#1927)', () => {
const jwt = require('jsonwebtoken');
it('AUTH-TOKEN-001: embeds remember and picks the matching lifetime when the caller chose', async () => {
const { user } = createUser(testDb);
const long = jwt.decode(await svc.generateToken({ id: user.id }, true)) as { remember?: boolean; iat: number; exp: number };
expect(long.remember).toBe(true);
expect(long.exp - long.iat).toBe(2592000);
const short = jwt.decode(await svc.generateToken({ id: user.id }, false)) as { remember?: boolean; iat: number; exp: number };
expect(short.remember).toBe(false);
expect(short.exp - short.iat).toBe(86400);
});
it('AUTH-TOKEN-002: omits the claim entirely when the caller did not choose (legacy payload)', async () => {
const { user } = createUser(testDb);
const decoded = jwt.decode(await svc.generateToken({ id: user.id })) as { remember?: boolean; iat: number; exp: number };
expect('remember' in decoded).toBe(false);
expect(decoded.exp - decoded.iat).toBe(86400);
});
});
describe('registerUser loses the race for an email', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('AUTH-IDENT-001: a signup the database refuses after the check passed answers the same 409, not a 500', async () => {
createUser(testDb, { username: 'anna', email: 'anna@x.com' });
vi.spyOn(UsersRepository.prototype, 'findIdByEmailOrUsernameCI').mockResolvedValue(null);
expect(await svc.registerUser({ username: 'anna2', email: 'ANNA@x.com', password: 'Secure123!' })).toEqual({
error: 'Registration failed. Please try different credentials.',
status: 409,
});
});
});
describe('session tracking', () => {
const active = async (userId: number) => (await sessionRows(testDb, userId)).filter((row) => row.revoked_at === null);
it('AUTH-SESS-001: a minted token names a new session row whose expiry is the token\'s own', async () => {
const { user } = createUser(testDb);
const token = await svc.generateToken({ id: user.id }, true, { userAgent: 'x'.repeat(300) });
const claims = decodeJwt(token) as { jti: string; exp: number };
const rows = await sessionRows(testDb, user.id);
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(claims.jti);
expect(rows[0].expires_at).toBe(new Date(claims.exp * 1000).toISOString().slice(0, 19).replace('T', ' '));
expect(rows[0].user_agent).toHaveLength(256); // cut like a Web Push device's
expect(rows[0].revoked_at).toBeNull();
});
it('AUTH-SESS-002: login records the device it came from', async () => {
const { user, password } = createUser(testDb);
const result = await svc.loginUser({ email: user.email, password }, { userAgent: 'Firefox/130' });
expect(typeof result.token).toBe('string');
expect((await sessionRows(testDb, user.id)).map((row) => row.user_agent)).toEqual(['Firefox/130']);
});
it('AUTH-SESS-003: a revoked session token no longer verifies', async () => {
const { user } = createUser(testDb);
const token = await svc.generateToken({ id: user.id });
expect(await svc.verifyJwtToken(token)).not.toBeNull();
await updateRows(orm, UserSessions, { user: user.id }, { revoked_at: '2026-01-01 00:00:00' });
expect(await svc.verifyJwtToken(token)).toBeNull();
});
it('AUTH-SESS-004: a password change ends every session and hands back a new one for this device', async () => {
const { user, password } = createUser(testDb);
const laptop = await svc.generateToken({ id: user.id });
const phone = await svc.generateToken({ id: user.id });
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, undefined, { userAgent: 'Safari' });
expect(await svc.verifyJwtToken(laptop)).toBeNull();
expect(await svc.verifyJwtToken(phone)).toBeNull();
expect(await svc.verifyJwtToken(result.token!)).not.toBeNull();
expect((await active(user.id)).map((row) => row.user_agent)).toEqual(['Safari']);
});
it('AUTH-SESS-005: a password reset ends every session', async () => {
const { user } = createUser(testDb);
const token = await svc.generateToken({ id: user.id });
const issued = await svc.requestPasswordReset(user.email, null);
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).toEqual({ success: true, userId: user.id });
expect(await active(user.id)).toEqual([]);
expect(await svc.verifyJwtToken(token)).toBeNull();
});
it('AUTH-SESS-006: disabling MFA ends every other session and keeps the one it was done from', async () => {
const { user, password } = createUser(testDb);
const secret = authenticator.generateSecret();
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
const here = await svc.generateToken({ id: user.id });
const elsewhere = await svc.generateToken({ id: user.id });
const hereId = (decodeJwt(here) as { jti: string }).jti;
expect(await svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) }, hereId)).toEqual({ success: true, mfa_enabled: false });
expect(await svc.verifyJwtToken(here)).not.toBeNull();
expect(await svc.verifyJwtToken(elsewhere)).toBeNull();
});
it('AUTH-SESS-007: a refused MFA disable leaves the sessions alone', async () => {
const { user } = createUser(testDb);
const secret = authenticator.generateSecret();
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
const token = await svc.generateToken({ id: user.id });
expect((await svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) })).status).toBe(401);
expect(await svc.verifyJwtToken(token)).not.toBeNull();
});
it('AUTH-SESS-008: an MFA login records the device too', async () => {
const { user, password } = createUser(testDb);
const secret = authenticator.generateSecret();
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
const interstitial = await svc.loginUser({ email: user.email, password });
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }, { userAgent: 'Edge' });
expect(await svc.verifyJwtToken(result.token!)).not.toBeNull();
expect((await sessionRows(testDb, user.id)).map((row) => row.user_agent)).toEqual(['Edge']);
});
it('AUTH-SESS-009: register records the first session with its device', async () => {
const result = await svc.registerUser({ username: 'sess-reg', email: 'sess-reg@example.test', password: 'Secure123!' }, { userAgent: 'Chrome' });
const userId = (decodeJwt(result.token!) as { id: number }).id;
expect((await sessionRows(testDb, userId)).map((row) => row.user_agent)).toEqual(['Chrome']);
});
});