83 lines
4.3 KiB
TypeScript
83 lines
4.3 KiB
TypeScript
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 = `<table><tr><th>Auth Username</th></tr><tr>${[
|
|
"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 => `<td>${value}</td>`).join("")}</tr></table>`;
|
|
const threads = '<table><tr><th>Approx. Hashrate</th></tr><tr><td>0</td><td>1</td><td>1</td><td>1.2 Th/s</td></tr></table>';
|
|
|
|
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);
|
|
});
|