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]>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""Admin departments, profiles, checklists and directory flags."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from pshelpers import app_conn, needs_pg, sign_in
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from packetstation.auth import groups
|
||||
from packetstation.domain.checklists import DEFAULT_TEMPLATES
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_group_map() -> Iterator[None]:
|
||||
yield
|
||||
groups.use_departments(groups.DEPARTMENTS)
|
||||
|
||||
|
||||
def _actions(settings: Settings, like: str) -> list[tuple[str, dict]]:
|
||||
with app_conn(settings) as conn:
|
||||
return [
|
||||
(r[0], r[1])
|
||||
for r in conn.execute(
|
||||
"SELECT action, detail FROM audit_events WHERE action LIKE %s ORDER BY id", (like,)
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
def test_departments(pg_client: TestClient, pg_settings: Settings) -> None:
|
||||
sign_in(pg_client, "admin.ann")
|
||||
listed = pg_client.get("/api/admin/departments").json()
|
||||
assert [d["code"] for d in listed][:5] == ["CL", "WH", "QC", "PM", "01"] and len(listed) == 18
|
||||
weld = next(d for d in listed if d["code"] == "03")
|
||||
assert weld["weld"] is True and weld["kind"] == "shop" and len(weld["stations"]) == 6
|
||||
assert [g["name"] for g in weld["groups"]] == [
|
||||
"PS-03-WeldParts-Worker",
|
||||
"PS-03-WeldParts-Lead",
|
||||
"PS-03-WeldParts-Supervisor",
|
||||
]
|
||||
assert all(g["in_ou"] is True for g in weld["groups"])
|
||||
elec = next(d for d in listed if d["code"] == "14")
|
||||
assert {g["name"]: g["in_ou"] for g in elec["groups"]}["PS-14-Electrical-Lead"] is False
|
||||
pm = next(d for d in listed if d["code"] == "PM")
|
||||
assert [g["name"] for g in pm["groups"]] == ["PS-ProjectMgmt"]
|
||||
|
||||
# Code and slug can never change; extra fields are refused
|
||||
for body in ({"code": "99"}, {"slug": "Other"}):
|
||||
r = pg_client.patch("/api/admin/departments/03", json=body)
|
||||
assert r.status_code == 422, body
|
||||
assert pg_client.patch("/api/admin/departments/ZZ", json={"name": "x"}).status_code == 404
|
||||
|
||||
st = weld["stations"]
|
||||
r = pg_client.patch(
|
||||
"/api/admin/departments/03",
|
||||
json={
|
||||
"name": " Weld Parts Shop ",
|
||||
"stations": [
|
||||
{"id": st[0]["id"], "name": st[1]["name"], "sort": 1, "active": True},
|
||||
{"id": st[1]["id"], "name": st[0]["name"], "sort": 2, "active": False},
|
||||
{"name": "Stall 7", "sort": 7, "active": True},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
got = r.json()
|
||||
assert got["name"] == "Weld Parts Shop" and len(got["stations"]) == 7 # nothing deleted
|
||||
by_id = {s["id"]: s for s in got["stations"]}
|
||||
assert by_id[st[0]["id"]]["name"] == "Stall 2" and by_id[st[1]["id"]]["active"] is False
|
||||
dup = pg_client.patch(
|
||||
"/api/admin/departments/03", json={"stations": [{"name": "stall 3", "sort": 9, "active": True}]}
|
||||
)
|
||||
assert dup.status_code == 422 and dup.json()["code"] == "duplicate_station"
|
||||
other = pg_client.patch(
|
||||
"/api/admin/departments/03",
|
||||
json={"stations": [{"id": listed[5]["stations"][0]["id"], "name": "X", "sort": 1, "active": True}]},
|
||||
)
|
||||
assert other.status_code == 422
|
||||
# Deactivated stations drop out of the public list
|
||||
pub = next(d for d in pg_client.get("/api/departments").json() if d["code"] == "03")
|
||||
assert st[1]["id"] not in {s["id"] for s in pub["stations"]} and pub["name"] == "Weld Parts Shop"
|
||||
|
||||
# Add a department: the three group names IT must create
|
||||
body = {
|
||||
"code": "15",
|
||||
"slug": "Machining",
|
||||
"name": "Machining",
|
||||
"kind": "shop",
|
||||
"weld": False,
|
||||
"supervisor_group": True,
|
||||
"stations": ["Mill 1", "Lathe 1"],
|
||||
}
|
||||
r = pg_client.post("/api/admin/departments", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
out = r.json()
|
||||
assert out["groups_to_create"] == ["PS-15-Machining-Worker", "PS-15-Machining-Lead", "PS-15-Machining-Supervisor"]
|
||||
assert out["department"]["sort"] == 190 and out["department"]["created_at"]
|
||||
assert [s["name"] for s in out["department"]["stations"]] == ["Mill 1", "Lathe 1"]
|
||||
assert all(g["in_ou"] is False for g in out["department"]["groups"])
|
||||
# The group map now knows the new groups
|
||||
assert groups.role_for_group("PS-15-Machining-Lead") is not None
|
||||
assert pg_client.post("/api/admin/departments", json=body).json()["code"] == "code_taken"
|
||||
r = pg_client.post("/api/admin/departments", json={**body, "code": "16", "slug": "machining"})
|
||||
assert r.status_code == 409 and r.json()["code"] == "slug_taken"
|
||||
for bad in ({"code": "1"}, {"code": "abc"}, {"slug": "9Bad"}, {"slug": "Has Space"}):
|
||||
assert pg_client.post("/api/admin/departments", json={**body, "code": "17", **bad}).status_code == 422, bad
|
||||
r = pg_client.post("/api/admin/departments", json={**body, "code": "17", "slug": "Front", "kind": "support"})
|
||||
assert r.status_code == 422 and r.json()["code"] == "support_fixed"
|
||||
|
||||
acts = _actions(pg_settings, "admin.department%")
|
||||
assert [a for a, _ in acts] == ["admin.department_updated", "admin.department_added"]
|
||||
assert acts[0][1]["before"]["name"] == "Weld Parts" and acts[0][1]["after"]["name"] == "Weld Parts Shop"
|
||||
assert acts[1][1]["groups_to_create"] == out["groups_to_create"]
|
||||
|
||||
|
||||
def test_profiles_and_flags(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
|
||||
for sam in ("weld.andy", "lead.lou", "dup.dave", "dup.dina", "noemp.nora"):
|
||||
sign_in(pg_client, sam)
|
||||
andy, lou = str(fake_dir.user("weld.andy").guid), str(fake_dir.user("lead.lou").guid)
|
||||
sign_in(pg_client, "admin.ann")
|
||||
stations = {s["name"]: s["id"] for s in pg_client.get("/api/departments").json()[6]["stations"]}
|
||||
assert pg_client.get("/api/departments").json()[6]["code"] == "03"
|
||||
|
||||
r = pg_client.patch(
|
||||
f"/api/admin/users/{andy}/profile",
|
||||
json={"welder_stamp": "AW-3", "station_limits": {"03": [stations["Stall 1"], stations["Stall 2"]]}},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["welder_stamp"] == "AW-3" and r.json()["station_limits"] == {
|
||||
"03": [stations["Stall 1"], stations["Stall 2"]]
|
||||
}
|
||||
r = pg_client.patch(f"/api/admin/users/{lou}/profile", json={"welder_stamp": "aw-3"})
|
||||
assert r.status_code == 409 and r.json()["code"] == "stamp_taken"
|
||||
for bad in ("x" * 13, "A B", "A_1"):
|
||||
r = pg_client.patch(f"/api/admin/users/{lou}/profile", json={"welder_stamp": bad})
|
||||
assert r.status_code == 422, bad
|
||||
r = pg_client.patch(f"/api/admin/users/{andy}/profile", json={"station_limits": {"03": [999999]}})
|
||||
assert r.status_code == 422 and r.json()["code"] == "unknown_station"
|
||||
r = pg_client.patch(f"/api/admin/users/{andy}/profile", json={"station_limits": {"ZZ": []}})
|
||||
assert r.status_code == 422 and r.json()["code"] == "unknown_department"
|
||||
assert pg_client.patch(f"/api/admin/users/{andy}/profile", json={"active": False}).status_code == 422
|
||||
|
||||
row = next(u for u in pg_client.get("/api/admin/users").json() if u["id"] == andy)
|
||||
assert row["welder_stamp"] == "AW-3" and row["profile_active"] is True
|
||||
assert row["station_limits"] == {"03": [stations["Stall 1"], stations["Stall 2"]]}
|
||||
assert "directory_flags" in row and "groups" not in row["directory_flags"]
|
||||
detail = pg_client.get(f"/api/admin/users/{andy}").json()
|
||||
assert detail["welder_stamp"] == "AW-3"
|
||||
|
||||
# The welder picker honours profile_active
|
||||
people = {p["id"] for p in pg_client.get("/api/departments/03/people").json()}
|
||||
assert andy in people
|
||||
r = pg_client.patch(
|
||||
f"/api/admin/users/{andy}/profile", json={"profile_active": False, "station_limits": {"03": []}}
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["profile_active"] is False and r.json()["station_limits"] == {}
|
||||
assert andy not in {p["id"] for p in pg_client.get("/api/departments/03/people").json()}
|
||||
r = pg_client.patch(f"/api/admin/users/{andy}/profile", json={"welder_stamp": None})
|
||||
assert r.json()["welder_stamp"] is None
|
||||
|
||||
acts = _actions(pg_settings, "admin.profile%")
|
||||
assert [a for a, _ in acts] == ["admin.profile_updated"] * 3
|
||||
assert acts[0][1]["before"]["welder_stamp"] is None and acts[0][1]["after"]["welder_stamp"] == "AW-3"
|
||||
|
||||
# Directory flags as the reconcile writes them
|
||||
with app_conn(pg_settings) as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET directory_flags = %s WHERE sam_account IN ('dup.dave', 'dup.dina')",
|
||||
(Jsonb({"emp_duplicate": True}),),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE users SET directory_flags = %s WHERE sam_account = 'noemp.nora'", (Jsonb({"emp_missing": True}),)
|
||||
)
|
||||
flags = pg_client.get("/api/admin/directory-flags").json()
|
||||
assert [m["sam_account"] for m in flags["missing"]] == ["noemp.nora"]
|
||||
assert flags["duplicate"][0]["emp_no"] == "9001"
|
||||
assert sorted(u["sam_account"] for u in flags["duplicate"][0]["users"]) == ["dup.dave", "dup.dina"]
|
||||
assert flags["checked_at"] is not None
|
||||
|
||||
|
||||
def test_checklists_cas(pg_client: TestClient, pg_settings: Settings) -> None:
|
||||
sign_in(pg_client, "admin.ann")
|
||||
got = pg_client.get("/api/admin/checklists").json()
|
||||
assert got["defaults"] == DEFAULT_TEMPLATES and set(got["value"]) == {"weld", "plain"}
|
||||
version = got["version"]
|
||||
value = copy.deepcopy(got["value"])
|
||||
value["plain"].append({"group": "When done", "key": "tag", "text": "Tag on {drawing} rev {rev}"})
|
||||
r = pg_client.put("/api/admin/checklists", json={"value": value, "version": version})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["version"] == version + 1 and r.json()["value"]["plain"][-1]["key"] == "tag"
|
||||
|
||||
stale = pg_client.put("/api/admin/checklists", json={"value": value, "version": version})
|
||||
assert stale.status_code == 409 and stale.json()["current"]["version"] == version + 1
|
||||
|
||||
def bad(change: dict) -> dict:
|
||||
v = copy.deepcopy(value)
|
||||
v["weld"][0] = {**v["weld"][0], **change}
|
||||
r = pg_client.put("/api/admin/checklists", json={"value": v, "version": version + 1})
|
||||
assert r.status_code == 422, change
|
||||
return r.json()
|
||||
|
||||
assert "unknown placeholder {stamp}" in bad({"text": "Stamp {stamp}"})["detail"]
|
||||
assert "brace" in bad({"text": "Open { brace"})["detail"]
|
||||
assert "key" in bad({"key": "Bad-Key"})["detail"]
|
||||
assert "used twice" in bad({"key": "heat"})["detail"]
|
||||
assert "group is empty" in bad({"group": " "})["detail"]
|
||||
assert "text is empty" in bad({"text": ""})["detail"]
|
||||
too_many = copy.deepcopy(value)
|
||||
too_many["plain"] = [{"group": "G", "key": f"k{i}", "text": "t"} for i in range(41)]
|
||||
assert pg_client.put("/api/admin/checklists", json={"value": too_many, "version": version + 1}).status_code == 422
|
||||
empty = {"weld": value["weld"], "plain": []}
|
||||
assert pg_client.put("/api/admin/checklists", json={"value": empty, "version": version + 1}).status_code == 422
|
||||
|
||||
acts = _actions(pg_settings, "admin.checklists%")
|
||||
assert len(acts) == 1 and acts[0][1]["after"]["plain"][-1]["key"] == "tag"
|
||||
assert len(acts[0][1]["before"]["plain"]) == len(value["plain"]) - 1
|
||||
Reference in New Issue
Block a user