mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
6dda05f672
Eight files in services/ hold no state and touch no database: avatarUrl, conflictResult, demo, passwordPolicy, timezoneService, cookie, and the two crypto helpers. They are imported from 65 places, 62 of them already inside src/nest, so having them live in the legacy layer was pure inertia. They stay plain functions rather than becoming providers. Three importers sit outside the container and would have no way to reach an injectable one: db/migrations.ts runs before the app is built, and middleware/auth.ts and middleware/mfaPolicy.ts run beside it. Wrapping these in DI would buy ceremony and nothing else. mfaCrypto and apiKeyCrypto move together into common/crypto — they read the same ENCRYPTION_KEY, and the directory says so. services/ drops from 44 files to 36, 9,870 lines to 8,493. One trap worth recording: src/app-config and src/nest/app-config both exist. cookie.ts imported '../app-config' from services/, which resolved to the former; after the move the same string silently pointed at the latter, which has no readEnv. It is now explicit about which one it wants. Nothing else changes — no signatures, no behaviour, no tests rewritten beyond their import paths.
324 lines
15 KiB
TypeScript
324 lines
15 KiB
TypeScript
/**
|
||
* Unit tests for the DI-native SettingsService — SET-SVC-001 through
|
||
* SET-SVC-030 (001–026 moved 1:1 from the legacy
|
||
* tests/unit/services/settingsService.test.ts; 027–030 pin the post-migration
|
||
* quirk fixes: null-serializes-as-'' and the bulk masked-sentinel skip).
|
||
* Uses a real in-memory SQLite DB; apiKeyCrypto is mocked to a passthrough
|
||
* so we don't need real encryption for most tests.
|
||
*/
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
|
||
// ── DB + apiKeyCrypto mock ────────────────────────────────────────────────────
|
||
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
db.exec('PRAGMA journal_mode = WAL');
|
||
db.exec('PRAGMA foreign_keys = ON');
|
||
db.exec('PRAGMA busy_timeout = 5000');
|
||
const mock = {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
getPlaceWithTags: () => null,
|
||
canAccessTrip: () => null,
|
||
isOwner: () => false,
|
||
};
|
||
return { testDb: db, dbMock: mock };
|
||
});
|
||
|
||
vi.mock('../../../src/db/database', () => dbMock);
|
||
vi.mock('../../../src/config', () => ({
|
||
JWT_SECRET: 'test-secret',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
updateJwtSecret: () => {},
|
||
}));
|
||
|
||
// Passthrough crypto — value comes back unchanged for most tests
|
||
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||
maybe_encrypt_api_key: (v: string) => v,
|
||
decrypt_api_key: (v: string) => v,
|
||
}));
|
||
|
||
import { createTables } from '../../../src/db/schema';
|
||
import { runMigrations } from '../../../src/db/migrations';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
import { createUser } from '../../helpers/factories';
|
||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||
|
||
const svc = new SettingsService(new DatabaseService(testDb));
|
||
|
||
beforeAll(() => {
|
||
createTables(testDb);
|
||
runMigrations(testDb);
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
});
|
||
|
||
afterAll(() => {
|
||
testDb.close();
|
||
});
|
||
|
||
// ── getUserSettings ───────────────────────────────────────────────────────────
|
||
|
||
describe('getUserSettings', () => {
|
||
it('SET-SVC-001 — returns empty object when user has no settings', () => {
|
||
const { user } = createUser(testDb);
|
||
expect(svc.getUserSettings(user.id)).toEqual({});
|
||
});
|
||
|
||
it('SET-SVC-002 — returns stored plain string values', () => {
|
||
const { user } = createUser(testDb);
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(user.id);
|
||
const s = svc.getUserSettings(user.id);
|
||
expect(s.theme).toBe('dark');
|
||
});
|
||
|
||
it('SET-SVC-003 — JSON-parses values that are valid JSON', () => {
|
||
const { user } = createUser(testDb);
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'count', '42')").run(user.id);
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'flag', 'true')").run(user.id);
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'obj', '{\"x\":1}')").run(user.id);
|
||
const s = svc.getUserSettings(user.id);
|
||
expect(s.count).toBe(42);
|
||
expect(s.flag).toBe(true);
|
||
expect(s.obj).toEqual({ x: 1 });
|
||
});
|
||
|
||
it('SET-SVC-004 — falls back to raw string when value is not valid JSON', () => {
|
||
const { user } = createUser(testDb);
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'raw', 'not-json')").run(user.id);
|
||
const s = svc.getUserSettings(user.id);
|
||
expect(s.raw).toBe('not-json');
|
||
});
|
||
|
||
it('SET-SVC-005 — webhook_url with a value is masked as ••••••••', () => {
|
||
const { user } = createUser(testDb);
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://secret.example.com')").run(user.id);
|
||
const s = svc.getUserSettings(user.id);
|
||
expect(s.webhook_url).toBe('••••••••');
|
||
});
|
||
|
||
it('SET-SVC-006 — webhook_url with empty value returns empty string', () => {
|
||
const { user } = createUser(testDb);
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', '')").run(user.id);
|
||
const s = svc.getUserSettings(user.id);
|
||
expect(s.webhook_url).toBe('');
|
||
});
|
||
|
||
it('SET-SVC-007 — only returns settings for the requesting user', () => {
|
||
const { user: a } = createUser(testDb);
|
||
const { user: b } = createUser(testDb);
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_a', '\"a\"')").run(a.id);
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_b', '\"b\"')").run(b.id);
|
||
const s = svc.getUserSettings(a.id);
|
||
expect(s).toHaveProperty('key_a');
|
||
expect(s).not.toHaveProperty('key_b');
|
||
});
|
||
|
||
// Admin "user defaults" fall-through (#1634) — a system-wide Mapbox token must
|
||
// reach a user who left their own token blank.
|
||
const setAdminDefault = (settingKey: string, value: string) =>
|
||
testDb.prepare(
|
||
"INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||
).run(`default_user_setting_${settingKey}`, value);
|
||
|
||
it('SET-SVC-020 — new user with no rows inherits the admin default token', () => {
|
||
const { user } = createUser(testDb);
|
||
setAdminDefault('mapbox_access_token', 'pk.admin');
|
||
expect(svc.getUserSettings(user.id).mapbox_access_token).toBe('pk.admin');
|
||
});
|
||
|
||
it('SET-SVC-021 — an empty user token falls through to the admin default', () => {
|
||
const { user } = createUser(testDb);
|
||
setAdminDefault('mapbox_access_token', 'pk.admin');
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_access_token', '')").run(user.id);
|
||
expect(svc.getUserSettings(user.id).mapbox_access_token).toBe('pk.admin');
|
||
});
|
||
|
||
it('SET-SVC-022 — a non-empty user token overrides the admin default', () => {
|
||
const { user } = createUser(testDb);
|
||
setAdminDefault('mapbox_access_token', 'pk.admin');
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_access_token', 'pk.user')").run(user.id);
|
||
expect(svc.getUserSettings(user.id).mapbox_access_token).toBe('pk.user');
|
||
});
|
||
|
||
it('SET-SVC-023 — an empty value with no admin default stays empty (no regression)', () => {
|
||
const { user } = createUser(testDb);
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_style', '')").run(user.id);
|
||
expect(svc.getUserSettings(user.id).mapbox_style).toBe('');
|
||
});
|
||
|
||
it('SET-SVC-024 — a non-defaultable empty value is preserved even against a same-named default', () => {
|
||
const { user } = createUser(testDb);
|
||
// 'theme' is not a defaultable key: an empty stored value must be returned as-is.
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', '')").run(user.id);
|
||
expect(svc.getUserSettings(user.id).theme).toBe('');
|
||
});
|
||
|
||
it('SET-SVC-025 — an inherited admin llm_api_key is masked, never returned in cleartext', () => {
|
||
const { user } = createUser(testDb);
|
||
setAdminDefault('llm_api_key', 'sk-admin-secret');
|
||
// No user row → the value is inherited from the admin default; it is a secret
|
||
// and must reach the client masked, not in cleartext.
|
||
expect(svc.getUserSettings(user.id).llm_api_key).toBe('••••••••');
|
||
});
|
||
|
||
it('SET-SVC-026 — an empty user llm_api_key falls back to the admin key, still masked', () => {
|
||
const { user } = createUser(testDb);
|
||
setAdminDefault('llm_api_key', 'sk-admin-secret');
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'llm_api_key', '')").run(user.id);
|
||
expect(svc.getUserSettings(user.id).llm_api_key).toBe('••••••••');
|
||
});
|
||
});
|
||
|
||
// ── upsertSetting ─────────────────────────────────────────────────────────────
|
||
|
||
describe('upsertSetting', () => {
|
||
it('SET-SVC-008 — inserts a new setting', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.upsertSetting(user.id, 'language', 'en');
|
||
const s = svc.getUserSettings(user.id);
|
||
expect(s.language).toBe('en');
|
||
});
|
||
|
||
it('SET-SVC-009 — updates an existing setting (ON CONFLICT)', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.upsertSetting(user.id, 'language', 'en');
|
||
svc.upsertSetting(user.id, 'language', 'fr');
|
||
const s = svc.getUserSettings(user.id);
|
||
expect(s.language).toBe('fr');
|
||
});
|
||
|
||
it('SET-SVC-010 — serializes object values as JSON', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.upsertSetting(user.id, 'prefs', { dark: true, size: 14 });
|
||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'prefs'").get(user.id) as any;
|
||
expect(raw.value).toBe('{"dark":true,"size":14}');
|
||
});
|
||
|
||
it('SET-SVC-011 — serializes boolean values as strings', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.upsertSetting(user.id, 'notifications', true);
|
||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'notifications'").get(user.id) as any;
|
||
expect(raw.value).toBe('true');
|
||
});
|
||
|
||
it('SET-SVC-012 — webhook_url passes through maybe_encrypt_api_key', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.upsertSetting(user.id, 'webhook_url', 'https://hook.example.com');
|
||
// With passthrough mock, value is stored as-is
|
||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(user.id) as any;
|
||
expect(raw.value).toBe('https://hook.example.com');
|
||
// But getUserSettings masks it
|
||
const s = svc.getUserSettings(user.id);
|
||
expect(s.webhook_url).toBe('••••••••');
|
||
});
|
||
});
|
||
|
||
// ── bulkUpsertSettings ────────────────────────────────────────────────────────
|
||
|
||
describe('bulkUpsertSettings', () => {
|
||
it('SET-SVC-013 — inserts multiple settings in one call', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.bulkUpsertSettings(user.id, { a: 'alpha', b: 'beta', c: 'gamma' });
|
||
const s = svc.getUserSettings(user.id);
|
||
expect(s.a).toBe('alpha');
|
||
expect(s.b).toBe('beta');
|
||
expect(s.c).toBe('gamma');
|
||
});
|
||
|
||
it('SET-SVC-014 — returns the count of settings processed', () => {
|
||
const { user } = createUser(testDb);
|
||
const count = svc.bulkUpsertSettings(user.id, { x: 1, y: 2, z: 3 });
|
||
expect(count).toBe(3);
|
||
});
|
||
|
||
it('SET-SVC-015 — updates existing keys (ON CONFLICT)', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.upsertSetting(user.id, 'theme', 'light');
|
||
svc.bulkUpsertSettings(user.id, { theme: 'dark', lang: 'en' });
|
||
const s = svc.getUserSettings(user.id);
|
||
expect(s.theme).toBe('dark');
|
||
expect(s.lang).toBe('en');
|
||
});
|
||
|
||
it('SET-SVC-016 — returns 0 for empty settings object', () => {
|
||
const { user } = createUser(testDb);
|
||
const count = svc.bulkUpsertSettings(user.id, {});
|
||
expect(count).toBe(0);
|
||
});
|
||
|
||
it('SET-SVC-017 — all changes are committed atomically (transaction)', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.bulkUpsertSettings(user.id, { p: '1', q: '2' });
|
||
const rows = testDb.prepare('SELECT key FROM settings WHERE user_id = ?').all(user.id) as any[];
|
||
const keys = rows.map((r: any) => r.key);
|
||
expect(keys).toContain('p');
|
||
expect(keys).toContain('q');
|
||
});
|
||
|
||
it('SET-SVC-018 — settings from different users do not interfere', () => {
|
||
const { user: a } = createUser(testDb);
|
||
const { user: b } = createUser(testDb);
|
||
svc.bulkUpsertSettings(a.id, { shared_key: 'from-a' });
|
||
svc.bulkUpsertSettings(b.id, { shared_key: 'from-b' });
|
||
expect((svc.getUserSettings(a.id) as any).shared_key).toBe('from-a');
|
||
expect((svc.getUserSettings(b.id) as any).shared_key).toBe('from-b');
|
||
});
|
||
|
||
it('SET-SVC-019 — rolls back and re-throws when DB write fails mid-transaction', () => {
|
||
const { user } = createUser(testDb);
|
||
const origPrepare = testDb.prepare.bind(testDb);
|
||
let intercepted = false;
|
||
vi.spyOn(testDb, 'prepare').mockImplementationOnce((sql: string) => {
|
||
const stmt = origPrepare(sql);
|
||
intercepted = true;
|
||
return { run: () => { throw new Error('forced DB error'); } } as any;
|
||
});
|
||
expect(() => svc.bulkUpsertSettings(user.id, { k: 'v' })).toThrow('forced DB error');
|
||
expect(intercepted).toBe(true);
|
||
vi.restoreAllMocks();
|
||
});
|
||
});
|
||
|
||
// ── post-migration quirk fixes ────────────────────────────────────────────────
|
||
|
||
describe('legacy quirk fixes', () => {
|
||
it('SET-SVC-027 — null serializes as the empty string, not the string "null"', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.upsertSetting(user.id, 'llm_api_key', null);
|
||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'llm_api_key'").get(user.id) as { value: string };
|
||
expect(raw.value).toBe('');
|
||
// The legacy "null" storage leaked back out of getDecryptedUserSetting as
|
||
// the literal string "null"; a cleared secret must read as null.
|
||
expect(svc.getDecryptedUserSetting(user.id, 'llm_api_key')).toBeNull();
|
||
});
|
||
|
||
it('SET-SVC-028 — a nulled defaultable key falls through to the admin default', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.setAdminUserDefaults({ mapbox_style: 'admin-style' });
|
||
svc.upsertSetting(user.id, 'mapbox_style', null);
|
||
expect(svc.getUserSettings(user.id).mapbox_style).toBe('admin-style');
|
||
});
|
||
|
||
it('SET-SVC-029 — bulk skips the masked sentinel so a stored secret survives', () => {
|
||
const { user } = createUser(testDb);
|
||
svc.upsertSetting(user.id, 'ntfy_token', 'tok-real');
|
||
const count = svc.bulkUpsertSettings(user.id, { ntfy_topic: 'trek', ntfy_token: '••••••••' });
|
||
expect(count).toBe(1);
|
||
// Passthrough crypto mock: the stored value must still be the real token.
|
||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'ntfy_token'").get(user.id) as { value: string };
|
||
expect(raw.value).toBe('tok-real');
|
||
expect(testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'ntfy_topic'").get(user.id)).toEqual({ value: 'trek' });
|
||
});
|
||
|
||
it('SET-SVC-030 — bulk returns the count of keys actually written', () => {
|
||
const { user } = createUser(testDb);
|
||
expect(svc.bulkUpsertSettings(user.id, { a: '1', b: '••••••••' })).toBe(1);
|
||
});
|
||
});
|