r""" The Portal's Sign-in, roles and Admin pages — `web/app.py` with `web/users.py` (spec 2026-10-22 Stage 2, EEWA-32), against the in-memory user store and the fake repository / runner (no SQL Server). python tests\test_portal_signin.py * Nothing but /signin without a Sign-in: a page goes to /signin (and back after), /api answers 401. * The Sign-in form needs this page's token and Origin; the cookie is HttpOnly + SameSite=Strict; `next` never leaves the site. * Wrong password / lock / disabled read as such; 30 minutes idle signs out (and is recorded). * A user an Admin made must change the password before anything else. * Roles: a Viewer sees no Start / Stop and is refused them; only an Admin reaches Portal Users and the Sign-in history. * Started By / Stopped By reach the runner as the signed-in username. * Admin changes take effect on the user's open session at once. """ import sys import pathlib import tempfile from datetime import timedelta ROOT = pathlib.Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "src")) sys.path.insert(0, str(ROOT / "tests")) from fastapi.testclient import TestClient # noqa: E402 from web import app as web_app # noqa: E402 from web import users as portal_users # noqa: E402 from test_portal_users import FakeUserStore, Clock # noqa: E402 from test_web_app import FakeRepo, FakeRunner # noqa: E402 CASES = [] BASE = "http://127.0.0.1:8000" ORIGIN = {"Origin": BASE} PW = "first-password-1" def case(fn): CASES.append(fn) return fn def _portal(runner=None, clock=None): """(app, store, runner) with users asha (Admin), olu (Operator) and vee (Viewer), all able to sign in straight away.""" store = FakeUserStore() acc = portal_users.Accounts(store) for name, role in (("asha", "Admin"), ("olu", "Operator"), ("vee", "Viewer")): acc.create_user(name, name.title(), role, PW) runner = runner or FakeRunner() sessions = portal_users.Sessions(now=clock) if clock else None app = web_app.create_app(FakeRepo(), runner=runner, users_store=store, sessions=sessions, logs_dir=tempfile.mkdtemp()) return app, store, runner def _sign_in(app, name, password=PW, next_url="/days"): client = TestClient(app, base_url=BASE) r = client.post("/signin", headers=ORIGIN, data={ "username": name, "password": password, "token": app.state.run_token, "next": next_url}, follow_redirects=False) return client, r def _form(app, **data): return {"token": app.state.run_token, **data} @case def nothing_opens_without_a_sign_in(): app, _, _ = _portal() c = TestClient(app, base_url=BASE) r = c.get("/day/2026-10-16?category=Fail", follow_redirects=False) assert r.status_code == 303, r.status_code assert r.headers["location"] == ("/signin?next=%2Fday%2F2026-10-16%3F" "category%3DFail"), r.headers for path in ("/api/runs", "/api/results", "/api/bot/status"): assert c.get(path).status_code == 401, path assert c.post("/api/bot/start", headers={**ORIGIN, "X-Run-Token": app.state.run_token}).status_code == 401 assert c.get("/signin").status_code == 200 @case def a_sign_in_goes_back_and_sets_a_strict_httponly_cookie(): app, store, _ = _portal() c, r = _sign_in(app, "ASHA", next_url="/day/2026-10-16") assert r.status_code == 303 and r.headers["location"] == "/day/2026-10-16" cookie = r.headers["set-cookie"].lower() assert "httponly" in cookie and "samesite=strict" in cookie, cookie assert c.get("/days").status_code == 200 assert ("asha", "sign_in", "") in store.events @case def next_never_leaves_the_site(): app, _, _ = _portal() for bad in ("//evil.example/x", "https://evil.example", "/\\evil", "javascript:alert(1)"): _, r = _sign_in(app, "asha", next_url=bad) assert r.headers["location"] == "/", (bad, r.headers["location"]) @case def the_sign_in_form_needs_the_token_and_origin(): app, _, _ = _portal() c = TestClient(app, base_url=BASE) data = {"username": "asha", "password": PW} assert c.post("/signin", headers=ORIGIN, data=data).status_code == 403 assert c.post("/signin", headers={"Origin": "http://evil.example"}, data=_form(app, **data)).status_code == 403 @case def a_wrong_password_says_so_and_five_lock(): app, store, _ = _portal() _, r = _sign_in(app, "asha", "wrong-password-x") assert r.status_code == 401 and "Wrong username or password" in r.text _, r = _sign_in(app, "nobody", "wrong-password-x") assert "Wrong username or password" in r.text, "same words, unknown user" for _ in range(4): _, r = _sign_in(app, "asha", "wrong-password-x") assert "locked for 15 minutes" in r.text, r.text _, r = _sign_in(app, "asha") assert r.status_code == 401 and "locked" in r.text @case def sign_out_ends_the_session(): app, store, _ = _portal() c, _ = _sign_in(app, "olu") assert c.post("/signout", headers=ORIGIN, data={"token": "wrong"}).status_code == 403 r = c.post("/signout", headers=ORIGIN, data=_form(app), follow_redirects=False) assert r.status_code == 303 and r.headers["location"] == "/signin" c.cookies.clear() assert c.get("/days", follow_redirects=False).status_code == 303 assert ("olu", "sign_out", "") in store.events @case def thirty_idle_minutes_sign_out_and_are_recorded(): clock = Clock() app, store, _ = _portal(clock=clock) c, _ = _sign_in(app, "vee") clock.t += timedelta(minutes=29) assert c.get("/days").status_code == 200 clock.t += timedelta(minutes=31) assert c.get("/days", follow_redirects=False).status_code == 303 assert ("vee", "timeout", "") in store.events @case def an_admin_made_user_must_change_the_password_first(): app, store, _ = _portal() portal_users.Accounts(store).create_user("newbie", "New", "Viewer", "temp-password-1", by="asha") c, r = _sign_in(app, "newbie", "temp-password-1") assert r.headers["location"] == "/account/password" assert c.get("/days", follow_redirects=False).headers["location"] \ == "/account/password" assert c.get("/api/runs").status_code == 403 r = c.post("/account/password", headers=ORIGIN, data=_form( app, current="temp-password-1", new="my-own-password", confirm="my-own-password"), follow_redirects=False) assert r.status_code == 303, r.text assert c.get("/days").status_code == 200 @case def a_password_change_with_a_mismatch_is_refused(): app, _, _ = _portal() c, _ = _sign_in(app, "vee") r = c.post("/account/password", headers=ORIGIN, data=_form( app, current=PW, new="new-password-1", confirm="new-password-2")) assert r.status_code == 400 and "not the same" in r.text @case def a_viewer_sees_no_start_and_is_refused_it(): app, _, runner = _portal() c, _ = _sign_in(app, "vee") page = c.get("/").text assert '