fix(i18n): hold every refusal reason by type, and refuse what the scan cannot read
Review round 1 of the refusal contract: it claimed more than it held. - The other reason carriers get their Literal too (BlockedUrl, ClipError, FolderError, the playlist and sign-in refusal helpers), so the type checker refuses a reason passed by variable or an unknown literal, and the contract reads one shape. - A literal reason in a detail dict built anywhere counts as a reason of its code; a code the scan cannot read, a refused() whose reason it cannot read, and a new reason passed by variable each fail the contract until listed with what holds them. - A sign-in refusal key the backend no longer sends is caught too. - localizeDetail's numbers (download cap in GB, upload cap in bytes) and the shared-sentence fallbacks are tested in both languages. - One Literal parser in the contract; a stale comment fixed.
This commit is contained in:
@@ -90,9 +90,9 @@ const runEnum = (name: string) =>
|
||||
const runStatuses = runEnum("RunStatus");
|
||||
const runTriggers = runEnum("RunTrigger");
|
||||
const quotaActions = enumValues("quota.py");
|
||||
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
|
||||
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
|
||||
// ruff wraps over several lines still matches.
|
||||
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are the refusal records
|
||||
// under that prefix (lib/api/refusalCodes), so a code recorded there is scanned here too; a detail
|
||||
// dict ruff wraps over several lines still matches.
|
||||
const adminCodes = Object.entries(REFUSAL_FAMILIES)
|
||||
.filter(([, f]) => f.prefix === "errors.admin")
|
||||
.map(([code]) => code);
|
||||
@@ -172,29 +172,38 @@ const goneWhats = [
|
||||
),
|
||||
),
|
||||
];
|
||||
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
|
||||
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
|
||||
// `setting_refused` picks from at runtime, and every `FolderError` reason.
|
||||
const appSources = (dir = ""): string[] =>
|
||||
// A backend `Name = Literal["a", "b"]` alias, by name: the reasons a typed parameter accepts, which
|
||||
// basedpyright then holds at every call site — a reason passed by variable included.
|
||||
const literalOf = (file: string, name: string) =>
|
||||
[
|
||||
...(
|
||||
backend(file).match(new RegExp(`^${name} = Literal\\[([^\\]]*)\\]`, "m"))?.[1] ?? ""
|
||||
).matchAll(/"(\w+)"/g),
|
||||
].map((m) => m[1]!);
|
||||
// Every backend source, by its path under app/.
|
||||
const appPaths = (dir = ""): string[] =>
|
||||
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
||||
withFileTypes: true,
|
||||
}).flatMap((e) =>
|
||||
e.isDirectory()
|
||||
? appSources(`${dir}${e.name}/`)
|
||||
? e.name === "__pycache__"
|
||||
? []
|
||||
: appPaths(`${dir}${e.name}/`)
|
||||
: e.name.endsWith(".py")
|
||||
? [backend(`${dir}${e.name}`)]
|
||||
? [`${dir}${e.name}`]
|
||||
: [],
|
||||
);
|
||||
const appSources = (): string[] => appPaths().map(backend);
|
||||
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
|
||||
// the backend (not `_refused`, a router's own family), the values of the settings map, which
|
||||
// `setting_refused` picks from at runtime, and the folder rules' `FolderReason`, answered with
|
||||
// `refused(409, e.reason)`.
|
||||
const refusedReasons = [
|
||||
...new Set([
|
||||
...appSources().flatMap((src) =>
|
||||
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
|
||||
),
|
||||
// A folder change the rules refuse travels as `FolderError("<reason>")` and is answered with
|
||||
// `refused(409, e.reason)`, which the pattern above cannot read.
|
||||
...appSources().flatMap((src) =>
|
||||
[...src.matchAll(/FolderError\("(\w+)"\)/g)].map((m) => m[1]!),
|
||||
),
|
||||
...literalOf("downloads/folders.py", "FolderReason"),
|
||||
...[
|
||||
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
|
||||
/:\s*"(\w+)"/g,
|
||||
@@ -202,61 +211,90 @@ const refusedReasons = [
|
||||
].map((m) => m[1]!),
|
||||
]),
|
||||
];
|
||||
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
|
||||
const youtubeReasons = [
|
||||
...(
|
||||
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
|
||||
).matchAll(/"(\w+)"/g),
|
||||
].map((m) => m[1]!);
|
||||
// A clip the server will not cut → `clip.errors.<reason>`: every `ClipError("<reason>"…)` in the backend
|
||||
// (a reason may carry its numbers after it: `ClipError("too_long", limit_s=…)`).
|
||||
const clipReasons = [
|
||||
...new Set(
|
||||
appSources().flatMap((src) => [...src.matchAll(/ClipError\("(\w+)"[,)]/g)].map((m) => m[1]!)),
|
||||
),
|
||||
];
|
||||
// A `refused(…)` whose reason the scan above cannot read. Only two may: a folder rule's `e.reason`
|
||||
// (the nearest `except` above it catches `FolderError`, typed `FolderReason`) and the settings map.
|
||||
const opaqueRefused = appPaths().flatMap((f) => {
|
||||
const src = backend(f);
|
||||
return [...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*(?![\s"])([^,)]+)/g)]
|
||||
.filter((m) => {
|
||||
const arg = m[1]!.trim();
|
||||
if (f === "refusal.py" && arg.startsWith("_SETTING_REASONS[")) return false;
|
||||
const caught = [...src.slice(0, m.index).matchAll(/except ([^\n:]*):/g)].pop()?.[1] ?? "";
|
||||
return !(arg === "e.reason" && /FolderError as e$/.test(caught));
|
||||
})
|
||||
.map((m) => `${f}: ${m[0]}`);
|
||||
});
|
||||
const youtubeReasons = literalOf("youtube/client.py", "YouTubeRefusal");
|
||||
const clipReasons = literalOf("downloads/clip.py", "ClipReason");
|
||||
|
||||
// Every refusal code the backend answers: each `"code": "<x>"` of a detail anywhere in the app. A
|
||||
// subtitle's status payload (`{"state": …, "code": …}`) is the player's own vocabulary, not a refusal.
|
||||
const backendCodes = [
|
||||
// Every `"code": …` a backend dict carries, with the `"reason": …` that follows it. Two modules use a
|
||||
// `code` key for something that is not a refusal; everything else is one.
|
||||
const NOT_REFUSALS: Record<string, string> = {
|
||||
"plex/subtitles.py":
|
||||
"the player's subtitle status vocabulary (`/status`), read by the player itself",
|
||||
"plex/tmdb.py": "a production country's ISO code",
|
||||
};
|
||||
const detailSites = appPaths()
|
||||
.filter((f) => !(f in NOT_REFUSALS))
|
||||
.flatMap((f) =>
|
||||
[
|
||||
...backend(f).matchAll(
|
||||
/"code":\s*(?:"(\w+)"|([^\s,}]+))(?:,\s*"reason":\s*(?:"(\w+)"|([^\s,}]+)))?/g,
|
||||
),
|
||||
].map((m) => ({ f, code: m[1], codeExpr: m[2], reason: m[3], reasonExpr: m[4] })),
|
||||
);
|
||||
const backendCodes = [...new Set(detailSites.flatMap((s) => (s.code ? [s.code] : [])))];
|
||||
// A code the scan cannot read (`"code": SOME_CONSTANT`) is a refusal no record is checked for.
|
||||
const opaqueCodes = detailSites.filter((s) => !s.code).map((s) => `${s.f}: "code": ${s.codeExpr}`);
|
||||
// A reason passed by variable, each pinned to what holds it, so a new one has to say how it is held.
|
||||
const OPAQUE_REASONS: Record<string, string> = {
|
||||
"refusal.py: refused ← reason": "a literal at each refused() call, else opaqueRefused",
|
||||
"routes/auth.py: login ← reason": "_login_refused's LoginRefusal",
|
||||
"routes/downloads.py: url_blocked ← urlguard.REASON_MALFORMED": "UrlBlockedReason",
|
||||
"routes/downloads.py: url_blocked ← e.reason": "BlockedUrl's UrlBlockedReason",
|
||||
"routes/downloads.py: cookies ← e.reason": "InvalidCookies' CookieReason",
|
||||
"routes/downloads.py: edit ← e.reason": "EditError's EditReason",
|
||||
"routes/downloads.py: clip ← e.reason": "ClipError's ClipReason",
|
||||
"routes/files.py: quota ← e.reason": "QuotaExceeded's QuotaReason",
|
||||
"routes/playlists.py: playlist ← reason": "_refused's PlaylistRefusal",
|
||||
"routes/playlists.py: playlist_move ← e.reason": "MoveRefused's MoveReason",
|
||||
"routes/plex/playback.py: subtitle ← code": "REFUSAL_UNSHOWN: no screen reads it",
|
||||
"routes/_common.py: not_found ← what": "opaqueWhats",
|
||||
"routes/_common.py: youtube ← reason": "YouTubeRefusal",
|
||||
};
|
||||
const opaqueReasons = [
|
||||
...new Set(
|
||||
appSources().flatMap((src) =>
|
||||
src
|
||||
.split("\n")
|
||||
.filter((line) => !line.includes('"state":'))
|
||||
.flatMap((line) => [...line.matchAll(/"code":\s*"(\w+)"/g)].map((m) => m[1]!)),
|
||||
),
|
||||
detailSites
|
||||
.filter((s) => s.code && s.reasonExpr)
|
||||
.map((s) => `${s.f}: ${s.code} ← ${s.reasonExpr}`),
|
||||
),
|
||||
];
|
||||
const literalOf = (file: string, name: string) =>
|
||||
[
|
||||
...(
|
||||
backend(file).match(new RegExp(`^${name} = Literal\\[([^\\]]*)\\]`, "m"))?.[1] ?? ""
|
||||
).matchAll(/"(\w+)"/g),
|
||||
].map((m) => m[1]!);
|
||||
const literalCalls = (file: string, re: RegExp) => [
|
||||
...new Set([...backend(file).matchAll(re)].map((m) => m[1]!)),
|
||||
];
|
||||
const detailReasons = (file: string, code: string) =>
|
||||
literalCalls(file, new RegExp(`"code":\\s*"${code}",\\s*"reason":\\s*"(\\w+)"`, "g"));
|
||||
// What each refusal code can carry as its reason, read where it is decided: a Literal when the reason
|
||||
// travels in a variable (`e.reason`), the call's own literal otherwise.
|
||||
const reasonsOf: Record<string, string[]> = {
|
||||
refused: refusedReasons,
|
||||
youtube: youtubeReasons,
|
||||
not_found: goneWhats,
|
||||
clip: clipReasons,
|
||||
...Object.fromEntries(adminCodes.map((c) => [c, detailReasons("routes/admin.py", c)])),
|
||||
playlist: literalCalls("routes/playlists.py", /_refused\(\s*\d+,\s*"(\w+)"/g),
|
||||
playlist_move: literalOf("reader/service.py", "MoveReason"),
|
||||
edit: literalOf("downloads/service.py", "EditReason"),
|
||||
cookies: literalOf("downloads/credentials.py", "CookieReason"),
|
||||
url_blocked: literalCalls("downloads/urlguard.py", /^REASON_\w+ = "(\w+)"/gm),
|
||||
login: literalCalls("routes/auth.py", /_login_refused\(\s*\d+,\s*"(\w+)"/g),
|
||||
quota: literalOf("downloads/quota.py", "QuotaReason"),
|
||||
reset: detailReasons("routes/auth.py", "reset"),
|
||||
collection: detailReasons("routes/plex/collections.py", "collection"),
|
||||
};
|
||||
// A reason written as a literal in a detail dict, wherever that dict is built.
|
||||
const literalReasons = (code: string) =>
|
||||
detailSites.filter((s) => s.code === code && s.reason).map((s) => s.reason!);
|
||||
// What each refusal code can carry as its reason: the Literal its typed parameter accepts, or the
|
||||
// call's own literal — and every literal any detail dict anywhere gives it.
|
||||
const reasonsOf: Record<string, string[]> = Object.fromEntries(
|
||||
Object.entries({
|
||||
refused: refusedReasons,
|
||||
youtube: youtubeReasons,
|
||||
not_found: goneWhats,
|
||||
clip: clipReasons,
|
||||
...Object.fromEntries(adminCodes.map((c) => [c, []])),
|
||||
playlist: literalOf("routes/playlists.py", "PlaylistRefusal"),
|
||||
playlist_move: literalOf("reader/service.py", "MoveReason"),
|
||||
edit: literalOf("downloads/service.py", "EditReason"),
|
||||
cookies: literalOf("downloads/credentials.py", "CookieReason"),
|
||||
url_blocked: literalOf("downloads/urlguard.py", "UrlBlockedReason"),
|
||||
login: literalOf("routes/auth.py", "LoginRefusal"),
|
||||
quota: literalOf("downloads/quota.py", "QuotaReason"),
|
||||
reset: [],
|
||||
collection: [],
|
||||
} as Record<string, string[]>).map(([code, rs]) => [
|
||||
code,
|
||||
[...new Set([...rs, ...literalReasons(code)])],
|
||||
]),
|
||||
);
|
||||
const recordedCodes = [
|
||||
...Object.keys(REFUSAL_FAMILIES),
|
||||
...Object.keys(REFUSAL_BY_REASON),
|
||||
@@ -359,6 +397,14 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(recordedCodes.filter((c) => !backendCodes.includes(c))).toEqual([]);
|
||||
});
|
||||
|
||||
it("every refusal code and reason is one the scans can read, or says what holds it", () => {
|
||||
// The modules set aside as not refusals still exist and still use the key.
|
||||
for (const f of Object.keys(NOT_REFUSALS)) expect(backend(f), f).toContain('"code":');
|
||||
expect(opaqueCodes).toEqual([]);
|
||||
expect(opaqueRefused).toEqual([]);
|
||||
expect(opaqueReasons.sort()).toEqual(Object.keys(OPAQUE_REASONS).sort());
|
||||
});
|
||||
|
||||
it("read every refusal's reasons (a scan that matches nothing is green over nothing)", () => {
|
||||
const coded = [...Object.keys(REFUSAL_FAMILIES), ...Object.keys(REFUSAL_BY_REASON)];
|
||||
expect(Object.keys(reasonsOf).sort()).toEqual(coded.sort());
|
||||
@@ -542,16 +588,21 @@ describe("backend ids have their names in both languages", () => {
|
||||
});
|
||||
|
||||
it(`${lang}: no sentence for a refusal reason the backend cannot send`, () => {
|
||||
// A family whose prefix is its own (the shared `errors.admin`, and the sign-in screens, which
|
||||
// name more than the one-line refusal, are checked elsewhere or not at all).
|
||||
// A family whose prefix is its own (the shared `errors.admin` is checked below). A suffixed
|
||||
// family shares its prefix with more than its refusals — the sign-in screens' `gate`,
|
||||
// `addressLabel` — so only an id with the refusal's own key counts as a reason there.
|
||||
for (const [code, f] of Object.entries(REFUSAL_FAMILIES)) {
|
||||
if (f.prefix === "errors.admin" || "suffix" in f) continue;
|
||||
if (f.prefix === "errors.admin") continue;
|
||||
const suffix = "suffix" in f ? f.suffix : "";
|
||||
const fallbackId = f.fallback.startsWith(`${f.prefix}.`)
|
||||
? f.fallback.slice(f.prefix.length + 1)
|
||||
: null;
|
||||
expect(
|
||||
[...idsUnder(keys, f.prefix)].filter(
|
||||
(r) => r !== fallbackId && !reasonsOf[code]!.includes(r),
|
||||
(r) =>
|
||||
keys.has(`${f.prefix}.${r}${suffix}`) &&
|
||||
r !== fallbackId &&
|
||||
!reasonsOf[code]!.includes(r),
|
||||
),
|
||||
code,
|
||||
).toEqual([]);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// one case per record shape, so moving the dispatch into data changed what no reader sees.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import i18n from "../../i18n";
|
||||
import { formatBytes } from "../format";
|
||||
import { localizeDetail } from "./refusalText";
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -45,6 +46,24 @@ describe.each(["en", "hu"])("localizeDetail (%s)", (lng) => {
|
||||
);
|
||||
});
|
||||
|
||||
it("puts a download cap in GB and an upload cap in bytes, in the reader's number format", async () => {
|
||||
expect(await say({ code: "quota", reason: "max_bytes", limit: 1.5 * 2 ** 30 })).toContain(
|
||||
lng === "hu" ? "1,5" : "1.5",
|
||||
);
|
||||
const upload = await say({ code: "upload_too_large", limit: 5_242_880 });
|
||||
expect(upload).toContain(formatBytes(5_242_880, lng));
|
||||
expect(upload).not.toBe(i18n.t("downloads.errors.upload.tooLarge", { size: "" }));
|
||||
});
|
||||
|
||||
it("says a shared-sentence code's own fallback for a reason it does not know", async () => {
|
||||
expect(await say({ code: "quota", reason: "nope" })).toBe(
|
||||
i18n.t("downloads.errors.quota.generic"),
|
||||
);
|
||||
expect(await say({ code: "collection", reason: "nope" })).toBe(
|
||||
i18n.t("plex.collection.editFailed"),
|
||||
);
|
||||
});
|
||||
|
||||
it("says a shared-sentence code's fallback, and a fixed code's one sentence", async () => {
|
||||
expect(await say({ code: "reset", reason: "invalid" })).toBe(
|
||||
i18n.t("welcome.auth.deadLink.short"),
|
||||
|
||||
Reference in New Issue
Block a user