Files
clean-architecture-frontend…/tests/browser-capabilities/resumable-upload.spec.ts
T

520 lines
17 KiB
TypeScript

import { createHash } from "node:crypto";
import type { Route } from "@playwright/test";
import { expect, test } from "../support/browser/strict-browser-test.ts";
const PAGE_ORIGIN = "http://127.0.0.1:4174";
const API_ORIGIN = "https://upload-api.example.test";
const OBJECT_ORIGIN = "https://upload-objects.example.test";
const SESSION_ID = "session_browser_01";
const RESOURCE_ID = "resource_browser_01";
const UPLOAD_PROTOCOL = "PRESIGNED_MULTIPART_V1";
type JsonRecord = Readonly<Record<string, unknown>>;
function corsHeaders(): Record<string, string> {
return {
"access-control-allow-credentials": "true",
"access-control-allow-headers": "content-type",
"access-control-allow-methods": "POST, OPTIONS",
"access-control-allow-origin": PAGE_ORIGIN,
};
}
async function fulfillJson(
route: Route,
status: number,
value: unknown,
): Promise<void> {
const body = JSON.stringify(value);
await route.fulfill({
status,
body,
headers: {
...corsHeaders(),
"content-length": String(Buffer.byteLength(body)),
"content-type": "application/json; charset=utf-8",
},
});
}
test("uploads three presigned parts through native IndexedDB, Web Locks and fetch", async ({
page,
}) => {
let session: JsonRecord | null = null;
const acceptedParts = new Map<number, JsonRecord>();
const issuedParts = new Map<number, JsonRecord>();
const controlRequests: Readonly<{
path: string;
body: JsonRecord;
}>[] = [];
const uploadedBodies: Readonly<{
partNumber: number;
bytes: number[];
checksum: string | undefined;
}>[] = [];
let completedParts: readonly JsonRecord[] = [];
await page.route(`${API_ORIGIN}/**`, async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: corsHeaders() });
return;
}
const path = new URL(route.request().url()).pathname;
const body = route.request().postDataJSON() as JsonRecord;
controlRequests.push({ path, body });
if (path === "/uploads/create") {
const fingerprint = body.fingerprint as JsonRecord;
session = {
protocol: UPLOAD_PROTOCOL,
sessionId: SESSION_ID,
requestBindingSha256: body.requestBindingSha256,
fingerprint,
partSizeBytes: body.requestedPartSizeBytes,
partCount: fingerprint.partCount,
maxConcurrency: 1,
expiresAtEpochMs: Date.now() + 5 * 60_000,
};
await fulfillJson(route, 201, session);
return;
}
if (path === "/uploads/status") {
if (!session) {
await fulfillJson(route, 404, { state: "NOT_FOUND" });
return;
}
await fulfillJson(route, 200, {
state: "ACTIVE",
session,
acceptedParts: [...acceptedParts.values()].sort(
(left, right) =>
Number(left.partNumber) - Number(right.partNumber),
),
});
return;
}
if (path === "/uploads/complete") {
completedParts = body.orderedParts as readonly JsonRecord[];
await fulfillJson(route, 200, {
state: "QUARANTINED",
protocol: UPLOAD_PROTOCOL,
sessionId: body.sessionId,
requestBindingSha256: body.requestBindingSha256,
fingerprint: body.fingerprint,
resourceId: RESOURCE_ID,
});
return;
}
if (path === "/uploads/abort") {
await fulfillJson(route, 200, { state: "ABORTED" });
return;
}
if (path === "/uploads/part-capability") {
const binding = body.binding as JsonRecord;
const partNumber = Number(binding.partNumber);
const descriptor = {
partNumber,
offset: binding.offset,
byteLength: body.byteLength,
checksumSha256: body.expectedSha256,
};
issuedParts.set(partNumber, descriptor);
const objectPath =
`/uploads/${SESSION_ID}/parts/${String(partNumber)}`;
await fulfillJson(route, 200, {
capabilityReceipt: `browser-part-capability-${String(partNumber)}`,
method: "PUT",
binding,
href:
`${OBJECT_ORIGIN}${objectPath}?sig=opaque-${String(partNumber)}`,
origin: OBJECT_ORIGIN,
path: objectPath,
allowedQueryParameters: ["sig"],
requestHeaders: [
{
name: "content-type",
value: body.mediaType,
},
{
name: "x-checksum-sha256",
value: body.expectedSha256,
},
],
requiredResponseHeaders: [
{
name: "x-policy-version",
value: "v1",
},
],
digestRequestHeader: "x-checksum-sha256",
digestResponseHeader: null,
receiptResponseHeader: "etag",
expectedResponseByteLength: 0,
expectedStatus: 204,
mediaType: body.mediaType,
byteLength: body.byteLength,
maxBytes: body.byteLength,
expectedSha256: body.expectedSha256,
expiresAtEpochMs:
Number(session?.expiresAtEpochMs) - 10_000,
singleUse: true,
});
return;
}
await route.fulfill({ status: 404, headers: corsHeaders() });
});
await page.route(`${OBJECT_ORIGIN}/**`, async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: {
"access-control-allow-headers":
"content-type, x-checksum-sha256",
"access-control-allow-methods": "PUT, OPTIONS",
"access-control-allow-origin": "*",
},
});
return;
}
const match = new URL(route.request().url()).pathname.match(
/\/parts\/([1-9][0-9]*)$/u,
);
const partNumber = Number(match?.[1]);
const body = route.request().postDataBuffer();
if (!body || !Number.isSafeInteger(partNumber)) {
await route.fulfill({ status: 400 });
return;
}
const checksum = route.request().headers()["x-checksum-sha256"];
const actualChecksum = createHash("sha256")
.update(body)
.digest("hex");
const descriptor = issuedParts.get(partNumber);
if (
!descriptor ||
checksum !== actualChecksum ||
descriptor.checksumSha256 !== actualChecksum ||
descriptor.byteLength !== body.byteLength
) {
await route.fulfill({ status: 422 });
return;
}
const receiptToken = `etag-part-${String(partNumber)}`;
acceptedParts.set(partNumber, {
...descriptor,
receiptToken,
});
uploadedBodies.push({
partNumber,
bytes: [...body],
checksum,
});
await route.fulfill({
status: 204,
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "etag, x-policy-version",
etag: `"${receiptToken}"`,
"x-policy-version": "v1",
},
});
});
await page.goto("/");
const result = await page.evaluate(
async ({ apiOrigin, objectOrigin }) => {
const vaultModulePath =
"/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts";
const providerModulePath =
"/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts";
const executorModulePath =
"/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts";
const fetchTransportModulePath =
"/src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts";
const controlPlaneModulePath =
"/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts";
const checkpointModulePath =
"/src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts";
const partExecutorModulePath =
"/src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts";
const uploadRuntimeModulePath =
"/src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts";
const cancellationModulePath =
"/src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
const lockModulePath =
"/src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
const [
{
createPresignedCapabilityVault,
createSingleUsePresignedReplayGuard,
},
{ createPresignedCapabilityHttpProvider },
{ createPresignedTransferExecutor },
{ createResumableUploadFetchJsonTransport },
{ createResumableUploadHttpControlPlane },
{ createIndexedDbResumableUploadCheckpointRuntime },
{ createPresignedUploadPartExecutor },
{ createResumableUploadRuntime },
{ createBrowserUploadCancellationChannel },
{ createResumableUploadWebLock },
] = await Promise.all([
import(/* @vite-ignore */ vaultModulePath),
import(/* @vite-ignore */ providerModulePath),
import(/* @vite-ignore */ executorModulePath),
import(/* @vite-ignore */ fetchTransportModulePath),
import(/* @vite-ignore */ controlPlaneModulePath),
import(/* @vite-ignore */ checkpointModulePath),
import(/* @vite-ignore */ partExecutorModulePath),
import(/* @vite-ignore */ uploadRuntimeModulePath),
import(/* @vite-ignore */ cancellationModulePath),
import(/* @vite-ignore */ lockModulePath),
]);
const suffix = crypto.randomUUID().replaceAll("-", "");
const uploadKey = `upload_browser_${suffix}`;
const checkpointRuntime =
createIndexedDbResumableUploadCheckpointRuntime({
scope: {
authorityToken: `authority_${suffix}`,
namespaceToken: `namespace_${suffix}`,
partitionToken: `partition_${suffix}`,
},
blockedTimeoutMs: 2_000,
});
const vault = createPresignedCapabilityVault({
maxActiveCapabilities: 8,
});
const capabilityProvider =
createPresignedCapabilityHttpProvider({
endpoint: `${apiOrigin}/uploads/part-capability`,
vault,
allowedDataOrigins: [objectOrigin],
allowedDataPathPrefixes: ["/uploads/"],
allowedQueryParameters: ["sig"],
allowedRequestHeaders: [
"content-type",
"x-checksum-sha256",
],
allowedResponseHeaders: ["etag", "x-policy-version"],
hardMaxTransferBytes: 8,
hardMaxUploadResponseBytes: 1_024,
maxCapabilityTtlMs: 10 * 60_000,
minimumRemainingLifetimeMs: 30_000,
timeoutMs: 5_000,
controlPlaneCredentials: "include",
});
const transferExecutor = createPresignedTransferExecutor({
vault,
replayGuard: createSingleUsePresignedReplayGuard(),
hardMaxTransferBytes: 8,
hardMaxChunkBytes: 2,
hardMaxUploadResponseBytes: 1_024,
minimumRemainingLifetimeMs: 30_000,
timeoutMs: 5_000,
});
const transport = createResumableUploadFetchJsonTransport({
endpoints: {
CREATE_SESSION: `${apiOrigin}/uploads/create`,
GET_STATUS: `${apiOrigin}/uploads/status`,
COMPLETE: `${apiOrigin}/uploads/complete`,
ABORT: `${apiOrigin}/uploads/abort`,
},
allowedOrigins: [apiOrigin],
credentials: "include",
timeoutMs: 5_000,
maxRequestBytes: 64 * 1024,
maxResponseBytes: 64 * 1024,
maxRetryAfterMs: 1_000,
});
const controlPlane = createResumableUploadHttpControlPlane({
transport,
partCapabilities: capabilityProvider,
});
const cancellationChannelName =
`browser-upload-cancel-${suffix}`;
const crossContextCancellation =
createBrowserUploadCancellationChannel({
channelName: cancellationChannelName,
});
const cancellationPeer =
createBrowserUploadCancellationChannel({
channelName: cancellationChannelName,
});
const runtime = createResumableUploadRuntime({
controlPlane,
partExecutor: createPresignedUploadPartExecutor(
transferExecutor.uploadParts,
),
checkpoints: checkpointRuntime.store,
mutationLock: createResumableUploadWebLock(
navigator.locks,
"browser-resumable-upload",
),
...(crossContextCancellation
? { crossContextCancellation }
: {}),
crypto,
policy: {
partSizeBytes: 2,
maxFileBytes: 6,
maxPartCount: 3,
maxConcurrency: 2,
maxInFlightBytes: 16,
partBufferCopyFactor: 4,
maxSourceChunkBytes: 2,
maxRetries: 1,
retryBaseDelayMs: 1,
retryMaxDelayMs: 5,
maxRetryAfterMs: 1_000,
capabilityRefreshSkewMs: 1_000,
maxSessionLifetimeMs: 10 * 60_000,
providerAttemptTimeoutMs: 10_000,
},
});
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
const progress: string[] = [];
let upload: unknown;
let checkpoint: unknown;
let deletion: unknown;
let cancellationDelivered = false;
try {
if (crossContextCancellation && cancellationPeer) {
cancellationDelivered = await new Promise<boolean>(
(resolve) => {
const timer = setTimeout(() => resolve(false), 2_000);
const release = cancellationPeer.subscribe((key: string) => {
if (key !== `upload_probe_${suffix}`) return;
clearTimeout(timer);
release();
resolve(true);
});
if (
!crossContextCancellation.publish(
`upload_probe_${suffix}`,
)
) {
clearTimeout(timer);
release();
resolve(false);
}
},
);
}
upload = await runtime.upload({
uploadKey,
purpose: "browser_attachment",
mediaType: "application/octet-stream",
source: {
kind: "RANGE_READER",
reader: {
byteLength: bytes.byteLength,
async readRange(input: {
offset: number;
length: number;
signal: AbortSignal;
}) {
return input.signal.aborted
? {
ok: false as const,
error: {
code: "ABORTED" as const,
operation: "FILE_READ" as const,
retryable: false,
recovery: "NONE" as const,
},
}
: {
ok: true as const,
value: bytes.slice(
input.offset,
input.offset + input.length,
),
};
},
},
},
signal: new AbortController().signal,
onProgress(value: { phase: string }) {
progress.push(value.phase);
},
});
checkpoint = await checkpointRuntime.store.read(uploadKey);
} finally {
runtime.close();
cancellationPeer?.close();
deletion =
await checkpointRuntime.admin.deletePartition();
vault.dispose();
}
return {
upload,
checkpoint,
deletion,
progress,
webLocksAvailable: Boolean(navigator.locks),
broadcastCancellationAvailable:
Boolean(crossContextCancellation),
cancellationDelivered,
};
},
{ apiOrigin: API_ORIGIN, objectOrigin: OBJECT_ORIGIN },
);
expect(result.webLocksAvailable).toBe(true);
expect(result.broadcastCancellationAvailable).toBe(true);
expect(result.cancellationDelivered).toBe(true);
expect(result.upload).toMatchObject({
ok: true,
value: {
state: "QUARANTINED",
resourceId: RESOURCE_ID,
byteLength: 5,
replayed: false,
},
});
expect(result.checkpoint).toEqual({ ok: true, value: null });
expect(result.deletion).toEqual({
ok: true,
value: { state: "DELETED" },
});
expect(
uploadedBodies
.slice()
.sort((left, right) => left.partNumber - right.partNumber)
.map(({ partNumber, bytes }) => ({ partNumber, bytes })),
).toEqual([
{ partNumber: 1, bytes: [1, 2] },
{ partNumber: 2, bytes: [3, 4] },
{ partNumber: 3, bytes: [5] },
]);
expect(completedParts.map((part) => part.partNumber)).toEqual([
1, 2, 3,
]);
expect(completedParts.map((part) => part.receiptToken)).toEqual([
"etag-part-1",
"etag-part-2",
"etag-part-3",
]);
expect(controlRequests.map((request) => request.path)).toEqual([
"/uploads/create",
"/uploads/status",
"/uploads/part-capability",
"/uploads/part-capability",
"/uploads/part-capability",
"/uploads/status",
"/uploads/complete",
]);
expect(JSON.stringify(controlRequests)).not.toContain("?sig=");
expect(JSON.stringify(controlRequests)).not.toContain(OBJECT_ORIGIN);
});