Files
PartsInventorySystem/tests/test_concurrency.py
T
thejayman77 ab82b5e9a9 Manage the password in the app; close remaining audit findings
Password management moves out of the CLI entirely. The credential is now a
salted scrypt hash in the database (so it survives rebuilds, living in the /data
volume) rather than an environment variable; PARTS_PASSWORD is demoted to a
bootstrap value that stops working the moment a password is set in the UI. Every
token carries a session epoch, so changing the password — or "sign out other
devices" — invalidates outstanding cookies while keeping the browser that made
the change signed in. A banner nags until the handed-over password is replaced.
app/admin.py remains for the one case the UI cannot cover, a forgotten password.

Audit findings:
- Taxonomy update and delete scanned affected parts before taking the write
  lock, so a concurrent rename could leave the search index matching a name the
  UI no longer showed. All four routes now lock first; removing the lock again
  makes the new test fail exactly that way.
- History of a missing part returned 200 with an empty list; now 404.
- Infinity and NaN passed ge=0 and failed at the database. They are rejected as
  422 now, and the validation error handler no longer chokes trying to echo a
  non-finite value back.

Checks go from 134 to 181.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 10:31:06 -04:00

179 lines
8.7 KiB
Python

"""Concurrency checks against a real uvicorn process.
The in-process test client is not enough here: a lost update needs two requests
genuinely overlapping inside SQLite, which means a real server and real threads.
"""
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
PORT = 8137
BASE = f"http://127.0.0.1:{PORT}"
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
failures = []
def check(label, condition, detail=""):
print((" PASS " if condition else " FAIL ") + label + (f" [{detail}]" if detail and not condition else ""))
if not condition:
failures.append(label)
def req(path, method="GET", body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(BASE + path, data=data, method=method,
headers={"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(r, timeout=60))
def main():
tmp = tempfile.mkdtemp()
env = {
**os.environ,
"PARTS_DB": os.path.join(tmp, "conc.db"),
"PARTS_AUTH": "off",
}
server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--port", str(PORT), "--log-level", "warning"],
cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
try:
for _ in range(120):
try:
req("/healthz")
break
except Exception:
time.sleep(0.25)
else:
raise RuntimeError("server never started")
# --- concurrent decrements must not lose updates ---
pid = req("/api/parts", "POST", {"name": "race target", "quantity": 100})["id"]
with ThreadPoolExecutor(max_workers=50) as ex:
codes = list(ex.map(
lambda _: req(f"/api/parts/{pid}/adjust", "POST", {"delta": -1, "reason": "race"}) and 200,
range(50)))
check("all 50 concurrent adjustments succeeded", codes.count(200) == 50)
final = req(f"/api/parts/{pid}")["quantity"]
check("50 concurrent -1 adjustments land at 50", final == 50, f"got {final}")
hist = req(f"/api/parts/{pid}/history?limit=500")["items"]
check("history has one row per change", len(hist) == 51, f"got {len(hist)}")
total = sum(h["delta"] for h in hist)
check("stock log sums to the stored quantity", total == final, f"log={total} stored={final}")
# --- mixed concurrent increments and decrements ---
# Start high enough that no intermediate ordering can hit the floor at
# zero: with the floor in play the net is legitimately order-dependent,
# which would make this assertion about clamping rather than atomicity.
start = 100.0
deltas = [5] * 30 + [-3] * 30
pid2 = req("/api/parts", "POST", {"name": "mixed target", "quantity": start})["id"]
with ThreadPoolExecutor(max_workers=30) as ex:
list(ex.map(lambda d: req(f"/api/parts/{pid2}/adjust", "POST", {"delta": d}), deltas))
got = req(f"/api/parts/{pid2}")["quantity"]
check("mixed concurrent adjustments net out correctly",
got == start + sum(deltas), f"got {got}, expected {start + sum(deltas)}")
h2 = req(f"/api/parts/{pid2}/history?limit=500")["items"]
check("mixed adjustment log sums to the stored quantity",
sum(x["delta"] for x in h2) == got, f"log={sum(x['delta'] for x in h2)} stored={got}")
# --- the floor at zero is still honoured under contention ---
pid_floor = req("/api/parts", "POST", {"name": "floor race", "quantity": 10})["id"]
with ThreadPoolExecutor(max_workers=20) as ex:
list(ex.map(lambda _: req(f"/api/parts/{pid_floor}/adjust", "POST", {"delta": -1}), range(20)))
fq = req(f"/api/parts/{pid_floor}")["quantity"]
fh = req(f"/api/parts/{pid_floor}/history?limit=500")["items"]
check("20 concurrent -1 on a stock of 10 floors at zero", fq == 0, f"got {fq}")
check("floored concurrent log still sums to the stored quantity",
sum(x["delta"] for x in fh) == fq, f"log={sum(x['delta'] for x in fh)} stored={fq}")
# --- concurrent PATCH quantity is logged exactly once per change ---
pid3 = req("/api/parts", "POST", {"name": "patch target", "quantity": 0})["id"]
with ThreadPoolExecutor(max_workers=20) as ex:
list(ex.map(lambda i: req(f"/api/parts/{pid3}", "PATCH", {"quantity": float(i + 1)}), range(20)))
h3 = req(f"/api/parts/{pid3}/history?limit=500")["items"]
stored = req(f"/api/parts/{pid3}")["quantity"]
check("concurrent PATCHes each logged a row", len(h3) == 20, f"got {len(h3)}")
check("PATCH log's final quantity_after matches stored",
h3[0]["quantity_after"] == stored, f"log={h3[0]['quantity_after']} stored={stored}")
# --- taxonomy renames must leave the index agreeing with the tree ---
# The scan for affected parts and the reindex that follows have to see
# one consistent tree. Without the write lock a concurrent rename slips
# between them, and search keeps matching a name the UI no longer shows.
# Names are chosen so no token is a prefix of another: search uses prefix
# matching, so "Taxo 1" would legitimately match "Taxo 19" and the test
# would report a race that isn't there.
WORDS = ["alfa", "bravo", "charlie", "delta", "echo", "foxtrot", "golf",
"hotel", "india", "juliett", "kilo", "lima", "mike", "november",
"oscar", "papa", "quebec", "romeo", "sierra", "tango"]
cat = req("/api/categories", "POST", {"name": "Taxo zulu"})["id"]
req("/api/parts", "POST", {"name": "taxo widget", "category_id": cat, "quantity": 1})
names = [f"Taxo {w}" for w in WORDS]
with ThreadPoolExecutor(max_workers=20) as ex:
list(ex.map(lambda n: req(f"/api/categories/{cat}", "PATCH", {"name": n}), names))
final = [c["name"] for c in req("/api/categories")["items"] if c["id"] == cat][0]
hits_final = req(f"/api/parts?q={final.replace(' ', '+')}")["total"]
check("search matches the category's final name", hits_final == 1, f"{final} -> {hits_final}")
stale = [n for n in ["Taxo zulu"] + names if n != final
and req(f"/api/parts?q={n.replace(' ', '+')}")["total"] > 0]
check("no superseded category name still matches", not stale, f"stale: {stale}")
# Same race with a location, and with parts being created concurrently.
loc = req("/api/locations", "POST", {"name": "Loc zulu"})["id"]
loc_names = [f"Loc {w}" for w in WORDS[:15]]
def rename_or_add(i):
if i % 3 == 0:
req("/api/parts", "POST", {"name": f"loc widget {i}", "location_id": loc, "quantity": 1})
else:
req(f"/api/locations/{loc}", "PATCH", {"name": loc_names[i % len(loc_names)]})
with ThreadPoolExecutor(max_workers=15) as ex:
list(ex.map(rename_or_add, range(15)))
final_loc = [l["name"] for l in req("/api/locations")["items"] if l["id"] == loc][0]
in_loc = req(f"/api/parts?location_id={loc}&limit=100")["total"]
by_name = req(f"/api/parts?q={final_loc.replace(' ', '+')}&limit=100")["total"]
check("every part in the location is indexed under its final name",
by_name == in_loc, f"filter={in_loc} search={by_name}")
stale_loc = [n for n in ["Loc zulu"] + loc_names if n != final_loc
and req(f"/api/parts?q={n.replace(' ', '+')}&limit=100")["total"] > 0]
check("no superseded location name still matches", not stale_loc, f"stale: {stale_loc}")
# --- concurrent creates don't collide ---
with ThreadPoolExecutor(max_workers=25) as ex:
ids = list(ex.map(
lambda i: req("/api/parts", "POST", {"name": f"bulk {i}", "quantity": 1})["id"], range(25)))
check("25 concurrent creates produced 25 distinct parts", len(set(ids)) == 25)
check("all concurrent creates are searchable",
req("/api/parts?q=bulk&limit=100")["total"] == 25,
str(req("/api/parts?q=bulk&limit=100")["total"]))
finally:
server.send_signal(signal.SIGINT)
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
shutil.rmtree(tmp, ignore_errors=True)
print()
if failures:
print(f"{len(failures)} FAILED: " + "; ".join(failures))
return 1
print("all concurrency checks passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())