BT-PRE-02: add the top-level PRESIGNED_TRANSFER_V1 protocol literal to the capability request and response. A missing, V0 or V2 envelope is closed as POLICY_REJECTED before the vault registers anything. The server negotiates by request shape; fields are never dual-emitted into a strict decoder, and the nested PRESIGNED_MULTIPART_V1 binding protocol is unchanged. BT-PRE-03: aborting a controller does not settle a fetch that ignores its signal, so both presigned scopes now race the task, cancel a late response body and survive a throwing scheduler without leaking the external abort listener. BT-PRE-04: the vault owns its registration invariants, re-checking method, href/origin/path agreement, embedded credentials, byte bounds, digest shape and expiry, so a second issuer cannot register a weaker capability of the same type. BT-PRE-05: decode each path segment once and require it to round-trip through the canonical uppercase percent encoder, closing %2f, %5c, %252e%252e, mixed-case escapes and encoded NUL while still admitting valid opaque UTF-8 segments. BT-UP-02: inject and snapshot the upload transport clock and scheduler, so Retry-After delta-seconds and HTTP-date resolve against the same captured now and a clock rollback clamps to zero instead of producing a negative delay. BT-IMG-01: make the image resolve() lifetime signal required, replacing the hidden PRIMARY_REQUIRED preset precondition with a type-level one, and add the negative typecheck fixture and gate that prove it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
516 lines
16 KiB
TypeScript
516 lines
16 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
|
|
|
|
import type {
|
|
PresignedUploadPartCapabilityProvider,
|
|
} from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
|
|
import type {
|
|
ResumableUploadCheckpoint,
|
|
ResumableUploadCheckpointStore,
|
|
UploadFileFingerprint,
|
|
UploadPartReceipt,
|
|
UploadSession,
|
|
} from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
|
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
|
import type { BrowserDataResult } from "../../src/application/ports/browser-file-storage/shared.ts";
|
|
import {
|
|
browserDataFailure,
|
|
browserDataSuccess,
|
|
} from "../../src/adapters/browser-file-storage/result.ts";
|
|
import {
|
|
createPresignedCapabilityVault,
|
|
createSingleUsePresignedReplayGuard,
|
|
} from "../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts";
|
|
import { createPresignedCapabilityHttpProvider } from "../../src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts";
|
|
import { createPresignedTransferExecutor } from "../../src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts";
|
|
import { createResumableUploadFetchJsonTransport } from "../../src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts";
|
|
import {
|
|
createResumableUploadHttpControlPlane,
|
|
type ResumableUploadJsonTransport,
|
|
} from "../../src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts";
|
|
import { createPresignedUploadPartExecutor } from "../../src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts";
|
|
import { createResumableUploadRuntime } from "../../src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts";
|
|
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
|
|
|
const NOW = 1_000_000;
|
|
const API_ORIGIN = "https://api.example";
|
|
const OBJECT_ORIGIN = "https://objects.example";
|
|
const CAPABILITY_ENDPOINT = `${API_ORIGIN}/capabilities`;
|
|
const CONTROL_ENDPOINTS = Object.freeze({
|
|
CREATE_SESSION: `${API_ORIGIN}/uploads/create`,
|
|
GET_STATUS: `${API_ORIGIN}/uploads/status`,
|
|
COMPLETE: `${API_ORIGIN}/uploads/complete`,
|
|
ABORT: `${API_ORIGIN}/uploads/abort`,
|
|
});
|
|
const CHECKSUM_HEADER = "x-checksum-sha256";
|
|
const POLICY_HEADER = "x-policy-version";
|
|
const activeSignal = new AbortController().signal;
|
|
|
|
const noContentionLock: UploadMutationLock = Object.freeze({
|
|
async run<Value>(
|
|
_uploadKey: string,
|
|
_signal: AbortSignal,
|
|
task: () => Promise<Value>,
|
|
): Promise<Value> {
|
|
return await task();
|
|
},
|
|
});
|
|
|
|
class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
|
|
readonly rows = new Map<string, ResumableUploadCheckpoint>();
|
|
|
|
async read(
|
|
uploadKey: string,
|
|
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
|
|
return browserDataSuccess(
|
|
structuredClone(this.rows.get(uploadKey) ?? null),
|
|
);
|
|
}
|
|
|
|
async compareAndSwap(
|
|
input: Parameters<
|
|
ResumableUploadCheckpointStore["compareAndSwap"]
|
|
>[0],
|
|
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
|
|
const current = this.rows.get(input.checkpoint.uploadKey);
|
|
if (
|
|
(input.expectedRevision === null && current) ||
|
|
(input.expectedRevision !== null &&
|
|
current?.revision !== input.expectedRevision)
|
|
) {
|
|
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
|
recovery: "RECONCILE",
|
|
});
|
|
}
|
|
const snapshot = structuredClone(input.checkpoint);
|
|
this.rows.set(snapshot.uploadKey, snapshot);
|
|
return browserDataSuccess(snapshot);
|
|
}
|
|
|
|
async remove(
|
|
input: Parameters<
|
|
ResumableUploadCheckpointStore["remove"]
|
|
>[0],
|
|
): Promise<BrowserDataResult<void>> {
|
|
const current = this.rows.get(input.uploadKey);
|
|
if (current?.revision !== input.expectedRevision) {
|
|
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
|
recovery: "RECONCILE",
|
|
});
|
|
}
|
|
this.rows.delete(input.uploadKey);
|
|
return browserDataSuccess(undefined);
|
|
}
|
|
|
|
close(): void {}
|
|
}
|
|
|
|
function responseAt(
|
|
url: string,
|
|
body: BodyInit | null,
|
|
init: ResponseInit,
|
|
): Response {
|
|
const response = new Response(body, init);
|
|
Object.defineProperty(response, "url", {
|
|
configurable: true,
|
|
value: url,
|
|
});
|
|
return response;
|
|
}
|
|
|
|
function jsonResponseAt(
|
|
url: string,
|
|
value: unknown,
|
|
status = 200,
|
|
): Response {
|
|
const body = JSON.stringify(value);
|
|
return responseAt(url, body, {
|
|
status,
|
|
headers: {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"content-length": String(new TextEncoder().encode(body).byteLength),
|
|
},
|
|
});
|
|
}
|
|
|
|
function rangeSource(bytes: Uint8Array) {
|
|
return Object.freeze({
|
|
kind: "RANGE_READER" as const,
|
|
reader: Object.freeze({
|
|
byteLength: bytes.byteLength,
|
|
async readRange(input: Readonly<{
|
|
offset: number;
|
|
length: number;
|
|
signal: AbortSignal;
|
|
}>) {
|
|
return input.signal.aborted
|
|
? browserDataFailure("ABORTED", "FILE_READ")
|
|
: browserDataSuccess(
|
|
bytes.slice(
|
|
input.offset,
|
|
input.offset + input.length,
|
|
),
|
|
);
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
describe("resumable upload HTTP control plane", () => {
|
|
it("rejects unknown response fields so URLs cannot cross the DTO boundary", async () => {
|
|
const fingerprint: UploadFileFingerprint = Object.freeze({
|
|
algorithm: "SHA-256-PARTS-V1",
|
|
digestHex: "a".repeat(64),
|
|
byteLength: 4,
|
|
partSizeBytes: 4,
|
|
partCount: 1,
|
|
});
|
|
const transport: ResumableUploadJsonTransport = {
|
|
async execute() {
|
|
return browserDataSuccess({
|
|
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
|
sessionId: "session_01",
|
|
requestBindingSha256: "b".repeat(64),
|
|
fingerprint,
|
|
partSizeBytes: 4,
|
|
partCount: 1,
|
|
maxConcurrency: 1,
|
|
expiresAtEpochMs: NOW + 10_000,
|
|
signedUrl: "https://objects.example/secret?signature=leak",
|
|
});
|
|
},
|
|
};
|
|
const partCapabilities: PresignedUploadPartCapabilityProvider = {
|
|
issueUploadPart: vi.fn(),
|
|
};
|
|
const control = createResumableUploadHttpControlPlane({
|
|
transport,
|
|
partCapabilities,
|
|
});
|
|
|
|
const result = await control.createSession({
|
|
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
|
uploadKey: "upload_key_strict",
|
|
purpose: "attachment",
|
|
mediaType: "application/octet-stream",
|
|
requestBindingSha256: "b".repeat(64),
|
|
fingerprint,
|
|
requestedPartSizeBytes: 4,
|
|
requestedMaxConcurrency: 1,
|
|
idempotencyKey: "upload-create-idempotency-01",
|
|
signal: activeSignal,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: "CORRUPT_DATA",
|
|
operation: "UPLOAD_SESSION",
|
|
recovery: "RECONCILE",
|
|
},
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("signature=leak");
|
|
});
|
|
|
|
it("runs create, list-parts, session-bound capability PUT and ordered complete through actual fetch adapters", async () => {
|
|
const checkpoints = new MemoryCheckpointStore();
|
|
const accepted = new Map<number, UploadPartReceipt>();
|
|
const issuedBindings: Array<Readonly<Record<string, unknown>>> = [];
|
|
const objectPutOptions: RequestInit[] = [];
|
|
const completedParts: UploadPartReceipt[][] = [];
|
|
const partByHref = new Map<
|
|
string,
|
|
Readonly<{
|
|
partNumber: number;
|
|
offset: number;
|
|
byteLength: number;
|
|
checksumSha256: string;
|
|
}>
|
|
>();
|
|
let session: UploadSession | null = null;
|
|
let completed = false;
|
|
let capabilitySequence = 0;
|
|
|
|
const fetcher = vi.fn(
|
|
async (
|
|
input: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
): Promise<Response> => {
|
|
const url = String(input);
|
|
if (url === CAPABILITY_ENDPOINT) {
|
|
const request = JSON.parse(String(init?.body)) as Readonly<{
|
|
method: string;
|
|
binding: Readonly<{
|
|
kind: string;
|
|
sessionId: string;
|
|
requestBindingSha256: string;
|
|
uploadBindingSha256: string;
|
|
partNumber: number;
|
|
offset: number;
|
|
idempotencyKey: string;
|
|
}>;
|
|
mediaType: string;
|
|
byteLength: number;
|
|
expectedSha256: string;
|
|
}>;
|
|
issuedBindings.push(
|
|
Object.freeze({ ...request.binding }),
|
|
);
|
|
capabilitySequence += 1;
|
|
const path =
|
|
`/multipart/${request.binding.sessionId}` +
|
|
`/part-${request.binding.partNumber}`;
|
|
const href =
|
|
`${OBJECT_ORIGIN}${path}` +
|
|
`?signature=opaque-${capabilitySequence}`;
|
|
partByHref.set(
|
|
href,
|
|
Object.freeze({
|
|
partNumber: request.binding.partNumber,
|
|
offset: request.binding.offset,
|
|
byteLength: request.byteLength,
|
|
checksumSha256: request.expectedSha256,
|
|
}),
|
|
);
|
|
return jsonResponseAt(CAPABILITY_ENDPOINT, {
|
|
// BT-PRE-02. The capability envelope declares its wire protocol.
|
|
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
|
capabilityReceipt: `capability-upload-${capabilitySequence}`,
|
|
method: "PUT",
|
|
binding: request.binding,
|
|
href,
|
|
origin: OBJECT_ORIGIN,
|
|
path,
|
|
allowedQueryParameters: ["signature"],
|
|
requestHeaders: [
|
|
{
|
|
name: "content-type",
|
|
value: request.mediaType,
|
|
},
|
|
{
|
|
name: CHECKSUM_HEADER,
|
|
value: request.expectedSha256,
|
|
},
|
|
],
|
|
requiredResponseHeaders: [
|
|
{ name: POLICY_HEADER, value: "v1" },
|
|
],
|
|
digestRequestHeader: CHECKSUM_HEADER,
|
|
digestResponseHeader: null,
|
|
receiptResponseHeader: "etag",
|
|
expectedStatus: 200,
|
|
expectedResponseByteLength: 0,
|
|
mediaType: request.mediaType,
|
|
byteLength: request.byteLength,
|
|
maxBytes: request.byteLength,
|
|
expectedSha256: request.expectedSha256,
|
|
expiresAtEpochMs: NOW + 30_000,
|
|
singleUse: true,
|
|
});
|
|
}
|
|
const part = partByHref.get(url);
|
|
if (part) {
|
|
objectPutOptions.push(init ?? {});
|
|
const receiptToken = `etag-part-${part.partNumber}`;
|
|
accepted.set(
|
|
part.partNumber,
|
|
Object.freeze({
|
|
...part,
|
|
receiptToken,
|
|
}),
|
|
);
|
|
return responseAt(url, null, {
|
|
status: 200,
|
|
headers: {
|
|
"content-length": "0",
|
|
[POLICY_HEADER]: "v1",
|
|
etag: receiptToken,
|
|
},
|
|
});
|
|
}
|
|
|
|
const request = JSON.parse(String(init?.body)) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
if (url === CONTROL_ENDPOINTS.CREATE_SESSION) {
|
|
const fingerprint =
|
|
request.fingerprint as UploadFileFingerprint;
|
|
session = Object.freeze({
|
|
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
|
sessionId: "session_integration_01",
|
|
requestBindingSha256: String(
|
|
request.requestBindingSha256,
|
|
),
|
|
fingerprint,
|
|
partSizeBytes: Number(
|
|
request.requestedPartSizeBytes,
|
|
),
|
|
partCount: fingerprint.partCount,
|
|
maxConcurrency: 2,
|
|
expiresAtEpochMs: NOW + 60_000,
|
|
});
|
|
return jsonResponseAt(url, session, 201);
|
|
}
|
|
if (url === CONTROL_ENDPOINTS.GET_STATUS && session) {
|
|
return completed
|
|
? jsonResponseAt(url, {
|
|
state: "QUARANTINED",
|
|
session,
|
|
resourceId: "resource_integration_01",
|
|
})
|
|
: jsonResponseAt(url, {
|
|
state: "ACTIVE",
|
|
session,
|
|
acceptedParts: [...accepted.values()].sort(
|
|
(left, right) =>
|
|
left.partNumber - right.partNumber,
|
|
),
|
|
});
|
|
}
|
|
if (url === CONTROL_ENDPOINTS.COMPLETE && session) {
|
|
completedParts.push(
|
|
request.orderedParts as UploadPartReceipt[],
|
|
);
|
|
completed = true;
|
|
return jsonResponseAt(url, {
|
|
state: "QUARANTINED",
|
|
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
|
sessionId: session.sessionId,
|
|
requestBindingSha256:
|
|
session.requestBindingSha256,
|
|
fingerprint: session.fingerprint,
|
|
resourceId: "resource_integration_01",
|
|
});
|
|
}
|
|
if (url === CONTROL_ENDPOINTS.ABORT) {
|
|
return jsonResponseAt(url, { state: "ABORTED" });
|
|
}
|
|
throw new TypeError("Unexpected test endpoint.");
|
|
},
|
|
) as unknown as typeof fetch;
|
|
|
|
const vault = createPresignedCapabilityVault({
|
|
maxActiveCapabilities: 16,
|
|
now: () => NOW,
|
|
});
|
|
const capabilityProvider =
|
|
createPresignedCapabilityHttpProvider({
|
|
endpoint: CAPABILITY_ENDPOINT,
|
|
vault,
|
|
allowedDataOrigins: [OBJECT_ORIGIN],
|
|
allowedDataPathPrefixes: ["/multipart/"],
|
|
allowedQueryParameters: ["signature"],
|
|
allowedRequestHeaders: [
|
|
"content-type",
|
|
CHECKSUM_HEADER,
|
|
],
|
|
allowedResponseHeaders: [POLICY_HEADER, "etag"],
|
|
hardMaxTransferBytes: 64,
|
|
hardMaxUploadResponseBytes: 16,
|
|
maxCapabilityTtlMs: 60_000,
|
|
minimumRemainingLifetimeMs: 100,
|
|
timeoutMs: 5_000,
|
|
fetcher,
|
|
now: () => NOW,
|
|
});
|
|
const presignedExecutor = createPresignedTransferExecutor({
|
|
vault,
|
|
replayGuard: createSingleUsePresignedReplayGuard(),
|
|
hardMaxTransferBytes: 64,
|
|
hardMaxChunkBytes: 4,
|
|
hardMaxUploadResponseBytes: 16,
|
|
minimumRemainingLifetimeMs: 100,
|
|
timeoutMs: 5_000,
|
|
fetcher,
|
|
now: () => NOW,
|
|
});
|
|
const transport = createResumableUploadFetchJsonTransport({
|
|
endpoints: CONTROL_ENDPOINTS,
|
|
allowedOrigins: [API_ORIGIN],
|
|
credentials: "same-origin",
|
|
fetcher,
|
|
timeoutMs: 5_000,
|
|
maxRequestBytes: 64 * 1024,
|
|
maxResponseBytes: 64 * 1024,
|
|
});
|
|
const controlPlane = createResumableUploadHttpControlPlane({
|
|
transport,
|
|
partCapabilities: capabilityProvider,
|
|
});
|
|
const runtime = createResumableUploadRuntime({
|
|
controlPlane,
|
|
partExecutor: createPresignedUploadPartExecutor(
|
|
presignedExecutor.uploadParts,
|
|
() => NOW,
|
|
),
|
|
checkpoints,
|
|
mutationLock: noContentionLock,
|
|
crypto,
|
|
policy: {
|
|
partSizeBytes: 4,
|
|
maxFileBytes: 64,
|
|
maxPartCount: 16,
|
|
maxConcurrency: 2,
|
|
maxInFlightBytes: 32,
|
|
partBufferCopyFactor: 4,
|
|
maxSourceChunkBytes: 4,
|
|
maxRetries: 1,
|
|
retryBaseDelayMs: 1,
|
|
retryMaxDelayMs: 10,
|
|
maxRetryAfterMs: 100,
|
|
capabilityRefreshSkewMs: 100,
|
|
maxSessionLifetimeMs: 120_000,
|
|
providerAttemptTimeoutMs: 5_000,
|
|
},
|
|
now: () => NOW,
|
|
random: () => 0,
|
|
sleep: async () => {},
|
|
});
|
|
|
|
const result = await runtime.upload({
|
|
uploadKey: "upload_key_integration",
|
|
purpose: "attachment",
|
|
mediaType: "application/octet-stream",
|
|
source: rangeSource(
|
|
new Uint8Array([1, 2, 3, 4, 5, 6]),
|
|
),
|
|
signal: activeSignal,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
state: "QUARANTINED",
|
|
resourceId: "resource_integration_01",
|
|
byteLength: 6,
|
|
replayed: false,
|
|
},
|
|
});
|
|
expect(issuedBindings).toHaveLength(2);
|
|
expect(
|
|
issuedBindings.every(
|
|
(binding) =>
|
|
binding.protocol === RESUMABLE_UPLOAD_PROTOCOL &&
|
|
binding.sessionId === "session_integration_01" &&
|
|
binding.requestBindingSha256 ===
|
|
session?.requestBindingSha256,
|
|
),
|
|
).toBe(true);
|
|
expect(objectPutOptions).toHaveLength(2);
|
|
expect(
|
|
objectPutOptions.every(
|
|
(options) =>
|
|
options.method === "PUT" &&
|
|
options.credentials === "omit" &&
|
|
options.redirect === "error",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
completedParts[0]?.map((part) => part.partNumber),
|
|
).toEqual([1, 2]);
|
|
expect(checkpoints.rows.size).toBe(0);
|
|
});
|
|
});
|