@biffo/cli 0.249.6 → 0.249.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
"""Every literal `/api/v1/...` path the frontend calls must exist on this BFF.
|
|
2
|
+
|
|
3
|
+
biffo-template#1330 / tabsii-crm#232. Two occurrences in tabsii-crm, ten days
|
|
4
|
+
apart, both found only by clicking a deployed page, never by a test:
|
|
5
|
+
|
|
6
|
+
- tabsii-crm#218 — the simulation menu called a BFF route that did not exist.
|
|
7
|
+
- tabsii-crm#231 — the entire Finance feature, 13 paths, none served, three
|
|
8
|
+
Brand HQ sections rendering `Not Found` after thirteen merged milestones.
|
|
9
|
+
|
|
10
|
+
Neither existing suite could have caught either one. The frontend's own tests
|
|
11
|
+
mock the API client (`createApiClient`), so the path string handed to `.get`/
|
|
12
|
+
`.post`/etc is never actually sent anywhere — a typo or a route that was
|
|
13
|
+
renamed on the BFF side is invisible to them by construction. The BFF's own
|
|
14
|
+
tests exercise routes it registers, and never look at what the frontend calls.
|
|
15
|
+
Both suites are green, CI is green, the deploy is green, and the page is dead
|
|
16
|
+
the moment a person opens it.
|
|
17
|
+
|
|
18
|
+
This test closes that seam from the BFF side, which is the only place that can
|
|
19
|
+
see both halves: it reads the frontend's source for every `/api/v1/...`
|
|
20
|
+
literal, and asserts each one matches a route this app actually registers —
|
|
21
|
+
read from the running `FastAPI` instance (see `registered_route_paths` below
|
|
22
|
+
for exactly how, and why not the `{r.path for r in app.routes}` this issue's
|
|
23
|
+
own text proposes), never a hand-maintained second list. A second list is
|
|
24
|
+
exactly the `_extract_detail` mistake (biffo-template#1107/#1108): written
|
|
25
|
+
once, drifting the moment either side changes without the other.
|
|
26
|
+
|
|
27
|
+
## What counts as a call site
|
|
28
|
+
|
|
29
|
+
Every `.ts`/`.tsx` file under `apps/frontend/src/**`, EXCLUDING `*.test.ts(x)`
|
|
30
|
+
and `*.spec.ts(x)`. Test files are deliberately excluded: they mock the API
|
|
31
|
+
client and routinely reference fictional paths (`/api/v1/courses`,
|
|
32
|
+
`/api/v1/modules`, ...) as illustrative fixtures for `api-client.test.ts`
|
|
33
|
+
itself, not as real call sites — including those would make this test fail
|
|
34
|
+
permanently on paths nobody's browser ever requests, which is not the class
|
|
35
|
+
this guards against and would just get the whole file learned-to-ignore.
|
|
36
|
+
|
|
37
|
+
## Unresolvable paths FAIL, they do not skip
|
|
38
|
+
|
|
39
|
+
A check that drops an input it cannot evaluate shrinks its own denominator and
|
|
40
|
+
reports the remainder as the whole — `scripts/protection-audit.sh` reporting
|
|
41
|
+
27 clean branches when the real number was 34 is the standing example
|
|
42
|
+
(AGENTS.md §2). Three shapes are extracted but deliberately treated as
|
|
43
|
+
unresolvable rather than matched or silently ignored, and all three FAIL the
|
|
44
|
+
test rather than being dropped:
|
|
45
|
+
|
|
46
|
+
1. **A computed prefix** — `` `${apiBase}/api/v1/x` `` — where the part
|
|
47
|
+
*before* `/api/v1/` is an interpolation rather than a literal. This test
|
|
48
|
+
has no way to know what `apiBase` evaluates to, so it cannot rule out that
|
|
49
|
+
the real request never reaches `/api/v1/` at all.
|
|
50
|
+
2. **String concatenation** — `'/api/v1/x/' + id` (built with `+`, not a
|
|
51
|
+
template-literal interpolation) — where the literal fragment this test
|
|
52
|
+
would extract is incomplete on its own (`/api/v1/x/`) and comparing that
|
|
53
|
+
fragment against a route template would either produce a false failure (the
|
|
54
|
+
fragment alone matches nothing) or, worse, a false pass if some unrelated
|
|
55
|
+
route happens to share the prefix.
|
|
56
|
+
3. **A template literal nested inside another's interpolation** — real shape,
|
|
57
|
+
tabsii-crm's `ops-history-api.ts`:
|
|
58
|
+
`` `/api/v1/ops/units/${id}/history${cond ? `?limit=${limit}` : ''}` ``.
|
|
59
|
+
The extractor's regex is not nesting-aware, so it stops at the FIRST
|
|
60
|
+
closing backtick it finds — the nested literal's opening one — truncating
|
|
61
|
+
`raw` mid-expression. Detected in `_is_nested_template_literal_artifact`
|
|
62
|
+
(a dangling `${` or a literal newline surviving into what should be a URL
|
|
63
|
+
path is the tell) and routed to `unresolved_reason` rather than silently
|
|
64
|
+
producing a corrupted, garbage `normalized` value that would then fail to
|
|
65
|
+
match for a misleading reason — or, worse, coincidentally happen to match
|
|
66
|
+
something and mask a real defect.
|
|
67
|
+
|
|
68
|
+
`${...}` interpolations *inside* an otherwise-literal template — the ordinary,
|
|
69
|
+
expected case, e.g. `` `/api/v1/courses/${id}` `` — are not in either category:
|
|
70
|
+
they are normalised to `{param}` and compared structurally (see below). There
|
|
71
|
+
are no concatenated or computed-prefix call sites in this skeleton today; this
|
|
72
|
+
is future-proofing for whatever a sibling adds, backed by
|
|
73
|
+
`TestExtractApiPaths` below so the behaviour is proven rather than assumed.
|
|
74
|
+
|
|
75
|
+
A third shape is common in practice and deliberately NOT unresolvable: a
|
|
76
|
+
literal query string appended after the path, e.g.
|
|
77
|
+
`` `/api/v1/board?brand_id=${id}` `` (real call sites in tabsii-crm's
|
|
78
|
+
`PipelineBoard.tsx` and several other components — found while checking that
|
|
79
|
+
repo's frontend against its BFF ahead of ever distributing this guard there).
|
|
80
|
+
The query string is stripped before matching, on the same basis FastAPI itself
|
|
81
|
+
uses: a route's path template never includes one, and the query string cannot
|
|
82
|
+
change which route handles the request. Treating the query string as part of
|
|
83
|
+
the path to match would make this guard fail a repo for something FastAPI's
|
|
84
|
+
own router does not care about — a false positive that would have misfired on
|
|
85
|
+
a real sibling the very first time it ran, so the case is proven in
|
|
86
|
+
`TestExtractApiPaths` rather than left to be discovered by the next repo that
|
|
87
|
+
tries this.
|
|
88
|
+
|
|
89
|
+
A fourth shape is the same problem one step further: a query string built as
|
|
90
|
+
its OWN variable and interpolated whole, with no literal `?` anywhere in the
|
|
91
|
+
template — tabsii-crm's `AnalyticsPanel.tsx` calls
|
|
92
|
+
`` `/api/v1/analytics/pipeline/funnel${query}` `` where `query` is
|
|
93
|
+
`` `?${params.toString()}` ``. There is no literal `?` to split on, so this is
|
|
94
|
+
genuinely ambiguous from the call site alone: a trailing `${...}` glued
|
|
95
|
+
directly onto the end of a path (nothing after it, no `/` before it) reads
|
|
96
|
+
exactly as plausibly as a path parameter (`.../funnel{param}`) as it does a
|
|
97
|
+
whole query string (`.../funnel`). Rather than guess, `alt_normalized` records
|
|
98
|
+
the second reading, and a match against *either* reading counts — see
|
|
99
|
+
`_maybe_trailing_dynamic_suffix` and `ExtractedPath.alt_normalized`. This is
|
|
100
|
+
still not a skip: if neither reading matches a registered route, the test
|
|
101
|
+
fails and reports both readings it tried (see the `UNMATCHED` message below).
|
|
102
|
+
|
|
103
|
+
## Comments are stripped first, and why that is not optional
|
|
104
|
+
|
|
105
|
+
Found while checking tabsii-crm and tabsii-lms (ahead of ever distributing
|
|
106
|
+
this guard, per the sequencing note in #1330): `access-scope.ts` and
|
|
107
|
+
`display-name.ts` each carry a `//` comment that names a historical path in a
|
|
108
|
+
backtick code-span for a human reader — `` // ... `/api/v1/data/roles` already
|
|
109
|
+
returns the whole catalogue ... `` — which is not a call site at all, but a
|
|
110
|
+
naive backtick-literal regex cannot tell the difference. Left unhandled, this
|
|
111
|
+
guard would fail real, correct repos on their own documentation, which is
|
|
112
|
+
exactly the kind of noise that trains people to stop reading a check
|
|
113
|
+
(AGENTS.md's `mustBeUniform`/orphan-ratchet posture makes the same point about
|
|
114
|
+
residue that blocks on arrival rather than on a regression).
|
|
115
|
+
|
|
116
|
+
`/* ... */` block comments are removed outright, and `//` line comments are
|
|
117
|
+
removed from the first occurrence preceded by whitespace or start-of-line
|
|
118
|
+
onward — deliberately NOT from the first bare `//` in the line, which would
|
|
119
|
+
also truncate a perfectly normal string literal like `'https://…'` (no
|
|
120
|
+
whitespace precedes that `//`; Prettier — this repo's own formatter — always
|
|
121
|
+
puts either a space or nothing-but-line-start before a real comment marker).
|
|
122
|
+
This is a heuristic, not a tokenizer, and a string containing a real `//`
|
|
123
|
+
preceded by whitespace inside its own literal could defeat it in principle —
|
|
124
|
+
no instance of that shape exists anywhere in this skeleton or in the two
|
|
125
|
+
sibling repos this was checked against, so the trade is accepted rather than
|
|
126
|
+
built out into a full parser for a case that has not occurred.
|
|
127
|
+
|
|
128
|
+
## Matching
|
|
129
|
+
|
|
130
|
+
Every `${...}` interpolation is normalised to the literal placeholder
|
|
131
|
+
`{param}` first, so `` `/api/v1/courses/${id}` `` on the frontend and
|
|
132
|
+
`/api/v1/courses/{course_id}` on the BFF are never expected to agree on a
|
|
133
|
+
parameter's *name*, only on the path's *shape* — but "shape" is decided by
|
|
134
|
+
asking the app's OWN router to match a concrete instance of that shape
|
|
135
|
+
(`path_is_registered`, below), not by comparing normalised strings against a
|
|
136
|
+
set built from `app.openapi()["paths"]`.
|
|
137
|
+
|
|
138
|
+
That is a deliberate departure from the more obvious design, forced by a real
|
|
139
|
+
route this was checked against: tabsii-crm's
|
|
140
|
+
`@router.get("/analytics/{report:path}")` uses FastAPI's `:path` converter,
|
|
141
|
+
which matches *any number of remaining segments* — `/api/v1/analytics/sources`
|
|
142
|
+
and `/api/v1/analytics/pipeline/funnel` both dispatch to it, at one and three
|
|
143
|
+
segments respectively. `app.openapi()["paths"]` reports that route as
|
|
144
|
+
`/api/v1/analytics/{report}`, structurally indistinguishable in the JSON from
|
|
145
|
+
an ordinary single-segment param — the `:path` converter is compiled into the
|
|
146
|
+
route's matching *regex*, not preserved in its path string, so nothing about
|
|
147
|
+
the OpenAPI schema alone can tell a one-segment placeholder from a
|
|
148
|
+
many-segment catch-all. A flat string/segment comparison would have failed
|
|
149
|
+
every one of `AnalyticsPanel.tsx`'s five real, working calls against this
|
|
150
|
+
guard the moment tabsii-crm adopted it. `registered_route_paths()` is kept
|
|
151
|
+
only for the human-readable route list in a failure message — it is no longer
|
|
152
|
+
what a match is decided against.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
from __future__ import annotations
|
|
156
|
+
|
|
157
|
+
import re
|
|
158
|
+
from dataclasses import dataclass
|
|
159
|
+
from pathlib import Path
|
|
160
|
+
|
|
161
|
+
from fastapi import APIRouter, FastAPI
|
|
162
|
+
from starlette.routing import Match
|
|
163
|
+
|
|
164
|
+
from api.main import app
|
|
165
|
+
|
|
166
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
167
|
+
FRONTEND_SRC = REPO_ROOT / "apps" / "frontend" / "src"
|
|
168
|
+
|
|
169
|
+
_TEST_FILE_SUFFIXES = (".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx")
|
|
170
|
+
|
|
171
|
+
_TEMPLATE_LITERAL = re.compile(r"`([^`]*)`")
|
|
172
|
+
_STRING_LITERAL = re.compile(r"'([^'\n]*)'|\"([^\"\n]*)\"")
|
|
173
|
+
_INTERPOLATION = re.compile(r"\$\{[^}]*\}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
#: Interpolated base-URL identifiers that mean "this call goes somewhere other
|
|
177
|
+
#: than this app's own BFF". A frontend may legitimately call the core API
|
|
178
|
+
#: directly for PUBLIC, unauthenticated endpoints — `${CORE_API_URL}/api/v1/
|
|
179
|
+
#: public/...` is real in tabsii-intake (7 call sites) and tabsii-marketplace
|
|
180
|
+
#: (1), found by running this guard across every sibling before distributing
|
|
181
|
+
#: it. Those paths are not this BFF's contract, so asserting them against its
|
|
182
|
+
#: route table would fail five working call sites for a fact about the guard
|
|
183
|
+
#: rather than about the code.
|
|
184
|
+
#:
|
|
185
|
+
#: It is an ALLOWLIST, not a wildcard, and that is the whole safety property:
|
|
186
|
+
#: an interpolated prefix this list does not name is still unresolvable and
|
|
187
|
+
#: still FAILS, so nobody can hide a real BFF path behind a computed base by
|
|
188
|
+
#: accident. Adding a name here is a deliberate statement that the identifier
|
|
189
|
+
#: denotes an external origin.
|
|
190
|
+
EXTERNAL_BASE_IDENTIFIERS = frozenset({"CORE_API_URL"})
|
|
191
|
+
|
|
192
|
+
_LEADING_INTERPOLATION = re.compile(r"^\$\{\s*([A-Za-z_$][\w$]*)\s*\}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _external_base_name(raw: str) -> str | None:
|
|
196
|
+
"""The identifier a path is prefixed with, when that identifier is declared
|
|
197
|
+
external — otherwise None, and the caller treats it as unresolvable."""
|
|
198
|
+
match = _LEADING_INTERPOLATION.match(raw)
|
|
199
|
+
if match is None:
|
|
200
|
+
return None
|
|
201
|
+
name = match.group(1)
|
|
202
|
+
return name if name in EXTERNAL_BASE_IDENTIFIERS else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _template_literals(text: str):
|
|
206
|
+
"""Every template literal in `text`, as `(content, start_index)` — scanned
|
|
207
|
+
with brace and backtick depth rather than matched with a regex.
|
|
208
|
+
|
|
209
|
+
A regex cannot do this. `` `a${ cond ? `b` : '' }c` `` contains a NESTED
|
|
210
|
+
template literal inside its own interpolation, and `` `([^`]*)` `` stops at
|
|
211
|
+
that inner backtick, capturing a fragment that ends mid-expression. That
|
|
212
|
+
fragment is not the path — it is a truncation, and treating it as one is
|
|
213
|
+
how a guard either fails for a misleading reason or, worse, coincidentally
|
|
214
|
+
passes and masks the defect it exists to catch.
|
|
215
|
+
|
|
216
|
+
So: on `${` the scanner enters an interpolation and tracks `{`/`}` depth,
|
|
217
|
+
skipping any backticks inside it, and only a backtick at depth zero closes
|
|
218
|
+
the literal. `/api/v1/ops/units/${id}/history${c ? `?limit=${n}` : ''}`
|
|
219
|
+
(real, tabsii-crm) then yields its whole content, which normalises to a
|
|
220
|
+
real path plus a trailing interpolation — a shape
|
|
221
|
+
`_maybe_trailing_dynamic_suffix` already reads both ways.
|
|
222
|
+
"""
|
|
223
|
+
i, n = 0, len(text)
|
|
224
|
+
while i < n:
|
|
225
|
+
if text[i] != "`":
|
|
226
|
+
i += 1
|
|
227
|
+
continue
|
|
228
|
+
start = i + 1
|
|
229
|
+
j = start
|
|
230
|
+
depth = 0
|
|
231
|
+
while j < n:
|
|
232
|
+
ch = text[j]
|
|
233
|
+
if ch == "\\":
|
|
234
|
+
j += 2
|
|
235
|
+
continue
|
|
236
|
+
if depth == 0 and ch == "`":
|
|
237
|
+
yield text[start:j], start
|
|
238
|
+
break
|
|
239
|
+
if ch == "$" and j + 1 < n and text[j + 1] == "{":
|
|
240
|
+
depth += 1
|
|
241
|
+
j += 2
|
|
242
|
+
continue
|
|
243
|
+
if depth:
|
|
244
|
+
if ch == "{":
|
|
245
|
+
depth += 1
|
|
246
|
+
elif ch == "}":
|
|
247
|
+
depth -= 1
|
|
248
|
+
j += 1
|
|
249
|
+
else:
|
|
250
|
+
# Unterminated literal: not a path, and not something to guess at.
|
|
251
|
+
return
|
|
252
|
+
i = j + 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
_PARAM_SEGMENT = re.compile(r"\{[^}]*\}")
|
|
256
|
+
|
|
257
|
+
_BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
|
|
258
|
+
# From the first `//` preceded by whitespace or start-of-line, to end of line.
|
|
259
|
+
# NOT from the first bare `//`, which would also truncate a `'https://…'`
|
|
260
|
+
# string literal — see the module docstring's "Comments are stripped first"
|
|
261
|
+
# section for the real false positives (tabsii-crm, tabsii-lms) this exists
|
|
262
|
+
# to stop.
|
|
263
|
+
_LINE_COMMENT = re.compile(r"(?:^|(?<=\s))//.*$", re.MULTILINE)
|
|
264
|
+
|
|
265
|
+
API_PREFIX = "/api/v1/"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _strip_comments(text: str) -> str:
|
|
269
|
+
"""Remove `/* ... */` and `// ...` so a comment merely mentioning a path
|
|
270
|
+
— in prose, as documentation — is never mistaken for a call site.
|
|
271
|
+
|
|
272
|
+
A block comment is replaced by the same number of newlines it contained,
|
|
273
|
+
not deleted outright — this codebase leans heavily on multi-line `/** */`
|
|
274
|
+
JSDoc, and dropping those newlines would shift every subsequent line
|
|
275
|
+
number, misdirecting a failure message at the wrong line for anything
|
|
276
|
+
below one.
|
|
277
|
+
"""
|
|
278
|
+
text = _BLOCK_COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), text)
|
|
279
|
+
return _LINE_COMMENT.sub("", text)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class ExtractedPath:
|
|
284
|
+
"""One `/api/v1/...` literal found in the frontend source.
|
|
285
|
+
|
|
286
|
+
`normalized` is the comparable path with every dynamic segment collapsed
|
|
287
|
+
to `{param}`, or `None` when the path cannot be resolved at all — see
|
|
288
|
+
`unresolved_reason` for why, which is always set exactly when `normalized`
|
|
289
|
+
is not.
|
|
290
|
+
|
|
291
|
+
`alt_normalized` covers one specific, real ambiguity (see
|
|
292
|
+
`_maybe_trailing_dynamic_suffix` below): a trailing `${...}` glued
|
|
293
|
+
directly onto the end of the last path segment, with nothing after it and
|
|
294
|
+
no `/` before it, reads identically whether the variable holds a path
|
|
295
|
+
parameter or a query string built elsewhere and interpolated whole
|
|
296
|
+
(tabsii-crm's `` `/api/v1/analytics/pipeline/funnel${query}` `` is the
|
|
297
|
+
real example this was found against). Either `normalized` or
|
|
298
|
+
`alt_normalized` matching a registered route counts as a match; this is
|
|
299
|
+
still a FAIL when neither does, never a skip.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
file: Path
|
|
303
|
+
line: int
|
|
304
|
+
raw: str
|
|
305
|
+
normalized: str | None
|
|
306
|
+
unresolved_reason: str | None
|
|
307
|
+
alt_normalized: str | None = None
|
|
308
|
+
#: Set when the path is prefixed with a DECLARED external base
|
|
309
|
+
#: (`EXTERNAL_BASE_IDENTIFIERS`), meaning it never reaches this BFF. Such a
|
|
310
|
+
#: path is neither matched nor unresolved: it is out of this guard's scope
|
|
311
|
+
#: by a stated rule, and is reported in the denominator as its own count so
|
|
312
|
+
#: "all matched" can never quietly mean "we stopped looking at five".
|
|
313
|
+
external_base: str | None = None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _line_number(text: str, index: int) -> int:
|
|
317
|
+
return text.count("\n", 0, index) + 1
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _touches_concatenation(text: str, start: int, end: int) -> bool:
|
|
321
|
+
"""True when a `+` sits immediately beside the literal (outside quotes).
|
|
322
|
+
|
|
323
|
+
Checked in a small window on both sides rather than adjacent characters
|
|
324
|
+
only, so whitespace around the operator (`'/api/v1/x/' + id`, or
|
|
325
|
+
`'/api/v1/x/'+id`) does not let a concatenated fragment slip through as if
|
|
326
|
+
it were a complete, standalone path.
|
|
327
|
+
"""
|
|
328
|
+
window = 40
|
|
329
|
+
after = text[end : end + window].lstrip()
|
|
330
|
+
before = text[max(0, start - window) : start].rstrip()
|
|
331
|
+
return after.startswith("+") or before.endswith("+")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _relative(path: Path) -> str:
|
|
335
|
+
return str(path.relative_to(REPO_ROOT))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _resolve_interpolations(raw: str) -> str:
|
|
339
|
+
"""Replace every BALANCED `${...}` with `{param}`, tracking brace depth.
|
|
340
|
+
|
|
341
|
+
`_INTERPOLATION`'s `[^}]*` stops at the first `}` it meets, which is the
|
|
342
|
+
wrong one whenever an interpolation contains a nested template literal
|
|
343
|
+
with its own interpolation inside it — `${c ? `?limit=${n}` : ''}` gets
|
|
344
|
+
cut after `${n}`, leaving `: ''}` behind as if it were path text. Depth
|
|
345
|
+
tracking is the only thing that reads that correctly.
|
|
346
|
+
|
|
347
|
+
An interpolation that never closes is left exactly as it is, so
|
|
348
|
+
`_is_nested_template_literal_artifact` can still see the `${` and fail
|
|
349
|
+
loudly rather than guess.
|
|
350
|
+
"""
|
|
351
|
+
out: list[str] = []
|
|
352
|
+
i, n = 0, len(raw)
|
|
353
|
+
while i < n:
|
|
354
|
+
if raw[i] == "$" and i + 1 < n and raw[i + 1] == "{":
|
|
355
|
+
depth, j = 1, i + 2
|
|
356
|
+
while j < n and depth:
|
|
357
|
+
if raw[j] == "{":
|
|
358
|
+
depth += 1
|
|
359
|
+
elif raw[j] == "}":
|
|
360
|
+
depth -= 1
|
|
361
|
+
j += 1
|
|
362
|
+
if depth:
|
|
363
|
+
out.append(raw[i:])
|
|
364
|
+
break
|
|
365
|
+
out.append("{param}")
|
|
366
|
+
i = j
|
|
367
|
+
continue
|
|
368
|
+
out.append(raw[i])
|
|
369
|
+
i += 1
|
|
370
|
+
return "".join(out)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _is_nested_template_literal_artifact(raw: str) -> bool:
|
|
374
|
+
"""True when `raw` is almost certainly not the true content of the
|
|
375
|
+
template literal it was extracted from — a truncation, not a real path.
|
|
376
|
+
|
|
377
|
+
`_TEMPLATE_LITERAL` is a plain, non-nesting regex: it matches from an
|
|
378
|
+
opening backtick to the very next backtick, full stop. A JS/TS template
|
|
379
|
+
literal containing a NESTED one inside its own interpolation — tabsii-crm's
|
|
380
|
+
`` `/api/v1/ops/units/${id}/history${ cond ? `?limit=${limit}` : '' }` ``
|
|
381
|
+
(real, found while checking that repo ahead of ever distributing this
|
|
382
|
+
guard there) — has an inner backtick before its real closing one, so the
|
|
383
|
+
regex stops there instead, capturing `raw` as everything up to that INNER
|
|
384
|
+
opening backtick: a fragment ending mid-expression, with a dangling `${`
|
|
385
|
+
and a literal newline in what should be a URL path.
|
|
386
|
+
|
|
387
|
+
Detected two ways, either sufficient on its own: a literal newline (no
|
|
388
|
+
real API path contains one), or a `${` that survives resolution
|
|
389
|
+
unresolved (every WELL-FORMED interpolation closes with a matching `}`
|
|
390
|
+
and gets replaced; one that does not close within the captured text is
|
|
391
|
+
exactly what a truncation looks like). This is a heuristic, not a full
|
|
392
|
+
tokenizer — see the module docstring's "Comments are stripped first"
|
|
393
|
+
section for the same trade made elsewhere in this file — and rather than
|
|
394
|
+
risk silently producing a plausible-looking but WRONG normalised path
|
|
395
|
+
(which would fail the guard for the wrong, misleading reason, or worse,
|
|
396
|
+
coincidentally NOT fail and mask a real defect), a detected truncation is
|
|
397
|
+
routed to `unresolved_reason` and FAILS loudly, per the module docstring's
|
|
398
|
+
"Unresolvable paths FAIL, they do not skip" rule.
|
|
399
|
+
"""
|
|
400
|
+
resolved = _resolve_interpolations(raw)
|
|
401
|
+
if "${" in resolved:
|
|
402
|
+
# An interpolation that never closes: the capture really is truncated.
|
|
403
|
+
return True
|
|
404
|
+
# A newline in the PATH text is impossible; one inside an interpolation is
|
|
405
|
+
# just Prettier wrapping a long ternary, and `_template_literals` now
|
|
406
|
+
# captures those whole rather than stopping at the inner backtick. So the
|
|
407
|
+
# newline test has to run AFTER interpolations are resolved away, or every
|
|
408
|
+
# multi-line-formatted call site reads as a truncation it is not.
|
|
409
|
+
return "\n" in resolved
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _strip_query_string(raw: str) -> str:
|
|
413
|
+
"""Drop a literal `?...` suffix — FastAPI route templates never include
|
|
414
|
+
one, and it cannot change which route handles the request, so keeping it
|
|
415
|
+
in the comparable path would fail a real, working call site (see the
|
|
416
|
+
module docstring's third shape)."""
|
|
417
|
+
return raw.split("?", 1)[0]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# Matches the RESOLVED form: interpolations are turned into `{param}` before
|
|
421
|
+
# this runs (see the ordering note in `extract_api_paths`), so a trailing
|
|
422
|
+
# dynamic suffix looks like `{param}` at the end, not `${...}`.
|
|
423
|
+
_TRAILING_INTERPOLATION = re.compile(r"\{param\}$")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _maybe_trailing_dynamic_suffix(path_only: str) -> str | None:
|
|
427
|
+
"""The alternate reading for a trailing `${...}` with no `/` before it.
|
|
428
|
+
|
|
429
|
+
`/api/v1/analytics/pipeline/funnel${query}` (tabsii-crm, real) is exactly
|
|
430
|
+
as plausible read as "a path parameter glued onto the segment with no
|
|
431
|
+
separator" (`.../funnel{param}`, `normalized`'s reading) as it is "a query
|
|
432
|
+
string built elsewhere and interpolated whole" (`.../funnel`, dropping it
|
|
433
|
+
entirely) — the two are genuinely indistinguishable from the call site
|
|
434
|
+
alone, and `query` there is in fact built as `` `?${params.toString()}` ``.
|
|
435
|
+
|
|
436
|
+
Returns `None` when there is no trailing interpolation glued directly
|
|
437
|
+
onto the end (nothing to disambiguate), so callers can tell "not
|
|
438
|
+
applicable" from "applies, and resolves to the empty suffix".
|
|
439
|
+
"""
|
|
440
|
+
match = _TRAILING_INTERPOLATION.search(path_only)
|
|
441
|
+
if match is None:
|
|
442
|
+
return None
|
|
443
|
+
before = path_only[: match.start()]
|
|
444
|
+
if before.endswith("/"):
|
|
445
|
+
return None # a genuine path segment, not a glued-on suffix
|
|
446
|
+
return before
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def extract_api_paths(text: str, file: Path) -> list[ExtractedPath]:
|
|
450
|
+
"""Every `/api/v1/...` literal or template in `text`, resolved or not.
|
|
451
|
+
|
|
452
|
+
Comments are stripped first (see `_strip_comments`) — a path merely
|
|
453
|
+
mentioned in a `//` or `/* */` comment is not a call site.
|
|
454
|
+
"""
|
|
455
|
+
text = _strip_comments(text)
|
|
456
|
+
found: list[ExtractedPath] = []
|
|
457
|
+
|
|
458
|
+
for _content, _start in _template_literals(text):
|
|
459
|
+
raw = _content
|
|
460
|
+
_end = _start + len(_content)
|
|
461
|
+
if raw.startswith(API_PREFIX):
|
|
462
|
+
if _touches_concatenation(text, _start - 1, _end + 1):
|
|
463
|
+
found.append(
|
|
464
|
+
ExtractedPath(
|
|
465
|
+
file,
|
|
466
|
+
_line_number(text, _start),
|
|
467
|
+
raw,
|
|
468
|
+
None,
|
|
469
|
+
"built with string concatenation (+) — the extracted "
|
|
470
|
+
"literal is a fragment, not the whole path",
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
elif _is_nested_template_literal_artifact(raw):
|
|
474
|
+
found.append(
|
|
475
|
+
ExtractedPath(
|
|
476
|
+
file,
|
|
477
|
+
_line_number(text, _start),
|
|
478
|
+
raw,
|
|
479
|
+
None,
|
|
480
|
+
"contains a template literal nested inside its own "
|
|
481
|
+
"interpolation (e.g. a conditional query string built "
|
|
482
|
+
"with a second backtick-quoted expression) — this "
|
|
483
|
+
"extractor's regex is not nesting-aware and would "
|
|
484
|
+
"otherwise truncate at the inner backtick, producing "
|
|
485
|
+
"a corrupted path rather than a real mismatch",
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
else:
|
|
489
|
+
# Resolve interpolations BEFORE splitting off the query
|
|
490
|
+
# string. A ternary inside an interpolation carries its own
|
|
491
|
+
# `?` (`${c ? `?limit=${n}` : ""}`), and splitting first
|
|
492
|
+
# truncates the path at that operator — which reads as a
|
|
493
|
+
# missing route rather than as the parsing bug it is.
|
|
494
|
+
path_only = _strip_query_string(_resolve_interpolations(raw))
|
|
495
|
+
normalized = path_only
|
|
496
|
+
alt_base = _maybe_trailing_dynamic_suffix(path_only)
|
|
497
|
+
alt_normalized = alt_base
|
|
498
|
+
found.append(
|
|
499
|
+
ExtractedPath(
|
|
500
|
+
file,
|
|
501
|
+
_line_number(text, _start),
|
|
502
|
+
raw,
|
|
503
|
+
normalized,
|
|
504
|
+
None,
|
|
505
|
+
alt_normalized,
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
elif (_base := _external_base_name(raw)) is not None and API_PREFIX in raw:
|
|
509
|
+
# Prefixed with a DECLARED external origin, so it never reaches
|
|
510
|
+
# this BFF at all — counted and reported, never silently dropped.
|
|
511
|
+
found.append(
|
|
512
|
+
ExtractedPath(
|
|
513
|
+
file,
|
|
514
|
+
_line_number(text, _start),
|
|
515
|
+
raw,
|
|
516
|
+
None,
|
|
517
|
+
None,
|
|
518
|
+
None,
|
|
519
|
+
_base,
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
elif API_PREFIX in raw:
|
|
523
|
+
# Contains it, but does not start with it: the segment before
|
|
524
|
+
# `/api/v1/` is itself an interpolation (`${apiBase}/api/v1/x`),
|
|
525
|
+
# so this test cannot know what actually precedes the request.
|
|
526
|
+
found.append(
|
|
527
|
+
ExtractedPath(
|
|
528
|
+
file,
|
|
529
|
+
_line_number(text, _start),
|
|
530
|
+
raw,
|
|
531
|
+
None,
|
|
532
|
+
"computed path prefix — an interpolation precedes "
|
|
533
|
+
f"{API_PREFIX!r}, so the real request target is unknown",
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
for match in _STRING_LITERAL.finditer(text):
|
|
538
|
+
raw = match.group(1) if match.group(1) is not None else match.group(2)
|
|
539
|
+
if not raw.startswith(API_PREFIX):
|
|
540
|
+
continue
|
|
541
|
+
if _touches_concatenation(text, match.start(), match.end()):
|
|
542
|
+
found.append(
|
|
543
|
+
ExtractedPath(
|
|
544
|
+
file,
|
|
545
|
+
_line_number(text, match.start()),
|
|
546
|
+
raw,
|
|
547
|
+
None,
|
|
548
|
+
"built with string concatenation (+) — the extracted "
|
|
549
|
+
"literal is a fragment, not the whole path",
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
else:
|
|
553
|
+
normalized = _strip_query_string(raw)
|
|
554
|
+
found.append(
|
|
555
|
+
ExtractedPath(file, _line_number(text, match.start()), raw, normalized, None)
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
return found
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _frontend_source_files() -> list[Path]:
|
|
562
|
+
"""`.ts`/`.tsx` call sites under `apps/frontend/src/**`, tests excluded.
|
|
563
|
+
|
|
564
|
+
Test files are excluded deliberately, not by omission — see the module
|
|
565
|
+
docstring. They mock the API client, so a path string inside one is a
|
|
566
|
+
fixture value, never something a browser actually requests.
|
|
567
|
+
"""
|
|
568
|
+
candidates = [*FRONTEND_SRC.rglob("*.ts"), *FRONTEND_SRC.rglob("*.tsx")]
|
|
569
|
+
return sorted(p for p in candidates if not p.name.endswith(_TEST_FILE_SUFFIXES))
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def registered_route_paths() -> set[str]:
|
|
573
|
+
"""This BFF's own registered route templates, normalised for display.
|
|
574
|
+
|
|
575
|
+
Read from the live `FastAPI` app — never a hand-maintained second list,
|
|
576
|
+
which is precisely how #1107/#1108 (`_extract_detail`) and #218/#231 (this
|
|
577
|
+
issue) happened: two copies of the same fact, kept in step by nothing.
|
|
578
|
+
|
|
579
|
+
Deliberately `app.openapi()["paths"]`, not `{r.path for r in app.routes}`
|
|
580
|
+
as biffo-template#1330 proposes: on the FastAPI version this skeleton
|
|
581
|
+
actually pins (0.139.0), `app.include_router(...)` defers expansion behind
|
|
582
|
+
an internal `_IncludedRouter` wrapper with no `.path` of its own, so
|
|
583
|
+
walking `app.routes` and skipping anything without `.path` silently
|
|
584
|
+
returns only the three auto-generated meta-routes (`/openapi.json`,
|
|
585
|
+
`/api/docs`, `/docs/oauth2-redirect`) and NONE of the app's real routes —
|
|
586
|
+
confirmed by running exactly that snippet against this app before writing
|
|
587
|
+
this function. That is the same failure shape this whole test exists to
|
|
588
|
+
prevent, one level in: a check that quietly evaluates against less than it
|
|
589
|
+
thinks it does. `app.openapi()` is the documented, version-stable way to
|
|
590
|
+
ask "what does this app actually serve": it walks whatever internal
|
|
591
|
+
representation the installed FastAPI uses and returns the fully-resolved
|
|
592
|
+
path templates (prefixes already applied, params already in `{name}`
|
|
593
|
+
form), so it does not need to know about `_IncludedRouter` or whatever
|
|
594
|
+
FastAPI's next refactor replaces it with.
|
|
595
|
+
|
|
596
|
+
Used only to build the human-readable route list in a failure message —
|
|
597
|
+
see `path_is_registered` for what a match is actually decided against, and
|
|
598
|
+
why this set alone is not enough (the `:path`-converter case).
|
|
599
|
+
"""
|
|
600
|
+
schema = app.openapi()
|
|
601
|
+
paths: set[str] = set(schema.get("paths", {}).keys())
|
|
602
|
+
return {_PARAM_SEGMENT.sub("{param}", path) for path in paths}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
_PLACEHOLDER_SEGMENT = "x"
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _concrete_candidate(normalized: str) -> str:
|
|
609
|
+
"""A `{param}`-templated path turned into one concrete instance — an
|
|
610
|
+
ordinary, inert path segment with no special meaning to any router."""
|
|
611
|
+
return normalized.replace("{param}", _PLACEHOLDER_SEGMENT)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def path_is_registered(fastapi_app: FastAPI, normalized: str) -> bool:
|
|
615
|
+
"""Does a concrete instance of this template dispatch to ANY route this
|
|
616
|
+
app registers?
|
|
617
|
+
|
|
618
|
+
Asks the app's OWN router — `route.matches(scope)`, the exact mechanism a
|
|
619
|
+
real request goes through — rather than reconstructing FastAPI's path
|
|
620
|
+
semantics as a second, parallel implementation. That distinction is not
|
|
621
|
+
tidiness: a flat string/segment comparison cannot represent a `:path`
|
|
622
|
+
converter at all. `@router.get("/analytics/{report:path}")` (a real route
|
|
623
|
+
in tabsii-crm) matches any number of trailing segments, so
|
|
624
|
+
`/api/v1/analytics/{param}` (one segment) and
|
|
625
|
+
`/api/v1/analytics/pipeline/funnel` (three) are never equal as strings,
|
|
626
|
+
yet the same real request dispatches to that one route either way — see
|
|
627
|
+
the module docstring's "Matching" section for how this was found. Only
|
|
628
|
+
Starlette's own compiled route regex — reached through `.matches()`, never
|
|
629
|
+
re-derived from `app.openapi()`'s already-flattened path strings, which do
|
|
630
|
+
not preserve which converter a segment used — knows that.
|
|
631
|
+
|
|
632
|
+
`Match.NONE` means no match at all; `Match.PARTIAL` means the path matched
|
|
633
|
+
but the HTTP method did not. Either non-`NONE` result means a route
|
|
634
|
+
exists for this path shape, which is the only question this guard asks —
|
|
635
|
+
it does not care which verb the frontend used.
|
|
636
|
+
"""
|
|
637
|
+
candidate = _concrete_candidate(normalized)
|
|
638
|
+
scope = {"type": "http", "path": candidate, "method": "GET", "path_params": {}}
|
|
639
|
+
for route in fastapi_app.routes:
|
|
640
|
+
match, _ = route.matches(scope)
|
|
641
|
+
if match != Match.NONE:
|
|
642
|
+
return True
|
|
643
|
+
return False
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def test_every_frontend_api_v1_path_is_registered_on_the_bff() -> None:
|
|
647
|
+
extracted: list[ExtractedPath] = []
|
|
648
|
+
for file in _frontend_source_files():
|
|
649
|
+
extracted.extend(extract_api_paths(file.read_text(encoding="utf-8"), file))
|
|
650
|
+
|
|
651
|
+
external = [p for p in extracted if p.external_base is not None]
|
|
652
|
+
in_scope = [p for p in extracted if p.external_base is None]
|
|
653
|
+
resolved = [p for p in in_scope if p.normalized is not None]
|
|
654
|
+
unresolved = [p for p in in_scope if p.normalized is None]
|
|
655
|
+
|
|
656
|
+
def _is_matched(p: ExtractedPath) -> bool:
|
|
657
|
+
if p.normalized is not None and path_is_registered(app, p.normalized):
|
|
658
|
+
return True
|
|
659
|
+
return p.alt_normalized is not None and path_is_registered(app, p.alt_normalized)
|
|
660
|
+
|
|
661
|
+
unmatched = [p for p in resolved if not _is_matched(p)]
|
|
662
|
+
matched_count = len(resolved) - len(unmatched)
|
|
663
|
+
|
|
664
|
+
# The denominator, always reported — never just "N failures" with no sense
|
|
665
|
+
# of how many paths were even in scope. A share with an invisible
|
|
666
|
+
# denominator is how #1330's own motivating class ("27 clean branches"
|
|
667
|
+
# when the real count was 34) recurs one level up.
|
|
668
|
+
summary = (
|
|
669
|
+
f"{len(extracted)} /api/v1/... path template(s) found under "
|
|
670
|
+
f"apps/frontend/src/** ({_relative(FRONTEND_SRC)}); "
|
|
671
|
+
f"{matched_count} matched a route this BFF registers, "
|
|
672
|
+
f"{len(unmatched)} did not, {len(unresolved)} could not be resolved "
|
|
673
|
+
f"at all, {len(external)} target a declared external base and are out "
|
|
674
|
+
"of this BFF's scope."
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
lines = [summary]
|
|
678
|
+
for p in unmatched:
|
|
679
|
+
readings = (
|
|
680
|
+
repr(p.normalized)
|
|
681
|
+
if p.alt_normalized is None
|
|
682
|
+
else (f"{p.normalized!r} or {p.alt_normalized!r}")
|
|
683
|
+
)
|
|
684
|
+
lines.append(
|
|
685
|
+
f" UNMATCHED {_relative(p.file)}:{p.line} {p.raw!r} "
|
|
686
|
+
f"-> normalised {readings}, no BFF route matches either"
|
|
687
|
+
)
|
|
688
|
+
for p in unresolved:
|
|
689
|
+
lines.append(
|
|
690
|
+
f" UNRESOLVED {_relative(p.file)}:{p.line} {p.raw!r} -> {p.unresolved_reason}"
|
|
691
|
+
)
|
|
692
|
+
for p in external:
|
|
693
|
+
# Reported even though they pass, so the excluded set stays visible
|
|
694
|
+
# rather than becoming a silent hole in the denominator.
|
|
695
|
+
lines.append(
|
|
696
|
+
f" EXTERNAL {_relative(p.file)}:{p.line} {p.raw!r} "
|
|
697
|
+
f"-> prefixed with ${{{p.external_base}}}, declared external"
|
|
698
|
+
)
|
|
699
|
+
if unmatched or unresolved:
|
|
700
|
+
# Display only -- `path_is_registered` above is what actually decided
|
|
701
|
+
# pass/fail; see its docstring for why this flattened set cannot be
|
|
702
|
+
# used for the decision itself.
|
|
703
|
+
lines.append(f"\nRoutes this BFF registers: {sorted(registered_route_paths())}")
|
|
704
|
+
|
|
705
|
+
assert not unmatched and not unresolved, "\n".join(lines)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class TestExtractApiPaths:
|
|
709
|
+
"""`extract_api_paths` in isolation, so its handling of each shape is
|
|
710
|
+
proven rather than only exercised indirectly through whatever this
|
|
711
|
+
skeleton's frontend happens to contain today (currently: one literal
|
|
712
|
+
call, `/api/v1/whoami`, and no concatenated or computed-prefix call sites
|
|
713
|
+
at all)."""
|
|
714
|
+
|
|
715
|
+
def test_plain_literal_resolves_unchanged(self) -> None:
|
|
716
|
+
found = extract_api_paths("api.get('/api/v1/whoami')", Path("x.ts"))
|
|
717
|
+
assert len(found) == 1
|
|
718
|
+
assert found[0].normalized == "/api/v1/whoami"
|
|
719
|
+
assert found[0].unresolved_reason is None
|
|
720
|
+
|
|
721
|
+
def test_template_interpolation_normalises_to_param(self) -> None:
|
|
722
|
+
found = extract_api_paths("api.get(`/api/v1/courses/${id}`)", Path("x.ts"))
|
|
723
|
+
assert len(found) == 1
|
|
724
|
+
assert found[0].normalized == "/api/v1/courses/{param}"
|
|
725
|
+
|
|
726
|
+
def test_multiple_interpolations_all_normalise(self) -> None:
|
|
727
|
+
found = extract_api_paths(
|
|
728
|
+
"api.get(`/api/v1/courses/${courseId}/modules/${moduleId}`)", Path("x.ts")
|
|
729
|
+
)
|
|
730
|
+
assert len(found) == 1
|
|
731
|
+
assert found[0].normalized == "/api/v1/courses/{param}/modules/{param}"
|
|
732
|
+
|
|
733
|
+
def test_string_concatenation_is_unresolved_not_skipped(self) -> None:
|
|
734
|
+
found = extract_api_paths("api.get('/api/v1/courses/' + id)", Path("x.ts"))
|
|
735
|
+
assert len(found) == 1
|
|
736
|
+
assert found[0].normalized is None
|
|
737
|
+
assert found[0].unresolved_reason is not None
|
|
738
|
+
assert "concatenation" in found[0].unresolved_reason
|
|
739
|
+
|
|
740
|
+
def test_concatenation_before_the_literal_is_also_unresolved(self) -> None:
|
|
741
|
+
found = extract_api_paths("api.get(prefix + '/api/v1/courses')", Path("x.ts"))
|
|
742
|
+
assert len(found) == 1
|
|
743
|
+
assert found[0].normalized is None
|
|
744
|
+
|
|
745
|
+
def test_query_string_is_stripped_not_treated_as_part_of_the_path(self) -> None:
|
|
746
|
+
# Real shape, not hypothetical: tabsii-crm's PipelineBoard.tsx calls
|
|
747
|
+
# `/api/v1/board?brand_id=${encodeURIComponent(brandId)}` against a
|
|
748
|
+
# route registered as plain `/api/v1/board` — found while checking
|
|
749
|
+
# that repo's frontend against its BFF ahead of distributing this
|
|
750
|
+
# guard there. Without stripping the query string this would fail a
|
|
751
|
+
# real, working call site: FastAPI's own router does not consider the
|
|
752
|
+
# query string part of the path template it dispatches on.
|
|
753
|
+
found = extract_api_paths(
|
|
754
|
+
"api.get(`/api/v1/board?brand_id=${encodeURIComponent(brandId)}`)", Path("x.ts")
|
|
755
|
+
)
|
|
756
|
+
assert len(found) == 1
|
|
757
|
+
assert found[0].normalized == "/api/v1/board"
|
|
758
|
+
assert found[0].unresolved_reason is None
|
|
759
|
+
|
|
760
|
+
def test_query_string_on_a_plain_string_literal_is_also_stripped(self) -> None:
|
|
761
|
+
found = extract_api_paths("api.get('/api/v1/units?active=true')", Path("x.ts"))
|
|
762
|
+
assert len(found) == 1
|
|
763
|
+
assert found[0].normalized == "/api/v1/units"
|
|
764
|
+
|
|
765
|
+
def test_trailing_interpolation_with_no_slash_gets_an_alt_reading(self) -> None:
|
|
766
|
+
# Real shape, not hypothetical: tabsii-crm's AnalyticsPanel.tsx calls
|
|
767
|
+
# `/api/v1/analytics/pipeline/funnel${query}` where `query` is built
|
|
768
|
+
# separately as `` `?${params.toString()}` `` — no literal `?` in
|
|
769
|
+
# this template for `_strip_query_string` to find. `normalized` reads
|
|
770
|
+
# it as a path param glued on with no separator; `alt_normalized`
|
|
771
|
+
# reads it as the whole trailing query string, i.e. dropped.
|
|
772
|
+
found = extract_api_paths(
|
|
773
|
+
"api.get(`/api/v1/analytics/pipeline/funnel${query}`)", Path("x.ts")
|
|
774
|
+
)
|
|
775
|
+
assert len(found) == 1
|
|
776
|
+
assert found[0].normalized == "/api/v1/analytics/pipeline/funnel{param}"
|
|
777
|
+
assert found[0].alt_normalized == "/api/v1/analytics/pipeline/funnel"
|
|
778
|
+
|
|
779
|
+
def test_interpolation_after_a_slash_is_not_treated_as_ambiguous(self) -> None:
|
|
780
|
+
# `.../courses/${id}` is an ordinary path parameter -- the `/`
|
|
781
|
+
# immediately before `${` is exactly what distinguishes it from the
|
|
782
|
+
# glued-on-suffix shape above, so there is nothing to disambiguate.
|
|
783
|
+
found = extract_api_paths("api.get(`/api/v1/courses/${id}`)", Path("x.ts"))
|
|
784
|
+
assert len(found) == 1
|
|
785
|
+
assert found[0].alt_normalized is None
|
|
786
|
+
|
|
787
|
+
def test_a_path_named_only_in_a_line_comment_is_not_a_call_site(self) -> None:
|
|
788
|
+
# Real shape, not hypothetical: tabsii-crm's access-scope.ts and
|
|
789
|
+
# display-name.ts each have a `//` comment naming a path in a
|
|
790
|
+
# backtick code-span for a human reader
|
|
791
|
+
# (`` /api/v1/data/roles already returns the whole catalogue ``) —
|
|
792
|
+
# found while checking that repo ahead of ever distributing this
|
|
793
|
+
# guard there. Neither is a call site.
|
|
794
|
+
found = extract_api_paths(
|
|
795
|
+
"// see `/api/v1/data/roles` for the full catalogue\napi.get('/api/v1/roles')",
|
|
796
|
+
Path("x.ts"),
|
|
797
|
+
)
|
|
798
|
+
assert len(found) == 1
|
|
799
|
+
assert found[0].normalized == "/api/v1/roles"
|
|
800
|
+
|
|
801
|
+
def test_a_path_named_only_in_a_block_comment_is_not_a_call_site(self) -> None:
|
|
802
|
+
found = extract_api_paths(
|
|
803
|
+
"/** deprecated: used to call `/api/v1/legacy/x` */\napi.get('/api/v1/whoami')",
|
|
804
|
+
Path("x.ts"),
|
|
805
|
+
)
|
|
806
|
+
assert len(found) == 1
|
|
807
|
+
assert found[0].normalized == "/api/v1/whoami"
|
|
808
|
+
|
|
809
|
+
def test_a_url_containing_double_slash_is_not_mistaken_for_a_comment(self) -> None:
|
|
810
|
+
found = extract_api_paths(
|
|
811
|
+
"const base = 'https://example.com'\napi.get('/api/v1/whoami')",
|
|
812
|
+
Path("x.ts"),
|
|
813
|
+
)
|
|
814
|
+
assert len(found) == 1
|
|
815
|
+
assert found[0].normalized == "/api/v1/whoami"
|
|
816
|
+
|
|
817
|
+
def test_block_comment_stripping_preserves_line_numbers(self) -> None:
|
|
818
|
+
found = extract_api_paths(
|
|
819
|
+
"/**\n * a multi-line\n * jsdoc block\n */\napi.get('/api/v1/whoami')",
|
|
820
|
+
Path("x.ts"),
|
|
821
|
+
)
|
|
822
|
+
assert len(found) == 1
|
|
823
|
+
assert found[0].line == 5
|
|
824
|
+
|
|
825
|
+
def test_computed_prefix_is_unresolved_not_skipped(self) -> None:
|
|
826
|
+
found = extract_api_paths("api.get(`${apiBase}/api/v1/courses`)", Path("x.ts"))
|
|
827
|
+
assert len(found) == 1
|
|
828
|
+
assert found[0].normalized is None
|
|
829
|
+
assert found[0].unresolved_reason is not None
|
|
830
|
+
assert "computed path prefix" in found[0].unresolved_reason
|
|
831
|
+
|
|
832
|
+
def test_a_path_with_no_api_v1_prefix_at_all_is_ignored(self) -> None:
|
|
833
|
+
found = extract_api_paths("const url = '/health'; const other = `${x}/y`", Path("x.ts"))
|
|
834
|
+
assert found == []
|
|
835
|
+
|
|
836
|
+
def test_nested_template_literal_resolves_rather_than_being_given_up_on(self) -> None:
|
|
837
|
+
# Real shape, not hypothetical: tabsii-crm's ops-history-api.ts builds
|
|
838
|
+
# a conditional query string as its own nested template literal —
|
|
839
|
+
# found by running this guard against that repo before distributing it.
|
|
840
|
+
#
|
|
841
|
+
# A plain `` `([^`]*)` `` regex truncates at the NESTED backtick, and
|
|
842
|
+
# the first version of this guard therefore declared the path
|
|
843
|
+
# unresolvable and failed loudly. That was the right call over
|
|
844
|
+
# guessing, but it left the guard unable to pass in the one repo whose
|
|
845
|
+
# defect motivated it — so it could never have been distributed.
|
|
846
|
+
# `_template_literals` and `_resolve_interpolations` now track brace
|
|
847
|
+
# and backtick depth, so the path resolves for real. It is the ONLY
|
|
848
|
+
# unresolvable case either repo had.
|
|
849
|
+
found = extract_api_paths(
|
|
850
|
+
"api.get(\n"
|
|
851
|
+
" `/api/v1/ops/units/${encodeURIComponent(unitId)}/history${\n"
|
|
852
|
+
" limit !== undefined ? `?limit=${limit}` : ''\n"
|
|
853
|
+
" }`,\n"
|
|
854
|
+
")",
|
|
855
|
+
Path("x.ts"),
|
|
856
|
+
)
|
|
857
|
+
assert len(found) == 1
|
|
858
|
+
assert found[0].unresolved_reason is None
|
|
859
|
+
# The ternary's own `?` must not be mistaken for the query separator:
|
|
860
|
+
# resolving interpolations has to happen BEFORE the query string is
|
|
861
|
+
# split off, or the path truncates at `limit !== undefined `.
|
|
862
|
+
assert found[0].normalized == "/api/v1/ops/units/{param}/history{param}"
|
|
863
|
+
# ...and the trailing suffix keeps its alternate reading, which is the
|
|
864
|
+
# one that matches the registered route.
|
|
865
|
+
assert found[0].alt_normalized == "/api/v1/ops/units/{param}/history"
|
|
866
|
+
|
|
867
|
+
def test_is_nested_template_literal_artifact_accepts_well_formed_paths(self) -> None:
|
|
868
|
+
# Sanity check on the detector itself: it must not flag ordinary,
|
|
869
|
+
# correctly-extracted paths as artifacts, or every real call site
|
|
870
|
+
# would spuriously fail alongside the truncated ones.
|
|
871
|
+
assert _is_nested_template_literal_artifact("/api/v1/whoami") is False
|
|
872
|
+
assert _is_nested_template_literal_artifact("/api/v1/courses/${id}") is False
|
|
873
|
+
assert (
|
|
874
|
+
_is_nested_template_literal_artifact("/api/v1/courses/${courseId}/modules/${moduleId}")
|
|
875
|
+
is False
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
def test_registered_route_paths_normalises_fastapi_params(self) -> None:
|
|
879
|
+
registered = registered_route_paths()
|
|
880
|
+
assert "/api/v1/whoami" in registered
|
|
881
|
+
assert "/api/v1/health" in registered
|
|
882
|
+
assert all("{" not in r or r.count("{param}") == r.count("{") for r in registered)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
class TestPathIsRegistered:
|
|
886
|
+
"""`path_is_registered` in isolation, against both this skeleton's real
|
|
887
|
+
app and a throwaway one built to carry the shape that broke a flat
|
|
888
|
+
string/segment comparison: a `:path` converter."""
|
|
889
|
+
|
|
890
|
+
def test_matches_a_plain_registered_route(self) -> None:
|
|
891
|
+
assert path_is_registered(app, "/api/v1/whoami") is True
|
|
892
|
+
|
|
893
|
+
def test_matches_a_registered_route_through_its_param(self) -> None:
|
|
894
|
+
# sanity: the matcher is not simply "true for everything"
|
|
895
|
+
assert path_is_registered(app, "/api/v1/whoami/{param}") is False
|
|
896
|
+
|
|
897
|
+
def test_rejects_a_genuinely_unregistered_path(self) -> None:
|
|
898
|
+
# The tabsii-crm#231 shape, reproduced directly: a path with no route
|
|
899
|
+
# behind it at all.
|
|
900
|
+
assert path_is_registered(app, "/api/v1/finance/reports") is False
|
|
901
|
+
|
|
902
|
+
def test_a_path_converter_catch_all_matches_any_number_of_segments(self) -> None:
|
|
903
|
+
# Real shape, not hypothetical: tabsii-crm's analytics.py registers
|
|
904
|
+
# `@router.get("/analytics/{report:path}")`, and AnalyticsPanel.tsx
|
|
905
|
+
# legitimately calls it at both one segment
|
|
906
|
+
# (`/api/v1/analytics/sources`) and effectively three
|
|
907
|
+
# (`/api/v1/analytics/pipeline/funnel`) — found while checking that
|
|
908
|
+
# repo's frontend against its BFF ahead of ever distributing this
|
|
909
|
+
# guard there. A flat string/segment comparison could only ever
|
|
910
|
+
# accept ONE of those shapes; `path_is_registered` accepts both,
|
|
911
|
+
# because Starlette's own compiled route regex does.
|
|
912
|
+
catchall_app = FastAPI()
|
|
913
|
+
catchall_router = APIRouter()
|
|
914
|
+
|
|
915
|
+
@catchall_router.get("/analytics/{report:path}")
|
|
916
|
+
async def _report(report: str) -> dict[str, str]:
|
|
917
|
+
return {"report": report}
|
|
918
|
+
|
|
919
|
+
catchall_app.include_router(catchall_router, prefix="/api/v1")
|
|
920
|
+
|
|
921
|
+
assert path_is_registered(catchall_app, "/api/v1/analytics/{param}") is True
|
|
922
|
+
assert path_is_registered(catchall_app, "/api/v1/analytics/pipeline/funnel") is True
|
|
923
|
+
assert path_is_registered(catchall_app, "/api/v1/analytics/pipeline/funnel/{param}") is True
|
|
924
|
+
assert path_is_registered(catchall_app, "/api/v1/other/path") is False
|