Initial import: eCW Charge Validation bot and results Portal
Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Portal Users, Sign-in rules and sessions — `web/users.py` (spec 2026-10-22
|
||||
Stage 2, EEWA-32), against an in-memory store (no SQL Server).
|
||||
|
||||
python tests\\test_portal_users.py
|
||||
|
||||
* Passwords: hashed with their own salt, never kept in plain; 10+
|
||||
characters, not the username.
|
||||
* Sign-in: a wrong password and an unknown username read the same;
|
||||
5 wrong in a row lock the user for 15 minutes; a disabled user cannot
|
||||
sign in; every outcome is recorded.
|
||||
* Admin changes: a new user must pick their own password at first
|
||||
Sign-in, a reset does the same, the last active Admin cannot be
|
||||
demoted or disabled, nobody disables themselves.
|
||||
* Sessions: 30 minutes idle ends one; ending a user's sessions works.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import pathlib
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from web import users # noqa: E402
|
||||
|
||||
users.ITERATIONS = 1_000 # fast tests; the real count is checked below
|
||||
CASES = []
|
||||
|
||||
|
||||
def case(fn):
|
||||
CASES.append(fn)
|
||||
return fn
|
||||
|
||||
|
||||
class FakeUserStore:
|
||||
"""The SqlUserStore methods, in memory."""
|
||||
|
||||
def __init__(self):
|
||||
self.users = {}
|
||||
self.events = []
|
||||
|
||||
def get(self, username):
|
||||
u = self.users.get(username)
|
||||
return dict(u) if u else None
|
||||
|
||||
def list(self):
|
||||
return [dict(u) for _, u in sorted(self.users.items())]
|
||||
|
||||
def create(self, user):
|
||||
self.users[user["username"]] = dict(user)
|
||||
|
||||
def update(self, username, **fields):
|
||||
self.users[username].update(fields)
|
||||
|
||||
def record(self, username, event, by=""):
|
||||
self.events.append((username, event, by or ""))
|
||||
|
||||
def history(self, limit=300):
|
||||
return [{"at": datetime(2026, 10, 22, 12, 0), "username": u,
|
||||
"event": e, "by_user": b or None}
|
||||
for u, e, b in reversed(self.events)][:limit]
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = datetime(2026, 10, 22, 9, 0, 0)
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
def _accounts():
|
||||
store, clock = FakeUserStore(), Clock()
|
||||
acc = users.Accounts(store, now=clock)
|
||||
acc.create_user("Asha", "Asha R", "Admin", "first-password-1")
|
||||
return acc, store, clock
|
||||
|
||||
|
||||
@case
|
||||
def the_real_iteration_count_is_at_least_600k():
|
||||
import importlib
|
||||
fresh = importlib.reload(users)
|
||||
try:
|
||||
assert fresh.ITERATIONS >= 600_000, fresh.ITERATIONS
|
||||
finally:
|
||||
fresh.ITERATIONS = 1_000
|
||||
|
||||
|
||||
@case
|
||||
def a_password_is_kept_only_as_a_salted_hash():
|
||||
acc, store, _ = _accounts()
|
||||
u = store.users["asha"]
|
||||
assert "first-password-1" not in str(u), u
|
||||
assert len(u["pw_hash"]) == 64 and len(u["pw_salt"]) == 32, u
|
||||
a, s1, _ = users.hash_password("same-password!")
|
||||
b, s2, _ = users.hash_password("same-password!")
|
||||
assert a != b and s1 != s2, "each hash has its own salt"
|
||||
|
||||
|
||||
@case
|
||||
def password_rules():
|
||||
assert users.password_problem("short", "x")
|
||||
assert users.password_problem("asha.r.123", "asha.r.123")
|
||||
assert users.password_problem("long-enough-1", "asha") is None
|
||||
|
||||
|
||||
@case
|
||||
def a_user_made_by_the_tool_signs_in_and_names_are_case_blind():
|
||||
acc, store, _ = _accounts()
|
||||
assert store.users["asha"]["must_change"] is False, "tool user"
|
||||
user, why = acc.sign_in(" ASHA ", "first-password-1")
|
||||
assert why == "ok" and user["username"] == "asha", why
|
||||
assert store.events[-1] == ("asha", "sign_in", "")
|
||||
|
||||
|
||||
@case
|
||||
def wrong_password_and_unknown_user_read_the_same():
|
||||
acc, store, _ = _accounts()
|
||||
assert acc.sign_in("asha", "wrong-password-x") == (None, "bad")
|
||||
assert acc.sign_in("nobody", "wrong-password-x") == (None, "bad")
|
||||
assert [e[1] for e in store.events[-2:]] == ["wrong_password"] * 2
|
||||
|
||||
|
||||
@case
|
||||
def five_wrong_passwords_lock_for_fifteen_minutes():
|
||||
acc, store, clock = _accounts()
|
||||
for _ in range(4):
|
||||
assert acc.sign_in("asha", "nope-nope-nope")[1] == "bad"
|
||||
assert acc.sign_in("asha", "nope-nope-nope")[1] == "locked"
|
||||
assert acc.sign_in("asha", "first-password-1")[1] == "locked", \
|
||||
"even the right password waits"
|
||||
clock.t += timedelta(minutes=15, seconds=1)
|
||||
assert acc.sign_in("asha", "first-password-1")[1] == "ok"
|
||||
assert store.users["asha"]["failed_count"] == 0
|
||||
|
||||
|
||||
@case
|
||||
def a_good_sign_in_clears_the_wrong_count():
|
||||
acc, store, _ = _accounts()
|
||||
for _ in range(4):
|
||||
acc.sign_in("asha", "nope-nope-nope")
|
||||
acc.sign_in("asha", "first-password-1")
|
||||
for _ in range(4):
|
||||
assert acc.sign_in("asha", "nope-nope-nope")[1] == "bad"
|
||||
|
||||
|
||||
@case
|
||||
def an_admin_made_user_must_change_and_a_reset_does_the_same():
|
||||
acc, store, _ = _accounts()
|
||||
acc.create_user("ravi", "Ravi", "Operator", "temp-password-1", by="asha")
|
||||
assert store.users["ravi"]["must_change"] is True
|
||||
assert store.users["ravi"]["created_by"] == "asha"
|
||||
acc.change_password("ravi", "temp-password-1", "ravis-own-pass")
|
||||
assert store.users["ravi"]["must_change"] is False
|
||||
acc.reset_password("ravi", "another-temp-1", by="asha")
|
||||
assert store.users["ravi"]["must_change"] is True
|
||||
assert ("ravi", "password_reset", "asha") in store.events
|
||||
|
||||
|
||||
@case
|
||||
def change_password_needs_the_current_one_and_a_new_one():
|
||||
acc, _, _ = _accounts()
|
||||
for current, new in (("wrong-current", "brand-new-pass"),
|
||||
("first-password-1", "first-password-1"),
|
||||
("first-password-1", "short")):
|
||||
try:
|
||||
acc.change_password("asha", current, new)
|
||||
raise AssertionError(f"accepted {current!r} -> {new!r}")
|
||||
except users.UserError:
|
||||
pass
|
||||
|
||||
|
||||
@case
|
||||
def bad_new_users_are_refused():
|
||||
acc, _, _ = _accounts()
|
||||
for args in (("a", "A", "Viewer", "long-enough-1"),
|
||||
("bad name", "B", "Viewer", "long-enough-1"),
|
||||
("bob", "B", "Boss", "long-enough-1"),
|
||||
("bob", "B", "Viewer", "short"),
|
||||
("asha", "A", "Viewer", "long-enough-1")):
|
||||
try:
|
||||
acc.create_user(*args, by="asha")
|
||||
raise AssertionError(f"accepted {args}")
|
||||
except users.UserError:
|
||||
pass
|
||||
|
||||
|
||||
@case
|
||||
def a_disabled_user_cannot_sign_in_and_is_never_deleted():
|
||||
acc, store, _ = _accounts()
|
||||
acc.create_user("ravi", "Ravi", "Viewer", "temp-password-1", by="asha")
|
||||
acc.set_disabled("ravi", True, by="asha")
|
||||
assert acc.sign_in("ravi", "temp-password-1") == (None, "disabled")
|
||||
assert "ravi" in store.users
|
||||
acc.set_disabled("ravi", False, by="asha")
|
||||
assert acc.sign_in("ravi", "temp-password-1")[1] == "ok"
|
||||
|
||||
|
||||
@case
|
||||
def the_last_admin_stays_and_nobody_disables_themselves():
|
||||
acc, _, _ = _accounts()
|
||||
for fn in (lambda: acc.set_role("asha", "Viewer", by="asha"),
|
||||
lambda: acc.set_disabled("asha", True, by="asha")):
|
||||
try:
|
||||
fn()
|
||||
raise AssertionError("allowed")
|
||||
except users.UserError:
|
||||
pass
|
||||
acc.create_user("ravi", "Ravi", "Admin", "temp-password-1", by="asha")
|
||||
acc.set_role("asha", "Operator", by="ravi")
|
||||
try:
|
||||
acc.set_disabled("ravi", True, by="asha")
|
||||
raise AssertionError("disabled the last Admin")
|
||||
except users.UserError:
|
||||
pass
|
||||
|
||||
|
||||
@case
|
||||
def sessions_time_out_after_thirty_idle_minutes():
|
||||
clock = Clock()
|
||||
s = users.Sessions(now=clock)
|
||||
tok = s.new({"username": "asha", "role": "Admin", "pw_hash": "x"})
|
||||
user, _ = s.get(tok)
|
||||
assert user["username"] == "asha" and "pw_hash" not in user, user
|
||||
clock.t += timedelta(minutes=29)
|
||||
assert s.get(tok)[0], "29 idle minutes is fine, and resets the clock"
|
||||
clock.t += timedelta(minutes=30, seconds=1)
|
||||
assert s.get(tok) == (None, "asha"), "timed out, says whose"
|
||||
assert s.get(tok) == (None, None), "gone"
|
||||
|
||||
|
||||
@case
|
||||
def a_users_sessions_can_be_ended_and_refreshed():
|
||||
s = users.Sessions()
|
||||
t1 = s.new({"username": "ravi", "role": "Operator"})
|
||||
t2 = s.new({"username": "asha", "role": "Admin"})
|
||||
s.refresh_user({"username": "ravi", "role": "Viewer"})
|
||||
assert s.get(t1)[0]["role"] == "Viewer"
|
||||
s.drop_user("ravi")
|
||||
assert s.get(t1) == (None, None) and s.get(t2)[0]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = 0
|
||||
for fn in CASES:
|
||||
name = fn.__name__.replace("_", " ")
|
||||
try:
|
||||
fn()
|
||||
print(f" ok {name}")
|
||||
except AssertionError as exc:
|
||||
print(f" FAIL {name}: {exc}")
|
||||
failures += 1
|
||||
except Exception as exc:
|
||||
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
||||
failures += 1
|
||||
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user