mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
45564b0a8a
Fold the 1497-line services/authService.ts into the DI-native nest/auth
domain (Wave-5 chain opener, unblocked by the atlas fold):
- Pure crypto half (backup-code hash/match/generate, stripUserForClient,
key masking, the import-time DUMMY_PASSWORD_HASH timing equaliser and
avatarDir mkdir — documented parity exceptions) moves verbatim to the
plain module nest/auth/auth.helpers.ts.
- DB half folds into AuthService over DatabaseService + injected
PermissionsService (ex permissions.bridge) + AtlasService (ex
atlas.bridge, deleted on schedule). mfaSetupPending and the per-email
reset throttle stay module-scoped so the bridge and container instances
share them; avatarDir and require('../../../package.json') re-anchor one
directory deeper — with the two injection swaps, the only non-verbatim
lines. Controller-facing method surface unchanged.
- New 8-export auth.bridge.ts for out-of-container consumers: mcp/index.ts
token verification, the journey/notifications/transports registrars'
isDemoUser, and the still-legacy adminService/oidcService/passkeyService.
- 15 domain *.mcp.ts demo guards now inject AuthService (their modules
import AuthModule); atlas.mcp.ts alone stays on the bridge because
AuthService injects AtlasService and the reverse module edge would close
an AuthModule<->AtlasModule cycle (places.mcp precedent). OidcService and
PasskeyEnabledGuard inject AuthService too.
- Tests move with case IDs preserved: authService.test.ts ->
auth.helpers.test.ts, authServiceDb.test.ts -> auth.service.test.ts
(+ AUTH-BR-001..007 bridge delegation, + AUTH-DB-050..088 pinning the
previously untested getAppConfig/login/MFA/reset/admin-settings branches
now under the src/nest coverage gate). auth.e2e converts DI-native (real
bcrypt, real audit rows); oidc.e2e swaps its dead path mock for an
instance spy; websocket/integration suites repoint to bridge/helpers.
Quirks preserved byte-for-byte (0-coord drop in getTravelStats,
un-transactioned registerUser/verifyMfaLogin multi-writes, unguarded
revokeUserSessions in deleteMcpToken, current! assertions in
updateApiKeys) — fixes land separately.
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
/**
|
|
* Pure-function half of the auth unit suite, moved 1:1 from the legacy
|
|
* tests/unit/services/authService.test.ts when the auth backend went
|
|
* DI-native (case IDs preserved — SEC-008 secret-omission block). The DB
|
|
* half lives in tests/unit/nest/auth.service.test.ts. Only the apiKeyCrypto
|
|
* mock survives (auth.helpers imports it for mask_stored_api_key); the
|
|
* legacy db/permissions/mcp/scheduler mocks guarded imports the pure module
|
|
* no longer has.
|
|
*/
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
|
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
|
decrypt_api_key: vi.fn((v) => v),
|
|
maybe_encrypt_api_key: vi.fn((v) => v),
|
|
encrypt_api_key: vi.fn((v) => v),
|
|
}));
|
|
|
|
import {
|
|
utcSuffix,
|
|
stripUserForClient,
|
|
maskKey,
|
|
normalizeBackupCode,
|
|
hashBackupCode,
|
|
generateBackupCodes,
|
|
parseBackupCodeHashes,
|
|
} from '../../../src/nest/auth/auth.helpers';
|
|
import { avatarUrl } from '../../../src/services/avatarUrl';
|
|
import type { User } from '../../../src/types';
|
|
|
|
// ── utcSuffix ────────────────────────────────────────────────────────────────
|
|
|
|
describe('utcSuffix', () => {
|
|
it('returns null for null', () => {
|
|
expect(utcSuffix(null)).toBeNull();
|
|
});
|
|
|
|
it('returns null for undefined', () => {
|
|
expect(utcSuffix(undefined)).toBeNull();
|
|
});
|
|
|
|
it('returns null for empty string', () => {
|
|
expect(utcSuffix('')).toBeNull();
|
|
});
|
|
|
|
it('returns timestamp unchanged when already ending with Z', () => {
|
|
expect(utcSuffix('2024-01-01T12:00:00Z')).toBe('2024-01-01T12:00:00Z');
|
|
});
|
|
|
|
it('replaces space with T and appends Z for SQLite-style datetime', () => {
|
|
expect(utcSuffix('2024-01-01 12:00:00')).toBe('2024-01-01T12:00:00Z');
|
|
});
|
|
|
|
it('appends Z when T is present but Z is missing', () => {
|
|
expect(utcSuffix('2024-06-15T08:30:00')).toBe('2024-06-15T08:30:00Z');
|
|
});
|
|
});
|
|
|
|
// ── stripUserForClient ───────────────────────────────────────────────────────
|
|
|
|
function makeUser(overrides: Partial<User> = {}): User {
|
|
return {
|
|
id: 1,
|
|
username: 'alice',
|
|
email: 'alice@example.com',
|
|
role: 'user',
|
|
password_hash: 'supersecret',
|
|
maps_api_key: 'maps-key',
|
|
openweather_api_key: 'weather-key',
|
|
unsplash_api_key: 'unsplash-key',
|
|
mfa_secret: 'totpsecret',
|
|
mfa_backup_codes: '["hash1","hash2"]',
|
|
mfa_enabled: 0,
|
|
must_change_password: 0,
|
|
avatar: null,
|
|
created_at: '2024-01-01 00:00:00',
|
|
updated_at: '2024-06-01 00:00:00',
|
|
last_login: null,
|
|
...overrides,
|
|
} as unknown as User;
|
|
}
|
|
|
|
describe('stripUserForClient', () => {
|
|
it('SEC-008: omits password_hash', () => {
|
|
const result = stripUserForClient(makeUser());
|
|
expect(result).not.toHaveProperty('password_hash');
|
|
});
|
|
|
|
it('SEC-008: omits maps_api_key', () => {
|
|
const result = stripUserForClient(makeUser());
|
|
expect(result).not.toHaveProperty('maps_api_key');
|
|
});
|
|
|
|
it('SEC-008: omits openweather_api_key', () => {
|
|
const result = stripUserForClient(makeUser());
|
|
expect(result).not.toHaveProperty('openweather_api_key');
|
|
});
|
|
|
|
it('SEC-008: omits unsplash_api_key', () => {
|
|
const result = stripUserForClient(makeUser());
|
|
expect(result).not.toHaveProperty('unsplash_api_key');
|
|
});
|
|
|
|
it('SEC-008: omits mfa_secret', () => {
|
|
const result = stripUserForClient(makeUser());
|
|
expect(result).not.toHaveProperty('mfa_secret');
|
|
});
|
|
|
|
it('SEC-008: omits mfa_backup_codes', () => {
|
|
const result = stripUserForClient(makeUser());
|
|
expect(result).not.toHaveProperty('mfa_backup_codes');
|
|
});
|
|
|
|
it('preserves non-sensitive fields', () => {
|
|
const result = stripUserForClient(makeUser({ username: 'alice', email: 'alice@example.com', role: 'user' }));
|
|
expect(result.id).toBe(1);
|
|
expect(result.username).toBe('alice');
|
|
expect(result.email).toBe('alice@example.com');
|
|
expect(result.role).toBe('user');
|
|
});
|
|
|
|
it('normalizes mfa_enabled integer 1 to true', () => {
|
|
const result = stripUserForClient(makeUser({ mfa_enabled: 1 } as any));
|
|
expect(result.mfa_enabled).toBe(true);
|
|
});
|
|
|
|
it('normalizes mfa_enabled integer 0 to false', () => {
|
|
const result = stripUserForClient(makeUser({ mfa_enabled: 0 } as any));
|
|
expect(result.mfa_enabled).toBe(false);
|
|
});
|
|
|
|
it('normalizes mfa_enabled boolean true to true', () => {
|
|
const result = stripUserForClient(makeUser({ mfa_enabled: true } as any));
|
|
expect(result.mfa_enabled).toBe(true);
|
|
});
|
|
|
|
it('normalizes must_change_password integer 1 to true', () => {
|
|
const result = stripUserForClient(makeUser({ must_change_password: 1 } as any));
|
|
expect(result.must_change_password).toBe(true);
|
|
});
|
|
|
|
it('normalizes must_change_password integer 0 to false', () => {
|
|
const result = stripUserForClient(makeUser({ must_change_password: 0 } as any));
|
|
expect(result.must_change_password).toBe(false);
|
|
});
|
|
|
|
it('converts created_at through utcSuffix', () => {
|
|
const result = stripUserForClient(makeUser({ created_at: '2024-01-01 00:00:00' }));
|
|
expect(result.created_at).toBe('2024-01-01T00:00:00Z');
|
|
});
|
|
|
|
it('converts updated_at through utcSuffix', () => {
|
|
const result = stripUserForClient(makeUser({ updated_at: '2024-06-01 12:00:00' }));
|
|
expect(result.updated_at).toBe('2024-06-01T12:00:00Z');
|
|
});
|
|
|
|
it('passes null last_login through as null', () => {
|
|
const result = stripUserForClient(makeUser({ last_login: null }));
|
|
expect(result.last_login).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── maskKey ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('maskKey', () => {
|
|
it('returns null for null', () => {
|
|
expect(maskKey(null)).toBeNull();
|
|
});
|
|
|
|
it('returns null for undefined', () => {
|
|
expect(maskKey(undefined)).toBeNull();
|
|
});
|
|
|
|
it('returns null for empty string', () => {
|
|
expect(maskKey('')).toBeNull();
|
|
});
|
|
|
|
it('returns -------- for keys with 8 or fewer characters', () => {
|
|
expect(maskKey('abcd1234')).toBe('--------');
|
|
expect(maskKey('short')).toBe('--------');
|
|
expect(maskKey('a')).toBe('--------');
|
|
});
|
|
|
|
it('returns ---- + last 4 chars for keys longer than 8 characters', () => {
|
|
expect(maskKey('abcdefghijkl')).toBe('----ijkl');
|
|
expect(maskKey('sk-test-12345678')).toBe('----5678');
|
|
});
|
|
});
|
|
|
|
// ── avatarUrl ────────────────────────────────────────────────────────────────
|
|
|
|
describe('avatarUrl', () => {
|
|
it('returns /uploads/avatars/<filename> when avatar is set', () => {
|
|
expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg');
|
|
});
|
|
|
|
it('returns null when avatar is null', () => {
|
|
expect(avatarUrl({ avatar: null })).toBeNull();
|
|
});
|
|
|
|
it('returns null when avatar is undefined', () => {
|
|
expect(avatarUrl({})).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── normalizeBackupCode ──────────────────────────────────────────────────────
|
|
|
|
describe('normalizeBackupCode', () => {
|
|
it('uppercases the input', () => {
|
|
expect(normalizeBackupCode('abcd1234')).toBe('ABCD1234');
|
|
});
|
|
|
|
it('strips non-alphanumeric characters', () => {
|
|
expect(normalizeBackupCode('AB-CD 12!34')).toBe('ABCD1234');
|
|
});
|
|
|
|
it('handles code with dashes (normal backup code format)', () => {
|
|
expect(normalizeBackupCode('A1B2-C3D4')).toBe('A1B2C3D4');
|
|
});
|
|
|
|
it('returns empty string for empty input', () => {
|
|
expect(normalizeBackupCode('')).toBe('');
|
|
});
|
|
});
|
|
|
|
// ── hashBackupCode ───────────────────────────────────────────────────────────
|
|
|
|
describe('hashBackupCode', () => {
|
|
it('returns a 64-character hex string', () => {
|
|
const hash = hashBackupCode('A1B2-C3D4');
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('is deterministic: same input always produces same output', () => {
|
|
expect(hashBackupCode('A1B2-C3D4')).toBe(hashBackupCode('A1B2-C3D4'));
|
|
});
|
|
|
|
it('normalizes before hashing: dashed and plain form produce the same hash', () => {
|
|
expect(hashBackupCode('A1B2-C3D4')).toBe(hashBackupCode('a1b2c3d4'));
|
|
});
|
|
});
|
|
|
|
// ── generateBackupCodes ──────────────────────────────────────────────────────
|
|
|
|
describe('generateBackupCodes', () => {
|
|
it('returns 10 codes by default', () => {
|
|
const codes = generateBackupCodes();
|
|
expect(codes).toHaveLength(10);
|
|
});
|
|
|
|
it('respects a custom count', () => {
|
|
expect(generateBackupCodes(5)).toHaveLength(5);
|
|
expect(generateBackupCodes(20)).toHaveLength(20);
|
|
});
|
|
|
|
it('each code matches the XXXX-XXXX uppercase hex pattern', () => {
|
|
const codes = generateBackupCodes();
|
|
for (const code of codes) {
|
|
expect(code).toMatch(/^[0-9A-F]{4}-[0-9A-F]{4}$/);
|
|
}
|
|
});
|
|
|
|
it('generates no duplicate codes', () => {
|
|
const codes = generateBackupCodes(10);
|
|
expect(new Set(codes).size).toBe(10);
|
|
});
|
|
});
|
|
|
|
// ── parseBackupCodeHashes ────────────────────────────────────────────────────
|
|
|
|
describe('parseBackupCodeHashes', () => {
|
|
it('returns [] for null', () => {
|
|
expect(parseBackupCodeHashes(null)).toEqual([]);
|
|
});
|
|
|
|
it('returns [] for undefined', () => {
|
|
expect(parseBackupCodeHashes(undefined)).toEqual([]);
|
|
});
|
|
|
|
it('returns [] for empty string', () => {
|
|
expect(parseBackupCodeHashes('')).toEqual([]);
|
|
});
|
|
|
|
it('returns [] for invalid JSON', () => {
|
|
expect(parseBackupCodeHashes('not-json')).toEqual([]);
|
|
});
|
|
|
|
it('returns [] for JSON that is not an array', () => {
|
|
expect(parseBackupCodeHashes('{"key":"value"}')).toEqual([]);
|
|
});
|
|
|
|
it('filters out non-string entries', () => {
|
|
expect(parseBackupCodeHashes('[1, "abc", null, true]')).toEqual(['abc']);
|
|
});
|
|
|
|
it('returns all strings from a valid JSON string array', () => {
|
|
expect(parseBackupCodeHashes('["hash1","hash2","hash3"]')).toEqual(['hash1', 'hash2', 'hash3']);
|
|
});
|
|
});
|