/** * Guard: no native `confirm` or `alert` in the app, and every confirmation awaited (#421). * * On desktop, tauri-plugin-dialog replaces `window.confirm` with an async function and * `window.alert` with one that does not block. `if (!confirm(...)) return;` then tests a * Promise, always truthy, and the deletion ran before the user answered. TypeScript does not * see it: the DOM type of `confirm` stays `boolean`, and `!promise` is not an error. * * It reads the non-test sources of `src/`, comments removed, as `i18n-mock-guard.test.ts` does, * and fails on: * 1. a native `confirm` or `alert`: called bare or through `window`, `globalThis` or `self` * (a member call such as `deps.confirm(` is not the native one), with `?.(` too, read by * index (`window["confirm"]`) or destructured from one of those objects; * 2. a call to `confirmAction(` or to an injected `confirm(` (`deps.confirm(`, * `ui.confirm(`) that is not directly preceded by `await`; * 3. any other mention of `confirmAction`: an alias (`const c = confirmAction`) or a renamed * import would call it out of reach of rule 2 (#465 review). Allowed besides the awaited * call: its declaration, a plain named import, and `confirm: confirmAction`, the value of * an injected `confirm` whose calls rule 2 checks. * `confirmAction` itself calls the plugin's `ask`, so `src/services/confirmService.ts` is * scanned like any other file. Keywords rather than a parse: the samples below check that each * rule flags what it targets and lets the right form through. */ import { describe, it, expect } from "vitest"; import { readdirSync, readFileSync } from "fs"; import { relative, resolve, sep } from "path"; const SRC = resolve(import.meta.dirname, ".."); const NATIVE_CALL = /(?:\b(?:window|globalThis|self)\s*\.\s*|(?.confirm(`, the receiver captured to skip the native ones. const CONFIRM_CALL = /\b(?:confirmAction|(\w+)\s*\.\s*confirm)\s*\(/g; const NATIVE_RECEIVER = /^(?:window|globalThis|self)$/; const AWAITED = /\bawait\s+$/; const DECLARED = /\bfunction\s+$/; // Mentions of `confirmAction` that rule 3 allows; any other one is an offence. const CONFIRM_ACTION = /\bconfirmAction\b/g; const ALLOWED_MENTION = [ /\bawait\s+confirmAction\s*\(/g, /\bfunction\s+confirmAction\s*\(/g, /^\s*import\s*\{\s*confirmAction\s*\}\s*from\s/g, /\bconfirm\s*:\s*confirmAction\s*[,}]/g, ]; /** The source without its comments, line breaks kept so that line numbers stay right. */ function withoutComments(source: string): string { return source .replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, "")) .replace(/(^|[^:])\/\/.*$/gm, "$1"); } /** The offences of one line: a native call, or a confirmation not awaited. */ function lineOffences(line: string): string[] { const found: string[] = []; if (NATIVE_CALL.test(line) || NATIVE_INDEX.test(line) || NATIVE_DESTRUCTURED.test(line)) { found.push("native confirm/alert"); } for (const match of line.matchAll(CONFIRM_CALL)) { if (match[1] !== undefined && NATIVE_RECEIVER.test(match[1])) continue; const before = line.slice(0, match.index); if (!AWAITED.test(before) && !DECLARED.test(before)) found.push("confirmation not awaited"); } const allowed = ALLOWED_MENTION.reduce((n, rule) => n + [...line.matchAll(rule)].length, 0); if ([...line.matchAll(CONFIRM_ACTION)].length > allowed) found.push("confirmAction out of reach"); return found; } /** The offending lines of one source, as `path:line: kind: text`. */ function offences(path: string, source: string): string[] { const name = relative(SRC, path).split(sep).join("/"); return withoutComments(source) .split("\n") .flatMap((line, i) => lineOffences(line).map((kind) => `${name}:${i + 1}: ${kind}: ${line.trim()}`)); } const SOURCE_FILES = (readdirSync(SRC, { recursive: true }) as string[]) .filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry) && !entry.endsWith(".d.ts")) .map((entry) => resolve(SRC, entry)); describe("native dialog guard (#421)", () => { it("scans the app's sources", () => { const names = SOURCE_FILES.map((path) => relative(SRC, path).split(sep).join("/")); expect(names.length).toBeGreaterThan(200); expect(names).toContain("components/profile/ProfileFormModal.tsx"); expect(names).toContain("hooks/useLicenseMachines.ts"); expect(names).toContain("services/confirmService.ts"); }); it.each([ "if (!confirm(t(\"profile.deleteConfirm\"))) return;", "if (confirm(message)) {", "if (!window.confirm(message)) return;", "onClick={() => removeKey({ confirm: (m) => window.confirm(m), t })}", "globalThis.confirm(message);", "self . alert(message);", "alert(message);", "if (!window[\"confirm\"](message)) return;", "globalThis['alert'](message);", "if (!confirm?.(message)) return;", "const { confirm } = window;", "const { alert: show, confirm: ask } = globalThis;", ])("flags the native call in %s", (line) => { expect(lineOffences(line)).toContain("native confirm/alert"); }); it.each([ "if (!confirmAction(t(\"categories.deleteConfirm\"))) return;", "if (confirmAction(message)) {", "if (!deps.confirm(deps.t(key))) return;", "const ok = ui.confirm(message);", ])("flags the confirmation not awaited in %s", (line) => { expect(lineOffences(line)).toContain("confirmation not awaited"); }); it.each([ "const c = confirmAction;", "import { confirmAction as ask } from \"../services/confirmService\";", "const handlers = { remove: confirmAction };", "items.map(confirmAction);", "if (await confirmAction(a) && check(confirmAction)) {", ])("flags confirmAction out of reach in %s", (line) => { expect(lineOffences(line)).toContain("confirmAction out of reach"); }); it.each([ "if (!(await confirmAction(t(\"profile.deleteConfirm\")))) return;", "if (await confirmAction(message)) {", "if (!(await deps.confirm(deps.t(key)))) return;", "const ok = await confirmAction(message);", "onClick={() => void removeKey({ confirm: confirmAction, t })}", "import { confirmAction } from \"../../services/confirmService\";", "export async function confirmAction(message: string): Promise {", "if ((await confirmAction(a)) && (await confirmAction(b))) {", "deps.confirm.mockClear();", "const confirmed = await ask(message, { kind: \"warning\" });", "t(\"common.confirm\")", ])("lets %s through", (line) => { expect(lineOffences(line)).toEqual([]); }); it("finds no native dialog and no confirmation left unawaited", () => { const found = SOURCE_FILES.flatMap((path) => offences(path, readFileSync(path, "utf8"))); expect(found).toEqual([]); }); });