Repo X-ray

data/sample-service: where the bodies are

64 lines of Python across two files, two business endpoints, five tests, nine assertions, all green. The bug the README hides is fixed and the money is right. Two risks are still open. Every number here came from running the code; the commands are in the footer.

python 3.12.12fastapi 0.141.1pytest 9.1.1fix applied 2026-09-07
repo now: 5 green
Test suite
5 / 5
9 assertions, 0 failed
Account A total
4,050.00
matches the hand arithmetic
Business endpoints
2
both compute money through one helper
Ranked risks open
2
R1 closed; 1 high, 1 medium left

01 Module map

One file owns the app, the store, the math, and the routes. There is no layer boundary to hide behind.

Files in data/sample-service, their line counts, and what each owns
PathLinesWhat it owns
app/main.py34The FastAPI app, the in-memory ORDERS dict (3 orders, 2 accounts), order_total(), and both route handlers. Every risk below lives here.
app/__init__.py0Package marker only.
tests/test_orders.py305 tests, 9 assertions: 3 through TestClient, 2 calling order_total() directly against the live ORDERS dict. Two lines longer than it was; see 03.
tests/__init__.py0Package marker only.
pytest.ini3testpaths=tests, pythonpath=.. That is why bare pytest -q works from the repo root.
requirements.txt3fastapi, httpx, pytest, all unpinned. No lockfile, so the versions in the header are what resolved today, not what the repo asks for.
README.md5Says one test fails and asks you to find the bug. Now stale: the bug is fixed and the suite is green. Left as written, because it is the exercise brief, not documentation.

02 Endpoints, as the app answers them

Ten paths requested through TestClient. The totals column moves when you rewind; the status codes do not. FastAPI also mounts /docs, /redoc and /openapi.json by default, so the two below are the business surface, not the whole route table.

Ten requests made through TestClient with their status codes and totals
RequestStatusResponsetotal
GET/orders/A-1001 200 {id, account, lines[1], total} 2400.0
GET/orders/A-1002 200 {id, account, lines[2], total} 1650.0
GET/orders/B-2001 200 lines: [], the empty order 0.0
GET/orders/nope 404 {"detail":"order not found"} n/a
GET/orders/%20 404 Blank id falls through to the same handler 404. n/a
GET/orders/ 404 {"detail":"Not Found"}, the router's 404, not the handler's. n/a
GET/accounts/Account A/total 200 orders: 2 4050.0
GET/accounts/Account B/total 200 orders: 1, and its only order is empty 0.0
GET/accounts/account a/total 404 Same string, lower case. See risk 3, still open. n/a
GET/accounts/Nope/total 404 {"detail":"account not found"} n/a

03 Test summary

Five tests, nine assertions, all green. Two of those assertions are new, and they are the point: the four tests that used to pass did so while the money was wrong.

The five tests, their line numbers, results, and what each one asserts
TestLineResultWhat it asserts
test_get_order_ok+1 assert 7 pass Status 200, the id, and now the total. This is the assertion that closes the HTTP-boundary gap: it fails on the old code.
test_get_order_missing14passUnknown id gives 404.
test_total_two_lines18 pass The helper-level arithmetic check. It was the only red test, and the fix turned it green.
test_empty_order_total_is_zero22passPassed either way. On an empty lines list the loop body never runs, so total stays 0.0 whichever operator is in it.
test_account_total+1 assert 26 pass Order count and now the rolled-up total, 4050.0. Also fails on the old code.
pytest -q, verbatim
.....                                                                    [100%]
5 passed, 2 warnings in 0.31s
the new assertions earn their place: fix reverted in a scratch copy, new tests kept
FAILED tests/test_orders.py::test_get_order_ok    - assert 1202.0 == (2 * 1200.0)
FAILED tests/test_orders.py::test_total_two_lines - assert 1256.0 == ((10 * 45.0) ...
FAILED tests/test_orders.py::test_account_total   - assert 2458.0 == (((2 * 1200.0)...
3 failed, 2 passed, 2 warnings in 0.27s
The gap that let this ship, now closed. Before the fix, no test anywhere asserted the total field on an endpoint response. Both money paths reached the client unchecked, and one helper-level test was the entire safety net. Reintroducing the bug now turns three tests red instead of one, two of them at the HTTP boundary where a caller actually feels it.

04 The three ranked risks

Ranked by what a wrong answer costs, not by how hard it is to fix. R1 is closed. R2 and R3 are open, and neither was masked by closing R1.

R1

Every non-empty order total was wrong. Fixed.

closed

app/main.py:17 added the line quantity to the unit price instead of multiplying them. It now multiplies, which is what the docstring on line 14 always said. It was never contained: both endpoints compute money through this one helper, so the wrong number reached the order response and the account rollup, and nothing in either response signalled it. Kept here as the record of what it cost while it was live.

line 17 now
total += line["qty"] * line["unit"]
hand-checked against the store
A-1001   2 x 1200.00                = 2400.00 truth, API says 2400.00   (match)
A-1002   10 x 45.00 + 1 x 1200.00   = 1650.00 truth, API says 1650.00   (match)
Account A rollup                    = 4050.00 truth, API says 4050.00   (match)
R2

A malformed record is an unhandled 500

high

app/main.py:16-17 reaches into order["lines"], line["qty"] and line["unit"] with raw indexing. Nothing validates the store: no Pydantic model on the way in, no response model on the way out. A row missing a key raises KeyError out of the handler, which FastAPI turns into an unhandled 500 with no useful body. Today the store is a literal, so it holds. Reading a real database or feed, the reach depends on the missing key: a bad qty or unit breaks the orders that contain it and any account rollup that touches them, while a missing account key breaks the scan at app/main.py:31 for every account. A 500 is the honest status for corrupt server-side data; the defect is that it is unhandled and unlogged, so nobody learns which row did it.

reproduced directly against the helper
order_total({"lines":[{"sku":"X","qty":1}]})    ->  KeyError: 'unit'
order_total({"id":"X"})                         ->  KeyError: 'lines'
order_total({"lines":[{"qty":"2","unit":10.0}]}) ->  TypeError: can only concatenate str
order_total({"lines":[{"qty":2,"unit":None}]})   ->  TypeError: unsupported operand type

A missing key is not the only shape. A key present with the wrong type raises TypeError from the same line, so the guard has to validate types, not just key presence. The two TypeError messages above are from the old +; the fix changes the wording, not the failure.

R3

Account lookup is an unindexed exact-string scan

medium

app/main.py:31 filters the entire store on exact string equality, with the account name arriving as free text in the path. Three consequences. Matching is case-sensitive and untrimmed, verified below, and nothing in the code or tests states whether that is intended, so the identity rule is undefined rather than merely strict. There is no account registry: an account exists only as a string on an order, so the 404 at app/main.py:33 is the only possible answer for a name with no orders, and the endpoint cannot distinguish a typo from a real but empty account. And the scan is O(n) per request over the whole store, which is free at 3 orders and not free once the store is a table.

two requests, same string, different case
GET /accounts/Account A/total  ->  200  {"account":"Account A","orders":2,...}
GET /accounts/account a/total  ->  404  {"detail":"account not found"}

05 The fix, applied

One character in the app, two assertions in the tests. Applied to data/sample-service/ on disk, not to a scratch copy: the green suite above is the repo's real state.

app/main.py
@@ -13,7 +13,7 @@ def order_total(order: dict) -> float:
     """Sum of qty * unit across lines. Empty orders total 0."""
     total = 0.0
     for line in order["lines"]:
-        total += line["qty"] + line["unit"]
+        total += line["qty"] * line["unit"]
     return round(total, 2)
tests/test_orders.py
@@ -7,6 +7,7 @@ def test_get_order_ok():
     r = client.get("/orders/A-1001")
     assert r.status_code == 200
     assert r.json()["id"] == "A-1001"
+    assert r.json()["total"] == 2 * 1200.0
 
@@ -25,3 +26,4 @@ def test_account_total():
     r = client.get("/accounts/Account A/total")
     assert r.status_code == 200
     assert r.json()["orders"] == 2
+    assert r.json()["total"] == 2 * 1200.0 + 10 * 45.0 + 1 * 1200.0
Verified, in this order: the suite went from 1 failed, 4 passed to 5 passed; the three money endpoints returned 2400.0, 1650.0 and 4050.0, matching the hand arithmetic in R1; then the fix was reverted in a scratch copy with the new tests kept, and three tests went red. The last step is the one that matters, because a new assertion that cannot fail is not a test.
  • Still open: R2, R3, and the unbounded response in 06. None is a one-liner.
  • Not addressed: float arithmetic on money. round(total, 2) papers over it at 3 orders and will not at scale.
  • README.md still says one test fails. It is the exercise brief, so I left it; say the word and it gets a line about the fix.

06 What I threw at it

Bad, empty and huge input, against the service and against this page. Most of it held. One thing did not, and it is not in the three ranked risks because I only found it here.

Adversarial inputs tried against the service and the page, and what happened
Thrown at itResultRead
Bad input. 8,000-character order id; ../../etc/passwd; %00; a <script> tag; ' OR 1=1; a unicode account name; a trailing path segment 404 on all nine Held. Path params are dict keys and list comparisons, never a query language, so there is nothing to inject into. The 404 body is a fixed string, so nothing reflects back either.
Empty input. Emptied the ORDERS store, then requested both endpoints. Also order_total on {"lines": []} and on {} 404, 404, 0.0, KeyError Held, except the bare {} case, which is R2 again. An empty store degrades to 404 rather than 500, which is the right shape.
Huge input. One order with 500,000 lines, then GET /orders/H-9999 200, a 16.0 MB body in 1.94 s Did not hold, still open. The handler at app/main.py:26 spreads the whole order into the response, so lines is echoed back unbounded. No cap, no pagination, no Content-Length guard. The math is fast (0.03 s); serializing is the cost. One caller can pull a 16 MB response per request.
The page. Toggled the rewind switch both directions repeatedly, reloaded, ran it with JavaScript off, parsed the markup, and checked every swap node for a missing counterpart state No stale values, no script errors, no unclosed tags Held. Every swap node carries both states, so nothing is left showing the wrong run. Values are written with textContent, so the verbatim pytest output cannot inject markup. With JavaScript off the page pins to the current state and says so.
The fix for the one that did not hold: cap or paginate lines in the order response, and return a line count plus a first page rather than the whole array. That is the same class of problem as R2 and R3: the response contract is implicit, so nothing bounds it.