@mikeargento/bitgraph 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -40
- package/dist/__tests__/audit-anchors.test.d.ts +2 -0
- package/dist/__tests__/audit-anchors.test.d.ts.map +1 -0
- package/dist/__tests__/audit-anchors.test.js +152 -0
- package/dist/__tests__/audit-anchors.test.js.map +1 -0
- package/dist/__tests__/audit-anomalies.test.d.ts +2 -0
- package/dist/__tests__/audit-anomalies.test.d.ts.map +1 -0
- package/dist/__tests__/audit-anomalies.test.js +663 -0
- package/dist/__tests__/audit-anomalies.test.js.map +1 -0
- package/dist/__tests__/audit-attestation.test.d.ts +2 -0
- package/dist/__tests__/audit-attestation.test.d.ts.map +1 -0
- package/dist/__tests__/audit-attestation.test.js +403 -0
- package/dist/__tests__/audit-attestation.test.js.map +1 -0
- package/dist/__tests__/audit-bench-smoke.test.d.ts +2 -0
- package/dist/__tests__/audit-bench-smoke.test.d.ts.map +1 -0
- package/dist/__tests__/audit-bench-smoke.test.js +87 -0
- package/dist/__tests__/audit-bench-smoke.test.js.map +1 -0
- package/dist/__tests__/audit-boundary.test.d.ts +20 -0
- package/dist/__tests__/audit-boundary.test.d.ts.map +1 -0
- package/dist/__tests__/audit-boundary.test.js +84 -0
- package/dist/__tests__/audit-boundary.test.js.map +1 -0
- package/dist/__tests__/audit-cli.test.d.ts +2 -0
- package/dist/__tests__/audit-cli.test.d.ts.map +1 -0
- package/dist/__tests__/audit-cli.test.js +239 -0
- package/dist/__tests__/audit-cli.test.js.map +1 -0
- package/dist/__tests__/audit-dependencies.test.d.ts +2 -0
- package/dist/__tests__/audit-dependencies.test.d.ts.map +1 -0
- package/dist/__tests__/audit-dependencies.test.js +116 -0
- package/dist/__tests__/audit-dependencies.test.js.map +1 -0
- package/dist/__tests__/audit-fixtures.d.ts +265 -0
- package/dist/__tests__/audit-fixtures.d.ts.map +1 -0
- package/dist/__tests__/audit-fixtures.js +511 -0
- package/dist/__tests__/audit-fixtures.js.map +1 -0
- package/dist/__tests__/audit-ingest.test.d.ts +2 -0
- package/dist/__tests__/audit-ingest.test.d.ts.map +1 -0
- package/dist/__tests__/audit-ingest.test.js +473 -0
- package/dist/__tests__/audit-ingest.test.js.map +1 -0
- package/dist/__tests__/audit-memory-ingest.test.d.ts +12 -0
- package/dist/__tests__/audit-memory-ingest.test.d.ts.map +1 -0
- package/dist/__tests__/audit-memory-ingest.test.js +120 -0
- package/dist/__tests__/audit-memory-ingest.test.js.map +1 -0
- package/dist/__tests__/audit-property.test.d.ts +2 -0
- package/dist/__tests__/audit-property.test.d.ts.map +1 -0
- package/dist/__tests__/audit-property.test.js +771 -0
- package/dist/__tests__/audit-property.test.js.map +1 -0
- package/dist/__tests__/audit-real-fixtures.test.d.ts +2 -0
- package/dist/__tests__/audit-real-fixtures.test.d.ts.map +1 -0
- package/dist/__tests__/audit-real-fixtures.test.js +132 -0
- package/dist/__tests__/audit-real-fixtures.test.js.map +1 -0
- package/dist/__tests__/audit-real-live.test.d.ts +2 -0
- package/dist/__tests__/audit-real-live.test.d.ts.map +1 -0
- package/dist/__tests__/audit-real-live.test.js +107 -0
- package/dist/__tests__/audit-real-live.test.js.map +1 -0
- package/dist/__tests__/audit-reconstruct.test.d.ts +2 -0
- package/dist/__tests__/audit-reconstruct.test.d.ts.map +1 -0
- package/dist/__tests__/audit-reconstruct.test.js +287 -0
- package/dist/__tests__/audit-reconstruct.test.js.map +1 -0
- package/dist/__tests__/audit-report.test.d.ts +2 -0
- package/dist/__tests__/audit-report.test.d.ts.map +1 -0
- package/dist/__tests__/audit-report.test.js +186 -0
- package/dist/__tests__/audit-report.test.js.map +1 -0
- package/dist/__tests__/audit-roundtrip.test.d.ts +2 -0
- package/dist/__tests__/audit-roundtrip.test.d.ts.map +1 -0
- package/dist/__tests__/audit-roundtrip.test.js +368 -0
- package/dist/__tests__/audit-roundtrip.test.js.map +1 -0
- package/dist/__tests__/audit-temporal.test.d.ts +2 -0
- package/dist/__tests__/audit-temporal.test.d.ts.map +1 -0
- package/dist/__tests__/audit-temporal.test.js +364 -0
- package/dist/__tests__/audit-temporal.test.js.map +1 -0
- package/dist/__tests__/audit-tiers.test.d.ts +2 -0
- package/dist/__tests__/audit-tiers.test.d.ts.map +1 -0
- package/dist/__tests__/audit-tiers.test.js +282 -0
- package/dist/__tests__/audit-tiers.test.js.map +1 -0
- package/dist/__tests__/audit-witness-real.test.d.ts +19 -0
- package/dist/__tests__/audit-witness-real.test.d.ts.map +1 -0
- package/dist/__tests__/audit-witness-real.test.js +82 -0
- package/dist/__tests__/audit-witness-real.test.js.map +1 -0
- package/dist/__tests__/audit-witness.test.d.ts +2 -0
- package/dist/__tests__/audit-witness.test.d.ts.map +1 -0
- package/dist/__tests__/audit-witness.test.js +308 -0
- package/dist/__tests__/audit-witness.test.js.map +1 -0
- package/dist/__tests__/chain-hash-anchor.test.d.ts +22 -0
- package/dist/__tests__/chain-hash-anchor.test.d.ts.map +1 -0
- package/dist/__tests__/chain-hash-anchor.test.js +55 -0
- package/dist/__tests__/chain-hash-anchor.test.js.map +1 -0
- package/dist/__tests__/declared-proof-fixture.d.ts +66 -0
- package/dist/__tests__/declared-proof-fixture.d.ts.map +1 -0
- package/dist/__tests__/declared-proof-fixture.js +66 -0
- package/dist/__tests__/declared-proof-fixture.js.map +1 -0
- package/dist/__tests__/mock-proof-fixture.d.ts +15 -0
- package/dist/__tests__/mock-proof-fixture.d.ts.map +1 -0
- package/dist/__tests__/mock-proof-fixture.js +34 -0
- package/dist/__tests__/mock-proof-fixture.js.map +1 -0
- package/dist/__tests__/proof-hash-regression.test.js +4 -43
- package/dist/__tests__/proof-hash-regression.test.js.map +1 -1
- package/dist/__tests__/proof-hash.test.js +3 -21
- package/dist/__tests__/proof-hash.test.js.map +1 -1
- package/dist/__tests__/proof-integrity.test.d.ts +2 -0
- package/dist/__tests__/proof-integrity.test.d.ts.map +1 -0
- package/dist/__tests__/proof-integrity.test.js +426 -0
- package/dist/__tests__/proof-integrity.test.js.map +1 -0
- package/dist/__tests__/realistic-proof-fixture.d.ts +56 -0
- package/dist/__tests__/realistic-proof-fixture.d.ts.map +1 -0
- package/dist/__tests__/realistic-proof-fixture.js +57 -0
- package/dist/__tests__/realistic-proof-fixture.js.map +1 -0
- package/dist/__tests__/verifier.test.js +74 -0
- package/dist/__tests__/verifier.test.js.map +1 -1
- package/package.json +10 -5
- package/src/__tests__/audit-anchors.test.ts +188 -0
- package/src/__tests__/audit-anomalies.test.ts +815 -0
- package/src/__tests__/audit-attestation.test.ts +529 -0
- package/src/__tests__/audit-bench-smoke.test.ts +108 -0
- package/src/__tests__/audit-boundary.test.ts +96 -0
- package/src/__tests__/audit-cli.test.ts +288 -0
- package/src/__tests__/audit-dependencies.test.ts +141 -0
- package/src/__tests__/audit-fixtures.ts +736 -0
- package/src/__tests__/audit-ingest.test.ts +575 -0
- package/src/__tests__/audit-memory-ingest.test.ts +140 -0
- package/src/__tests__/audit-property.test.ts +892 -0
- package/src/__tests__/audit-real-fixtures.test.ts +153 -0
- package/src/__tests__/audit-real-live.test.ts +128 -0
- package/src/__tests__/audit-reconstruct.test.ts +353 -0
- package/src/__tests__/audit-report.test.ts +222 -0
- package/src/__tests__/audit-roundtrip.test.ts +464 -0
- package/src/__tests__/audit-temporal.test.ts +446 -0
- package/src/__tests__/audit-tiers.test.ts +343 -0
- package/src/__tests__/audit-witness-real.test.ts +92 -0
- package/src/__tests__/audit-witness.test.ts +345 -0
- package/src/__tests__/chain-hash-anchor.test.ts +67 -0
- package/src/__tests__/declared-proof-fixture.ts +65 -0
- package/src/__tests__/mock-proof-fixture.ts +36 -0
- package/src/__tests__/proof-hash-regression.test.ts +4 -44
- package/src/__tests__/proof-hash.test.ts +3 -22
- package/src/__tests__/proof-integrity.test.ts +541 -0
- package/src/__tests__/real-fixtures/anchor-after-112762.json +47 -0
- package/src/__tests__/real-fixtures/anchor-with-ethereum-112758.json +47 -0
- package/src/__tests__/real-fixtures/live-proof-0.json +42 -0
- package/src/__tests__/real-fixtures/live-proof-1.json +43 -0
- package/src/__tests__/real-fixtures/live-proof-2.json +43 -0
- package/src/__tests__/real-fixtures/successor-112760.json +38 -0
- package/src/__tests__/real-fixtures/witness-25525146.json +6 -0
- package/src/__tests__/real-fixtures/witness-25525147.json +6 -0
- package/src/__tests__/realistic-proof-fixture.ts +57 -0
- package/src/__tests__/verifier.test.ts +83 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Copyright (c) Mike Argento. All rights reserved. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the Phase 4d audit orchestrator and report generators:
|
|
5
|
+
* runAudit end-to-end over the standard mixed bundle, JSON report with
|
|
6
|
+
* stable codes and correct counts, deterministic double-run equality
|
|
7
|
+
* (runMetadata stripped), executive-summary language in the brief's
|
|
8
|
+
* style (two-position gap wording, no-winner divergence wording), and
|
|
9
|
+
* zero em dash characters in all generated output.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, before, after } from "node:test";
|
|
13
|
+
import * as assert from "node:assert/strict";
|
|
14
|
+
import { rm } from "node:fs/promises";
|
|
15
|
+
import {
|
|
16
|
+
buildJsonReport,
|
|
17
|
+
buildMarkdownReport,
|
|
18
|
+
computeExitFlags,
|
|
19
|
+
runAudit,
|
|
20
|
+
} from "@mikeargento/bitgraph-audit";
|
|
21
|
+
import type { AuditJsonReport, AuditResult } from "@mikeargento/bitgraph-audit";
|
|
22
|
+
import { makeStandardAuditBundle, type StandardAuditBundle } from "./audit-fixtures.js";
|
|
23
|
+
|
|
24
|
+
const EM_DASH = "—";
|
|
25
|
+
|
|
26
|
+
describe("audit orchestrator and reports: standard mixed bundle", () => {
|
|
27
|
+
let bundle: StandardAuditBundle;
|
|
28
|
+
let result: AuditResult;
|
|
29
|
+
let report: AuditJsonReport;
|
|
30
|
+
let markdown: string;
|
|
31
|
+
|
|
32
|
+
before(async () => {
|
|
33
|
+
bundle = await makeStandardAuditBundle();
|
|
34
|
+
result = await runAudit(bundle.dir);
|
|
35
|
+
report = buildJsonReport(result);
|
|
36
|
+
markdown = buildMarkdownReport(result);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
after(async () => {
|
|
40
|
+
await rm(bundle.dir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("runs the full pipeline end-to-end", () => {
|
|
44
|
+
assert.equal(result.ingest.container, "directory");
|
|
45
|
+
assert.equal(result.runMetadata.bundlePath, bundle.dir);
|
|
46
|
+
assert.equal(typeof result.runMetadata.toolVersion, "string");
|
|
47
|
+
assert.ok(result.runMetadata.startedAt.includes("T"));
|
|
48
|
+
|
|
49
|
+
// Two partitions: the main chain and the anchor's own epoch.
|
|
50
|
+
assert.equal(result.reconstruction.partitions.length, 2);
|
|
51
|
+
|
|
52
|
+
// Verification tiers.
|
|
53
|
+
assert.equal(result.verification.verified, bundle.expected.verified);
|
|
54
|
+
assert.equal(result.verification.failed, bundle.expected.failed);
|
|
55
|
+
assert.equal(result.verification.artifactUnavailable, bundle.expected.artifactUnavailable);
|
|
56
|
+
assert.equal(result.verification.chainless, bundle.expected.chainless);
|
|
57
|
+
|
|
58
|
+
// Chain anomalies: gap, break, fork.
|
|
59
|
+
const codes = result.anomalies.anomalies.map((a) => a.code).sort();
|
|
60
|
+
assert.deepEqual(codes, [
|
|
61
|
+
"chain-break-missing",
|
|
62
|
+
"predecessor-reuse",
|
|
63
|
+
"unexplained-counter-positions",
|
|
64
|
+
]);
|
|
65
|
+
assert.equal(result.anomalies.divergences.length, 1);
|
|
66
|
+
assert.equal(result.anomalies.divergences[0]?.kind, "predecessor-reuse");
|
|
67
|
+
const partyHashes = result.anomalies.divergences[0]?.parties.map((p) => p.proofHash).sort();
|
|
68
|
+
assert.deepEqual(partyHashes, [bundle.forkAProofHash, bundle.forkBProofHash].sort());
|
|
69
|
+
|
|
70
|
+
// The gap is exactly the dropped proof's two positions (slot 3, commit 4).
|
|
71
|
+
const gap = result.anomalies.anomalies.find(
|
|
72
|
+
(a) => a.code === "unexplained-counter-positions"
|
|
73
|
+
);
|
|
74
|
+
assert.deepEqual((gap?.details as { positions: string[] }).positions, ["3", "4"]);
|
|
75
|
+
|
|
76
|
+
// Authority analysis is quiet; attestation records exist without documents.
|
|
77
|
+
assert.equal(result.authorities.anomalies.length, 0);
|
|
78
|
+
assert.equal(result.attestations.records.length, 6);
|
|
79
|
+
assert.equal(result.attestations.counts.proofsWithDocument, 0);
|
|
80
|
+
|
|
81
|
+
// The anchor was identified and its witness verified with the header timestamp.
|
|
82
|
+
assert.deepEqual(result.temporal.verifiedAnchorProofHashes, [bundle.anchorProofHash]);
|
|
83
|
+
const outcome = result.witnesses.outcomes.find((o) => o.verified);
|
|
84
|
+
assert.equal(outcome?.anchorProofHash, bundle.anchorProofHash);
|
|
85
|
+
assert.equal(outcome?.timestamp, bundle.blockTimestamp);
|
|
86
|
+
assert.equal(outcome?.blockNumber, String(bundle.blockNumber));
|
|
87
|
+
|
|
88
|
+
// Exit flags: unsupported-version sets bit 1, anomalies set bit 2.
|
|
89
|
+
assert.deepEqual(computeExitFlags(result), {
|
|
90
|
+
verificationFailures: true,
|
|
91
|
+
chainAnomaliesOrDivergences: true,
|
|
92
|
+
code: 3,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("JSON report carries the schema version, stable codes, and correct counts", () => {
|
|
97
|
+
assert.equal(report.reportSchemaVersion, "bitgraph-audit-report/2");
|
|
98
|
+
assert.equal(report.toolVersion, result.runMetadata.toolVersion);
|
|
99
|
+
assert.equal(report.runMetadata.nondeterministic, true);
|
|
100
|
+
|
|
101
|
+
assert.equal(report.input.counts.observed, bundle.expected.observed);
|
|
102
|
+
assert.equal(report.input.counts.proofFiles, bundle.expected.proofFiles);
|
|
103
|
+
assert.equal(report.input.counts.exactDuplicates, bundle.expected.exactDuplicates);
|
|
104
|
+
assert.equal(report.input.counts.semanticDuplicates, bundle.expected.semanticDuplicates);
|
|
105
|
+
assert.equal(report.input.counts.unsupportedVersion, bundle.expected.unsupportedVersion);
|
|
106
|
+
assert.equal(report.input.counts.verified, bundle.expected.verified);
|
|
107
|
+
assert.equal(report.input.counts.failed, bundle.expected.failed);
|
|
108
|
+
assert.equal(report.input.counts.artifactUnavailable, bundle.expected.artifactUnavailable);
|
|
109
|
+
assert.equal(report.input.counts.chainless, bundle.expected.chainless);
|
|
110
|
+
|
|
111
|
+
// Machine consumers key on stable codes, never prose.
|
|
112
|
+
const codes = report.anomalies.map((a) => a.code);
|
|
113
|
+
for (const expected of [
|
|
114
|
+
"unsupported-version",
|
|
115
|
+
"exact-duplicate",
|
|
116
|
+
"semantic-duplicate",
|
|
117
|
+
"unexplained-counter-positions",
|
|
118
|
+
"chain-break-missing",
|
|
119
|
+
"predecessor-reuse",
|
|
120
|
+
]) {
|
|
121
|
+
assert.ok(codes.includes(expected), `anomaly list includes ${expected}`);
|
|
122
|
+
}
|
|
123
|
+
assert.equal(report.anomalies.length, 6);
|
|
124
|
+
assert.deepEqual(report.summary.anomalyCountsByCode, {
|
|
125
|
+
"chain-break-missing": 1,
|
|
126
|
+
"exact-duplicate": 1,
|
|
127
|
+
"predecessor-reuse": 1,
|
|
128
|
+
"semantic-duplicate": 1,
|
|
129
|
+
"unexplained-counter-positions": 1,
|
|
130
|
+
"unsupported-version": 1,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Per-proof records: sorted by canonical hash, compact fields only.
|
|
134
|
+
const hashes = report.proofs.map((p) => p.proofHash);
|
|
135
|
+
assert.deepEqual(hashes, [...hashes].sort());
|
|
136
|
+
assert.equal(report.proofs.length, bundle.expected.observed);
|
|
137
|
+
assert.ok(!("proof" in (report.proofs[0] as object)), "raw proof objects never serialized");
|
|
138
|
+
|
|
139
|
+
// The stored-form copy is cross-checked, never trusted. Its embedded
|
|
140
|
+
// proofHash matched, so no mismatch escalation and no finding; the
|
|
141
|
+
// record keeps the first observed copy's "absent" status per ingest
|
|
142
|
+
// semantics (only a mismatch on any copy escalates).
|
|
143
|
+
const genesis = report.proofs.find((p) => p.proofHash === bundle.chainProofHashes[0]);
|
|
144
|
+
assert.notEqual(genesis?.embeddedProofHash, "mismatch");
|
|
145
|
+
assert.ok(!codes.includes("proofhash-mismatch"));
|
|
146
|
+
assert.equal(genesis?.sources.length, 3);
|
|
147
|
+
assert.equal(genesis?.verificationStatus, "verified");
|
|
148
|
+
|
|
149
|
+
// Partition intactness: main chain broken, anchor partition intact.
|
|
150
|
+
assert.equal(report.partitions.length, 2);
|
|
151
|
+
const mainPartition = report.partitions.find((p) => p.key.epochId === bundle.epochId);
|
|
152
|
+
const anchorPartition = report.partitions.find((p) => p.key.epochId === bundle.anchorEpochId);
|
|
153
|
+
assert.equal(mainPartition?.intact, false);
|
|
154
|
+
assert.equal(mainPartition?.components.length, 2);
|
|
155
|
+
assert.equal(anchorPartition?.intact, true);
|
|
156
|
+
assert.equal(report.summary.chainIntact, false);
|
|
157
|
+
assert.equal(report.summary.partitionsIntact, 1);
|
|
158
|
+
|
|
159
|
+
// Epoch relationships: two epochs, no ordering evidence between them.
|
|
160
|
+
assert.equal(report.summary.epochsObserved, 2);
|
|
161
|
+
assert.equal(report.epochRelationships.unorderedPairs.length, 1);
|
|
162
|
+
assert.equal(report.epochRelationships.orderedPairs.length, 0);
|
|
163
|
+
|
|
164
|
+
// Rejected input listed with path and offending version string.
|
|
165
|
+
assert.equal(report.unsupportedVersions.length, 1);
|
|
166
|
+
assert.equal(report.unsupportedVersions[0]?.path, bundle.unsupportedPath);
|
|
167
|
+
assert.equal(report.unsupportedVersions[0]?.version, "occ/1");
|
|
168
|
+
|
|
169
|
+
// Temporal summary: the anchor's own segment is lower-bounded, the rest unanchored.
|
|
170
|
+
assert.equal(report.summary.temporal.anchorsIdentified, 1);
|
|
171
|
+
assert.equal(report.summary.temporal.anchorsWithVerifiedWitness, 1);
|
|
172
|
+
assert.ok(report.summary.temporal.segmentsLowerBounded >= 1);
|
|
173
|
+
assert.ok(report.summary.temporal.segmentsUnanchored >= 1);
|
|
174
|
+
|
|
175
|
+
// Exit flags mirrored into the summary.
|
|
176
|
+
assert.deepEqual(report.summary.exit, {
|
|
177
|
+
verificationFailures: true,
|
|
178
|
+
chainAnomaliesOrDivergences: true,
|
|
179
|
+
code: 3,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("is deterministic across runs once runMetadata is stripped", async () => {
|
|
184
|
+
const resultA = await runAudit(bundle.dir);
|
|
185
|
+
const resultB = await runAudit(bundle.dir);
|
|
186
|
+
const reportA = buildJsonReport(resultA);
|
|
187
|
+
const reportB = buildJsonReport(resultB);
|
|
188
|
+
|
|
189
|
+
const { runMetadata: metaA, ...restA } = reportA;
|
|
190
|
+
const { runMetadata: metaB, ...restB } = reportB;
|
|
191
|
+
assert.deepEqual(restA, restB);
|
|
192
|
+
assert.equal(JSON.stringify(restA), JSON.stringify(restB));
|
|
193
|
+
|
|
194
|
+
// The markdown differs only in the wall-clock start time.
|
|
195
|
+
const mdA = buildMarkdownReport(resultA).replaceAll(metaA.startedAt, "STARTED_AT");
|
|
196
|
+
const mdB = buildMarkdownReport(resultB).replaceAll(metaB.startedAt, "STARTED_AT");
|
|
197
|
+
assert.equal(mdA, mdB);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("markdown executive summary uses the brief's gap and divergence language", () => {
|
|
201
|
+
assert.ok(markdown.includes("# BitGraph Audit Report"));
|
|
202
|
+
// G2 gap language: two-position wording, honest absence claim.
|
|
203
|
+
assert.ok(markdown.includes("neither commit positions nor referenced slot positions"));
|
|
204
|
+
assert.ok(markdown.includes("does not, by itself, prove"));
|
|
205
|
+
// Divergence: no winner is ever chosen.
|
|
206
|
+
assert.ok(markdown.includes("does not choose between them"));
|
|
207
|
+
// Rejected legacy input listed with its path and version string.
|
|
208
|
+
assert.ok(markdown.includes(bundle.unsupportedPath));
|
|
209
|
+
assert.ok(markdown.includes("occ/1"));
|
|
210
|
+
// Chain intactness stated per partition.
|
|
211
|
+
assert.ok(markdown.includes("Chain intact: no"));
|
|
212
|
+
assert.ok(markdown.includes("Chain intact: yes"));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("contains zero em dash characters in the markdown and the JSON", () => {
|
|
216
|
+
assert.ok(!markdown.includes(EM_DASH), "markdown contains no em dashes");
|
|
217
|
+
const json = JSON.stringify(report, null, 2);
|
|
218
|
+
assert.ok(!json.includes(EM_DASH), "JSON report contains no em dashes");
|
|
219
|
+
// The report never leaks signatures or raw proof bodies.
|
|
220
|
+
assert.ok(!json.includes('"signatureB64"'));
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// Copyright (c) Mike Argento. All rights reserved. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Export round trip (Phase 5, mandatory per the build brief): a synthetic
|
|
5
|
+
* epoch (real signed chain, an interleaved Ethereum anchor, and a valid
|
|
6
|
+
* offline witness) goes through the archive builder, the resulting .tar.gz
|
|
7
|
+
* goes through the full audit pipeline, and the report must come back
|
|
8
|
+
* completely clean. Also covers the open-epoch snapshot manifest round
|
|
9
|
+
* trip, builder determinism, and conformance of the website's independent
|
|
10
|
+
* export assembly (website/src/lib/export-epoch.ts, imported by a spawned
|
|
11
|
+
* node process under type stripping) against the reference builder,
|
|
12
|
+
* byte for byte.
|
|
13
|
+
*
|
|
14
|
+
* All data is synthetic and local. No network, no live ledger, ever.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, before } from "node:test";
|
|
18
|
+
import * as assert from "node:assert/strict";
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
26
|
+
import type { BitGraphProof } from "@mikeargento/bitgraph-verify";
|
|
27
|
+
import {
|
|
28
|
+
buildBundleArchive,
|
|
29
|
+
runAudit,
|
|
30
|
+
computeExitFlags,
|
|
31
|
+
buildJsonReport,
|
|
32
|
+
type AuditResult,
|
|
33
|
+
type BundleArchiveInput,
|
|
34
|
+
} from "@mikeargento/bitgraph-audit";
|
|
35
|
+
import {
|
|
36
|
+
b64,
|
|
37
|
+
utf8,
|
|
38
|
+
makeCounterChain,
|
|
39
|
+
makeAnchorProof,
|
|
40
|
+
makeEthereumHeader,
|
|
41
|
+
makeTempDir,
|
|
42
|
+
storedProofJson,
|
|
43
|
+
type CounterChainLink,
|
|
44
|
+
} from "./audit-fixtures.js";
|
|
45
|
+
|
|
46
|
+
const execFileAsync = promisify(execFile);
|
|
47
|
+
|
|
48
|
+
const EPOCH_ID = "epoch-roundtrip-1";
|
|
49
|
+
const CHAIN_ID = "bitgraph:main";
|
|
50
|
+
const MEASUREMENT = "test-measurement-roundtrip";
|
|
51
|
+
const GENERATED_AT = "2026-07-10T00:00:00Z";
|
|
52
|
+
const BLOCK_NUMBER = 987654;
|
|
53
|
+
const BLOCK_TIMESTAMP = 1_720_000_000;
|
|
54
|
+
|
|
55
|
+
const pad12 = (counter: string): string => counter.padStart(12, "0");
|
|
56
|
+
const toSafe = (value: string): string => value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
57
|
+
|
|
58
|
+
interface SyntheticEpoch {
|
|
59
|
+
/** The four user proofs, chain order. */
|
|
60
|
+
userProofs: CounterChainLink[];
|
|
61
|
+
/** The anchor proof, an ordinary member of the same chain (slot 5 / commit 6). */
|
|
62
|
+
anchor: { proof: BitGraphProof; proofHash: string };
|
|
63
|
+
blockHash: string;
|
|
64
|
+
witness: Record<string, unknown>;
|
|
65
|
+
/** All five members in chain order. */
|
|
66
|
+
members: Array<{ proof: BitGraphProof; proofHash: string }>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One epoch, one signer, one chain, enclave-shaped counters:
|
|
71
|
+
* user proofs at slot/commit 1/2 and 3/4, an Ethereum anchor at 5/6, then
|
|
72
|
+
* user proofs at 7/8 and 9/10, all prevB64-linked into one intact chain.
|
|
73
|
+
* The anchor's block hash is the Keccak-256 of a synthetic RLP header, so
|
|
74
|
+
* the witness verifies fully offline.
|
|
75
|
+
*/
|
|
76
|
+
async function makeSyntheticEpoch(): Promise<SyntheticEpoch> {
|
|
77
|
+
const part1 = await makeCounterChain({
|
|
78
|
+
epochId: EPOCH_ID,
|
|
79
|
+
pairs: [
|
|
80
|
+
{ slot: "1", commit: "2" },
|
|
81
|
+
{ slot: "3", commit: "4" },
|
|
82
|
+
],
|
|
83
|
+
chainId: CHAIN_ID,
|
|
84
|
+
measurement: MEASUREMENT,
|
|
85
|
+
payloadPrefix: "rt-part1",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const { headerBytes, headerRlpHex } = makeEthereumHeader({
|
|
89
|
+
blockNumber: BLOCK_NUMBER,
|
|
90
|
+
timestamp: BLOCK_TIMESTAMP,
|
|
91
|
+
});
|
|
92
|
+
const blockHash = `0x${Buffer.from(keccak_256(headerBytes)).toString("hex")}`;
|
|
93
|
+
const anchor = await makeAnchorProof({
|
|
94
|
+
blockHash,
|
|
95
|
+
blockNumber: BLOCK_NUMBER,
|
|
96
|
+
key: part1.key,
|
|
97
|
+
epochId: EPOCH_ID,
|
|
98
|
+
counter: "6",
|
|
99
|
+
slotCounter: "5",
|
|
100
|
+
prevB64: part1.proofs[1]!.chainHash,
|
|
101
|
+
chainId: CHAIN_ID,
|
|
102
|
+
measurement: MEASUREMENT,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const part2 = await makeCounterChain({
|
|
106
|
+
epochId: EPOCH_ID,
|
|
107
|
+
pairs: [
|
|
108
|
+
{ slot: "7", commit: "8" },
|
|
109
|
+
{ slot: "9", commit: "10" },
|
|
110
|
+
],
|
|
111
|
+
key: part1.key,
|
|
112
|
+
chainId: CHAIN_ID,
|
|
113
|
+
measurement: MEASUREMENT,
|
|
114
|
+
payloadPrefix: "rt-part2",
|
|
115
|
+
prevB64OfFirst: anchor.chainHash,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const userProofs = [...part1.proofs, ...part2.proofs];
|
|
119
|
+
const witness: Record<string, unknown> = {
|
|
120
|
+
version: "bitgraph-anchor-witness/1",
|
|
121
|
+
headerRlpHex,
|
|
122
|
+
blockNumber: BLOCK_NUMBER,
|
|
123
|
+
blockHash,
|
|
124
|
+
};
|
|
125
|
+
const members: Array<{ proof: BitGraphProof; proofHash: string }> = [
|
|
126
|
+
...part1.proofs.map((p) => ({ proof: p.proof, proofHash: p.proofHash })),
|
|
127
|
+
{ proof: anchor.proof, proofHash: anchor.proofHash },
|
|
128
|
+
...part2.proofs.map((p) => ({ proof: p.proof, proofHash: p.proofHash })),
|
|
129
|
+
];
|
|
130
|
+
return { userProofs, anchor, blockHash, witness, members };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function builderInput(epoch: SyntheticEpoch, extras?: Partial<BundleArchiveInput>): BundleArchiveInput {
|
|
134
|
+
return {
|
|
135
|
+
proofs: epoch.userProofs.map((p) => ({ proof: p.proof as unknown as Record<string, unknown>, storedProofHash: p.proofHash })),
|
|
136
|
+
anchors: [{ proof: epoch.anchor.proof as unknown as Record<string, unknown>, storedProofHash: epoch.anchor.proofHash }],
|
|
137
|
+
witnesses: [{ path: `witnesses/${BLOCK_NUMBER}.json`, witness: epoch.witness }],
|
|
138
|
+
artifactFiles: [
|
|
139
|
+
...epoch.userProofs.map((p, i) => ({ name: `payload-${i}.bin`, bytes: p.bytes })),
|
|
140
|
+
{ name: "anchor-block-hash.txt", bytes: utf8(epoch.blockHash) },
|
|
141
|
+
],
|
|
142
|
+
epochIds: [EPOCH_ID],
|
|
143
|
+
chainIds: [CHAIN_ID],
|
|
144
|
+
generatedAt: GENERATED_AT,
|
|
145
|
+
...extras,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Every clean-report assertion in one place, shared by the variants. */
|
|
150
|
+
function assertCleanAudit(result: AuditResult, expected: { verified: number; artifactUnavailable: number; verifiedWitnesses?: number }): void {
|
|
151
|
+
const expectedWitnesses = expected.verifiedWitnesses ?? 1;
|
|
152
|
+
const flags = computeExitFlags(result);
|
|
153
|
+
assert.equal(flags.code, 0, "exit flags must be 0 (clean)");
|
|
154
|
+
|
|
155
|
+
assert.equal(result.ingest.counts.observed, 5);
|
|
156
|
+
assert.equal(result.ingest.counts.unsupportedVersion, 0);
|
|
157
|
+
assert.equal(result.ingest.counts.exactDuplicates, 0);
|
|
158
|
+
assert.equal(result.ingest.counts.semanticDuplicates, 0);
|
|
159
|
+
assert.deepEqual(result.ingest.findings, [], "zero ingest findings");
|
|
160
|
+
|
|
161
|
+
assert.equal(result.verification.total, 5);
|
|
162
|
+
assert.equal(result.verification.verified, expected.verified);
|
|
163
|
+
assert.equal(result.verification.failed, 0);
|
|
164
|
+
assert.equal(result.verification.artifactUnavailable, expected.artifactUnavailable);
|
|
165
|
+
assert.equal(result.verification.chainless, 0);
|
|
166
|
+
|
|
167
|
+
assert.deepEqual(result.anomalies.anomalies, [], "zero chain anomalies");
|
|
168
|
+
assert.deepEqual(result.anomalies.divergences, [], "zero divergences");
|
|
169
|
+
assert.deepEqual(result.authorities.anomalies, [], "zero authority anomalies");
|
|
170
|
+
assert.deepEqual(result.anchors.findings, [], "zero anchor findings");
|
|
171
|
+
assert.deepEqual(result.witnesses.findings, [], "zero witness findings");
|
|
172
|
+
assert.deepEqual(result.attestations.findings, [], "zero attestation findings");
|
|
173
|
+
|
|
174
|
+
// One partition, one intact component.
|
|
175
|
+
assert.equal(result.reconstruction.partitions.length, 1);
|
|
176
|
+
assert.equal(result.reconstruction.partitions[0]!.components.length, 1);
|
|
177
|
+
assert.equal(result.reconstruction.partitions[0]!.components[0]!.memberProofHashes.length, 5);
|
|
178
|
+
|
|
179
|
+
// Manifest: recognized, valid, contents hash verifies, counts match.
|
|
180
|
+
const manifest = result.ingest.manifest;
|
|
181
|
+
assert.ok(manifest, "manifest present");
|
|
182
|
+
assert.equal(manifest.parsed, true);
|
|
183
|
+
assert.equal(manifest.recognized, true);
|
|
184
|
+
assert.deepEqual(manifest.problems, []);
|
|
185
|
+
assert.ok(manifest.contentsHash, "manifest declared a contents hash");
|
|
186
|
+
assert.equal(manifest.contentsHash.match, true, "manifest contents hash verifies");
|
|
187
|
+
assert.equal(manifest.manifest!["proofCount"], result.ingest.counts.observed, "manifest proofCount matches observed");
|
|
188
|
+
assert.deepEqual(manifest.manifest!["epochIds"], [EPOCH_ID]);
|
|
189
|
+
assert.deepEqual(manifest.manifest!["chainIds"], [CHAIN_ID]);
|
|
190
|
+
assert.deepEqual(manifest.manifest!["counterRanges"], [
|
|
191
|
+
{ epochId: EPOCH_ID, chainId: CHAIN_ID, min: "2", max: "10" },
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
// The anchor is identified from its signed attribution regardless of
|
|
195
|
+
// witness presence. When the bundle carries a witness (the reference
|
|
196
|
+
// builder variants), it verifies fully offline and yields the header
|
|
197
|
+
// timestamp as evidence; website exports carry no witnesses because the
|
|
198
|
+
// ledger stores neither witnesses nor artifacts.
|
|
199
|
+
assert.equal(result.anchors.anchors.length, 1);
|
|
200
|
+
assert.equal(result.anchors.anchors[0]!.blockNumber, String(BLOCK_NUMBER));
|
|
201
|
+
const outcomes = result.witnesses.outcomes.filter((o) => o.verified);
|
|
202
|
+
assert.equal(outcomes.length, expectedWitnesses, "verified witness outcomes");
|
|
203
|
+
if (expectedWitnesses > 0) {
|
|
204
|
+
assert.equal(outcomes[0]!.timestamp, BLOCK_TIMESTAMP);
|
|
205
|
+
assert.equal(outcomes[0]!.blockNumber, String(BLOCK_NUMBER));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Report-level verdicts.
|
|
209
|
+
const report = buildJsonReport(result);
|
|
210
|
+
assert.equal(report.summary.chainIntact, true, "chain intact");
|
|
211
|
+
assert.deepEqual(report.summary.anomalyCountsByCode, {});
|
|
212
|
+
assert.equal(report.summary.temporal.anchorsWithVerifiedWitness, expectedWitnesses);
|
|
213
|
+
assert.equal(report.summary.exit.code, 0);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
describe("export round trip: buildBundleArchive -> runAudit", () => {
|
|
217
|
+
let epoch: SyntheticEpoch;
|
|
218
|
+
before(async () => {
|
|
219
|
+
epoch = await makeSyntheticEpoch();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("closed-epoch archive with artifacts audits completely clean", async () => {
|
|
223
|
+
const archive = buildBundleArchive(builderInput(epoch));
|
|
224
|
+
const dir = await makeTempDir("bitgraph-roundtrip-closed-");
|
|
225
|
+
const bundlePath = join(dir, "bundle.tar.gz");
|
|
226
|
+
await writeFile(bundlePath, archive);
|
|
227
|
+
|
|
228
|
+
const result = await runAudit(bundlePath);
|
|
229
|
+
assertCleanAudit(result, { verified: 5, artifactUnavailable: 0 });
|
|
230
|
+
assert.equal(result.ingest.container, "tar-gz");
|
|
231
|
+
assert.equal(result.ingest.manifest!.manifest!["artifactsIncluded"], true);
|
|
232
|
+
assert.equal(result.ingest.manifest!.manifest!["openEpochs"], undefined);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("open-epoch snapshot: openEpochs round-trips through the manifest and audits clean", async () => {
|
|
236
|
+
const openEpochs = [{ epochId: EPOCH_ID, counterAtSnapshot: "10" }];
|
|
237
|
+
const archive = buildBundleArchive(builderInput(epoch, { openEpochs }));
|
|
238
|
+
const dir = await makeTempDir("bitgraph-roundtrip-open-");
|
|
239
|
+
const bundlePath = join(dir, "bundle.tar.gz");
|
|
240
|
+
await writeFile(bundlePath, archive);
|
|
241
|
+
|
|
242
|
+
const result = await runAudit(bundlePath);
|
|
243
|
+
assertCleanAudit(result, { verified: 5, artifactUnavailable: 0 });
|
|
244
|
+
assert.deepEqual(result.ingest.manifest!.manifest!["openEpochs"], openEpochs, "openEpochs round-trips");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("is deterministic: the same input produces a byte-identical archive", () => {
|
|
248
|
+
const a = buildBundleArchive(builderInput(epoch));
|
|
249
|
+
const c = buildBundleArchive(builderInput(epoch));
|
|
250
|
+
assert.ok(Buffer.from(a).equals(Buffer.from(c)), "two builds of the same input are byte-identical");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("rejects producer-conformance violations deterministically", async () => {
|
|
254
|
+
// Non-bitgraph/1 member.
|
|
255
|
+
assert.throws(
|
|
256
|
+
() =>
|
|
257
|
+
buildBundleArchive({
|
|
258
|
+
proofs: [{ proof: { version: "occ/1" } }],
|
|
259
|
+
epochIds: [],
|
|
260
|
+
chainIds: [],
|
|
261
|
+
}),
|
|
262
|
+
/only bitgraph\/1 proofs/
|
|
263
|
+
);
|
|
264
|
+
// storedProofHash disagreeing with the computed canonical hash.
|
|
265
|
+
assert.throws(
|
|
266
|
+
() =>
|
|
267
|
+
buildBundleArchive({
|
|
268
|
+
proofs: [{ proof: epoch.userProofs[0]!.proof as unknown as Record<string, unknown>, storedProofHash: "bm90LXRoZS1oYXNo" }],
|
|
269
|
+
epochIds: [EPOCH_ID],
|
|
270
|
+
chainIds: [CHAIN_ID],
|
|
271
|
+
}),
|
|
272
|
+
/storedProofHash does not match/
|
|
273
|
+
);
|
|
274
|
+
// Duplicate canonical identity.
|
|
275
|
+
assert.throws(
|
|
276
|
+
() =>
|
|
277
|
+
buildBundleArchive({
|
|
278
|
+
proofs: [
|
|
279
|
+
{ proof: epoch.userProofs[0]!.proof as unknown as Record<string, unknown> },
|
|
280
|
+
{ proof: epoch.userProofs[0]!.proof as unknown as Record<string, unknown> },
|
|
281
|
+
],
|
|
282
|
+
epochIds: [EPOCH_ID],
|
|
283
|
+
chainIds: [CHAIN_ID],
|
|
284
|
+
}),
|
|
285
|
+
/duplicate proof/
|
|
286
|
+
);
|
|
287
|
+
// Unsafe witness path.
|
|
288
|
+
assert.throws(
|
|
289
|
+
() =>
|
|
290
|
+
buildBundleArchive({
|
|
291
|
+
proofs: [{ proof: epoch.userProofs[0]!.proof as unknown as Record<string, unknown> }],
|
|
292
|
+
witnesses: [{ path: "../escape.json", witness: {} }],
|
|
293
|
+
epochIds: [EPOCH_ID],
|
|
294
|
+
chainIds: [CHAIN_ID],
|
|
295
|
+
}),
|
|
296
|
+
/unsafe component/
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Website conformance: the independent assembly in
|
|
303
|
+
// website/src/lib/export-epoch.ts must produce a spec-conformant bundle,
|
|
304
|
+
// byte-identical to the reference builder for the same input. The website
|
|
305
|
+
// lib is imported by a SPAWNED node process under type stripping (it is
|
|
306
|
+
// self-contained: node builtins only), because the website's tsconfig and
|
|
307
|
+
// package scope are separate from the root project.
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
const WEBSITE_LIB_PATH = fileURLToPath(
|
|
311
|
+
new URL("../../website/src/lib/export-epoch.ts", import.meta.url)
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// The website lib is TypeScript and self-contained (node builtins only).
|
|
315
|
+
// Transpile it to plain ESM with the TypeScript compiler rather than relying
|
|
316
|
+
// on the Node runtime to strip types: native type stripping is on by default
|
|
317
|
+
// only on Node 22.6 and up, so importing the .ts directly fails on the Node 20
|
|
318
|
+
// floor that CI runs against. Transpiling here makes the spawned runner import
|
|
319
|
+
// work identically on every supported Node version.
|
|
320
|
+
const tsRequire = createRequire(import.meta.url);
|
|
321
|
+
async function transpileWebsiteLib(destDir: string): Promise<string> {
|
|
322
|
+
const ts = tsRequire("typescript") as typeof import("typescript");
|
|
323
|
+
const source = await readFile(WEBSITE_LIB_PATH, "utf8");
|
|
324
|
+
const js = ts.transpileModule(source, {
|
|
325
|
+
compilerOptions: {
|
|
326
|
+
module: ts.ModuleKind.ESNext,
|
|
327
|
+
target: ts.ScriptTarget.ES2022,
|
|
328
|
+
},
|
|
329
|
+
}).outputText;
|
|
330
|
+
const libPath = join(destDir, "export-epoch.mjs");
|
|
331
|
+
await writeFile(libPath, js);
|
|
332
|
+
return pathToFileURL(libPath).href;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const RUNNER_SOURCE = `
|
|
336
|
+
const [configPath] = process.argv.slice(2);
|
|
337
|
+
const { readFile, writeFile } = await import("node:fs/promises");
|
|
338
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
339
|
+
const mod = await import(config.libUrl);
|
|
340
|
+
const source = {
|
|
341
|
+
listProofKeys: async () => config.proofKeys,
|
|
342
|
+
listAnchorKeys: async () => config.anchorKeys,
|
|
343
|
+
getObjectText: async (key) => Object.hasOwn(config.objects, key) ? config.objects[key] : null,
|
|
344
|
+
getCurrentEpochSafeId: async () => config.currentSafeId,
|
|
345
|
+
};
|
|
346
|
+
const result = await mod.exportEpoch(source, config.safeEpochId, config.generatedAt);
|
|
347
|
+
if (result === null) {
|
|
348
|
+
console.error("exportEpoch returned null");
|
|
349
|
+
process.exit(2);
|
|
350
|
+
}
|
|
351
|
+
await writeFile(config.outPath, result.archive);
|
|
352
|
+
await writeFile(config.metaPath, JSON.stringify({
|
|
353
|
+
open: result.open,
|
|
354
|
+
proofCount: result.proofCount,
|
|
355
|
+
epochId: result.epochId,
|
|
356
|
+
maxCounter: result.maxCounter,
|
|
357
|
+
skipped: result.skipped,
|
|
358
|
+
}));
|
|
359
|
+
`;
|
|
360
|
+
|
|
361
|
+
interface WebsiteRun {
|
|
362
|
+
archivePath: string;
|
|
363
|
+
meta: { open: boolean; proofCount: number; epochId: string; maxCounter: string | null; skipped: number };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function runWebsiteExport(epoch: SyntheticEpoch, opts: { open: boolean }): Promise<WebsiteRun> {
|
|
367
|
+
const dir = await makeTempDir("bitgraph-roundtrip-website-");
|
|
368
|
+
const safeEpoch = toSafe(EPOCH_ID);
|
|
369
|
+
|
|
370
|
+
// The in-memory ledger view: stored-form proofs under proofs/{epoch}/,
|
|
371
|
+
// plus the anchors/ index copy of the anchor (extra unsigned "ethereum"
|
|
372
|
+
// field, no hash suffix in the key) that the exporter must dedup, never
|
|
373
|
+
// mirror.
|
|
374
|
+
const objects: Record<string, string> = {};
|
|
375
|
+
const proofKeys: string[] = [];
|
|
376
|
+
for (const member of epoch.members) {
|
|
377
|
+
const counter = (member.proof.commit as { counter?: string }).counter ?? "0";
|
|
378
|
+
const key = `proofs/${safeEpoch}/${pad12(counter)}-${toSafe(member.proofHash)}.json`;
|
|
379
|
+
objects[key] = storedProofJson(member.proof, member.proofHash);
|
|
380
|
+
proofKeys.push(key);
|
|
381
|
+
}
|
|
382
|
+
proofKeys.sort();
|
|
383
|
+
const anchorIndexKey = `anchors/${safeEpoch}/${pad12("6")}.json`;
|
|
384
|
+
objects[anchorIndexKey] = JSON.stringify({
|
|
385
|
+
...(JSON.parse(storedProofJson(epoch.anchor.proof, epoch.anchor.proofHash)) as Record<string, unknown>),
|
|
386
|
+
ethereum: { blockNumber: BLOCK_NUMBER, blockHash: epoch.blockHash },
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const runnerPath = join(dir, "runner.mjs");
|
|
390
|
+
const configPath = join(dir, "config.json");
|
|
391
|
+
const outPath = join(dir, "website-export.tar.gz");
|
|
392
|
+
const metaPath = join(dir, "meta.json");
|
|
393
|
+
const libUrl = await transpileWebsiteLib(dir);
|
|
394
|
+
await writeFile(runnerPath, RUNNER_SOURCE);
|
|
395
|
+
await writeFile(
|
|
396
|
+
configPath,
|
|
397
|
+
JSON.stringify({
|
|
398
|
+
libUrl,
|
|
399
|
+
safeEpochId: safeEpoch,
|
|
400
|
+
currentSafeId: opts.open ? safeEpoch : "some-other-epoch",
|
|
401
|
+
generatedAt: GENERATED_AT,
|
|
402
|
+
proofKeys,
|
|
403
|
+
anchorKeys: [anchorIndexKey],
|
|
404
|
+
objects,
|
|
405
|
+
outPath,
|
|
406
|
+
metaPath,
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
await execFileAsync(process.execPath, [runnerPath, configPath]);
|
|
411
|
+
const meta = JSON.parse(await readFile(metaPath, "utf8")) as WebsiteRun["meta"];
|
|
412
|
+
return { archivePath: outPath, meta };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
describe("export round trip: website export-epoch conformance", () => {
|
|
416
|
+
let epoch: SyntheticEpoch;
|
|
417
|
+
before(async () => {
|
|
418
|
+
epoch = await makeSyntheticEpoch();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("closed-epoch website export is byte-identical to the reference builder and audits clean", async () => {
|
|
422
|
+
const { archivePath, meta } = await runWebsiteExport(epoch, { open: false });
|
|
423
|
+
assert.equal(meta.open, false);
|
|
424
|
+
assert.equal(meta.proofCount, 5);
|
|
425
|
+
assert.equal(meta.epochId, EPOCH_ID);
|
|
426
|
+
assert.equal(meta.maxCounter, "10");
|
|
427
|
+
// Exactly one skip: the anchors/ index copy, deduped by stored proofHash.
|
|
428
|
+
assert.equal(meta.skipped, 1);
|
|
429
|
+
|
|
430
|
+
const websiteArchive = await readFile(archivePath);
|
|
431
|
+
|
|
432
|
+
// Reference builder, same input: all five members under proofs/ (the
|
|
433
|
+
// website exports the proofs/ listing, where anchors already live), no
|
|
434
|
+
// witnesses or artifacts (the ledger stores neither).
|
|
435
|
+
const reference = buildBundleArchive({
|
|
436
|
+
proofs: epoch.members.map((m) => ({ proof: m.proof as unknown as Record<string, unknown>, storedProofHash: m.proofHash })),
|
|
437
|
+
epochIds: [EPOCH_ID],
|
|
438
|
+
chainIds: [CHAIN_ID],
|
|
439
|
+
generatedAt: GENERATED_AT,
|
|
440
|
+
});
|
|
441
|
+
assert.ok(
|
|
442
|
+
websiteArchive.equals(Buffer.from(reference)),
|
|
443
|
+
"website assembly is byte-identical to the reference builder for the same input"
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
const result = await runAudit(archivePath);
|
|
447
|
+
// No artifact bytes in a ledger export: every member is
|
|
448
|
+
// artifact-unavailable at the integrity tier, and that is not a failure.
|
|
449
|
+
assertCleanAudit(result, { verified: 0, artifactUnavailable: 5, verifiedWitnesses: 0 });
|
|
450
|
+
assert.equal(result.ingest.manifest!.manifest!["artifactsIncluded"], false);
|
|
451
|
+
assert.equal(result.ingest.manifest!.manifest!["openEpochs"], undefined);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("open-epoch website export carries the openEpochs snapshot and audits clean", async () => {
|
|
455
|
+
const { archivePath, meta } = await runWebsiteExport(epoch, { open: true });
|
|
456
|
+
assert.equal(meta.open, true);
|
|
457
|
+
|
|
458
|
+
const result = await runAudit(archivePath);
|
|
459
|
+
assertCleanAudit(result, { verified: 0, artifactUnavailable: 5, verifiedWitnesses: 0 });
|
|
460
|
+
assert.deepEqual(result.ingest.manifest!.manifest!["openEpochs"], [
|
|
461
|
+
{ epochId: EPOCH_ID, counterAtSnapshot: "10" },
|
|
462
|
+
]);
|
|
463
|
+
});
|
|
464
|
+
});
|