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,315 @@
|
||||
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 '<button type="button" id="br-start" hidden>' in page
|
||||
assert "const CAN_RUN = false;" in page
|
||||
h = {**ORIGIN, "X-Run-Token": app.state.run_token}
|
||||
assert c.post("/api/bot/start", headers=h).status_code == 403
|
||||
assert c.post("/api/bot/stop", headers=h).status_code == 403
|
||||
assert runner.calls == []
|
||||
for path in ("/admin/users", "/admin/signins"):
|
||||
assert c.get(path).status_code == 403, path
|
||||
|
||||
|
||||
@case
|
||||
def started_by_and_stopped_by_are_the_signed_in_user():
|
||||
app, _, runner = _portal()
|
||||
c, _ = _sign_in(app, "olu")
|
||||
assert "const CAN_RUN = true;" in c.get("/").text
|
||||
h = {**ORIGIN, "X-Run-Token": app.state.run_token}
|
||||
assert c.post("/api/bot/start", headers=h).status_code == 200
|
||||
assert runner.started_by == "olu"
|
||||
assert c.post("/api/bot/stop", headers=h).status_code == 200
|
||||
assert runner.stopped_by == "olu"
|
||||
assert c.get("/admin/users").status_code == 403, "Operator is no Admin"
|
||||
|
||||
|
||||
@case
|
||||
def the_header_names_the_user_and_admin_links_are_admin_only():
|
||||
app, _, _ = _portal()
|
||||
a, _ = _sign_in(app, "asha")
|
||||
page = a.get("/runs").text
|
||||
assert "<b>Asha</b> · Admin" in page and 'href="/admin/users"' in page
|
||||
o, _ = _sign_in(app, "olu")
|
||||
assert 'href="/admin/users"' not in o.get("/runs").text
|
||||
|
||||
|
||||
@case
|
||||
def an_admin_adds_a_user_who_then_must_change():
|
||||
app, store, _ = _portal()
|
||||
c, _ = _sign_in(app, "asha")
|
||||
r = c.post("/admin/users", headers=ORIGIN, data=_form(
|
||||
app, username="Ravi", display_name="Ravi K", role="Operator",
|
||||
password="temp-password-1"))
|
||||
assert r.status_code == 200 and "ravi added" in r.text, r.text[:300]
|
||||
assert store.users["ravi"]["must_change"] is True
|
||||
assert ("ravi", "user_created", "asha") in store.events
|
||||
r = c.post("/admin/users", headers=ORIGIN, data=_form(
|
||||
app, username="x", display_name="", role="Viewer",
|
||||
password="temp-password-1"))
|
||||
assert r.status_code == 400 and "Username" in r.text
|
||||
|
||||
|
||||
@case
|
||||
def admin_changes_reach_an_open_session_at_once():
|
||||
app, store, _ = _portal()
|
||||
admin, _ = _sign_in(app, "asha")
|
||||
olu, _ = _sign_in(app, "olu")
|
||||
vee, _ = _sign_in(app, "vee")
|
||||
admin.post("/admin/users/olu/role", headers=ORIGIN,
|
||||
data=_form(app, role="Viewer"))
|
||||
assert "const CAN_RUN = false;" in olu.get("/").text, "role now Viewer"
|
||||
admin.post("/admin/users/vee/disable", headers=ORIGIN, data=_form(app))
|
||||
assert vee.get("/days", follow_redirects=False).status_code == 303
|
||||
_, r = _sign_in(app, "vee")
|
||||
assert r.status_code == 401 and "disabled" in r.text
|
||||
admin.post("/admin/users/olu/reset", headers=ORIGIN,
|
||||
data=_form(app, password="temp-password-2"))
|
||||
assert olu.get("/days", follow_redirects=False).status_code == 303
|
||||
assert store.users["olu"]["must_change"] is True
|
||||
|
||||
|
||||
@case
|
||||
def admin_forms_need_the_token():
|
||||
app, store, _ = _portal()
|
||||
c, _ = _sign_in(app, "asha")
|
||||
r = c.post("/admin/users/vee/disable", headers=ORIGIN,
|
||||
data={"token": "nope"})
|
||||
assert r.status_code == 403 and not store.users["vee"]["disabled"]
|
||||
|
||||
|
||||
@case
|
||||
def the_sign_in_history_lists_events_for_an_admin():
|
||||
app, _, _ = _portal()
|
||||
_sign_in(app, "vee", "wrong-password-x")
|
||||
c, _ = _sign_in(app, "asha")
|
||||
page = c.get("/admin/signins").text
|
||||
assert "wrong password" in page and "vee" in page
|
||||
|
||||
|
||||
@case
|
||||
def with_no_users_the_sign_in_page_says_how_to_add_one():
|
||||
app = web_app.create_app(FakeRepo(), runner=FakeRunner(),
|
||||
users_store=FakeUserStore(),
|
||||
logs_dir=tempfile.mkdtemp())
|
||||
page = TestClient(app, base_url=BASE).get("/signin").text
|
||||
assert "add_portal_user.py" in page
|
||||
|
||||
|
||||
@case
|
||||
def no_signed_in_page_carries_an_inline_style():
|
||||
app, _, _ = _portal()
|
||||
c, _ = _sign_in(app, "asha")
|
||||
for path in ("/signin", "/account/password", "/admin/users",
|
||||
"/admin/signins"):
|
||||
assert " style=" not in c.get(path).text, path
|
||||
|
||||
|
||||
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