Files
MikrotikManager/backend/src/services/server-poll-archive-accounting.test.ts
T

192 lines
15 KiB
TypeScript

/** Complete ledger component checks with real AES-GCM/HMAC archive metadata.
* Fixed SQL doubles are not PostgreSQL/retained-storage acceptance. */
import assert from "node:assert/strict"
import { createCipheriv, createDecipheriv, createHmac, createHash, randomBytes, randomUUID } from "node:crypto"
import { test } from "node:test"
import { assertServerPollArchiveAccountingForTests } from "./server-poll-archive-accounting.js"
import type { ServerPollRetiredArchive } from "./server-poll-retired-archive.js"
import { createServerPollTerminalArchiveForTests, type ServerPollTerminalArchive, type ServerPollArchiveSourceMetadata } from "./server-poll-terminal-archive.js"
import { PollStorageRefused, SERVER_POLL_STORAGE_POLICY as policy } from "./server-poll-storage-model.js"
import type { ProbeStorageSql } from "./server-probe-journal-maintenance.js"
const canonical = (value: unknown): string => value && typeof value === "object" ? Array.isArray(value) ? "[" + value.map(canonical).join(",") + "]"
: "{" + Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([name, item]) => JSON.stringify(name) + ":" + canonical(item)).join(",") + "}" : JSON.stringify(value)
const secret = Buffer.from("5c".repeat(32), "hex"), sha = (value: string) => createHash("sha256").update(value).digest("hex")
const proof = async (purpose: string, value: unknown) => createHmac("sha256", secret).update(purpose + canonical(value)).digest("hex")
const codec = createServerPollTerminalArchiveForTests({
encrypt: async value => { const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", secret, iv), ciphertext = Buffer.concat([cipher.update(value), cipher.final()]); return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]) },
decrypt: async value => { const cipher = createDecipheriv("aes-256-gcm", secret, value.subarray(0, 12)); cipher.setAuthTag(value.subarray(12, 28)); return Buffer.concat([cipher.update(value.subarray(28)), cipher.final()]) },
proof,
verifySource: async source => source.admission.metadata as unknown as ServerPollArchiveSourceMetadata,
})
const id = (n: number) => "00000000-0000-4000-8000-" + n.toString(16).padStart(12, "0")
interface Entry { row: Record<string, unknown>; archive?: ServerPollTerminalArchive | ServerPollRetiredArchive }
const full = (n: number, owner: string): Entry => ({ row: { admission_receipt_id: id(n), identity_kind: "rest", admission_execution_id: randomUUID(), admission_connection_id: null,
admission_owner_id: owner, admission_binding_proof: "a".repeat(64), admission_context_proof: "b".repeat(64), storage_state: "full", compact_at: null,
admission_archive_bytes: null, admission_activity_at: null, receipt_id: null, has_children: true } })
async function compact(n: number, owner: string): Promise<Entry> {
const metadata: ServerPollArchiveSourceMetadata = { receiptId: id(n), executionId: randomUUID(), connectionId: randomUUID(), ownerId: owner,
bindingProof: "a".repeat(64), contextProof: "b".repeat(64), originalActivityAt: "2026-10-29T01:00:00.123456Z", originalExpiresAt: "2026-10-29T01:00:00.123000Z", retentionDays: 30, safetyMs: 60000, terminalDigest: "c".repeat(64) }
const archive = await codec.create(JSON.stringify({ admission: { metadata }, journal: { execution: {}, intents: [], acks: [], receipt: {}, quarantines: [] }, terminal: { operation: {}, plan: {}, step: {} } }), 1)
assert(archive)
const { ciphertext: _ciphertext, ...columns } = archive
return { archive, row: { ...columns, admission_receipt_id: archive.receipt_id, identity_kind: "mcp", admission_execution_id: archive.execution_id,
admission_connection_id: archive.connection_id, admission_owner_id: archive.owner_id, admission_binding_proof: archive.binding_proof, admission_context_proof: archive.context_proof,
storage_state: "compacted", compact_at: new Date("2026-10-29T02:00:00Z"), admission_archive_bytes: archive.ciphertext_bytes,
admission_activity_at: archive.original_activity_at, admission_expires_at: archive.original_expires_at, asserted_ciphertext_bytes: archive.ciphertext_bytes, has_children: false } }
}
function fixture(entries: Entry[], retiredCharge?: Parameters<typeof assertServerPollArchiveAccountingForTests>[2]) {
const totals = new Map<string, { heavy: number; compact: number; archiveBytes: number; bytes: number; rows: number }>()
for (const entry of entries) {
for (const scope of ["global", "owner:" + entry.row.admission_owner_id]) {
const total = totals.get(scope) ?? { heavy: 0, compact: 0, archiveBytes: 0, bytes: 0, rows: 0 }
if (entry.row.storage_state === "full") { total.heavy++; total.bytes += 8 * 1024 * 1024 + 32768; total.rows += 4019 }
else { total.compact++; total.archiveBytes += entry.archive!.ciphertext_bytes; total.bytes += entry.archive!.ciphertext_bytes + 32768; total.rows += 2 }
totals.set(scope, total)
}
}
if (!totals.has("global")) totals.set("global", { heavy: 0, compact: 0, archiveBytes: 0, bytes: 0, rows: 0 })
const usage: Record<string, unknown>[] = [...totals].map(([scope, total]) => ({ scope, admissions: total.heavy, compacted_admissions: total.compact, archive_bytes: String(total.archiveBytes), reserved_rows: total.rows, reserved_bytes: String(total.bytes) }))
const state = { orphan: false, duplicate: false }, queries: string[] = [], batches: number[] = [], usageBatches: number[] = []
let factories = 0, charges = 0
const sql: ProbeStorageSql = { query: async (text, args = []) => {
queries.push(text)
if (text.startsWith("SELECT a.receipt_id AS admission_receipt_id")) {
assert(text.endsWith("LIMIT 128")); assert(text.includes("octet_length(r.ciphertext) AS ciphertext_bytes"))
assert(text.includes("sha256(pg_catalog.convert_to(r.ciphertext,'UTF8'))"))
assert(!/(?:SELECT|,)\s*r\.ciphertext\s*(?:AS\s+ciphertext\b|,|FROM)/i.test(text), "No full ciphertext payload may cross the JS boundary")
assert(!text.includes("r.*")); assert(!text.includes("SELECT *"))
const cursor = args[0] as string | null
const rows = entries.filter(entry => cursor === null || String(entry.row.admission_receipt_id) > cursor).sort((a, b) => String(a.row.admission_receipt_id).localeCompare(String(b.row.admission_receipt_id))).slice(0, 128).map(entry => {
const row = structuredClone(entry.row)
if (entry.archive) { row.ciphertext_bytes = Buffer.byteLength(entry.archive.ciphertext); row.ciphertext_sha256 = sha(entry.archive.ciphertext) }
assert.equal(Object.hasOwn(row, "ciphertext"), false)
return row
})
if (state.duplicate && rows.length >= 3) rows[2] = structuredClone(rows[0])
batches.push(rows.length); return { rows }
}
if (text.startsWith("SELECT r.receipt_id FROM server_poll_archives")) return { rows: state.orphan ? [{ receipt_id: id(999999) }] : [] }
assert(text.startsWith("SELECT scope,admissions,compacted_admissions,archive_bytes,reserved_rows,reserved_bytes"))
assert(text.endsWith("LIMIT 128 FOR SHARE"))
const cursor = args[0] as string | null, rows = usage.filter(row => cursor === null || String(row.scope) > cursor).sort((a, b) => String(a.scope) < String(b.scope) ? -1 : 1).slice(0, 128)
usageBatches.push(rows.length); return { rows: structuredClone(rows) }
} }
return { entries, usage, state, queries, batches, usageBatches, counts: () => ({ factories, charges }), run: () => assertServerPollArchiveAccountingForTests(sql, async () => {
factories++; return async row => { charges++; return codec.verifyCharge(row) }
}, retiredCharge) }
}
const refused = (work: Promise<unknown>) => assert.rejects(work, PollStorageRefused)
test("mixed V1/V2 allocations authenticate actual stored ciphertext and never fall back across formats", async () => {
const { createServerPollRetiredArchiveForTests } = await import("./server-poll-retired-archive.js")
const unexpected = async () => { throw new Error("Metadata accounting must not open or write archives") }
const v2 = createServerPollRetiredArchiveForTests({
proof, decrypt: unexpected, encrypt: unexpected, openV1: unexpected, verifyHistory: unexpected,
})
const first = await compact(1, "owner"), second = await compact(2, "owner")
const { archive_proof: _v1Proof, ciphertext, ...previous } = second.archive!
const metadata = { ...previous, version: 2 as const }
const archiveProof = await proof("server-poll-retired-archive-record-v2", { ...metadata, identity_kind: "mcp",
storage_state: "compacted", admission_bytes: 32768, charged_rows: 2 })
second.archive = { ...metadata, ciphertext, archive_proof: archiveProof }
Object.assign(second.row, { version: 2, archive_proof: archiveProof })
const f = fixture([first, second], v2.verifyCharge)
await f.run()
assert.deepEqual(f.counts(), { factories: 1, charges: 1 })
second.archive.ciphertext = "A" + ciphertext.slice(1)
await refused(f.run())
assert.deepEqual(f.counts(), { factories: 2, charges: 2 })
second.archive.ciphertext = ciphertext
second.row.version = 3
await refused(f.run())
assert.deepEqual(f.counts(), { factories: 3, charges: 3 })
})
test("empty/global0 and full128 ledgers use complete exact global/owner counters without archive crypto", async () => {
const empty = fixture([]); await empty.run(); assert.deepEqual(empty.counts(), { factories: 0, charges: 0 })
const f = fixture(Array.from({ length: 128 }, (_, n) => full(n + 1, "owner-" + Math.floor(n / 8))))
await f.run(); assert.deepEqual(f.batches, [128, 0]); assert.deepEqual(f.counts(), { factories: 0, charges: 0 })
assert.equal(Number(f.usage.find(row => row.scope === "global")!.reserved_bytes), policy.globalBytes)
})
test("compact336 plus full reservations and multiowner usage paginate in128-row bounded metadata batches", async () => {
const entries = await Promise.all(Array.from({ length: 336 }, (_, n) => compact(n + 1, "compact-owner-" + n)))
entries.push(...Array.from({ length: 8 }, (_, n) => full(1000 + n, "full-owner")))
const f = fixture(entries); await f.run()
assert.deepEqual(f.batches, [128, 128, 88]); assert.deepEqual(f.usageBatches, [128, 128, 82])
assert.deepEqual(f.counts(), { factories: 1, charges: 336 })
})
test("forged full/compacted flags, lowering counters, missing global/owner usage and orphan archive cannot create credit", async () => {
for (const fault of ["full-flag", "compacted-flag", "heavy-counter", "compact-counter", "bytes-counter", "rows-counter", "missing-global", "missing-owner", "orphan", "children"] as const) {
const f = fixture([await compact(1, "owner-a"), full(2, "owner-b")])
if (fault === "full-flag") f.entries[0].row.storage_state = "full"
if (fault === "compacted-flag") f.entries[1].row.storage_state = "compacted"
if (fault === "heavy-counter") f.usage[0].admissions = 0
if (fault === "compact-counter") f.usage[0].compacted_admissions = 0
if (fault === "bytes-counter") f.usage[0].reserved_bytes = Number(f.usage[0].reserved_bytes) - 1
if (fault === "rows-counter") f.usage[0].reserved_rows = Number(f.usage[0].reserved_rows) - 1
if (fault === "missing-global") f.usage.splice(f.usage.findIndex(row => row.scope === "global"), 1)
if (fault === "missing-owner") f.usage.splice(f.usage.findIndex(row => row.scope === "owner:owner-a"), 1)
if (fault === "orphan") f.state.orphan = true
if (fault === "children") f.entries[0].row.has_children = true
await refused(f.run())
}
})
test("invalid MAC and ACTUAL ciphertext SHA/length changes refuse despite unchanged stored assertions", async () => {
for (const fault of ["mac", "hash", "size", "asserted-size", "admission-size"] as const) {
const f = fixture([await compact(1, "owner")]), row = f.entries[0].row, archive = f.entries[0].archive!
if (fault === "mac") row.archive_proof = "d".repeat(64)
if (fault === "hash") archive.ciphertext = archive.ciphertext.replace(/^./, letter => letter === "A" ? "B" : "A")
if (fault === "size") archive.ciphertext += "AAAA"
if (fault === "asserted-size") row.asserted_ciphertext_bytes = Number(row.asserted_ciphertext_bytes) + 1
if (fault === "admission-size") row.admission_archive_bytes = Number(row.admission_archive_bytes) + 1
await refused(f.run())
}
})
test("archive/admission identity, binding, context, timestamps and retention metadata must match the MAC", async () => {
for (const [field, value] of Object.entries({ identity_kind: "rest", admission_execution_id: randomUUID(), admission_connection_id: randomUUID(), admission_owner_id: "foreign",
admission_binding_proof: "e".repeat(64), admission_context_proof: "e".repeat(64), admission_activity_at: "2026-10-29T01:00:00.123457Z", admission_expires_at: "2026-10-29T01:00:00.123001Z", retention_days: 31, safety_ms: 60001 })) {
const f = fixture([await compact(1, "owner")]); f.entries[0].row[field] = value; await refused(f.run())
}
})
test("full heavy-count global129 and owner9 refuse even if usage counters claim conservation", async () => {
await refused(fixture(Array.from({ length: 129 }, (_, n) => full(n + 1, "owner-" + Math.floor(n / 8)))).run())
await refused(fixture(Array.from({ length: 9 }, (_, n) => full(n + 1, "one-owner"))).run())
})
test("compacted rows still pay retained bytes and cannot share an already fully charged owner", async () => {
const f = fixture([...Array.from({ length: 8 }, (_, n) => full(n + 1, "owner")), await compact(100, "owner")])
await refused(f.run())
})
test("nonadjacent duplicate receipt IDs cannot count twice even when cardinality/owner counters agree", async () => {
const f = fixture([full(1, "owner"), full(2, "owner"), full(3, "owner")])
f.state.duplicate = true; await refused(f.run())
})
test("compacted timestamp must be an actual finite SQL timestamp", async () => {
for (const value of ["not-a-date", new Date(NaN), undefined, 0]) {
const f = fixture([await compact(1, "owner")]); f.entries[0].row.compact_at = value; await refused(f.run())
}
})
test("retained archive bytes can exhaust global budget before heavy-count or row-count limits", async () => {
const compacts = await Promise.all(Array.from({ length: 336 }, (_, n) => compact(1000 + n, "archive-owner-" + n)))
const f = fixture([...Array.from({ length: 127 }, (_, n) => full(n + 1, "full-owner-" + Math.floor(n / 8))), ...compacts])
const global = f.usage.find(row => row.scope === "global")!
assert.equal(global.admissions, 127); assert(Number(global.reserved_rows) < policy.globalRows); assert(Number(global.reserved_bytes) > policy.globalBytes)
await refused(f.run())
})
test("invalid numeric counters cannot hide through coercion or extra scopes", async () => {
for (const value of ["01", "-1", -0, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const f = fixture([]); f.usage[0].reserved_bytes = value; await refused(f.run())
}
const f = fixture([]); f.usage.push({ ...f.usage[0], scope: "foreign-scope" }); await refused(f.run())
})