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>
This commit is contained in:
+120
-4
@@ -27,8 +27,8 @@ with TestClient(app) as client:
|
||||
import app.auth as _auth
|
||||
import base64 as _b64, hashlib as _hl, hmac as _hm
|
||||
|
||||
def _issue_at(exp):
|
||||
payload = str(exp).encode()
|
||||
def _issue_at(exp, epoch=0):
|
||||
payload = f"{exp}:{epoch}".encode()
|
||||
return _b64.urlsafe_b64encode(
|
||||
payload + _hm.new(_auth._secret(), payload, _hl.sha256).digest()).decode()
|
||||
|
||||
@@ -198,13 +198,52 @@ with TestClient(app) as client:
|
||||
check("deleted part is gone", client.get(f"/api/parts/{screw['id']}").status_code == 404)
|
||||
check("deleted part leaves the index", "M3x8 socket cap screw" not in ids("M3x8"))
|
||||
check("missing part 404s", client.get("/api/parts/999999").status_code == 404)
|
||||
check("history of a missing part 404s",
|
||||
client.get("/api/parts/999999/history").status_code == 404)
|
||||
# httpx refuses to serialise inf/nan, so these go as raw bodies — which is
|
||||
# exactly how a real client would smuggle them in: Python's json.loads
|
||||
# accepts the bare `Infinity` and `NaN` tokens.
|
||||
JSONH = {"Content-Type": "application/json"}
|
||||
|
||||
def raw_post(path, body):
|
||||
return client.post(path, content=body, headers=JSONH)
|
||||
|
||||
def raw_patch(path, body):
|
||||
return client.patch(path, content=body, headers=JSONH)
|
||||
|
||||
check("Infinity quantity rejected",
|
||||
raw_post("/api/parts", '{"name":"inf","quantity":Infinity}').status_code == 422)
|
||||
check("-Infinity quantity rejected",
|
||||
raw_post("/api/parts", '{"name":"ninf","quantity":-Infinity}').status_code == 422)
|
||||
check("NaN quantity rejected",
|
||||
raw_post("/api/parts", '{"name":"nan","quantity":NaN}').status_code == 422)
|
||||
check("Infinity cost rejected",
|
||||
raw_post("/api/parts", '{"name":"inf2","cost_each":Infinity}').status_code == 422)
|
||||
check("Infinity min_quantity rejected",
|
||||
raw_post("/api/parts", '{"name":"inf3","min_quantity":Infinity}').status_code == 422)
|
||||
_infp = client.post("/api/parts", json={"name": "inf target", "quantity": 5}).json()["id"]
|
||||
check("Infinity adjustment delta rejected",
|
||||
raw_post(f"/api/parts/{_infp}/adjust", '{"delta":Infinity}').status_code == 422)
|
||||
check("NaN adjustment delta rejected",
|
||||
raw_post(f"/api/parts/{_infp}/adjust", '{"delta":NaN}').status_code == 422)
|
||||
check("Infinity PATCH quantity rejected",
|
||||
raw_patch(f"/api/parts/{_infp}", '{"quantity":Infinity}').status_code == 422)
|
||||
check("the part is untouched after a rejected Infinity",
|
||||
client.get(f"/api/parts/{_infp}").json()["quantity"] == 5)
|
||||
check("adjusting a missing part 404s",
|
||||
client.post("/api/parts/999999/adjust", json={"delta": 1}).status_code == 404)
|
||||
|
||||
# --- stats & validation ---
|
||||
# Derived from the listing rather than a magic number, so adding a fixture
|
||||
# part earlier in the file can't silently invalidate it.
|
||||
s = client.get("/api/stats").json()
|
||||
check("stats counts remaining parts", s["parts"] == 2, str(s))
|
||||
check("stats counts low stock", s["low_stock"] == 2, str(s))
|
||||
listed = client.get("/api/parts", params={"limit": 1000}).json()
|
||||
check("stats part count matches the listing", s["parts"] == listed["total"], str(s))
|
||||
check("stats low-stock count matches the listing",
|
||||
s["low_stock"] == sum(1 for p in listed["items"] if p["low_stock"]), str(s))
|
||||
check("stats value matches the listing",
|
||||
s["estimated_value"] == round(sum(p["quantity"] * (p["cost_each"] or 0)
|
||||
for p in listed["items"]), 2), str(s))
|
||||
check("whitespace-only name rejected",
|
||||
client.post("/api/parts", json={"name": " "}).status_code == 422)
|
||||
check("negative quantity rejected",
|
||||
@@ -351,6 +390,83 @@ with TestClient(app) as client:
|
||||
client.post("/api/login", json={"password": "hunter2"}).status_code == 200)
|
||||
check("a successful login resets the failure budget", auth_mod.login_retry_after() == 0)
|
||||
|
||||
# --- password management through the API ---
|
||||
check("bootstrap password is flagged", client.get("/api/me").json()["using_bootstrap_password"] is True)
|
||||
check("min length is advertised", client.get("/api/me").json()["min_password_length"] == 8)
|
||||
|
||||
check("wrong current password is refused",
|
||||
client.post("/api/password",
|
||||
json={"current_password": "nope", "new_password": "brand-new-secret"}).status_code == 403)
|
||||
auth_mod.clear_failures()
|
||||
check("short new password rejected",
|
||||
client.post("/api/password",
|
||||
json={"current_password": "hunter2", "new_password": "short"}).status_code == 422)
|
||||
check("reusing the current password rejected",
|
||||
client.post("/api/password",
|
||||
json={"current_password": "hunter2", "new_password": "hunter2"}).status_code == 422)
|
||||
check("still logged in after failed attempts", client.get("/api/parts").status_code == 200)
|
||||
|
||||
# A second, independent session that should be cut off by the change.
|
||||
other = TestClient(app)
|
||||
other.post("/api/login", json={"password": "hunter2"})
|
||||
check("second session works before the change", other.get("/api/parts").status_code == 200)
|
||||
|
||||
r = client.post("/api/password",
|
||||
json={"current_password": "hunter2", "new_password": "correct-horse-battery"})
|
||||
check("password change succeeds", r.status_code == 200, r.text)
|
||||
check("change reports other sessions signed out", r.json()["other_sessions_signed_out"] is True)
|
||||
check("the changing session stays signed in", client.get("/api/parts").status_code == 200)
|
||||
check("other sessions are signed out", other.get("/api/parts").status_code == 401)
|
||||
check("bootstrap flag clears", client.get("/api/me").json()["using_bootstrap_password"] is False)
|
||||
|
||||
fresh = TestClient(app)
|
||||
check("the old password no longer works",
|
||||
fresh.post("/api/login", json={"password": "hunter2"}).status_code == 401)
|
||||
auth_mod.clear_failures()
|
||||
check("the bootstrap env password is ignored once one is set",
|
||||
fresh.post("/api/login", json={"password": os.environ["PARTS_PASSWORD"]}).status_code == 401)
|
||||
auth_mod.clear_failures()
|
||||
check("the new password works",
|
||||
fresh.post("/api/login", json={"password": "correct-horse-battery"}).status_code == 200)
|
||||
check("the new session can read data", fresh.get("/api/parts").status_code == 200)
|
||||
|
||||
# --- sign out other devices, without signing out this one ---
|
||||
third = TestClient(app)
|
||||
third.post("/api/login", json={"password": "correct-horse-battery"})
|
||||
check("third session works", third.get("/api/parts").status_code == 200)
|
||||
check("revoke succeeds", client.post("/api/sessions/revoke").status_code == 200)
|
||||
check("revoke keeps the calling session", client.get("/api/parts").status_code == 200)
|
||||
check("revoke cuts off the other session", third.get("/api/parts").status_code == 401)
|
||||
|
||||
# --- the hash itself ---
|
||||
import app.db as _db
|
||||
with _db.session() as _conn:
|
||||
stored = auth_mod.stored_hash(_conn)
|
||||
check("password is stored hashed, not in the clear",
|
||||
stored is not None and stored.startswith("scrypt$") and "correct-horse-battery" not in stored)
|
||||
check("the stored hash verifies", auth_mod.verify_hash("correct-horse-battery", stored))
|
||||
check("the stored hash rejects a wrong password", not auth_mod.verify_hash("wrong", stored))
|
||||
check("two hashes of the same password differ (salted)",
|
||||
auth_mod.hash_password("same") != auth_mod.hash_password("same"))
|
||||
|
||||
# --- recovery CLI ---
|
||||
import app.admin as _admin
|
||||
check("admin set-password works", _admin.main(["set-password", "cli-set-password"]) == 0)
|
||||
cli = TestClient(app)
|
||||
check("CLI-set password logs in",
|
||||
cli.post("/api/login", json={"password": "cli-set-password"}).status_code == 200)
|
||||
auth_mod.clear_failures()
|
||||
check("CLI change signed out the API session", client.get("/api/parts").status_code == 401)
|
||||
check("admin rejects a short password", _admin.main(["set-password", "abc"]) == 1)
|
||||
check("admin show-status works", _admin.main(["show-status"]) == 0)
|
||||
check("admin rejects an unknown command", _admin.main(["nonsense"]) == 2)
|
||||
check("admin clear-password works", _admin.main(["clear-password"]) == 0)
|
||||
back = TestClient(app)
|
||||
check("clearing restores the env password",
|
||||
back.post("/api/login", json={"password": os.environ["PARTS_PASSWORD"]}).status_code == 200)
|
||||
check("bootstrap flag returns after clearing",
|
||||
back.get("/api/me").json()["using_bootstrap_password"] is True)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{len(failures)} FAILED: " + "; ".join(failures))
|
||||
|
||||
@@ -107,6 +107,49 @@ def main():
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user