""" 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())