diff --git a/server/src/db/known-migrations.ts b/server/src/db/known-migrations.ts new file mode 100644 index 000000000..ee671c87a --- /dev/null +++ b/server/src/db/known-migrations.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const MIGRATION_FILE = /^(Migration\d+_\w+)\.(?:js|ts)$/; + +/** + * The migrations this build ships, by the name the migrator records in + * `mikro_orm_migrations`. Read from the directory the migrator itself reads: + * `src/db/migrations` under tsx and vitest, `dist/db/migrations` in a build. + */ +export function knownMigrationNames(dir = path.join(__dirname, 'migrations')): Set { + const names = new Set(); + for (const entry of fs.readdirSync(dir)) { + const match = MIGRATION_FILE.exec(entry); + if (match) names.add(match[1]); + } + return names; +} + +/** + * Recorded migrations this build does not ship: the database was migrated by + * a newer TREK. Running older code against it fails later and far from the + * cause, so the callers refuse instead. + */ +export function unknownMigrations(executed: Iterable, known: Set = knownMigrationNames()): string[] { + return [...executed].filter((name) => !known.has(name)); +} diff --git a/server/src/db/legacy-baseline.ts b/server/src/db/legacy-baseline.ts index b5930838b..51be84e37 100644 --- a/server/src/db/legacy-baseline.ts +++ b/server/src/db/legacy-baseline.ts @@ -2,6 +2,7 @@ import type { Connection, MigrationInfo } from '@mikro-orm/core'; import type { Migrator } from '@mikro-orm/migrations'; import fs from 'node:fs'; +import { knownMigrationNames, unknownMigrations } from './known-migrations'; /** * Upgrading an install the retired hand-written runner migrated. @@ -162,8 +163,10 @@ export async function migrateToHead( connection: Connection, migrator: Migrator, readSource: ReadSource = readFromDisk, + known: Set = knownMigrationNames(), ): Promise { const baselined = await planLegacyBaseline(connection, migrator, readSource); + refuseNewerDatabase((await migrator.getExecuted()).map((row) => row.name), known); const already = new Set(baselined); const pending = (await migrator.getPending()).filter((migration) => !already.has(migration.name)); if (pending.length === 0 && baselined.length === 0) return; @@ -180,3 +183,18 @@ export async function migrateToHead( await migrator.up({ transaction: trx }); }); } + +/** + * A database a newer TREK migrated carries migrations this build does not + * ship. An image rolled back, or a newer backup restored, would otherwise run + * older code against a schema it does not know and fail later, far from the + * cause. Refused before anything is written. + */ +export function refuseNewerDatabase(executed: string[], known: Set): void { + const unknown = unknownMigrations(executed, known); + if (unknown.length === 0) return; + throw new Error( + `${REFUSAL} the database was migrated by a newer TREK (${unknown.length} unknown migration(s), latest ${unknown[unknown.length - 1]}). ` + + 'Run that version again, or restore a backup taken with this one.', + ); +} diff --git a/server/src/nest/backup/backup-archive.ts b/server/src/nest/backup/backup-archive.ts index fc9281e3e..c16f7c012 100644 --- a/server/src/nest/backup/backup-archive.ts +++ b/server/src/nest/backup/backup-archive.ts @@ -3,6 +3,7 @@ import path from 'path'; import unzipper from 'unzipper'; import { readEnv } from '../../app-config'; import { openDatabase } from '../../db/connection'; +import { unknownMigrations } from '../../db/known-migrations'; /** * Reading a backup archive, without touching the live database. @@ -113,6 +114,16 @@ export function checkBackupDatabase(extractDir: string): ArchiveRefusal | null { return { error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.`, status: 400 }; } } + // A backup a newer TREK made would be swapped in and then refused at boot + // (legacy-baseline.ts), leaving the install down. Refuse it here, while the + // current database is still in place. + if (tableNames.has('mikro_orm_migrations')) { + const executed = uploadedDb.prepare('SELECT name FROM mikro_orm_migrations').all() as { name: string }[]; + const unknown = unknownMigrations(executed.map(row => row.name)); + if (unknown.length > 0) { + return { error: 'This backup was made by a newer TREK version. Update TREK before restoring it.', status: 400 }; + } + } return null; } catch { return { error: 'Uploaded file is not a valid SQLite database', status: 400 }; diff --git a/server/src/nest/backup/backup.impl.ts b/server/src/nest/backup/backup.impl.ts index 886f7fecb..967eca67c 100644 --- a/server/src/nest/backup/backup.impl.ts +++ b/server/src/nest/backup/backup.impl.ts @@ -6,7 +6,7 @@ import fs from 'fs'; import { RequestContext } from '@mikro-orm/core'; import { closeDb, reinitialize } from '../../db/database'; import { MaintenanceRepository } from '../../db/repositories/MaintenanceRepository'; -import { logWarn } from '../audit/audit-log.logger'; +import { logInfo, logWarn } from '../audit/audit-log.logger'; import { VALID_INTERVALS } from './auto-backup.settings'; import { checkBackupDatabase, extractBackupArchive } from './backup-archive'; import { invalidatePermissionsCache } from '../permissions/permissions-cache'; @@ -415,6 +415,24 @@ export async function rehydrateUploads(storage: StorageService, extractedUploads } } +/** + * A copy of the database a restore is about to replace, next to it in data/. + * The swap below deletes the current file, and a restore of the wrong archive + * used to leave nothing to go back to. Best effort: a database broken enough to + * need the restore may not snapshot, and that must not block the recovery. + */ +async function snapshotBeforeRestore(): Promise { + const target = path.join(dataDir, `pre-restore-${Date.now()}.db`); + try { + const em = RequestContext.getEntityManager(); + if (!em) throw new Error('no EntityManager available for VACUUM INTO'); + await new MaintenanceRepository(em).vacuumInto(target); + logInfo(`Restore: the replaced database was kept as ${target}`); + } catch (err) { + logWarn(`Restore: could not keep a copy of the current database (${err instanceof Error ? err.message : String(err)})`); + } +} + export async function restoreFromZip(storage: StorageService, zipPath: string): Promise { const extractDir = path.join(dataDir, `restore-${Date.now()}`); let reinitFailed: unknown = null; @@ -429,6 +447,7 @@ export async function restoreFromZip(storage: StorageService, zipPath: string): } const extractedDb = path.join(extractDir, 'travel.db'); + await snapshotBeforeRestore(); closeDb(); try { diff --git a/server/tests/unit/db/known-migrations.test.ts b/server/tests/unit/db/known-migrations.test.ts new file mode 100644 index 000000000..df0c9edf0 --- /dev/null +++ b/server/tests/unit/db/known-migrations.test.ts @@ -0,0 +1,34 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { knownMigrationNames, unknownMigrations } from '../../../src/db/known-migrations'; +import { refuseNewerDatabase } from '../../../src/db/legacy-baseline'; + +describe('known migrations', () => { + it('KNOWNMIG-001: lists every migration this build ships, by its recorded name', () => { + const known = knownMigrationNames(); + expect(known.has('Migration20200101000000_baseline_schema')).toBe(true); + expect(known.has('Migration20200101042000_tours')).toBe(true); + expect(known.size).toBe(fs.readdirSync(path.join(__dirname, '../../../src/db/migrations')).length); + }); + + it('KNOWNMIG-002: reads compiled files too, and ignores maps, declarations and strangers', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-known-migrations-')); + try { + for (const file of ['Migration20200101000000_a.js', 'Migration20200101000000_a.js.map', 'Migration20200101000100_b.d.ts', 'README.md']) { + fs.writeFileSync(path.join(dir, file), ''); + } + expect([...knownMigrationNames(dir)]).toEqual(['Migration20200101000000_a']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('KNOWNMIG-003: names only what this build does not ship, and refuses on it', () => { + const known = new Set(['Migration1_a', 'Migration2_b']); + expect(unknownMigrations(['Migration1_a', 'Migration3_c'], known)).toEqual(['Migration3_c']); + expect(() => refuseNewerDatabase(['Migration1_a', 'Migration2_b'], known)).not.toThrow(); + expect(() => refuseNewerDatabase(['Migration1_a', 'Migration3_c'], known)).toThrow(/newer TREK/); + }); +}); diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index d16c95ba0..6eb70860f 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -225,4 +225,17 @@ describe('planLegacyBaseline / migrateToHead', () => { expect(planned.at(-1)).toBe('Migration20200101041100_half_company_holidays_0_5_covers_the'); expect(await recorded()).toEqual([]); }); + + it('LEGACYBASE-008: refuses a database a newer TREK migrated, before applying anything', async () => { + await migrate(); + await rawExec(orm, "INSERT INTO mikro_orm_migrations (name, executed_at) VALUES ('Migration20990101000000_from_a_newer_release', CURRENT_TIMESTAMP)"); + const before = await recorded(); + await expect(migrate()).rejects.toThrow(/Refusing to boot: the database was migrated by a newer TREK \(1 unknown migration\(s\), latest Migration20990101000000_from_a_newer_release\)/); + expect(await recorded()).toEqual(before); + }); + + it('LEGACYBASE-009: a database at head of this build boots again without complaint', async () => { + await migrate(); + await expect(migrate()).resolves.toBeUndefined(); + }); }); diff --git a/server/tests/unit/nest/backup-archive-version.test.ts b/server/tests/unit/nest/backup-archive-version.test.ts new file mode 100644 index 000000000..e44eca119 --- /dev/null +++ b/server/tests/unit/nest/backup-archive-version.test.ts @@ -0,0 +1,46 @@ +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { checkBackupDatabase } from '../../../src/nest/backup/backup-archive'; + +describe('checkBackupDatabase and the release that made the backup', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-backup-version-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const backupWith = (recorded: string[]): void => { + const db = new Database(path.join(dir, 'travel.db')); + for (const table of ['users', 'trips', 'trip_members', 'places', 'days']) db.exec(`CREATE TABLE ${table} (id INTEGER)`); + db.exec('CREATE TABLE mikro_orm_migrations (id INTEGER PRIMARY KEY, name TEXT, executed_at TEXT)'); + const insert = db.prepare('INSERT INTO mikro_orm_migrations (name) VALUES (?)'); + for (const name of recorded) insert.run(name); + db.close(); + }; + + it('BACKUPVER-001: refuses a backup a newer TREK made, before anything is swapped', () => { + backupWith(['Migration20200101000000_baseline_schema', 'Migration20990101000000_from_a_newer_release']); + expect(checkBackupDatabase(dir)).toEqual({ + error: 'This backup was made by a newer TREK version. Update TREK before restoring it.', + status: 400, + }); + }); + + it('BACKUPVER-002: accepts a backup this release or an older one made', () => { + backupWith(['Migration20200101000000_baseline_schema', 'Migration20200101042000_tours']); + expect(checkBackupDatabase(dir)).toBeNull(); + }); + + it('BACKUPVER-003: accepts a pre-ORM backup without the migrations table', () => { + const db = new Database(path.join(dir, 'travel.db')); + for (const table of ['users', 'trips', 'trip_members', 'places', 'days']) db.exec(`CREATE TABLE ${table} (id INTEGER)`); + db.close(); + expect(checkBackupDatabase(dir)).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/backup.impl.test.ts b/server/tests/unit/nest/backup.impl.test.ts index 626197df3..5a49f1db1 100644 --- a/server/tests/unit/nest/backup.impl.test.ts +++ b/server/tests/unit/nest/backup.impl.test.ts @@ -2,7 +2,7 @@ * Unit tests for backupService. * Covers BACKUP-031 to BACKUP-060. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, onTestFinished } from 'vitest'; // --------------------------------------------------------------------------- // Hoisted mocks — must be defined before any vi.mock() calls @@ -76,7 +76,9 @@ const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn vi.mock('../../../src/db/database', () => dbMock); vi.mock('../../../src/db/repositories/MaintenanceRepository', () => ({ - MaintenanceRepository: vi.fn().mockImplementation(() => maintenanceRepoMock), + MaintenanceRepository: vi.fn().mockImplementation(function () { + return maintenanceRepoMock; + }), })); vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock); vi.mock('../../../src/config', () => ({ @@ -1262,6 +1264,45 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => { expect(callOrder.indexOf('closeDb')).toBeLessThan(callOrder.indexOf('copyFileSync')); }); + it('BACKUP-045g — keeps a copy of the database it replaces, before closing it', async () => { + setupSuccessfulExtraction(); + setupAllTablesPresent(); + const { RequestContext } = await import('@mikro-orm/core'); + const em = vi.spyOn(RequestContext, 'getEntityManager').mockReturnValue({} as never); + onTestFinished(() => em.mockRestore()); + + const callOrder: string[] = []; + maintenanceRepoMock.vacuumInto.mockImplementation(async (target: string) => { callOrder.push(`vacuum:${target}`); }); + dbMock.closeDb.mockImplementation(() => { callOrder.push('closeDb'); }); + fsMock.unlinkSync.mockReturnValue(undefined); + fsMock.rmSync.mockReturnValue(undefined); + fsMock.existsSync.mockImplementation((p: string) => !String(p).includes('uploads')); + + await restoreFromZip(stubStorage(), '/data/tmp/upload.zip'); + + expect(callOrder[0]).toMatch(/^vacuum:.*pre-restore-\d+\.db$/); + expect(callOrder.indexOf('closeDb')).toBeGreaterThan(0); + expect(logMock.logInfo).toHaveBeenCalledWith(expect.stringContaining('the replaced database was kept as')); + }); + + it('BACKUP-045h — a database that cannot be copied does not block the restore', async () => { + setupSuccessfulExtraction(); + setupAllTablesPresent(); + const { RequestContext } = await import('@mikro-orm/core'); + const em = vi.spyOn(RequestContext, 'getEntityManager').mockReturnValue({} as never); + onTestFinished(() => em.mockRestore()); + maintenanceRepoMock.vacuumInto.mockRejectedValueOnce(new Error('database disk image is malformed')); + fsMock.unlinkSync.mockReturnValue(undefined); + fsMock.rmSync.mockReturnValue(undefined); + fsMock.existsSync.mockImplementation((p: string) => !String(p).includes('uploads')); + + const result = await restoreFromZip(stubStorage(), '/data/tmp/upload.zip'); + + expect(result.success).toBe(true); + expect(logMock.logWarn).toHaveBeenCalledWith(expect.stringContaining('could not keep a copy of the current database')); + expect(dbMock.closeDb).toHaveBeenCalled(); + }); + it('BACKUP-045c — reinitialize is called even when copyFileSync throws', async () => { setupSuccessfulExtraction(); setupAllTablesPresent();