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
+98
View File
@@ -0,0 +1,98 @@
"""The backup-check job: the newest dump and the markers under BACKUP_DIR."""
from __future__ import annotations
import os
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
from pshelpers import make_settings, needs_pg
from packetstation.settings import Settings
from packetstation.worker import jobs, runs
from packetstation.worker.jobs import backup_check
from packetstation.worker.scheduler import Scheduler
HOUR = 3600
def put(folder: Path, name: str, data: bytes = b"PGDMP dump", hours_ago: float = 1.0) -> Path:
p = folder / name
p.write_bytes(data)
t = time.time() - hours_ago * HOUR
os.utime(p, (t, t))
return p
def test_fresh_dump_is_fine(tmp_path: Path) -> None:
put(tmp_path, "packetstation-test-20261020.dump", hours_ago=30)
put(tmp_path, "packetstation-test-20261021.dump", data=b"x" * 2048, hours_ago=2)
put(tmp_path, "last-success", b"", hours_ago=2)
put(tmp_path, "last-failure", b"", hours_ago=50)
put(tmp_path, "notes.txt", hours_ago=0)
counts, problems = backup_check.inspect(tmp_path)
assert problems == []
assert counts["dumps"] == 2 and counts["newest"] == "packetstation-test-20261021.dump"
assert counts["size"] == 2048 and 1.9 <= counts["age_hours"] <= 2.1
@pytest.mark.parametrize(
"setup,expect",
[
(lambda d: None, "No backup dump"),
(lambda d: put(d, "a.dump", hours_ago=27), "27 hours old"),
(lambda d: put(d, "a.dump", b"", hours_ago=1), "is empty"),
(
lambda d: (put(d, "a.dump"), put(d, "last-success", b"", 5), put(d, "last-failure", b"", 0.5)),
"last backup run failed",
),
(lambda d: (put(d, "a.dump"), put(d, "last-failure", b"", 0.5)), "last backup run failed"),
],
)
def test_problems(tmp_path: Path, setup: Any, expect: str) -> None:
setup(tmp_path)
_, problems = backup_check.inspect(tmp_path)
assert any(expect in p for p in problems), problems
def test_missing_folder(tmp_path: Path) -> None:
counts, problems = backup_check.inspect(tmp_path / "nope")
assert counts["dumps"] == 0 and problems and "not there" in problems[0]
def test_age_uses_the_given_clock(tmp_path: Path) -> None:
p = put(tmp_path, "a.dump", hours_ago=0)
at = datetime.fromtimestamp(p.stat().st_mtime, UTC)
_, problems = backup_check.inspect(tmp_path, now=at.replace(year=at.year + 1))
assert problems and "hours old" in problems[0]
@needs_pg
def test_job_raises_then_resolves(pg_settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, str, dict[str, Any]]] = []
monkeypatch.setattr(jobs, "raise_event", lambda conn, t, **kw: calls.append(("raise", t, kw)))
monkeypatch.setattr(jobs, "resolve_event", lambda conn, t, **kw: calls.append(("resolve", t, kw)))
folder = tmp_path / "backups"
folder.mkdir()
s = pg_settings.model_copy(update={"backup_dir": folder})
sch = Scheduler(s, build="pytest", jobs={"backup-check": backup_check.SPEC})
row = runs.run_job(sch, "backup-check", "schedule")
assert row["status"] == "ok" and row["counts"]["dumps"] == 0 and row["counts"]["problems"]
((kind, etype, kw),) = calls
assert (kind, etype, kw["subject"], kw["condition"]) == ("raise", "backup_stale", "ci", True)
assert kw["title"] == "Backup failed or stale (ci)" and "No backup dump" in kw["body"]
calls.clear()
put(folder, "ps.dump")
put(folder, "last-success", b"")
row = runs.run_job(sch, "backup-check", "schedule")
assert row["counts"]["problems"] == [] and row["counts"]["newest"] == "ps.dump"
assert calls == [("resolve", "backup_stale", {"subject": "ci", "title": "Backup failed or stale (ci)"})]
def test_settings_backup_dir_default(tmp_path: Path) -> None:
assert make_settings(tmp_path).backup_dir == tmp_path / "no-backups"