182 lines
9.4 KiB
Python
182 lines
9.4 KiB
Python
"""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": "[email protected]",
|
||
|
|
"display_name": "Clara Clerk",
|
||
|
|
"emp_no": "4001",
|
||
|
|
"email": "[email protected]",
|
||
|
|
}
|
||
|
|
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, "[email protected]").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, "[email protected]") # 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
|