"""The audit-action registry must match what the code actually writes. The 操作日志 action filter is served from a hand-kept dict (``manage_actions.EMAIL_GROUP_ACTIONS``) rather than ``SELECT DISTINCT action`` — see that module for why. The cost of a hand-kept list is that it rots: a new ``action="email_group.foo"`` audit call ships and is silently unfilterable, or a retired action lingers as a dead dropdown option that always returns nothing. This test closes that by grepping the source for the literals and asserting *set equality* in both directions. It is deliberately a source grep, not a runtime check — the actions are strings inside functions nobody calls during a test run, so there is nothing to introspect. """ from __future__ import annotations import re from pathlib import Path from app.domains.email_group.manage_actions import ( DYNAMICALLY_BUILT_ACTIONS, EMAIL_GROUP_ACTIONS, category_of, ) # Repo-relative root of the backend package (…/server/app). APP_ROOT = Path(__file__).resolve().parents[2] / "app" # ANY ``"email_group.x"`` string literal in the source counts as a # written action. # # This used to be anchored to ``action="email_group.x"`` / # ``audit_action="email_group.x"``, which is how the domain names an # action *most* of the time — but not always, and the exception is not # exotic: one call site picks between two actions # (``member_added`` / ``owner_added``, since the same batch endpoint # serves 添加成员 and 添加负责人) and therefore builds the value in a # conditional before passing ``action=action``. Under the anchored # regex both literals became invisible, the gate reported # ``member_added`` as a stale registry entry, and the obvious way to # silence it — adding it to ``DYNAMICALLY_BUILT_ACTIONS`` — would have # switched the check OFF for a literal that is right there in the # source. # # Matching the bare literal is strictly stronger in both directions and # needs no per-call-site cooperation. Two things are stripped first: # # * comment lines, so a "``action="email_group.foo"`` was retired" # note cannot resurrect a dead action; # * ``@idempotent("email_group.…")`` scope names, which share this # prefix but are a DIFFERENT namespace — they name a request-dedupe # family (``email_group.member_add``), never an audit action # (``email_group.member_added``), and they must not be registered as # filter options. _LITERAL = re.compile(r'"(email_group\.[a-z0-9_.]+)"') _COMMENT = re.compile(r"^\s*#.*$", re.M) _IDEMPOTENT_SCOPE = re.compile(r'idempotent\(\s*"email_group\.[a-z0-9_.]+"') # The i18n bundles / registry module themselves mention action names in # prose and in the registry literal; only real call sites count. _SKIP_FILES = {"manage_actions.py"} def _grep_written_actions() -> set[str]: found: set[str] = set() for path in APP_ROOT.rglob("*.py"): if path.name in _SKIP_FILES: continue src = _COMMENT.sub("", path.read_text(encoding="utf-8")) src = _IDEMPOTENT_SCOPE.sub("idempotent(", src) found.update(_LITERAL.findall(src)) return found def test_every_written_action_is_registered(): """A new audit action must land in the registry in the SAME PR. Otherwise it is written to ``audit_logs`` but cannot be selected in the operation log's filter, and it renders under the catch-all ``other`` category. """ written = _grep_written_actions() missing = sorted(written - set(EMAIL_GROUP_ACTIONS)) assert not missing, ( "These email_group audit actions are written by the code but " "missing from manage_actions.EMAIL_GROUP_ACTIONS — add them (with " f"a category) in the same PR: {missing}" ) def test_no_stale_registered_actions(): """A registered action nobody writes is a dead filter option.""" written = _grep_written_actions() stale = sorted( set(EMAIL_GROUP_ACTIONS) - written - DYNAMICALLY_BUILT_ACTIONS ) assert not stale, ( "These actions are in manage_actions.EMAIL_GROUP_ACTIONS but no " "call site writes them. Remove them, or list them in " f"DYNAMICALLY_BUILT_ACTIONS if the name is built at runtime: {stale}" ) def test_dynamically_built_actions_are_registered(): """The escape hatch may only excuse actions that ARE in the registry.""" assert DYNAMICALLY_BUILT_ACTIONS <= set(EMAIL_GROUP_ACTIONS) def test_every_action_has_a_known_category(): known = { "group", "member", "request", "dynamic", "sync", "cleanup", "export", } bad = {a: c for a, c in EMAIL_GROUP_ACTIONS.items() if c not in known} assert not bad, f"unknown category on: {bad}" def test_category_of_falls_back_to_other(): """An unregistered action still renders — it just has no category. The log must never hide a row because the registry is stale. """ assert category_of("email_group.brand_new_thing") == "other" assert category_of("email_group.delete") == "group" # ── i18n completeness ─────────────────────────────────────────────── WEB_I18N = Path(__file__).resolve().parents[3] / "web" / "src" / "i18n" _LOCALES = ("zh-CN", "en-US", "ja-JP") _LABEL_PREFIX = "emailGroupManage.action.log." def test_every_registered_action_has_a_label_in_all_three_locales(): """A registered action with no label renders its raw key. The registry drives BOTH the 操作日志 action-filter dropdown and the table's 动作 column, and ``actionLabel`` falls back to the raw action string — so a missing label puts ``email_group.owner_added`` on screen, in every language, on the one surface this desk exists to make readable. That is also a direct breach of the repo's non-negotiable i18n rule. This shipped: ``email_group.owner_mirror_repaired`` was registered in one PR and labelled in none of the three bundles. Nine other actions had done the same thing a round earlier. A parity guard on the bundles cannot catch it (all three were equally missing), and the frontend never imports the registry — so the check has to compare the two sides, and this is the only test that sees both. """ missing: list[str] = [] for locale in _LOCALES: text = (WEB_I18N / f"{locale}.ts").read_text(encoding="utf-8") for action in sorted(EMAIL_GROUP_ACTIONS): if f"'{_LABEL_PREFIX}{action}'" not in text: missing.append(f"{locale}: {_LABEL_PREFIX}{action}") assert not missing, ( "These audit actions are in EMAIL_GROUP_ACTIONS but have no " "label — the operation log will render the raw action key. Add " "the key to every bundle in the same PR:\n " + "\n ".join(missing) )