mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
1a0834b866
Searching worked for the admin and 403'd for every other member. getMapsKey read the calling user's own row first and then fell through to "any admin" via LIMIT 1 with no ORDER BY, so the admin who pasted the key hit their own row while everyone else got a non-deterministic stranger's. That fallback is what CLAUDE.md forbids. The Places and Unsplash keys bill the install, not the account that pasted them in, so they become instance configuration: an encrypted app_settings row, resolved as env -> instance -> the caller's own row, the same shape #1772 chose for the LLM endpoint. The per-user column stays as the last step because /me/api-keys has always accepted a personal key. The trailing migration carries an existing install's key into the instance row so nothing degrades to OpenStreetMap on upgrade, but only where that key already was the whole install's: as soon as a second row holds one, it is left alone, because the instance row outranks everybody's own column and an upgrade must not move a member onto someone else's key and bill. Two things from the same report: The key test button probed with field mask places.displayName while the search asks for places.rating and more, which is a separate SKU and can be restricted on its own, so the button could pass while every search failed. It now probes the resolved key with the search's own mask. Saving API keys wrote no audit line. It now writes one settings.api_keys_update row naming which fields changed, never their values, comparing cleartext because the random IV makes every ciphertext look different. Closes #1939
142 lines
5.7 KiB
TypeScript
142 lines
5.7 KiB
TypeScript
/**
|
|
* Unit test for the instance API-key backfill migration (#1939).
|
|
*
|
|
* The Places/Unsplash credential moved from "whichever admin row the resolver
|
|
* happened to find" to an instance-wide app_settings row. An install that
|
|
* upgrades must keep searching with the key it already had, so the migration
|
|
* copies the value the old resolver would have handed out — the lowest-id admin
|
|
* who has one — and leaves the users columns alone. It only does so where that
|
|
* value was the whole install's key: as soon as a second row holds one, the
|
|
* promotion is skipped, because the instance row outranks everybody's own
|
|
* column and nobody may be moved onto a stranger's key by an upgrade.
|
|
*
|
|
* Seeded before runMigrations rather than by rewinding schema_version
|
|
* afterwards: createTables already brings users.role and both key columns, so
|
|
* the backfill fires on its normal pass and finds the rows. That keeps this
|
|
* file independent of where the entry sits in the append-only array, so
|
|
* appending the next migration does not drag it along. The one guard that does
|
|
* have to track the tail is tests/integration/leg-mode-incoming.test.ts, which
|
|
* says so in place.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
|
|
function freshDb() {
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
createTables(db);
|
|
return db;
|
|
}
|
|
|
|
function seedUser(
|
|
db: Database.Database,
|
|
id: number,
|
|
role: 'admin' | 'user',
|
|
keys: { maps?: string; unsplash?: string } = {},
|
|
) {
|
|
db.prepare(
|
|
`INSERT INTO users (id, username, email, password_hash, role, maps_api_key, unsplash_api_key)
|
|
VALUES (?, ?, ?, 'x', ?, ?, ?)`
|
|
).run(id, `u${id}`, `u${id}@test.local`, role, keys.maps ?? null, keys.unsplash ?? null);
|
|
}
|
|
|
|
const setting = (db: Database.Database, key: string) =>
|
|
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
|
|
|
describe('instance API-key backfill migration', () => {
|
|
it('KEYFILL-001: copies the one key the install was searching with and keeps the column', () => {
|
|
const db = freshDb();
|
|
seedUser(db, 1, 'admin', { maps: 'the-admin-google', unsplash: 'the-admin-unsplash' });
|
|
seedUser(db, 2, 'user');
|
|
|
|
runMigrations(db);
|
|
|
|
expect(setting(db, 'maps_api_key')).toBe('the-admin-google');
|
|
expect(setting(db, 'unsplash_api_key')).toBe('the-admin-unsplash');
|
|
// Nothing is taken away: the columns are still the per-user fallback.
|
|
const row = db.prepare('SELECT maps_api_key FROM users WHERE id = 1').get() as { maps_api_key: string };
|
|
expect(row.maps_api_key).toBe('the-admin-google');
|
|
db.close();
|
|
});
|
|
|
|
it('KEYFILL-002: skips an admin whose column is empty and takes the next one', () => {
|
|
const db = freshDb();
|
|
seedUser(db, 1, 'admin', { maps: '' });
|
|
seedUser(db, 2, 'admin', { maps: 'the-only-real-key' });
|
|
|
|
runMigrations(db);
|
|
|
|
expect(setting(db, 'maps_api_key')).toBe('the-only-real-key');
|
|
db.close();
|
|
});
|
|
|
|
it('KEYFILL-003: never overwrites a value the admin has already saved instance-wide', () => {
|
|
const db = freshDb();
|
|
seedUser(db, 1, 'admin', { maps: 'old-column-key' });
|
|
db.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'already-instance-wide')").run();
|
|
|
|
runMigrations(db);
|
|
|
|
expect(setting(db, 'maps_api_key')).toBe('already-instance-wide');
|
|
db.close();
|
|
});
|
|
|
|
it('KEYFILL-004: ignores a non-admin key and writes no row at all', () => {
|
|
const db = freshDb();
|
|
seedUser(db, 1, 'user', { maps: 'members-own-key' });
|
|
|
|
runMigrations(db);
|
|
|
|
// A member's key stays theirs — the resolver still finds it for them, and
|
|
// promoting it would hand their billing to the whole instance.
|
|
expect(setting(db, 'maps_api_key')).toBeUndefined();
|
|
db.close();
|
|
});
|
|
|
|
it('KEYFILL-005: writes nothing on an install that never had a key', () => {
|
|
const db = freshDb();
|
|
seedUser(db, 1, 'admin');
|
|
|
|
runMigrations(db);
|
|
|
|
expect(setting(db, 'maps_api_key')).toBeUndefined();
|
|
expect(setting(db, 'unsplash_api_key')).toBeUndefined();
|
|
db.close();
|
|
});
|
|
|
|
it('KEYFILL-006: leaves a column alone once a member holds a key of their own', () => {
|
|
const db = freshDb();
|
|
seedUser(db, 1, 'admin', { maps: 'admins-own-google', unsplash: 'admins-own-unsplash' });
|
|
seedUser(db, 7, 'user', { maps: 'members-own-google' });
|
|
|
|
runMigrations(db);
|
|
|
|
// Member 7 pays for their own Google key. Promoting the admin's would put
|
|
// every one of their searches on his key and his bill, because the instance
|
|
// row is resolved before their own column.
|
|
expect(setting(db, 'maps_api_key')).toBeUndefined();
|
|
// Decided per column: nobody else has an Unsplash key, so that one really
|
|
// was the whole install's and stays it.
|
|
expect(setting(db, 'unsplash_api_key')).toBe('admins-own-unsplash');
|
|
const row = db.prepare('SELECT maps_api_key FROM users WHERE id = 7').get() as { maps_api_key: string };
|
|
expect(row.maps_api_key).toBe('members-own-google');
|
|
db.close();
|
|
});
|
|
|
|
it('KEYFILL-007: two admins with their own keys both keep them', () => {
|
|
const db = freshDb();
|
|
seedUser(db, 1, 'admin', { maps: 'first-admin-google' });
|
|
seedUser(db, 2, 'admin', { maps: 'second-admin-google' });
|
|
|
|
runMigrations(db);
|
|
|
|
expect(setting(db, 'maps_api_key')).toBeUndefined();
|
|
const rows = db.prepare('SELECT maps_api_key FROM users ORDER BY id').all() as { maps_api_key: string }[];
|
|
expect(rows.map((r) => r.maps_api_key)).toEqual(['first-admin-google', 'second-admin-google']);
|
|
db.close();
|
|
});
|
|
});
|