mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(db): refuse a database or backup a newer TREK migrated, and keep a copy before a restore
Rolling an image back, or restoring a backup a newer release made, ran older code against a schema it does not know and failed later, far from the cause. Boot now refuses a database that records migrations this build does not ship, and the restore check refuses such a backup while the current database is still in place. A restore also keeps the database it replaces as data/pre-restore-<time>.db.
This commit is contained in:
@@ -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<string> {
|
||||
const names = new Set<string>();
|
||||
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<string>, known: Set<string> = knownMigrationNames()): string[] {
|
||||
return [...executed].filter((name) => !known.has(name));
|
||||
}
|
||||
@@ -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<string> = knownMigrationNames(),
|
||||
): Promise<void> {
|
||||
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<string>): 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.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<void> {
|
||||
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<RestoreResult> {
|
||||
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 {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user