759 lines
20 KiB
TypeScript
759 lines
20 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type {
|
|
FileVerificationReceipt,
|
|
LocalFileRef,
|
|
} from "../../src/application/ports/browser-file-storage/file.ts";
|
|
import { BrowserFileVault } from "../../src/adapters/browser-files/browser-file-vault.ts";
|
|
import {
|
|
BrowserTransientPreview,
|
|
ObjectUrlLeaseRegistry,
|
|
} from "../../src/adapters/browser-files/object-url-lease.ts";
|
|
import {
|
|
sanitizeSuggestedFileName,
|
|
type RegisteredFileInspectionPolicy,
|
|
type RegisteredFileSelectionPolicy,
|
|
} from "../../src/adapters/browser-files/file-policy.ts";
|
|
import {
|
|
BrowserFilePolicyRegistry,
|
|
browserFilePolicyReference,
|
|
} from "../../src/adapters/browser-files/browser-file-policy-registry.ts";
|
|
|
|
const pngSelectionPolicy: RegisteredFileSelectionPolicy = Object.freeze({
|
|
policyId: "avatar-v1",
|
|
purpose: "avatar",
|
|
classification: "PERSONAL",
|
|
multiple: false,
|
|
maxCount: 1,
|
|
maxFileBytes: 1_024,
|
|
maxTotalBytes: 1_024,
|
|
allowEmpty: false,
|
|
accept: Object.freeze([
|
|
Object.freeze({
|
|
mediaType: "image/png",
|
|
extensions: Object.freeze([".png"]),
|
|
}),
|
|
]),
|
|
});
|
|
|
|
const pngInspectionPolicy: RegisteredFileInspectionPolicy = Object.freeze({
|
|
policyId: "avatar-v1",
|
|
maxInspectionBytes: 16,
|
|
acceptedSignatures: Object.freeze([
|
|
Object.freeze({
|
|
mediaType: "image/png",
|
|
extensions: Object.freeze([".png"]),
|
|
patterns: Object.freeze([
|
|
Object.freeze({
|
|
offset: 0,
|
|
bytes: Object.freeze([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
]),
|
|
}),
|
|
]),
|
|
}),
|
|
]),
|
|
});
|
|
const pngPolicy = browserFilePolicyReference(
|
|
"avatar",
|
|
"inspect-and-preview-png",
|
|
);
|
|
|
|
function pngPolicies(
|
|
maxPreviewBytes = 10,
|
|
): BrowserFilePolicyRegistry {
|
|
return new BrowserFilePolicyRegistry({
|
|
profiles: [
|
|
{
|
|
reference: pngPolicy,
|
|
inspection: pngInspectionPolicy,
|
|
preview: {
|
|
allowedMediaTypes: ["image/png"],
|
|
maxPreviewBytes,
|
|
},
|
|
},
|
|
],
|
|
hardLimits: {
|
|
maxInspectionBytes: 64 * 1024,
|
|
maxRetainedFileBytes: 2_048,
|
|
maxPreviewBytes,
|
|
maxObjectUrlBytes: 2_048,
|
|
maxTransferBytes: 2_048,
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("browser file content policy", () => {
|
|
it("neutralizes path, bidi, device-name and executable filename tricks", () => {
|
|
expect(
|
|
sanitizeSuggestedFileName("..\\CON\u202Egpj.exe", {
|
|
safeExtension: ".pdf",
|
|
}),
|
|
).toBe("CONgpj.pdf");
|
|
expect(
|
|
sanitizeSuggestedFileName("invoice.exe.pdf", {
|
|
safeExtension: ".pdf",
|
|
}),
|
|
).toBe("invoice_exe.pdf");
|
|
expect(
|
|
sanitizeSuggestedFileName("CON.txt", {
|
|
safeExtension: ".txt",
|
|
}),
|
|
).toBe("download.txt");
|
|
expect(
|
|
sanitizeSuggestedFileName("report.tar.gz", {
|
|
safeExtension: ".tar.gz",
|
|
}),
|
|
).toBe("report.tar.gz");
|
|
|
|
const bounded = sanitizeSuggestedFileName("가".repeat(100), {
|
|
safeExtension: ".txt",
|
|
maxUtf8Bytes: 24,
|
|
});
|
|
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(
|
|
24,
|
|
);
|
|
expect(
|
|
sanitizeSuggestedFileName("😀", {
|
|
safeExtension: ".bin",
|
|
maxUtf8Bytes: 5,
|
|
}),
|
|
).toBe("d.bin");
|
|
});
|
|
|
|
it("keeps native files behind opaque refs and performs bounded reads", async () => {
|
|
const policies = pngPolicies();
|
|
const vault = new BrowserFileVault({
|
|
policies,
|
|
createReference: () => "file:opaque-1",
|
|
});
|
|
const bytes = new Uint8Array([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2,
|
|
]);
|
|
const captured = vault.captureFiles(
|
|
[
|
|
new File([bytes], "portrait.png", {
|
|
type: "image/png",
|
|
lastModified: 100,
|
|
}),
|
|
],
|
|
pngSelectionPolicy,
|
|
);
|
|
expect(captured.ok).toBe(true);
|
|
if (!captured.ok) return;
|
|
const candidate = captured.value[0];
|
|
expect(candidate).toBeDefined();
|
|
if (!candidate) return;
|
|
expect(candidate.ref).not.toContain(candidate.displayName);
|
|
|
|
const signal = new AbortController().signal;
|
|
expect(
|
|
await vault.inspect({
|
|
ref: candidate.ref,
|
|
policy: pngPolicy,
|
|
signal,
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
detectedMediaType: "image/png",
|
|
normalizedExtension: ".png",
|
|
signature: "MATCHED",
|
|
},
|
|
});
|
|
expect(
|
|
await vault.readRange({
|
|
ref: candidate.ref,
|
|
offset: 8,
|
|
length: 2,
|
|
signal,
|
|
}),
|
|
).toEqual({ ok: true, value: new Uint8Array([1, 2]) });
|
|
|
|
const opened = await vault.openSource({
|
|
ref: candidate.ref,
|
|
signal,
|
|
});
|
|
expect(opened.ok).toBe(true);
|
|
if (opened.ok) {
|
|
const streamed: number[] = [];
|
|
for await (const chunk of opened.value.stream(signal)) {
|
|
expect(chunk.ok).toBe(true);
|
|
if (chunk.ok) streamed.push(...chunk.value);
|
|
}
|
|
expect(streamed).toEqual([...bytes]);
|
|
}
|
|
|
|
vault.release(candidate.ref);
|
|
expect(vault.activeVerificationCount).toBe(0);
|
|
expect(
|
|
await vault.readRange({
|
|
ref: candidate.ref,
|
|
offset: 0,
|
|
length: 1,
|
|
signal,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "NOT_FOUND", recovery: "RESELECT" },
|
|
});
|
|
});
|
|
|
|
it("snapshots vault requests before asynchronous file resolution", async () => {
|
|
const references = ["file:a", "file:b"];
|
|
const policies = pngPolicies(32);
|
|
const vault = new BrowserFileVault({
|
|
policies,
|
|
createReference: () =>
|
|
references.shift() ?? "file:unexpected",
|
|
createVerificationReceipt: () => "verification:a",
|
|
});
|
|
const pngHeader = [
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
];
|
|
const captured = vault.captureFiles(
|
|
[
|
|
new File(
|
|
[new Uint8Array([...pngHeader, 1, 2])],
|
|
"a.png",
|
|
{ type: "image/png", lastModified: 1 },
|
|
),
|
|
new File(
|
|
[new Uint8Array([...pngHeader, 9, 8])],
|
|
"b.png",
|
|
{ type: "image/png", lastModified: 2 },
|
|
),
|
|
],
|
|
{
|
|
...pngSelectionPolicy,
|
|
multiple: true,
|
|
maxCount: 2,
|
|
maxTotalBytes: 2_048,
|
|
},
|
|
);
|
|
expect(captured.ok).toBe(true);
|
|
if (
|
|
!captured.ok ||
|
|
!captured.value[0] ||
|
|
!captured.value[1]
|
|
) {
|
|
return;
|
|
}
|
|
const [first, second] = captured.value;
|
|
const active = new AbortController();
|
|
const replacement = new AbortController();
|
|
replacement.abort();
|
|
|
|
const readRequest = {
|
|
ref: first.ref,
|
|
offset: 8,
|
|
length: 1,
|
|
signal: active.signal,
|
|
};
|
|
const pendingRead = vault.readRange(readRequest);
|
|
readRequest.ref = second.ref;
|
|
readRequest.offset = 9;
|
|
readRequest.signal = replacement.signal;
|
|
expect(await pendingRead).toEqual({
|
|
ok: true,
|
|
value: new Uint8Array([1]),
|
|
});
|
|
|
|
const inspectionRequest = {
|
|
ref: first.ref,
|
|
policy: pngPolicy,
|
|
signal: active.signal,
|
|
};
|
|
const pendingInspection = vault.inspect(inspectionRequest);
|
|
inspectionRequest.ref = second.ref;
|
|
inspectionRequest.policy = browserFilePolicyReference(
|
|
"forged",
|
|
"forged",
|
|
);
|
|
inspectionRequest.signal = replacement.signal;
|
|
expect(await pendingInspection).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
byteLength: 10,
|
|
verificationReceipt: "verification:a",
|
|
},
|
|
});
|
|
|
|
const sourceRequest = {
|
|
ref: first.ref,
|
|
signal: active.signal,
|
|
};
|
|
const pendingSource = vault.openSource(sourceRequest);
|
|
sourceRequest.ref = second.ref;
|
|
sourceRequest.signal = replacement.signal;
|
|
const opened = await pendingSource;
|
|
expect(opened.ok).toBe(true);
|
|
if (!opened.ok) return;
|
|
const streamed: number[] = [];
|
|
for await (const chunk of opened.value.stream(active.signal)) {
|
|
expect(chunk.ok).toBe(true);
|
|
if (chunk.ok) streamed.push(...chunk.value);
|
|
}
|
|
expect(streamed).toEqual([...pngHeader, 1, 2]);
|
|
});
|
|
|
|
it("snapshots file handles and loader methods before awaiting them", async () => {
|
|
const policies = pngPolicies();
|
|
let reference = 0;
|
|
const vault = new BrowserFileVault({
|
|
policies,
|
|
createReference: () => `file:handle-${reference++}`,
|
|
});
|
|
let resolveFirst: ((file: File) => void) | undefined;
|
|
const firstFile = new File(["a"], "a.txt", {
|
|
type: "text/plain",
|
|
lastModified: 1,
|
|
});
|
|
const secondFile = new File(["b"], "b.txt", {
|
|
type: "text/plain",
|
|
lastModified: 2,
|
|
});
|
|
const originalFirst = vi.fn(
|
|
() =>
|
|
new Promise<File>((resolve) => {
|
|
resolveFirst = resolve;
|
|
}),
|
|
);
|
|
const originalSecond = vi.fn(async () => secondFile);
|
|
const replaced = vi.fn(async () =>
|
|
new File(["x"], "x.txt"),
|
|
);
|
|
const firstHandle = {
|
|
kind: "file" as const,
|
|
name: "a.txt",
|
|
getFile: originalFirst,
|
|
};
|
|
const secondHandle = {
|
|
kind: "file" as const,
|
|
name: "b.txt",
|
|
getFile: originalSecond,
|
|
};
|
|
const handles = [firstHandle, secondHandle];
|
|
const pending = vault.captureHandles(
|
|
handles,
|
|
{
|
|
...pngSelectionPolicy,
|
|
multiple: true,
|
|
maxCount: 2,
|
|
accept: Object.freeze([]),
|
|
},
|
|
new AbortController().signal,
|
|
);
|
|
|
|
firstHandle.getFile = replaced;
|
|
secondHandle.getFile = replaced;
|
|
handles.splice(1, 1, {
|
|
kind: "file",
|
|
name: "x.txt",
|
|
getFile: replaced,
|
|
});
|
|
resolveFirst?.(firstFile);
|
|
|
|
const result = await pending;
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: [
|
|
{ displayName: "a.txt" },
|
|
{ displayName: "b.txt" },
|
|
],
|
|
});
|
|
expect(originalFirst).toHaveBeenCalledOnce();
|
|
expect(originalSecond).toHaveBeenCalledOnce();
|
|
expect(replaced).not.toHaveBeenCalled();
|
|
if (!result.ok || !result.value[1]) return;
|
|
expect(
|
|
await vault.resolveFile(
|
|
result.value[1].ref,
|
|
new AbortController().signal,
|
|
),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: secondFile,
|
|
});
|
|
expect(originalSecond).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("marks MIME/extension/signature disagreement and stale handles", async () => {
|
|
const refs = ["file:mismatch", "file:stale"];
|
|
const policies = pngPolicies();
|
|
const vault = new BrowserFileVault({
|
|
policies,
|
|
createReference: () => refs.shift() ?? "file:unexpected",
|
|
});
|
|
const permissive = {
|
|
...pngSelectionPolicy,
|
|
accept: Object.freeze([]),
|
|
};
|
|
const mismatch = vault.captureFiles(
|
|
[
|
|
new File(
|
|
[
|
|
new Uint8Array([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
]),
|
|
],
|
|
"portrait.jpg",
|
|
{ type: "image/jpeg", lastModified: 100 },
|
|
),
|
|
],
|
|
permissive,
|
|
);
|
|
expect(mismatch.ok).toBe(true);
|
|
if (!mismatch.ok || !mismatch.value[0]) return;
|
|
expect(
|
|
await vault.inspect({
|
|
ref: mismatch.value[0].ref,
|
|
policy: pngPolicy,
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
detectedMediaType: "image/png",
|
|
signature: "MISMATCHED",
|
|
verificationReceipt: null,
|
|
},
|
|
});
|
|
|
|
let current = new File([new Uint8Array([1])], "state.bin", {
|
|
lastModified: 1,
|
|
});
|
|
const handle = {
|
|
kind: "file" as const,
|
|
name: "state.bin",
|
|
getFile: vi.fn(async () => current),
|
|
};
|
|
const handleCapture = await vault.captureHandles(
|
|
[handle],
|
|
permissive,
|
|
new AbortController().signal,
|
|
);
|
|
expect(handleCapture.ok).toBe(true);
|
|
if (!handleCapture.ok || !handleCapture.value[0]) return;
|
|
current = new File([new Uint8Array([1, 2])], "state.bin", {
|
|
lastModified: 2,
|
|
});
|
|
expect(
|
|
await vault.resolveFile(
|
|
handleCapture.value[0].ref,
|
|
new AbortController().signal,
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "STALE_RESULT", recovery: "RESELECT" },
|
|
});
|
|
});
|
|
|
|
it("owns preview object URL leases and rejects active content", async () => {
|
|
const revoked: string[] = [];
|
|
const createObjectURL = vi.fn(() => "blob:test-1");
|
|
const leases = new ObjectUrlLeaseRegistry({
|
|
createObjectURL,
|
|
revokeObjectURL: (url) => revoked.push(url),
|
|
});
|
|
const policies = pngPolicies();
|
|
const vault = new BrowserFileVault({
|
|
policies,
|
|
createReference: () => "file:preview",
|
|
createVerificationReceipt: () => "verification:preview",
|
|
});
|
|
const capture = vault.captureFiles(
|
|
[
|
|
new File(
|
|
[
|
|
new Uint8Array([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
]),
|
|
],
|
|
"preview.png",
|
|
{
|
|
type: "image/png",
|
|
lastModified: 1,
|
|
},
|
|
),
|
|
],
|
|
pngSelectionPolicy,
|
|
);
|
|
expect(capture.ok).toBe(true);
|
|
if (!capture.ok || !capture.value[0]) return;
|
|
const inspected = await vault.inspect({
|
|
ref: capture.value[0].ref,
|
|
policy: pngPolicy,
|
|
signal: new AbortController().signal,
|
|
});
|
|
expect(inspected.ok).toBe(true);
|
|
if (!inspected.ok || !inspected.value.verificationReceipt) return;
|
|
const verificationReceipt =
|
|
inspected.value.verificationReceipt;
|
|
const preview = new BrowserTransientPreview({
|
|
files: vault,
|
|
policies,
|
|
leases,
|
|
hardMaxPreviewBytes: 10,
|
|
observer: {
|
|
record() {
|
|
throw new Error("ignored");
|
|
},
|
|
},
|
|
});
|
|
const created = await preview.create({
|
|
ref: capture.value[0].ref,
|
|
verificationReceipt,
|
|
policy: pngPolicy,
|
|
maxPreviewBytes: 10,
|
|
signal: new AbortController().signal,
|
|
});
|
|
expect(created.ok).toBe(true);
|
|
if (created.ok) {
|
|
created.value.release();
|
|
created.value.release();
|
|
}
|
|
expect(revoked).toEqual(["blob:test-1"]);
|
|
expect(leases.activeLeaseCount).toBe(0);
|
|
|
|
expect(
|
|
await preview.create({
|
|
ref: capture.value[0].ref,
|
|
verificationReceipt,
|
|
policy: pngPolicy,
|
|
maxPreviewBytes: 11,
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "LIMIT_EXCEEDED" },
|
|
});
|
|
expect(createObjectURL).toHaveBeenCalledOnce();
|
|
|
|
expect(
|
|
await preview.create({
|
|
ref: capture.value[0].ref as LocalFileRef,
|
|
verificationReceipt:
|
|
"verification:forged" as FileVerificationReceipt,
|
|
policy: pngPolicy,
|
|
maxPreviewBytes: 10,
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "POLICY_REJECTED" },
|
|
});
|
|
});
|
|
|
|
it("cannot weaken the built-in active-content preview denylist", async () => {
|
|
const htmlPolicy = browserFilePolicyReference(
|
|
"active-content",
|
|
"preview-html",
|
|
);
|
|
const policies = new BrowserFilePolicyRegistry({
|
|
profiles: [
|
|
{
|
|
reference: htmlPolicy,
|
|
inspection: {
|
|
policyId: "html-signature-v1",
|
|
maxInspectionBytes: 6,
|
|
acceptedSignatures: [
|
|
{
|
|
mediaType: "text/html",
|
|
extensions: [".html"],
|
|
patterns: [
|
|
{
|
|
offset: 0,
|
|
bytes: [60, 104, 116, 109, 108],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
preview: {
|
|
allowedMediaTypes: ["text/html"],
|
|
maxPreviewBytes: 10,
|
|
},
|
|
},
|
|
],
|
|
hardLimits: {
|
|
maxInspectionBytes: 64 * 1024,
|
|
maxRetainedFileBytes: 1_024,
|
|
maxPreviewBytes: 10,
|
|
maxObjectUrlBytes: 1_024,
|
|
maxTransferBytes: 1_024,
|
|
},
|
|
});
|
|
const vault = new BrowserFileVault({
|
|
policies,
|
|
createReference: () => "file:html",
|
|
createVerificationReceipt: () => "verification:html",
|
|
});
|
|
const captured = vault.captureFiles(
|
|
[
|
|
new File([new TextEncoder().encode("<html>")], "page.html", {
|
|
type: "text/html",
|
|
lastModified: 1,
|
|
}),
|
|
],
|
|
{
|
|
...pngSelectionPolicy,
|
|
accept: Object.freeze([]),
|
|
},
|
|
);
|
|
expect(captured.ok).toBe(true);
|
|
if (!captured.ok || !captured.value[0]) return;
|
|
const inspected = await vault.inspect({
|
|
ref: captured.value[0].ref,
|
|
policy: htmlPolicy,
|
|
signal: new AbortController().signal,
|
|
});
|
|
expect(inspected.ok).toBe(true);
|
|
if (!inspected.ok || !inspected.value.verificationReceipt) return;
|
|
const createObjectURL = vi.fn(() => "blob:must-not-exist");
|
|
const preview = new BrowserTransientPreview({
|
|
files: vault,
|
|
policies,
|
|
hardMaxPreviewBytes: 10,
|
|
hardForbiddenMediaTypes: new Set(["image/jpeg"]),
|
|
leases: new ObjectUrlLeaseRegistry({
|
|
createObjectURL,
|
|
revokeObjectURL() {},
|
|
}),
|
|
});
|
|
|
|
expect(
|
|
await preview.create({
|
|
ref: captured.value[0].ref,
|
|
verificationReceipt: inspected.value.verificationReceipt,
|
|
policy: htmlPolicy,
|
|
maxPreviewBytes: 10,
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "POLICY_REJECTED" },
|
|
});
|
|
expect(createObjectURL).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects count and zero-byte policy violations before retaining refs", () => {
|
|
const vault = new BrowserFileVault({
|
|
policies: pngPolicies(),
|
|
});
|
|
expect(vault.captureFiles([], pngSelectionPolicy)).toMatchObject({
|
|
ok: false,
|
|
error: { code: "INVALID_INPUT" },
|
|
});
|
|
expect(
|
|
vault.captureFiles(
|
|
[new File([], "empty.png", { type: "image/png" })],
|
|
pngSelectionPolicy,
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "LIMIT_EXCEEDED" },
|
|
});
|
|
expect(vault.activeReferenceCount).toBe(0);
|
|
});
|
|
|
|
it("caps retained files and aggregate object URL leases", () => {
|
|
let referenceSequence = 0;
|
|
const vault = new BrowserFileVault({
|
|
policies: pngPolicies(),
|
|
createReference: () => `file:bounded-${referenceSequence++}`,
|
|
hardMaxActiveReferences: 1,
|
|
hardMaxRetainedBytes: 3,
|
|
});
|
|
const permissive = {
|
|
...pngSelectionPolicy,
|
|
accept: Object.freeze([]),
|
|
};
|
|
const first = vault.captureFiles(
|
|
[new File([new Uint8Array(2)], "one.bin")],
|
|
permissive,
|
|
);
|
|
expect(first.ok).toBe(true);
|
|
expect(vault.retainedByteLength).toBe(2);
|
|
expect(
|
|
vault.captureFiles(
|
|
[new File([new Uint8Array(2)], "two.bin")],
|
|
permissive,
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "LIMIT_EXCEEDED" },
|
|
});
|
|
if (first.ok && first.value[0]) vault.release(first.value[0].ref);
|
|
expect(vault.retainedByteLength).toBe(0);
|
|
|
|
let urlSequence = 0;
|
|
const leases = new ObjectUrlLeaseRegistry(
|
|
{
|
|
createObjectURL: () => `blob:bounded-${urlSequence++}`,
|
|
revokeObjectURL() {},
|
|
},
|
|
{
|
|
hardMaxActiveLeases: 1,
|
|
hardMaxSingleLeaseBytes: 6,
|
|
hardMaxAggregateLeaseBytes: 6,
|
|
},
|
|
);
|
|
const lease = leases.create(new Blob([new Uint8Array(6)]));
|
|
expect(leases.aggregateLeaseByteLength).toBe(6);
|
|
expect(() =>
|
|
leases.create(new Blob([new Uint8Array(1)])),
|
|
).toThrowError(DOMException);
|
|
lease.release();
|
|
expect(leases.aggregateLeaseByteLength).toBe(0);
|
|
});
|
|
|
|
it("converts native stream exceptions to closed chunk failures", async () => {
|
|
const file = new File([new Uint8Array([1])], "broken.bin", {
|
|
lastModified: 1,
|
|
});
|
|
Object.defineProperty(file, "stream", {
|
|
value: () =>
|
|
new ReadableStream<Uint8Array>({
|
|
pull() {
|
|
throw new DOMException("native detail", "NotReadableError");
|
|
},
|
|
}),
|
|
});
|
|
const vault = new BrowserFileVault({
|
|
policies: pngPolicies(),
|
|
createReference: () => "file:broken",
|
|
});
|
|
const captured = vault.captureFiles(
|
|
[file],
|
|
{ ...pngSelectionPolicy, accept: Object.freeze([]) },
|
|
);
|
|
expect(captured.ok).toBe(true);
|
|
if (!captured.ok || !captured.value[0]) return;
|
|
const opened = await vault.openSource({
|
|
ref: captured.value[0].ref,
|
|
signal: new AbortController().signal,
|
|
});
|
|
expect(opened.ok).toBe(true);
|
|
if (!opened.ok) return;
|
|
|
|
const results = [];
|
|
for await (const result of opened.value.stream(
|
|
new AbortController().signal,
|
|
)) {
|
|
results.push(result);
|
|
}
|
|
expect(results).toEqual([
|
|
{
|
|
ok: false,
|
|
error: {
|
|
code: "NOT_READABLE",
|
|
operation: "FILE_READ",
|
|
retryable: true,
|
|
recovery: "REOPEN",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|