diff --git a/src/__tests__/approval.test.ts b/src/__tests__/approval.test.ts new file mode 100644 index 0000000..496113c --- /dev/null +++ b/src/__tests__/approval.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { createApprovalLedger, describeApproval, type FileApproval } from "../lib/approval"; +import type { ApprovalRequest } from "../lib/files"; + +const request = (overrides: Partial = {}): ApprovalRequest => ({ + operation: "overwrite", + path: "/Users/me/Downloads/report.md", + display: "~/Downloads/report.md", + subject: "report.md", + details: ["Replaces 14 bytes, last changed 3 days ago."], + irreversible: false, + ...overrides, +}); + +describe("describeApproval", () => { + it("names the operation and the file", () => { + expect(describeApproval(request()).title).toBe("Overwrite report.md?"); + expect(describeApproval(request({ operation: "create" })).title).toBe("Create report.md?"); + expect(describeApproval(request({ operation: "edit" })).title).toBe("Edit report.md?"); + }); + + it("shows the concrete path and every detail line", () => { + const approval = describeApproval(request()); + expect(approval.body).toContain("~/Downloads/report.md"); + expect(approval.body).toContain("Replaces 14 bytes"); + }); + + it("puts the path first, because it is the thing being approved", () => { + expect(describeApproval(request()).body.split("\n")[0]).toBe("~/Downloads/report.md"); + }); + + it("marks a delete as destructive, and says it cannot be undone", () => { + const approval = describeApproval( + request({ operation: "delete", details: ["This cannot be undone."], irreversible: true }), + ); + expect(approval.destructive).toBe(true); + expect(approval.body).toContain("cannot be undone"); + }); + + it("names both ends of a move", () => { + const approval = describeApproval( + request({ + operation: "move", + display: "~/a.md to ~/Documents/b.md", + subject: "a.md to b.md", + }), + ); + expect(approval.title).toBe("Move a.md to b.md?"); + expect(approval.body).toContain("~/a.md to ~/Documents/b.md"); + }); +}); + +describe("createApprovalLedger", () => { + it("asks once for a path and remembers the answer", async () => { + const asked: FileApproval[] = []; + const ledger = createApprovalLedger(async (approval) => { + asked.push(approval); + return true; + }); + + expect(await ledger.approve(request())).toBe(true); + expect(await ledger.approve(request())).toBe(true); + expect(asked).toHaveLength(1); + }); + + it("shows the dialog the same words the ledger keyed on", async () => { + // A ledger that approved one thing while the dialog described another is the + // failure this pair exists to make impossible. + const asked: FileApproval[] = []; + const ledger = createApprovalLedger(async (approval) => { + asked.push(approval); + return true; + }); + + await ledger.approve(request({ operation: "create", subject: "notes.md" })); + expect(asked[0].title).toBe("Create notes.md?"); + }); + + it("remembers nothing when the user declines, so the next attempt asks", async () => { + let calls = 0; + const ledger = createApprovalLedger(async () => { + calls++; + return false; + }); + + expect(await ledger.approve(request())).toBe(false); + expect(await ledger.approve(request())).toBe(false); + expect(calls).toBe(2); + }); + + it("treats two spellings of one path as one path", async () => { + // The ledger keys on the resolved path, so an edit approved for + // ~/Downloads/report.md also covers /Users/me/Downloads/report.md. + let calls = 0; + const ledger = createApprovalLedger(async () => { + calls++; + return true; + }); + + await ledger.approve(request()); + await ledger.approve(request({ display: "/Users/me/Downloads/report.md" })); + expect(calls).toBe(1); + }); + + it("asks every time for a delete or a move, even after an approval for the same path", async () => { + let calls = 0; + const ledger = createApprovalLedger(async () => { + calls++; + return true; + }); + + await ledger.approve(request()); + await ledger.approve(request({ operation: "delete", irreversible: true })); + await ledger.approve(request({ operation: "delete", irreversible: true })); + expect(calls).toBe(3); + }); + + it("refuses when the dialog itself fails, rather than assuming consent", async () => { + // Fail closed: an unshowable dialog is not permission. + const ledger = createApprovalLedger(async () => { + throw new Error("no such widget"); + }); + expect(await ledger.approve(request())).toBe(false); + }); +}); diff --git a/src/lib/approval.ts b/src/lib/approval.ts new file mode 100644 index 0000000..332f6e6 --- /dev/null +++ b/src/lib/approval.ts @@ -0,0 +1,93 @@ +import { Alert, confirmAlert } from "@vicinae/api"; +import type { ApprovalRequest, ApproveFn, FileOperation } from "./files"; + +/** + * The approval step, and the only file in this feature that imports Vicinae. + * + * Kept apart from `files.ts` on purpose: the policy there is a value and this is a + * dialog, so the boundary can be tested without a launcher and the wording can be + * changed without touching a path rule. + */ + +export interface FileApproval { + title: string; + /** Every line the user needs: the path first, then what changes. */ + body: string; + /** A delete or a move, which the dialog styles differently. */ + destructive: boolean; +} + +const VERBS: Record = { + create: "Create", + overwrite: "Overwrite", + edit: "Edit", + delete: "Delete", + move: "Move", +}; + +/** + * Turns a request into the words the user reads. + * + * The path is the second thing in the body and is never omitted: it is the thing + * being approved, and a dialog that said "Lam wants to write a file" would leave the + * user with nothing to judge. + */ +export function describeApproval(request: ApprovalRequest): FileApproval { + return { + title: `${VERBS[request.operation]} ${request.subject}?`, + body: [request.display, ...request.details].join("\n"), + destructive: request.irreversible, + }; +} + +/** + * The ledger: one approval per resolved path, per answer, except for the + * irreversible operations, which ask every time. + * + * Per answer rather than per session because a session can run for hours, and because + * "yes, edit that file while we work on it" is not consent to whatever Lam decides to + * do with it twenty minutes later. + * + * Keyed on the resolved path, so `~/Downloads/x.md` and `/Users/me/Downloads/x.md` + * are one entry: they are one file, and keying on the written form would let a second + * spelling of the same path slip past an approval the user already gave for it. + */ +export function createApprovalLedger(confirm: (approval: FileApproval) => Promise): { + approve: ApproveFn; +} { + const approved = new Set(); + + return { + async approve(request: ApprovalRequest): Promise { + if (!request.irreversible && approved.has(request.path)) return true; + + // A dialog that cannot be shown is not consent, and neither is an exception + // thrown while trying to show one, so both come back as a refusal. This is the + // fail-closed half of the approval story, and it is here rather than at a call + // site because every call site would have to remember it. + const granted = await confirm(describeApproval(request)).catch(() => false); + if (granted && !request.irreversible) approved.add(request.path); + + return granted; + }, + }; +} + +/** + * The dialog itself. + * + * Vicinae's `rememberUserChoice` is declared in the API but not implemented in the + * server, so nothing here offers "do not ask again": the ledger above is the only + * memory, and it lasts one answer. + */ +export function confirmWithAlert(approval: FileApproval): Promise { + return confirmAlert({ + title: approval.title, + message: approval.body, + primaryAction: { + title: "Allow", + style: approval.destructive ? Alert.ActionStyle.Destructive : Alert.ActionStyle.Default, + }, + dismissAction: { title: "Decline", style: Alert.ActionStyle.Cancel }, + }); +}