Phase 1: identity, sessions, audit, frontend shell

AD sign-in over LDAPS (search with the service account, bind as the user,
nested groups through LDAP_MATCHING_RULE_IN_CHAIN, disabled accounts refused),
roles as the union of PS-* groups mapped by a table derived from the seeded
departments. Server-side sessions with idle, lock and absolute timeouts, Origin
and X-Requested-With check, step-up unlock. Optional Argon2id PIN set with the
domain password; PIN sign-in by employee ID on registered devices only; five
wrong attempts disable the PIN, three wrong at unlock or signature end the
session. Rate limits per account and per address before any bind. Devices,
signatures table and sign helper, audit rows for every identity event, SSE over
LISTEN/NOTIFY with presence. Account settings and Admin APIs and screens
(AD view, devices, PIN clearing, group map with missing-group flags, refresh
from AD now). CLI ad-groups --list and ad-refresh. Migration 0002.

Frontend: real sign-in (password tab, PIN keypad on registered devices), lock
screen, role switcher, Account settings, Admin shell, screen placeholders.

CI: Samba AD DC container (PSTEST.LOCAL) with a generated CA, 49 of the 50
groups plus one extra, 17 test users; wired into the CI compose overlay and
the Gitea workflow; Playwright sign-in scenario.

Verified on docker-1: 171 server tests green inside the CI stack against
PostgreSQL and the Samba DC, 48 frontend tests, 8 Playwright scenarios against
the live stack (password and PIN sign-in, lock and unlock, Admin group map).
Findings recorded in the docs: the DC needs CAP_SYS_ADMIN, a hidden tab locks
only after a minute as the old build did,
and the app uses the alias ps-app for Playwright because "app" is on
Chromium's HSTS preload list.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 07:40:29 -06:00
co-authored by Claude Fable 5.1
parent 0b663bb2b2
commit 97b0973130
95 changed files with 9609 additions and 183 deletions
+17 -1
View File
@@ -7,7 +7,9 @@ from collections.abc import Iterator
from pathlib import Path
import pytest
from pshelpers import PG_URL, fake_visual, make_settings
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, PG_URL, fake_visual, make_settings
from packetstation.settings import Settings
@@ -55,3 +57,17 @@ def pg_settings(tmp_path: Path) -> Iterator[Settings]:
db.close_pool()
with psycopg.connect(PG_URL, autocommit=True) as su:
su.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(name)))
@pytest.fixture
def fake_dir() -> FakeDirectory:
return FakeDirectory()
@pytest.fixture
def pg_client(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
"""The app on a fresh migrated database with the fake directory; Origin headers set on every call."""
from packetstation.main import create_app
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as client:
yield client
+140
View File
@@ -0,0 +1,140 @@
"""An in-memory DirectoryProtocol for tests. Passwords here are obviously fake fixture values."""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from packetstation.auth.groups import group_names
from packetstation.auth.ldap import AdUser, LdapUnavailable
FAKE_PASSWORD = "fake-password-1"
NESTED_GROUP = "Shop-Welders-Nested"
@dataclass
class FakeUser:
sam: str
display: str
emp_no: str | None
groups: list[str]
password: str = FAKE_PASSWORD
disabled: bool = False
guid: uuid.UUID = field(default_factory=uuid.uuid4)
@property
def dn(self) -> str:
return f"CN={self.display},OU=Users,OU=PESCO_FARMINGTON,DC=pstest,DC=local"
@property
def upn(self) -> str:
return f"{self.sam}@pstest.local"
def default_users() -> list[FakeUser]:
"""The same people as the CI domain controller (deploy/samba)."""
return [
FakeUser("admin.ann", "Ann Admin", "1001", ["PS-Admins"]),
FakeUser("clerk.clara", "Clara Clerk", "4001", ["PS-Clerical"]),
FakeUser("clerk.leo", "Leo Lead", "4002", ["PS-Clerical-Supervisor"]),
FakeUser("wh.wade", "Wade Warehouse", "6001", ["PS-Warehouse"]),
FakeUser("qc.quinn", "Quinn QC", "5001", ["PS-QC"]),
FakeUser("pm.paula", "Paula PM", "7001", ["PS-ProjectMgmt"]),
FakeUser("weld.andy", "Andy Welder", "3003", ["PS-03-WeldParts-Worker"]),
FakeUser("lead.lou", "Lou Lead", "3002", ["PS-03-WeldParts-Lead"]),
FakeUser("sup.wes", "Wes Super", "2003", ["PS-03-WeldParts-Supervisor"]),
FakeUser("multi.mia", "Mia Multi", "2006", ["PS-Clerical", "PS-06-CodeBldg2-Supervisor"]),
FakeUser("nested.ned", "Ned Nested", "3010", [NESTED_GROUP]),
FakeUser("both.bo", "Bo Both", "3011", ["PS-03-WeldParts-Worker", "PS-03-WeldParts-Supervisor"]),
FakeUser("disabled.dan", "Dan Disabled", "3004", ["PS-03-WeldParts-Worker"], disabled=True),
FakeUser("noemp.nora", "Nora NoEmp", None, ["PS-Warehouse"]),
FakeUser("dup.dave", "Dave Dup", "9001", ["PS-03-WeldParts-Worker"]),
FakeUser("dup.dina", "Dina Dup", "9001", ["PS-Warehouse"]),
FakeUser("norole.nick", "Nick NoRole", "8001", []),
]
class FakeDirectory:
"""Users, groups in the PacketStation OU, and nested groups outside it."""
def __init__(
self,
users: list[FakeUser] | None = None,
ou_groups: list[str] | None = None,
nested: dict[str, list[str]] | None = None,
):
self.users = users if users is not None else default_users()
if ou_groups is None:
ou_groups = [g for g in group_names() if g != "PS-14-Electrical-Lead"] + ["PS-Extra-Legacy"]
self.ou_groups = ou_groups
self.nested = nested if nested is not None else {NESTED_GROUP: ["PS-10-WeldAssembly-Worker"]}
self.down = False
self.binds = 0
self.searches = 0
# ------------------------------------------------ helpers
def user(self, sam: str) -> FakeUser:
return next(u for u in self.users if u.sam == sam)
def _check(self) -> None:
if self.down:
raise LdapUnavailable("fake directory is down")
self.searches += 1
def _ad(self, u: FakeUser) -> AdUser:
return AdUser(u.guid, u.dn, u.sam, u.upn, u.display, u.emp_no, f"{u.sam}@example.test", u.disabled)
def _closure(self, u: FakeUser) -> set[str]:
seen: set[str] = set()
todo = list(u.groups)
while todo:
g = todo.pop()
if g.lower() in seen:
continue
seen.add(g.lower())
todo.extend(self.nested.get(g, []))
return seen
def _in_ou(self, names: set[str]) -> list[str]:
return sorted(g for g in self.ou_groups if g.lower() in names)
# ------------------------------------------------ protocol
def find_user(self, login: str) -> AdUser | None:
self._check()
key = login.strip().lower()
for u in self.users:
if key in (u.sam.lower(), u.upn.lower()):
return self._ad(u)
return None
def get_user_by_guid(self, guid: uuid.UUID) -> AdUser | None:
self._check()
for u in self.users:
if u.guid == guid:
return self._ad(u)
return None
def verify_password(self, dn: str, password: str) -> bool:
if self.down:
raise LdapUnavailable("fake directory is down")
if not password:
return False
self.binds += 1
return any(u.dn == dn and u.password == password for u in self.users)
def groups_of(self, dn: str) -> list[str]:
self._check()
u = next((x for x in self.users if x.dn == dn), None)
return [] if u is None else self._in_ou(self._closure(u))
def list_groups(self) -> list[str]:
self._check()
return sorted(self.ou_groups)
def members_of(self, group_cn: str) -> list[AdUser]:
self._check()
if group_cn.lower() not in {g.lower() for g in self.ou_groups}:
return []
return [self._ad(u) for u in self.users if group_cn.lower() in self._closure(u)]
+53
View File
@@ -11,8 +11,18 @@ import pytest
from packetstation.settings import Settings
TEST_SESSION_KEY = "test-session-key-0123456789abcdef0123456789"
ORIGIN_HEADERS = {"Origin": "http://testserver", "X-Requested-With": "PacketStation"}
def make_settings(tmp: Path, **over: Any) -> Settings:
"""Settings for tests. Writes a throwaway session key file unless `session_key_file` is given."""
if "session_key_file" not in over:
key_file = tmp / "session_key"
if not key_file.exists():
key_file.parent.mkdir(parents=True, exist_ok=True)
key_file.write_text(TEST_SESSION_KEY + "\n")
over["session_key_file"] = key_file
values: dict[str, Any] = {
"_env_file": None,
"stack": "ci",
@@ -37,3 +47,46 @@ def fake_visual(production: bool = False, explicit: bool = False) -> SimpleNames
PG_URL = os.environ.get("TEST_PG_SUPERUSER_URL")
needs_pg = pytest.mark.skipif(not PG_URL, reason="TEST_PG_SUPERUSER_URL not set (no PostgreSQL)")
def sign_in(client: Any, login: str, password: str | None = None) -> Any:
"""Password sign-in through the API (FakeDirectory password by default); returns the response."""
from fakedir import FAKE_PASSWORD
return client.post(
"/api/auth/signin/password", json={"login": login, "password": FAKE_PASSWORD if password is None else password}
)
def app_conn(settings: Settings) -> Any:
"""An autocommit connection as the app role (for test setup and checks)."""
from packetstation import db
return db.connect_as("app", settings, autocommit=True)
PIN = "246810"
def register_device(client: Any, sam: str = "sup.wes", name: str = "Bldg 2 tablet 3") -> dict[str, str]:
assert sign_in(client, sam).status_code == 200
r = client.post("/api/account/device", json={"name": name})
assert r.status_code == 200, r.text
assert "ps_device=" in r.headers["set-cookie"] and "max-age=31536000" in r.headers["set-cookie"].lower()
assert client.post("/api/auth/signout").status_code == 204
device: dict[str, str] = r.json()["device"]
return device
def set_pin(client: Any, sam: str, pin: str = PIN) -> None:
from fakedir import FAKE_PASSWORD
assert sign_in(client, sam).status_code == 200
r = client.post("/api/account/pin", json={"password": FAKE_PASSWORD, "pin": pin})
assert r.status_code == 200, r.text
assert r.json() == {"pin": {"set": True, "disabled": False}}
assert client.post("/api/auth/signout").status_code == 204
def pin_signin(client: Any, emp_no: str, pin: str = PIN) -> Any:
return client.post("/api/auth/signin/pin", json={"emp_no": emp_no, "pin": pin})
+153
View File
@@ -0,0 +1,153 @@
"""Admin routes: users, AD view, PIN clearing, devices, group map, AD refresh, attempts."""
from __future__ import annotations
from fakedir import FAKE_PASSWORD, FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, PIN, app_conn, needs_pg, pin_signin, register_device, set_pin, sign_in
from packetstation.main import create_app
from packetstation.settings import Settings
pytestmark = needs_pg
def test_admin_only(pg_client: TestClient) -> None:
sign_in(pg_client, "clerk.clara")
for path in ("/api/admin/users", "/api/admin/devices", "/api/admin/groups", "/api/admin/signin-attempts"):
r = pg_client.get(path)
assert r.status_code == 403 and r.json()["code"] == "forbidden", path
assert pg_client.post("/api/admin/ad/refresh").status_code == 403
def test_group_map_flags_missing_and_extra(pg_client: TestClient, fake_dir: FakeDirectory) -> None:
sign_in(pg_client, "admin.ann")
view = pg_client.get("/api/admin/groups").json()
assert view["ad_reachable"] is True and view["checked_at"]
assert len(view["groups"]) == 50
missing = [g["name"] for g in view["groups"] if g["in_ad"] is False]
assert missing == ["PS-14-Electrical-Lead"]
assert view["extra_in_ou"] == ["PS-Extra-Legacy"]
first = view["groups"][0]
assert first == {"name": "PS-Admins", "role": "admin", "dept_code": None, "in_ad": True}
weld = next(g for g in view["groups"] if g["name"] == "PS-03-WeldParts-Lead")
assert weld["role"] == "shop:03:lead" and weld["dept_code"] == "03"
def test_ad_refresh_counts_and_flags(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
sign_in(pg_client, "admin.ann")
res = pg_client.post("/api/admin/ad/refresh").json()
assert res == {
"groups_checked": 49,
"groups_missing": ["PS-14-Electrical-Lead"],
"users_seen": 16,
"created": 15,
"updated": 1,
"deactivated": 0,
"emp_missing": ["noemp.nora"],
"emp_duplicate": ["9001"],
}
users = {u["sam_account"]: u for u in pg_client.get("/api/admin/users").json()}
assert "norole.nick" not in users and len(users) == 16
assert users["dup.dave"]["flags"]["emp_duplicate"] and users["dup.dina"]["flags"]["emp_duplicate"]
assert users["noemp.nora"]["flags"]["emp_missing"] and users["noemp.nora"]["emp_no"] is None
assert users["disabled.dan"]["active"] is False and users["disabled.dan"]["flags"]["disabled"]
assert users["nested.ned"]["roles"] == ["shop:10:worker"]
assert users["both.bo"]["roles"] == ["shop:03:supervisor"]
assert users["multi.mia"]["roles"] == ["clerical", "shop:06:supervisor"]
assert [u["sam_account"] for u in pg_client.get("/api/admin/users", params={"q": "dup"}).json()] == [
"dup.dave",
"dup.dina",
]
# Clara signs in elsewhere, then AD disables her; the next refresh deactivates her and ends the session
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as other:
sign_in(other, "clerk.clara")
fake_dir.user("clerk.clara").disabled = True
fake_dir.users.remove(fake_dir.user("wh.wade")) # gone from AD
fake_dir.user("qc.quinn").groups = [] # still in AD, in no group
res = pg_client.post("/api/admin/ad/refresh").json()
assert res["deactivated"] == 2 and res["users_seen"] == 14
assert other.get("/api/auth/me").json()["code"] == "no_session"
users = {u["sam_account"]: u for u in pg_client.get("/api/admin/users").json()}
assert users["clerk.clara"]["active"] is False and users["wh.wade"]["active"] is False
assert users["qc.quinn"]["active"] is True and users["qc.quinn"]["roles"] == []
with app_conn(pg_settings) as conn:
acts = conn.execute("SELECT action FROM audit_events WHERE action LIKE 'ad.%' ORDER BY id").fetchall()
assert [a[0] for a in acts] == ["ad.refresh", "ad.user_deactivated", "ad.user_deactivated", "ad.refresh"]
def test_user_detail_and_lookup(pg_client: TestClient, fake_dir: FakeDirectory) -> None:
sign_in(pg_client, "admin.ann")
pg_client.post("/api/admin/ad/refresh")
mia = next(u for u in pg_client.get("/api/admin/users", params={"q": "mia"}).json())
detail = pg_client.get(f"/api/admin/users/{mia['id']}").json()
assert detail["groups"] == ["PS-06-CodeBldg2-Supervisor", "PS-Clerical"] and detail["groups_source"] == "ad"
assert detail["note"] == "Roles come from AD groups. Ask IT to change them." and detail["sessions"] == []
fake_dir.down = True
cached = pg_client.get(f"/api/admin/users/{mia['id']}").json()
assert cached["groups_source"] == "cache" and set(cached["groups"]) == set(detail["groups"])
assert pg_client.get("/api/admin/ad/lookup", params={"login": "mia"}).status_code == 503
fake_dir.down = False
found = pg_client.get("/api/admin/ad/lookup", params={"login": "norole.nick"}).json()
assert found["found"] is True and found["roles"] == [] and found["user"]["emp_no"] == "8001"
assert pg_client.get("/api/admin/ad/lookup", params={"login": "ghost"}).json()["found"] is False
assert pg_client.get("/api/admin/users/00000000-0000-0000-0000-000000000000").status_code == 404
def test_clear_pin(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
set_pin(pg_client, "weld.andy")
andy = str(fake_dir.user("weld.andy").guid)
sign_in(pg_client, "admin.ann")
r = pg_client.request("DELETE", f"/api/admin/users/{andy}/pin", json={"reason": " "})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
r = pg_client.request("DELETE", f"/api/admin/users/{andy}/pin", json={"reason": "Forgot it, ticket 4411"})
assert r.status_code == 204
assert pg_client.request("DELETE", f"/api/admin/users/{andy}/pin", json={"reason": "again"}).status_code == 404
with app_conn(pg_settings) as conn:
row = conn.execute(
"SELECT user_id, subject_id, it_reason FROM audit_events WHERE action = 'pin.cleared'"
).fetchone()
assert str(row[0]) == str(fake_dir.user("admin.ann").guid) and row[1] == andy
assert row[2] == "Forgot it, ticket 4411"
sign_in(pg_client, "weld.andy")
assert pg_client.get("/api/account").json()["pin"] == {"set": False, "disabled": False}
assert any(e["action"] == "pin.cleared" for e in pg_client.get("/api/account/access-log").json())
def test_devices_and_revoke_ends_pin_sessions(
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory
) -> None:
device = register_device(pg_client)
set_pin(pg_client, "admin.ann")
assert pin_signin(pg_client, "1001").status_code == 200
# Admin is a password session only
r = pg_client.get("/api/admin/users")
assert r.status_code == 403 and r.json()["code"] == "password_session_required"
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as admin:
sign_in(admin, "admin.ann")
listed = admin.get("/api/admin/devices").json()
assert len(listed) == 1 and listed[0]["id"] == device["id"] and listed[0]["active_sessions"] == 1
assert listed[0]["registered_by"]["display_name"] == "Wes Super"
assert admin.delete(f"/api/admin/devices/{device['id']}").status_code == 204
assert admin.get("/api/admin/devices").json()[0]["revoked_at"] is not None
assert admin.get("/api/auth/me").status_code == 200 # the password session is not on the device
assert pg_client.get("/api/auth/me").json()["code"] == "no_session"
assert pg_client.get("/api/auth/context").json()["device"] is None
r = pin_signin(pg_client, "1001")
assert r.status_code == 403 and r.json()["code"] == "device_required"
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT revoked_reason FROM sessions WHERE kind = 'pin'").fetchone()[0] == "device_revoked"
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'device.revoked'").fetchone()[0] == 1
def test_signin_attempts_list(pg_client: TestClient) -> None:
sign_in(pg_client, "clerk.clara", "wrong")
sign_in(pg_client, "admin.ann", FAKE_PASSWORD)
rows = pg_client.get("/api/admin/signin-attempts").json()
assert len(rows) == 1 and rows[0]["account"] == "clerk.clara" and rows[0]["reason"] == "bad_password"
assert set(rows[0]) == {"id", "at", "account", "ip", "kind", "reason", "user_id", "display_name"}
assert FAKE_PASSWORD not in str(rows) and PIN not in str(rows)
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import asyncio
import json
import uuid
from pshelpers import needs_pg
from packetstation import db, events
from packetstation.settings import Settings
def _sub(hub: events.EventHub, name: str, screen: str | None = None) -> events.Subscriber:
return hub.subscribe(session_id=uuid.uuid4(), user_id=uuid.uuid4(), display_name=name, screen=screen)
async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]:
await asyncio.sleep(0) # let call_soon_threadsafe callbacks run
out = []
while not q.empty():
out.append(q.get_nowait())
return out
def test_fan_out_presence_and_session_targeting() -> None:
async def run() -> None:
hub = events.EventHub()
a = _sub(hub, "Ann", "admin")
b = _sub(hub, "Bo", "stall")
got_a = await _drain(a.queue)
assert [n for n, _ in got_a] == ["presence", "presence"]
assert got_a[-1][1] == {
"users": [
{"user_id": a.user_id, "display_name": "Ann", "screen": "admin"},
{"user_id": b.user_id, "display_name": "Bo", "screen": "stall"},
]
}
await _drain(b.queue)
hub._deliver(json.dumps({"event": "changed", "collection": "packets", "id": 7}))
hub._deliver(json.dumps({"collection": "projects"})) # no event name means changed
hub._deliver("not json")
for q in (a.queue, b.queue):
assert await _drain(q) == [
("changed", {"collection": "packets", "id": 7}),
("changed", {"collection": "projects"}),
]
hub._deliver(json.dumps({"event": "session", "session_id": b.session_id, "action": "revoked"}))
assert await _drain(a.queue) == []
assert await _drain(b.queue) == [("session", {"session_id": b.session_id, "action": "revoked"})]
hub.unsubscribe(b)
got = await _drain(a.queue)
assert got == [("presence", {"users": [{"user_id": a.user_id, "display_name": "Ann", "screen": "admin"}]})]
asyncio.run(run())
def test_sse_stream_hello_ping_and_end_on_own_revoke() -> None:
async def run() -> list[str]:
hub = events.EventHub()
sub = _sub(hub, "Ann")
await _drain(sub.queue)
async def connected() -> bool:
return False
gen = events.stream(
hub, sub, hello={"session_id": sub.session_id, "build": "t"}, is_disconnected=connected, ping_seconds=0.05
)
chunks = [await gen.__anext__(), await gen.__anext__()]
hub._deliver(json.dumps({"event": "changed", "collection": "packets"}))
chunks.append(await gen.__anext__())
hub._deliver(json.dumps({"event": "session", "session_id": sub.session_id, "action": "revoked"}))
chunks.append(await gen.__anext__())
rest = [c async for c in gen]
assert rest == []
assert hub.presence() == {"users": []}
return chunks
chunks = asyncio.run(run())
assert chunks[0].startswith("event: hello\ndata: {") and '"build":"t"' in chunks[0]
assert chunks[1] == ": ping\n\n"
assert chunks[2] == 'event: changed\ndata: {"collection":"packets"}\n\n'
assert chunks[3].startswith("event: session\n")
@needs_pg
def test_pg_notify_round_trip(pg_settings: Settings) -> None:
async def run() -> events.Item:
hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True))
sub = _sub(hub, "Ann")
await _drain(sub.queue)
hub.start()
try:
for _ in range(100):
if hub.connected:
break
await asyncio.sleep(0.05)
assert hub.connected
with db.connect_as("app", pg_settings, autocommit=True) as conn:
events.publish(conn, {"event": "changed", "collection": "packets", "id": "7A"})
return await asyncio.wait_for(sub.queue.get(), timeout=5)
finally:
await asyncio.to_thread(hub.stop)
assert asyncio.run(run()) == ("changed", {"collection": "packets", "id": "7A"})
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from pathlib import Path
import pytest
from packetstation import cli
from packetstation.auth import groups
GROUPS_TXT = Path(__file__).resolve().parents[2] / "deploy" / "samba" / "groups.txt"
def test_fifty_names_in_contract_order() -> None:
names = groups.group_names()
assert len(names) == 50 == len(set(names))
assert names[:8] == [
"PS-Admins",
"PS-Clerical",
"PS-Clerical-Supervisor",
"PS-Warehouse",
"PS-Warehouse-Supervisor",
"PS-QC",
"PS-QC-Supervisor",
"PS-ProjectMgmt",
]
assert names[8:11] == ["PS-01-Layout-Worker", "PS-01-Layout-Lead", "PS-01-Layout-Supervisor"]
assert names[-1] == "PS-14-Electrical-Supervisor"
def test_cli_list_equals_committed_file(capsys: pytest.CaptureFixture[str]) -> None:
assert cli.main(["ad-groups", "--list"]) == 0
out = capsys.readouterr().out
assert out == GROUPS_TXT.read_text(encoding="utf-8")
def test_role_for_group_is_case_insensitive() -> None:
assert str(groups.role_for_group("ps-03-weldparts-LEAD")) == "shop:03:lead"
assert str(groups.role_for_group("PS-Admins")) == "admin"
assert groups.role_for_group("PS-Admins").dept_code is None # type: ignore[union-attr]
assert str(groups.role_for_group("PS-QC-Supervisor")) == "qc_supervisor"
assert groups.role_for_group("PS-Extra-Legacy") is None
assert groups.role_for_group("PS-ProjectMgmt-Supervisor") is None
def test_roles_union_and_highest_shop_level() -> None:
assert groups.roles_from_groups(["PS-03-WeldParts-Worker", "PS-03-WeldParts-Supervisor"]) == ["shop:03:supervisor"]
assert groups.roles_from_groups(["PS-Clerical", "PS-06-CodeBldg2-Supervisor", "x"]) == [
"clerical",
"shop:06:supervisor",
]
assert groups.roles_from_groups(["PS-Clerical", "PS-Clerical-Supervisor"]) == ["clerical", "clerical_supervisor"]
assert groups.roles_from_groups(["PS-10-WeldAssembly-Lead", "PS-03-WeldParts-Worker"]) == [
"shop:03:worker",
"shop:10:lead",
]
assert groups.roles_from_groups([]) == []
assert groups.roles_from_groups(["Domain Users"]) == []
def test_screens() -> None:
assert groups.screens_for(["clerical", "shop:03:supervisor"]) == ["clerical", "supervisor", "stall"]
assert groups.screens_for(["shop:03:worker"]) == ["stall"]
assert groups.screens_for(["shop:03:lead"]) == ["supervisor", "stall"]
assert groups.screens_for(["warehouse_supervisor", "qc", "pm"]) == ["warehouse", "qc", "pm"]
assert groups.screens_for(["admin"]) == list(groups.SCREENS)
@pytest.mark.parametrize("text", ["admin", "clerical", "qc_supervisor", "pm", "shop:03:worker", "shop:14:supervisor"])
def test_parse_role_round_trip(text: str) -> None:
assert str(groups.parse_role(text)) == text
@pytest.mark.parametrize("text", ["", "shop:03", "shop:03:boss", "root", "shop::lead"])
def test_parse_role_rejects(text: str) -> None:
with pytest.raises(ValueError):
groups.parse_role(text)
def test_departments_mirror_and_custom_map() -> None:
assert [d.code for d in groups.DEPARTMENTS][:4] == ["CL", "WH", "QC", "PM"]
custom = groups.GroupMap([*groups.DEPARTMENTS, groups.Department("15", "Machining", "Machining", "shop", 190)])
assert custom.names()[-3:] == ["PS-15-Machining-Worker", "PS-15-Machining-Lead", "PS-15-Machining-Supervisor"]
assert len(custom.names()) == 53
assert groups.is_shop_supervisor(["shop:01:supervisor"]) and not groups.is_shop_supervisor(["shop:01:lead"])
+133
View File
@@ -0,0 +1,133 @@
"""Against the CI Samba domain controller (deploy/samba). Skipped unless the TEST_LDAP_* values are set.
TEST_LDAP_URL, TEST_LDAP_BASE_DN, TEST_LDAP_BIND_DN, TEST_LDAP_PASSWORD_FILE, TEST_LDAP_GROUP_BASE_DN,
TEST_LDAP_CA_BUNDLE and TEST_DC_USERS_FILE (the users.json the DC seed writes, with per-run passwords).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, app_conn, make_settings, needs_pg
from packetstation.auth import groups
from packetstation.auth.directory import refresh_from_ad
from packetstation.auth.ldap import Directory
from packetstation.settings import Settings
ENV = (
"TEST_LDAP_URL",
"TEST_LDAP_BASE_DN",
"TEST_LDAP_BIND_DN",
"TEST_LDAP_PASSWORD_FILE",
"TEST_LDAP_GROUP_BASE_DN",
"TEST_LDAP_CA_BUNDLE",
"TEST_DC_USERS_FILE",
)
MISSING = [k for k in ENV if not os.environ.get(k)]
pytestmark = pytest.mark.skipif(bool(MISSING), reason=f"no test domain controller ({', '.join(MISSING)} not set)")
def ldap_values() -> dict[str, Any]:
return {
"ldap_url": os.environ["TEST_LDAP_URL"],
"ldap_base_dn": os.environ["TEST_LDAP_BASE_DN"],
"ldap_bind_dn": os.environ["TEST_LDAP_BIND_DN"],
"ldap_password_file": Path(os.environ["TEST_LDAP_PASSWORD_FILE"]),
"ldap_group_base_dn": os.environ["TEST_LDAP_GROUP_BASE_DN"],
"ldap_ca_bundle": Path(os.environ["TEST_LDAP_CA_BUNDLE"]),
"ldap_starttls": os.environ.get("TEST_LDAP_STARTTLS", "").lower() == "true",
}
@pytest.fixture(scope="module")
def dc_users() -> dict[str, dict[str, str]]:
data = json.loads(Path(os.environ["TEST_DC_USERS_FILE"]).read_text(encoding="utf-8"))
users: dict[str, dict[str, str]] = data["users"]
return users
@pytest.fixture
def directory(tmp_path: Path) -> Directory:
return Directory(make_settings(tmp_path, **ldap_values()))
def roles_of(d: Directory, login: str) -> list[str]:
u = d.find_user(login)
assert u is not None, login
return groups.roles_from_groups(d.groups_of(u.dn))
def test_password_bind(directory: Directory, dc_users: dict[str, dict[str, str]]) -> None:
u = directory.find_user("clerk.clara")
assert u is not None and not u.disabled and u.emp_no == "4001" and u.display_name == "Clara Clerk"
assert directory.verify_password(u.dn, dc_users["clerk.clara"]["password"]) is True
assert directory.verify_password(u.dn, "not-the-password") is False
assert directory.verify_password(u.dn, "") is False
assert roles_of(directory, "clerk.clara") == ["clerical"]
again = directory.get_user_by_guid(u.guid)
assert again is not None and again.sam_account == "clerk.clara"
def test_nested_and_levels(directory: Directory) -> None:
assert roles_of(directory, "nested.ned") == ["shop:10:worker"]
assert roles_of(directory, "both.bo") == ["shop:03:supervisor"]
assert roles_of(directory, "multi.mia") == ["clerical", "shop:06:supervisor"]
assert roles_of(directory, "norole.nick") == []
def test_disabled_account(directory: Directory, dc_users: dict[str, dict[str, str]]) -> None:
u = directory.find_user("disabled.dan")
assert u is not None and u.disabled is True
assert directory.verify_password(u.dn, dc_users["disabled.dan"]["password"]) is False
def test_group_listing_and_members(directory: Directory) -> None:
found = directory.list_groups()
lower = {g.lower() for g in found}
assert "ps-14-electrical-lead" not in lower and "ps-extra-legacy" in lower
assert sum(1 for n in groups.group_names() if n.lower() in lower) == 49
sams = {u.sam_account for u in directory.members_of("PS-03-WeldParts-Worker")}
assert {"weld.andy", "both.bo", "dup.dave"} <= sams
assert directory.members_of("PS-No-Such-Group") == []
@needs_pg
def test_refresh_flags_duplicates(pg_settings: Settings, directory: Directory) -> None:
with app_conn(pg_settings) as conn:
res = refresh_from_ad(conn, directory)
assert res.groups_missing == ["PS-14-Electrical-Lead"]
assert "9001" in res.emp_duplicate and "noemp.nora" in res.emp_missing
@needs_pg
def test_signin_api_against_dc(
pg_settings: Settings, tmp_path: Path, dev_visual: None, dc_users: dict[str, dict[str, str]]
) -> None:
from packetstation.main import create_app
s = make_settings(
tmp_path / "dc",
**ldap_values(),
**{k: getattr(pg_settings, k) for k in ("pg_host", "pg_port", "pg_database")},
pg_app_password_file=pg_settings.pg_app_password_file,
pg_migrate_password_file=pg_settings.pg_migrate_password_file,
)
with TestClient(create_app(s), headers=ORIGIN_HEADERS) as c:
def signin(sam: str, pw: str | None = None) -> Any:
password = dc_users[sam]["password"] if pw is None else pw
return c.post("/api/auth/signin/password", json={"login": sam, "password": password})
r = signin("clerk.clara")
assert r.status_code == 200 and r.json()["roles"] == ["clerical"]
assert signin("clerk.clara", "wrong").status_code == 401
assert signin("disabled.dan").status_code == 401
r = signin("norole.nick")
assert r.status_code == 403 and r.json()["code"] == "no_role"
assert signin("nested.ned").json()["roles"] == ["shop:10:worker"]
+87
View File
@@ -0,0 +1,87 @@
"""LDAP helpers and the fake directory; no domain controller needed."""
from __future__ import annotations
import uuid
from pathlib import Path
import pytest
from fakedir import FAKE_PASSWORD, FakeDirectory
from pshelpers import make_settings
from packetstation.auth import groups
from packetstation.auth.ldap import Directory, LdapUnavailable, guid_filter, parse_guid, user_from_entry
GUID = uuid.UUID("01234567-89ab-cdef-0123-456789abcdef")
def test_parse_guid_bytes_and_string() -> None:
raw = GUID.bytes_le
assert parse_guid(raw) == GUID
assert parse_guid([raw]) == GUID
assert parse_guid("{01234567-89ab-cdef-0123-456789abcdef}") == GUID
assert parse_guid("01234567-89AB-CDEF-0123-456789ABCDEF") == GUID
with pytest.raises(ValueError):
parse_guid(b"short")
def test_guid_filter_escapes_every_byte() -> None:
flt = guid_filter(GUID)
assert flt.startswith("(objectGUID=\\67\\45\\23\\01") and flt.endswith(")")
assert flt.count("\\") == 16
def test_user_from_entry() -> None:
entry = {
"dn": "CN=Andy,DC=x",
"raw_attributes": {"objectGUID": [GUID.bytes_le]},
"attributes": {
"sAMAccountName": ["weld.andy"],
"userPrincipalName": ["[email protected]"],
"displayName": ["Andy Welder"],
"employeeID": ["0303"],
"mail": [],
"userAccountControl": ["514"],
"distinguishedName": ["CN=Andy,DC=x"],
},
}
u = user_from_entry(entry)
assert u.guid == GUID and u.sam_account == "weld.andy" and u.display_name == "Andy Welder"
assert u.emp_no == "0303" # text, leading zero kept
assert u.email is None and u.disabled is True and u.dn == "CN=Andy,DC=x"
def test_plain_ldap_refused_without_starttls(tmp_path: Path) -> None:
pw = tmp_path / "pw"
pw.write_text("x")
s = make_settings(tmp_path, ldap_url="ldap://dc.example:389", ldap_bind_dn="CN=svc", ldap_password_file=pw)
with pytest.raises(LdapUnavailable, match="plain LDAP"):
Directory(s).find_user("someone")
def test_empty_password_never_binds(tmp_path: Path) -> None:
s = make_settings(tmp_path, ldap_url="ldaps://dc.invalid:636")
assert Directory(s).verify_password("CN=x,DC=y", "") is False
def test_missing_service_secret_is_unavailable(tmp_path: Path) -> None:
s = make_settings(
tmp_path, ldap_url="ldaps://dc.invalid:636", ldap_bind_dn="CN=svc", ldap_password_file=tmp_path / "none"
)
with pytest.raises(LdapUnavailable, match="LDAP_PASSWORD_FILE"):
Directory(s).list_groups()
def test_fake_directory_nested_and_ou() -> None:
d = FakeDirectory()
ned = d.find_user("NESTED.NED")
assert ned is not None
assert groups.roles_from_groups(d.groups_of(ned.dn)) == ["shop:10:worker"]
bo = d.find_user("[email protected]")
assert bo is not None
assert groups.roles_from_groups(d.groups_of(bo.dn)) == ["shop:03:supervisor"]
assert "PS-14-Electrical-Lead" not in d.list_groups() and "PS-Extra-Legacy" in d.list_groups()
sams = {u.sam_account for u in d.members_of("PS-03-WeldParts-Worker")}
assert {"weld.andy", "both.bo", "dup.dave", "disabled.dan"} <= sams
assert d.verify_password(bo.dn, FAKE_PASSWORD) and not d.verify_password(bo.dn, "nope")
assert d.verify_password(bo.dn, "") is False
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, make_settings
from packetstation.api.origin import allowed
from packetstation.main import create_app
def scope(method: str = "POST", path: str = "/api/auth/lock", scheme: str = "https", **headers: str) -> dict[str, Any]:
return {
"type": "http",
"method": method,
"path": path,
"scheme": scheme,
"headers": [(k.replace("_", "-").encode(), v.encode()) for k, v in headers.items()],
}
def test_allowed_rules() -> None:
good = {"host": "ps.pesco.com", "origin": "https://ps.pesco.com", "x_requested_with": "PacketStation"}
assert allowed(scope(**good))
assert allowed(scope(method="GET", host="ps.pesco.com")) # safe methods pass
assert allowed(scope(path="/healthz", host="x")) # outside /api
assert not allowed(scope(**{**good, "origin": "https://evil.example"}))
assert not allowed(scope(**{**good, "origin": "http://ps.pesco.com"})) # scheme must match
assert not allowed(scope(host="ps.pesco.com", origin="https://ps.pesco.com")) # marker missing
assert not allowed(scope(**{**good, "x_requested_with": "XMLHttpRequest"}))
no_origin = {"host": "ps.pesco.com", "x_requested_with": "PacketStation"}
assert not allowed(scope(**no_origin))
assert allowed(scope(**no_origin, referer="https://ps.pesco.com/account"))
assert not allowed(scope(**no_origin, referer="https://ps.pesco.com.evil.example/"))
for method in ("PUT", "PATCH", "DELETE"):
assert not allowed(scope(method=method, host="ps.pesco.com"))
@pytest.fixture
def client(tmp_path: Path, dev_visual: None) -> TestClient:
return TestClient(create_app(make_settings(tmp_path), directory=FakeDirectory()))
def test_wrong_origin_refused_on_signin(client: TestClient) -> None:
body = {"login": "clerk.clara", "password": "x"}
r = client.post("/api/auth/signin/password", json=body, headers={**ORIGIN_HEADERS, "Origin": "http://evil.example"})
assert r.status_code == 403
assert r.json() == {"detail": "Cross-origin request refused", "code": "bad_origin"}
assert r.headers["x-frame-options"] == "DENY"
r = client.post("/api/auth/signin/password", json=body, headers={"Origin": "http://testserver"})
assert r.status_code == 403 and r.json()["code"] == "bad_origin"
def test_right_origin_passes_the_check(client: TestClient) -> None:
# No database here, so the request gets past the Origin check and stops at the database
r = client.post("/api/auth/signin/password", json={"login": "a", "password": "b"}, headers=ORIGIN_HEADERS)
assert r.status_code == 503
assert r.json()["code"] == "database_unavailable"
def test_me_without_cookie_is_no_session(client: TestClient) -> None:
r = client.get("/api/auth/me")
assert r.status_code == 401
assert r.json()["code"] == "no_session"
assert client.get("/api/events").json()["code"] == "no_session"
assert client.get("/api/account").status_code == 401
def test_context_without_device(client: TestClient) -> None:
r = client.get("/api/auth/context")
assert r.status_code == 200
assert r.json() == {"device": None, "stack": "ci", "insecure_ldap": False, "pin_digits": {"min": 6, "max": 12}}
def test_validation_error_carries_a_code(client: TestClient) -> None:
r = client.post("/api/auth/signin/password", json={"login": "x"}, headers=ORIGIN_HEADERS)
assert r.status_code == 422
assert r.json()["code"] == "invalid_request"
def test_missing_session_key_fails_cleanly(tmp_path: Path, dev_visual: None) -> None:
s = make_settings(tmp_path, session_key_file=tmp_path / "missing")
c = TestClient(create_app(s, directory=FakeDirectory()))
r = c.post("/api/auth/signin/password", json={"login": "a", "password": "b"}, headers=ORIGIN_HEADERS)
assert r.status_code == 503
assert r.json()["code"] == "server_misconfigured"
assert str(tmp_path) not in r.json()["detail"]
+140
View File
@@ -0,0 +1,140 @@
"""The Packet Station PIN: format, hashing, and the whole lifecycle through the API."""
from __future__ import annotations
from pathlib import Path
import pytest
from fakedir import FAKE_PASSWORD, FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import PIN, app_conn, make_settings, needs_pg, pin_signin, register_device, set_pin, sign_in
from packetstation.auth import pin as pins
from packetstation.settings import Settings
@pytest.mark.parametrize(
("value", "ok"),
[
("123456", True),
("123456789012", True),
("12345", False),
("1234567890123", False),
("12345a", False),
("١٢٣٤٥٦", False),
("", False),
],
)
def test_format(tmp_path: Path, value: str, ok: bool) -> None:
assert pins.valid_format(value, make_settings(tmp_path)) is ok
def test_hash_is_argon2id_and_verifies() -> None:
h = pins.hash_pin(PIN)
assert h.startswith("$argon2id$") and PIN not in h
assert pins.verify_hash(h, PIN) and not pins.verify_hash(h, "135791") and not pins.verify_hash("junk", PIN)
@needs_pg
def test_pin_needs_a_registered_device(pg_client: TestClient) -> None:
set_pin(pg_client, "weld.andy")
r = pg_client.post("/api/auth/signin/pin", json={"emp_no": "3003", "pin": PIN})
assert r.status_code == 403 and r.json()["code"] == "device_required"
assert pg_client.get("/api/auth/context").json()["device"] is None
@needs_pg
def test_only_registrars_register_devices(pg_client: TestClient) -> None:
sign_in(pg_client, "weld.andy")
r = pg_client.post("/api/account/device", json={"name": "x"})
assert r.status_code == 403
sign_in(pg_client, "lead.lou")
assert pg_client.post("/api/account/device", json={"name": "x"}).status_code == 403
sign_in(pg_client, "admin.ann")
assert pg_client.post("/api/account/device", json={"name": "Office PC"}).status_code == 200
@needs_pg
def test_pin_set_rules(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "weld.andy")
r = pg_client.post("/api/account/pin", json={"password": FAKE_PASSWORD, "pin": "12ab56"})
assert r.status_code == 400 and r.json()["code"] == "invalid_pin"
r = pg_client.post("/api/account/pin", json={"password": "wrong", "pin": PIN})
assert r.status_code == 403 and r.json()["code"] == "bad_password"
assert pg_client.get("/api/account").json()["pin"] == {"set": False, "disabled": False}
with app_conn(pg_settings) as conn:
row = conn.execute("SELECT account, kind, reason FROM signin_attempts WHERE NOT ok").fetchone()
assert row[0].startswith("user:") and row[1:] == ("pin_set", "bad_password")
assert conn.execute("SELECT count(*) FROM user_pins").fetchone()[0] == 0
@needs_pg
def test_pin_lifecycle(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
device = register_device(pg_client)
assert pg_client.get("/api/auth/context").json()["device"] == device
set_pin(pg_client, "weld.andy")
r = pin_signin(pg_client, "3003")
assert r.status_code == 200, r.text
me = r.json()
assert me["session"]["kind"] == "pin" and me["session"]["device"] == device
assert me["roles"] == ["shop:03:worker"] and me["pin"] == {"set": True, "disabled": False}
# A PIN session cannot change account settings
r = pg_client.post("/api/account/pin", json={"password": FAKE_PASSWORD, "pin": "135791"})
assert r.status_code == 403 and r.json()["code"] == "password_session_required"
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) == 10
detail = conn.execute(
"SELECT detail FROM audit_events WHERE action = 'auth.signin' ORDER BY id DESC"
).fetchone()
assert detail[0] == {"kind": "pin", "device_id": device["id"], "device_name": device["name"]}
log = pg_client.get("/api/account/access-log").json()
assert any(e["action"] == "auth.signin" and e["detail"]["device_name"] == device["name"] for e in log)
pg_client.post("/api/auth/signout")
# Unknown, duplicate and wrong all answer the same
for emp in ("0000", "9001"):
r = pin_signin(pg_client, emp)
assert r.status_code == 401 and r.json() == {"detail": "Sign-in failed.", "code": "signin_failed"}
# Five wrong PINs disable it
for _ in range(5):
assert pin_signin(pg_client, "3003", "000000").status_code == 401
with app_conn(pg_settings) as conn:
failed, disabled = conn.execute("SELECT failed_count, disabled_at IS NOT NULL FROM user_pins").fetchone()
assert failed == 5 and disabled
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'pin.disabled'").fetchone()[0] == 1
assert pin_signin(pg_client, "3003").status_code in (401, 429) # disabled (and the account is now limited)
# A password sign-in and a new PIN bring it back
set_pin(pg_client, "weld.andy", "13579135")
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT failed_count, disabled_at FROM user_pins").fetchone() == (0, None)
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'pin.set'").fetchone()[0] == 2
@needs_pg
def test_pin_unlock_failures_count_and_end_session(pg_client: TestClient, pg_settings: Settings) -> None:
register_device(pg_client)
set_pin(pg_client, "weld.andy")
assert pin_signin(pg_client, "3003").status_code == 200
pg_client.post("/api/auth/lock")
r = pg_client.post("/api/auth/unlock", json={"factor": "pin", "secret": "111111"})
assert r.status_code == 403 and r.json()["attempts_left"] == 2
r = pg_client.post("/api/auth/unlock", json={"factor": "pin", "secret": PIN})
assert r.status_code == 200 and r.json()["session"]["locked"] is False
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT failed_count FROM user_pins").fetchone()[0] == 0
pg_client.post("/api/auth/lock")
for _ in range(2):
assert pg_client.post("/api/auth/unlock", json={"factor": "pin", "secret": "111111"}).status_code == 403
r = pg_client.post("/api/auth/unlock", json={"factor": "pin", "secret": "111111"})
assert r.status_code == 401 and r.json()["code"] == "session_ended"
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT failed_count FROM user_pins").fetchone()[0] == 3
reason = conn.execute("SELECT revoked_reason FROM sessions WHERE kind = 'pin'").fetchone()[0]
assert reason == "stepup_failures"
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from pshelpers import needs_pg
from packetstation import db
from packetstation.auth import ratelimit
from packetstation.settings import Settings
T0 = datetime(2026, 10, 16, 8, 0, tzinfo=UTC)
W = timedelta(minutes=5)
C = timedelta(minutes=10)
def m(n: float) -> datetime:
return T0 + timedelta(minutes=n)
def test_account_under_limit_is_free() -> None:
assert ratelimit.account_block_until([m(0), m(1), m(2), m(3)], limit=5, window=W, cooldown=C) is None
def test_account_limit_hit_blocks_for_cooldown_from_the_fifth() -> None:
fails = [m(0), m(1), m(2), m(3), m(4)]
until = ratelimit.account_block_until(fails, limit=5, window=W, cooldown=C)
assert until == m(14)
# Still refused after the failures leave the window: the cooldown is longer than the window
assert ratelimit.retry_seconds(until, m(9)) == 300
assert ratelimit.retry_seconds(until, m(14)) is None
def test_account_failures_spread_wider_than_window_do_not_block() -> None:
fails = [m(0), m(2), m(4), m(6), m(8), m(10)]
assert ratelimit.account_block_until(fails, limit=5, window=W, cooldown=C) is None
def test_ip_rule_counts_only_the_last_window() -> None:
now = m(10)
fails = [m(4.5)] + [m(6 + i * 0.1) for i in range(29)]
assert ratelimit.ip_block_until(fails, now=now, limit=30, window=W) is None # m(4.5) is out of the window
fails.append(m(9))
until = ratelimit.ip_block_until(fails, now=now, limit=30, window=W)
assert until == m(11) # the oldest of the 30 (m(6)) leaves the window at m(11)
assert ratelimit.retry_seconds(until, now) == 60
def test_retry_seconds_rounds_up() -> None:
assert ratelimit.retry_seconds(T0 + timedelta(seconds=0.2), T0) == 1
assert ratelimit.retry_seconds(None, T0) is None
def test_record_refuses_unknown_kind() -> None:
with pytest.raises(ValueError):
ratelimit.record(None, account="x", ip=None, kind="sneaky", ok=False)
@needs_pg
def test_pg_check_with_explicit_times(pg_settings: Settings) -> None:
with db.connect_as("app", pg_settings, autocommit=True) as conn:
now = ratelimit.db_now(conn)
acct = "weld.andy"
for i in range(4):
ratelimit.record(
conn, account=acct, ip="10.0.0.9", kind="password", ok=False, reason="bad_password", at=now - W / 5 * i
)
# A success and rate-limited refusals never count
ratelimit.record(conn, account=acct, ip="10.0.0.9", kind="password", ok=True, at=now)
ratelimit.record(conn, account=acct, ip="10.0.0.9", kind="password", ok=False, reason="rate_limited", at=now)
assert ratelimit.check(conn, pg_settings, accounts=[acct], ip="10.0.0.9", now=now) is None
ratelimit.record(conn, account=acct, ip="10.0.0.9", kind="password", ok=False, reason="bad_password", at=now)
wait = ratelimit.check(conn, pg_settings, accounts=[acct], ip="10.0.0.9", now=now)
assert wait == 600
# Another account from the same address is still allowed
assert ratelimit.check(conn, pg_settings, accounts=["clerk.clara"], ip="10.0.0.9", now=now) is None
# Aliases count together
assert ratelimit.check(conn, pg_settings, accounts=["andy@x", acct], ip=None, now=now) == 600
# After the cooldown the account may try again
assert ratelimit.check(conn, pg_settings, accounts=[acct], ip=None, now=now + C + timedelta(seconds=1)) is None
@needs_pg
def test_pg_ip_limit(pg_settings: Settings) -> None:
with db.connect_as("app", pg_settings, autocommit=True) as conn:
now = ratelimit.db_now(conn)
for i in range(30):
ratelimit.record(
conn, account=f"user{i}", ip="10.9.9.9", kind="password", ok=False, reason="no_such_user", at=now
)
assert ratelimit.check(conn, pg_settings, accounts=["fresh"], ip="10.9.9.9", now=now) == 300
assert ratelimit.check(conn, pg_settings, accounts=["fresh"], ip="10.9.9.8", now=now) is None
+185
View File
@@ -0,0 +1,185 @@
"""Session timeouts and the roles refresh, enforced by the server."""
from __future__ import annotations
from pathlib import Path
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, app_conn, make_settings, needs_pg, sign_in
from packetstation.auth import sessions
from packetstation.settings import Settings
pytestmark = needs_pg
def _age(settings: Settings, sql: str) -> None:
with app_conn(settings) as conn:
conn.execute(sql)
def test_token_is_stored_only_as_hmac(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "clerk.clara")
token = pg_client.cookies.get(sessions.SESSION_COOKIE)
assert token
key = pg_settings.session_key()
with app_conn(pg_settings) as conn:
stored = bytes(conn.execute("SELECT token_hash FROM sessions").fetchone()[0])
assert stored == sessions.token_hash(key, token) and token.encode() not in stored
def test_idle_lock_after_lock_minutes(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "clerk.clara")
_age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '4 minutes'")
r = pg_client.get("/api/account")
assert r.status_code == 423 and r.json()["code"] == "locked"
assert pg_client.get("/api/auth/me").json()["session"]["locked"] is True
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT locked_since IS NOT NULL FROM sessions").fetchone()[0]
lock = conn.execute("SELECT detail FROM audit_events WHERE action = 'auth.lock'").fetchone()[0]
assert lock == {"source": "idle"}
def test_hidden_tab_locks_only_after_a_minute(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "clerk.clara")
# A reload fires a hidden report and the next request follows at once: no lock
assert pg_client.post("/api/auth/visibility", json={"hidden": True}, headers=ORIGIN_HEADERS).status_code == 204
assert pg_client.get("/api/account").status_code == 200
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT hidden_since IS NULL AND locked_since IS NULL FROM sessions").fetchone()[0]
# Hidden for over a minute: the visible report (or any request) locks
assert pg_client.post("/api/auth/visibility", json={"hidden": True}, headers=ORIGIN_HEADERS).status_code == 204
_age(pg_settings, "UPDATE sessions SET hidden_since = now() - interval '61 seconds'")
assert pg_client.post("/api/auth/visibility", json={"hidden": False}, headers=ORIGIN_HEADERS).status_code == 204
r = pg_client.get("/api/account")
assert r.status_code == 423 and r.json()["code"] == "locked"
with app_conn(pg_settings) as conn:
lock = conn.execute("SELECT detail FROM audit_events WHERE action = 'auth.lock'").fetchone()[0]
assert lock == {"source": "hidden"}
def test_idle_signout_after_idle_minutes(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "clerk.clara")
_age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '16 minutes'")
r = pg_client.get("/api/auth/me")
assert r.status_code == 401 and r.json()["code"] == "session_expired"
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] == "idle"
ended = conn.execute("SELECT detail FROM audit_events WHERE action = 'auth.session_ended'").fetchone()[0]
assert ended == {"reason": "idle"}
def test_locked_session_does_not_stay_alive_by_polling(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "clerk.clara")
pg_client.post("/api/auth/lock")
_age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '14 minutes'")
assert pg_client.get("/api/auth/me").status_code == 200
with app_conn(pg_settings) as conn:
age = conn.execute("SELECT now() - last_seen_at > interval '13 minutes' FROM sessions").fetchone()[0]
assert age # /me while locked does not count as activity
def test_last_seen_updates_after_30_seconds(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "clerk.clara")
_age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '2 minutes'")
pg_client.get("/api/account")
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT now() - last_seen_at < interval '10 seconds' FROM sessions").fetchone()[0]
def test_absolute_expiry(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "clerk.clara")
_age(pg_settings, "UPDATE sessions SET expires_at = now() - interval '1 second'")
r = pg_client.get("/api/auth/me")
assert r.status_code == 401 and r.json()["code"] == "no_session"
def test_session_hours_are_settings(
pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None, tmp_path: Path
) -> None:
from pshelpers import ORIGIN_HEADERS
from packetstation.main import create_app
s = make_settings(
tmp_path / "s2",
**{k: getattr(pg_settings, k) for k in ("pg_host", "pg_port", "pg_database")},
pg_app_password_file=pg_settings.pg_app_password_file,
pg_migrate_password_file=pg_settings.pg_migrate_password_file,
session_password_hours=3,
)
with TestClient(create_app(s, directory=fake_dir), headers=ORIGIN_HEADERS) as c:
me = sign_in(c, "clerk.clara").json()
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) == 3
def test_roles_refresh_rewrites_snapshot(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
assert sign_in(pg_client, "weld.andy").json()["roles"] == ["shop:03:worker"]
fake_dir.user("weld.andy").groups = ["PS-03-WeldParts-Lead", "PS-QC"]
assert pg_client.get("/api/auth/me").json()["roles"] == ["shop:03:worker"] # not due yet
_age(pg_settings, "UPDATE sessions SET roles_checked_at = now() - interval '16 minutes'")
me = pg_client.get("/api/auth/me").json()
assert me["roles"] == ["qc", "shop:03:lead"] and me["screens"] == ["supervisor", "stall", "qc"]
with app_conn(pg_settings) as conn:
rows = conn.execute("SELECT role::text, dept_code FROM user_roles ORDER BY role").fetchall()
assert rows == [("qc", "QC"), ("shop_lead", "03")]
snap = conn.execute("SELECT roles_snapshot FROM sessions").fetchone()[0]
assert snap["groups"] == ["PS-03-WeldParts-Lead", "PS-QC"]
def test_roles_refresh_ends_session_when_roles_gone(
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory
) -> None:
sign_in(pg_client, "weld.andy")
fake_dir.user("weld.andy").groups = []
_age(pg_settings, "UPDATE sessions SET roles_checked_at = now() - interval '16 minutes'")
r = pg_client.get("/api/auth/me")
assert r.status_code == 401 and r.json()["code"] == "session_expired"
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT revoked_reason FROM sessions").fetchone()[0] == "roles_lost"
assert conn.execute("SELECT count(*) FROM user_roles").fetchone()[0] == 0
assert conn.execute("SELECT active FROM users").fetchone()[0] is True
def test_roles_refresh_disabled_account(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
sign_in(pg_client, "weld.andy")
fake_dir.user("weld.andy").disabled = True
_age(pg_settings, "UPDATE sessions SET roles_checked_at = now() - interval '16 minutes'")
assert pg_client.get("/api/auth/me").json()["code"] == "session_expired"
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT active FROM users").fetchone()[0] is False
def test_roles_refresh_keeps_roles_when_ad_down(
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory
) -> None:
sign_in(pg_client, "weld.andy")
fake_dir.down = True
_age(pg_settings, "UPDATE sessions SET roles_checked_at = now() - interval '16 minutes'")
assert pg_client.get("/api/auth/me").json()["roles"] == ["shop:03:worker"]
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT now() - roles_checked_at < interval '1 minute' FROM sessions").fetchone()[0]
def test_signout_others(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
from pshelpers import ORIGIN_HEADERS
from packetstation.main import create_app
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as other:
sign_in(other, "clerk.clara")
sign_in(pg_client, "clerk.clara")
listed = pg_client.get("/api/account/sessions").json()
assert len(listed) == 2 and sum(s["current"] for s in listed) == 1
assert pg_client.post("/api/account/sessions/signout-others").json() == {"revoked": 1}
assert other.get("/api/auth/me").json()["code"] == "no_session"
assert pg_client.get("/api/auth/me").status_code == 200
log = pg_client.get("/api/account/access-log").json()
assert log[0]["action"] == "auth.signout_others" and log[0]["detail"] == {"count": 1} and log[0]["ok"] is True
+129
View File
@@ -0,0 +1,129 @@
"""signatures.sign: step-up, the append-only row, its content hash and the audit row."""
from __future__ import annotations
import hashlib
import uuid
from typing import Any
import psycopg
import pytest
from fakedir import FAKE_PASSWORD, FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import PIN, app_conn, needs_pg, set_pin, sign_in
from packetstation import audit
from packetstation.api.errors import ApiError
from packetstation.auth import sessions, signatures
from packetstation.settings import Settings
def test_content_hash_is_canonical() -> None:
a = signatures.content_hash({"b": [1, 2], "a": "7A"})
b = signatures.content_hash({"a": "7A", "b": [1, 2]})
assert a == b == hashlib.sha256(b'{"a":"7A","b":[1,2]}').digest()
assert signatures.content_hash(None) is None
def _actor(conn: Any, sam: str) -> sessions.Actor:
row = conn.execute(
"SELECT s.id, u.id, u.sam_account, u.display_name, s.kind::text"
" FROM sessions s JOIN users u ON u.id = s.user_id"
" WHERE u.sam_account = %s AND s.revoked_at IS NULL ORDER BY s.created_at DESC LIMIT 1",
(sam,),
).fetchone()
assert row is not None
return sessions.Actor(row[0], row[1], row[2], row[3], row[4], ("shop:03:worker",))
@needs_pg
def test_sign_with_pin_and_password(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
set_pin(pg_client, "weld.andy")
sign_in(pg_client, "weld.andy")
content = {"packet": "L662001/1", "lot": "7A", "step": "submit"}
with app_conn(pg_settings) as conn:
actor = _actor(conn, "weld.andy")
sig = signatures.sign(
conn,
fake_dir,
actor,
factor="pin",
secret=PIN,
action="packet.submit",
meaning="Work complete",
packet_id=12,
subject_type="packet",
subject_id=12,
content=content,
settings=pg_settings,
)
assert sig.content_hash == signatures.content_hash(content) and sig.factor == "pin"
sig2 = signatures.sign(
conn,
fake_dir,
actor,
factor="password",
secret=FAKE_PASSWORD,
action="x",
meaning=None,
settings=pg_settings,
)
assert sig2.id > sig.id and sig2.content_hash is None
row = conn.execute(
"SELECT account, display_name, session_kind::text, factor::text, action, packet_id, subject_id,"
" content_hash FROM signatures WHERE id = %s",
(sig.id,),
).fetchone()
assert row[:7] == ("weld.andy", "Andy Welder", "password", "pin", "packet.submit", 12, "12")
assert bytes(row[7]) == signatures.content_hash(content)
detail = conn.execute("SELECT detail FROM audit_events WHERE action = 'sig.signed' ORDER BY id").fetchone()[0]
assert detail == {"signature_id": sig.id, "action": "packet.submit", "factor": "pin"}
for stmt in ("UPDATE signatures SET action = 'x'", "DELETE FROM signatures", "TRUNCATE signatures"):
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute(stmt)
for stmt in ("UPDATE signin_attempts SET ok = true", "DELETE FROM signin_attempts"):
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute(stmt)
assert audit.verify_chain(conn)[0]
@needs_pg
def test_wrong_signatures_end_the_session(
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory
) -> None:
sign_in(pg_client, "weld.andy")
with app_conn(pg_settings) as conn:
actor = _actor(conn, "weld.andy")
for code in ("wrong_secret", "wrong_secret", "session_ended"):
with pytest.raises(ApiError) as e:
signatures.sign(
conn,
fake_dir,
actor,
factor="password",
secret="nope",
action="x",
meaning=None,
settings=pg_settings,
)
assert e.value.code == code
assert conn.execute("SELECT count(*) FROM signatures").fetchone()[0] == 0
kinds = conn.execute("SELECT DISTINCT kind FROM signin_attempts WHERE NOT ok").fetchall()
assert kinds == [("signature",)]
assert pg_client.get("/api/auth/me").json()["code"] == "no_session"
def test_unknown_factor_refused() -> None:
actor = sessions.Actor(uuid.uuid4(), uuid.uuid4(), "a", "A", "password", ())
with pytest.raises(ApiError) as e:
signatures.sign(
None,
FakeDirectory(),
actor,
factor="sms",
secret="1",
action="x",
meaning=None,
settings=Settings(_env_file=None, stack="ci"),
)
assert e.value.code == "bad_factor"
+169
View File
@@ -0,0 +1,169 @@
"""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", "device"}
assert me["session"]["kind"] == "password" and me["session"]["locked"] is False
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 weld == {
"code": "03",
"slug": "WeldParts",
"name": "Weld Parts",
"kind": "shop",
"stations": [f"Stall {i}" for i in range(1, 7)],
}
hydro = next(d for d in deps if d["code"] == "09")
assert hydro["stations"] == ["Hydro Bay 1", "Hydro Bay 2"]