Bound browser upload identity memory
This commit is contained in:
parent
0c2d992224
commit
4d31c441d0
1 changed files with 544 additions and 5 deletions
196
web/test/upload-identity.test.mjs
Normal file
196
web/test/upload-identity.test.mjs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
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)
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue