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:
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
@@ -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"})
|
||||
@@ -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"])
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user