import assert from "node:assert/strict" import test from "node:test" import { execFile } from "node:child_process" import { promisify } from "node:util" import { readFile, mkdir, mkdtemp, writeFile, rm } from "node:fs/promises" import { readFileSync } from "node:fs" import path from "node:path" import os from "node:os" import { randomUUID } from "node:crypto" import { Pool } from "pg" import { drizzle } from "drizzle-orm/node-postgres" import { migrate } from "drizzle-orm/node-postgres/migrator" import { readMigrationFiles } from "drizzle-orm/migrator" import Fastify from "fastify" import routes from "../src/routes/instanceAgents" const migrationsFolder = path.resolve(__dirname, "../db/migrations") + path.sep const repairTag = "0072_restore_instance_scan_agents" const journal = JSON.parse(readFileSync(migrationsFolder + "meta/_journal.json", "utf8")).entries const repair = journal.find((entry: any) => entry.tag === repairTag) const previousWatermark = Math.max(...journal.filter((entry: any) => entry.idx < repair?.idx).map((entry: any) => entry.when)) const execute = promisify(execFile) test("the scanner repair is registered after every previously shipped migration", () => { assert.ok(repair, "scanner repair must be in the Drizzle journal") assert.ok(repair.when > previousWatermark) const pending = readMigrationFiles({ migrationsFolder }).filter(entry => entry.folderMillis > previousWatermark) assert.ok(pending.some(entry => entry.sql.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS "instance_agents"')))) }) // Opt in with FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine. This creates its own // disposable database container; it never reads DATABASE_URL or application secrets. test("migration repairs upgraded databases and preserves manually installed scanners", { skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE, }, async t => { // Isolate this repair from unrelated future migrations. Registration in the // actual shipped journal is checked above; its timestamp remains unchanged. const testMigrations = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-migrations-")) t.after(() => rm(testMigrations, { recursive: true, force: true })) await mkdir(path.join(testMigrations, "meta")) await writeFile(path.join(testMigrations, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [repair] })) await writeFile(path.join(testMigrations, repairTag + ".sql"), await readFile(migrationsFolder + repairTag + ".sql")) const container = "fedeo-scan-migration-" + randomUUID() const password = randomUUID() await execute("docker", ["run", "--rm", "-d", "--name", container, "-e", "POSTGRES_PASSWORD=" + password, "-p", "127.0.0.1::5432", process.env.FEDEO_TEST_POSTGRES_IMAGE!]) let admin: Pool | undefined t.after(async () => { try { await admin?.end() } finally { await execute("docker", ["rm", "-f", container]) } }) const { stdout } = await execute("docker", ["port", container, "5432/tcp"]) const port = Number(stdout.trim().split(":").at(-1)) admin = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 }) for (let attempt = 0; ; attempt++) { try { await admin.query("SELECT 1"); break } catch (error) { if (attempt >= 80) throw error await new Promise(resolve => setTimeout(resolve, 100)) } } for (const state of ["absent", "0051", "0052", "0053"]) { await t.test(state, async () => { const database = "scan_" + state await admin.query('CREATE DATABASE "' + database + '"') const pool = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database, max: 1 }) const app = Fastify() try { await pool.query(` CREATE TABLE tenants (id bigint PRIMARY KEY); CREATE TABLE auth_users (id uuid PRIMARY KEY); CREATE TABLE files (id uuid PRIMARY KEY); INSERT INTO tenants VALUES (1); CREATE SCHEMA drizzle; CREATE TABLE drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint); `) // An existing installation already passed 0071. Re-inserting old // journal entries 0051-0053 would therefore NOT fix this database. await pool.query("INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('previous-release', $1)", [previousWatermark]) if (state !== "absent") { for (const file of ["0051_instance_scan_agents", "0052_instance_agent_scan_defaults", "0053_instance_agent_postprocess_defaults"]) { if (file.slice(0, 4) <= state) await pool.query(await readFile(migrationsFolder + file + ".sql", "utf8")) } await pool.query(` INSERT INTO instance_agents (id, name, token_prefix, token_hash) VALUES ('00000000-0000-4000-8000-000000000001', 'Existing scanner', 'prefix', 'existing-hash'); INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id, status, settings) VALUES (1, '00000000-0000-4000-8000-000000000001', 'completed', '{"resolution":600}'); `) if (state !== "0051") { await pool.query(`UPDATE instance_agents SET preferred_scanner_name='existing-device', scan_defaults='{"resolution":600}'`) } } const db = drizzle(pool) await migrate(db, { migrationsFolder: testMigrations }) await migrate(db, { migrationsFolder: testMigrations }) // Also prove the repair SQL itself is safe after manual application. await pool.query(await readFile(migrationsFolder + repairTag + ".sql", "utf8")) app.decorate("db", db) app.addHook("onRequest", async req => { req.user = { is_admin: true } as any }) await app.register(routes) const listing = await app.inject({ method: "GET", url: "/instance-agents" }) assert.equal(listing.statusCode, 200, listing.body) const agents = listing.json().agents assert.equal(agents.length, state === "absent" ? 0 : 1) if (state !== "absent") { assert.equal(agents[0].name, "Existing scanner") assert.equal((await pool.query("SELECT token_hash FROM instance_agents")).rows[0].token_hash, "existing-hash") assert.equal((await pool.query("SELECT status, settings FROM instance_agent_scan_jobs")).rows[0].settings.resolution, 600) if (state !== "0051") { assert.equal(agents[0].preferredScannerName, "existing-device") assert.deepEqual(agents[0].scanDefaults, { resolution: 600 }) } } const created = await app.inject({ method: "POST", url: "/instance-agents", payload: { name: "New scanner" } }) assert.equal(created.statusCode, 200, created.body) assert.equal(created.json().agent.scanDefaults.postprocess, false) assert.equal(created.json().agent.scanDefaults.postprocessProfile, "document") assert.equal(created.json().agent.scanDefaults.format, "pdf") assert.equal((await pool.query("SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations WHERE created_at = $1", [repair.when])).rows[0].count, 1) const indexes = (await pool.query("SELECT indexname FROM pg_indexes WHERE tablename='instance_agent_scan_jobs'")).rows.map(row => row.indexname) assert.ok(indexes.includes("instance_agent_scan_jobs_agent_status_idx")) assert.ok(indexes.includes("instance_agent_scan_jobs_tenant_idx")) await assert.rejects(pool.query("INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id) VALUES (999, $1)", [created.json().agent.id]), { code: "23503" }) } finally { await app.close() await pool.end() } }) } })