mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +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.
78 lines
4.1 KiB
TypeScript
78 lines
4.1 KiB
TypeScript
/**
|
|
* Per-user plugin settings (#plugins): a user stores their OWN scope:'user' config
|
|
* (API keys, prefs), separate from the admin-owned instance config. Proves: secrets
|
|
* are encrypted at rest + masked to the client, an unchanged secret keeps its stored
|
|
* ciphertext, only DECLARED user-scope keys are accepted, and the runtime read
|
|
* (ctx.settings) returns the decrypted value.
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
// Reversible crypto stub so we can assert encrypt-at-rest without a real key env.
|
|
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
|
maybe_encrypt_api_key: (v: unknown) => (typeof v === 'string' ? `enc:${v}` : v),
|
|
decrypt_api_key: (v: unknown) => (typeof v === 'string' && v.startsWith('enc:') ? v.slice(4) : v),
|
|
}));
|
|
|
|
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
|
|
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
|
|
import { db as dbConn } from '../../../src/db/database';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
|
|
import Database from 'better-sqlite3';
|
|
import { PluginsService, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service';
|
|
|
|
function freshDb() {
|
|
const d = new Database(':memory:');
|
|
d.exec(`
|
|
CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, sort_order INTEGER);
|
|
CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, config TEXT, updated_at TEXT, PRIMARY KEY (plugin_id, user_id));
|
|
`);
|
|
// p: a user-scope api key (secret) + a user-scope pref (not secret) + an INSTANCE field.
|
|
const ins = d.prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order) VALUES (?,?,?,?,?,?,?)');
|
|
ins.run('p', 'apiKey', 'text', 1, 1, 'user', 0);
|
|
ins.run('p', 'units', 'select', 0, 0, 'user', 1);
|
|
ins.run('p', 'adminOnly', 'text', 0, 1, 'instance', 2);
|
|
return d;
|
|
}
|
|
|
|
describe('per-user plugin settings', () => {
|
|
let svc: PluginsService;
|
|
beforeEach(() => { getDb.current = freshDb(); svc = new PluginsService(new DatabaseService(dbConn)); });
|
|
|
|
it('lists only the user-scope fields, in order', () => {
|
|
const fields = svc.userSettingsFields('p');
|
|
expect(fields.map(f => f.key)).toEqual(['apiKey', 'units']); // not the instance field
|
|
expect(fields[0]).toMatchObject({ secret: true, required: true });
|
|
});
|
|
|
|
it('encrypts a secret at rest, masks it to the client, stores a plain field verbatim', () => {
|
|
const masked = svc.updateUserConfig('p', 42, { apiKey: 'sk-123', units: 'metric' });
|
|
expect(masked.apiKey).toBe('••••••••'); // never echoed
|
|
expect(masked.units).toBe('metric');
|
|
// decrypted runtime read returns the real value; the stored form is ciphertext
|
|
expect(readUserSettingDecrypted('p', 42, 'apiKey')).toBe('sk-123');
|
|
expect(svc.getUserConfig('p', 42).apiKey).toBe('••••••••');
|
|
});
|
|
|
|
it('an unchanged secret (the mask) keeps the stored ciphertext', () => {
|
|
svc.updateUserConfig('p', 42, { apiKey: 'sk-123' });
|
|
svc.updateUserConfig('p', 42, { apiKey: '••••••••', units: 'imperial' }); // mask = untouched
|
|
expect(readUserSettingDecrypted('p', 42, 'apiKey')).toBe('sk-123'); // still the original
|
|
expect(svc.getUserConfig('p', 42).units).toBe('imperial');
|
|
});
|
|
|
|
it('ignores keys that are not declared user-scope fields', () => {
|
|
svc.updateUserConfig('p', 42, { adminOnly: 'nope', bogus: 'x', units: 'metric' } as Record<string, unknown>);
|
|
const cfg = svc.getUserConfig('p', 42);
|
|
expect(cfg.adminOnly).toBeUndefined(); // instance field — not accepted here
|
|
expect(cfg.bogus).toBeUndefined();
|
|
expect(cfg.units).toBe('metric');
|
|
});
|
|
|
|
it('is per-user — one user cannot see another\'s value', () => {
|
|
svc.updateUserConfig('p', 42, { units: 'metric' });
|
|
expect(svc.getUserConfig('p', 99).units).toBeUndefined();
|
|
expect(readUserSettingDecrypted('p', 99, 'apiKey')).toBeUndefined();
|
|
});
|
|
});
|