Files
PacketStation/tests/server/test_session_refresh.py
spencermandClaude Opus 5.5 a82e056b6a
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s
Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must
create), Profiles (welder stamp, station limits, active) with a CSV import
that previews and applies only when clean, Checklists (weld and plain),
Security (session, lockout and rate-limit settings with bounds, alerts for
the last 24 hours, the integrity result), Access log with filters and a
guarded CSV, Who is in, Stamp check, Notifications, and the employeeID
flags. Every Admin change is audited.

Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh
(15 minutes), integrity (seals, asset hashes, missing and orphan files, the
audit chain), backup-check, notify-send and notify-digest. Notifications
route per event type to AD groups, users, mailboxes or the department
Supervisor group, immediately or in a daily digest, over STARTTLS verified
against the PESCO CA, with dry run on the test stack.

Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer
at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21.
CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19.

Migration 0010. Closes the app part of I6, M3, O1, O2 and S3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:27:09 -06:00

98 lines
4.5 KiB
Python

"""The session-refresh job: roles rebuilt for live sessions, disabled, gone and roleless users signed out."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
import pytest
from fakedir import FakeDirectory
from pshelpers import app_conn, needs_pg
from packetstation.auth import directory as userdir
from packetstation.auth import sessions
from packetstation.feed.config import FeedSettings
from packetstation.settings import Settings
from packetstation.worker import runs
from packetstation.worker.jobs import ad_reconcile, session_refresh
from packetstation.worker.scheduler import Scheduler
def run(settings: Settings, d: FakeDirectory, name: str = "session-refresh") -> dict[str, Any]:
spec = session_refresh.SPEC if name == "session-refresh" else ad_reconcile.SPEC
sch = Scheduler(settings, build="pytest", jobs={name: spec})
sch.stack.cache["directory"] = d
return runs.run_job(sch, name, "schedule")
def session_for(settings: Settings, conn: Any, user_id: uuid.UUID, roles: list[str]) -> uuid.UUID:
_, row = sessions.create(
conn, settings, settings.session_key(), user_id=user_id, kind="password", roles=roles, groups=[]
)
conn.execute("UPDATE sessions SET roles_checked_at = now() - interval '1 hour' WHERE id = %s", (row["id"],))
sid: uuid.UUID = row["id"]
return sid
def test_cadence_follows_the_last_run(monkeypatch: pytest.MonkeyPatch) -> None:
now = datetime(2026, 10, 21, 10, tzinfo=ZoneInfo("America/Denver"))
monkeypatch.setattr(session_refresh, "_minutes", 15)
assert session_refresh.cadence(FeedSettings(), now) == 900
session_refresh._set_minutes(30)
assert session_refresh.cadence(FeedSettings(), now) == 1800
@needs_pg
def test_session_refresh(pg_settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None:
d = FakeDirectory()
monkeypatch.setattr("packetstation.worker.jobs.raise_event", lambda *a, **k: None)
monkeypatch.setattr("packetstation.worker.jobs.resolve_event", lambda *a, **k: None)
assert run(pg_settings, d, "ad-reconcile")["status"] == "ok"
# Nobody signed in: nothing to do
row = run(pg_settings, d)
assert row["status"] == "ok" and row["counts"]["users"] == 0
g = {s: d.user(s).guid for s in ("clerk.clara", "wh.wade", "qc.quinn", "lead.lou", "sup.wes")}
with app_conn(pg_settings) as conn:
sid = {s: session_for(pg_settings, conn, uid, ["clerical"]) for s, uid in g.items()}
conn.execute("UPDATE sessions SET revoked_at = now() WHERE id = %s", (sid["sup.wes"],))
d.user("lead.lou").groups = ["PS-03-WeldParts-Supervisor"] # promoted
d.user("clerk.clara").disabled = True
d.users.remove(d.user("wh.wade"))
d.user("qc.quinn").groups = []
# AD down: degraded, nothing ended or changed
d.down = True
row = run(pg_settings, d)
assert row["status"] == "degraded" and row["counts"]["ad"] == "unavailable"
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT count(*) FROM sessions WHERE revoked_at IS NULL").fetchone()[0] == 4
d.down = False
row = run(pg_settings, d)
assert row["status"] == "ok", row["errors"]
c = row["counts"]
assert c["users"] == 4 and c["refreshed"] == 1 and c["deactivated"] == 2 and c["roles_lost"] == 1
assert c["sessions_ended"] == 3 and c["roles_changed"] == 2 # Lou promoted, Quinn lost QC
with app_conn(pg_settings) as conn:
state = dict(conn.execute("SELECT id, revoked_reason FROM sessions").fetchall())
assert state[sid["clerk.clara"]] == "ad_disabled" and state[sid["wh.wade"]] == "ad_gone"
assert state[sid["qc.quinn"]] == "roles_lost" and state[sid["lead.lou"]] is None
snap, fresh = conn.execute(
"SELECT roles_snapshot, roles_checked_at > now() - interval '1 minute' FROM sessions WHERE id = %s",
(sid["lead.lou"],),
).fetchone()
assert snap["roles"] == ["shop:03:supervisor"] and snap["groups"] == ["PS-03-WeldParts-Supervisor"] and fresh
assert userdir.roles_of(conn, g["lead.lou"]) == ["shop:03:supervisor"]
active = dict(conn.execute("SELECT id, active FROM users").fetchall())
assert active[g["clerk.clara"]] is False and active[g["wh.wade"]] is False and active[g["qc.quinn"]] is True
ends = conn.execute(
"SELECT detail->>'reason' FROM audit_events WHERE action = 'auth.session_ended'"
" AND detail->>'by' = 'session-refresh' ORDER BY 1"
).fetchall()
assert [e[0] for e in ends] == ["ad_disabled", "ad_gone", "roles_lost"]