134 lines
8.2 KiB
TypeScript
134 lines
8.2 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
}
|
|
})
|