"""The security settings overlay: bounds, compare-and-set, and the effect on the rate limiter, the idle lock, the session length and the security alerts.""" 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 app_conn, make_settings, needs_pg, sign_in from packetstation.auth import secsettings from packetstation.settings import Settings @pytest.fixture def raised(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: """Record notify.raise_event calls instead of writing events (agent A's module is tested on its own).""" from packetstation import notify calls: list[dict[str, Any]] = [] def fake(conn: Any, event_type: str, **kw: Any) -> int: calls.append({"event_type": event_type, **kw}) return len(calls) monkeypatch.setattr(notify, "raise_event", fake) return calls # ---------------------------------------------------------------- pure def test_validate_bounds_and_unknown_keys() -> None: assert secsettings.validate({"session_idle_minutes": 60, "signin_account_cooldown_minutes": 0}) == { "session_idle_minutes": 60, "signin_account_cooldown_minutes": 0, } with pytest.raises(secsettings.SecSettingsError) as e: secsettings.validate( { "session_idle_minutes": 61, "signin_account_limit": 16, "signin_ip_limit": "50", "roles_refresh_minutes": True, "pin_max_failures": 3, } ) assert len(e.value.errors) == 5 assert "session_idle_minutes: a whole number from 1 to 60" in e.value.errors assert "pin_max_failures: not a security setting" in e.value.errors # Always under the domain's lockout at 20 assert secsettings.BOUNDS["signin_account_limit"][1] < 20 def test_stored_values_drop_bad_keys_and_defaults_come_from_env(tmp_path: Path) -> None: assert secsettings.stored_values({"session_lock_minutes": 5, "session_idle_minutes": 0, "x": 1}) == { "session_lock_minutes": 5 } assert secsettings.stored_values(["not", "a", "dict"]) == {} s = make_settings(tmp_path, session_idle_minutes=20, signin_ip_limit=40) d = secsettings.defaults(s) assert d["session_idle_minutes"] == 20 and d["signin_ip_limit"] == 40 and d["signin_burst_threshold"] == 20 assert set(d) == set(secsettings.BOUNDS) # ---------------------------------------------------------------- database pg = needs_pg @pg def test_settings_api_bounds_cas_and_effect( pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, raised: list[dict[str, Any]] ) -> None: sign_in(pg_client, "admin.ann") got = pg_client.get("/api/admin/security/settings").json() assert got["version"] == 0 and got["values"] == got["defaults"] assert got["values"]["signin_account_limit"] == 5 and got["bounds"]["signin_account_limit"] == [1, 15] assert set(got["bounds"]) == set(secsettings.BOUNDS) r = pg_client.put("/api/admin/security/settings", json={"values": {"session_lock_minutes": 31}, "version": 0}) assert r.status_code == 422 and r.json()["code"] == "bad_settings" assert r.json()["errors"] == ["session_lock_minutes: a whole number from 1 to 30"] r = pg_client.put("/api/admin/security/settings", json={"values": {"nope": 3}, "version": 0}) assert r.status_code == 422 r = pg_client.put( "/api/admin/security/settings", json={"values": {"signin_account_limit": 2, "session_lock_minutes": 1, "session_pin_hours": 2}, "version": 0}, ) assert r.status_code == 200, r.text body = r.json() assert body["version"] == 1 and body["values"]["signin_account_limit"] == 2 assert body["values"]["session_idle_minutes"] == body["defaults"]["session_idle_minutes"] stale = pg_client.put("/api/admin/security/settings", json={"values": {"signin_ip_limit": 50}, "version": 0}) assert stale.status_code == 409 and stale.json()["code"] == "version_conflict" assert stale.json()["current"]["version"] == 1 with app_conn(pg_settings) as conn: row = conn.execute( "SELECT detail FROM audit_events WHERE action = 'admin.security_settings_updated'" ).fetchone()[0] assert row["changed"]["signin_account_limit"] == {"from": 5, "to": 2} assert row["before"]["session_lock_minutes"] == 3 and row["after"]["session_lock_minutes"] == 1 eff = secsettings.effective(conn, pg_settings) assert eff.signin_account_limit == 2 and eff.version == 1 and eff.settings is not None assert eff.settings.signin_account_limit == 2 and pg_settings.signin_account_limit == 5 # An overlaid copy passed again comes back as it is assert secsettings.overlay(conn, eff.settings) is eff.settings # The rate limiter: two failures now start the cooldown (the environment says five) pg_client.post("/api/auth/signout") assert sign_in(pg_client, "clerk.clara", "wrong").status_code == 401 assert sign_in(pg_client, "clerk.clara", "wrong").status_code == 401 r = sign_in(pg_client, "clerk.clara") assert r.status_code == 429 and r.json()["code"] == "rate_limited" assert [c["event_type"] for c in raised] == ["security_alert"] assert raised[0]["subject"] == "account:clerk.clara" and raised[0]["dedupe_minutes"] == 15 from packetstation.notify import EVENT_TYPES assert "security_alert" in EVENT_TYPES and "signin_burst" in EVENT_TYPES with app_conn(pg_settings) as conn: lock = conn.execute("SELECT subject_id, detail FROM audit_events WHERE action = 'auth.lockout'").fetchall() assert len(lock) == 1 and lock[0][0] == "account:clerk.clara" and lock[0][1]["minutes"] == 10 # The idle lock: one minute now (the environment says three); PIN sessions last two hours sign_in(pg_client, "wh.wade") assert pg_client.get("/api/auth/me").json()["session"]["lock_minutes"] == 1 with app_conn(pg_settings) as conn: conn.execute("UPDATE sessions SET last_seen_at = now() - interval '90 seconds' WHERE revoked_at IS NULL") r = pg_client.get("/api/account") assert r.status_code == 423 and r.json()["code"] == "locked" # The Security tab lists the lockout and the admin change pg_client.post("/api/auth/signout") sign_in(pg_client, "admin.ann") kinds = {a["kind"] for a in pg_client.get("/api/admin/security/alerts").json()} assert {"lockout", "admin_change"} <= kinds assert pg_client.get("/api/admin/security/alerts", params={"hours": 169}).status_code == 422 @pg def test_session_hours_and_burst( pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, raised: list[dict[str, Any]] ) -> None: sign_in(pg_client, "admin.ann") r = pg_client.put( "/api/admin/security/settings", json={"values": {"session_password_hours": 2, "signin_burst_threshold": 5}, "version": 0}, ) assert r.status_code == 200, r.text sign_in(pg_client, "clerk.clara") with app_conn(pg_settings) as conn: hours = conn.execute( "SELECT extract(epoch FROM s.expires_at - s.created_at) / 3600 FROM sessions s JOIN users u" " ON u.id = s.user_id WHERE u.sam_account = 'clerk.clara'" ).fetchone()[0] assert round(float(hours)) == 2 # Five failures across five accounts reach the burst threshold for sam in ("a1", "a2", "a3", "a4", "a5"): assert sign_in(pg_client, sam, "x").status_code == 401 bursts = [c for c in raised if c["event_type"] == "signin_burst"] assert len(bursts) == 1 and bursts[0]["subject"] == "burst" and bursts[0]["detail"]["failures"] == 5 assert sign_in(pg_client, "a6", "x").status_code == 401 assert len([c for c in raised if c["event_type"] == "signin_burst"]) == 1 # audited once per 30 minutes sign_in(pg_client, "admin.ann") alerts = pg_client.get("/api/admin/security/alerts").json() assert any(a["kind"] == "signin_burst" and a["detail"]["reason"] == "burst" for a in alerts)