fix: complete presigned capability and upload transport contracts
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
976c8a8da4
commit
000a2581af
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
|
||||
|
||||
import type {
|
||||
PresignedDownloadCapability,
|
||||
PresignedDownloadByteSource,
|
||||
@@ -43,6 +45,8 @@ function downloadCapabilityPayload(
|
||||
) {
|
||||
const digest = sha256Hex(bytes);
|
||||
return {
|
||||
// BT-PRE-02. Every capability envelope carries the top-level protocol.
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: "capability-download-1",
|
||||
method: "GET",
|
||||
binding: {
|
||||
@@ -81,6 +85,7 @@ function uploadCapabilityPayload(input: Readonly<{
|
||||
checksum: string;
|
||||
}>, overrides: Readonly<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: "capability-upload-1",
|
||||
method: "PUT",
|
||||
binding: {
|
||||
@@ -658,6 +663,224 @@ describe("presigned transfer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "missing", protocol: undefined },
|
||||
{ label: "V0", protocol: "PRESIGNED_TRANSFER_V0" },
|
||||
{ label: "V2", protocol: "PRESIGNED_TRANSFER_V2" },
|
||||
])(
|
||||
"requires PRESIGNED_TRANSFER_V1 in request and response ($label)",
|
||||
async ({ protocol }) => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
const body =
|
||||
protocol === undefined
|
||||
? (({ protocol: _dropped, ...rest }) => rest)(
|
||||
payload as Record<string, unknown>,
|
||||
)
|
||||
: { ...payload, protocol };
|
||||
const requests: unknown[] = [];
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input) === CONTROL_ENDPOINT) {
|
||||
requests.push(JSON.parse(String(init?.body)));
|
||||
return jsonResponse(body as never);
|
||||
}
|
||||
return downloadResponse(bytes.slice().buffer, payload);
|
||||
}) as unknown as typeof fetch;
|
||||
const { provider, vault } = createHarness({ fetcher });
|
||||
|
||||
const issued = await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
// BT-PRE-02. The request always declares V1, and a response that does not
|
||||
// is closed before the vault ever registers it.
|
||||
expect(requests[0]).toMatchObject({
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
});
|
||||
expect(issued).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
// The rejected envelope never reached vault registration.
|
||||
expect(vault.resolve).toBeTypeOf("function");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "encoded slash", path: "/files/a%2Fb" },
|
||||
{ label: "encoded backslash", path: "/files/a%5Cb" },
|
||||
{ label: "double-encoded dot segment", path: "/files/%252e%252e" },
|
||||
{ label: "lowercase percent-hex", path: "/files/a%c3%a9" },
|
||||
{ label: "encoded NUL", path: "/files/a%00b" },
|
||||
])("rejects a provider path that can decode again ($label)", async ({ path }) => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const payload = downloadCapabilityPayload(bytes, {
|
||||
path,
|
||||
href: `${DATA_ORIGIN}${path}`,
|
||||
});
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input) === CONTROL_ENDPOINT
|
||||
? jsonResponse(payload)
|
||||
: downloadResponse(bytes.slice().buffer, payload),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider } = createHarness({ fetcher });
|
||||
|
||||
await expect(
|
||||
provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a valid opaque UTF-8 path segment", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
// BT-PRE-05. Canonical uppercase percent-hex for a real UTF-8 segment.
|
||||
const path = `/files/${encodeURIComponent("caf\u00e9")}`;
|
||||
const payload = downloadCapabilityPayload(bytes, {
|
||||
path,
|
||||
href: `${DATA_ORIGIN}${path}?sig=do-not-log-this`,
|
||||
});
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input) === CONTROL_ENDPOINT
|
||||
? jsonResponse(payload)
|
||||
: downloadResponse(bytes.slice().buffer, payload),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider } = createHarness({ fetcher });
|
||||
|
||||
await expect(
|
||||
provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("bounds a fetch that ignores its abort signal and cancels the late body", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
const cancel = vi.fn(async () => {});
|
||||
let releaseControl: ((response: Response) => void) | undefined;
|
||||
const timers: Array<() => void> = [];
|
||||
const fetcher = vi.fn(
|
||||
async () =>
|
||||
await new Promise<Response>((resolve) => {
|
||||
releaseControl = resolve;
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider } = createHarness({
|
||||
fetcher,
|
||||
scheduler: {
|
||||
setTimeout: (callback: () => void) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const issuing = provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
// BT-PRE-03. The timeout fires while the fetch is still pending and never
|
||||
// settles on its own.
|
||||
timers.forEach((fire) => fire());
|
||||
|
||||
await expect(issuing).resolves.toMatchObject({ ok: false });
|
||||
|
||||
releaseControl?.({ body: { cancel } } as unknown as Response);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
void payload;
|
||||
});
|
||||
|
||||
it("does not leak an abort listener when the scheduler throws", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
const caller = new AbortController();
|
||||
const remove = vi.spyOn(caller.signal, "removeEventListener");
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input) === CONTROL_ENDPOINT
|
||||
? jsonResponse(payload)
|
||||
: downloadResponse(bytes.slice().buffer, payload),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider } = createHarness({
|
||||
fetcher,
|
||||
scheduler: {
|
||||
setTimeout: () => {
|
||||
throw new TypeError("scheduler exploded");
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
// The public result stays a typed Result, not a rejection.
|
||||
await expect(
|
||||
provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: caller.signal,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "href/origin mismatch", patch: { origin: "https://evil.example" } },
|
||||
{ label: "href/path mismatch", patch: { path: "/files/other" } },
|
||||
{ label: "credentials in href", patch: { href: `https://u:p@objects.example${DOWNLOAD_PATH}` } },
|
||||
{ label: "maxBytes below byteLength", patch: { maxBytes: 0 } },
|
||||
{ label: "malformed digest", patch: { expectedSha256: "not-a-digest" } },
|
||||
{ label: "non-positive expiry", patch: { expiresAtEpochMs: 0 } },
|
||||
])(
|
||||
"rejects a malformed registration at the vault issuer seam ($label)",
|
||||
({ patch }) => {
|
||||
// BT-PRE-04. The vault owns these invariants itself, so a second issuer
|
||||
// cannot register a weaker capability of the same type.
|
||||
const vault = createPresignedCapabilityVault({
|
||||
now: () => NOW,
|
||||
maxActiveCapabilities: 4,
|
||||
});
|
||||
const base = {
|
||||
capabilityReceipt: "capability-direct-1",
|
||||
method: "GET" as const,
|
||||
binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" },
|
||||
href: `${DATA_ORIGIN}${DOWNLOAD_PATH}`,
|
||||
origin: DATA_ORIGIN,
|
||||
path: DOWNLOAD_PATH,
|
||||
allowedQueryParameters: [],
|
||||
requestHeaders: [],
|
||||
requiredResponseHeaders: [],
|
||||
digestRequestHeader: null,
|
||||
digestResponseHeader: null,
|
||||
receiptResponseHeader: null,
|
||||
expectedStatus: 200,
|
||||
expectedResponseByteLength: 3,
|
||||
mediaType: "application/octet-stream",
|
||||
byteLength: 3,
|
||||
maxBytes: 3,
|
||||
expectedSha256: "a".repeat(64),
|
||||
expiresAtEpochMs: NOW + 60_000,
|
||||
};
|
||||
|
||||
expect(
|
||||
vault.register({ ...base, ...patch } as never),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
// The same registration without the defect is accepted.
|
||||
expect(vault.register(base as never)).toMatchObject({ ok: true });
|
||||
vault.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not fetch a presigned download until stream consumption", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const responsePayload = downloadCapabilityPayload(bytes);
|
||||
|
||||
Reference in New Issue
Block a user