2026-10-16 07:40:29 -06:00
|
|
|
"""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:
|
2026-10-21 13:16:26 -06:00
|
|
|
sign_in(pg_client, "wh.wade")
|
2026-10-16 07:40:29 -06:00
|
|
|
_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:
|
2026-10-21 13:16:26 -06:00
|
|
|
sign_in(pg_client, "wh.wade")
|
2026-10-16 07:40:29 -06:00
|
|
|
_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"}
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 13:16:26 -06:00
|
|
|
def test_admin_and_clerical_get_desk_minutes(pg_client: TestClient, pg_settings: Settings) -> None:
|
|
|
|
|
# Issue #45: Admin and Clerical lock and sign out after 60 idle minutes, not 3 and 15
|
|
|
|
|
for login in ("clerk.clara", "clerk.leo", "admin.ann", "multi.mia"):
|
|
|
|
|
pg_client.cookies.clear()
|
|
|
|
|
sign_in(pg_client, login)
|
|
|
|
|
assert pg_client.get("/api/auth/me").json()["session"]["lock_minutes"] == 60
|
|
|
|
|
_age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '59 minutes' WHERE revoked_at IS NULL")
|
|
|
|
|
assert pg_client.get("/api/account").status_code == 200, login
|
|
|
|
|
_age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '61 minutes' WHERE revoked_at IS NULL")
|
|
|
|
|
r = pg_client.get("/api/auth/me")
|
|
|
|
|
assert r.status_code == 401 and r.json()["code"] == "session_expired", login
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_shop_screens_keep_short_minutes(pg_client: TestClient, pg_settings: Settings) -> None:
|
|
|
|
|
sign_in(pg_client, "weld.andy")
|
|
|
|
|
assert pg_client.get("/api/auth/me").json()["session"]["lock_minutes"] == 3
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 07:40:29 -06:00
|
|
|
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
|