From 0f222577d848a057e4466c531213a4e8f40f096b Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Fri, 22 May 2026 21:08:02 +0700 Subject: [PATCH] Add resumable upload checkpoint guard --- resumable-upload-checkpoint-guard/README.md | 31 +++ resumable-upload-checkpoint-guard/demo.js | 59 +++++ resumable-upload-checkpoint-guard/index.js | 216 ++++++++++++++++++ .../reports/checkpoint-report.md | 102 +++++++++ .../reports/demo.mp4 | Bin 0 -> 28683 bytes .../reports/summary.svg | 28 +++ .../reports/upload-checkpoint-packet.json | 149 ++++++++++++ .../sample-data.js | 73 ++++++ resumable-upload-checkpoint-guard/test.js | 112 +++++++++ 9 files changed, 770 insertions(+) create mode 100644 resumable-upload-checkpoint-guard/README.md create mode 100644 resumable-upload-checkpoint-guard/demo.js create mode 100644 resumable-upload-checkpoint-guard/index.js create mode 100644 resumable-upload-checkpoint-guard/reports/checkpoint-report.md create mode 100644 resumable-upload-checkpoint-guard/reports/demo.mp4 create mode 100644 resumable-upload-checkpoint-guard/reports/summary.svg create mode 100644 resumable-upload-checkpoint-guard/reports/upload-checkpoint-packet.json create mode 100644 resumable-upload-checkpoint-guard/sample-data.js create mode 100644 resumable-upload-checkpoint-guard/test.js diff --git a/resumable-upload-checkpoint-guard/README.md b/resumable-upload-checkpoint-guard/README.md new file mode 100644 index 00000000..bec66543 --- /dev/null +++ b/resumable-upload-checkpoint-guard/README.md @@ -0,0 +1,31 @@ +# Resumable Upload Checkpoint Guard + +Self-contained Scientific/Engineering Data & Code Hosting slice for issue #14. It validates multipart upload checkpoint evidence before scientific datasets, notebooks, and supplements become durable hosted artifacts. + +## What It Checks + +- Contiguous chunk coverage from index `0` through the expected final chunk. +- Per-chunk declared checksum versus observed checksum evidence. +- Final artifact manifest hash before commit. +- DataCite/schema.org metadata schema readiness. +- Expired checkpoint state that must be restarted instead of resumed. + +## Outputs + +- `reports/upload-checkpoint-packet.json`: structured reviewer decisions and findings. +- `reports/checkpoint-report.md`: readable report for each synthetic upload scenario. +- `reports/summary.svg`: visual summary of commit, hold, and abort decisions. +- `reports/demo.mp4`: short demo artifact for Algora review. + +## Local Verification + +```bash +node resumable-upload-checkpoint-guard/test.js +node resumable-upload-checkpoint-guard/demo.js +node --check resumable-upload-checkpoint-guard/index.js +node --check resumable-upload-checkpoint-guard/test.js +node --check resumable-upload-checkpoint-guard/demo.js +node --check resumable-upload-checkpoint-guard/sample-data.js +``` + +The module is dependency-free, uses synthetic data only, and makes no network calls. diff --git a/resumable-upload-checkpoint-guard/demo.js b/resumable-upload-checkpoint-guard/demo.js new file mode 100644 index 00000000..645a3183 --- /dev/null +++ b/resumable-upload-checkpoint-guard/demo.js @@ -0,0 +1,59 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const {evaluateUploadCheckpoint, buildCheckpointReport} = require('./index'); +const {scenarios} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, {recursive: true}); + +const evaluations = scenarios.map((scenario) => ({ + scenario: scenario.name, + ...evaluateUploadCheckpoint(scenario), +})); + +const packetJson = JSON.stringify(evaluations, null, 2); +const reviewerReport = evaluations.map(buildCheckpointReport).join('\n---\n'); +const commit = evaluations.filter((item) => item.decision === 'commit-artifact').length; +const metadata = evaluations.filter((item) => item.decision === 'hold-metadata').length; +const resume = evaluations.filter((item) => item.decision === 'hold-resume').length; +const abort = evaluations.filter((item) => item.decision === 'abort-and-reupload').length; +const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0); + +const svg = ` + + Resumable Upload Checkpoint Guard + Synthetic multipart upload safety packet for scientific artifacts + + + Commit + ${commit} + + + + Metadata + ${metadata} + + + + Resume Hold + ${resume} + + + + Abort + ${abort} + + Checks: contiguous chunks, checksum evidence, manifest hash, metadata schema, expiry + Reviewer findings: ${findings}. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No private datasets, credentials, storage provider calls, or network access. + +`; + +fs.writeFileSync(path.join(reportsDir, 'upload-checkpoint-packet.json'), `${packetJson}\n`); +fs.writeFileSync(path.join(reportsDir, 'checkpoint-report.md'), reviewerReport); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); + +console.log(`Wrote ${evaluations.length} upload checkpoint evaluations to ${reportsDir}`); +console.log(`Decision counts: commit=${commit}, metadata=${metadata}, resume=${resume}, abort=${abort}`); +console.log(`Reviewer findings: ${findings}`); diff --git a/resumable-upload-checkpoint-guard/index.js b/resumable-upload-checkpoint-guard/index.js new file mode 100644 index 00000000..be342e75 --- /dev/null +++ b/resumable-upload-checkpoint-guard/index.js @@ -0,0 +1,216 @@ +function normalizeList(value) { + return Array.isArray(value) ? value : []; +} + +function uploadAction(type, target, reason) { + return {type, target, reason}; +} + +function missingChunkIndexes(expectedChunks, chunkIndexes) { + const received = new Set(chunkIndexes); + const missing = []; + for (let index = 0; index < expectedChunks; index += 1) { + if (!received.has(index)) { + missing.push(index); + } + } + return missing; +} + +function severityCounts(findings) { + return findings.reduce((counts, finding) => { + counts[finding.severity] = (counts[finding.severity] || 0) + 1; + return counts; + }, {}); +} + +function evaluateUploadCheckpoint(input) { + const artifact = input.artifact || {}; + const chunks = normalizeList(input.chunks); + const expectedChunks = Number(artifact.expectedChunks || 0); + const chunkIndexes = chunks.map((chunk) => chunk.index); + const uniqueChunkIndexes = [...new Set(chunkIndexes)]; + const findings = []; + const requiredActions = []; + const missingChunks = missingChunkIndexes(expectedChunks, chunkIndexes); + const receivedBytes = chunks.reduce((sum, chunk) => sum + Number(chunk.sizeBytes || 0), 0); + + if (missingChunks.length > 0) { + findings.push({ + type: 'missing-upload-chunk', + severity: 'critical', + missingChunks, + message: `Upload checkpoint is missing chunk indexes ${missingChunks.join(', ')}`, + }); + requiredActions.push(uploadAction( + 'abort_incomplete_checkpoint', + input.uploadId, + 'durable artifact commits require contiguous multipart coverage' + )); + } + + const duplicateChunks = uniqueChunkIndexes.filter((index) => + chunkIndexes.filter((chunkIndex) => chunkIndex === index).length > 1 + ); + if (duplicateChunks.length > 0) { + findings.push({ + type: 'duplicate-upload-chunk', + severity: 'major', + duplicateChunks, + message: `Upload checkpoint repeats chunk indexes ${duplicateChunks.join(', ')}`, + }); + requiredActions.push(uploadAction( + 'deduplicate_checkpoint_chunks', + input.uploadId, + 'resume state must have one authoritative checksum per chunk index' + )); + } + + for (const chunk of chunks) { + if (chunk.declaredHash !== chunk.observedHash) { + findings.push({ + type: 'chunk-checksum-mismatch', + severity: 'major', + chunk: chunk.index, + declaredHash: chunk.declaredHash, + observedHash: chunk.observedHash, + message: `Chunk ${chunk.index} declares ${chunk.declaredHash} but observed ${chunk.observedHash}`, + }); + requiredActions.push(uploadAction( + 'reupload_chunk', + `${input.uploadId}:chunk-${chunk.index}`, + 'chunk checksum evidence must match before upload resume or artifact commit' + )); + } + } + + if (artifact.expectedSizeBytes && receivedBytes !== artifact.expectedSizeBytes) { + findings.push({ + type: 'upload-size-mismatch', + severity: 'major', + expectedSizeBytes: artifact.expectedSizeBytes, + receivedBytes, + message: `Received ${receivedBytes} bytes but artifact manifest expects ${artifact.expectedSizeBytes}`, + }); + requiredActions.push(uploadAction( + 'reconcile_upload_size', + input.uploadId, + 'chunk byte totals must match the artifact manifest' + )); + } + + if (!artifact.finalManifestHash) { + findings.push({ + type: 'missing-final-manifest-hash', + severity: 'metadata', + message: 'Artifact lacks final manifest hash', + }); + requiredActions.push(uploadAction( + 'record_final_manifest_hash', + artifact.path || input.uploadId, + 'durable commits need a stable manifest hash for replay and DOI metadata' + )); + } + + if (!artifact.metadataSchema) { + findings.push({ + type: 'missing-metadata-schema', + severity: 'metadata', + message: 'Artifact lacks DataCite/schema.org metadata schema evidence', + }); + requiredActions.push(uploadAction( + 'attach_metadata_schema', + artifact.path || input.uploadId, + 'hosted research artifacts need machine-readable metadata before commit' + )); + } + + if (input.generatedAt && input.expiresAt && Date.parse(input.generatedAt) > Date.parse(input.expiresAt)) { + findings.push({ + type: 'stale-upload-checkpoint', + severity: 'critical', + expiresAt: input.expiresAt, + message: `Upload checkpoint expired at ${input.expiresAt}`, + }); + requiredActions.push(uploadAction( + 'restart_expired_upload', + input.uploadId, + 'expired resume state cannot safely become a durable artifact' + )); + } + + const counts = severityCounts(findings); + const criticalCount = counts.critical || 0; + const majorCount = counts.major || 0; + const metadataCount = counts.metadata || 0; + const decision = criticalCount > 0 + ? 'abort-and-reupload' + : majorCount > 0 + ? 'hold-resume' + : metadataCount > 0 + ? 'hold-metadata' + : 'commit-artifact'; + const integrityScore = Math.max(0, 100 - criticalCount * 40 - majorCount * 25 - metadataCount * 10); + + return { + uploadId: input.uploadId, + generatedAt: input.generatedAt, + expiresAt: input.expiresAt, + artifactPath: artifact.path, + decision, + integrityScore, + findings, + requiredActions, + summary: { + expectedChunks, + receivedChunks: uniqueChunkIndexes.length, + coverage: expectedChunks === 0 ? 1 : uniqueChunkIndexes.length / expectedChunks, + expectedSizeBytes: artifact.expectedSizeBytes || 0, + receivedBytes, + severityCounts: counts, + }, + }; +} + +function percent(value) { + return `${Math.round(value * 100)}%`; +} + +function buildCheckpointReport(result) { + const lines = [ + '# Resumable Upload Checkpoint Guard Report', + '', + `Upload: ${result.uploadId}`, + `Artifact: ${result.artifactPath}`, + `Generated: ${result.generatedAt}`, + `Expires: ${result.expiresAt}`, + `Decision: ${result.decision}`, + `Integrity score: ${result.integrityScore}`, + '', + '## Upload Coverage', + '', + `Chunks: ${result.summary.receivedChunks}/${result.summary.expectedChunks}`, + `Coverage: ${percent(result.summary.coverage)}`, + `Bytes: ${result.summary.receivedBytes}/${result.summary.expectedSizeBytes}`, + `Findings: ${result.findings.length}`, + '', + '## Findings', + '', + ...(result.findings.length + ? result.findings.map((finding) => `- ${finding.severity}: ${finding.type} - ${finding.message}`) + : ['- None']), + '', + '## Required Actions', + '', + ...(result.requiredActions.length + ? result.requiredActions.map((action) => `- ${action.type}: ${action.target} (${action.reason})`) + : ['- None']), + '', + ]; + return lines.join('\n'); +} + +module.exports = { + evaluateUploadCheckpoint, + buildCheckpointReport, +}; diff --git a/resumable-upload-checkpoint-guard/reports/checkpoint-report.md b/resumable-upload-checkpoint-guard/reports/checkpoint-report.md new file mode 100644 index 00000000..d45b9439 --- /dev/null +++ b/resumable-upload-checkpoint-guard/reports/checkpoint-report.md @@ -0,0 +1,102 @@ +# Resumable Upload Checkpoint Guard Report + +Upload: upload-climate-parquet +Artifact: datasets/climate-observations.parquet +Generated: 2026-05-22T14:10:00Z +Expires: 2026-05-23T00:00:00Z +Decision: abort-and-reupload +Integrity score: 35 + +## Upload Coverage + +Chunks: 3/4 +Coverage: 75% +Bytes: 3072/4096 +Findings: 2 + +## Findings + +- critical: missing-upload-chunk - Upload checkpoint is missing chunk indexes 2 +- major: upload-size-mismatch - Received 3072 bytes but artifact manifest expects 4096 + +## Required Actions + +- abort_incomplete_checkpoint: upload-climate-parquet (durable artifact commits require contiguous multipart coverage) +- reconcile_upload_size: upload-climate-parquet (chunk byte totals must match the artifact manifest) + +--- +# Resumable Upload Checkpoint Guard Report + +Upload: upload-notebook +Artifact: notebooks/reproduce.ipynb +Generated: 2026-05-22T14:10:00Z +Expires: 2026-05-23T00:00:00Z +Decision: hold-resume +Integrity score: 75 + +## Upload Coverage + +Chunks: 2/2 +Coverage: 100% +Bytes: 2048/2048 +Findings: 1 + +## Findings + +- major: chunk-checksum-mismatch - Chunk 1 declares sha256:declared but observed sha256:observed + +## Required Actions + +- reupload_chunk: upload-notebook:chunk-1 (chunk checksum evidence must match before upload resume or artifact commit) + +--- +# Resumable Upload Checkpoint Guard Report + +Upload: upload-rna-counts +Artifact: datasets/rna-counts.csv +Generated: 2026-05-22T14:10:00Z +Expires: 2026-05-23T00:00:00Z +Decision: hold-metadata +Integrity score: 80 + +## Upload Coverage + +Chunks: 1/1 +Coverage: 100% +Bytes: 1000/1000 +Findings: 2 + +## Findings + +- metadata: missing-final-manifest-hash - Artifact lacks final manifest hash +- metadata: missing-metadata-schema - Artifact lacks DataCite/schema.org metadata schema evidence + +## Required Actions + +- record_final_manifest_hash: datasets/rna-counts.csv (durable commits need a stable manifest hash for replay and DOI metadata) +- attach_metadata_schema: datasets/rna-counts.csv (hosted research artifacts need machine-readable metadata before commit) + +--- +# Resumable Upload Checkpoint Guard Report + +Upload: upload-clean-supplement +Artifact: supplements/source-data.zip +Generated: 2026-05-22T14:10:00Z +Expires: 2026-05-23T00:00:00Z +Decision: commit-artifact +Integrity score: 100 + +## Upload Coverage + +Chunks: 3/3 +Coverage: 100% +Bytes: 3072/3072 +Findings: 0 + +## Findings + +- None + +## Required Actions + +- None diff --git a/resumable-upload-checkpoint-guard/reports/demo.mp4 b/resumable-upload-checkpoint-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..cd84fda5a67b7392a816b00ab7d11673a5aa47f7 GIT binary patch literal 28683 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 PnsGd3WMyY$VP^S1UXt%U literal 0 HcmV?d00001 diff --git a/resumable-upload-checkpoint-guard/reports/summary.svg b/resumable-upload-checkpoint-guard/reports/summary.svg new file mode 100644 index 00000000..cff70e8c --- /dev/null +++ b/resumable-upload-checkpoint-guard/reports/summary.svg @@ -0,0 +1,28 @@ + + + Resumable Upload Checkpoint Guard + Synthetic multipart upload safety packet for scientific artifacts + + + Commit + 1 + + + + Metadata + 1 + + + + Resume Hold + 1 + + + + Abort + 1 + + Checks: contiguous chunks, checksum evidence, manifest hash, metadata schema, expiry + Reviewer findings: 5. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No private datasets, credentials, storage provider calls, or network access. + diff --git a/resumable-upload-checkpoint-guard/reports/upload-checkpoint-packet.json b/resumable-upload-checkpoint-guard/reports/upload-checkpoint-packet.json new file mode 100644 index 00000000..95dcc316 --- /dev/null +++ b/resumable-upload-checkpoint-guard/reports/upload-checkpoint-packet.json @@ -0,0 +1,149 @@ +[ + { + "scenario": "missing-chunk-abort", + "uploadId": "upload-climate-parquet", + "generatedAt": "2026-05-22T14:10:00Z", + "expiresAt": "2026-05-23T00:00:00Z", + "artifactPath": "datasets/climate-observations.parquet", + "decision": "abort-and-reupload", + "integrityScore": 35, + "findings": [ + { + "type": "missing-upload-chunk", + "severity": "critical", + "missingChunks": [ + 2 + ], + "message": "Upload checkpoint is missing chunk indexes 2" + }, + { + "type": "upload-size-mismatch", + "severity": "major", + "expectedSizeBytes": 4096, + "receivedBytes": 3072, + "message": "Received 3072 bytes but artifact manifest expects 4096" + } + ], + "requiredActions": [ + { + "type": "abort_incomplete_checkpoint", + "target": "upload-climate-parquet", + "reason": "durable artifact commits require contiguous multipart coverage" + }, + { + "type": "reconcile_upload_size", + "target": "upload-climate-parquet", + "reason": "chunk byte totals must match the artifact manifest" + } + ], + "summary": { + "expectedChunks": 4, + "receivedChunks": 3, + "coverage": 0.75, + "expectedSizeBytes": 4096, + "receivedBytes": 3072, + "severityCounts": { + "critical": 1, + "major": 1 + } + } + }, + { + "scenario": "checksum-hold", + "uploadId": "upload-notebook", + "generatedAt": "2026-05-22T14:10:00Z", + "expiresAt": "2026-05-23T00:00:00Z", + "artifactPath": "notebooks/reproduce.ipynb", + "decision": "hold-resume", + "integrityScore": 75, + "findings": [ + { + "type": "chunk-checksum-mismatch", + "severity": "major", + "chunk": 1, + "declaredHash": "sha256:declared", + "observedHash": "sha256:observed", + "message": "Chunk 1 declares sha256:declared but observed sha256:observed" + } + ], + "requiredActions": [ + { + "type": "reupload_chunk", + "target": "upload-notebook:chunk-1", + "reason": "chunk checksum evidence must match before upload resume or artifact commit" + } + ], + "summary": { + "expectedChunks": 2, + "receivedChunks": 2, + "coverage": 1, + "expectedSizeBytes": 2048, + "receivedBytes": 2048, + "severityCounts": { + "major": 1 + } + } + }, + { + "scenario": "metadata-hold", + "uploadId": "upload-rna-counts", + "generatedAt": "2026-05-22T14:10:00Z", + "expiresAt": "2026-05-23T00:00:00Z", + "artifactPath": "datasets/rna-counts.csv", + "decision": "hold-metadata", + "integrityScore": 80, + "findings": [ + { + "type": "missing-final-manifest-hash", + "severity": "metadata", + "message": "Artifact lacks final manifest hash" + }, + { + "type": "missing-metadata-schema", + "severity": "metadata", + "message": "Artifact lacks DataCite/schema.org metadata schema evidence" + } + ], + "requiredActions": [ + { + "type": "record_final_manifest_hash", + "target": "datasets/rna-counts.csv", + "reason": "durable commits need a stable manifest hash for replay and DOI metadata" + }, + { + "type": "attach_metadata_schema", + "target": "datasets/rna-counts.csv", + "reason": "hosted research artifacts need machine-readable metadata before commit" + } + ], + "summary": { + "expectedChunks": 1, + "receivedChunks": 1, + "coverage": 1, + "expectedSizeBytes": 1000, + "receivedBytes": 1000, + "severityCounts": { + "metadata": 2 + } + } + }, + { + "scenario": "clean-upload", + "uploadId": "upload-clean-supplement", + "generatedAt": "2026-05-22T14:10:00Z", + "expiresAt": "2026-05-23T00:00:00Z", + "artifactPath": "supplements/source-data.zip", + "decision": "commit-artifact", + "integrityScore": 100, + "findings": [], + "requiredActions": [], + "summary": { + "expectedChunks": 3, + "receivedChunks": 3, + "coverage": 1, + "expectedSizeBytes": 3072, + "receivedBytes": 3072, + "severityCounts": {} + } + } +] diff --git a/resumable-upload-checkpoint-guard/sample-data.js b/resumable-upload-checkpoint-guard/sample-data.js new file mode 100644 index 00000000..052c5700 --- /dev/null +++ b/resumable-upload-checkpoint-guard/sample-data.js @@ -0,0 +1,73 @@ +const scenarios = [ + { + name: 'missing-chunk-abort', + uploadId: 'upload-climate-parquet', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'datasets/climate-observations.parquet', + expectedChunks: 4, + expectedSizeBytes: 4096, + finalManifestHash: 'sha256:manifest-ok', + metadataSchema: 'DataCite-4.5', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:c1', observedHash: 'sha256:c1'}, + {index: 3, sizeBytes: 1024, declaredHash: 'sha256:c3', observedHash: 'sha256:c3'}, + ], + }, + { + name: 'checksum-hold', + uploadId: 'upload-notebook', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'notebooks/reproduce.ipynb', + expectedChunks: 2, + expectedSizeBytes: 2048, + finalManifestHash: 'sha256:manifest-ok', + metadataSchema: 'schema.org/Dataset', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:declared', observedHash: 'sha256:observed'}, + ], + }, + { + name: 'metadata-hold', + uploadId: 'upload-rna-counts', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'datasets/rna-counts.csv', + expectedChunks: 1, + expectedSizeBytes: 1000, + finalManifestHash: '', + metadataSchema: '', + }, + chunks: [ + {index: 0, sizeBytes: 1000, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + ], + }, + { + name: 'clean-upload', + uploadId: 'upload-clean-supplement', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'supplements/source-data.zip', + expectedChunks: 3, + expectedSizeBytes: 3072, + finalManifestHash: 'sha256:manifest-clean', + metadataSchema: 'DataCite-4.5', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:a', observedHash: 'sha256:a'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:b', observedHash: 'sha256:b'}, + {index: 2, sizeBytes: 1024, declaredHash: 'sha256:c', observedHash: 'sha256:c'}, + ], + }, +]; + +module.exports = {scenarios}; diff --git a/resumable-upload-checkpoint-guard/test.js b/resumable-upload-checkpoint-guard/test.js new file mode 100644 index 00000000..b29143d0 --- /dev/null +++ b/resumable-upload-checkpoint-guard/test.js @@ -0,0 +1,112 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateUploadCheckpoint, + buildCheckpointReport, +} = require('./index'); + +test('blocks commit when upload chunks are not contiguous', () => { + const result = evaluateUploadCheckpoint({ + uploadId: 'upload-climate-parquet', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'datasets/climate-observations.parquet', + expectedChunks: 4, + expectedSizeBytes: 4096, + finalManifestHash: 'sha256:manifest-ok', + metadataSchema: 'DataCite-4.5', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:c1', observedHash: 'sha256:c1'}, + {index: 3, sizeBytes: 1024, declaredHash: 'sha256:c3', observedHash: 'sha256:c3'}, + ], + }); + + assert.equal(result.decision, 'abort-and-reupload'); + assert.equal(result.findings[0].type, 'missing-upload-chunk'); + assert.deepEqual(result.findings[0].missingChunks, [2]); + assert.equal(result.summary.receivedChunks, 3); +}); + +test('holds resume when a chunk checksum does not match checkpoint evidence', () => { + const result = evaluateUploadCheckpoint({ + uploadId: 'upload-notebook', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'notebooks/reproduce.ipynb', + expectedChunks: 2, + expectedSizeBytes: 2048, + finalManifestHash: 'sha256:manifest-ok', + metadataSchema: 'schema.org/Dataset', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:declared', observedHash: 'sha256:observed'}, + ], + }); + + assert.equal(result.decision, 'hold-resume'); + assert.equal(result.findings.length, 1); + assert.equal(result.findings[0].type, 'chunk-checksum-mismatch'); + assert.equal(result.requiredActions[0].type, 'reupload_chunk'); +}); + +test('requires final manifest hash and metadata schema before durable artifact commit', () => { + const result = evaluateUploadCheckpoint({ + uploadId: 'upload-rna-counts', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'datasets/rna-counts.csv', + expectedChunks: 1, + expectedSizeBytes: 1000, + finalManifestHash: '', + metadataSchema: '', + }, + chunks: [ + {index: 0, sizeBytes: 1000, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + ], + }); + + assert.equal(result.decision, 'hold-metadata'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['missing-final-manifest-hash', 'missing-metadata-schema'] + ); +}); + +test('approves complete checkpoint and builds deterministic reviewer report', () => { + const result = evaluateUploadCheckpoint({ + uploadId: 'upload-clean-supplement', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'supplements/source-data.zip', + expectedChunks: 3, + expectedSizeBytes: 3072, + finalManifestHash: 'sha256:manifest-clean', + metadataSchema: 'DataCite-4.5', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:a', observedHash: 'sha256:a'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:b', observedHash: 'sha256:b'}, + {index: 2, sizeBytes: 1024, declaredHash: 'sha256:c', observedHash: 'sha256:c'}, + ], + }); + + assert.equal(result.decision, 'commit-artifact'); + assert.equal(result.findings.length, 0); + assert.equal(result.integrityScore, 100); + assert.equal(result.summary.coverage, 1); + + const report = buildCheckpointReport(result); + assert.match(report, /# Resumable Upload Checkpoint Guard Report/); + assert.match(report, /Upload: upload-clean-supplement/); + assert.match(report, /Decision: commit-artifact/); + assert.match(report, /Integrity score: 100/); + assert.match(report, /Findings: 0/); +});