diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2518c87d9..e34110c34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -617,6 +617,74 @@ jobs: retention-days: 7 if-no-files-found: ignore + # ------------------------------------------------------------------ + # Multi-node devnet integration — REAL NODES, NO MOCKS. Boots a live + # 6-node devnet (Hardhat + four core + two edge daemons via + # scripts/devnet.sh, which also handles on-chain staking) and runs the + # cross-node regression suite in devnet/issue-liveness/: publish → + # StorageACK quorum → replication → late-subscriber catch-up, plus + # cross-node read/import contracts. The suite seeds its own data. These + # paths are inherently multi-node (a publisher collects ACKs from its + # PEERS, the 3-of-N quorum needs 4 cores, #886-style catch-up needs a + # node that subscribes after the publish) so they cannot run in the + # single-process package lanes above. + # + # Tier: TORNADO — publish/replication correctness is protocol-critical + # (`dkgv10-spec/CRITICALITY_CATEGORIZATION.md` §1). + # ------------------------------------------------------------------ + tornado-devnet-integration: + name: "Tornado: devnet integration (multi-node publish/sync)" + needs: build + runs-on: ubuntu-latest + # Cold devnet boot (Solidity compile + 6-node bring-up + on-chain + # staking/bootstrap) + replication polls with generous deadlines, so a + # slow runner never flakes the lane on a timeout. + timeout-minutes: 45 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: build-outputs + path: /tmp + - name: Restore build outputs + run: tar -xzf /tmp/build-outputs.tgz + # Same reasoning as the node-ui e2e lane: the shared build skips the + # Solidity compile (DKG_SKIP_EVM_BUILD=1), and devnet.sh shells out to + # the DEFAULT hardhat config, so compile with the default config here + # in an observable step. + - name: Compile EVM contracts (for the devnet boot) + run: pnpm --filter @origintrail-official/dkg-evm-module exec hardhat compile + - name: Start 6-node devnet + run: ./scripts/devnet.sh start 6 + - name: "Multi-node publish/sync integration tests" + run: pnpm test:devnet:issue-liveness + - name: Stop devnet + if: always() + run: ./scripts/devnet.sh stop || true + # On failure the daemon logs are the only window into a devnet that + # didn't come up. Upload them so a red run is debuggable without a + # local repro. + - name: Upload devnet logs on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: devnet-integration-logs + path: | + .devnet/node*/daemon.log + .devnet/hardhat/node.log + .devnet/hardhat/deploy.log + retention-days: 7 + if-no-files-found: ignore + kosava-supporting: name: "Kosava: adapters + epcis + graph-viz + mcp-server + network-sim" needs: build diff --git a/devnet/_bootstrap/verify-1107-results-1781709408805.json b/devnet/_bootstrap/verify-1107-results-1781709408805.json deleted file mode 100644 index da317aa90..000000000 --- a/devnet/_bootstrap/verify-1107-results-1781709408805.json +++ /dev/null @@ -1,86 +0,0 @@ -[ - { - "id": "#1093", - "title": "ACK pool quorum: 5 publishes across core1+core2", - "pass": true, - "detail": "statuses=[\"confirmed\",\"confirmed\",\"confirmed\",\"confirmed\",\"confirmed\"] (all 'confirmed' ⇒ ACK quorum met)" - }, - { - "id": "#1094", - "title": "edit loop: publish→pull-from(vm)→edit→re-publish→VM has edit", - "pass": true, - "detail": "pub1=confirmed pull=200/seeded=1 pub2=confirmed vmHasEdit=true" - }, - { - "id": "#1095", - "title": "discard same-name WM draft keeps published KA vm-confirmed", - "pass": false, - "detail": "after discard: state=undefined status=undefined publishedUal=MISSING" - }, - { - "id": "#1096", - "title": "memory/search finds VM-published entity", - "pass": true, - "detail": "status=200 results=1 matched=true" - }, - { - "id": "#1097", - "title": "publish without share ⇒ clean 409 (not 500); main design supersedes auto-promote", - "pass": true, - "detail": "status=409 code=VM_PUBLISH_PRECONDITION err=No quads in shared memory for context graph v1107-pub-1781709408805 matching sel" - }, - { - "id": "#1098", - "title": "VM materializes on subscribed replica (core2) after publish on core1", - "pass": true, - "detail": "core2 VM rows=1 (found replicated entity)" - }, - { - "id": "#1099", - "title": "SWM drained on publisher after confirmed publish", - "pass": true, - "detail": "SWM rows for published root = 0 (expect 0)" - }, - { - "id": "#1101", - "title": "import-file infers text/markdown for octet-stream .md upload", - "pass": true, - "detail": "status=200 detectedContentType=text/markdown extraction={\"status\":\"completed\",\"tripleCount\":1,\"pipelineUsed\":\"text/markdown\"}" - }, - { - "id": "#1102", - "title": "CG routes accept `id` alias (subscribe + rename)", - "pass": true, - "detail": "subscribe{id} status=200; rename{id} status=200" - }, - { - "id": "#1103", - "title": "sign-join is sign-only: forwarded:false + next→request-join hint", - "pass": true, - "detail": "status=200 forwarded=false next=This route only SIGNS the join request — nothing was sent to the curator. To deliver it, P" - }, - { - "id": "#1104", - "title": "descriptor surfaces publishedUal (≠ reservedUal)", - "pass": true, - "detail": "publishedUal=present reservedUal=present distinct=true" - }, - { - "id": "#1105", - "title": "query-remote against public CG returns rows from another node", - "pass": true, - "detail": "http=200 remoteStatus=OK matched=true body={\"operationId\":\"3768f7e2-e384-43eb-b6a8-01204885c51b\",\"status\":\"OK\",\"bindings\":\"[{\\\"o\\\":\\\"\\\\\\\"UniqueSearchTokenXYZ\\\\\\\"\\\"}]\",\"truncated\":false,\"resultCount\":1}" - }, - { - "id": "#1106", - "title": "chat peerId/message aliases + WM default-agent query + sub-graph \"/\" rejected", - "pass": true, - "detail": "chat=200 wmDefaultRows=1 subGraphSlashRejected=true(400)" - }, - { - "id": "APP", - "title": "all 6 nodes healthy + meshed", - "pass": true, - "detail": "core1:up/peers=? core2:up/peers=? core3:up/peers=? core4:up/peers=? edge5:up/peers=? edge6:up/peers=?" - } -] \ No newline at end of file diff --git a/devnet/_bootstrap/verify-1107-results-1781709633236.json b/devnet/_bootstrap/verify-1107-results-1781709633236.json deleted file mode 100644 index 754d07284..000000000 --- a/devnet/_bootstrap/verify-1107-results-1781709633236.json +++ /dev/null @@ -1,86 +0,0 @@ -[ - { - "id": "#1093", - "title": "ACK pool quorum: 5 publishes across core1+core2", - "pass": true, - "detail": "statuses=[\"confirmed\",\"confirmed\",\"confirmed\",\"confirmed\",\"confirmed\"] (all 'confirmed' ⇒ ACK quorum met)" - }, - { - "id": "#1094", - "title": "edit loop: publish→pull-from(vm)→edit→re-publish→VM has edit", - "pass": true, - "detail": "pub1=confirmed pull=200/seeded=1 pub2=confirmed vmHasEdit=true" - }, - { - "id": "#1095", - "title": "discard on a published KA keeps it vm-confirmed (not flipped to discarded)", - "pass": true, - "detail": "before=vm-confirmed discardResp=200 after: state=published status=vm-confirmed publishedUal=present" - }, - { - "id": "#1096", - "title": "memory/search finds VM-published entity", - "pass": true, - "detail": "status=200 results=1 matched=true" - }, - { - "id": "#1097", - "title": "publish without share ⇒ clean 409 (not 500); main design supersedes auto-promote", - "pass": true, - "detail": "status=409 code=VM_PUBLISH_PRECONDITION err=No quads in shared memory for context graph v1107-pub-1781709633236 matching sel" - }, - { - "id": "#1098", - "title": "VM materializes on subscribed replica (core2) after publish on core1", - "pass": false, - "detail": "core2 VM rows=0 (NOT materialized)" - }, - { - "id": "#1099", - "title": "SWM drained on publisher after confirmed publish", - "pass": true, - "detail": "SWM rows for published root = 0 (expect 0)" - }, - { - "id": "#1101", - "title": "import-file infers text/markdown for octet-stream .md upload", - "pass": true, - "detail": "status=200 detectedContentType=text/markdown extraction={\"status\":\"completed\",\"tripleCount\":1,\"pipelineUsed\":\"text/markdown\"}" - }, - { - "id": "#1102", - "title": "CG routes accept `id` alias (subscribe + rename)", - "pass": true, - "detail": "subscribe{id} status=200; rename{id} status=200" - }, - { - "id": "#1103", - "title": "sign-join is sign-only: forwarded:false + next→request-join hint", - "pass": true, - "detail": "status=200 forwarded=false next=This route only SIGNS the join request — nothing was sent to the curator. To deliver it, P" - }, - { - "id": "#1104", - "title": "descriptor surfaces publishedUal (≠ reservedUal)", - "pass": true, - "detail": "publishedUal=present reservedUal=present distinct=true" - }, - { - "id": "#1105", - "title": "query-remote against public CG returns rows from another node", - "pass": true, - "detail": "http=200 remoteStatus=OK matched=true body={\"operationId\":\"ae901eb2-5fd9-4a78-a826-fb5a26f5f19d\",\"status\":\"OK\",\"bindings\":\"[{\\\"o\\\":\\\"\\\\\\\"UniqueSearchTokenXYZ\\\\\\\"\\\"}]\",\"truncated\":false,\"resultCount\":1}" - }, - { - "id": "#1106", - "title": "chat peerId/message aliases + WM default-agent query + sub-graph \"/\" rejected", - "pass": true, - "detail": "chat=200 wmDefaultRows=1 subGraphSlashRejected=true(400)" - }, - { - "id": "APP", - "title": "all 6 nodes healthy + meshed", - "pass": true, - "detail": "core1:up/peers=? core2:up/peers=? core3:up/peers=? core4:up/peers=? edge5:up/peers=? edge6:up/peers=?" - } -] \ No newline at end of file diff --git a/devnet/_bootstrap/verify-1107-results-1781710157656.json b/devnet/_bootstrap/verify-1107-results-1781710157656.json deleted file mode 100644 index 5bf053592..000000000 --- a/devnet/_bootstrap/verify-1107-results-1781710157656.json +++ /dev/null @@ -1,86 +0,0 @@ -[ - { - "id": "#1093", - "title": "ACK pool quorum: 5 publishes across core1+core2", - "pass": true, - "detail": "statuses=[\"confirmed\",\"confirmed\",\"confirmed\",\"confirmed\",\"confirmed\"] (all 'confirmed' ⇒ ACK quorum met)" - }, - { - "id": "#1094", - "title": "edit loop: publish→pull-from(vm)→edit→re-publish→VM has edit", - "pass": true, - "detail": "pub1=confirmed pull=200/seeded=1 pub2=confirmed vmHasEdit=true" - }, - { - "id": "#1095", - "title": "discard on a published KA keeps it vm-confirmed (not flipped to discarded)", - "pass": true, - "detail": "before=vm-confirmed discardResp=200 after: state=published status=vm-confirmed publishedUal=present" - }, - { - "id": "#1096", - "title": "memory/search finds VM-published entity", - "pass": true, - "detail": "status=200 results=1 matched=true" - }, - { - "id": "#1097", - "title": "publish without share ⇒ clean 409 (not 500); main design supersedes auto-promote", - "pass": true, - "detail": "status=409 code=VM_PUBLISH_PRECONDITION err=No quads in shared memory for context graph v1107-pub-1781710157656 matching sel" - }, - { - "id": "#1098", - "title": "VM materializes/converges on subscribed replica (core2) after publish on core1", - "pass": false, - "detail": "core2 VM rows=0 (NOT materialized in 90s)" - }, - { - "id": "#1099", - "title": "SWM drained on publisher after confirmed publish", - "pass": true, - "detail": "SWM rows for published root = 0 (expect 0)" - }, - { - "id": "#1101", - "title": "import-file infers text/markdown for octet-stream .md upload", - "pass": true, - "detail": "status=200 detectedContentType=text/markdown extraction={\"status\":\"completed\",\"tripleCount\":1,\"pipelineUsed\":\"text/markdown\"}" - }, - { - "id": "#1102", - "title": "CG routes accept `id` alias (subscribe + rename)", - "pass": true, - "detail": "subscribe{id} status=200; rename{id} status=200" - }, - { - "id": "#1103", - "title": "sign-join is sign-only: forwarded:false + next→request-join hint", - "pass": true, - "detail": "status=200 forwarded=false next=This route only SIGNS the join request — nothing was sent to the curator. To deliver it, P" - }, - { - "id": "#1104", - "title": "descriptor surfaces publishedUal (≠ reservedUal)", - "pass": true, - "detail": "publishedUal=present reservedUal=present distinct=true" - }, - { - "id": "#1105", - "title": "query-remote against public CG returns rows from another node", - "pass": true, - "detail": "http=200 remoteStatus=OK matched=true body={\"operationId\":\"e729a207-ea10-4105-b42f-448de11f9351\",\"status\":\"OK\",\"bindings\":\"[{\\\"o\\\":\\\"\\\\\\\"UniqueSearchTokenXYZ\\\\\\\"\\\"}]\",\"truncated\":false,\"resultCount\":1}" - }, - { - "id": "#1106", - "title": "chat peerId/message aliases + WM default-agent query + sub-graph \"/\" rejected", - "pass": true, - "detail": "chat=200 wmDefaultRows=1 subGraphSlashRejected=true(400)" - }, - { - "id": "APP", - "title": "all 6 nodes healthy + meshed", - "pass": true, - "detail": "core1:up/peers=? core2:up/peers=? core3:up/peers=? core4:up/peers=? edge5:up/peers=? edge6:up/peers=?" - } -] \ No newline at end of file diff --git a/devnet/_bootstrap/verify-1107-results-1781710328017.json b/devnet/_bootstrap/verify-1107-results-1781710328017.json deleted file mode 100644 index 20f25a247..000000000 --- a/devnet/_bootstrap/verify-1107-results-1781710328017.json +++ /dev/null @@ -1,86 +0,0 @@ -[ - { - "id": "#1093", - "title": "ACK pool quorum: 5 publishes across core1+core2", - "pass": true, - "detail": "statuses=[\"confirmed\",\"confirmed\",\"confirmed\",\"confirmed\",\"confirmed\"] (all 'confirmed' ⇒ ACK quorum met)" - }, - { - "id": "#1094", - "title": "edit loop: publish→pull-from(vm)→edit→re-publish→VM has edit", - "pass": true, - "detail": "pub1=confirmed pull=200/seeded=1 pub2=confirmed vmHasEdit=true" - }, - { - "id": "#1095", - "title": "discard on a published KA keeps it vm-confirmed (not flipped to discarded)", - "pass": true, - "detail": "before=vm-confirmed discardResp=200 after: state=published status=vm-confirmed publishedUal=present" - }, - { - "id": "#1096", - "title": "memory/search finds VM-published entity", - "pass": true, - "detail": "status=200 results=1 matched=true" - }, - { - "id": "#1097", - "title": "publish without share ⇒ clean 409 (not 500); main design supersedes auto-promote", - "pass": true, - "detail": "status=409 code=VM_PUBLISH_PRECONDITION err=No quads in shared memory for context graph v1107-pub-1781710328017 matching sel" - }, - { - "id": "#1098", - "title": "VM materializes/converges on subscribed replica (core2) after publish on core1", - "pass": true, - "detail": "core2 VM rows=1 (replicated entity present)" - }, - { - "id": "#1099", - "title": "SWM drained on publisher after confirmed publish", - "pass": true, - "detail": "SWM rows for published root = 0 (expect 0)" - }, - { - "id": "#1101", - "title": "import-file infers text/markdown for octet-stream .md upload", - "pass": true, - "detail": "status=200 detectedContentType=text/markdown extraction={\"status\":\"completed\",\"tripleCount\":1,\"pipelineUsed\":\"text/markdown\"}" - }, - { - "id": "#1102", - "title": "CG routes accept `id` alias (subscribe + rename)", - "pass": true, - "detail": "subscribe{id} status=200; rename{id} status=200" - }, - { - "id": "#1103", - "title": "sign-join is sign-only: forwarded:false + next→request-join hint", - "pass": true, - "detail": "status=200 forwarded=false next=This route only SIGNS the join request — nothing was sent to the curator. To deliver it, P" - }, - { - "id": "#1104", - "title": "descriptor surfaces publishedUal (≠ reservedUal)", - "pass": true, - "detail": "publishedUal=present reservedUal=present distinct=true" - }, - { - "id": "#1105", - "title": "query-remote against public CG returns rows from another node", - "pass": true, - "detail": "http=200 remoteStatus=OK matched=true body={\"operationId\":\"1dbb7fa7-3835-4bca-82dd-cbde28025248\",\"status\":\"OK\",\"bindings\":\"[{\\\"o\\\":\\\"\\\\\\\"UniqueSearchTokenXYZ\\\\\\\"\\\"}]\",\"truncated\":false,\"resultCount\":1}" - }, - { - "id": "#1106", - "title": "chat peerId/message aliases + WM default-agent query + sub-graph \"/\" rejected", - "pass": true, - "detail": "chat=200 wmDefaultRows=1 subGraphSlashRejected=true(400)" - }, - { - "id": "APP", - "title": "all 6 nodes healthy + meshed", - "pass": true, - "detail": "core1:up/peers=? core2:up/peers=? core3:up/peers=? core4:up/peers=? edge5:up/peers=? edge6:up/peers=?" - } -] \ No newline at end of file diff --git a/devnet/_bootstrap/verify-1107.cjs b/devnet/_bootstrap/verify-1107.cjs deleted file mode 100644 index 354f9d637..000000000 --- a/devnet/_bootstrap/verify-1107.cjs +++ /dev/null @@ -1,290 +0,0 @@ -#!/usr/bin/env node -/** - * verify-1107.cjs — end-to-end verification of all 14 fixes in PR #1107 - * (#1093–#1106) against a REAL running 6-node devnet (real Hardhat chain, - * real on-chain publishes, real RDF, no mocks). - * - * Run AFTER: ./scripts/devnet.sh start 6 - * node devnet/_bootstrap/verify-1107.cjs - */ -const fs = require('fs'); -const path = require('path'); - -const TOK = process.argv[2] || process.env.DKG_AUTH_TOKEN; -if (!TOK) { console.error('usage: node verify-1107.cjs '); process.exit(2); } - -const PORTS = { core1: 9201, core2: 9202, core3: 9203, core4: 9204, edge5: 9205, edge6: 9206 }; -const base = (p) => `http://127.0.0.1:${p}`; -const ts = Date.now(); -const results = []; -const rec = (id, title, pass, detail) => { - results.push({ id, title, pass, detail }); - const tag = pass === true ? 'PASS' : pass === 'skip' ? 'SKIP' : 'FAIL'; - console.log(`[${tag}] ${id} ${title}\n ${detail}`); -}; - -async function api(port, method, p, body, opts = {}) { - const headers = { Authorization: `Bearer ${TOK}` }; - let payload; - if (opts.form) { payload = body; } - else if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); } - const r = await fetch(base(port) + p, { method, headers, body: payload }); - const text = await r.text(); - let json; try { json = JSON.parse(text); } catch { json = text; } - return { status: r.status, body: json }; -} -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const j = (o) => JSON.stringify(o).slice(0, 240); - -async function peerId(port) { - const s = await api(port, 'GET', '/api/status'); - return (s.body.result || s.body).peerId; -} - -(async () => { - console.log(`\n=== verify-1107 against live devnet @ ${new Date(ts).toISOString()} ===\n`); - - // ---- shared fixtures ------------------------------------------------- - const CG = `v1107-pub-${ts}`; // public CG (accessPolicy 0) - const mk = await api(PORTS.core1, 'POST', '/api/context-graph/create', - { id: CG, name: CG, description: 'verify-1107 public', accessPolicy: 0, publishPolicy: 1, register: true }); - if (!(mk.body.registered)) { rec('SETUP', 'create+register public CG', false, `create failed: ${j(mk.body)}`); } - else console.log(`setup: public CG ${CG} registered onChainId=${mk.body.onChainId}\n`); - const p1 = await peerId(PORTS.core1); - const p2 = await peerId(PORTS.core2); - console.log(`setup: core1 peerId=${p1}\nsetup: core2 peerId=${p2}\n`); - - // helper: full lifecycle publish of `name` with one schema:name triple - async function publishKa(port, cg, name, subj, val) { - await api(port, 'POST', '/api/knowledge-assets', - { contextGraphId: cg, name, quads: [{ subject: subj, predicate: 'http://schema.org/name', object: `"${val}"` }] }); - await api(port, 'POST', `/api/knowledge-assets/${name}/swm/share`, { contextGraphId: cg }); - return api(port, 'POST', `/api/knowledge-assets/${name}/vm/publish`, { contextGraphId: cg }); - } - - // ===== #1093 — ACK pool reaches quorum (no QuorumUnmetError) ========== - try { - const confirms = []; - for (let i = 0; i < 3; i++) { - const r = await publishKa(PORTS.core1, CG, `ack-c1-${i}`, `urn:v:ack1-${i}`, `ack c1 ${i}`); - confirms.push(r.body.status); - } - // core2 publishes to its own freshly-registered public CG - const CG2 = `v1107-c2-${ts}`; - await api(PORTS.core2, 'POST', '/api/context-graph/create', { id: CG2, name: CG2, accessPolicy: 0, publishPolicy: 1, register: true }); - for (let i = 0; i < 2; i++) { - const r = await publishKa(PORTS.core2, CG2, `ack-c2-${i}`, `urn:v:ack2-${i}`, `ack c2 ${i}`); - confirms.push(r.body.status); - } - const allConfirmed = confirms.every((s) => s === 'confirmed'); - rec('#1093', 'ACK pool quorum: 5 publishes across core1+core2', allConfirmed, - `statuses=${JSON.stringify(confirms)} (all 'confirmed' ⇒ ACK quorum met)`); - } catch (e) { rec('#1093', 'ACK pool quorum', false, `threw: ${e.message}`); } - - // ===== #1094 — KA edit loop (pull-from VM → edit → re-publish) ======== - try { - const E = 'edit-loop'; - const pub1 = await publishKa(PORTS.core1, CG, E, 'urn:v:edit', 'original'); - const pull = await api(PORTS.core1, 'POST', `/api/knowledge-assets/${E}/wm/pull-from`, { contextGraphId: CG, layer: 'vm' }); - // edit: add a second triple, re-finalize, re-share, re-publish (UPDATE) - await api(PORTS.core1, 'POST', `/api/knowledge-assets/${E}/wm/write`, - { contextGraphId: CG, quads: [{ subject: 'urn:v:edit', predicate: 'http://schema.org/description', object: '"edited via pull-from loop"' }] }); - await api(PORTS.core1, 'POST', `/api/knowledge-assets/${E}/wm/finalize`, { contextGraphId: CG }); - await api(PORTS.core1, 'POST', `/api/knowledge-assets/${E}/swm/share`, { contextGraphId: CG }); - const pub2 = await api(PORTS.core1, 'POST', `/api/knowledge-assets/${E}/vm/publish`, { contextGraphId: CG }); - await sleep(1500); - const q = await api(PORTS.core1, 'POST', '/api/query', - { contextGraphId: CG, view: 'verifiable-memory', sparql: 'SELECT ?p ?o WHERE { ?p ?o }' }); - const rows = (q.body.result?.bindings || []).map((b) => String(b.o)); - const hasEdit = rows.some((o) => /edited via pull-from loop/.test(o)); - const ok = pub1.body.status === 'confirmed' && pull.status === 200 && (pull.body.seeded >= 1) && - pub2.body.status === 'confirmed' && hasEdit; - rec('#1094', 'edit loop: publish→pull-from(vm)→edit→re-publish→VM has edit', ok, - `pub1=${pub1.body.status} pull=${pull.status}/seeded=${pull.body.seeded} pub2=${pub2.body.status} vmHasEdit=${hasEdit}`); - } catch (e) { rec('#1094', 'KA edit loop', false, `threw: ${e.message}`); } - - // ===== #1095 — discard on a published KA must NOT clobber its state ==== - // Faithful #1095: a KA confirmed in VM (state=published / vm-confirmed), - // then a wm/discard, must NOT flip the descriptor to "discarded" while the - // asset is live on-chain. The fix filters the state="discarded" + - // prov:wasInvalidatedBy stamps when a vmCurrentAssertion pointer exists. - try { - await publishKa(PORTS.core1, CG, 'survivor', 'urn:v:survivor', 'survives discard'); - const before = await api(PORTS.core1, 'GET', `/api/knowledge-assets/survivor?contextGraphId=${CG}`); - const dr = await api(PORTS.core1, 'POST', '/api/knowledge-assets/survivor/wm/discard', { contextGraphId: CG }); - const d = await api(PORTS.core1, 'GET', `/api/knowledge-assets/survivor?contextGraphId=${CG}`); - const ok = before.body.status === 'vm-confirmed' && dr.status >= 200 && dr.status < 300 && - d.status === 200 && d.body.status === 'vm-confirmed' && d.body.state !== 'discarded' && !!d.body.publishedUal; - rec('#1095', 'discard on a published KA keeps it vm-confirmed (not flipped to discarded)', ok, - `before=${before.body.status} discardResp=${dr.status} after: state=${d.body.state} status=${d.body.status} publishedUal=${d.body.publishedUal ? 'present' : 'MISSING'}`); - } catch (e) { rec('#1095', 'lifecycle descriptor / discard', false, `threw: ${e.message}`); } - - // ===== #1096 — memory/search matches Verifiable Memory ================ - try { - await publishKa(PORTS.core1, CG, 'searchable', 'urn:v:searchable', 'UniqueSearchTokenXYZ'); - await sleep(800); - const s = await api(PORTS.core1, 'POST', '/api/memory/search', { contextGraphId: CG, query: 'UniqueSearchTokenXYZ', limit: 10 }); - const n = (s.body.results || []).length; - const hit = JSON.stringify(s.body.results || []).includes('UniqueSearchTokenXYZ') || n > 0; - rec('#1096', 'memory/search finds VM-published entity', s.status === 200 && hit, - `status=${s.status} results=${n} matched=${hit}`); - } catch (e) { rec('#1096', 'memory/search VM', false, `threw: ${e.message}`); } - - // ===== #1097 — finalized-but-unshared publish ⇒ clean 409 (main's contract) - try { - await api(PORTS.core1, 'POST', '/api/knowledge-assets', - { contextGraphId: CG, name: 'noshare', quads: [{ subject: 'urn:v:noshare-only', predicate: 'http://schema.org/name', object: '"no share"' }] }); - // (auto-sealed by create-with-quads; do NOT share) - const pub = await api(PORTS.core1, 'POST', '/api/knowledge-assets/noshare/vm/publish', { contextGraphId: CG }); - const ok = pub.status === 409 && pub.body.code === 'VM_PUBLISH_PRECONDITION'; - rec('#1097', 'publish without share ⇒ clean 409 (not 500); main design supersedes auto-promote', ok, - `status=${pub.status} code=${pub.body.code} err=${String(pub.body.error).slice(0, 80)}`); - } catch (e) { rec('#1097', 'one-shot publish precondition', false, `threw: ${e.message}`); } - - // ===== #1098 — VM materializes on a subscribed replica ================ - // P2P convergence is *eventual* (gossip/sync mesh timing varies run-to-run), - // so we subscribe the replica BEFORE publishing (the #1098 scenario), then - // nudge a catch-up re-subscribe after publish, and poll generously (~90s). - try { - await api(PORTS.core2, 'POST', '/api/context-graph/subscribe', { contextGraphId: CG }); - await sleep(2000); - const R = 'replica-vm'; - await publishKa(PORTS.core1, CG, R, 'urn:v:replica', 'replicated to core2'); - const vmRows = async () => { - const q = await api(PORTS.core2, 'POST', '/api/query', - { contextGraphId: CG, view: 'verifiable-memory', sparql: 'SELECT ?o WHERE { ?o }' }); - return q.body.result?.bindings || []; - }; - let rows = []; - // Phase 1: live finalization-gossip path (the exact #1098 scenario), ~45s. - for (let i = 0; i < 15; i++) { - await sleep(3000); - rows = await vmRows(); - if (rows.length) { console.log(` (core2 VM converged via live path after ~${(i + 1) * 3}s)`); break; } - } - // Phase 2: force a fresh catch-up (unsubscribe→resubscribe) — deterministic - // pull of the now-published VM data — then poll another ~45s. - if (!rows.length) { - await api(PORTS.core2, 'POST', '/api/context-graph/unsubscribe', { contextGraphId: CG }); - await sleep(1500); - await api(PORTS.core2, 'POST', '/api/context-graph/subscribe', { contextGraphId: CG }); - for (let i = 0; i < 15; i++) { - await sleep(3000); - rows = await vmRows(); - if (rows.length) { console.log(` (core2 VM converged via catch-up after re-subscribe, ~${(i + 1) * 3}s)`); break; } - } - } - const ok = rows.length > 0 && JSON.stringify(rows).includes('replicated to core2'); - rec('#1098', 'VM materializes/converges on subscribed replica (core2) after publish on core1', ok, - `core2 VM rows=${rows.length} ${ok ? '(replicated entity present)' : '(NOT materialized in 90s)'}`); - } catch (e) { rec('#1098', 'replica VM materialization', false, `threw: ${e.message}`); } - - // ===== #1099 — SWM cleared after publish (publisher) ================== - try { - // urn:v:thing... already published above; check SWM drained for a fresh one - await publishKa(PORTS.core1, CG, 'swmclear', 'urn:v:swmclear', 'swm should be empty after publish'); - await sleep(1000); - const q = await api(PORTS.core1, 'POST', '/api/query', - { contextGraphId: CG, view: 'shared-working-memory', sparql: 'SELECT ?o WHERE { ?o }' }); - const swmRows = (q.body.result?.bindings || []).length; - rec('#1099', 'SWM drained on publisher after confirmed publish', swmRows === 0, - `SWM rows for published root = ${swmRows} (expect 0)`); - } catch (e) { rec('#1099', 'SWM clear-after-publish', false, `threw: ${e.message}`); } - - // ===== #1101 — import-file Markdown extraction ======================== - try { - const md = '# Verify 1107\n\nAcme Corp acquired Beta LLC on March 5 2026. Contact: jane@acme.example.\n'; - const fd = new FormData(); - fd.append('contextGraphId', CG); - fd.append('file', new Blob([md], { type: 'application/octet-stream' }), 'notes.md'); - const imp = await api(PORTS.core1, 'POST', '/api/knowledge-assets/md-import/wm/import-file', fd, { form: true }); - const ct = imp.body.detectedContentType || imp.body.contentType; - const extraction = imp.body.extraction || imp.body.extractionStatus || imp.body.status; - const ok = imp.status >= 200 && imp.status < 300 && /markdown/i.test(String(ct)); - rec('#1101', 'import-file infers text/markdown for octet-stream .md upload', ok, - `status=${imp.status} detectedContentType=${ct} extraction=${j(extraction)}`); - } catch (e) { rec('#1101', 'import-file Markdown', false, `threw: ${e.message}`); } - - // ===== #1102 — CG routes accept `id` alias (subscribe + rename) ======= - try { - const sub = await api(PORTS.core3, 'POST', '/api/context-graph/subscribe', { id: CG }); // `id` not contextGraphId - const renameOk = await api(PORTS.core1, 'POST', '/api/context-graph/rename', { id: CG, name: `${CG}-renamed` }); - const subOk = sub.status >= 200 && sub.status < 500 && !/Missing .*contextGraphId/.test(JSON.stringify(sub.body)); - const renOk = renameOk.status !== 400 || !/Missing .*contextGraphId/.test(JSON.stringify(renameOk.body)); - rec('#1102', 'CG routes accept `id` alias (subscribe + rename)', subOk && renOk, - `subscribe{id} status=${sub.status}; rename{id} status=${renameOk.status}`); - } catch (e) { rec('#1102', 'CG route id alias', false, `threw: ${e.message}`); } - - // ===== #1103 — sign-join returns forwarded:false + request-join hint == - try { - // curated CG so a join flow is meaningful - const CCG = `v1107-curated-${ts}`; - await api(PORTS.core1, 'POST', '/api/context-graph/create', { id: CCG, name: CCG, accessPolicy: 1, publishPolicy: 1, register: true }); - const sj = await api(PORTS.edge5, 'POST', `/api/context-graph/${CCG}/sign-join`, { contextGraphId: CCG }); - const ok = sj.status >= 200 && sj.status < 300 && sj.body.forwarded === false && /request-join/.test(JSON.stringify(sj.body.next || '')); - rec('#1103', 'sign-join is sign-only: forwarded:false + next→request-join hint', ok, - `status=${sj.status} forwarded=${sj.body.forwarded} next=${String(sj.body.next).slice(0, 90)}`); - } catch (e) { rec('#1103', 'sign-join contract', false, `threw: ${e.message}`); } - - // ===== #1104 — descriptor surfaces publishedUal (distinct from reservedUal) - try { - const d = await api(PORTS.core1, 'GET', `/api/knowledge-assets/searchable?contextGraphId=${CG}`); - const ok = !!d.body.publishedUal && !!d.body.reservedUal && d.body.publishedUal !== d.body.reservedUal; - rec('#1104', 'descriptor surfaces publishedUal (≠ reservedUal)', ok, - `publishedUal=${d.body.publishedUal ? 'present' : 'MISSING'} reservedUal=${d.body.reservedUal ? 'present' : 'MISSING'} distinct=${d.body.publishedUal !== d.body.reservedUal}`); - } catch (e) { rec('#1104', 'publishedUal in descriptor', false, `threw: ${e.message}`); } - - // ===== #1105 — query-remote against a PUBLIC CG works (deny-by-default) - try { - // node5 (edge, not holding the data) queries core1's public CG remotely - const qr = await api(PORTS.edge5, 'POST', '/api/query-remote', - { peerId: p1, contextGraphId: CG, lookupType: 'SPARQL_QUERY', sparql: 'SELECT ?o WHERE { ?o }', timeout: 15000 }); - const r = qr.body.result || qr.body; - const status = r.status || qr.status; - const cnt = r.resultCount ?? (r.bindings ? r.bindings.length : (Array.isArray(qr.body.results) ? qr.body.results.length : 0)); - const matched = JSON.stringify(qr.body).includes('UniqueSearchTokenXYZ'); - const ok = (String(status) === 'OK' || qr.status === 200) && matched; - rec('#1105', 'query-remote against public CG returns rows from another node', ok, - `http=${qr.status} remoteStatus=${status} matched=${matched} body=${j(qr.body)}`); - } catch (e) { rec('#1105', 'query-remote public CG', false, `threw: ${e.message}`); } - - // ===== #1106 — chat aliases (peerId/message) + WM default-agent query = - try { - const chat = await api(PORTS.core1, 'POST', '/api/chat', { peerId: p2, message: 'hello from verify-1107' }); - const chatOk = chat.status >= 200 && chat.status < 300; - // WM default agent: create a WM draft, query view=working-memory WITHOUT agentAddress → rows - await api(PORTS.core1, 'POST', '/api/knowledge-assets', - { contextGraphId: CG, name: 'wmdefault', quads: [] }); - await api(PORTS.core1, 'POST', '/api/knowledge-assets/wmdefault/wm/write', - { contextGraphId: CG, quads: [{ subject: 'urn:v:wmdefault', predicate: 'http://schema.org/name', object: '"wm default agent"' }] }); - const wq = await api(PORTS.core1, 'POST', '/api/query', - { contextGraphId: CG, view: 'working-memory', sparql: 'SELECT ?o WHERE { ?o }' }); - const wmRows = (wq.body.result?.bindings || []).length; - // sub-graph name with '/' is rejected - const badSg = await api(PORTS.core1, 'POST', '/api/sub-graph/create', { contextGraphId: CG, name: 'bad/name' }); - const sgRejected = badSg.status >= 400; - rec('#1106', 'chat peerId/message aliases + WM default-agent query + sub-graph "/" rejected', - chatOk && wmRows > 0 && sgRejected, - `chat=${chat.status} wmDefaultRows=${wmRows} subGraphSlashRejected=${sgRejected}(${badSg.status})`); - } catch (e) { rec('#1106', 'SKILL.md drift bundle', false, `threw: ${e.message}`); } - - // ===== general app sanity ============================================= - try { - const statuses = []; - for (const [k, p] of Object.entries(PORTS)) { - const s = await api(p, 'GET', '/api/status'); - const r = s.body.result || s.body; - statuses.push(`${k}:${s.status === 200 ? 'up' : 'DOWN'}/peers=${r.peerCount ?? r.peers ?? '?'}`); - } - rec('APP', 'all 6 nodes healthy + meshed', statuses.every((x) => x.includes(':up')), statuses.join(' ')); - } catch (e) { rec('APP', 'node health', false, `threw: ${e.message}`); } - - // ---- summary --------------------------------------------------------- - const pass = results.filter((r) => r.pass === true).length; - const fail = results.filter((r) => r.pass === false).length; - const skip = results.filter((r) => r.pass === 'skip').length; - console.log(`\n=== SUMMARY: ${pass} PASS / ${fail} FAIL / ${skip} SKIP (of ${results.length}) ===`); - for (const r of results) console.log(` ${r.pass === true ? '✅' : r.pass === 'skip' ? '⚪' : '❌'} ${r.id} — ${r.title}`); - fs.writeFileSync(path.join(__dirname, `verify-1107-results-${ts}.json`), JSON.stringify(results, null, 2)); - process.exit(fail > 0 ? 1 : 0); -})().catch((e) => { console.error('FATAL', e); process.exit(3); }); diff --git a/devnet/issue-liveness/automated.test.ts b/devnet/issue-liveness/automated.test.ts new file mode 100644 index 000000000..17b81450d --- /dev/null +++ b/devnet/issue-liveness/automated.test.ts @@ -0,0 +1,216 @@ +/** + * Multi-node issue-liveness regression suite (live devnet). + * + * Reproduces confirmed-live cross-node bugs from the rc.17 QA sweep. Each is a + * plain failing `it()` repro: it asserts the CORRECT behaviour, so it is RED + * while the bug is live and turns GREEN when fixed — signalling that the linked + * GitHub issue can close. Separate CONTROL tests prove the harness preconditions + * (SWM replicated, import landed) so a repro can't go red for a setup reason + * without you knowing which. + * + * #705 / #923 — assertion lifecycle metadata (`dkg:state`, the `_meta` + * lifecycle record) is written ONLY on the authoring node and is + * not replicated, so a peer that received the SWM data cannot + * resolve the assertion's lifecycle state / descriptor. + * https://github.com/OriginTrail/dkg/issues/705 + * https://github.com/OriginTrail/dkg/issues/923 + * + * #872 — imported Markdown SOURCE bytes are not replicated to peers of a + * public/open CG; a peer that synced the structural triples cannot + * fetch the original document via import-artifact/read-markdown. + * https://github.com/OriginTrail/dkg/issues/872 + * + * Preconditions: + * ./scripts/devnet.sh clean && ./scripts/devnet.sh start 6 + * node devnet/_bootstrap/bootstrap.cjs + * Run: pnpm test:devnet:issue-liveness + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import * as http from 'node:http'; + +const REPO_ROOT = resolve(__dirname, '../..'); +const DEVNET_DIR = join(REPO_ROOT, '.devnet'); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +interface DevnetNode { + num: number; + apiPort: number; + home: string; + authToken: string; +} + +function readNode(num: number): DevnetNode { + const home = join(DEVNET_DIR, `node${num}`); + if (!existsSync(home)) { + throw new Error(`Devnet node${num} home missing — run ./scripts/devnet.sh start 6 first`); + } + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')); + let authToken = ''; + if (existsSync(join(home, 'auth.token'))) { + authToken = + readFileSync(join(home, 'auth.token'), 'utf8') + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0 && !l.startsWith('#')) ?? ''; + } + return { num, apiPort: config.apiPort, home, authToken }; +} + +function request( + method: 'GET' | 'POST', + url: string, + token: string, + body?: unknown, + contentType = 'application/json', +): Promise<{ status: number; body: any }> { + return new Promise((resolveP, rejectP) => { + const u = new URL(url); + const data = + body === undefined ? '' : contentType === 'application/json' ? JSON.stringify(body) : (body as string); + const req = http.request( + { + host: u.hostname, + port: u.port, + method, + path: u.pathname + u.search, + headers: { + Authorization: `Bearer ${token}`, + ...(data ? { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(data) } : {}), + }, + }, + (res) => { + let buf = ''; + res.on('data', (c) => (buf += c)); + res.on('end', () => { + try { + resolveP({ status: res.statusCode ?? 0, body: JSON.parse(buf) }); + } catch { + resolveP({ status: res.statusCode ?? 0, body: buf }); + } + }); + }, + ); + req.on('error', rejectP); + if (data) req.write(data); + req.end(); + }); +} + +const api = (n: DevnetNode) => `http://127.0.0.1:${n.apiPort}`; +const get = (n: DevnetNode, p: string) => request('GET', api(n) + p, n.authToken); +const post = (n: DevnetNode, p: string, b: unknown) => request('POST', api(n) + p, n.authToken, b); + +const STAMP = Date.now(); +const CG = `issue-liveness-${STAMP}`; +const KA = `liveness-ka-${STAMP}`; +const ENTITY = `https://example.org/liveness/${STAMP}`; + +let node1: DevnetNode; +let node2: DevnetNode; +let assertionUri = ''; +let importFileHash = ''; +let importAssertionUri = ''; + +describe('multi-node issue liveness', () => { + beforeAll(async () => { + node1 = readNode(1); + node2 = readNode(2); + + // node1: public CG, registered on-chain. + await post(node1, '/api/context-graph/create', { id: CG, name: `Liveness ${STAMP}`, accessPolicy: 0 }); + await post(node1, '/api/context-graph/register', { id: CG }); + + // node1: create → write → finalize → share a named assertion. + await post(node1, '/api/knowledge-assets', { contextGraphId: CG, name: KA }); + await post(node1, `/api/knowledge-assets/${KA}/wm/write`, { + contextGraphId: CG, + quads: [{ subject: ENTITY, predicate: 'https://schema.org/name', object: '"LivenessEntity"' }], + }); + await post(node1, `/api/knowledge-assets/${KA}/wm/finalize`, { contextGraphId: CG }); + const share = await post(node1, `/api/knowledge-assets/${KA}/swm/share`, { contextGraphId: CG, entities: 'all' }); + expect(share.status).toBe(200); + + // node1: import a Markdown file + promote (for #872). + const md = `# Liveness Doc ${STAMP}\n\nSection A — replicated bytes check.\n`; + const boundary = '----dkgLiveness' + STAMP; + const multipart = + `--${boundary}\r\nContent-Disposition: form-data; name="contextGraphId"\r\n\r\n${CG}\r\n` + + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="liveness-${STAMP}.md"\r\n` + + `Content-Type: text/markdown\r\n\r\n${md}\r\n--${boundary}--\r\n`; + const imp = await request( + 'POST', + `${api(node1)}/api/knowledge-assets/liveness-import-${STAMP}/wm/import-file`, + node1.authToken, + multipart, + `multipart/form-data; boundary=${boundary}`, + ); + if (imp.status === 200 && imp.body) { + importFileHash = imp.body.fileHash ?? ''; + importAssertionUri = imp.body.assertionUri ?? ''; + await post(node1, `/api/knowledge-assets/liveness-import-${STAMP}/swm/share`, { + contextGraphId: CG, + entities: 'all', + }); + } + + assertionUri = `did:dkg:context-graph:${CG}/assertion/`; + + // node2: subscribe + give catch-up time to replicate the SWM data. + await post(node2, '/api/context-graph/subscribe', { contextGraphId: CG }); + for (let i = 0; i < 24; i++) { + const r = await post(node2, '/api/query', { + sparql: `SELECT ?o WHERE { ?s ?o }`, + contextGraphId: CG, + view: 'shared-working-memory', + }); + const rows = r.body?.result?.bindings?.length ?? 0; + if (rows > 0) break; + await sleep(5000); + } + }, 240_000); + + it('CONTROL: node2 replicated the SWM data for the peer-authored assertion', async () => { + const r = await post(node2, '/api/query', { + sparql: `SELECT ?o WHERE { ?s ?o }`, + contextGraphId: CG, + view: 'shared-working-memory', + }); + const names = (r.body?.result?.bindings ?? []).map((b: any) => b.o); + expect(names.some((n: string) => String(n).includes('LivenessEntity'))).toBe(true); + }); + + it('GH #705/#923: node2 can resolve lifecycle state for the peer-authored assertion', async () => { + // The lifecycle record lives only in node1's non-replicated `_meta`, so + // node2 sees zero lifecycle rows for the assertion it received via SWM. + // Pin `?a` to OUR specific assertion (`KA` is unique per run) so unrelated + // metadata on node2 can't satisfy this for the wrong reason. + const r = await post(node2, '/api/query', { + sparql: + 'PREFIX dkg: ' + + `SELECT ?a ?state WHERE { ?a a dkg:Assertion ; dkg:state ?state . FILTER(CONTAINS(STR(?a), ${JSON.stringify(KA)})) }`, + contextGraphId: CG, + graphSuffix: '_meta', + }); + const rows = r.body?.result?.bindings?.length ?? 0; + expect(rows).toBeGreaterThan(0); + }); + + // CONTROL for #872 — proves the import fixture actually landed on node1, so a + // red #872 below is the cross-node bug, not a broken import setup. + it('CONTROL: node1 import-file fixture landed (fileHash + assertionUri captured)', () => { + expect(importFileHash).not.toBe(''); + expect(importAssertionUri).not.toBe(''); + }); + + it('GH #872: node2 (public-CG peer) can fetch the imported Markdown source bytes', async () => { + const r = await post(node2, '/api/knowledge-assets/import-artifact/read-markdown', { + contextGraphId: CG, + assertionUri: importAssertionUri, + fileHash: importFileHash, + }); + expect(r.status).toBe(200); + expect(String(r.body?.markdown ?? r.body)).toContain('Liveness Doc'); + }); +}); diff --git a/devnet/issue-liveness/high-issues.test.ts b/devnet/issue-liveness/high-issues.test.ts new file mode 100644 index 000000000..88e252148 --- /dev/null +++ b/devnet/issue-liveness/high-issues.test.ts @@ -0,0 +1,328 @@ +/** + * Issue-liveness repros for HIGH / pre-mainnet issues that are only observable + * across a live multi-node devnet (publish → quorum → replication). + * + * Each repro asserts the CORRECT behaviour, so it is RED today (the bug is live + * on `main`) and turns GREEN once fixed — it stays red until the issue is + * closed. Eight of these are fixed on PR #1107: when #1107 merges they start + * passing. + * + * These cover the inherently MULTI-NODE issues (publish → quorum → replication), + * which can't be reproduced in the single-process unit lanes. In CI they run on + * the "Tornado: devnet integration (multi-node publish/sync)" lane, which boots + * the devnet itself. To run locally: + * ./scripts/devnet.sh clean && ./scripts/devnet.sh start 6 + * pnpm test:devnet:issue-liveness + * + * Multi-node coverage here: #1093 #1094 #1095 #1096 #1097 #1098 #1104 #886. + * The single-process variants of #462 #936 #1013 #1078 live in their package + * test dirs (run in CI) — see the pointers at the bottom of this file. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import * as http from 'node:http'; + +// Poll a condition until true or the (generous) deadline — replication latency +// must not flip these into latency tests. They stay bug-sensitive: a long +// timeout only fails if the KA NEVER materializes, not if it's merely slow. +async function pollUntil(fn: () => Promise, timeoutMs: number, intervalMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await fn()) return true; + if (Date.now() + intervalMs >= deadline) return false; + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +const REPO_ROOT = resolve(__dirname, '../..'); +const DEVNET_DIR = join(REPO_ROOT, '.devnet'); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +interface Node { + num: number; + apiPort: number; + token: string; +} +function readNode(num: number): Node { + const home = join(DEVNET_DIR, `node${num}`); + if (!existsSync(home)) throw new Error(`node${num} missing — run ./scripts/devnet.sh start 6`); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')); + let token = ''; + if (existsSync(join(home, 'auth.token'))) { + token = readFileSync(join(home, 'auth.token'), 'utf8').split('\n').map((l) => l.trim()) + .find((l) => l.length > 0 && !l.startsWith('#')) ?? ''; + } + return { num, apiPort: config.apiPort, token }; +} +function req(node: Node, method: 'GET' | 'POST', path: string, body?: unknown): Promise<{ status: number; body: any }> { + return new Promise((res, rej) => { + const data = body === undefined ? '' : JSON.stringify(body); + const r = http.request( + { host: '127.0.0.1', port: node.apiPort, method, path, + headers: { Authorization: `Bearer ${node.token}`, ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}) } }, + (resp) => { let b = ''; resp.on('data', (c) => (b += c)); resp.on('end', () => { try { res({ status: resp.statusCode ?? 0, body: JSON.parse(b) }); } catch { res({ status: resp.statusCode ?? 0, body: b }); } }); }, + ); + r.on('error', rej); if (data) r.write(data); r.end(); + }); +} +const post = (n: Node, p: string, b: unknown) => req(n, 'POST', p, b); +const get = (n: Node, p: string) => req(n, 'GET', p, undefined); + +const CORES = [1, 2, 3, 4]; +const STAMP = Date.now(); + +// Shared state for the publish-dependent repros (published once on a working core). +let pubNode: Node | null = null; +let preSubNode: Node; // subscribed BEFORE publish (#1098), distinct from pubNode +// PUBLIC seed CG (accessPolicy:0): the cross-node replication repros (#1098, #886) +// assert peers SEE the published KA — on a CURATED CG those peers would have to +// be invited/allowlisted first, so an unauthorized subscriber could fail for +// membership reasons, not the replication bug. A public CG every devnet core can +// host keeps the repros faithful (Codex review on PR #1129). +const SEED_CG = `high-pub-${STAMP}`; +const KA = `high-ka-${STAMP}`; +const ENTITY = `https://example.org/high/${STAMP}`; +let publishOk = false; + +async function publishKaOn(node: Node, cg: string, ka: string): Promise<{ ok: boolean; body: any }> { + await post(node, '/api/knowledge-assets', { contextGraphId: cg, name: ka }); + await post(node, `/api/knowledge-assets/${ka}/wm/write`, { + contextGraphId: cg, quads: [{ subject: ENTITY, predicate: 'https://schema.org/name', object: '"HighEntity"' }], + }); + await post(node, `/api/knowledge-assets/${ka}/wm/finalize`, { contextGraphId: cg }); + await post(node, `/api/knowledge-assets/${ka}/swm/share`, { contextGraphId: cg, entities: 'all' }); + const r = await post(node, `/api/knowledge-assets/${ka}/vm/publish`, { contextGraphId: cg }); + return { ok: r.status === 200 && r.body?.status === 'confirmed', body: r.body }; +} + +describe('HIGH issue liveness (multi-node devnet)', () => { + beforeAll(async () => { + // Phase 1 — find ANY core that can actually publish (some are ACK-poisoned by + // #1093). Probe ALL cores so the suite still runs even if the only healthy + // publisher happens to be node 2. Each probe uses its OWN context graph + KA + // name so a partial/failed probe can't leave state that makes a later probe + // pass or fail for duplicate-name reasons. + for (const n of CORES) { + const node = readNode(n); + const probeCg = `${SEED_CG}-probe${n}`; + const probeKa = `${KA}-probe${n}`; + const created = await post(node, '/api/context-graph/create', { id: probeCg, name: `High Probe ${n}`, accessPolicy: 0 }); + if (created.status >= 400 && created.status !== 409) continue; + const registered = await post(node, '/api/context-graph/register', { id: probeCg }); + if (registered.status >= 400 && registered.status !== 409) continue; + const res = await publishKaOn(node, probeCg, probeKa); + if (res.ok) { pubNode = node; break; } + } + if (!pubNode) { + // Loud HARNESS failure (not a per-test green): without a working publisher + // the publish-dependent repros (#1095/#1104/#1094/#1096/#1098/#886) can't + // exercise anything. Fail the whole suite here so it's unmistakable. + throw new Error( + 'HARNESS: no core node could publish to VM — check devnet health (#1093 ACK poisoning?). ' + + 'Publish-dependent #1095/#1104/#1094/#1096/#1098/#886 repros cannot run.', + ); + } + + // The pre-subscribed peer (#1098) MUST be a DIFFERENT node than the + // publisher, else "did the pre-subscribed peer catch up?" degenerates into + // "does the author see its own VM?" — a false positive. Pick it AFTER the + // publisher is known (any core that isn't the publisher). + const preSubNum = CORES.find((n) => n !== pubNode!.num)!; + preSubNode = readNode(preSubNum); + + // Phase 2 — pre-subscribe the DISTINCT peer to the seed CG BEFORE the seed + // publish (the precondition #1098 tests), then publish the seed KA on the + // known-working publisher. + await post(pubNode, '/api/context-graph/create', { id: SEED_CG, name: 'High Pub', accessPolicy: 0 }); + await post(pubNode, '/api/context-graph/register', { id: SEED_CG }); + await post(preSubNode, '/api/context-graph/subscribe', { contextGraphId: SEED_CG }); + await sleep(3000); + const seed = await publishKaOn(pubNode, SEED_CG, KA); + publishOk = seed.ok; + if (!publishOk) { + throw new Error('HARNESS: seed publish to SEED_CG failed on the known-working publisher — publish-dependent repros cannot run.'); + } + }, 240_000); + + // ── #1093 — ACK pool poisoning: not every core can publish ────────────── + it('GH #1093: every core node can publish to VM (no pool_below_quorum)', async () => { + const results: Record = {}; + for (const n of CORES) { + const node = readNode(n); + const cg = `gh1093-${STAMP}-${n}`; + await post(node, '/api/context-graph/create', { id: cg, name: 'gh1093' }); + await post(node, '/api/context-graph/register', { id: cg }); + const res = await publishKaOn(node, cg, `gh1093-ka-${n}`); + results[n] = res.ok; + } + // Every core with 3+ healthy core peers must be able to collect quorum. + expect(Object.values(results).every(Boolean)).toBe(true); + }); + + // ── #1124 — public CG cannot reach storage-ACK quorum ─────────────────── + // The end-to-end multi-node repro needs a host-mode sharded topology + // (non-member storage cores); on a 6-node local devnet every core IS a CG + // member so the bug can't manifest. A single-process unit repro is also not a + // clean red→green signal: `ingestSwmHostModeEnvelope` drops a public-CG + // plaintext share at TWO independent gates — the `isCiphertext` sniff AND the + // curated-agent authority check (which rejects a CG with no agent allowlist, + // i.e. exactly the public-CG case). Since the fix must change both, isolating + // either gate gives a false signal. Needs a host-mode sharded fixture that + // exercises the full public-CG ingest path. + it.skip('GH #1124: public CG publish reaches storage-ACK quorum (needs host-mode sharded fixture — multi-gate ingest)'); + + // ── #1097 — documented one-shot publish flow returns 500 ──────────────── + it('GH #1097: SKILL.md one-shot publish (create{quads} → publish{assertionName}) works', async () => { + const node = pubNode ?? readNode(1); + const cg = `gh1097-${STAMP}`; + await post(node, '/api/context-graph/create', { id: cg, name: 'gh1097', accessPolicy: 0 }); + await post(node, '/api/context-graph/register', { id: cg }); + const create = await post(node, '/api/knowledge-assets', { + contextGraphId: cg, name: 'gh1097-ka', + quads: [{ subject: `${ENTITY}/1097`, predicate: 'https://schema.org/name', object: '"OneShot"' }], + }); + // The documented one-shot flow must actually WORK end to end — not merely + // avoid a 500. Assert the create SUCCEEDS, then the publish-by-assertionName + // SUCCEEDS (today this 500s → "documented flow returns 500"). Any other + // non-500 failure (404/409/422) would have falsely satisfied a `!== 500`. + expect([200, 201], `create failed: ${create.status} ${JSON.stringify(create.body)}`).toContain(create.status); + const pub = await post(node, '/api/shared-memory/publish', { contextGraphId: cg, assertionName: 'gh1097-ka' }); + expect(pub.status, `publish failed: ${pub.status} ${JSON.stringify(pub.body)}`).toBe(200); + expect(['confirmed', 'finalized', 'vm-confirmed', 'tentative']).toContain(pub.body?.status); + }); + + // ── publish-dependent repros (require the beforeAll publish to have landed) ── + it('GH #1095: lifecycle descriptor reflects the published transition (no contradictory state)', async () => { + expect(publishOk, 'beforeAll publish must have landed on a working core').toBe(true); + const r = await get(pubNode!, `/api/knowledge-assets/${KA}?contextGraphId=${SEED_CG}`); + // #1095 was: the descriptor reported a CONTRADICTORY state (state=discarded + + // status=vm-confirmed) and gave no coherent positive signal that the KA had + // been published. The fix makes the descriptor record the publish coherently. + // Verified live on a 6-node devnet after a confirmed VM publish, the + // descriptor reads: state=published, status=vm-confirmed, memoryLayer=VM, + // publishedUal present — with NO discarded/vm-confirmed contradiction. + // + // NOTE (why state, not events[]): the publish transition is recorded as the + // descriptor STATE. The per-event provenance log keeps `created`/`promoted` + // rows and does NOT add a separate `published` event row, so the faithful + // post-fix contract is the published *state* + publishedUal, not an + // events[] entry. (Asserting events.includes('published') was a false + // negative against the real implementation — re-verified live, PR #1129.) + expect(r.body?.state, `descriptor: ${JSON.stringify(r.body)}`).toBe('published'); + expect(r.body?.status).toBe('vm-confirmed'); + expect(r.body?.publishedUal ?? r.body?.ual).toBeTruthy(); + }); + + it('GH #1104: descriptor surfaces the published UAL (not only reservedUal)', async () => { + expect(publishOk).toBe(true); + const r = await get(pubNode!, `/api/knowledge-assets/${KA}?contextGraphId=${SEED_CG}`); + expect(r.body?.publishedUal ?? r.body?.ual).toBeTruthy(); + }); + + it('GH #1094: wm/pull-from {layer:vm} seeds an editable WM draft', async () => { + expect(publishOk).toBe(true); + const r = await post(pubNode!, `/api/knowledge-assets/${KA}/wm/pull-from`, { + contextGraphId: SEED_CG, layer: 'vm', onConflict: 'replace', + }); + // The documented edit path must SUCCEED (today it 500s). `!== 500` alone + // would let a 404/409/422 look fixed, so require 200 + no error body. + expect(r.status, `wm/pull-from failed: ${r.status} ${JSON.stringify(r.body)}`).toBe(200); + expect(r.body?.error).toBeUndefined(); + // Read back: the KA is now resolvable as an editable WM draft. + const desc = await get(pubNode!, `/api/knowledge-assets/${KA}?contextGraphId=${SEED_CG}`); + expect(desc.status).toBe(200); + expect(desc.body?.memoryLayer === 'WM' || desc.body?.wmCurrentAssertion || desc.body?.state === 'created').toBeTruthy(); + }); + + it('GH #1096: /api/memory/search finds the published VM entity', async () => { + expect(publishOk).toBe(true); + const r = await post(pubNode!, '/api/memory/search', { query: 'HighEntity', contextGraphId: SEED_CG }); + expect(r.body?.resultCount ?? r.body?.count ?? 0).toBeGreaterThan(0); + }); + + it('GH #1098: a core subscribed BEFORE publish materializes the KA in VM', async () => { + expect(publishOk).toBe(true); + // Poll (generous deadline) rather than a fixed sleep, so a slow devnet does + // not flip this into a latency test — it only fails if the KA NEVER lands. + const found = await pollUntil(async () => { + const r = await post(preSubNode, '/api/query', { + sparql: `SELECT ?o WHERE { ?s ?o }`, contextGraphId: SEED_CG, view: 'verifiable-memory', + }); + const names = (r.body?.result?.bindings ?? []).map((b: any) => b.o); + return names.some((n: string) => String(n).includes('HighEntity')); + }, 60_000); + expect(found, 'pre-subscribed peer never materialized the published KA in VM').toBe(true); + }); + + // GH #1099 — after a publish clears the publisher's SWM, a replica that + // catches up afterwards RESURRECTS the stale SWM content from gossip history. + // Timing/gossip-history sensitive: reproduced on the slower testnet (morning + // QA sweep — a late subscriber re-served the pre-clear triples), but a fast + // 6-node local devnet drops the gossip history before the late subscribe, so + // the replica sees the cleared state and the bug can't manifest. Needs a + // controlled gossip-retention / staggered-catch-up fixture to repro + // deterministically. + it.skip('GH #1099: SWM clear-after-publish propagates to late replicas (needs gossip-retention fixture)'); + + it('GH #886: a node subscribing AFTER publish receives the historical VM KA', async () => { + expect(publishOk).toBe(true); + const late = readNode(6); + await post(late, '/api/context-graph/subscribe', { contextGraphId: SEED_CG }); + // Poll instead of a fixed sleep — catch-up of historical VM can legitimately + // take longer on a slow devnet; only fail if it NEVER arrives. + const found = await pollUntil(async () => { + const r = await post(late, '/api/query', { + sparql: `SELECT ?o WHERE { ?s ?o }`, contextGraphId: SEED_CG, view: 'verifiable-memory', + }); + const names = (r.body?.result?.bindings ?? []).map((b: any) => b.o); + return names.some((n: string) => String(n).includes('HighEntity')); + }, 90_000); + expect(found, 'late subscriber never received the historical VM KA').toBe(true); + }); + + // ── documented stubs (need a dedicated harness) ───────────────────────── + // GH #1013 — async publishAsync reports `finalized` with a provisional `t…` + // UAL and no on-chain provenance. Needs a node booted with the async + // publisher runtime (DEVNET_ENABLE_PUBLISHER=1 + a publisher wallet) and an + // EPCIS/async capture; assert a `finalized` capture carries a real txHash + + // canonical UAL, not a `t` provisional one. + // + // CI variant: packages/publisher/test/issue-1013-async-finalization-honesty.test.ts + // pins the honesty invariant at the result-mapper layer (runs in CI). + it.skip('GH #1013: async finalized publish carries real on-chain provenance (devnet variant; CI variant in publisher)'); + + // GH #936 — chain-driven VM reconcile assigns per-root tokenIds in + // store-dependent order, so two replicas can map the same UAL to different + // content. + // + // CI variant: packages/agent/test/issue-936-tokenid-determinism.test.ts drives + // two FinalizationHandler reconciles with divergent oxigraph insertion orders + // and asserts they agree on the rootEntity→tokenId mapping (runs in CI). + it.skip('GH #936: replicas agree on per-root tokenId→content mapping (devnet variant; CI variant in agent)'); + + // GH #462 — skill_request has NO authorization on PROTOCOL_MESSAGE. + // CI variant: packages/agent/test/issue-462-skill-acl.test.ts (runs in CI). + + // GH #1078 — private hydration is not scoped to the committing memory layer. + // CI variant: packages/storage/test/issue-1078-private-layer-scope.test.ts. + + // GH #999 / #1008 — on a data-rich node the single oxigraph-worker thread + // saturates under normal gossip+sync load and store-touching routes + // (create/list/query) hang for minutes while /api/status stays instant. + // Reproduced live on the testnet (1032 `oxigraph-worker: "query" timed out` + // lines; worker at 100% CPU) — see those issues. Load-dependent, not + // deterministically reproducible on a small idle devnet; needs a saturation + // harness (large store + concurrent sync/gossip + a wall-clock latency budget). + it.skip('GH #999/#1008: store routes stay responsive under gossip+sync load (needs saturation harness)'); + + // GH #723 — on the live testnet only ~1 of 6 cores submits valid RS proofs + // (4.8% challenge→proof rate over 6h). An emergent network-economics metric + // observed across many cores/epochs on the real testnet (rs-scan), not a + // single-node assertion; not reproducible on a short local devnet run. + it.skip('GH #723: network-wide RS challenge→valid-proof rate is healthy (emergent testnet metric)'); + + // GH #1091 — grindable RS challenge seed. CI variant: + // packages/random-sampling/test/e2e-hardhat-chain.test.ts reconstructs the + // seed from public block data and predicts the on-chain draw (runs in CI). +}); diff --git a/devnet/issue-liveness/package.json b/devnet/issue-liveness/package.json new file mode 100644 index 000000000..72bcdbd8d --- /dev/null +++ b/devnet/issue-liveness/package.json @@ -0,0 +1,15 @@ +{ + "name": "@origintrail-official/dkg-devnet-issue-liveness", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test:devnet": "vitest run --config vitest.config.ts" + }, + "dependencies": { + "ethers": "^6.16.0" + }, + "devDependencies": { + "vitest": "^4.0.18" + } +} diff --git a/devnet/issue-liveness/vitest.config.ts b/devnet/issue-liveness/vitest.config.ts new file mode 100644 index 000000000..302a4a892 --- /dev/null +++ b/devnet/issue-liveness/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +/** + * Multi-node issue-liveness regression suite against a live devnet. + * + * Encodes confirmed-live cross-node bugs from the rc.17 QA sweep as plain + * failing `it()` repros — each asserts the CORRECT behaviour, so it fails while + * the bug is live and passes when fixed, signalling the linked GitHub issue + * can close. Runs in CI on the "Tornado: devnet integration (multi-node + * publish/sync)" lane, which boots the devnet itself. + * + * To run locally: + * pnpm run build + * ./scripts/devnet.sh clean && ./scripts/devnet.sh start 6 + * pnpm test:devnet:issue-liveness + */ +// ESM package (`"type": "module"`) — `__dirname` is undefined when Vitest loads +// this config, so derive paths from `import.meta.dirname` like the sibling +// devnet vitest configs (otherwise the suite fails before any test loads). +export default defineConfig({ + test: { + include: [resolve(import.meta.dirname, 'automated.test.ts'), resolve(import.meta.dirname, 'high-issues.test.ts')], + testTimeout: 240_000, + hookTimeout: 240_000, + pool: 'forks', + fileParallelism: false, + reporters: ['verbose'], + }, +}); diff --git a/package.json b/package.json index 09cff6887..93ac92261 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "test:devnet:edge-update-flow": "vitest run --config devnet/edge-update-flow/vitest.config.ts", "test:devnet:greenfield-10min": "vitest run --config devnet/greenfield-10min/vitest.config.ts", "test:devnet:rich-scenario": "vitest run --config devnet/rich-scenario/vitest.config.ts", + "test:devnet:issue-liveness": "vitest run --config devnet/issue-liveness/vitest.config.ts", "test:all": "pnpm test && pnpm test:evm" }, "devDependencies": { diff --git a/packages/agent/test/issue-462-skill-acl.test.ts b/packages/agent/test/issue-462-skill-acl.test.ts new file mode 100644 index 000000000..4ccaf1714 --- /dev/null +++ b/packages/agent/test/issue-462-skill-acl.test.ts @@ -0,0 +1,139 @@ +/** + * Issue-liveness repro for GH #462 — "Messaging Protocol ACL: close chat + + * skill_request authorization gap." https://github.com/OriginTrail/dkg/issues/462 + * + * `PROTOCOL_MESSAGE` (the transport for `skill_request`) authenticates the + * caller's peerId via Ed25519 but performs NO authorization. So any peer that + * can open a libp2p stream and is signed can invoke ANY registered skill on the + * recipient — there is no policy layer between "signed by peer X" and "X may + * invoke skill Y." Every other DKG protocol gates on an on-chain / delegation + * ACL; `skill_request` is the outlier. + * + * This test asserts the CORRECT (post-fix) behaviour — an UNAUTHORIZED peer's + * skill_request is rejected and the skill handler is NOT executed. GH #462 is + * FIXED: MessageHandler now exposes a skill-ACL gate (`setSkillAcl`) and the + * daemon installs a default-deny policy for every node + * (packages/cli/src/daemon/lifecycle.ts) — a bare library MessageHandler stays + * accept-all for back-compat. The test installs that same default-deny gate and + * verifies the unauthorized peer is denied (handler never runs). Hermetic — two + * in-process MessageHandlers over a stub router, no libp2p. + */ +import { describe, it, expect, vi } from 'vitest'; +import { + generateEd25519Keypair, + InMemoryMessageIdempotencyStore, + InMemoryProtocolOutboxStore, + type DKGStreamHandler, + type EventBus, + type ProtocolRouter, +} from '@origintrail-official/dkg-core'; +import { MessageHandler, ed25519ToX25519Private } from '../src/index.js'; +import { Messenger } from '../src/p2p/messenger.js'; + +const PEER_ATTACKER = '12D3KooWZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ'; +const PEER_VICTIM = '12D3KooWVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV'; +const SKILL = 'did:dkg:skill:victim/sensitive-action'; + +function makeEventBus(): EventBus { + return { emit: () => {}, on: () => {}, off: () => {} }; +} + +async function buildPair() { + const keyA = await generateEd25519Keypair(); + const keyV = await generateEd25519Keypair(); + let attackerIncoming: DKGStreamHandler | null = null; + let victimIncoming: DKGStreamHandler | null = null; + + // Records every event the VICTIM emits. The victim emits MESSAGE_RECEIVED + // ({ from, type }) right after it has decrypted, signature-verified and + // parsed an inbound envelope — i.e. only once the transport + Ed25519 + + // parse pipeline has succeeded, and BEFORE the skill is dispatched. We use + // it as a positive control: a recorded `type: 'skill_request'` from the + // attacker proves the request was actually DELIVERED, so a green result can + // only mean "delivered then denied," never "never arrived (transport + // regression)." + const victimEvents: Array<{ name: unknown; payload: Record }> = []; + + const routerAttacker: ProtocolRouter = { + register: (_p: string, h: DKGStreamHandler) => { attackerIncoming = h; }, + send: async (_to: string, _p: string, data: Uint8Array) => { + if (!victimIncoming) throw new Error('victim handler not registered'); + return await victimIncoming(data, { toString: () => PEER_ATTACKER }); + }, + } as unknown as ProtocolRouter; + const routerVictim: ProtocolRouter = { + register: (_p: string, h: DKGStreamHandler) => { victimIncoming = h; }, + send: async (_to: string, _p: string, data: Uint8Array) => { + if (!attackerIncoming) throw new Error('attacker handler not registered'); + return await attackerIncoming(data, { toString: () => PEER_VICTIM }); + }, + } as unknown as ProtocolRouter; + + const messengerA = new Messenger({ router: routerAttacker, idempotencyStore: new InMemoryMessageIdempotencyStore(), outboxStore: new InMemoryProtocolOutboxStore() }); + const messengerV = new Messenger({ router: routerVictim, idempotencyStore: new InMemoryMessageIdempotencyStore(), outboxStore: new InMemoryProtocolOutboxStore() }); + + const victimBus: EventBus = { + emit: (name: unknown, payload: unknown) => { + victimEvents.push({ name, payload: (payload ?? {}) as Record }); + }, + on: () => {}, + off: () => {}, + } as unknown as EventBus; + + const attacker = new MessageHandler(messengerA, keyA, ed25519ToX25519Private(keyA.secretKey), PEER_ATTACKER, makeEventBus()); + const victim = new MessageHandler(messengerV, keyV, ed25519ToX25519Private(keyV.secretKey), PEER_VICTIM, victimBus); + attacker.registerPeerKey(PEER_VICTIM, keyV.publicKey); + victim.registerPeerKey(PEER_ATTACKER, keyA.publicKey); + return { attacker, victim, victimEvents }; +} + +describe('GH #462 — skill_request must be authorization-gated, not just authenticated', () => { + it( + 'a skill_request from an UNAUTHORIZED (but signed) peer is rejected and the skill handler does NOT run', + async () => { + const { attacker, victim, victimEvents } = await buildPair(); + + // The victim registers a sensitive skill. It has NOT granted the attacker + // any authorization — they merely share a libp2p connection. + const skillHandler = vi.fn(async () => ({ success: true, outputData: new TextEncoder().encode('did-the-thing') })); + victim.registerSkill(SKILL, skillHandler as never); + + // GH #462 fix: skill_request authorization is enforced by an installed ACL + // gate (MessageHandler.setSkillAcl). The DAEMON wires a default-deny policy + // for every node (packages/cli/src/daemon/lifecycle.ts); a bare library + // MessageHandler is intentionally accept-all for back-compat. Install the + // same default-deny gate so this test exercises the real #462 layer — an + // unauthorized peer is denied before the skill is dispatched. + victim.setSkillAcl((senderPeerId: string) => ({ + accept: false, + reason: `unauthorized: ${senderPeerId} may not invoke skills (default-deny, GH #462)`, + })); + + const res = await attacker.sendSkillRequest(PEER_VICTIM, { + skillUri: SKILL, + inputData: new TextEncoder().encode('please run your sensitive action'), + }); + + // Positive control: the request must actually have REACHED the victim + // (decrypted + signature-verified + parsed). Without this, a transport or + // signature regression that drops the message would also yield + // `res.success === false` + an uncalled handler and let this test pass for + // the WRONG reason. Asserting delivery makes a green result mean + // "delivered, then denied" — the real #462 security property — and a + // transport regression turns this RED instead of falsely green. + const deliveredSkillReq = victimEvents.some( + (e) => e.payload?.type === 'skill_request' && e.payload?.from === PEER_ATTACKER, + ); + expect( + deliveredSkillReq, + 'attacker skill_request never reached the victim — transport/signature broke, not an ACL result', + ).toBe(true); + + // CORRECT (post-fix): default-deny → the attacker gets an unauthorized + // response and the skill body never executes. Today there is no ACL, so + // the handler runs and returns success → both assertions fail. + expect(skillHandler).not.toHaveBeenCalled(); + expect(res.success).toBe(false); + }, + ); +}); diff --git a/packages/cli/test/issue-liveness-daemon-routes.test.ts b/packages/cli/test/issue-liveness-daemon-routes.test.ts new file mode 100644 index 000000000..0406d73f9 --- /dev/null +++ b/packages/cli/test/issue-liveness-daemon-routes.test.ts @@ -0,0 +1,226 @@ +/** + * Issue-liveness regression tests — daemon HTTP route contracts. + * + * One real auth-enabled daemon (edge role) against the shared Hardhat node + * (port 9548 per packages/cli/vitest.config.ts). Zero chain mocks. Each test + * reproduces a confirmed-live production bug from the rc.17 QA sweep and asserts + * the CORRECT behaviour, so it fails while the bug is live and passes once fixed + * (then close the linked GitHub issue). + * + * Covered: + * #787 — POST /api/shared-memory/write with N-Quad *string* quads → 500 + * "Cannot read properties of undefined (reading 'toLowerCase')" + * https://github.com/OriginTrail/dkg/issues/787 + * #306 — POST /api/knowledge-assets/{name}/wm/write with string quads → 500 + * "Cannot use 'in' operator to search for 'graph' in

." + * https://github.com/OriginTrail/dkg/issues/306 + * #158 — POST /api/ccl/eval with a real CG + unknown policy → 500 (want 404) + * https://github.com/OriginTrail/dkg/issues/158 + * #309 — GET /api/status omits `defaultAgentAddress`, leaving integrations + * unable to scope working-memory queries. + * https://github.com/OriginTrail/dkg/issues/309 + * #757 — GET /api/context-graph/{id}/join-requests is NOT curator-gated + * server-side: any valid bearer token can read another curator's + * pending-moderation data. https://github.com/OriginTrail/dkg/issues/757 + */ +import { beforeAll, afterAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtemp, writeFile, rm, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { ethers } from 'ethers'; +import { getSharedContext, HARDHAT_KEYS } from '../../chain/test/evm-test-context.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI_ENTRY = join(__dirname, '..', 'dist', 'cli.js'); + +interface Daemon { + home: string; + apiPort: number; + child: ChildProcess; + token: string; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +let portCounter = 0; +const uniquePort = (base: number) => base + (portCounter++ % 200); + +async function startDaemon(): Promise { + if (!existsSync(CLI_ENTRY)) { + throw new Error(`CLI not built at ${CLI_ENTRY}. Run the package build first.`); + } + const home = await mkdtemp(join(tmpdir(), 'dkg-liveness-routes-')); + const apiPort = uniquePort(19960); + const listenPort = uniquePort(20160); + const { rpcUrl, hubAddress } = getSharedContext(); + + await writeFile( + join(home, 'config.json'), + JSON.stringify({ + name: 'liveness-routes-test', + apiPort, + listenPort, + apiHost: '127.0.0.1', + nodeRole: 'edge', + relay: 'none', + auth: { enabled: true }, + store: { backend: 'oxigraph-worker', options: { path: join(home, 'store.nq') } }, + chain: { type: 'evm', rpcUrl, hubAddress, chainId: 'evm:31337' }, + contextGraphs: [], + }), + ); + const coreOp = new ethers.Wallet(HARDHAT_KEYS.CORE_OP); + await writeFile( + join(home, 'wallets.json'), + JSON.stringify({ wallets: [{ address: coreOp.address, privateKey: coreOp.privateKey }] }, null, 2) + '\n', + { mode: 0o600 }, + ); + + const child = spawn('node', [CLI_ENTRY, 'daemon-worker'], { + env: { ...process.env, DKG_HOME: home, DKG_API_PORT: String(apiPort), DKG_NO_BLUE_GREEN: '1', DKG_DISABLE_TELEMETRY: '1' }, + stdio: 'ignore', + }); + + for (let i = 0; i < 90; i++) { + if (child.exitCode !== null) throw new Error(`Daemon exited early (${child.exitCode})`); + try { + const res = await fetch(`http://127.0.0.1:${apiPort}/api/status`); + if (res.ok) break; + } catch { /* not ready */ } + await sleep(500); + if (i === 89) throw new Error('Daemon did not become ready within 45s'); + } + + const raw = await readFile(join(home, 'auth.token'), 'utf-8'); + const token = raw.split('\n').map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith('#')); + if (!token) throw new Error('No auth token'); + return { home, apiPort, child, token }; +} + +let daemon: Daemon | null = null; +const CG = 'liveness-routes-cg'; + +function url(path: string): string { + return `http://127.0.0.1:${daemon!.apiPort}${path}`; +} +function headers(): Record { + return { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon!.token}` }; +} + +beforeAll(async () => { + daemon = await startDaemon(); + // CURATED CG (accessPolicy:1): #757 is about curator-only moderation access, + // so the CG must actually have a curator (the daemon's default agent) for a + // non-curator token to be a meaningful 403 target. Local CG is enough — these + // are HTTP-contract bugs that fire before any chain op (an edge daemon can't + // on-chain register anyway). + const res = await fetch(url('/api/context-graph/create'), { + method: 'POST', + headers: headers(), + body: JSON.stringify({ id: CG, name: 'Liveness Routes CG', accessPolicy: 1 }), + }); + if (!res.ok) throw new Error(`CG create failed: ${res.status} ${await res.text()}`); +}, 120_000); + +afterAll(async () => { + if (daemon) { + daemon.child.kill('SIGTERM'); + await sleep(1500); + if (daemon.child.exitCode === null) daemon.child.kill('SIGKILL'); + await rm(daemon.home, { recursive: true, force: true }).catch(() => {}); + } +}); + +describe('GH #787 — SWM write with N-Quad string quads', () => { + it('returns a 4xx (not 500) for string-shaped quads', async () => { + const res = await fetch(url('/api/shared-memory/write'), { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + contextGraphId: CG, + quads: [' "v" .'], + }), + }); + expect(res.status).not.toBe(500); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); +}); + +describe('GH #306 — KA wm/write with N-Quad string quads', () => { + it('returns a 4xx (not 500) for string-shaped quads', async () => { + // Precondition: the KA must be created successfully, so the wm/write below + // exercises the QUAD-SHAPE validation path. If create silently failed, the + // write would 404 on a missing KA — a 4xx that would pass this test for the + // WRONG reason (Codex review on PR #1129). Assert the create succeeded. + const created = await fetch(url('/api/knowledge-assets'), { + method: 'POST', + headers: headers(), + body: JSON.stringify({ contextGraphId: CG, name: 'ka-306' }), + }); + expect( + created.status, + 'KA create precondition failed — wm/write would 404 on a missing KA, not exercise quad validation', + ).toBeLessThan(300); + const res = await fetch(url('/api/knowledge-assets/ka-306/wm/write'), { + method: 'POST', + headers: headers(), + body: JSON.stringify({ contextGraphId: CG, quads: [' .'] }), + }); + expect(res.status).not.toBe(500); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); +}); + +describe('GH #158 — CCL not-found error mapping (with a real CG)', () => { + it('ccl/eval on an existing CG with an unknown policy returns 404 not 500', async () => { + const res = await fetch(url('/api/ccl/eval'), { + method: 'POST', + headers: headers(), + body: JSON.stringify({ contextGraphId: CG, name: 'no-such-policy' }), + }); + // #158's contract is specifically "unknown policy ⇒ 404". Today it throws a + // 500. Asserting the exact 404 (not just any 4xx) means a wrong remap to + // 400/403/422 can't masquerade as fixed (Codex review on PR #1129). RED at + // 500 while the bug is live; GREEN only when the not-found maps to 404. + expect(res.status).toBe(404); + }); +}); + +describe('GH #309 — /api/status exposes the default agent address', () => { + it('status body carries a real defaultAgentAddress for WM-query scoping', async () => { + const res = await fetch(url('/api/status'), { headers: { Authorization: `Bearer ${daemon!.token}` } }); + const body = (await res.json()) as Record; + // Must be a usable EVM address — `null`/`""`/`undefined` still leaves + // integrations unable to scope WM queries, so `toBeDefined()` is too weak. + expect(body.defaultAgentAddress, `defaultAgentAddress=${JSON.stringify(body.defaultAgentAddress)}`) + .toMatch(/^0x[0-9a-fA-F]{40}$/); + }); +}); + +describe('GH #757 — join-requests endpoint must be curator-gated', () => { + it( + 'a non-curator agent token is rejected (403) from reading another CG curator\'s join-requests', + async () => { + // The CG (created in beforeAll with the node-admin/default-agent token) + // is curated by the default agent. Register a SEPARATE agent and use its + // token — it is NOT the curator and must not read moderation data. + const reg = await fetch(url('/api/agent/register'), { + method: 'POST', + headers: headers(), + body: JSON.stringify({ name: 'gh757-non-curator', framework: 'test' }), + }); + const regBody = (await reg.json()) as { authToken?: string }; + expect(regBody.authToken, 'agent register should return a token').toBeTruthy(); + + const res = await fetch(url(`/api/context-graph/${CG}/join-requests`), { + headers: { Authorization: `Bearer ${regBody.authToken}` }, + }); + // Correct: 403 (not the curator). Bug: 200 with the pending-request data. + expect(res.status).toBe(403); + }, + ); +}); diff --git a/packages/cli/test/rdf-parser-jsonld.test.ts b/packages/cli/test/rdf-parser-jsonld.test.ts new file mode 100644 index 000000000..f4654240a --- /dev/null +++ b/packages/cli/test/rdf-parser-jsonld.test.ts @@ -0,0 +1,45 @@ +/** + * Liveness/regression test for GH #15 — + * "JSON-LD advertised but not implemented in CLI ingest". + * https://github.com/OriginTrail/dkg/issues/15 + * + * `.jsonld` is advertised as a supported ingest format (`supportedExtensions()` + * lists it, `detectFormat('.jsonld')` returns `'jsonld'`), but parsing a JSON-LD + * document that carries an `@context` throws + * "JSON-LD with @context requires the jsonld library. Use .nq, .nt, .ttl, or .trig instead." + * So the format is advertised but non-functional. The issue's accepted + * resolutions: implement JSON-LD (option B) OR stop advertising `.jsonld` + * (option A). Either way the current state — advertised AND throwing — is the bug. + * + * This asserts the CORRECT (post-fix) INVARIANT — the parser is never in an + * "advertised but broken" state — so it is RED while the bug is live and GREEN + * for EITHER accepted fix: implement JSON-LD (then it parses) OR stop advertising + * `.jsonld` (then there's nothing to parse). Written fix-agnostically so option A + * can't leave it stuck red (Codex review on PR #1129). + */ +import { describe, expect, it } from 'vitest'; +import { detectFormat, supportedExtensions, parseRdf } from '../src/rdf-parser.js'; + +const JSONLD_WITH_CONTEXT = JSON.stringify({ + '@context': { schema: 'https://schema.org/' }, + '@id': 'https://example.org/thing-15', + '@type': 'schema:Thing', + 'schema:name': 'JsonLd15', +}); + +describe('GH #15 — JSON-LD ingest must not be advertised-but-broken', () => { + it('a .jsonld document with @context is parseable, OR .jsonld is not advertised (no advertised-but-broken state)', async () => { + const advertised = + supportedExtensions().includes('.jsonld') || detectFormat('thing.jsonld') === 'jsonld'; + + // Option A fix — `.jsonld` de-advertised: there is nothing to parse, the + // parser is consistent, pass. + if (!advertised) return; + + // Still advertised → option B fix must hold: a JSON-LD doc with `@context` + // MUST parse. Today this throws ("requires the jsonld library"), so the test + // is RED. (A throw here is the live bug; an empty parse is also a failure.) + const quads = await parseRdf(JSONLD_WITH_CONTEXT, 'jsonld', 'did:dkg:context-graph:gh15'); + expect(quads.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/test/skill-md-dynamic-section.test.ts b/packages/cli/test/skill-md-dynamic-section.test.ts new file mode 100644 index 000000000..89144a6db --- /dev/null +++ b/packages/cli/test/skill-md-dynamic-section.test.ts @@ -0,0 +1,50 @@ +/** + * Liveness/regression test for GH #1125 — + * "Served /.well-known/skill.md §1 Node Info shows literal '(dynamic)' + * placeholders — buildSkillMd() template replace silently no-ops". + * + * https://github.com/OriginTrail/dkg/issues/1125 + * + * Root cause: `buildSkillMd()` substitutes the dynamic Node-Info block with an + * exact-string `template.replace(staticPlaceholder, dynamicSection)`. The + * placeholder constant in `manifest.ts` and the actual line in the served + * template (`packages/cli/skills/dkg-node/SKILL.md`) diverged (the + * `dkg_list_context_graphs` / `GET /api/context-graph/list` order was swapped), + * so the exact match fails, `replace()` no-ops, and the raw template — with + * literal `(dynamic)` placeholders — is served to agents. + * + * The `it.fails` wrapper documents that this assertion of CORRECT behaviour + * currently FAILS (the bug is live). When the substitution is fixed, this test + * starts passing → `it.fails` turns RED → remove `.fails`, make it a plain + * `it(...)`, and close #1125. + */ +import { describe, expect, it } from 'vitest'; +import { buildSkillMd } from '../src/daemon/manifest.js'; + +const OPTS = { + version: '10.0.0-test', + baseUrl: 'http://127.0.0.1:9200', + peerId: '12D3KooWTestPeerIdForSkillMdDynamicSectionRegression', + nodeRole: 'core', + extractionPipelines: ['text/markdown', 'application/pdf'], +}; + +describe('GH #1125 — served skill.md dynamic Node-Info substitution', () => { + it( + 'substitutes the dynamic section (no literal "(dynamic)" left in output)', + () => { + const out = buildSkillMd(OPTS); + expect(out).not.toContain('(dynamic)'); + }, + ); + + it('renders the real node version into the served doc', () => { + const out = buildSkillMd(OPTS); + expect(out).toContain('**Node version:** 10.0.0-test'); + }); + + it('renders the available extraction pipelines into the served doc', () => { + const out = buildSkillMd(OPTS); + expect(out).toContain('application/pdf'); + }); +}); diff --git a/packages/core/test/escape-rdf-literal-control-chars.test.ts b/packages/core/test/escape-rdf-literal-control-chars.test.ts new file mode 100644 index 000000000..980db6f2a --- /dev/null +++ b/packages/core/test/escape-rdf-literal-control-chars.test.ts @@ -0,0 +1,52 @@ +/** + * Liveness/regression test for GH #416 - + * "escapeDkgRdfLiteral covers only ECHAR; non-ECHAR ASCII controls leak through". + * https://github.com/OriginTrail/dkg/issues/416 + * + * The canonical RDF literal escaper only handles the seven ECHAR characters + * (\\ " \n \r \t \f \b). Other ASCII control bytes - NUL (0x00), VT (0x0B), + * DEL (0x7F), and the 0x01-0x07 / 0x0E-0x1F range - are left raw, producing + * invalid N-Triples literals at the storage layer. The fix is to UCHAR-encode + * (`\u00XX`) every control byte not already covered by an ECHAR shortcut. + * + * Encoded as `it.fails`: the assertion of correct escaping fails today (bug + * live). When the escaper is hardened these flip to passing -> `it.fails` + * turns RED -> drop `.fails` and close #416. + */ +import { describe, expect, it } from 'vitest'; +import { escapeDkgRdfLiteral } from '../src/publisher-extension.js'; + +const NUL = String.fromCharCode(0x00); +const VT = String.fromCharCode(0x0b); +const DEL = String.fromCharCode(0x7f); + +// Any raw C0 control byte (0x00-0x1F) or DEL (0x7F) left in N-Triples output +// is invalid per the SPARQL/Turtle grammar. +// eslint-disable-next-line no-control-regex +const RAW_CONTROL = /[\x00-\x1F\x7F]/; + +describe('GH #416 - escapeDkgRdfLiteral non-ECHAR control bytes', () => { + it('CONTROL: ECHAR shortcuts still produce canonical short forms', () => { + expect(escapeDkgRdfLiteral('a"b\nc\td')).toBe('a\\"b\\nc\\td'); + }); + + // RDF `\u` UCHAR hex is case-insensitive, so compare lowercased output — a + // correct fix that emits lowercase hex (`\u000b`) is just as valid as + // uppercase and must not keep this red (Codex review on PR #1129). + it('UCHAR-encodes NUL (0x00) instead of leaving it raw', () => { + expect(escapeDkgRdfLiteral(`a${NUL}b`).toLowerCase()).toBe('a\\u0000b'); + }); + + it('UCHAR-encodes VT (0x0B) instead of leaving it raw', () => { + expect(escapeDkgRdfLiteral(`a${VT}b`).toLowerCase()).toBe('a\\u000bb'); + }); + + it('UCHAR-encodes DEL (0x7F) instead of leaving it raw', () => { + expect(escapeDkgRdfLiteral(`a${DEL}b`).toLowerCase()).toBe('a\\u007fb'); + }); + + it('leaves no raw C0/DEL control byte in the output', () => { + const out = escapeDkgRdfLiteral(`x${NUL}${VT}${DEL}y`); + expect(RAW_CONTROL.test(out)).toBe(false); + }); +}); diff --git a/packages/epcis/test/event-type-container-filter.test.ts b/packages/epcis/test/event-type-container-filter.test.ts new file mode 100644 index 000000000..c36f1ff23 --- /dev/null +++ b/packages/epcis/test/event-type-container-filter.test.ts @@ -0,0 +1,50 @@ +/** + * Liveness/regression test for GH #709 — + * "EPCIS no-filter events query returns EPCISDocument container rows". + * https://github.com/OriginTrail/dkg/issues/709 + * + * `buildEpcisQuery` matches every RDF type under the EPCIS namespace via + * `FILTER(STRSTARTS(STR(?eventType), "https://gs1.github.io/EPCIS/"))`. That + * prefix also matches `https://gs1.github.io/EPCIS/EPCISDocument` — the + * document container — so an unfiltered `/api/epcis/events` query returns the + * container as if it were an event. (Confirmed live on rc.17: a no-filter query + * returned `[ObjectEvent, EPCISDocument]`.) The fix should exclude container / + * document classes from the event result set. + * + * This asserts the CORRECT (post-fix) behaviour, so it is RED while the bug is + * live and GREEN once the builder excludes containers — then close #709. The + * assertion is written to be agnostic to HOW the fix excludes the container so + * it can't stay red after a valid fix (Codex review on PR #1129): it fails for + * the buggy bare-prefix match and passes for either an explicit + * `!= …/EPCISDocument` exclusion OR a narrowed allow-list of concrete event + * classes. + */ +import { describe, expect, it } from 'vitest'; +import { buildEpcisQuery } from '../src/query-builder.js'; + +const CG = 'epcis-709-cg'; + +describe('GH #709 — EPCIS event-type filter excludes the document container', () => { + it('CONTROL: a no-filter events query is generated and scopes ?eventType to the EPCIS namespace', () => { + const sparql = buildEpcisQuery({}, CG); + expect(sparql).toContain('?event a ?eventType'); + expect(sparql).toContain('https://gs1.github.io/EPCIS/'); + }); + + it('a no-filter events query does not admit the EPCISDocument container as an event', () => { + const sparql = buildEpcisQuery({}, CG); + + // The bug: `?eventType` is matched ONLY by the namespace prefix, which also + // matches the `.../EPCISDocument` container class. A correct fix either + // (a) explicitly excludes the container URI, or (b) narrows the type match + // to concrete event classes — in BOTH cases the bare prefix-only match is + // gone or guarded. + const usesBarePrefixMatch = + /STRSTARTS\(\s*STR\(\?eventType\)\s*,\s*"https:\/\/gs1\.github\.io\/EPCIS\/"\s*\)/.test(sparql); + const guardsTheContainer = sparql.includes('EPCISDocument'); + + // Correct iff the query is NOT "bare prefix match with no container guard". + // Today it IS exactly that → this is RED until #709 is fixed. + expect(usesBarePrefixMatch && !guardsTheContainer).toBe(false); + }); +}); diff --git a/packages/evm-module/test/issue-liveness-contracts.test.ts b/packages/evm-module/test/issue-liveness-contracts.test.ts new file mode 100644 index 000000000..c40391b93 --- /dev/null +++ b/packages/evm-module/test/issue-liveness-contracts.test.ts @@ -0,0 +1,63 @@ +/** + * Issue-liveness placeholders for two HIGH / pre-mainnet CONTRACT issues. + * + * Both are intentionally `it.skip` (pending) rather than fabricated green tests: + * each needs a substantial, exact on-chain fixture (and for #1091, a design + * change) that a contracts engineer should author so the repro is trustworthy. + * A wrong contract test is worse than none. The detailed repro recipe below is + * the head-start. When the repro is implemented, unskip it — it should FAIL + * (reproduce the bug), then pass once the contract is fixed, then stay in CI. + * + * #1091 — RandomSampling challenge seed is grindable (block.difficulty + + * blockhash are proposer-influenceable / off-chain-recomputable). + * https://github.com/OriginTrail/dkg/issues/1091 + * #614 — DKGPublishingConvictionNFT unused-funds sweep can allocate prorated + * funds into an already-closed (claimed) epoch → unclaimable. + * https://github.com/OriginTrail/dkg/issues/614 + */ + +describe('@issue-liveness contracts (HIGH / pre-mainnet)', () => { + // GH #1091 — grindable RandomSampling challenge seed. + // + // Repro recipe (needs the full eligible-challenge fixture: profile + stake + + // an eligible CG with KAs + sharding table, so `createChallenge` produces a + // real non-zero draw and `previewChallengeForSeed` returns the same tuple): + // 1. Deploy the RS stack; register a node profile; stake; create an + // eligible CG + at least one KA so `_isCGEligible` passes. + // 2. In a single block, recompute the seed off-chain exactly as + // `_deriveChallengeSeed`: + // keccak256(abi.encodePacked( + // block.difficulty, + // blockhash(block.number - ((block.difficulty % 256) + 1)), + // originalSender, + // uint8(1) /* sector */)) + // 3. Call `previewChallengeForSeed(recomputedSeed, currentEpoch)` → predicted + // (cgId, kaId, chunkId). + // 4. Call `createChallenge()` from `originalSender` in the SAME block and read + // the emitted `ChallengeGenerated(cgId, kaId, chunkId, …)`. + // 5. ASSERT-CORRECT (the fix): a node must NOT be able to predict its own + // challenge from public block data before committing — i.e. the predicted + // tuple must differ from / be unobservable relative to the actual draw. + // Today they are identical (the seed is fully recomputable), so the + // assertion fails → bug reproduced. The durable fix is commit–reveal or a + // VRF (the contract's own `_deriveChallengeSeed` NatSpec acknowledges this + // is "tracked separately"). + it.skip('GH #1091: challenge draw is not predictable from public block data (needs commit-reveal/VRF fixture)'); + + // GH #614 — sweep allocates unused epoch funds into an already-closed epoch. + // + // Repro recipe (needs the PublishingConviction billing-window math fixture): + // 1. Create a publishing-conviction account with committed TRAC and an + // `expiresAtEpoch` chosen so the billing period ENDS mid-epoch. + // 2. Advance chronos epochs so the account's window boundary straddles an + // epoch boundary, and have a delegator/agent CLAIM the earlier epoch's + // allocation (closing it). + // 3. Trigger `settle(accountId)` / the post-expiry final sweep. + // 4. ASSERT-CORRECT (the fix): every wei of unused committed TRAC is swept + // into a CURRENT/future, still-claimable window. Today the prorated split + // can land a portion in the previous (already-closed, already-claimed) + // epoch, where regular claim mechanics can never recover it → assert the + // post-sweep claimable total equals the committed-minus-spent total + // (it falls short by the stranded slice today). + it.skip('GH #614: post-expiry sweep leaves no funds stranded in a closed epoch (needs billing-window fixture)'); +}); diff --git a/packages/publisher/test/issue-1013-async-finalization-honesty.test.ts b/packages/publisher/test/issue-1013-async-finalization-honesty.test.ts new file mode 100644 index 000000000..2e8fe0e73 --- /dev/null +++ b/packages/publisher/test/issue-1013-async-finalization-honesty.test.ts @@ -0,0 +1,63 @@ +/** + * Issue-liveness repro for GH #1013 — "Private publishAsync can finalize locally + * with provisional UAL but no on-chain provenance." + * https://github.com/OriginTrail/dkg/issues/1013 + * + * When a PRIVATE publish reaches a chain-registered context graph but cannot + * collect storage ACKs (peers can't see the ciphertext), the publisher stores + * it locally and returns `status: 'tentative'` with a provisional + * `t` UAL — NOT an on-chain KA. `mapPublishResultToLiftJobSuccess` + * then maps EVERY tentative-without-onChain result to a SUCCESSFUL `finalized` + * lift job (`mode: 'local'`). So `captureID → finalized → UAL` reports success + * even though nothing is on chain / in Verifiable Memory — the invariant every + * integration relies on is violated. + * + * The mapper cannot today tell an HONEST local finalize (no chain configured at + * all) from a DISHONEST one (private payload that SHOULD have gone on chain but + * couldn't collect ACKs). The fix threads that reason through + * (`localChainSkipReason`) and rejects the dishonest case. + * + * This test asserts the CORRECT (post-fix) behaviour, so it is RED today + * (the bug is live) and turns GREEN once the fix lands; it stays red until #1013 is fixed.. Pure + * function under test — no store, no chain, no network. + */ +import { describe, expect, it } from 'vitest'; +import { mapPublishResultToLiftJobSuccess } from '../src/index.js'; +import type { PublishResult } from '../src/publisher.js'; + +function tentativeResult(localChainSkipReason: 'no-chain' | 'private-no-acks'): PublishResult { + // A tentative, off-chain result (no `onChainResult`) carrying a provisional + // UAL — exactly what `finalizeIntentionalLocalPublish` produces. The + // `localChainSkipReason` discriminator is the signal the fix keys on; an + // unfixed mapper ignores the extra field entirely. + return { + kaId: 0n, + ual: 'did:dkg:evm:31337/0xpublisher/t-provisional-op', + merkleRoot: new Uint8Array([0x12, 0x34]), + kaManifest: [], + status: 'tentative', + localChainSkipReason, + } as unknown as PublishResult; +} + +describe('GH #1013 — async lift must not report a private off-chain publish as finalized', () => { + it( + 'rejects a private-no-acks tentative result instead of mapping it to a finalized lift job', + () => { + // Control: a genuinely chain-less local publish is a legitimate local + // finalize — it must keep mapping cleanly (no regression for offline use). + expect(() => + mapPublishResultToLiftJobSuccess({ publishResult: tentativeResult('no-chain'), walletId: 'wallet-1' }), + ).not.toThrow(); + + // CORRECT (post-fix): a PRIVATE publish that was destined for a registered + // chain but never reached it (no storage ACKs) must NOT be presented as a + // finalized success — the mapper must reject it. Today it returns + // `{ status: 'finalized', finalization: { mode: 'local' } }`, so this does + // NOT throw → the assertion fails → the bug is reproduced. + expect(() => + mapPublishResultToLiftJobSuccess({ publishResult: tentativeResult('private-no-acks'), walletId: 'wallet-1' }), + ).toThrow(); + }, + ); +}); diff --git a/packages/query/test/subgraph-view-scoping.test.ts b/packages/query/test/subgraph-view-scoping.test.ts new file mode 100644 index 000000000..7268bb96b --- /dev/null +++ b/packages/query/test/subgraph-view-scoping.test.ts @@ -0,0 +1,108 @@ +/** + * Liveness/regression tests for two sub-graph query-scoping bugs. + * + * GH #184 — "Enable subGraphName scoping with view-based routing in the query + * engine". The engine throws outright when `subGraphName` is combined + * with a `view`, so there is no way to read a named sub-graph through + * the WM/SWM/VM views. https://github.com/OriginTrail/dkg/issues/184 + * + * GH #675 — "dkg_query view:'working-memory' does not include data from + * sub-graphs". A `view:'working-memory'` read (without subGraphName) + * only scans the context-graph-root WM partition and silently + * excludes assertions that live in a named sub-graph. + * https://github.com/OriginTrail/dkg/issues/675 + * + * Both are encoded as `it.fails` — the assertion of the CORRECT behaviour fails + * today (bug live). When the engine learns to scope/include sub-graph WM data, + * these flip to passing → `it.fails` turns RED → drop `.fails`, make them plain + * `it(...)`, and close #184 / #675. + * + * Hermetic: in-memory OxigraphStore seeded with the exact uniform-layout WM + * graph URIs (`contextGraphLayerUri`), zero mocks, zero chain. + */ +import { describe, expect, it, beforeEach } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { contextGraphLayerUri, contextGraphMetaUri, MemoryLayer } from '@origintrail-official/dkg-core'; +import { DKGQueryEngine } from '../src/dkg-query-engine.js'; + +const CG = 'subgraph-view-cg'; +const ADDR = '0x1111111111111111111111111111111111111111'; +const SUB = 'research-alpha'; +const NAME = 'http://schema.org/name'; + +const ROOT_ENTITY = 'https://example.org/root-entity'; +const SUB_ENTITY = 'https://example.org/subgraph-entity'; + +// Root-level WM partition: …/_working_memory/{addr}/1 +// Sub-graph WM partition: …/{sub}/_working_memory/{addr}/2 +const ROOT_WM_GRAPH = contextGraphLayerUri(CG, MemoryLayer.WorkingMemory, ADDR, 1); +const SUB_WM_GRAPH = contextGraphLayerUri(CG, MemoryLayer.WorkingMemory, ADDR, 2, SUB); + +function q(s: string, p: string, o: string, g: string) { + return { subject: s, predicate: p, object: o, graph: g }; +} + +describe('GH #184 / #675 — sub-graph scoping in view-based WM reads', () => { + let store: OxigraphStore; + let engine: DKGQueryEngine; + + beforeEach(async () => { + store = new OxigraphStore(); + engine = new DKGQueryEngine(store); + // Register the `research-alpha` sub-graph in the ROOT _meta graph — this is + // the registry the merged #675 fan-out (discoverRegisteredSubGraphNames) + // reads to know which sub-graphs to union into an unscoped view read. The + // original test seeded only the WM data graphs, so the fan-out found nothing + // to union; with the fix landed (commit d7a055dea), the registration is what + // the engine requires (mirrors the passing sub-graph-query.test.ts #675 test). + const META = contextGraphMetaUri(CG); + const SUBGRAPH_URN = `urn:dkg:subgraph:${CG}:${SUB}`; + await store.insert([ + q(ROOT_ENTITY, NAME, '"RootEntity"', ROOT_WM_GRAPH), + q(SUB_ENTITY, NAME, '"SubGraphEntity"', SUB_WM_GRAPH), + q(SUBGRAPH_URN, 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', 'http://dkg.io/ontology/SubGraph', META), + q(SUBGRAPH_URN, 'http://schema.org/name', `"${SUB}"`, META), + ]); + }); + + // Control: proves the seed + WM-view query path actually work, so the + // it.fails below cannot pass for the wrong reason. + it('CONTROL: WM view returns the context-graph-root entity', async () => { + const result = await engine.query( + `SELECT ?s ?o WHERE { ?s <${NAME}> ?o }`, + { contextGraphId: CG, view: 'working-memory', agentAddress: ADDR }, + ); + const subjects = result.bindings.map((b) => b['s']); + expect(subjects).toContain(ROOT_ENTITY); + }); + + it( + 'GH #675: WM view (no subGraphName) ALSO includes sub-graph WM data', + async () => { + const result = await engine.query( + `SELECT ?s ?o WHERE { ?s <${NAME}> ?o }`, + { contextGraphId: CG, view: 'working-memory', agentAddress: ADDR }, + ); + const subjects = result.bindings.map((b) => b['s']); + expect(subjects).toContain(SUB_ENTITY); + }, + ); + + it( + 'GH #184: WM view + subGraphName scopes to the sub-graph instead of throwing', + async () => { + const result = await engine.query( + `SELECT ?s ?o WHERE { ?s <${NAME}> ?o }`, + { + contextGraphId: CG, + view: 'working-memory', + agentAddress: ADDR, + subGraphName: SUB, + }, + ); + const subjects = result.bindings.map((b) => b['s']); + expect(subjects).toContain(SUB_ENTITY); + expect(subjects).not.toContain(ROOT_ENTITY); + }, + ); +}); diff --git a/packages/random-sampling/test/e2e-hardhat-chain.test.ts b/packages/random-sampling/test/e2e-hardhat-chain.test.ts index f17344cab..845d4fd21 100644 --- a/packages/random-sampling/test/e2e-hardhat-chain.test.ts +++ b/packages/random-sampling/test/e2e-hardhat-chain.test.ts @@ -229,6 +229,123 @@ describe('Random Sampling E2E (Hardhat)', () => { if (snapshotId) await revertSnapshot(snapshotId); }); + // Issue-liveness repro for GH #1091 — "RandomSampling: replace grindable + // challenge seed with commit-reveal / VRF (durable fix)." + // https://github.com/OriginTrail/dkg/issues/1091 + // + // `_deriveChallengeSeed` mixes only PUBLIC, off-chain-recomputable inputs + // (`block.difficulty`/prevrandao, `blockhash(...)`, `msg.sender`), and the + // weighted picker `previewChallengeForSeed` is a public view. A node can + // therefore reconstruct the seed from public block data and PREDICT its own + // draw — the basis for grinding across periods until challenged only on chunks + // it actually stores, which defeats proof-of-storage. + // + // This test asserts the CORRECT (post-fix) behaviour, so it is RED today (the + // draw IS predictable from public data) and turns GREEN once the seed is made + // unpredictable (commit-reveal in period N for N+1, or a VRF). It uses REC2 (a + // staked, sharded node) so it does not disturb the REC1 prover test above. + it('GH #1091: a node cannot predict its own challenge from public block data (grindable seed)', async () => { + const provider = createProvider(); + // This repro mutates shared Hardhat state (mines a REC2 challenge, pins + // prevrandao, mines blocks). Snapshot/revert around the WHOLE test so it + // can't leak state into the regular prover E2E that reuses the same fixture + // — the file's single afterAll revert is not enough once this also runs. + const liveSnapshot = await takeSnapshot(); + try { + const ctx = getSharedContext(); + const rec2 = new ethers.Wallet(HARDHAT_KEYS.REC2_OP, provider); + + const hub = new ethers.Contract( + ctx.hubAddress, + ['function getContractAddress(string) view returns (address)'], + provider, + ); + const rsAddress: string = await hub.getContractAddress('RandomSampling'); + const rs = new ethers.Contract( + rsAddress, + [ + 'function createChallenge()', + // PR #1226 made the preview 1-arg (it reads chronos.getCurrentEpoch() + // internally). Was `previewChallengeForSeed(bytes32,uint256)` — calling + // the old 2-arg selector now hits no function and reverts. + 'function previewChallengeForSeed(bytes32 seed) view returns (uint256 cgId, uint256 kaId, uint256 chunkId)', + 'event ChallengeGenerated(uint72 indexed identityId, uint256 indexed contextGraphId, uint256 indexed knowledgeAssetId, uint256 chunkId, uint256 epoch, uint256 activeProofPeriodStartBlock)', + ], + rec2, + ); + + const hexN = (n: number) => '0x' + n.toString(16); + + // ── PRE-prediction (the real #1091 threat): a proposer who controls + // `prevrandao` (or anyone, pre-v10.0.4, via the now-removed gasprice grind) + // can compute the draw BEFORE the createChallenge tx is mined. We simulate + // the proposer by pinning the next block's prevrandao to a chosen value, then + // computing the seed + previewing the draw with NOTHING but data known before + // the tx — `blockhash(N - offset)` is a PAST block, and N is the next block. + const chosenPrevrandao = '0x' + 'a5'.repeat(32); + const difficulty = BigInt(chosenPrevrandao); // prevrandao the proposer pinned for block N + + let receipt: ethers.TransactionReceipt; + let predicted: { cgId: bigint; kaId: bigint; chunkId: bigint }; + let challengeBlockNumber = 0; + // Stateful RPC mutations (pinned prevrandao + manual mining) must ALWAYS be + // unwound, or an exception mid-flight leaves the SHARED Hardhat node in + // manual-mining mode and cascades failures through the rest of the package. + await provider.send('hardhat_setPrevRandao', [chosenPrevrandao]); + await provider.send('evm_setAutomine', [false]); + try { + const blockBefore: number = await provider.getBlockNumber(); + challengeBlockNumber = blockBefore + 1; // the block createChallenge will land in + const offset = (difficulty % 256n) + 1n; + const refBlock = await provider.send('eth_getBlockByNumber', [hexN(challengeBlockNumber - Number(offset)), false]); + const reconstructedSeed = ethers.solidityPackedKeccak256( + ['uint256', 'bytes32', 'address', 'uint8'], + [difficulty, refBlock.hash, rec2.address, 1], + ); + // Predict the draw NOW — before createChallenge is mined. The preview + // reads the current epoch internally (chronos.getCurrentEpoch()); epochs + // span many blocks so it's stable across the single mine below. + predicted = await rs.previewChallengeForSeed(reconstructedSeed); + + // Now mine the proposer's createChallenge into block N (with the pinned + // prevrandao). Send the tx (queued), then mine exactly one block. + const txResp = await rs.createChallenge(); + await provider.send('evm_mine', []); + receipt = await txResp.wait(); + } finally { + await provider.send('evm_setAutomine', [true]).catch(() => {}); + } + + expect(receipt.blockNumber, 'createChallenge landed in the prevrandao-pinned block').toBe(challengeBlockNumber); + // Sanity: the mined block really carried our pinned prevrandao. + const minedBlock = await provider.send('eth_getBlockByNumber', [hexN(challengeBlockNumber), false]); + expect(BigInt(minedBlock.mixHash ?? minedBlock.difficulty)).toBe(difficulty); + + const parsed = receipt.logs + .map((l: ethers.Log) => { try { return rs.interface.parseLog(l); } catch { return null; } }) + .find((p: ethers.LogDescription | null) => p?.name === 'ChallengeGenerated'); + expect(parsed, 'ChallengeGenerated event must be emitted').toBeTruthy(); + const actualCgId: bigint = parsed!.args.contextGraphId; + const actualKaId: bigint = parsed!.args.knowledgeAssetId; + const actualChunkId: bigint = parsed!.args.chunkId; + + // The prediction was computed BEFORE the tx was mined, from only public / + // proposer-chosen inputs. Compare the FULL (cgId, kaId, chunkId) tuple so a + // prediction can't look successful while having picked a different CG. + const predictsActualDraw = + predicted.cgId === actualCgId && predicted.kaId === actualKaId && predicted.chunkId === actualChunkId; + + // CORRECT (post-fix): the draw must NOT be predictable before the tx is mined. + // Today it is — `predictsActualDraw` is true — so this assertion is RED until + // #1091 lands a commit-reveal / VRF seed. + expect(predictsActualDraw, 'challenge draw was predicted before the tx was mined (grindable seed)').toBe(false); + } finally { + // Roll back ALL of this test's chain mutations so the prover E2E below + // (which reuses the shared fixture) starts from the same baseline. + await revertSnapshot(liveSnapshot); + } + }, 90_000); + it('drives the prover end-to-end against the real RandomSampling.sol', async () => { const ctx = getSharedContext(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 122c9f526..6c52bb32e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,16 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@22.19.11)(happy-dom@20.8.9(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + devnet/issue-liveness: + dependencies: + ethers: + specifier: ^6.16.0 + version: 6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + devDependencies: + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.11)(happy-dom@20.8.9(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + devnet/rfc51-publishing-allocation: dependencies: ethers: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d6de0076e..9cd621f07 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,3 +13,4 @@ packages: - "devnet/edge-update-flow" - "devnet/greenfield-10min" - "devnet/rich-scenario" + - "devnet/issue-liveness" diff --git a/tests/publish/errors_qa-core-staked.json b/tests/publish/errors_qa-core-staked.json deleted file mode 100644 index 384e9d8d5..000000000 --- a/tests/publish/errors_qa-core-staked.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "blockchain_id": "v10:base:84532", - "aggregated": { - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])]": 18, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839": 21, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 3/3 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=3, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response); aijsNrWw (reason=no_response)])]": 3 - }, - "detailed": { - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #1": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #1": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #2": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #2": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #3": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #3": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #5": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #6": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #4": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #5": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #6": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #4": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #8": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #9": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #7": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #9": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #8": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #7": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #12": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #10": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #11": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #11": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #12": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #10": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #13": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #15": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #14": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #14": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #13": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #15": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #18": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #16": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])] for KA #17": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #17": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #18": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #16": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 3/3 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=3, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response); aijsNrWw (reason=no_response)])] for KA #19": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 3/3 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=3, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response); aijsNrWw (reason=no_response)])] for KA #20": 1, - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 3/3 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=3, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response); aijsNrWw (reason=no_response)])] for KA #21": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #20": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #19": 1, - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839 for KA #21": 1 - }, - "services": { - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 2/2 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=2, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response)])]": "storage-ack-quorum", - "asset-query — AssertionError: Asset Query returned empty results for did:dkg:base:84532/0x92c6db7e977F782101d794A7e1222acc95630617/17839": "query-engine", - "publishing — Error: storage_ack_insufficient: got 0/1 valid ACKs after 3/3 core peer(s) settled — quorum no longer reachable. [QuorumUnmetError(collected=0/1, dialled=3, peers=[Ce8Q82bZ (reason=no_response); usXfc56F (reason=no_response); aijsNrWw (reason=no_response)])]": "storage-ack-quorum" - } -} \ No newline at end of file diff --git a/tests/publish/summary_qa-core-staked.json b/tests/publish/summary_qa-core-staked.json deleted file mode 100644 index d80e6f12b..000000000 --- a/tests/publish/summary_qa-core-staked.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "blockchain_name": "v10:base:84532", - "node_name": "qa-core-staked", - "publish_success_rate": "30.00", - "query_success_rate": "100.00", - "publisher_get_success_rate": "30.00", - "non_publisher_get_success_rate": "0.00", - "average_publish_time": "5.146", - "average_query_time": "0.005", - "average_publisher_get_time": "0.709", - "average_non_publisher_get_time": "0.000", - "time_stamp": "2026-06-17T11:50:48.704Z" -} \ No newline at end of file