clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/prf-provision-custody
lofi / package / recipes / file-handler_test.ts
file-handler_test.ts 5.5K
1import {2 type FileImportIssue,3 installFileLaunchConsumer,4 prepareFileImportDrafts,5} from "./file-handler.ts";6import type { InstalledAppLaunchParameters, InstalledAppLaunchQueue } from "./launch-handler.ts";78function assert(condition: unknown, message: string): asserts condition {9 if (!condition) throw new Error(message);10}1112const options = {13 accept: { "application/json": [".json"] },14 maxFiles: 2,15 maxFileBytes: 64,16 maxTotalBytes: 96,17 async parse(file: File) {18 const value = JSON.parse(await file.text());19 if (!value || typeof value !== "object" || typeof value.title !== "string") {20 throw new Error("invalid export");21 }22 return { title: value.title as string };23 },24} as const;2526Deno.test("file import prepares parsed picker and handle drafts without persistence", async () => {27 const picker = new File(['{"title":"Picker"}'], "picker.json", {28 type: "application/json",29 });30 const handledFile = new File(['{"title":"Opened"}'], "opened.json", {31 type: "application/json",32 });33 const result = await prepareFileImportDrafts([34 picker,35 { kind: "file", name: "opened.json", getFile: () => Promise.resolve(handledFile) },36 ], options);37 assert(result.ok, `valid files failed: ${JSON.stringify(result)}`);38 assert(result.drafts.length === 2, "draft count drifted");39 assert(result.drafts[1].parsed.title === "Opened", "file content was not parsed");4041 const compound = await prepareFileImportDrafts(42 [43 new File(['{"title":"Compound"}'], "notes.FIELD-NOTES.JSON", {44 type: "application/json",45 }),46 ],47 { ...options, accept: { "application/json": [".field-notes.json"] } },48 );49 assert(compound.ok, "compound case-insensitive extension was rejected");50});5152Deno.test("file import rejects untrusted metadata, limits, access, and content", async () => {53 const cases: Array<[string, Parameters<typeof prepareFileImportDrafts>[0], FileImportIssue]> = [54 ["empty", [], "empty-selection"],55 [56 "count",57 [58 new File(["{}"], "a.json", { type: "application/json" }),59 new File(["{}"], "b.json", { type: "application/json" }),60 new File(["{}"], "c.json", { type: "application/json" }),61 ],62 "too-many-files",63 ],64 [65 "name",66 [new File(["{}"], "../private.json", { type: "application/json" })],67 "invalid-name",68 ],69 [70 "type",71 [new File(["{}"], "note.json", { type: "text/plain" })],72 "unsupported-type",73 ],74 [75 "extension",76 [new File(["{}"], "note.txt", { type: "application/json" })],77 "unsupported-extension",78 ],79 [80 "size",81 [new File(["x".repeat(65)], "note.json", { type: "application/json" })],82 "file-too-large",83 ],84 [85 "content",86 [new File(["{}"], "note.json", { type: "application/json" })],87 "invalid-content",88 ],89 [90 "denied",91 [{92 kind: "file",93 name: "note.json",94 getFile: () => Promise.reject(new DOMException("denied", "NotAllowedError")),95 }],96 "unreadable-file",97 ],98 ];99 for (const [label, files, issue] of cases) {100 const result = await prepareFileImportDrafts(files, options);101 assert(!result.ok && result.issue === issue, `${label} produced ${JSON.stringify(result)}`);102 }103104 const total = await prepareFileImportDrafts(105 [106 new File(['{"title":"One"}'], "one.json", { type: "application/json" }),107 new File(['{"title":"Two"}'], "two.json", { type: "application/json" }),108 ],109 { ...options, maxTotalBytes: 20 },110 );111 assert(!total.ok && total.issue === "selection-too-large", "total size bound was not enforced");112});113114Deno.test("file import rejects unsafe allow-list configuration", async () => {115 const unsafe: Array<Record<string, readonly string[]>> = [116 { "application/*": [".json"] },117 { "application/json": ["json"] },118 ];119 for (const accept of unsafe) {120 let rejected = false;121 try {122 await prepareFileImportDrafts(123 [new File(["{}"], "note.json", { type: "application/json" })],124 { ...options, accept },125 );126 } catch (error) {127 rejected = error instanceof TypeError;128 }129 assert(rejected, "unsafe file allow-list passed");130 }131});132133Deno.test("file launch consumer feature-detects, validates, and ignores stale async launches", async () => {134 const unsupported = installFileLaunchConsumer({135 ...options,136 onDrafts: () => {137 throw new Error("unsupported consumer ran");138 },139 });140 assert(!unsupported.supported, "missing launchQueue was reported as supported");141142 let current: (parameters: InstalledAppLaunchParameters) => void = () => {};143 const queue: InstalledAppLaunchQueue = {144 setConsumer(next) {145 current = next;146 },147 };148 const received: string[] = [];149 const rejected: FileImportIssue[] = [];150 const consumer = installFileLaunchConsumer({151 ...options,152 queue,153 onDrafts: (drafts) => received.push(drafts[0].parsed.title),154 onRejected: (issue) => rejected.push(issue),155 });156 current({157 files: [{158 kind: "file",159 name: "opened.json",160 getFile: () =>161 Promise.resolve(162 new File(['{"title":"Opened"}'], "opened.json", {163 type: "application/json",164 }),165 ),166 }] as unknown as FileSystemHandle[],167 });168 await new Promise((resolve) => setTimeout(resolve, 0));169 assert(received.join() === "Opened", `launch draft was not delivered: ${received}`);170 current({ files: [] });171 await new Promise((resolve) => setTimeout(resolve, 0));172 assert(rejected.join() === "empty-selection", `empty launch produced ${rejected}`);173 consumer.dispose();174});