feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
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, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user