Simpl-Resultat/src/__integration__/native-dialog-guard.test.ts
le king fu f75fcee12b
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m2s
test(dialogs): guard against aliases of confirmAction and other native forms
Review of PR #465 (comment 24953), suggestion 1: an alias
(`const c = confirmAction; if (!c(...))`) called confirmAction out of reach
of the "awaited" rule, and window["confirm"](...), confirm?.(...) and
`const { confirm } = window` escaped the native-call rule. Any mention of
confirmAction other than the awaited call, its declaration, a plain named
import or `confirm: confirmAction` is now refused; the three native forms
are flagged. Samples added for each.

Refs #421

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:26:19 -04:00

148 lines
6.9 KiB
TypeScript

/**
* 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*|(?<![.\w$]))(?:confirm|alert)\s*(?:\?\.\s*)?\(/;
const NATIVE_INDEX = /\b(?:window|globalThis|self)\s*(?:\?\.\s*)?\[\s*["'`](?:confirm|alert)["'`]\s*\]/;
const NATIVE_DESTRUCTURED = /\{[^}]*\b(?:confirm|alert)\b[^}]*\}\s*=\s*(?:window|globalThis|self)\b/;
// `confirmAction(` or `<receiver>.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<boolean> {",
"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([]);
});
});