"""Backend half of the BE↔FE deep-link contract. ``contracts/mobile-deep-links.json`` is the single source of truth for "where does a notification land on a phone?". This test scans ``server/app/`` for the desktop paths we put in notification / Feishu-card / email URLs and asserts every one of them is **classified** in that file — either ``mapped`` (with a ``/m/...`` target the frontend test verifies) or ``desktopOnly`` (with an honest reason). The frontend half is ``web/src/lib/mobileDeepLink.contract.test.ts``: it asserts the mapper actually produces the mapped target, and ``null`` for the desktop-only ones. Why the pair is needed (2026-07-28 audit): nothing tied the backend emitters to the mobile mapper, so drift was invisible. * ``/my/requests`` — the most-emitted deep link in the whole codebase (16 emitters) — had NO mobile mapping while ``/m/requests`` had existed for months. Every "your request changed state" card dumped the employee on the boot page. * ``/my/inventory`` (the company-wide asset-inventory reminder) likewise. * Six paths were being emitted that are **not routes on either shell** — ``/my-cloud-scopes``, ``/email-groups``, ``/admin/cmdb/grants``, ``/admin/software/seat-allocations``, ``/cloud/dns-requests/:id`` and a Zoom seed template's ``/my/seat-requests``. Guaranteed 404s, live in production, silently, for months. This test is the gate that makes those unrepeatable: a new notification URL cannot ship without someone deciding where it lands on mobile. """ from __future__ import annotations import ast import json import re from pathlib import Path _REPO_ROOT = Path(__file__).resolve().parents[3] _SERVER_APP = _REPO_ROOT / "server" / "app" _CONTRACT = _REPO_ROOT / "contracts" / "mobile-deep-links.json" # The kwargs / dict keys whose value is a URL the USER will click. Keep in # step with the construction shapes in the notification layer: # ``url=`` on NotificationEnvelope, ``url=`` on CardAction, # ``deep_link=`` on MobileActionItem, and the assorted # link / action_url / evidence_link / actor_link / admin_path / manage_link # response fields. _URL_FIELDS = frozenset({ "url", "deep_link", "action_url", "link", "evidence_link", "actor_link", "admin_path", "manage_link", "cta_path", "cta_url", "href", }) # A NAME that holds a user-clickable URL. Covers the kwarg/dict-key set # above plus the naming conventions used for locals, module constants, and # URL-builder helpers (``_SELF_SERVICE_URL``, ``_celebration_url()``, # ``cta_path=``). Broad on purpose: a false positive only costs one honest # contract entry, while a false NEGATIVE is a 404 nobody notices. _URL_NAME = re.compile( r"^(?:.*_)?(?:url|uri|path|link|deep_link|href)$", re.IGNORECASE, ) # Values that are rooted but are NOT SPA routes. # Relative paths that are genuinely not SPA routes. Each entry is a claim # that a browser will never be asked to route on it — keep the reason with # the path, because an unexplained entry here is indistinguishable from a # dead link someone silenced. _NON_SPA_RELATIVE: dict[str, str] = { "/oauth/token": "third-party OAuth token endpoint (Huilianyi / Azure), " "called server-to-server, never routed in a browser", "/.well-known/oauth-protected-resource/api/v1/mcp": "RFC 9728 protected-resource metadata served by the MCP middleware; " "fetched by MCP clients, never routed in a browser", "/docs": "FastAPI Swagger UI, served by the backend not the SPA", "/redoc": "FastAPI ReDoc, served by the backend not the SPA", "/openapi.json": "OpenAPI schema, served by the backend not the SPA", "/Employees": "Google Workspace OU path seeded in migration 103 — an " "org-unit path, not a URL (it merely looks like one)", "/Contractors": "Google Workspace OU path seeded in migration 103", } _HTTP_LIBS = frozenset({"httpx", "requests", "aiohttp", "urllib3"}) _NON_ROUTE_PREFIXES = ( "/api/", # backend endpoints — the browser never routes on these "/tmp/", "/var/", "/usr/", "/etc/", "/opt/", "/proc/", "/dev/", "/static/", "/assets/", ) def _is_url_name(name: str | None) -> bool: if not name: return False return name in _URL_FIELDS or bool(_URL_NAME.match(name)) # Token landing pages: the URL itself is the credential. Exempt from mobile # rewriting by design (``FeishuMobileGuard.TOKEN_ACTION_PATHS``). _KNOWN_TOKEN_PATHS = ( "/cloud/grants/renew", "/cmdb/expiry/ack", "/oauth/authorize", # Assistant proposal confirmation: the signed proposal token in the query # authorises view + submit, and the SPA route is public for exactly that # reason. Listed in ``FeishuMobileGuard.TOKEN_ACTION_PATHS`` as of this # change, so the card opens the page directly on a phone. "/assistant/confirm", ) def test_every_token_landing_page_is_exempt_in_the_mobile_guard() -> None: """The guard's carve-out list must cover every token landing page. These URLs carry their own credential and the SPA declares them public, so a mobile bounce through silent SSO defeats the point of the card. Three were listed; ``/assistant/confirm`` was not, which is how the assistant's "查看并确认" tap ended up going through the boot page first. """ guard = ( _REPO_ROOT / "web" / "src" / "components" / "FeishuMobileGuard.tsx" ).read_text(encoding="utf-8") listed = set(re.findall(r"'(/[a-z0-9/_-]+)'", guard.split( "const TOKEN_ACTION_PATHS = [", 1, )[1].split("]", 1)[0])) assert set(_KNOWN_TOKEN_PATHS) == listed, ( "FeishuMobileGuard.TOKEN_ACTION_PATHS and _KNOWN_TOKEN_PATHS have " f"drifted: guard={sorted(listed)} test={sorted(_KNOWN_TOKEN_PATHS)}" ) def _normalise(path: str) -> str: """Collapse a concrete/interpolated path to its contract template. ``/admin/assets/{}`` → ``/admin/assets/:id`` ``/x/{}/y/{}`` → ``/x/:id/y/:id`` Trailing slashes dropped; a bare ``/`` stays ``/``. """ out = re.sub(r"\{[^}]*\}", ":id", path) if len(out) > 1: out = out.rstrip("/") return out def _template_key(path: str) -> str: """Contract lookup key: every ``:param`` name flattened to ``:id``. The contract spells params descriptively (``:sid`` / ``:pid``); the scanner cannot know those names, so both sides collapse. """ return re.sub(r":[A-Za-z_]+", ":id", path) def _literal_paths(node: ast.AST, consts: dict[str, str]) -> list[str]: """Rooted path strings a URL-field expression can evaluate to. Handles the shapes that actually occur in this codebase — which is why this is an AST walk and not a regex. A line-based regex missed parenthesised multi-line assignments, ``url=(A if cond else B)`` branches, and module-level constants like ``_SELF_SERVICE_URL = "/me"`` (that last one is how ``/me`` stayed invisible in the first pass). """ out: list[str] = [] if isinstance(node, ast.Constant) and isinstance(node.value, str): if node.value.startswith("/"): out.append(node.value) elif isinstance(node, ast.JoinedStr): # f-string → literal parts kept, interpolations become "{}". rebuilt = "" for part in node.values: if isinstance(part, ast.Constant) and isinstance(part.value, str): rebuilt += part.value else: rebuilt += "{}" # ``f"{portal_url}/admin/x"`` starts with the interpolation; strip a # single leading placeholder so the PATH is what we compare. if rebuilt.startswith("{}/"): rebuilt = rebuilt[2:] if rebuilt.startswith("/"): out.append(rebuilt) elif isinstance(node, ast.IfExp): # ``url=("/a" if owner else "/b")`` — both branches are emitted. out.extend(_literal_paths(node.body, consts)) out.extend(_literal_paths(node.orelse, consts)) elif isinstance(node, ast.Name) and node.id in consts: out.append(consts[node.id]) elif isinstance(node, ast.Call): # ``MATERIAL_ROUTE.format(material_id=...)`` — a module constant # carrying ``{placeholder}`` segments, filled at the call site. # # This shape was INVISIBLE to the scanner until 2026-09-04, and it # hid the main emitter of an entire feature: the awareness # campaign card built its URL this way, so only the reminder # ladder's f-string was ever reported and the card — the one # people actually tap, in Feishu, on a phone — fell through to # the home page in production. The f-string branch above already # normalises ``{...}`` to ``{}``; do the same here rather than # inventing a second normalisation. func = node.func if isinstance(func, ast.Attribute) and func.attr == "format": for base in _literal_paths(func.value, consts): out.append(re.sub(r"\{[^}]*\}", "{}", base)) return out def _module_str_consts(tree: ast.Module) -> dict[str, str]: """Module-level ``NAME = "/path"`` assignments, for Name resolution.""" consts: dict[str, str] = {} for stmt in tree.body: if isinstance(stmt, ast.Assign): targets = stmt.targets elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None: targets = [stmt.target] else: continue value = stmt.value if not (isinstance(value, ast.Constant) and isinstance(value.value, str)): continue for tgt in targets: if isinstance(tgt, ast.Name) and value.value.startswith("/"): consts[tgt.id] = value.value return consts _WEB_SRC = _REPO_ROOT / "web" / "src" _DESKTOP_ROUTES_FILE = _WEB_SRC / "App.tsx" _MOBILE_ROUTES_FILE = _WEB_SRC / "mobile" / "routes.tsx" _ROUTE_PATH_RE = re.compile(r'path="(/[^"]*)"') def _declared_routes(tsx: Path) -> set[str]: return set(_ROUTE_PATH_RE.findall(tsx.read_text())) def _route_matches(path: str, routes: set[str]) -> str | None: """The declared route that would serve ``path``, or None. Segment-wise match with ``:param`` wildcards and a trailing ``*`` catch-all — the same shape react-router resolves. Query strings are ignored (they never participate in route matching). """ segs = path.split("?", 1)[0].strip("/").split("/") if segs == [""]: segs = [] best: str | None = None for route in routes: rsegs = route.strip("/").split("/") if rsegs == [""]: rsegs = [] if rsegs and rsegs[-1] == "*": if segs[: len(rsegs) - 1] == rsegs[:-1]: # A catch-all is the weakest possible match — keep looking # for something specific before settling for it. best = best or route continue if len(rsegs) != len(segs): continue if all(r.startswith(":") or r == sg for r, sg in zip(rsegs, segs)): # Exact/param match wins immediately. return route return best def _spa_top_segments() -> set[str]: """First path segment of every route the frontend declares. This is what separates "one of OUR routes" from "somebody else's API path". The scanner would otherwise flag every third-party endpoint the integration drivers call — Azure ARM ``/subscriptions/...``, Jira ``/wiki/spaces/...``, Bailian ``/modelstudio/...``, GitHub ``/scim/v2/...``, NetSuite ``/services/rest/...`` — all of which are assigned to variables named ``url`` and none of which a browser ever routes on. """ segs: set[str] = set() for f in (_DESKTOP_ROUTES_FILE, _MOBILE_ROUTES_FILE): for route in _declared_routes(f): first = route.strip("/").split("/")[0] if first and not first.startswith(":") and first != "*": segs.add(first) return segs def _speaks_http(tree: ast.AST) -> bool: """Does this module import an HTTP client (i.e. call third-party APIs)?""" for node in ast.walk(tree): if isinstance(node, ast.Import): if any(a.name.split(".")[0] in _HTTP_LIBS for a in node.names): return True elif isinstance(node, ast.ImportFrom): if (node.module or "").split(".")[0] in _HTTP_LIBS: return True return False def _scan_emitted_paths() -> dict[str, list[str]]: """Every rooted path that reaches a user-clickable URL sink. Four sink shapes, all of which occur in this codebase: 1. keyword argument ``url=…`` / ``deep_link=…`` / ``cta_path=…`` 2. dict entry ``{"admin_path": …}`` 3. assignment ``url = f"{portal_url}/dashboard"`` 4. helper return ``def _celebration_url(): return f"/feed/…"`` Shapes 3 and 4 are why this is an AST walk. The first version of this test used line regexes over ``url=`` and silently missed a third of the emitters — including ``/dashboard`` and ``/feed/celebrate/:id`` — which would have made the whole contract look complete while leaving holes. Returns ``{normalised_path: ["relpath:lineno", …]}``. """ spa_segments = _spa_top_segments() found: dict[str, list[str]] = {} sources = sorted(_SERVER_APP.rglob("*.py")) + sorted( (_REPO_ROOT / "server" / "alembic" / "versions").glob("*.py") ) for py in sources: rel = py.relative_to(_REPO_ROOT).as_posix() try: tree = ast.parse(py.read_text()) except SyntaxError: # pragma: no cover - would fail the build anyway continue consts = _module_str_consts(tree) # A module that speaks HTTP itself is where third-party API paths # live (Azure ARM `/subscriptions/...`, Jira `/wiki/spaces/...`, # NetSuite `/services/rest/...`), all of them assigned to variables # named `url`. Those keep the "must look like a declared route" # filter. Notification emitters — which is where deep links are # built — do not talk HTTP, so there every relative path must be # classified, including one whose first segment matches no route. # That last case is precisely the dead-link shape: `/email-groups` # and `/my-cloud-scopes` were both emitted from httpx-free modules. outbound = _speaks_http(tree) for node in ast.walk(tree): values: list[ast.AST] = [] if isinstance(node, ast.Call): values += [ kw.value for kw in node.keywords if _is_url_name(kw.arg) ] elif isinstance(node, ast.Dict): for key, val in zip(node.keys, node.values): if ( isinstance(key, ast.Constant) and isinstance(key.value, str) and _is_url_name(key.value) ): values.append(val) elif isinstance(node, ast.Assign): if any( isinstance(t, ast.Name) and _is_url_name(t.id) for t in node.targets ): values.append(node.value) elif isinstance(node, ast.FunctionDef) and _is_url_name(node.name): for inner in ast.walk(node): if isinstance(inner, ast.Return) and inner.value is not None: values.append(inner.value) for value in values: for raw in _literal_paths(value, consts): if raw.startswith(_NON_ROUTE_PREFIXES): continue first = raw.strip("/").split("/")[0].split("?", 1)[0] if outbound and first and first not in spa_segments: # NOT a shortcut for "third-party, ignore it". A # relative path whose first segment matches no # declared route is the single most likely shape of # a dead deep link — `/my-cloud-scopes` and # `/email-groups` (two of the six 404s this contract # was written to catch) both look exactly like this. # Skipping them made the guard blind to its own bug # class. Non-route relative paths must be declared # instead, with a reason. continue if first and first not in spa_segments: if raw.split("?", 1)[0].rstrip("/") in _NON_SPA_RELATIVE: continue # ``/m/...`` values are already mobile paths (the action # inbox emits those directly); the apps-catalog parity # test covers those instead. if raw == "/m" or raw.startswith("/m/"): continue found.setdefault(_normalise(raw), []).append( f"{rel}:{getattr(value, 'lineno', 0)}" ) return found def _contract() -> tuple[set[str], set[str], dict]: data = json.loads(_CONTRACT.read_text()) mapped = {_template_key(e["desktop"]) for e in data["mapped"]} desktop_only = {_template_key(e["path"]) for e in data["desktopOnly"]} return mapped, desktop_only, data def test_contract_file_is_well_formed(): mapped, desktop_only, data = _contract() assert data["mapped"], "contract has no mapped entries" assert data["desktopOnly"], "contract has no desktopOnly entries" # A path cannot be both. assert not (mapped & desktop_only), ( "paths listed in BOTH mapped and desktopOnly: " f"{sorted(mapped & desktop_only)}" ) for entry in data["mapped"]: assert entry["mobile"].startswith("/m/"), entry for entry in data["desktopOnly"]: assert entry.get("reason"), entry def test_every_emitted_deep_link_is_classified(): """Any user-clickable path the backend emits must be in the contract. Failure message names the file:line so the fix is mechanical: decide whether the path has a mobile twin, then add it to ``mapped`` (and add the mapper rule) or to ``desktopOnly`` with a reason. """ mapped, desktop_only, _ = _contract() known = mapped | desktop_only unclassified: dict[str, list[str]] = {} for path, sites in _scan_emitted_paths().items(): if _template_key(path) in known: continue unclassified[path] = sites[:3] assert not unclassified, ( "These paths are emitted to users but are not classified in " f"{_CONTRACT.relative_to(_REPO_ROOT)}:\n" + "\n".join( f" {p}\n emitted at: {', '.join(s)}" for p, s in sorted(unclassified.items()) ) + "\n\nAdd each to `mapped` (with its /m/... target, plus the rule in " "web/src/lib/mobileDeepLink.ts) or to `desktopOnly` with a reason." ) def test_every_contract_path_resolves_against_the_frontend_route_table(): """No contract path may be a 404. **This is the dead-link catcher.** Six paths were being emitted to real users that are not routes on either shell — ``/my-cloud-scopes`` (idle-sweep card to every scope owner), ``/email-groups`` + ``/email-groups?review=1`` (group-join approval), ``/admin/cmdb/grants`` and ``/admin/software/seat-allocations`` (both forensic-timeline rows), ``/cloud/dns-requests/:id`` (the DNS four-eyes-bypass peer notice, desktop side), plus a Zoom seed email's ``/my/seat-requests``. Every one of them 404'd, silently, for months — because nothing ever compared an emitted path against the router. Both directions are checked: the desktop side of every entry must resolve in ``App.tsx`` (or the mobile tree, for the handful of paths that only exist there), and every mapped mobile target must resolve in ``routes.tsx``. """ _, _, data = _contract() desktop_routes = _declared_routes(_DESKTOP_ROUTES_FILE) mobile_routes = _declared_routes(_MOBILE_ROUTES_FILE) all_routes = desktop_routes | mobile_routes dead: list[str] = [] for entry in data["mapped"]: probe = re.sub(r":[A-Za-z_]+", "abc123", entry["desktop"]) # ``legacyInbound`` entries are shapes that only ever arrive from # historical notification rows — no current emitter, no desktop # route, but the mapper rule still earns its keep. See the contract # file's $comment for the flag's contract. if not entry.get("legacyInbound") and _route_matches(probe, all_routes) is None: dead.append(f"mapped.desktop {entry['desktop']}") target = re.sub(r":[A-Za-z_]+", "abc123", entry["mobile"]) if _route_matches(target, mobile_routes) is None: dead.append(f"mapped.mobile {entry['mobile']}") for entry in data["desktopOnly"]: probe = re.sub(r":[A-Za-z_]+", "abc123", entry["path"]) if _route_matches(probe, all_routes) is None: dead.append(f"desktopOnly {entry['path']}") assert not dead, ( "Contract paths that no declared route can serve (i.e. a 404 for " "whoever taps the notification):\n " + "\n ".join(sorted(dead)) ) def test_mobile_apps_catalog_tiles_all_have_routes(): """Every apps-grid tile must land on a real mobile route. ``auditor_privileged_review`` advertised ``/m/auditor/privileged-review`` with no route registered, so every auditor who tapped that tile hit the ``/m/*`` 404 fallback. The catalog and the route tree are edited in different languages; this is the only thing that keeps them honest. """ catalog = (_SERVER_APP / "mobile" / "apps_catalog.py").read_text() tiles = sorted(set(re.findall(r'"path":\s*"(/m/[^"]+)"', catalog))) assert tiles, "no tiles parsed — the catalog format changed" mobile_routes = _declared_routes(_MOBILE_ROUTES_FILE) missing = [t for t in tiles if _route_matches(t, mobile_routes) is None] assert not missing, ( "apps-catalog tiles with no matching route in " f"{_MOBILE_ROUTES_FILE.relative_to(_REPO_ROOT)}:\n " + "\n ".join(missing) ) def test_action_inbox_deep_links_all_have_routes(): """Every to-do the mobile inbox surfaces must be reachable. The ``email_group_join_approval`` provider shipped pointing at ``/email-groups?review=1`` — not a route on either shell — so the one to-do a mail-group owner gets paged about 404'd on every tap. """ inbox = (_SERVER_APP / "mobile" / "action_inbox.py").read_text() links = sorted(set(re.findall(r'deep_link=f?"([^"]+)"', inbox))) assert links, "no deep_links parsed — action_inbox format changed" mobile_routes = _declared_routes(_MOBILE_ROUTES_FILE) desktop_routes = _declared_routes(_DESKTOP_ROUTES_FILE) bad: list[str] = [] for link in links: probe = re.sub(r"\{[^}]*\}", "abc123", link) if _route_matches(probe, mobile_routes | desktop_routes) is None: bad.append(link) assert not bad, ( "action-inbox deep_links that no route can serve:\n " + "\n ".join(bad) )