import { after, test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const dir = mkdtempSync(join(tmpdir(), "gashboard-recovery-")); after(() => rmSync(dir, { recursive: true, force: true })); process.env.JWT_SECRET = "synthetic-jwt-secret-at-least-32-characters"; process.env.DATUM_ADMIN_PASSWORD = "synthetic-datum-password"; process.env.NOSTR_OWNER_PUBKEYS = "a".repeat(64); process.env.DATUM_URL = "http://datum:7152"; process.env.MEMPOOL_API_URL = "https://mempool.invalid/api"; process.env.CONTRIBUTION_LEDGER_PATH = join(dir, "ledger.json"); process.env.LOG_LEVEL = "silent"; // Legacy offline ledger names must not reappear as payout addresses after // startup, and renaming must preserve the original contribution accounting. writeFileSync(process.env.CONTRIBUTION_LEDGER_PATH, JSON.stringify({ version: 1, entries: [ ["offline.fixture", { miner: { authUsername: "offline.fixture", nickname: "old-payout-address", userAgent: "nerdqaxe/fixture" }, lastRawAcceptedWork: 123, lastRawAcceptedShares: 1, totalAcceptedWork: 123, totalAcceptedShares: 1, firstSeenAt: 1, lastSeenAt: 2 }], ] })); const originalFetch = globalThis.fetch; after(() => { globalThis.fetch = originalFetch; }); const { getSnapshot, pollNow } = await import("../src/datum/poller.js"); const clients = `${[ "0", "192.0.2.3", "payout.fixture", "Subscribed", "2 s", "16384", "32768 (2)", "0 (0) 0%", "1.2 Th/s (1 s)", "", "bitaxe/BM1368", ].map(value => ``).join("")}
Auth Username
${value}
`; const threads = '
Approx. Hashrate
0111.2 Th/s
'; test("DATUM outage fails closed, recovery reauthenticates, and later failure retains the last data", async () => { globalThis.fetch = async input => { const url = String(input); if (url.endsWith("/clients")) throw Object.assign(new TypeError("synthetic outage"), { cause: Object.assign(new Error("not found"), { code: "ENOTFOUND", hostname: "datum" }) }); return new Response("", { status: 503 }); }; const failed = await pollNow(); assert.equal(failed.ok, false); assert.equal(failed.error?.code, "DATUM_UNREACHABLE"); assert.equal(getSnapshot(), failed); let clientsCalls = 0; globalThis.fetch = async (input, init) => { const url = String(input); if (url.endsWith("/clients")) { clientsCalls += 1; const auth = new Headers(init?.headers).get("authorization"); if (!auth) return new Response("auth", { status: 401, headers: { "www-authenticate": 'Digest realm="datum", nonce="fixture", algorithm=SHA-256, qop="auth"' } }); assert.match(auth, /^Digest /); assert.doesNotMatch(auth, /synthetic-datum-password/); return new Response(clients, { status: 200 }); } if (url.endsWith("/threads")) return new Response(threads, { status: 200 }); if (url.endsWith("/blocks/tip/height")) return new Response("900001", { status: 200 }); if (url.endsWith("/v1/mining/hashrate/3d")) return Response.json({ currentHashrate: 600e18, currentDifficulty: 100 }); throw new Error(`unexpected synthetic URL ${url}`); }; const recovered = await pollNow(); assert.equal(clientsCalls, 2); assert.equal(recovered.ok, true); assert.equal(recovered.pool.connectionStatus, "ok"); assert.equal(recovered.miners[0]?.authUsername, "payout.fixture"); assert.equal(recovered.miners[0]?.nickname, "bitaxe/BM1368"); const offline = recovered.miners.find(miner => miner.authUsername === "offline.fixture"); assert.equal(offline?.nickname, "nerdqaxe/fixture"); assert.equal(offline?.contributionAcceptedWork, 123); assert.equal(offline?.currentlyConnected, false); assert.equal(recovered.network.blockHeight, 900001); assert.doesNotMatch(JSON.stringify(recovered), /synthetic-datum-password/); globalThis.fetch = async input => { if (String(input).endsWith("/clients")) throw new Error("synthetic restart window"); return new Response("", { status: 503 }); }; const interrupted = await pollNow(); assert.equal(interrupted.ok, false); assert.equal(interrupted.error?.code, "DATUM_UNREACHABLE"); assert.equal(interrupted.miners[0]?.authUsername, "payout.fixture"); assert.equal(interrupted.network.blockHeight, 900001); });