Files
tech-log-frontend/tests/unit/browser-file-content.test.ts
T

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",
},
},
]);
});
});