"""Sign-in, Me, sign-out, lock and unlock through the API, on PostgreSQL with the fake directory.""" from __future__ import annotations from fakedir import FakeDirectory from fastapi.testclient import TestClient from pshelpers import app_conn, needs_pg, sign_in from packetstation import audit from packetstation.settings import Settings pytestmark = needs_pg GENERIC = "Sign-in failed. Check the account name and password." NO_ROLE = "No role in Packet Station. Ask IT to add you to a group." def test_password_signin_and_me_shape(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None: r = sign_in(pg_client, "clerk.clara") assert r.status_code == 200, r.text cookie = r.headers["set-cookie"].lower() assert cookie.startswith("ps_session=") and "httponly" in cookie and "samesite=strict" in cookie assert "max-age" not in cookie and "secure" not in cookie # plain http in tests me = r.json() assert set(me) == {"user", "roles", "screens", "groups", "session", "pin", "can_register_device"} clara = fake_dir.user("clerk.clara") assert me["user"] == { "id": str(clara.guid), "sam_account": "clerk.clara", "upn": "clerk.clara@pstest.local", "display_name": "Clara Clerk", "emp_no": "4001", "email": "clerk.clara@example.test", } assert me["roles"] == ["clerical"] and me["screens"] == ["clerical"] and me["groups"] == ["PS-Clerical"] assert set(me["session"]) == { "id", "kind", "created_at", "expires_at", "locked", "locked_since", "lock_minutes", "device", } assert me["session"]["kind"] == "password" and me["session"]["locked"] is False assert me["session"]["lock_minutes"] == 60 # Clerical: the desk minutes (issue #45) assert me["session"]["device"] is None and me["session"]["locked_since"] is None assert me["pin"] == {"set": False, "disabled": False} and me["can_register_device"] is False assert pg_client.get("/api/auth/me").json() == me with app_conn(pg_settings) as conn: hours = conn.execute( "SELECT extract(epoch FROM expires_at - created_at) / 3600 FROM sessions WHERE id = %s", (me["session"]["id"],), ).fetchone()[0] assert float(hours) == 12 assert conn.execute("SELECT role::text, dept_code FROM user_roles").fetchall() == [("clerical", "CL")] ok = conn.execute("SELECT account, kind, ok FROM signin_attempts").fetchall() assert ok == [("clerk.clara", "password", True)] row = conn.execute("SELECT action, detail, role_snapshot FROM audit_events").fetchone() assert row[0] == "auth.signin" and row[1]["kind"] == "password" and row[2] == "clerical" assert conn.execute("SELECT last_signin_at IS NOT NULL FROM users").fetchone()[0] def test_roles_union_nested_and_upn(pg_client: TestClient) -> None: me = sign_in(pg_client, "multi.mia").json() assert me["roles"] == ["clerical", "shop:06:supervisor"] assert me["screens"] == ["clerical", "supervisor", "stall"] and me["can_register_device"] is True assert sign_in(pg_client, "nested.ned").json()["roles"] == ["shop:10:worker"] assert sign_in(pg_client, "both.bo@pstest.local").json()["roles"] == ["shop:03:supervisor"] admin = sign_in(pg_client, "ADMIN.ANN").json() assert admin["roles"] == ["admin"] and admin["screens"][-1] == "admin" and len(admin["screens"]) == 7 def test_failures_do_not_reveal_which(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None: for login, pw in (("clerk.clara", "wrong"), ("nobody.here", None), ("disabled.dan", None), ("clerk.clara", "")): r = sign_in(pg_client, login, pw) assert r.status_code == 401, login assert r.json() == {"detail": GENERIC, "code": "signin_failed"} assert "set-cookie" not in r.headers r = sign_in(pg_client, "norole.nick") assert r.status_code == 403 and r.json() == {"detail": NO_ROLE, "code": "no_role"} with app_conn(pg_settings) as conn: reasons = [r[0] for r in conn.execute("SELECT reason FROM signin_attempts ORDER BY id").fetchall()] assert reasons == ["bad_password", "no_such_user", "disabled", "empty", "no_role"] audited = conn.execute("SELECT detail->>'reason' FROM audit_events ORDER BY id").fetchall() assert [a[0] for a in audited] == ["disabled", "no_role"] assert conn.execute("SELECT count(*) FROM sessions").fetchone()[0] == 0 def test_account_rate_limit_before_bind(pg_client: TestClient, fake_dir: FakeDirectory) -> None: for _ in range(5): assert sign_in(pg_client, "weld.andy", "wrong").status_code == 401 binds = fake_dir.binds r = sign_in(pg_client, "weld.andy") # even the right password is refused now assert r.status_code == 429 body = r.json() assert body["code"] == "rate_limited" and 590 <= body["retry_after"] <= 600 assert r.headers["retry-after"] == str(body["retry_after"]) assert fake_dir.binds == binds # no bind while limited r = sign_in(pg_client, "weld.andy@pstest.local") # the UPN alias is limited too assert r.status_code == 429 and fake_dir.binds == binds assert sign_in(pg_client, "clerk.clara").status_code == 200 # other accounts are not def test_ldap_unavailable(pg_client: TestClient, fake_dir: FakeDirectory) -> None: fake_dir.down = True r = sign_in(pg_client, "clerk.clara") assert r.status_code == 503 and r.json()["code"] == "ldap_unavailable" def test_signout(pg_client: TestClient, pg_settings: Settings) -> None: sign_in(pg_client, "clerk.clara") r = pg_client.post("/api/auth/signout") assert r.status_code == 204 assert pg_client.get("/api/auth/me").json()["code"] == "no_session" with app_conn(pg_settings) as conn: assert conn.execute("SELECT revoked_reason FROM sessions").fetchone()[0] == "signout" def test_lock_unlock_and_audit_chain(pg_client: TestClient, pg_settings: Settings) -> None: sign_in(pg_client, "clerk.clara") assert pg_client.post("/api/auth/lock").status_code == 204 r = pg_client.get("/api/account") assert r.status_code == 423 and r.json()["code"] == "locked" me = pg_client.get("/api/auth/me").json() assert me["session"]["locked"] is True and me["session"]["locked_since"] r = pg_client.post("/api/auth/unlock", json={"factor": "password", "secret": "wrong"}) assert r.status_code == 403 and r.json()["code"] == "wrong_secret" and r.json()["attempts_left"] == 2 r = pg_client.post("/api/auth/unlock", json={"factor": "pin", "secret": "123456"}) assert r.status_code == 409 and r.json()["code"] == "pin_not_set" r = pg_client.post("/api/auth/unlock", json={"factor": "password", "secret": "fake-password-1"}) assert r.status_code == 200 and r.json()["session"]["locked"] is False assert pg_client.get("/api/account").status_code == 200 assert pg_client.post("/api/auth/visibility", json={"hidden": False}).status_code == 204 assert pg_client.get("/api/account").status_code == 200 assert pg_client.post("/api/auth/visibility", json={"hidden": True}).status_code == 204 assert pg_client.get("/api/account").status_code == 200 # hidden under a minute: no lock assert pg_client.post("/api/auth/signout").status_code == 204 with app_conn(pg_settings) as conn: actions = [r[0] for r in conn.execute("SELECT action FROM audit_events ORDER BY id").fetchall()] assert actions == ["auth.signin", "auth.lock", "auth.unlock", "auth.signout"] sources = conn.execute("SELECT detail->>'source' FROM audit_events WHERE action = 'auth.lock' ORDER BY id") assert [s[0] for s in sources.fetchall()] == ["manual"] ok, n, detail = audit.verify_chain(conn) assert ok and n == 4, detail def test_three_wrong_unlocks_end_the_session(pg_client: TestClient, pg_settings: Settings) -> None: sign_in(pg_client, "clerk.clara") pg_client.post("/api/auth/lock") for left in (2, 1): r = pg_client.post("/api/auth/unlock", json={"factor": "password", "secret": "wrong"}) assert r.json()["attempts_left"] == left r = pg_client.post("/api/auth/unlock", json={"factor": "password", "secret": "wrong"}) assert r.status_code == 401 and r.json()["code"] == "session_ended" assert pg_client.get("/api/auth/me").json()["code"] == "no_session" with app_conn(pg_settings) as conn: assert conn.execute("SELECT revoked_reason FROM sessions").fetchone()[0] == "stepup_failures" kinds = conn.execute("SELECT kind, account FROM signin_attempts WHERE NOT ok").fetchall() assert len(kinds) == 3 and all(k == "unlock" and a.startswith("user:") for k, a in kinds) assert audit.verify_chain(conn)[0] def test_departments(pg_client: TestClient) -> None: sign_in(pg_client, "weld.andy") deps = pg_client.get("/api/departments").json() assert [d["code"] for d in deps][:5] == ["CL", "WH", "QC", "PM", "01"] weld = next(d for d in deps if d["code"] == "03") assert {k: v for k, v in weld.items() if k != "stations"} == { "code": "03", "slug": "WeldParts", "name": "Weld Parts", "kind": "shop", "weld": True, } assert [s["name"] for s in weld["stations"]] == [f"Stall {i}" for i in range(1, 7)] assert all(isinstance(s["id"], int) for s in weld["stations"]) hydro = next(d for d in deps if d["code"] == "09") assert [s["name"] for s in hydro["stations"]] == ["Hydro Bay 1", "Hydro Bay 2"] and hydro["weld"] is False