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,133 @@
|
||||
"""Helpers for the notify tests: a module-scoped database, a small directory fake with the notify calls, users."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import PG_URL, app_conn, make_settings
|
||||
|
||||
from packetstation.auth.ldap import AdUser
|
||||
from packetstation.notify.routing import cn_of
|
||||
from packetstation.settings import Settings
|
||||
|
||||
BASE_DN = "DC=pstest,DC=local"
|
||||
GROUP_BASE_DN = f"OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON,{BASE_DN}"
|
||||
OTHER_BASE_DN = f"OU=Groups,{BASE_DN}"
|
||||
OUTSIDE = "Ops-Mail"
|
||||
|
||||
|
||||
def fresh_db(tmp: Path, **over: Any) -> Iterator[Settings]:
|
||||
"""A fresh migrated database (like conftest.pg_settings), dropped afterwards."""
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
|
||||
from packetstation import db, migrate
|
||||
|
||||
assert PG_URL
|
||||
info = conninfo_to_dict(PG_URL)
|
||||
mig_pw, app_pw = secrets.token_urlsafe(18), secrets.token_urlsafe(18)
|
||||
(tmp / "mig").write_text(mig_pw + "\n")
|
||||
(tmp / "app").write_text(app_pw + "\n")
|
||||
name = f"ps_test_{secrets.token_hex(4)}"
|
||||
s = make_settings(
|
||||
tmp,
|
||||
pg_host=info.get("host", "localhost"),
|
||||
pg_port=int(info.get("port", 5432)),
|
||||
pg_database=name,
|
||||
pg_migrate_password_file=tmp / "mig",
|
||||
pg_app_password_file=tmp / "app",
|
||||
pg_superuser_url=PG_URL,
|
||||
ldap_base_dn=BASE_DN,
|
||||
ldap_group_base_dn=GROUP_BASE_DN,
|
||||
smtp_from="[email protected]",
|
||||
**over,
|
||||
)
|
||||
migrate.bootstrap_roles(s)
|
||||
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||||
for user, pw in (("ps_migrate", mig_pw), ("ps_app", app_pw)):
|
||||
su.execute(sql.SQL("ALTER ROLE {} LOGIN PASSWORD {}").format(sql.Identifier(user), sql.Literal(pw)))
|
||||
with db.connect_as("migrate", s, autocommit=True) as conn:
|
||||
migrate.run(conn, db.MIGRATIONS_DIR)
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
db.close_pool()
|
||||
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||||
su.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(name)))
|
||||
|
||||
|
||||
class NotifyDir(FakeDirectory):
|
||||
"""FakeDirectory plus members_of_dn and search_groups, by DN under GROUP_BASE_DN or OTHER_BASE_DN.
|
||||
|
||||
OUTSIDE is a group outside the OU whose only member is qc.quinn."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.outside: dict[str, list[str]] = {OUTSIDE: ["qc.quinn"]}
|
||||
self.member_calls: list[str] = []
|
||||
|
||||
@staticmethod
|
||||
def dn(name: str) -> str:
|
||||
return f"CN={name},{OTHER_BASE_DN if name == OUTSIDE else GROUP_BASE_DN}"
|
||||
|
||||
def members_of_dn(self, group_dn: str) -> list[AdUser]:
|
||||
self._check()
|
||||
self.member_calls.append(group_dn)
|
||||
name = cn_of(group_dn)
|
||||
if name in self.outside:
|
||||
return [self._ad(self.user(sam)) for sam in self.outside[name]]
|
||||
if name.lower() not in {g.lower() for g in self.ou_groups}:
|
||||
return []
|
||||
return [self._ad(u) for u in self.users if name.lower() in self._closure(u)]
|
||||
|
||||
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, Any]]:
|
||||
self._check()
|
||||
key = q.strip().lower()
|
||||
names = sorted({*self.ou_groups, OUTSIDE}, key=str.lower)
|
||||
return [{"dn": self.dn(n), "name": n, "description": f"group {n}"} for n in names if key in n.lower()][:limit]
|
||||
|
||||
|
||||
def add_users(settings: Settings, directory: FakeDirectory, sams: tuple[str, ...] | None = None) -> dict[str, str]:
|
||||
"""users rows for the fake people (id = their GUID, email [email protected]). Returns sam -> id."""
|
||||
out: dict[str, str] = {}
|
||||
with app_conn(settings) as conn:
|
||||
for u in directory.users:
|
||||
if sams is not None and u.sam not in sams:
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, sam_account, display_name, emp_no, email, active) VALUES (%s, %s, %s, %s, %s,"
|
||||
" %s) ON CONFLICT (id) DO NOTHING",
|
||||
(u.guid, u.sam, u.display, u.emp_no, f"{u.sam}@example.test", not u.disabled),
|
||||
)
|
||||
out[u.sam] = str(u.guid)
|
||||
return out
|
||||
|
||||
|
||||
def clean(settings: Settings) -> int:
|
||||
"""Empty the notification tables a test touches (notifications_sent is append-only: returns its max id)."""
|
||||
with app_conn(settings) as conn:
|
||||
conn.execute("DELETE FROM notification_outbox")
|
||||
conn.execute("DELETE FROM notification_events")
|
||||
conn.execute("DELETE FROM notification_exclusions")
|
||||
conn.execute("DELETE FROM notification_recipients")
|
||||
conn.execute("DELETE FROM notification_rules")
|
||||
conn.execute("DELETE FROM settings WHERE key = 'notify.last_build'")
|
||||
conn.execute("DELETE FROM drawing_lookups")
|
||||
return int(conn.execute("SELECT coalesce(max(id), 0) FROM notifications_sent").fetchone()[0])
|
||||
|
||||
|
||||
def sent_since(settings: Settings, marker: int) -> list[dict[str, Any]]:
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
with app_conn(settings) as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
return [dict(r) for r in cur.execute("SELECT * FROM notifications_sent WHERE id > %s ORDER BY id", (marker,))]
|
||||
|
||||
|
||||
def new_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
Reference in New Issue
Block a user