mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
6763648c45
SettingsRepository (getForUser/upsertForUser/getOne/deleteForUser) replaces the per-user settings table's raw SQL; SettingsService drops DatabaseService entirely. instance-api-keys.ts's readInstanceApiKey/writeInstanceApiKey/ resolveApiKey take an explicit AppSettingsRepository/UsersRepository instead of a raw connection. An earlier version resolved those two repositories itself from the active MikroORM request context, so none of its six callers needed to change. That worked in production and e2e (always inside a real request) but broke service construction in plain unit tests across every calling domain (addons, auth x2, maps, transit, unsplash - 185 failures on a full run), since none of them wrap a request context. Each of those six services now injects its own AppSettingsRepository/UsersRepository and passes them through explicitly, matching how every other converted repository in this migration is consumed; their own SQL is untouched (outside Plan 3a).
145 lines
7.1 KiB
TypeScript
145 lines
7.1 KiB
TypeScript
/**
|
||
* instance-api-keys.test.ts — INSTKEY-001 through INSTKEY-009.
|
||
*
|
||
* The shared resolver behind #1939: the Places/Unsplash credential is instance
|
||
* configuration, so it comes from the env or an encrypted app_settings row, and
|
||
* only then from the caller's own users row. Run against a real in-memory DB
|
||
* with real apiKeyCrypto (ENCRYPTION_KEY comes from tests/setup.ts), because
|
||
* the round trip through the random IV is half of what these functions
|
||
* promise.
|
||
*
|
||
* Plan 3a Task 5: `readInstanceApiKey`/`writeInstanceApiKey`/`resolveApiKey`
|
||
* take a real `AppSettingsRepository`/`UsersRepository` directly now (every
|
||
* real caller — nest/addons, nest/auth × 2, nest/maps, nest/transit,
|
||
* nest/unsplash — constructor-injects its own), so these tests build the same
|
||
* repositories `t.repo(X)` hands any other converted domain's tests, no
|
||
* request-context wrapper needed.
|
||
*/
|
||
|
||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||
import { resetTestDb } from '../../../helpers/test-db';
|
||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||
import { createUser, createAdmin } from '../../../helpers/factories';
|
||
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
|
||
import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository';
|
||
import { Users } from '../../../../src/db/entities/Users.entity';
|
||
import type { UsersRepository } from '../../../../src/db/repositories/Users.repository';
|
||
import { readInstanceApiKey, writeInstanceApiKey, resolveApiKey } from '../../../../src/nest/settings/instance-api-keys';
|
||
|
||
const testDb = createSnapshotTestDb();
|
||
let t: TestOrm;
|
||
let appSettings: AppSettingsRepository;
|
||
let users: UsersRepository;
|
||
|
||
beforeAll(async () => {
|
||
t = await createTestOrm(testDb);
|
||
appSettings = t.repo(AppSettings) as AppSettingsRepository;
|
||
users = t.repo(Users) as UsersRepository;
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
t.clear();
|
||
delete process.env.PLACES_API_KEY;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await t.close();
|
||
testDb.close();
|
||
});
|
||
|
||
const storedValue = (key: string) =>
|
||
(testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||
|
||
describe('instance API keys', () => {
|
||
it('INSTKEY-001: round-trips through encryption at rest', async () => {
|
||
await writeInstanceApiKey(appSettings, 'maps_api_key', 'AIza-instance-key');
|
||
expect(storedValue('maps_api_key')).toMatch(/^enc:v1:/);
|
||
expect(storedValue('maps_api_key')).not.toContain('AIza-instance-key');
|
||
expect(await readInstanceApiKey(appSettings, 'maps_api_key')).toBe('AIza-instance-key');
|
||
});
|
||
|
||
it('INSTKEY-002: a second write of the same value replaces the row (no second one)', async () => {
|
||
await writeInstanceApiKey(appSettings, 'maps_api_key', 'same-key');
|
||
const first = storedValue('maps_api_key');
|
||
await writeInstanceApiKey(appSettings, 'maps_api_key', 'same-key');
|
||
// Same plaintext, different blob — the IV is random. That is exactly why
|
||
// "did this change?" is never asked of the stored value.
|
||
expect(storedValue('maps_api_key')).not.toBe(first);
|
||
expect(await readInstanceApiKey(appSettings, 'maps_api_key')).toBe('same-key');
|
||
const rows = testDb.prepare("SELECT COUNT(*) AS n FROM app_settings WHERE key = 'maps_api_key'").get() as { n: number };
|
||
expect(rows.n).toBe(1);
|
||
});
|
||
|
||
it('INSTKEY-003: a blank value reads back as unset but keeps the row', async () => {
|
||
await writeInstanceApiKey(appSettings, 'unsplash_api_key', ' ');
|
||
expect(storedValue('unsplash_api_key')).toBe('');
|
||
expect(await readInstanceApiKey(appSettings, 'unsplash_api_key')).toBeNull();
|
||
});
|
||
|
||
it('INSTKEY-004: a legacy plaintext row still reads', async () => {
|
||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'plain-old-key')").run();
|
||
expect(await readInstanceApiKey(appSettings, 'maps_api_key')).toBe('plain-old-key');
|
||
});
|
||
|
||
it('INSTKEY-005: the operator env key wins and the database is never read', async () => {
|
||
process.env.PLACES_API_KEY = 'operator-key';
|
||
await writeInstanceApiKey(appSettings, 'maps_api_key', 'instance-key');
|
||
expect(await resolveApiKey(appSettings, users, 'maps_api_key', 1, process.env.PLACES_API_KEY)).toEqual({
|
||
key: 'operator-key',
|
||
source: 'operator-env',
|
||
});
|
||
});
|
||
|
||
it('INSTKEY-006: the instance value wins over the caller own row', async () => {
|
||
const { user } = createAdmin(testDb);
|
||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', user.id);
|
||
await writeInstanceApiKey(appSettings, 'maps_api_key', 'instance-key');
|
||
expect(await resolveApiKey(appSettings, users, 'maps_api_key', user.id, undefined)).toEqual({
|
||
key: 'instance-key',
|
||
source: 'instance',
|
||
});
|
||
});
|
||
|
||
it("INSTKEY-007: without an instance value the caller's own row answers — and nobody else's (#1939)", async () => {
|
||
const { user: admin } = createAdmin(testDb);
|
||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('admins-own-key', admin.id);
|
||
const { user: member } = createUser(testDb);
|
||
|
||
// The admin gets theirs...
|
||
expect(await resolveApiKey(appSettings, users, 'maps_api_key', admin.id, undefined)).toEqual({
|
||
key: 'admins-own-key',
|
||
source: 'user-row',
|
||
});
|
||
// ...and the member gets nothing rather than the admin's, which is the whole
|
||
// point: they used to get it, and Google answered them with a 403.
|
||
expect(await resolveApiKey(appSettings, users, 'maps_api_key', member.id, undefined)).toEqual({ key: null, source: null });
|
||
});
|
||
|
||
it('INSTKEY-009: userId 0 asks about the instance only', async () => {
|
||
const { user } = createAdmin(testDb);
|
||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', user.id);
|
||
// app-config is optional-auth: with nobody asking there is no own row, and
|
||
// the answer must not be some other row that happens to be first.
|
||
expect(await resolveApiKey(appSettings, users, 'maps_api_key', 0, undefined)).toEqual({ key: null, source: null });
|
||
await writeInstanceApiKey(appSettings, 'maps_api_key', 'instance-key');
|
||
expect(await resolveApiKey(appSettings, users, 'maps_api_key', 0, undefined)).toEqual({
|
||
key: 'instance-key',
|
||
source: 'instance',
|
||
});
|
||
});
|
||
|
||
it('INSTKEY-008: an empty instance value does not fall through to the own row', async () => {
|
||
const { user } = createAdmin(testDb);
|
||
testDb.prepare('UPDATE users SET unsplash_api_key = ? WHERE id = ?').run('stale-personal', user.id);
|
||
await writeInstanceApiKey(appSettings, 'unsplash_api_key', 'to-be-cleared');
|
||
await writeInstanceApiKey(appSettings, 'unsplash_api_key', '');
|
||
// The admin who cleared the field cleared their column in the same save, so
|
||
// the fallback finding the old value would only happen on a row nobody
|
||
// touched — here it must not resurrect a cleared instance key for them.
|
||
testDb.prepare('UPDATE users SET unsplash_api_key = NULL WHERE id = ?').run(user.id);
|
||
expect(await resolveApiKey(appSettings, users, 'unsplash_api_key', user.id, undefined)).toEqual({ key: null, source: null });
|
||
});
|
||
});
|