Files
TREK/server/tests/unit/nest/settings/instance-api-keys.test.ts
T

145 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
users = t.repo(Users);
});
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 });
});
});