mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
3c040fab11
* fix(share): serve place thumbnails in shared trip links (#1100) Google-sourced place photos are stored as image_url pointing at the JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401 for an unauthenticated shared-trip viewer and render as broken images. Rewrite place image_url values in the shared payload to a public, token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and add an unguarded SharedController route that validates the token and that the place belongs to its trip before streaming the cached bytes. Mirrors the existing JourneyPublicController precedent. No client changes needed. * fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119) Atlas sourced country and sub-national boundaries from Natural Earth's GitHub `master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020 counties such as Oppland/Hordaland) and depicts some contested territory in unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped entirely. - Country borders (admin0) now come from the geoBoundaries CGAZ composite; sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2 codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes them into committed gzipped bundles under server/assets/atlas, read server-side at runtime (no network at boot, no GitHub CSP allowlist entry). - New GET /addons/atlas/countries/geo serves the country layer; the client fetches it from the API instead of GitHub. - A migration reconciles manually-marked visited_regions against the new bundle (valid code -> keep; region name still matches -> re-code; curated merge crosswalk for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and visited_countries hold only invariant alpha-2 country codes, so they are untouched. - Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0. Closes #1119 * fix(packing): make templates admin-only to create, usable by members Creating a packing-list template was gated only by trip access, so any trip member could create one from the Lists feature, while applying a template silently failed for non-admins because the apply dropdown was populated from the AdminGuard-protected /api/admin/packing-templates endpoint. - save-as-template now returns 403 for non-admins; the Save-as-Template button is hidden unless the user is an admin (both the TripPlanner toolbar and the inline packing header). - add member-accessible GET /api/trips/:tripId/packing/templates so the apply dropdown lists templates for any trip member; client fetches from it instead of the admin endpoint. Closes #1120 Closes #1121 * fix(packing): show bag tracking to non-admin members The global Bag Tracking toggle was only readable via the admin-gated GET /api/admin/bag-tracking, so non-admin trip members got 403 and the weight fields, bag circles, and BAGS sidebar never rendered (#1124). Surface the flag through the already-authenticated GET /api/addons (loaded into the client addon store on app start for every user); the packing hook reads it from the store instead of the admin endpoint. The admin write path stays admin-gated and unchanged.
197 lines
10 KiB
TypeScript
197 lines
10 KiB
TypeScript
/**
|
|
* Migration hygiene guardrails.
|
|
*
|
|
* These tests scan the migration source statically and fail when a NEW
|
|
* destructive operation (DROP TABLE / DROP COLUMN / TRUNCATE / DELETE FROM /
|
|
* ALTER ... DROP) is introduced, or when an empty/silent `catch` block creeps
|
|
* back into the migration runner.
|
|
*
|
|
* Migrations 1..N are append-only and immutable once shipped (the live schema
|
|
* has already applied them; rewriting an applied migration is a breaking
|
|
* change). The destructive statements that already exist were each reviewed
|
|
* and are legitimate — almost all are the standard SQLite "table rebuild"
|
|
* pattern (create *_new, copy rows, DROP old, RENAME), plus a handful of
|
|
* deliberate, data-preserving cleanups. They are recorded in
|
|
* ALLOWED_DESTRUCTIVE below with the reason. Anything not on that list is
|
|
* treated as a regression.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { createTestDb } from '../../helpers/test-db';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const MIGRATIONS_PATH = resolve(here, '../../../src/db/migrations.ts');
|
|
const migrationsSource = readFileSync(MIGRATIONS_PATH, 'utf8');
|
|
|
|
/**
|
|
* Strip line and block comments so commented-out SQL (or prose mentioning
|
|
* "DROP TABLE") is never flagged. String/template contents are preserved —
|
|
* that is exactly where the real SQL lives.
|
|
*/
|
|
function stripComments(src: string): string {
|
|
return src
|
|
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
}
|
|
|
|
const scannableSource = stripComments(migrationsSource);
|
|
|
|
interface DestructiveHit {
|
|
/** Normalised signature used as the allowlist key, e.g. "DROP TABLE budget_items". */
|
|
signature: string;
|
|
/** The raw matched fragment, kept for diagnostics. */
|
|
fragment: string;
|
|
}
|
|
|
|
/**
|
|
* Detects destructive DDL/DML. For each match we build a normalised signature
|
|
* of "<OPERATION> <TARGET>" so cosmetic whitespace/quoting changes don't churn
|
|
* the allowlist, while a genuinely new target (or operation) shows up as a new
|
|
* signature.
|
|
*/
|
|
function findDestructiveStatements(src: string): DestructiveHit[] {
|
|
const hits: DestructiveHit[] = [];
|
|
const norm = (s: string) => s.replace(/[`"'\[\]]/g, '').replace(/\s+/g, ' ').trim();
|
|
|
|
// DROP TABLE [IF EXISTS] <name>
|
|
for (const m of src.matchAll(/DROP\s+TABLE\s+(IF\s+EXISTS\s+)?[`"'\[]?([A-Za-z_][\w]*)/gi)) {
|
|
hits.push({ signature: `DROP TABLE ${m[2]}`, fragment: norm(m[0]) });
|
|
}
|
|
// ALTER TABLE <t> DROP COLUMN <c> (and bare ALTER ... DROP <c>)
|
|
for (const m of src.matchAll(/ALTER\s+TABLE\s+[`"'\[]?([A-Za-z_][\w]*)[`"'\]]?\s+DROP\s+(COLUMN\s+)?[`"'\[]?([A-Za-z_][\w]*)/gi)) {
|
|
hits.push({ signature: `ALTER TABLE ${m[1]} DROP COLUMN ${m[3]}`, fragment: norm(m[0]) });
|
|
}
|
|
// TRUNCATE <t> (not valid SQLite, but guard anyway)
|
|
for (const m of src.matchAll(/TRUNCATE\s+(TABLE\s+)?[`"'\[]?([A-Za-z_][\w]*)/gi)) {
|
|
hits.push({ signature: `TRUNCATE ${m[2]}`, fragment: norm(m[0]) });
|
|
}
|
|
// DELETE FROM <t>
|
|
for (const m of src.matchAll(/DELETE\s+FROM\s+[`"'\[]?([A-Za-z_][\w]*)/gi)) {
|
|
hits.push({ signature: `DELETE FROM ${m[1]}`, fragment: norm(m[0]) });
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
/**
|
|
* Allowlist of destructive statements already present and reviewed as
|
|
* legitimate. Keyed by normalised signature. NEVER add to this without a
|
|
* code-review-level justification — that is the whole point of the guard.
|
|
*
|
|
* Rebuild = standard SQLite 12-step ALTER emulation: CREATE <t>_new,
|
|
* INSERT ... SELECT to copy rows, DROP old <t>, ALTER ... RENAME <t>_new TO <t>.
|
|
* Rows are preserved across the rebuild.
|
|
*/
|
|
const ALLOWED_DESTRUCTIVE: Record<string, string> = {
|
|
// ── table rebuilds (data preserved) ──────────────────────────────────────
|
|
'DROP TABLE budget_items':
|
|
'Migration 12: rebuild to drop a stale NOT NULL DEFAULT on persons/days. Rows copied first.',
|
|
'DROP TABLE oauth_clients':
|
|
'Make oauth_clients.user_id nullable for anonymous DCR clients. Rebuild, rows copied.',
|
|
'DROP TABLE idempotency_keys':
|
|
'Widen PK to (key,user_id,method,path). Rebuild, rows copied (old PK is a subset).',
|
|
'DROP TABLE day_accommodations':
|
|
'Make place_id nullable + ON DELETE SET NULL. Rebuild, rows copied.',
|
|
'DROP TABLE schema_version':
|
|
'Add surrogate id PK to schema_version. Rebuild, version row copied.',
|
|
|
|
// ── photo/journey table rebuilds (data preserved) ────────────────────────
|
|
'DROP TABLE trip_photos':
|
|
'trip_photos normalisation + later photo_id FK refactor. Rebuilds, rows copied.',
|
|
'DROP TABLE trip_album_links':
|
|
'Normalise trip_album_links to provider+album_id schema. Rebuild, rows copied.',
|
|
'DROP TABLE journey_photos':
|
|
'Journey photo provider support + photo_id FK refactor. Rebuilds, rows copied.',
|
|
'DROP TABLE journey_photos_old':
|
|
'Migration 121 gallery refactor: drops the temporary *_old backup after backfill.',
|
|
'DROP TABLE journey_location_trail':
|
|
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
|
'DROP TABLE journey_entries':
|
|
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
|
'DROP TABLE journey_checkins':
|
|
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
|
'DROP TABLE journey_members':
|
|
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
|
'DROP TABLE journey_trips':
|
|
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
|
'DROP TABLE journeys':
|
|
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
|
|
|
// ── template/cache scaffolding drops (no user content lost) ──────────────
|
|
'DROP TABLE packing_template_items':
|
|
'IF EXISTS drop to recreate the template-items table with a category_id FK. Template scaffolding.',
|
|
'DROP TABLE notification_preferences':
|
|
'IF EXISTS drop AFTER migration 71 copied the data into notification_channel_preferences.',
|
|
|
|
// ── guarded column drop ──────────────────────────────────────────────────
|
|
'ALTER TABLE photo_providers DROP COLUMN config':
|
|
'Drop generated-only config column; guarded by a PRAGMA table_info check that it exists.',
|
|
|
|
// ── targeted, bounded DELETEs ────────────────────────────────────────────
|
|
'DELETE FROM oauth_tokens':
|
|
'SEC-H6: DELETE ... WHERE audience IS NULL — purge pre-audience-binding tokens that the MCP server now rejects.',
|
|
'DELETE FROM journey_entries':
|
|
"Migration 121: DELETE ... WHERE title IN ('Gallery','[Trip Photos]') — remove synthetic wrapper entries replaced by the gallery model.",
|
|
'DELETE FROM place_regions':
|
|
'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) — invalidate stale region cache; re-resolved on next request.',
|
|
'DELETE FROM visited_regions':
|
|
'Atlas geoBoundaries swap (#1119): DELETE ... WHERE id = ? — after UPDATE OR IGNORE re-codes a manually-marked region to its current code, drop only the single leftover row whose UNIQUE(user_id, region_code) collision caused the update to be skipped (a duplicate of a region the user already has).',
|
|
};
|
|
|
|
describe('migration hygiene — destructive operation guard', () => {
|
|
it('introduces no destructive migration statement outside the reviewed allowlist', () => {
|
|
const hits = findDestructiveStatements(scannableSource);
|
|
const offenders = hits.filter((h) => !(h.signature in ALLOWED_DESTRUCTIVE));
|
|
|
|
if (offenders.length > 0) {
|
|
const detail = offenders
|
|
.map((o) => ` • ${o.signature} (matched: "${o.fragment}")`)
|
|
.join('\n');
|
|
throw new Error(
|
|
`Found ${offenders.length} destructive migration statement(s) that are not on the ` +
|
|
`reviewed allowlist in tests/unit/db/migration-hygiene.test.ts.\n` +
|
|
`Migrations are append-only and destructive DDL/DML risks data loss on upgrade.\n` +
|
|
`If the statement is genuinely safe (e.g. a SQLite table rebuild that copies rows ` +
|
|
`first, or a tightly-bounded cache/cleanup DELETE), add its signature to ` +
|
|
`ALLOWED_DESTRUCTIVE with a justification.\n\nOffending statement(s):\n${detail}`,
|
|
);
|
|
}
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it('every allowlist entry still corresponds to a real statement (no dead allowlist rows)', () => {
|
|
const present = new Set(findDestructiveStatements(scannableSource).map((h) => h.signature));
|
|
const dead = Object.keys(ALLOWED_DESTRUCTIVE).filter((sig) => !present.has(sig));
|
|
expect(dead, `Allowlist entries no longer found in migrations.ts: ${dead.join(', ')}`).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('migration hygiene — no silently swallowed errors', () => {
|
|
it('contains no empty catch block (catch must at least log)', () => {
|
|
// Matches `catch {}` and `catch (e) {}` where the body is only whitespace.
|
|
const emptyCatch = scannableSource.match(/catch\s*(\([^)]*\))?\s*\{\s*\}/g) ?? [];
|
|
expect(
|
|
emptyCatch,
|
|
`migrations.ts must not swallow errors silently. Give each catch a log line ` +
|
|
`(e.g. console.warn('[migrations] ...', err)). Found: ${emptyCatch.length}`,
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('migration hygiene — full chain smoke', () => {
|
|
it('migrates a fresh in-memory database from zero to the latest version', () => {
|
|
// createTestDb() runs createTables() + the entire runMigrations() chain.
|
|
// This proves the logging edits in the previously-empty catch blocks do
|
|
// not change control flow / break the migration runner.
|
|
const db = createTestDb();
|
|
try {
|
|
const row = db.prepare('SELECT version FROM schema_version').get() as { version: number };
|
|
expect(row.version).toBeGreaterThan(0);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|