import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { createIncrementalSha256 } from '../src/workflows/upload-hash-core.ts'; import { UPLOAD_HASH_CHUNK_BYTES, uploadContentSha256 } from '../src/workflows/upload-identity.ts'; import { captureUpload, executeUpload, initialUploadState } from '../src/workflows/upload.ts'; const fixtures = JSON.parse( await readFile( new URL('./fixtures/upload-content-sha256.json', import.meta.url), 'utf8' ) ); test('incremental browser SHA-256 matches shared fixtures', () => { for (const fixture of fixtures) { const hash = createIncrementalSha256(); const bytes = fixtureBytes(fixture.source); for (let offset = 0; offset < bytes.length; offset += 7919) { hash.update(bytes.subarray(offset, offset + 7919)); } assert.equal(hash.digest(), fixture.sha256, fixture.name); hash.destroy(); } }); test('upload hashing retains at most one bounded chunk', async () => { const size = UPLOAD_HASH_CHUNK_BYTES * 5 + 137; const progress = []; const worker = new FakeHashWorker(); const digest = await uploadContentSha256(new Blob([new Uint8Array(size)]), { workerFactory: () => worker, onProgress: (sample) => progress.push(sample) }); assert.equal( digest, 'ab58d3a8f7489bd83685df43c86a2b69b083dc86542f69603f57f7ae304939c2' ); assert.equal(Math.max(...progress.map(({ liveChunks }) => liveChunks)), 1); assert.equal(Math.max(...progress.map(({ peakLiveChunks }) => peakLiveChunks)), 1); assert.ok( progress.every(({ chunkBytes }) => chunkBytes <= UPLOAD_HASH_CHUNK_BYTES) ); assert.equal(worker.terminateCount, 1); }); test('cancellation terminates an in-flight worker without uploading', async () => { let chunkPosted; const posted = new Promise((resolve) => { chunkPosted = resolve; }); const worker = new FakeHashWorker({ holdChunks: true, onChunk: chunkPosted }); const controller = new AbortController(); const state = initialUploadState(); captureUpload( state, 'Personal', '/payload.bin', new File([new Uint8Array(32)], 'payload.bin'), 'operation-1' ); let uploads = 0; const client = { upload: async () => { uploads += 1; throw new Error('unexpected upload'); } }; const hashing = executeUpload(client, { csrf: 'csrf' }, state, (file) => uploadContentSha256(file, { signal: controller.signal, workerFactory: () => worker }) ); await posted; controller.abort(); await assert.rejects(hashing, { name: 'AbortError' }); assert.equal(uploads, 0); assert.equal(worker.terminateCount, 1); }); test('cancellation interrupts stalled progress instrumentation', async () => { let progressStarted; const started = new Promise((resolve) => { progressStarted = resolve; }); const worker = new FakeHashWorker(); const controller = new AbortController(); const hashing = uploadContentSha256(new Blob([new Uint8Array(32)]), { signal: controller.signal, workerFactory: () => worker, onProgress: () => { progressStarted(); return new Promise(() => {}); } }); await started; controller.abort(); await assert.rejects(hashing, { name: 'AbortError' }); assert.equal(worker.terminateCount, 1); }); test('worker failure prevents the upload request', async () => { const file = new File([new Uint8Array(32)], 'payload.bin'); const state = initialUploadState(); captureUpload(state, 'Personal', '/payload.bin', file, 'operation-1'); let uploads = 0; const client = { upload: async () => { uploads += 1; throw new Error('unexpected upload'); } }; const worker = new FakeHashWorker({ failure: 'worker unavailable' }); await assert.rejects( executeUpload(client, { csrf: 'csrf' }, state, (pending) => uploadContentSha256(pending, { workerFactory: () => worker }) ), /worker unavailable/ ); assert.equal(uploads, 0); assert.equal(worker.terminateCount, 1); }); class FakeHashWorker { onmessage = null; onerror = null; terminateCount = 0; #failure; #hash = createIncrementalSha256(); #holdChunks; #onChunk; constructor({ failure, holdChunks = false, onChunk = () => {} } = {}) { this.#failure = failure; this.#holdChunks = holdChunks; this.#onChunk = onChunk; } postMessage(message, transfer) { const cloned = structuredClone(message, { transfer }); queueMicrotask(() => { if (this.#failure !== undefined) { this.onmessage?.({ data: { kind: 'error', message: this.#failure } }); return; } if (cloned.kind === 'chunk') { this.#onChunk(); if (this.#holdChunks) return; this.#hash.update(new Uint8Array(cloned.bytes)); this.onmessage?.({ data: { kind: 'ready' } }); return; } const digest = this.#hash.digest(); this.onmessage?.({ data: { kind: 'digest', digest } }); }); } terminate() { this.terminateCount += 1; this.#hash.destroy(); } } function fixtureBytes(source) { if (source.kind === 'utf8') return new TextEncoder().encode(source.value); if (source.kind === 'hex') return hexBytes(source.value); const pattern = hexBytes(source.pattern); return Uint8Array.from( { length: source.bytes }, (_, index) => pattern[index % pattern.length] ); } function hexBytes(value) { return Uint8Array.from( value.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16) ); }