Files
MikrotikManager/backend/src/db/database-schema-control.test.ts
T

198 lines
12 KiB
TypeScript

import assert from "node:assert/strict"
import { test } from "node:test"
import { acquireDatabaseSchemaControl, DATABASE_SCHEMA_CONTROL_SQL as sql, type DatabaseSchemaControlClient } from "./database-schema-control.js"
import { initialDatabaseSchemaControlState, DatabaseSchemaControlError, type DatabaseSchemaControlState } from "./database-schema-control-model.js"
const id = "00000000-0000-4000-8000-000000000001", otherId = "00000000-0000-4000-8000-000000000002", hash = "a".repeat(64)
const command = (intentId = id) => ({ type: "begin" as const, id: intentId, kind: "restore" as const, manifestSha256: hash, artifactSha256: hash })
/** Inert protocol emulator: does not claim PostgreSQL durability. Root's
* private PG suite must independently test these SQL strings/commit windows. */
function fixture() {
let durable = { revision: "0", state: initialDatabaseSchemaControlState() }, intents = new Map<string, { intent: unknown; terminal: unknown }>()
let lock: number | undefined, sessions = 0
const calls: string[] = [], releases: boolean[] = []
const faults = { at: "", afterCommit: false, commitTag: "COMMIT" as string | undefined }, empty = { rows: [] as Record<string, unknown>[] }
const pool = { connect: async (): Promise<DatabaseSchemaControlClient> => {
const session = ++sessions
let tx: typeof durable | undefined, history: typeof intents | undefined
return {
query: async (query, values = []) => {
calls.push(query)
if (query === faults.at && !faults.afterCommit) throw new Error("injected SQL refusal")
if (query === sql.acquire) { if (lock !== undefined) return { rows: [{ acquired: false }] }; lock = session; return { rows: [{ acquired: true }] } }
if (query === sql.unlock) { assert.equal(lock, session); lock = undefined; return { rows: [{ unlocked: true }] } }
if (query === "BEGIN") { assert(!tx); tx = structuredClone(durable); history = structuredClone(intents); return empty }
if (query.startsWith("SET LOCAL ")) { assert(tx); return empty }
if (query === sql.read) { assert(tx); return { rows: [structuredClone(tx)] } }
if (query === sql.beginIntent) { assert(history); if (history.has(String(values[0]))) throw new Error("duplicate intent"); history.set(String(values[0]), { intent: JSON.parse(String(values[1])), terminal: null }); return { rows: [{ id: values[0] }] } }
if (query === sql.terminalIntent) { const row = history?.get(String(values[0])); if (!row || row.terminal) return empty; row.terminal = JSON.parse(String(values[1])); return { rows: [{ id: values[0] }] } }
if (query === sql.readIntent) { const row = history?.get(String(values[0])); return { rows: row ? [structuredClone(row)] : [] } }
if (query === sql.write) { assert(tx); if (values[1] !== tx.revision) return empty; tx = { revision: String(BigInt(tx.revision) + 1n), state: JSON.parse(String(values[0])) as DatabaseSchemaControlState }; return { rows: [structuredClone(tx)] } }
if (query === "COMMIT") { assert(tx && history); durable = tx; intents = history; tx = undefined; history = undefined; if (faults.at === query && faults.afterCommit) throw new Error("lost COMMIT ACK"); return { ...empty, command: faults.commitTag } }
throw new Error("Unexpected SQL: " + query)
},
release: destroy => { releases.push(Boolean(destroy)); tx = undefined; history = undefined; if (lock === session) lock = undefined },
}
} }
return { pool, calls, releases, faults, read: () => structuredClone(durable), intents: () => structuredClone(intents), corrupt: (change: (state: DatabaseSchemaControlState) => void) => change(durable.state) }
}
test("acquisition commits quarantine before returning, no DDL or application SQL", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
assert.equal(f.read().state.epoch, 1)
assert.equal(f.calls.at(-1), "COMMIT")
assert.equal((await handle.admission()).ready, false)
assert(f.calls.includes("SET LOCAL synchronous_commit = on"))
assert(!f.calls.some(query => /CREATE|DROP|public\./.test(query)))
await handle.close(); assert.deepEqual(f.releases, [false])
})
test("second owner cannot acquire a live session lease", async () => {
const f = fixture(), first = await acquireDatabaseSchemaControl(f.pool)
await assert.rejects(acquireDatabaseSchemaControl(f.pool), DatabaseSchemaControlError)
assert.equal(f.read().state.epoch, 1)
await first.close()
const second = await acquireDatabaseSchemaControl(f.pool)
assert.equal(f.read().state.epoch, 2)
await second.close()
})
test("intent and terminal history are atomically published with control CAS", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
await handle.transition(command())
assert(f.intents().has(id)); assert.equal(f.read().state.phase, "INTENT")
await handle.transition({ type: "terminal", id, outcome: "observed-success", observationSha256: hash })
assert(f.intents().get(id)?.terminal)
assert.equal((await handle.admission()).ready, false)
await handle.close()
})
test("historical id reuse refuses after subsequent operations", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
for (const current of [id, otherId]) {
await handle.transition(command(current))
await handle.transition({ type: "terminal", id: current, outcome: "observed-success", observationSha256: hash })
}
await assert.rejects(handle.transition(command()))
assert.equal(f.read().state.terminal?.intent.id, otherId)
assert.equal(f.releases.at(-1), true)
})
test("failed CAS rolls back intent publication and discards owner", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
f.faults.at = sql.write
await assert.rejects(handle.transition(command()))
assert.equal(f.intents().size, 0); assert.equal(f.read().state.intent, null)
await assert.rejects(handle.read())
assert.equal(f.releases.at(-1), true)
})
test("lost intent COMMIT ACK persists unknown intent but poisons old handle", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
f.faults.at = "COMMIT"; f.faults.afterCommit = true
await assert.rejects(handle.transition(command()), error => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
assert.equal(f.read().state.intent?.id, id)
await assert.rejects(handle.admission())
f.faults.at = ""
const restarted = await acquireDatabaseSchemaControl(f.pool)
assert.equal(f.read().state.epoch, 2)
assert.equal((await restarted.admission()).reason, "UNRESOLVED_INTENT")
await assert.rejects(restarted.transition({ type: "terminal", id, outcome: "observed-success", observationSha256: hash }))
})
test("lost terminal ACK never produces READY after fresh ownership", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
await handle.transition(command())
f.faults.at = "COMMIT"; f.faults.afterCommit = true
await assert.rejects(handle.transition({ type: "terminal", id, outcome: "observed-success", observationSha256: hash }))
f.faults.at = ""
const restarted = await acquireDatabaseSchemaControl(f.pool)
assert.equal(f.read().state.terminal?.outcome, "observed-success")
assert.equal((await restarted.admission()).ready, false)
await restarted.close()
})
test("restarted owner may durably record interrupted-unknown but stays quarantined", async () => {
const f = fixture(), first = await acquireDatabaseSchemaControl(f.pool)
await first.transition(command()); await first.close()
const next = await acquireDatabaseSchemaControl(f.pool)
await next.transition({ type: "terminal", id, outcome: "interrupted-unknown", observationSha256: hash })
assert.equal(f.read().state.intent, null)
assert.equal((await next.admission()).ready, false)
await next.close()
})
test("concurrent use of one handle refuses without duplicate publication", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
const first = handle.transition(command())
await assert.rejects(handle.transition(command(otherId)))
await first
assert.equal(f.intents().size, 1)
await handle.close(); await handle.close()
await assert.rejects(handle.read())
})
test("caller mutation after transition cannot change durable command or history", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool), mutable = command()
const pending = handle.transition(mutable)
mutable.id = otherId
await pending
assert.equal(f.read().state.intent?.id, id)
assert(f.intents().has(id)); assert(!f.intents().has(otherId))
await handle.close()
})
test("unknown command policy bytes refuse before SQL and leave owner usable", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool), before = f.calls.length
await assert.rejects(handle.transition({ ...command(), policy: { ready: true } } as ReturnType<typeof command>))
assert.equal(f.calls.length, before)
assert.equal((await handle.admission()).ready, false)
await handle.close()
})
test("tampered state cannot disagree with independently stored intent history", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
await handle.transition(command())
f.corrupt(state => { state.intent!.artifactSha256 = "b".repeat(64) })
await assert.rejects(handle.read())
assert.equal(f.releases.at(-1), true)
await assert.rejects(acquireDatabaseSchemaControl(f.pool))
assert.equal(f.read().state.epoch, 1)
})
test("accessor commands refuse without invocation or database IO", async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool), before = f.calls.length
let invoked = false
const accessor = command()
Object.defineProperty(accessor, "id", { enumerable: true, get: () => { invoked = true; return id } })
await assert.rejects(handle.transition(accessor))
assert.equal(invoked, false); assert.equal(f.calls.length, before)
await handle.close()
})
for (const tag of ["ROLLBACK", "", undefined]) {
test(`unconfirmed COMMIT command ${String(tag)} destroys owner and never returns a grant`, async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
f.faults.commitTag = tag
await assert.rejects(handle.transition(command()), error => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
assert.equal(f.releases.at(-1), true)
await assert.rejects(handle.read(), DatabaseSchemaControlError)
// Even if the server committed, no readback replays or upgrades the ACK.
assert.equal(f.calls.filter(query => query === sql.beginIntent).length, 1)
f.faults.commitTag = "COMMIT"
const restarted = await acquireDatabaseSchemaControl(f.pool)
assert.equal((await restarted.admission()).ready, false)
await restarted.close()
})
test(`claim COMMIT command ${String(tag)} refuses before a usable owner exists`, async () => {
const f = fixture()
f.faults.commitTag = tag
await assert.rejects(acquireDatabaseSchemaControl(f.pool), error => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
assert.deepEqual(f.releases, [true])
assert.equal(f.calls.filter(query => query === sql.beginIntent).length, 0)
})
}
for (const tag of ["ROLLBACK", "", undefined]) {
test(`terminal COMMIT command ${String(tag)} cannot publish an acknowledged outcome`, async () => {
const f = fixture(), handle = await acquireDatabaseSchemaControl(f.pool)
await handle.transition(command())
f.faults.commitTag = tag
await assert.rejects(handle.transition({ type: "terminal", id, outcome: "observed-success", observationSha256: hash }), error => error instanceof DatabaseSchemaControlError && error.outcomeUnknown)
assert.equal(f.releases.at(-1), true)
await assert.rejects(handle.admission(), DatabaseSchemaControlError)
assert.equal(f.calls.filter(query => query === sql.terminalIntent).length, 1)
f.faults.commitTag = "COMMIT"
const restarted = await acquireDatabaseSchemaControl(f.pool)
assert.equal((await restarted.admission()).ready, false)
await restarted.close()
})
}