Files
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

165 lines
6.9 KiB
Python

"""The integrity job: a clean run, then a tampered seal, a changed file, a missing file, an orphan file and an
edited audit row (through a superuser connection with the append-only trigger disabled)."""
from __future__ import annotations
import hashlib
import os
import time
import uuid
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import psycopg
import pytest
from closehelpers import filed
from fakedir import FakeDirectory
from pshelpers import PG_URL, app_conn, needs_pg
from psycopg.conninfo import conninfo_to_dict, make_conninfo
from shophelpers import Shop, open_shop
from packetstation.domain import assets
from packetstation.settings import Settings
from packetstation.worker import jobs, runs
from packetstation.worker.jobs import integrity
from packetstation.worker.scheduler import Scheduler
def test_full_on_sundays_site_time() -> None:
tz = ZoneInfo("America/Denver")
assert integrity.full_day(datetime(2026, 10, 25, 4, tzinfo=tz)) is True # a Sunday
assert integrity.full_day(datetime(2026, 10, 21, 4, tzinfo=tz)) is False
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
class Events:
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, Any]]] = []
def raise_event(self, conn: Any, event_type: str, **kw: Any) -> int:
self.calls.append(("raise", event_type, kw))
return 1
def resolve_event(self, conn: Any, event_type: str, **kw: Any) -> None:
self.calls.append(("resolve", event_type, kw))
def superuser(settings: Settings) -> psycopg.Connection[Any]:
assert PG_URL
info = conninfo_to_dict(PG_URL)
info["dbname"] = settings.pg_database
return psycopg.connect(make_conninfo(**info), autocommit=True)
def run_integrity(settings: Settings) -> dict[str, Any]:
sch = Scheduler(settings, build="pytest", jobs={"integrity": integrity.SPEC})
return runs.run_job(sch, "integrity", "schedule")
@needs_pg
def test_integrity(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
s = shop.settings
ev = Events()
monkeypatch.setattr(jobs, "raise_event", ev.raise_event)
monkeypatch.setattr(jobs, "resolve_event", ev.resolve_event)
monkeypatch.setattr(integrity, "full_day", lambda now: False)
pkt = filed(shop)
pid = pkt["id"]
row = run_integrity(s)
assert row["status"] == "ok", row["errors"]
c = row["counts"]
assert c["seals"] == 1 and c["total"] == 0 and c["assets"] >= 1 and c["hashed"] == c["assets"]
assert c["audit_rows"] > 0 and c["full"] is False
assert ev.calls == [("resolve", "integrity_failure", {"subject": "integrity", "title": "Integrity check failure"})]
with app_conn(s) as conn:
assert (
conn.execute("SELECT count(*) FROM assets WHERE hash_ok AND hash_checked_at IS NOT NULL").fetchone()[0]
== c["assets"]
)
db_id, db_key = conn.execute(
"SELECT a.id, a.storage_key FROM packet_seals s JOIN assets a ON a.id = s.databook_asset_id"
" WHERE s.packet_id = %s",
(pid,),
).fetchone()
# An old asset whose file changed: only the Sunday run rehashes it
old_id = uuid.uuid4()
old_key = assets.storage_key(old_id)
old_path = assets.path_of(s, old_key)
old_path.parent.mkdir(parents=True, exist_ok=True)
old_path.write_bytes(b"changed later")
conn.execute(
"INSERT INTO assets (id, sha256, size, mime, storage_key, uploaded_at)"
" VALUES (%s, %s, 5, 'application/pdf', %s, now() - interval '30 days')",
(old_id, hashlib.sha256(b"first").digest(), old_key),
)
# An asset row with no file
gone_id = uuid.uuid4()
conn.execute(
"INSERT INTO assets (id, sha256, size, mime, storage_key) VALUES (%s, %s, 1, 'application/pdf', %s)",
(gone_id, hashlib.sha256(b"x").digest(), assets.storage_key(gone_id)),
)
# Tamper with the filed packet behind the app's back
conn.execute("UPDATE packets SET title = title || ' (edited)' WHERE id = %s", (pid,))
# The data book's bytes change on disk
with assets.path_of(s, str(db_key)).open("ab") as f:
f.write(b"\n% tampered\n")
# A file nobody's row names, old enough to count; and a fresh one, skipped as an upload in flight
orphan = Path(s.assets_dir) / "zz" / "orphan.bin"
orphan.parent.mkdir(parents=True, exist_ok=True)
orphan.write_bytes(b"orphan")
old = time.time() - 2 * 3600
os.utime(orphan, (old, old))
(Path(s.assets_dir) / "zz" / "fresh.bin").write_bytes(b"in flight")
# An audit row edited through a superuser connection with the append-only trigger off
with superuser(s) as su:
aid = su.execute("SELECT id FROM audit_events ORDER BY id LIMIT 1 OFFSET 2").fetchone()[0]
su.execute("ALTER TABLE audit_events DISABLE TRIGGER USER")
try:
su.execute("UPDATE audit_events SET detail = '{\"edited\": true}' WHERE id = %s", (aid,))
finally:
su.execute("ALTER TABLE audit_events ENABLE TRIGGER USER")
ev.calls.clear()
row = run_integrity(s)
assert row["status"] == "ok", row["errors"]
f = row["counts"]["findings"]
assert f == {"seal": 1, "asset_hash": 1, "asset_missing": 1, "blob_orphan": 1, "audit_chain": 1}
assert orphan.exists() # reported, never deleted
((kind, etype, kw),) = ev.calls
assert (kind, etype) == ("raise", "integrity_failure") and kw["condition"] is True and kw["subject"] == "integrity"
assert kw["title"].startswith("Integrity check found 5 problems") and "file changed" in kw["title"]
with app_conn(s) as conn:
found = {
r[0]: (r[1], r[2])
for r in conn.execute(
"SELECT kind, subject, detail FROM integrity_findings WHERE run_id = %s", (row["id"],)
).fetchall()
}
assert found["seal"][0] == f"packet:{pid}"
assert found["asset_hash"][0] == str(db_id)
assert found["asset_missing"][0] == str(gone_id)
assert found["blob_orphan"][0] == "zz/orphan.bin"
assert found["audit_chain"][0] == str(aid)
oks = dict(conn.execute("SELECT id, hash_ok FROM assets").fetchall())
assert oks[db_id] is False and oks[gone_id] is False and oks[old_id] is None # not rehashed on a weekday
# Sunday: every file is rehashed, so the old one counts too
monkeypatch.setattr(integrity, "full_day", lambda now: True)
row = run_integrity(s)
assert row["counts"]["full"] is True and row["counts"]["findings"]["asset_hash"] == 2
with app_conn(s) as conn:
assert conn.execute("SELECT hash_ok FROM assets WHERE id = %s", (old_id,)).fetchone()[0] is False
assert conn.execute("SELECT count(*) FROM integrity_findings").fetchone()[0] == 11