Phase 9: Admin completion, reconcile, notifications, integrity, backups
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

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:
2026-10-21 18:27:09 -06:00
co-authored by Claude Opus 5.5
parent ce28d2f3c6
commit a82e056b6a
116 changed files with 15125 additions and 297 deletions
+153
View File
@@ -0,0 +1,153 @@
"""The profile CSV import: preview, every error, apply refused with errors, apply clean, the audit rows."""
from __future__ import annotations
from typing import Any
import pytest
from fakedir import FakeDirectory, FakeUser
from fastapi.testclient import TestClient
from pshelpers import app_conn, needs_pg, sign_in
from packetstation.domain import profiles
from packetstation.settings import Settings
pg = needs_pg
PEOPLE = ("weld.andy", "lead.lou", "sup.wes", "dup.dave", "dup.dina", "wh.wade", "qc.quinn")
def _import(client: TestClient, text: str, apply: bool = False) -> Any:
return client.post("/api/admin/users/import", json={"csv": text, "apply": apply})
def _profiles(settings: Settings) -> dict[str, tuple[Any, ...]]:
with app_conn(settings) as conn:
rows = conn.execute(
"SELECT u.sam_account, u.welder_stamp, u.profile_active,"
" (SELECT array_agg(s.dept_code || ':' || s.name ORDER BY s.dept_code, s.name)"
" FROM user_station_limits l JOIN stations s ON s.id = l.station_id WHERE l.user_id = u.id)"
" FROM users u"
).fetchall()
return {r[0]: (r[1], r[2], r[3] or []) for r in rows}
def test_parse_csv_shapes() -> None:
cols, rows = profiles.parse_csv("Employee_ID , Welder_Stamp\r\n0042,A1\r\n\r\n,\r\n7A,\r\n")
assert cols == ["employee_id", "welder_stamp"]
assert rows == [(2, {"employee_id": "0042", "welder_stamp": "A1"}), (5, {"employee_id": "7A", "welder_stamp": ""})]
for text, code in (
("", "bad_csv"),
("welder_stamp\nA1\n", "bad_csv"),
("employee_id,colour\n1,red\n", "bad_csv"),
("employee_id,employee_id\n1,1\n", "bad_csv"),
("employee_id\n" + "1\n" * (profiles.CSV_MAX_ROWS + 1), "bad_csv"),
("employee_id\n" + "x" * profiles.CSV_MAX_BYTES, "too_large"),
):
with pytest.raises(Exception) as e:
profiles.parse_csv(text)
assert getattr(e.value, "code", None) == code, text[:40]
@pg
def test_import_preview_errors_and_apply(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
# An employee ID with a leading zero, kept as text
fake_dir.users.append(FakeUser("zero.zed", "Zed Zero", "0042", ["PS-04-SouthWeld-Worker"]))
fake_dir.users.append(FakeUser("extra.ezra", "Ezra Extra", "7777", ["PS-03-WeldParts-Worker"]))
for sam in (*PEOPLE, "zero.zed", "extra.ezra"):
assert sign_in(pg_client, sam).status_code == 200, sam
sign_in(pg_client, "admin.ann")
with app_conn(pg_settings) as conn:
conn.execute("UPDATE users SET welder_stamp = 'W9' WHERE sam_account = 'sup.wes'")
before = _profiles(pg_settings)
bad = (
"employee_id,welder_stamp,active,stations\n"
"3003,A1,yes,03:Stall 1;03:stall 2\n" # 2 ok
"3003,A2,,\n" # 3 repeated in the file
"5555,,,\n" # 4 unknown employee ID
"9001,X1,,\n" # 5 on more than one user
"3002,bad stamp!,,\n" # 6 bad stamp
"6001,W9,,\n" # 7 stamp taken (sup.wes)
"5001,,maybe,\n" # 8 bad active
"0042,,,99:Stall 1\n" # 9 unknown department
"0042x,,,\n" # 10 unknown (not the same as 0042)
"42,,,\n" # 11 unknown: 42 is not 0042
",A3,,\n" # 12 no employee ID
"2003,,,03:Stall 99\n" # 13 unknown station
"7777,A1,,\n" # 14 A1 already claimed by line 2 in this file
)
r = _import(pg_client, bad)
assert r.status_code == 200, r.text
body = r.json()
assert body["applied"] is False and body["ok"] == 1 and body["errors"] == 12
rows = {x["line"]: x for x in body["rows"]}
assert rows[2]["errors"] == [] and rows[2]["display_name"] == "Andy Welder"
assert rows[2]["changes"] == {
"welder_stamp": {"from": None, "to": "A1"},
"station_limits": {"from": [], "to": ["03:Stall 1", "03:Stall 2"]},
}
expect = {
3: "repeated in the file (first on line 2)",
4: "Unknown employee ID 5555",
5: "on more than one user",
6: "Bad stamp",
7: "Stamp W9 is taken by Wes Super",
8: "Bad active value maybe",
9: "Unknown department 99",
10: "Unknown employee ID 0042x",
11: "Unknown employee ID 42",
12: "Employee ID is missing",
13: "Unknown station Stall 99 in department 03",
14: "Stamp A1 is taken by Andy Welder",
}
for line, text in expect.items():
assert len(rows[line]["errors"]) == 1 and text in rows[line]["errors"][0], (line, rows[line]["errors"])
# Apply with errors: 422 with the same body, nothing changed
r = _import(pg_client, bad, apply=True)
assert r.status_code == 422 and r.json()["code"] == "import_errors"
assert r.json()["rows"] == body["rows"] and r.json()["applied"] is False
assert _profiles(pg_settings) == before
# A clean file: a stamp moves from Wes to Lou, Wes gets a new one, limits set and cleared, a profile goes off
with app_conn(pg_settings) as conn:
conn.execute(
"INSERT INTO user_station_limits (user_id, station_id) SELECT u.id, s.id FROM users u, stations s"
" WHERE u.sam_account = 'wh.wade' AND s.dept_code = '01' AND s.name = 'Station 1'"
)
clean = (
"Employee_ID,Welder_Stamp,Active,Stations\n"
"3003,A1,yes,03:Stall 1;03:stall 2\n"
"3002,W9,,\n"
"2003,W10,,\n"
"6001,,no,-\n"
"0042,-,,04:Stall 3\n"
"5001,,,\n"
)
preview = _import(pg_client, clean).json()
assert preview["errors"] == 0 and preview["ok"] == 6, preview
assert {x["line"]: x["changes"] for x in preview["rows"]}[7] == {}
r = _import(pg_client, clean, apply=True)
assert r.status_code == 200, r.text
assert r.json()["applied"] is True
after = _profiles(pg_settings)
assert after["weld.andy"] == ("A1", True, ["03:Stall 1", "03:Stall 2"])
assert after["lead.lou"][0] == "W9" and after["sup.wes"][0] == "W10"
assert after["wh.wade"] == (None, False, [])
assert after["zero.zed"] == (None, True, ["04:Stall 3"])
assert after["qc.quinn"] == before["qc.quinn"]
with app_conn(pg_settings) as conn:
acts = conn.execute(
"SELECT action, subject_id, detail FROM audit_events WHERE action LIKE 'admin.profile%' ORDER BY id"
).fetchall()
assert [a[0] for a in acts] == ["admin.profile_updated"] * 5 + ["admin.profile_import"]
assert acts[-1][2] == {"rows": 6, "changed": 5, "unchanged": 1}
assert all(a[2]["source"] == "import" for a in acts[:5])
wade = next(a for a in acts if a[0] == "admin.profile_updated" and a[2]["after"]["profile_active"] is False)
assert wade[2]["before"]["profile_active"] is True and wade[2]["before"]["station_limits"] != {}
# Over 1 MB is refused before it is read
r = _import(pg_client, "employee_id\n" + "1" * (profiles.CSV_MAX_BYTES + 10))
assert r.status_code == 413