Files
TREK/server/tests/unit/plugins/plugin-user-settings.test.ts
T
Maurice 6dda05f672 refactor(server): move the stateless helpers into nest/common (#1823)
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.
2026-08-07 15:14:14 +02:00

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();
});
});