diff --git a/backend/app/downloads/clip.py b/backend/app/downloads/clip.py index af2cb46b..4b4b1021 100644 --- a/backend/app/downloads/clip.py +++ b/backend/app/downloads/clip.py @@ -78,7 +78,7 @@ class ClipError(Exception): message names (the cap itself, so the client never says a stale one).""" def __init__(self, reason: ClipReason, **params: float): - self.reason = reason + self.reason: ClipReason = reason self.params = params super().__init__(reason) diff --git a/backend/app/downloads/credentials.py b/backend/app/downloads/credentials.py index 39e1cd93..c80a892d 100644 --- a/backend/app/downloads/credentials.py +++ b/backend/app/downloads/credentials.py @@ -174,7 +174,7 @@ class InvalidCookies(ValueError): def __init__(self, reason: CookieReason): super().__init__(reason) - self.reason = reason + self.reason: CookieReason = reason def validate_cookies(raw: str) -> str: diff --git a/backend/app/downloads/folders.py b/backend/app/downloads/folders.py index fffb75c0..c440b9de 100644 --- a/backend/app/downloads/folders.py +++ b/backend/app/downloads/folders.py @@ -197,7 +197,7 @@ class FolderError(Exception): def __init__(self, reason: FolderReason) -> None: super().__init__(reason) - self.reason = reason + self.reason: FolderReason = reason def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder: diff --git a/backend/app/downloads/quota.py b/backend/app/downloads/quota.py index e8d56f85..179b3b92 100644 --- a/backend/app/downloads/quota.py +++ b/backend/app/downloads/quota.py @@ -60,7 +60,7 @@ class QuotaExceeded(Exception): """Raised by check_enqueue; `reason` names the cap it hit (an i18n key on the client).""" def __init__(self, reason: QuotaReason, limit: int, current: int): - self.reason = reason + self.reason: QuotaReason = reason self.limit = limit self.current = current super().__init__(f"download quota exceeded: {reason} ({current}/{limit})") diff --git a/backend/app/downloads/service.py b/backend/app/downloads/service.py index ffc40cbf..e2e80910 100644 --- a/backend/app/downloads/service.py +++ b/backend/app/downloads/service.py @@ -49,7 +49,7 @@ class EditError(Exception): """Bad edit request (empty spec / source not ready / not media); `reason` names the sentence.""" def __init__(self, reason: EditReason): - self.reason = reason + self.reason: EditReason = reason super().__init__(reason) diff --git a/backend/app/downloads/urlguard.py b/backend/app/downloads/urlguard.py index 575c7836..854aa732 100644 --- a/backend/app/downloads/urlguard.py +++ b/backend/app/downloads/urlguard.py @@ -59,7 +59,7 @@ class BlockedUrl(Exception): def __init__(self, reason: UrlBlockedReason): super().__init__(reason) - self.reason = reason + self.reason: UrlBlockedReason = reason def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: diff --git a/backend/app/reader/service.py b/backend/app/reader/service.py index 5eb779c0..fa651005 100644 --- a/backend/app/reader/service.py +++ b/backend/app/reader/service.py @@ -311,7 +311,7 @@ class MoveRefused(Exception): def __init__(self, reason: MoveReason): super().__init__(reason) - self.reason = reason + self.reason: MoveReason = reason def touch(pl: Playlist) -> None: diff --git a/backend/app/refusal.py b/backend/app/refusal.py index c092bb55..337a1c8a 100644 --- a/backend/app/refusal.py +++ b/backend/app/refusal.py @@ -7,16 +7,19 @@ whatever sentence the route happened to hand-write. `refused` is the generic fam (`errors.refused.`, the params interpolated into the sentence); the older specific codes (`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated. -A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail, -and the frontend's contract test (`i18n/backendIds.contract.test.ts`) requires every `code` a detail -carries to be in one record of `lib/api/refusalCodes.ts`, and every reason it can be sent with — a -literal, or a `Literal` type when it travels in a variable — to have its sentence in both languages. -A code or reason that scan cannot read fails it until it is listed with what holds it. So the rule is -enforced, not described. +A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail. +`tests/refusal_sites.py` reads every `{"code": …}` detail from the syntax tree and follows each reason +to its values: a literal, a `Literal` type on the parameter or attribute it travels in, every call of +a helper like `refused` below, a map's values. The frontend's contract test +(`i18n/backendIds.contract.test.ts`) runs it and requires each code in one record of +`lib/api/refusalCodes.ts`, and each reason's sentence in both languages; a code or reason it cannot +follow fails it, unless no screen shows that code. So the rule is enforced, not described. """ from fastapi import HTTPException +from app.sysconfig import InvalidSetting, SettingReason + def refused(status: int, reason: str, **params: object) -> HTTPException: """The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in @@ -24,9 +27,9 @@ def refused(status: int, reason: str, **params: object) -> HTTPException: return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params}) -# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the frontend's -# contract test can read every reason this can produce. -_SETTING_REASONS = { +# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the refusal +# scan reads every reason this can produce, and holds its keys to `SettingReason`. +_SETTING_REASONS: dict[SettingReason, str] = { "not_a_number": "setting_not_a_number", "too_low": "setting_too_low", "too_high": "setting_too_high", @@ -35,6 +38,6 @@ _SETTING_REASONS = { } -def setting_refused(exc) -> HTTPException: +def setting_refused(exc: InvalidSetting) -> HTTPException: """A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`).""" return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params) diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index d5c1adb5..74607b6b 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -443,15 +443,20 @@ def _default(s: ConfigSpec): return getattr(settings, s.default_attr) +# Every reason `set_value` refuses with. `refusal.setting_refused` looks each one up in its map, which +# the refusal scan holds to exactly these keys: a reason the map lacks would answer a 500. +SettingReason = Literal["not_a_number", "too_low", "too_high", "not_a_choice", "no_encryption_key"] + + class InvalidSetting(ValueError): """A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_`) plus what the sentence needs — the English message is for logs only. A ValueError still, so a caller that only needs "refused or not" keeps working.""" - def __init__(self, key: str, reason: str, **params: object): + def __init__(self, key: str, reason: SettingReason, **params: object): super().__init__(f"{key}: {reason} {params}") self.key = key - self.reason = reason + self.reason: SettingReason = reason self.params = params diff --git a/backend/tests/refusal_sites.py b/backend/tests/refusal_sites.py new file mode 100644 index 00000000..edf79344 --- /dev/null +++ b/backend/tests/refusal_sites.py @@ -0,0 +1,625 @@ +"""Every refusal the backend can answer, read from its syntax tree: each `{"code": …, "reason": …}` detail, +with every reason its code can be sent with. + +The frontend's contract (`i18n/backendIds.contract.test.ts`) runs this and requires a sentence for each +code and reason in both languages. A regex over the source read only the shapes it was written for: a +reason after another key, a status passed by name, a second handler with the same text all went past it. +Here a reason is followed to where it comes from, and what cannot be followed is reported, never guessed: + +- a literal; both arms of `a if c else b`, every operand of `a or b`; +- a module constant (`urlguard.REASON_MALFORMED`), a local variable's assignments; +- a parameter or attribute typed with a `Literal` (basedpyright holds every value passed in), the + attribute found on the class an `except … as e` catches, or the class a parameter is typed with; +- a `str` parameter of a helper (`refused`, `not_found`): followed to every call of it, its default + included, and a helper passed around as a value is unread; +- a call: its return annotation; a map lookup (`_SETTING_REASONS[exc.reason]`): the map's values, and + its keys must be exactly what the index can be. + +Anything else is UNREAD, and the contract fails on it unless no screen shows that code. Stdlib only: it +runs on the host, beside the frontend's tests (`python -I backend/tests/refusal_sites.py` → JSON). +""" + +import ast +import json +import pathlib +import sys +from dataclasses import dataclass, field + +APP = pathlib.Path(__file__).resolve().parents[1] / "app" + +# Files whose `"code"` dicts are data, not refusals: a subtitle track's status payload, TMDB country codes. +NOT_REFUSALS = { + "plex/subtitles.py": "a subtitle track's status payload (its own `code`)", + "plex/tmdb.py": "a production country's ISO code", +} + +Node = ast.AST + + +@dataclass +class Res: + values: set[str] = field(default_factory=set) + unread: list[str] = field(default_factory=list) + + def __or__(self, other: "Res") -> "Res": + return Res(self.values | other.values, self.unread + other.unread) + + +@dataclass +class Module: + path: str # relative to app/, posix + name: str # dotted, `app.…` + tree: ast.Module + parents: dict[Node, Node] + imports: dict[ + str, tuple[str, str | None] + ] # local name → (module, symbol or None for the module) + top: dict[str, Node] # module-level bindings: def, class, or the assigned value + + +def _module_name(path: str) -> str: + parts = path.removesuffix(".py").split("/") + if parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(["app", *parts]) + + +def _load(sources: dict[str, str]) -> dict[str, Module]: + mods: dict[str, Module] = {} + for path, src in sources.items(): + tree = ast.parse(src) + parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)} + imports: dict[str, tuple[str, str | None]] = {} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + for a in node.names: + imports[a.asname or a.name] = (node.module, a.name) + elif isinstance(node, ast.Import): + for a in node.names: + if a.asname: + imports[a.asname] = (a.name, None) + top: dict[str, Node] = {} + for stmt in tree.body: + if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + top[stmt.name] = stmt + elif isinstance(stmt, ast.Assign): + for t in stmt.targets: + if isinstance(t, ast.Name): + top[t.id] = stmt + elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + top[stmt.target.id] = stmt + name = _module_name(path) + mods[name] = Module(path, name, tree, parents, imports, top) + return mods + + +class Scan: + def __init__(self, sources: dict[str, str]): + self.mods = _load(sources) + self.errors: list[str] = [] + self._following: set[tuple[str, str, str]] = set() + + # --- names --------------------------------------------------------------------------------------- + + def lookup(self, m: Module, name: str) -> tuple[Module, Node] | tuple[str, None] | None: + """What a module-level name is: (its module, its binding), or (a module's name, None).""" + if name in m.top: + return m, m.top[name] + if name in m.imports: + mod, sym = m.imports[name] + if sym is None: + return (mod, None) if mod in self.mods else None + if f"{mod}.{sym}" in self.mods: + return f"{mod}.{sym}", None + if mod in self.mods: + return self.lookup(self.mods[mod], sym) + return None + + def lookup_expr(self, m: Module, e: Node) -> tuple[Module, Node] | tuple[str, None] | None: + if isinstance(e, ast.Name): + return self.lookup(m, e.id) + if isinstance(e, ast.Attribute): + base = self.lookup_expr(m, e.value) + if base and base[1] is None and isinstance(base[0], str): + return self.lookup(self.mods[base[0]], e.attr) + return None + + def at(self, m: Module, e: Node) -> str: + return f"{m.path}:{getattr(e, 'lineno', '?')}: {ast.unparse(e)}" + + def enclosing(self, m: Module, e: Node, kind: type | tuple[type, ...]) -> Node | None: + node = m.parents.get(e) + while node is not None and not isinstance(node, kind): + node = m.parents.get(node) + return node + + # --- types --------------------------------------------------------------------------------------- + + def ann_values(self, m: Module, ann: Node | None) -> set[str] | None: + """The values a `Literal` annotation allows, or None when it is not one (`str`, a class, …).""" + if ann is None: + return None + if isinstance(ann, ast.Constant): + if ann.value is None: + return set() + if isinstance(ann.value, str): + return self.ann_values(m, ast.parse(ann.value, mode="eval").body) + return None + if isinstance(ann, ast.Subscript) and getattr(ann.value, "id", None) == "Literal": + items = ann.slice.elts if isinstance(ann.slice, ast.Tuple) else [ann.slice] + if all(isinstance(i, ast.Constant) and isinstance(i.value, str) for i in items): + return {i.value for i in items} + return None + if isinstance(ann, ast.BinOp) and isinstance(ann.op, ast.BitOr): + left, right = self.ann_values(m, ann.left), self.ann_values(m, ann.right) + return None if left is None or right is None else left | right + found = self.lookup_expr(m, ann) + if found and isinstance(found[1], ast.Assign) and isinstance(found[0], Module): + if isinstance(found[1].value, (ast.Subscript, ast.BinOp, ast.Name, ast.Attribute)): + return self.ann_values(found[0], found[1].value) + return None + + def classes(self, m: Module, e: Node | None) -> list[tuple[Module, ast.ClassDef]] | None: + if e is None: + return None + if isinstance(e, ast.Tuple): + out = [self.classes(m, x) for x in e.elts] + return None if any(o is None for o in out) else [c for o in out for c in o] + found = self.lookup_expr(m, e) + if found and isinstance(found[0], Module) and isinstance(found[1], ast.ClassDef): + return [(found[0], found[1])] + return None + + def attr_values(self, cm: Module, cls: ast.ClassDef, attr: str) -> set[str] | None: + """An attribute's `Literal` values, from its annotation on the class or `self.attr: T` in a method, + else from a base class.""" + for node in ast.walk(cls): + if isinstance(node, ast.AnnAssign): + t = node.target + if (isinstance(t, ast.Name) and t.id == attr and node in cls.body) or ( + isinstance(t, ast.Attribute) + and t.attr == attr + and getattr(t.value, "id", None) == "self" + ): + return self.ann_values(cm, node.annotation) + for base in cls.bases: + for bm, bc in self.classes(cm, base) or []: + v = self.attr_values(bm, bc, attr) + if v is not None: + return v + return None + + # --- reasons ------------------------------------------------------------------------------------- + + def resolve(self, m: Module, e: Node) -> Res: + if isinstance(e, ast.Constant): + if e.value is None: + return Res() + if isinstance(e.value, str): + return Res({e.value}) + if isinstance(e, ast.IfExp): + return self.resolve(m, e.body) | self.resolve(m, e.orelse) + if isinstance(e, ast.BoolOp) and isinstance(e.op, ast.Or): + out = Res() + for v in e.values: + out = out | self.resolve(m, v) + return out + if isinstance(e, ast.Name): + return self.resolve_name(m, e) + if isinstance(e, ast.Attribute): + return self.resolve_attr(m, e) + if isinstance(e, ast.Call): + found = self.lookup_expr(m, e.func) + if ( + found + and isinstance(found[0], Module) + and isinstance(found[1], (ast.FunctionDef, ast.AsyncFunctionDef)) + ): + v = self.ann_values(found[0], found[1].returns) + if v is not None: + return Res(v) + if isinstance(e, ast.Subscript): + return self.resolve_lookup(m, e) + return Res(unread=[self.at(m, e)]) + + def resolve_name(self, m: Module, e: ast.Name) -> Res: + fn = self.enclosing(m, e, (ast.FunctionDef, ast.AsyncFunctionDef)) + while fn is not None: + assert isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) + if self.caught_as(m, e, e.id) is not None: + return Res(unread=[self.at(m, e)]) # the exception itself, not a reason + params = {a.arg: a for a in [*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs]} + if e.id in params: + return self.resolve_param(m, fn, params[e.id], e) + if ( + fn.args.vararg + and fn.args.vararg.arg == e.id + or fn.args.kwarg + and fn.args.kwarg.arg == e.id + ): + return Res(unread=[self.at(m, e)]) + assigned = self.assignments(fn, e.id) + if assigned: + out = Res() + for a in assigned: + if ( + isinstance(a, ast.AnnAssign) + and (v := self.ann_values(m, a.annotation)) is not None + ): + out = out | Res(v) + elif ( + isinstance(a, (ast.Assign, ast.AnnAssign)) + and a.value is not None + and not any( + isinstance(t, (ast.Tuple, ast.List)) for t in getattr(a, "targets", []) + ) + ): + out = out | self.resolve(m, a.value) + else: + out = out | Res(unread=[self.at(m, e)]) + return out + fn = self.enclosing(m, fn, (ast.FunctionDef, ast.AsyncFunctionDef)) + found = self.lookup(m, e.id) + if ( + found + and isinstance(found[0], Module) + and isinstance(found[1], (ast.Assign, ast.AnnAssign)) + ): + stmt = found[1] + if stmt.value is not None and isinstance(stmt.value, ast.Constant): + return self.resolve(found[0], stmt.value) + return Res(unread=[self.at(m, e)]) + + def assignments(self, fn: Node, name: str) -> list[Node]: + """The statements that bind `name` in this function's own body (not in a nested one).""" + out: list[Node] = [] + stack = list(ast.iter_child_nodes(fn)) + while stack: + node = stack.pop() + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)): + continue + if isinstance(node, ast.Assign): + if any( + name in {n.id for n in ast.walk(t) if isinstance(n, ast.Name)} + for t in node.targets + ): + out.append(node) + elif isinstance(node, ast.AnnAssign) and getattr(node.target, "id", None) == name: + out.append(node) + elif isinstance( + node, (ast.For, ast.With, ast.AugAssign, ast.NamedExpr, ast.comprehension) + ): + targets = [getattr(node, "target", None)] + [ + i.optional_vars + for i in getattr(node, "items", []) + if i.optional_vars is not None + ] + if any( + t is not None and name in {n.id for n in ast.walk(t) if isinstance(n, ast.Name)} + for t in targets + ): + out.append( + node + ) # bound by a loop, a `with`, an augmented or a walrus: not followed + stack.extend(ast.iter_child_nodes(node)) + return out + + def caught_as(self, m: Module, e: Node, name: str) -> ast.ExceptHandler | None: + node = m.parents.get(e) + while node is not None and not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if isinstance(node, ast.ExceptHandler) and node.name == name: + return node + node = m.parents.get(node) + return None + + def resolve_param(self, m: Module, fn: Node, param: ast.arg, e: Node) -> Res: + v = self.ann_values(m, param.annotation) + if v is not None: + return Res(v) + if param.annotation is not None and getattr(param.annotation, "id", None) != "str": + return Res(unread=[self.at(m, e)]) + return self.follow(m, fn, param) + + def follow(self, m: Module, fn: Node, param: ast.arg) -> Res: + """A `str` parameter of a helper: what every call of the helper passes it.""" + assert isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) + key = (m.name, fn.name, param.arg) + if key in self._following: + return Res() + if m.top.get(fn.name) is not fn: + return Res( + unread=[ + f"{m.path}:{fn.lineno}: {fn.name}({param.arg}) is not a module-level helper" + ] + ) + self._following.add(key) + positional = [a.arg for a in [*fn.args.posonlyargs, *fn.args.args]] + defaults = dict( + zip( + positional[len(positional) - len(fn.args.defaults) :], fn.args.defaults, strict=True + ) + ) + defaults |= { + a.arg: d + for a, d in zip(fn.args.kwonlyargs, fn.args.kw_defaults, strict=True) + if d is not None + } + out = Res() + for cm in self.mods.values(): + for node in ast.walk(cm.tree): + if not isinstance(node, (ast.Name, ast.Attribute)) or isinstance( + cm.parents.get(node), ast.Attribute + ): + continue + found = self.lookup_expr(cm, node) + if not (found and found[0] is m and found[1] is fn): + continue + call = cm.parents.get(node) + if not (isinstance(call, ast.Call) and call.func is node): + out = out | Res(unread=[f"{self.at(cm, node)} (the helper used as a value)"]) + continue + kw = {k.arg: k.value for k in call.keywords} + given = param.arg in positional and positional.index(param.arg) < len(call.args) + if param.arg in kw: + out = out | self.resolve(cm, kw[param.arg]) + elif any(isinstance(a, ast.Starred) for a in call.args): + out = out | Res(unread=[self.at(cm, call)]) + elif given: # given by position, a `**` spread cannot pass it again (a TypeError) + out = out | self.resolve(cm, call.args[positional.index(param.arg)]) + elif None in kw: + out = out | Res(unread=[self.at(cm, call)]) + elif param.arg in defaults: + out = out | self.resolve(m, defaults[param.arg]) + return out + + def resolve_attr(self, m: Module, e: ast.Attribute) -> Res: + base = e.value + if isinstance(base, ast.Name): + handler = self.caught_as(m, e, base.id) + fn = self.enclosing(m, e, (ast.FunctionDef, ast.AsyncFunctionDef)) + typed = None + if handler is not None: + typed = self.classes(m, handler.type) + elif fn is not None: + params = { + a.arg: a for a in [*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs] + } + if base.id in params: + typed = self.classes(m, params[base.id].annotation) + if typed is not None: + out = Res() + for cm, cls in typed: + v = self.attr_values(cm, cls, e.attr) + out = out | ( + Res(v) + if v is not None + else Res( + unread=[f"{self.at(m, e)} ({cls.name}.{e.attr} has no Literal type)"] + ) + ) + return out + found = self.lookup_expr(m, e) + if ( + found + and isinstance(found[0], Module) + and isinstance(found[1], (ast.Assign, ast.AnnAssign)) + ): + stmt = found[1] + if isinstance(stmt.value, ast.Constant): + return self.resolve(found[0], stmt.value) + return Res(unread=[self.at(m, e)]) + + def resolve_lookup(self, m: Module, e: ast.Subscript) -> Res: + found = self.lookup_expr(m, e.value) + if not ( + found + and isinstance(found[0], Module) + and isinstance(found[1], (ast.Assign, ast.AnnAssign)) + ): + return Res(unread=[self.at(m, e)]) + table = found[1].value + if not isinstance(table, ast.Dict) or not all( + isinstance(k, ast.Constant) and isinstance(k.value, str) for k in table.keys + ): + return Res(unread=[self.at(m, e)]) + out = Res() + for v in table.values: + out = out | self.resolve(found[0], v) + index = self.resolve(m, e.slice) + keys = {k.value for k in table.keys} + if index.unread or index.values != keys: + self.errors.append( + f"{self.at(m, e)}: the map's keys {sorted(keys)} are not what its index can be " + f"{sorted(index.values)}{' + unread ' + str(index.unread) if index.unread else ''}" + ) + return out + + # --- sites --------------------------------------------------------------------------------------- + + def spread_is_safe(self, m: Module, e: Node, src: Node) -> bool: + """A `**x` in a detail, or `detail |= {…}` from it, cannot carry a reason when `x` is the `**kwargs` + of a function that takes `reason` by name: Python binds that one to the parameter.""" + if isinstance(src, ast.Attribute): + return self.spread_is_safe_attr(m, e, src) + fn = self.enclosing(m, e, (ast.FunctionDef, ast.AsyncFunctionDef)) + if fn is None or fn.args.kwarg is None: + return False + names = {a.arg for a in [*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs]} + kwarg = fn.args.kwarg.arg + if isinstance(src, ast.Name): + return src.id == kwarg and "reason" in names + + if isinstance(src, ast.DictComp): + it = src.generators[0].iter + return ( + isinstance(it, ast.Call) + and isinstance(it.func, ast.Attribute) + and it.func.attr == "items" + and getattr(it.func.value, "id", None) == kwarg + and "reason" in names + ) + return False + + def spread_is_safe_attr(self, m: Module, e: Node, src: ast.Attribute) -> bool: + """`e.params` of a caught exception whose `__init__(self, reason, **params)` keeps `self.params = params`.""" + if not isinstance(src.value, ast.Name): + return False + handler = self.caught_as(m, e, src.value.id) + typed = self.classes(m, handler.type) if handler else None + return bool(typed) and all(self.keeps_kwargs(cls, src.attr) for _, cls in typed or []) + + def keeps_kwargs(self, cls: ast.ClassDef, attr: str) -> bool: + init = next( + (s for s in cls.body if isinstance(s, ast.FunctionDef) and s.name == "__init__"), None + ) + if init is None or init.args.kwarg is None: + return False + names = {a.arg for a in [*init.args.posonlyargs, *init.args.args, *init.args.kwonlyargs]} + kwarg = init.args.kwarg.arg + return "reason" in names and any( + isinstance(n, ast.Assign) + and any( + isinstance(t, ast.Attribute) + and t.attr == attr + and getattr(t.value, "id", None) == "self" + for t in n.targets + ) + and getattr(n.value, "id", None) == kwarg + for n in ast.walk(init) + ) + + def entries(self, d: Node) -> list[tuple[str | None, Node]]: + if isinstance(d, ast.Dict): + return [ + (k.value if isinstance(k, ast.Constant) else None if k is None else "?", v) + for k, v in zip(d.keys, d.values, strict=True) + ] + assert isinstance(d, ast.Call) + return [(k.arg, k.value) for k in d.keywords] + + def site(self, m: Module, d: Node, out: dict) -> None: + items = self.entries(d) + code_expr = next(v for k, v in items if k == "code") + code = self.resolve(m, code_expr) + if code.unread or len(code.values) != 1: + out["unreadCodes"].append(self.at(m, code_expr)) + return + entry = out["codes"].setdefault( + next(iter(code.values)), {"reasons": set(), "unread": [], "sites": 0, "reasonless": []} + ) + entry["sites"] += 1 + res, has_reason = Res(), False + for k, v in items: + if k == "reason": + res, has_reason = res | self.resolve(m, v), True + elif k is None and not self.spread_is_safe(m, d, v): + res = res | Res(unread=[f"{self.at(m, v)} (a spread that may carry a reason)"]) + res, more = self.mutations(m, d, res) + has_reason = has_reason or more + if not has_reason: + entry["reasonless"].append(f"{m.path}:{d.lineno}") + entry["reasons"] |= res.values + entry["unread"] += res.unread + + def mutations(self, m: Module, d: Node, res: Res) -> tuple[Res, bool]: + """`detail |= {…}`, `detail["reason"] = …`, `detail.update(…)` on the variable a detail is put in.""" + stmt = m.parents.get(d) + if not (isinstance(stmt, (ast.Assign, ast.AnnAssign)) and stmt.value is d): + return res, False + target = stmt.targets[0] if isinstance(stmt, ast.Assign) else stmt.target + fn = self.enclosing(m, d, (ast.FunctionDef, ast.AsyncFunctionDef)) + if not isinstance(target, ast.Name) or fn is None: + return res, False + found = False + for node in ast.walk(fn): + if isinstance(node, ast.AugAssign) and getattr(node.target, "id", None) == target.id: + v = node.value + if isinstance(v, ast.Dict): + for k, val in self.entries(v): + if k == "reason": + res, found = res | self.resolve(m, val), True + elif k is None and not self.spread_is_safe(m, node, val): + res = res | Res( + unread=[f"{self.at(m, val)} (a spread that may carry a reason)"] + ) + elif not self.spread_is_safe(m, node, v): + res = res | Res(unread=[f"{self.at(m, node)} (may carry a reason)"]) + elif ( + isinstance(node, ast.Subscript) + and getattr(node.value, "id", None) == target.id + and isinstance(node.ctx, ast.Store) + ): + if isinstance(node.slice, ast.Constant) and node.slice.value != "reason": + continue + assign = m.parents.get(node) + if isinstance(node.slice, ast.Constant) and isinstance(assign, ast.Assign): + res, found = res | self.resolve(m, assign.value), True + else: + res = res | Res(unread=[f"{self.at(m, node)} (may carry a reason)"]) + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and getattr(node.func.value, "id", None) == target.id + and node.func.attr in {"update", "setdefault"} + ): + res = res | Res(unread=[f"{self.at(m, node)} (may carry a reason)"]) + return res, found + + def run(self) -> dict: + out: dict = { + "codes": {}, + "unreadCodes": [], + "errors": self.errors, + "excluded": {}, + "aliases": {}, + "notRefusals": sorted(NOT_REFUSALS), + } + for m in self.mods.values(): + for name, stmt in m.top.items(): + if ( + isinstance(stmt, ast.Assign) + and isinstance(stmt.value, ast.Subscript) + and (v := self.ann_values(m, stmt.value)) + ): + out["aliases"].setdefault(m.path, {})[name] = sorted(v) + for node in ast.walk(m.tree): + is_dict = isinstance(node, ast.Dict) and any( + isinstance(k, ast.Constant) and k.value == "code" for k in node.keys + ) + is_call = ( + isinstance(node, ast.Call) + and getattr(node.func, "id", None) == "dict" + and any(k.arg == "code" for k in node.keywords) + ) + if not (is_dict or is_call): + continue + if m.path in NOT_REFUSALS: + out["excluded"][m.path] = out["excluded"].get(m.path, 0) + 1 + continue + self.site(m, node, out) + for entry in out["codes"].values(): + entry["reasons"] = sorted(entry["reasons"]) + return out + + +def scan(sources: dict[str, str]) -> dict: + return Scan(sources).run() + + +def scan_app(app: pathlib.Path = APP) -> dict: + return scan( + { + p.relative_to(app).as_posix(): p.read_text(encoding="utf-8") + for p in sorted(app.rglob("*.py")) + if "__pycache__" not in p.parts + } + ) + + +if __name__ == "__main__": + json.dump( + scan_app(pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else APP), + sys.stdout, + indent=1, + sort_keys=True, + ) diff --git a/backend/tests/test_refusal_sites.py b/backend/tests/test_refusal_sites.py new file mode 100644 index 00000000..9c6effbb --- /dev/null +++ b/backend/tests/test_refusal_sites.py @@ -0,0 +1,278 @@ +"""The refusal scan (`refusal_sites.py`) on small sources: every shape a reason travels in is followed to +its values or reported unread. A scan that silently drops a shape is a guard green over nothing, so each +shape that once went past the regex it replaced is here. Pure: no app import, no database.""" + +import textwrap + +from tests.refusal_sites import scan, scan_app + + +def _scan(**files: str) -> dict: + return scan( + {f"{name.replace('__', '/')}.py": textwrap.dedent(src) for name, src in files.items()} + ) + + +def _code(result: dict, code: str) -> dict: + return result["codes"][code] + + +REFUSAL = """ + from fastapi import HTTPException + + def refused(status: int, reason: str, **params: object) -> HTTPException: + return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params}) +""" + + +def test_a_literal_wherever_it_sits_in_the_dict(): + r = _scan( + a=""" + def f(x): + raise HTTPException(409, {"code": "clip", "limit_s": 5, "reason": "too_long"}) + def g(x): + raise HTTPException(409, {"reason": "before", "code": "clip"}) + def h(x): + raise HTTPException(409, { + "code": "clip", # a comment between + "reason": "after_comment", + }) + def i(x): + raise HTTPException(409, detail=dict(code="clip", reason="by_call")) + """ + ) + assert _code(r, "clip")["reasons"] == ["after_comment", "before", "by_call", "too_long"] + assert _code(r, "clip")["unread"] == [] + + +def test_every_arm_of_a_conditional_and_every_operand_of_or(): + r = _scan( + a=""" + def f(x, e): + raise HTTPException(409, {"code": "edit", "reason": "a" if x else "b"}) + def g(x): + raise HTTPException(409, {"code": "edit", "reason": x.why or "c"}) + """ + ) + assert _code(r, "edit")["reasons"] == ["a", "b", "c"] + assert [u.split(": ", 1)[1] for u in _code(r, "edit")["unread"]] == ["x.why"] + + +def test_a_helper_is_followed_to_every_call_however_it_is_written(): + r = _scan( + refusal=REFUSAL, + routes__a=""" + from http import HTTPStatus + from app import refusal + from app.refusal import refused + + def f(status): + raise refused(status, "by_name_status") + def g(): + raise refused(HTTPStatus.CONFLICT, reason="by_keyword") + def h(): + raise refusal.refused(409, "qualified") + def i(x): + raise refused(409, "a" if x else "second_arm") + """, + ) + assert _code(r, "refused")["reasons"] == [ + "a", + "by_keyword", + "by_name_status", + "qualified", + "second_arm", + ] + assert _code(r, "refused")["unread"] == [] + + +def test_a_helper_passed_as_a_value_or_spread_into_is_unread(): + r = _scan( + refusal=REFUSAL, + routes__a=""" + from app.refusal import refused + + def f(args): + pick = refused + raise refused(*args) + """, + ) + unread = _code(r, "refused")["unread"] + assert any("the helper used as a value" in u for u in unread) + assert any("refused(*args)" in u for u in unread) + + +def test_a_default_is_what_a_call_that_omits_it_sends(): + r = _scan( + routes___common=""" + def not_found(what: str = "generic"): + return HTTPException(404, {"code": "not_found", "reason": what}) + + def get_or_404(db, id_, *, what: str = "generic"): + raise not_found(what) + """, + routes__a=""" + from app.routes._common import get_or_404, not_found + + def f(db): + get_or_404(db, 1, what="user") + get_or_404(db, 2) + raise not_found("debug_session") + """, + ) + assert _code(r, "not_found")["reasons"] == ["debug_session", "generic", "user"] + + +def test_a_caught_exception_answers_with_its_attribute_type_every_class_it_catches(): + r = _scan( + errors=""" + from typing import Literal + ClipReason = Literal["bad_range", "too_short"] + EditReason = Literal["not_media"] + + class ClipError(Exception): + def __init__(self, reason: ClipReason, **params: float): + self.reason: ClipReason = reason + self.params = params + + class EditError(Exception): + def __init__(self, reason: EditReason): + self.reason: EditReason = reason + + class Untyped(Exception): + def __init__(self, reason: str): + self.reason = reason + + class Narrower(ClipError): + pass + """, + routes__a=""" + from app import errors + + def f(): + try: + pass + except (errors.ClipError, errors.EditError) as e: + raise HTTPException(422, {"code": "clip", "reason": e.reason, **e.params}) + except errors.Untyped as e: + raise HTTPException(422, {"code": "clip", "reason": e.reason}) + except errors.Narrower as e: + raise HTTPException(422, {"code": "edit", "reason": e.reason}) + """, + ) + assert _code(r, "clip")["reasons"] == ["bad_range", "not_media", "too_short"] + unread = _code(r, "clip")["unread"] + assert any("Untyped.reason has no Literal type" in u for u in unread) + # EditError keeps no `**params`, so its `e.params` could be anything + assert any("e.params" in u for u in unread) + assert _code(r, "edit")["reasons"] == ["bad_range", "too_short"] + + +def test_a_typed_parameter_is_its_literal_and_an_untyped_one_is_unread_when_never_called(): + r = _scan( + a=""" + from typing import Literal + Why = Literal["one", "two"] + + def typed(reason: Why): + return HTTPException(409, {"code": "playlist", "reason": reason}) + + class C: + def method(self, reason: str): + return HTTPException(409, {"code": "playlist", "reason": reason}) + """ + ) + assert _code(r, "playlist")["reasons"] == ["one", "two"] + assert any("is not a module-level helper" in u for u in _code(r, "playlist")["unread"]) + + +def test_a_value_the_scan_cannot_follow_is_unread(): + r = _scan( + a=""" + def f(svc): + hit, code = svc.fetch() + raise HTTPException(422, {"code": "subtitle", "reason": code}) + def g(svc): + raise HTTPException(422, {"code": "subtitle", "reason": svc.reason()}) + def h(e): + raise HTTPException(422, {"code": "subtitle", "reason": e.reason}) + """ + ) + assert len(_code(r, "subtitle")["unread"]) == 3 + assert _code(r, "subtitle")["reasons"] == [] + + +def test_a_map_lookup_is_its_values_and_its_keys_must_be_what_the_index_can_be(): + src = """ + from typing import Literal + SettingReason = Literal["too_low", "too_high"] + + class InvalidSetting(ValueError): + def __init__(self, key: str, reason: SettingReason, **params: object): + self.reason: SettingReason = reason + self.params = params + + _MAP: dict[SettingReason, str] = {{{keys}}} + + def refused(status: int, reason: str, **params: object): + return HTTPException(status, {{"code": "refused", "reason": reason, **params}}) + + def setting_refused(exc: InvalidSetting): + return refused(400, _MAP[exc.reason], **exc.params) + """ + whole = _scan(a=src.format(keys='"too_low": "setting_too_low", "too_high": "setting_too_high"')) + assert _code(whole, "refused")["reasons"] == ["setting_too_high", "setting_too_low"] + assert whole["errors"] == [] + short = _scan(a=src.format(keys='"too_low": "setting_too_low"')) + assert len(short["errors"]) == 1 and "not what its index can be" in short["errors"][0] + + +def test_a_detail_built_up_in_steps_is_read_through_its_variable(): + r = _scan( + a=""" + def f(status: int, reason: str, user=None, **extra): + detail: dict = {"code": "login", "reason": "invalid"} + if user is not None: + detail |= {"email": user.email, "reason": "suspended"} + detail |= {k: v for k, v in extra.items() if v is not None} + return HTTPException(status, detail) + + def g(more): + detail = {"code": "login"} + detail["reason"] = "late" + detail.update(more) + detail |= more + return HTTPException(401, detail) + """ + ) + entry = _code(r, "login") + assert entry["reasons"] == ["invalid", "late", "suspended"] + assert sum("may carry a reason" in u for u in entry["unread"]) == 2 + + +def test_a_code_must_be_one_the_scan_can_read(): + r = _scan( + a=""" + BUSY = "busy" + def f(code): + raise HTTPException(409, {"code": BUSY}) + def g(code): + raise HTTPException(409, {"code": code}) + """ + ) + assert "busy" in r["codes"] and r["codes"]["busy"]["reasonless"] + assert len(r["unreadCodes"]) == 1 and r["unreadCodes"][0].endswith(": code") + + +def test_the_data_files_are_excluded_by_name_only(): + r = _scan(plex__tmdb='x = {"code": "HU", "name": "Hungary"}', plex__other='y = {"code": "HU"}') + assert r["excluded"] == {"plex/tmdb.py": 1} + assert "HU" in r["codes"] + + +def test_the_app_scans(): + """The real tree, on the interpreter the backend runs: every refusal it holds is read (the + frontend's contract judges what is read).""" + result = scan_app() + assert {"refused", "not_found", "login", "clip"} <= set(result["codes"]) + assert result["errors"] == [] diff --git a/docs/development.md b/docs/development.md index 465b327c..dcbe382d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -144,7 +144,8 @@ npm run typecheck # tsc x3 (app / node / e2e) npm run lint # eslint (typed rules included; warnings fail) npm run format:check # prettier npm run knip # unused files/exports/deps -npm run test # vitest (incl. the compiler and gate-register contracts) +npm run test # vitest (incl. the compiler and gate-register contracts; the refusal contract runs + # the host's `python` on backend/tests/refusal_sites.py, stdlib only) npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot) npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above ``` diff --git a/frontend/src/i18n/backendIds.contract.test.ts b/frontend/src/i18n/backendIds.contract.test.ts index 524ed7db..436d5ce6 100644 --- a/frontend/src/i18n/backendIds.contract.test.ts +++ b/frontend/src/i18n/backendIds.contract.test.ts @@ -1,4 +1,6 @@ +import { execFileSync } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { RUN_STATUSES } from "../lib/activityUrlState"; @@ -27,6 +29,25 @@ import { vocabKey } from "../lib/vocab"; const backend = (file: string) => readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8"); +// Every refusal the backend can answer, read from its syntax tree by `backend/tests/refusal_sites.py` +// (stdlib `ast`, on the host's Python, as the ruff lane is): each code with every reason it can be sent +// with — a reason in a variable followed to its `Literal` type, its helper's every call, its map — and +// what it could not follow, which fails below. It also hands back every `Literal` alias it read. +type RefusalScan = { + codes: Record; + unreadCodes: string[]; + errors: string[]; + notRefusals: string[]; + excluded: Record; + aliases: Record>; +}; +const scan = JSON.parse( + execFileSync("python", ["-I", "backend/tests/refusal_sites.py"], { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + encoding: "utf-8", + }), +) as RefusalScan; + const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record< string, { default: Record } @@ -54,12 +75,11 @@ const keysOf = (lang: string): Set => { const specs = [ ...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g), ].map((m) => ({ key: m[1]!, type: m[2]!, group: m[3]!, attr: m[4]! })); -// A choice key's options, as config.py spells them: `: = …` → ` = Literal[…]`. +// A choice key's options, as config.py spells them: `: = …`, the alias's values from the scan. const configPy = backend("config.py"); const choicesOf = (attr: string): string[] => { const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1]; - const body = alias && configPy.match(new RegExp(`^${alias} = Literal\\[([^\\]]*)\\]`, "m"))?.[1]; - return [...(body ?? "").matchAll(/"(\w+)"/g)].map((m) => m[1]!); + return (alias && scan.aliases["config.py"]?.[alias]) || []; }; const choiceIds = specs .filter((s) => s.type === "choice") @@ -90,16 +110,10 @@ const runEnum = (name: string) => const runStatuses = runEnum("RunStatus"); const runTriggers = runEnum("RunTrigger"); const quotaActions = enumValues("quota.py"); -// The admin actions' refusal reasons → `errors.admin.`. 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. +// The admin actions' refusals → `errors.admin.`: the codes recorded under that prefix. const adminCodes = Object.entries(REFUSAL_FAMILIES) .filter(([, f]) => f.prefix === "errors.admin") .map(([code]) => code); -const adminRefusals = [ - ...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g), -].filter((m) => adminCodes.includes(m[1]!)); -const adminReasons = [...new Set(adminRefusals.map((m) => m[2]!))]; // The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.`. // A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part // rather than splitting on dots. @@ -120,66 +134,6 @@ const failCategories = [ ]), ]; -// What a structured 404 says is gone → `errors.notFound.`: every `not_found("x")` and every -// `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources. -const routeFiles = (dir = "routes"): string[] => - readdirSync(new URL(`../../../backend/app/${dir}/`, import.meta.url), { - withFileTypes: true, - }).flatMap((e) => - e.isDirectory() - ? routeFiles(`${dir}/${e.name}`) - : e.name.endsWith(".py") - ? [`${dir}/${e.name}`] - : [], - ); -const routeSources = (): string[] => routeFiles().map(backend); -// The words a job's result gives for not doing its work → `scheduler.skip.` (the Scheduler -// page, the run notice and the audit row name them; lib/jobResult). Read as the `"skipped": "…"` / -// `"reason": "…"` literals of the non-HTTP backend: under routes/ (and main.py) a `reason` is a refusal -// code, which `errors.*` names. A word from an exception's text is not a literal and is not covered. -const appFiles = (dir: string): string[] => - readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), { - withFileTypes: true, - }).flatMap((e) => - e.isDirectory() - ? e.name === "routes" || e.name === "__pycache__" - ? [] - : appFiles(`${dir}${e.name}/`) - : e.name.endsWith(".py") && `${dir}${e.name}` !== "main.py" - ? [`${dir}${e.name}`] - : [], - ); -const skipWords = [ - ...new Set( - appFiles("").flatMap((f) => - [...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!), - ), - ), -]; -// A target the scan cannot read — `what=SOME_CONSTANT` — is a 404 no locale is checked for (one hid -// `debug_session`). Only the helpers' own pass-through in `_common.py` may name it by variable. -const opaqueWhats = routeFiles() - .filter((f) => !f.endsWith("/_common.py")) - .flatMap((f) => - [...backend(f).matchAll(/not_found\((?!"|\))[^)]*\)|what=(?!")\S+/g)].map( - (m) => `${f}: ${m[0]}`, - ), - ); -const goneWhats = [ - ...new Set( - routeSources().flatMap((src) => - [...src.matchAll(/not_found\("(\w+)"\)|what="(\w+)"/g)].map((m) => (m[1] ?? m[2])!), - ), - ), -]; -// 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), { @@ -193,114 +147,32 @@ const appPaths = (dir = ""): string[] => ? [`${dir}${e.name}`] : [], ); -const appSources = (): string[] => appPaths().map(backend); -// Every generic refusal → `errors.refused.`: each `refused(, "")` 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(/(? m[1]!), - ), - ...literalOf("downloads/folders.py", "FolderReason"), - ...[ - ...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll( - /:\s*"(\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(/(? { - 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 `"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 = { - "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 = { - "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 = [ +// The words a job's result gives for not doing its work → `scheduler.skip.` (the Scheduler +// page, the run notice and the audit row name them; lib/jobResult). Read as the `"skipped": "…"` / +// `"reason": "…"` literals of the non-HTTP backend: under routes/ (and main.py) a `reason` is a refusal +// code, which `errors.*` names. A word from an exception's text is not a literal and is not covered. +const skipWords = [ ...new Set( - detailSites - .filter((s) => s.code && s.reasonExpr) - .map((s) => `${s.f}: ${s.code} ← ${s.reasonExpr}`), + appPaths() + .filter((f) => !f.startsWith("routes/") && f !== "main.py") + .flatMap((f) => + [...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!), + ), ), ]; -// 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 = 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).map(([code, rs]) => [ - code, - [...new Set([...rs, ...literalReasons(code)])], - ]), -); + +const backendCodes = Object.keys(scan.codes); const recordedCodes = [ ...Object.keys(REFUSAL_FAMILIES), ...Object.keys(REFUSAL_BY_REASON), ...Object.keys(REFUSAL_FIXED), ...Object.keys(REFUSAL_UNSHOWN), ]; +// The codes whose reason picks the sentence, and what each can carry as its reason. +const reasoned = [...Object.keys(REFUSAL_FAMILIES), ...Object.keys(REFUSAL_BY_REASON)]; +const reasonsOf: Record = Object.fromEntries( + reasoned.map((code) => [code, scan.codes[code]?.reasons ?? []]), +); // The closed English vocabularies the backend WRITES into data, named through `vocab.` // (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named — @@ -363,10 +235,11 @@ describe("backend ids have their names in both languages", () => { expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"])); expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"])); expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"])); - expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"])); - expect(opaqueWhats).toEqual([]); - expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]); - expect(clipReasons).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"])); + expect(reasonsOf.not_found).toEqual( + expect.arrayContaining(["playlist", "download", "job", "user", "debug_session"]), + ); + expect(reasonsOf.youtube).toEqual(["gone", "quota", "reconnect", "unavailable"]); + expect(reasonsOf.clip).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"])); expect(crewJobs).toEqual( expect.arrayContaining(["Director", "Creator", "Director of Photography"]), ); @@ -382,11 +255,14 @@ describe("backend ids have their names in both languages", () => { "no service user", ]), ); - expect(refusedReasons).toEqual( - expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]), + expect(reasonsOf.refused).toEqual( + expect.arrayContaining([ + "weak_password", + "setting_too_low", + "plex_write_failed", + "file_folder_gone", + ]), ); - // Every code the frontend localizes is raised somewhere the scan can see. - expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]); }); it("every refusal code the backend answers is said by exactly one record, and no record names another", () => { @@ -397,12 +273,19 @@ 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("every refusal code and reason is one the scan can follow", () => { + // The modules set aside as not refusals still use the key. + expect(Object.keys(scan.excluded).sort()).toEqual(scan.notRefusals); + expect(scan.unreadCodes).toEqual([]); + expect(scan.errors).toEqual([]); + // A reason it could not follow is a sentence nobody is asked for, unless no screen says that code. + expect( + Object.entries(scan.codes) + .filter(([code]) => !(code in REFUSAL_UNSHOWN)) + .flatMap(([, c]) => c.unread), + ).toEqual([]); + // A code whose reason picks the sentence, sent without one, says only its fallback. + expect(reasoned.flatMap((code) => scan.codes[code]?.reasonless ?? [])).toEqual([]); }); it("read every refusal's reasons (a scan that matches nothing is green over nothing)", () => { @@ -533,33 +416,6 @@ describe("backend ids have their names in both languages", () => { expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]); }); - it(`${lang}: every "that is gone" a 404 names, and every YouTube refusal`, () => { - expect(missing(goneWhats.map((w) => `errors.notFound.${w}`))).toEqual([]); - expect( - [...idsUnder(keys, "errors.notFound")].filter( - (w) => w !== "generic" && !goneWhats.includes(w), - ), - ).toEqual([]); - expect(missing(youtubeReasons.map((r) => `errors.youtube.${r}`))).toEqual([]); - expect( - [...idsUnder(keys, "errors.youtube")].filter((r) => !youtubeReasons.includes(r)), - ).toEqual([]); - }); - - it(`${lang}: every generic refusal the backend can answer, and none it cannot`, () => { - expect(missing(refusedReasons.map((r) => `errors.refused.${r}`))).toEqual([]); - expect( - [...idsUnder(keys, "errors.refused")].filter((r) => !refusedReasons.includes(r)), - ).toEqual([]); - }); - - it(`${lang}: every reason a clip is refused, and none it cannot be`, () => { - expect(missing(clipReasons.map((r) => `clip.errors.${r}`))).toEqual([]); - expect([...idsUnder(keys, "clip.errors")].filter((r) => !clipReasons.includes(r))).toEqual( - [], - ); - }); - it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => { for (const [group, words] of Object.entries(VOCAB)) { const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`); @@ -588,32 +444,32 @@ 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` 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. + // By prefix: the admin actions share `errors.admin`. 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. + const byPrefix = new Map< + string, + { suffix: string; reasons: Set; spare: Set } + >(); for (const [code, f] of Object.entries(REFUSAL_FAMILIES)) { - 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) => - keys.has(`${f.prefix}.${r}${suffix}`) && - r !== fallbackId && - !reasonsOf[code]!.includes(r), - ), - code, - ).toEqual([]); + const p = byPrefix.get(f.prefix) ?? { + suffix: "suffix" in f ? f.suffix : "", + reasons: new Set(), + spare: new Set(), + }; + for (const r of reasonsOf[code]!) p.reasons.add(r); + if (f.fallback.startsWith(`${f.prefix}.`)) + p.spare.add(f.fallback.slice(f.prefix.length + 1)); + byPrefix.set(f.prefix, p); } - }); - - it(`${lang}: every admin refusal reason`, () => { - expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]); - expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual( - [], - ); + for (const [prefix, { suffix, reasons, spare }] of byPrefix) + expect( + [...idsUnder(keys, prefix)].filter( + (r) => + (!suffix || keys.has(`${prefix}.${r}${suffix}`)) && !spare.has(r) && !reasons.has(r), + ), + prefix, + ).toEqual([]); }); } });