ab82b5e9a9
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>
179 lines
8.7 KiB
Python
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())
|