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
+50
View File
@@ -10,6 +10,9 @@ from packetstation.auth.ldap import AdUser, LdapUnavailable
FAKE_PASSWORD = "fake-password-1"
NESTED_GROUP = "Shop-Welders-Nested"
BASE_DN = "DC=pstest,DC=local"
OU_DN = "OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON," + BASE_DN
OTHER_DN = "OU=Groups,OU=PESCO_FARMINGTON," + BASE_DN
@dataclass
@@ -62,12 +65,19 @@ class FakeDirectory:
users: list[FakeUser] | None = None,
ou_groups: list[str] | None = None,
nested: dict[str, list[str]] | None = None,
other_groups: dict[str, str] | None = None,
):
self.users = users if users is not None else default_users()
if ou_groups is None:
ou_groups = [g for g in group_names() if g != "PS-14-Electrical-Lead"] + ["PS-Extra-Legacy"]
self.ou_groups = ou_groups
self.nested = nested if nested is not None else {NESTED_GROUP: ["PS-10-WeldAssembly-Worker"]}
# Groups outside the OU, by name, with a description (the nested helper group and a mail group)
self.other_groups = (
other_groups
if other_groups is not None
else {NESTED_GROUP: "Helper group for the nested membership test", "IT-Helpdesk": "IT help desk"}
)
self.down = False
self.binds = 0
self.searches = 0
@@ -96,6 +106,18 @@ class FakeDirectory:
todo.extend(self.nested.get(g, []))
return seen
def group_dn(self, name: str) -> str:
"""The DN of a group: in the PacketStation OU, or under Groups for the ones outside it."""
if any(g.lower() == name.lower() for g in self.ou_groups):
return f"CN={name},{OU_DN}"
return f"CN={name},{OTHER_DN}"
def _group_by_dn(self, dn: str) -> str | None:
for name in [*self.ou_groups, *self.other_groups]:
if self.group_dn(name).lower() == dn.strip().lower():
return name
return None
def _in_ou(self, names: set[str]) -> list[str]:
return sorted(g for g in self.ou_groups if g.lower() in names)
@@ -138,3 +160,31 @@ class FakeDirectory:
if group_cn.lower() not in {g.lower() for g in self.ou_groups}:
return []
return [self._ad(u) for u in self.users if group_cn.lower() in self._closure(u)]
def list_groups_detail(self) -> list[tuple[str, str]]:
self._check()
return sorted(((g, self.group_dn(g)) for g in self.ou_groups), key=lambda t: t[0].lower())
def members_of_dn(self, group_dn: str) -> list[AdUser]:
self._check()
name = self._group_by_dn(group_dn)
if name is None:
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, str]]:
self._check()
key = q.strip().lower()
if not key:
return []
out = []
for name in sorted({*self.ou_groups, *self.other_groups}, key=str.lower):
desc = self.other_groups.get(name, f"Packet Station: {name}")
if key in name.lower() or key in desc.lower():
out.append({"dn": self.group_dn(name), "name": name, "description": desc})
return out[: max(1, limit)]
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]:
self._check()
by = {u.guid: u for u in self.users}
return {g: (self._ad(by[g]) if g in by else None) for g in guids}
+278
View File
@@ -0,0 +1,278 @@
"""An in-process SMTP server with STARTTLS, and a throwaway CA made at test time. Never sends mail anywhere.
`make_ca(dir)` writes a CA and a server certificate for 127.0.0.1 and localhost (with `cryptography` when it is
installed, else the `openssl` command). `FakeSmtp(certs)` listens on 127.0.0.1 and keeps every message it takes.
"""
from __future__ import annotations
import contextlib
import datetime as dt
import shutil
import socket
import ssl
import subprocess
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
CRLF = b"\r\n"
@dataclass
class Certs:
ca: Path
cert: Path
key: Path
def can_make_ca() -> bool:
try:
import cryptography # noqa: F401
except ImportError:
return shutil.which("openssl") is not None
return True
def make_ca(where: Path, name: str = "ca") -> Certs:
where.mkdir(parents=True, exist_ok=True)
try:
return _with_cryptography(where, name)
except ImportError:
return _with_openssl(where, name)
def _with_cryptography(where: Path, name: str) -> Certs:
import ipaddress
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
now = dt.datetime.now(dt.UTC)
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"Packet Station test {name}")])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)
.issuer_name(ca_name)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - dt.timedelta(minutes=5))
.not_valid_after(now + dt.timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(x509.KeyUsage(True, False, False, False, False, True, True, False, False), critical=True)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]))
.issuer_name(ca_name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - dt.timedelta(minutes=5))
.not_valid_after(now + dt.timedelta(days=1))
.add_extension(
x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
pem = serialization.Encoding.PEM
certs = Certs(where / f"{name}.pem", where / f"{name}-server.pem", where / f"{name}-server.key")
certs.ca.write_bytes(ca_cert.public_bytes(pem))
certs.cert.write_bytes(cert.public_bytes(pem))
certs.key.write_bytes(
key.private_bytes(pem, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption())
)
return certs
def _with_openssl(where: Path, name: str) -> Certs:
exe = shutil.which("openssl")
if exe is None:
raise RuntimeError("neither cryptography nor openssl is available")
certs = Certs(where / f"{name}.pem", where / f"{name}-server.pem", where / f"{name}-server.key")
ca_key = where / f"{name}.key"
csr = where / f"{name}-server.csr"
ext = where / f"{name}-ext.cnf"
ext.write_text(
"basicConstraints=critical,CA:FALSE\nextendedKeyUsage=serverAuth\n"
"subjectAltName=DNS:localhost,IP:127.0.0.1\nauthorityKeyIdentifier=keyid\n"
)
ca_cfg = where / f"{name}-ca.cnf"
ca_cfg.write_text(
"[req]\ndistinguished_name=dn\nx509_extensions=v3_ca\nprompt=no\n[dn]\nCN=Packet Station test "
f"{name}\n[v3_ca]\nbasicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n"
"subjectKeyIdentifier=hash\n"
)
def run(*args: str) -> None:
subprocess.run([exe, *args], check=True, capture_output=True, timeout=60)
run("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256", "-days", "1", "-keyout", str(ca_key),
"-out", str(certs.ca), "-config", str(ca_cfg)) # fmt: skip
run("req", "-newkey", "rsa:2048", "-nodes", "-sha256", "-keyout", str(certs.key), "-out", str(csr),
"-subj", "/CN=localhost") # fmt: skip
run("x509", "-req", "-in", str(csr), "-CA", str(certs.ca), "-CAkey", str(ca_key), "-CAcreateserial",
"-days", "1", "-sha256", "-extfile", str(ext), "-out", str(certs.cert)) # fmt: skip
return certs
@dataclass
class Message:
mail_from: str
rcpts: list[str]
data: bytes
tls: bool
@property
def text(self) -> str:
return self.data.decode("utf-8", "replace")
def header(self, name: str) -> str | None:
import email
msg = email.message_from_bytes(self.data)
value: Any = msg.get(name)
return None if value is None else str(value)
@dataclass
class FakeSmtp:
"""A threaded SMTP server. offer_tls=False leaves STARTTLS out of EHLO. refuse: addresses RCPT refuses."""
certs: Certs | None = None
offer_tls: bool = True
refuse: set[str] = field(default_factory=set)
messages: list[Message] = field(default_factory=list)
connections: int = 0
tls_failures: int = 0
def __post_init__(self) -> None:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.bind(("127.0.0.1", 0))
self._sock.listen(8)
self._sock.settimeout(0.2)
self.host, self.port = self._sock.getsockname()
self._stop = threading.Event()
self._thread = threading.Thread(target=self._serve, daemon=True)
self._lock = threading.Lock()
def __enter__(self) -> FakeSmtp:
self._thread.start()
return self
def __exit__(self, *exc: object) -> None:
self._stop.set()
self._thread.join(5)
self._sock.close()
def _serve(self) -> None:
while not self._stop.is_set():
try:
conn, _ = self._sock.accept()
except (TimeoutError, OSError):
continue
threading.Thread(target=self._session, args=(conn,), daemon=True).start()
def _session(self, conn: socket.socket) -> None:
with self._lock:
self.connections += 1
conn.settimeout(10)
try:
self._talk(conn)
finally:
with contextlib.suppress(OSError):
conn.close()
def _talk(self, conn: socket.socket) -> None:
sock: Any = conn
buf = b""
tls = False
mail_from, rcpts = "", [] # type: str, list[str]
def send(line: str) -> None:
sock.sendall(line.encode() + CRLF)
def readline() -> bytes | None:
nonlocal buf
while CRLF not in buf:
chunk = sock.recv(65536)
if not chunk:
return None
buf += chunk
line, buf = buf.split(CRLF, 1)
return line
with contextlib.suppress(OSError, ssl.SSLError, ValueError):
send("220 fake.test ESMTP")
while True:
raw = readline()
if raw is None:
return
cmd = raw.decode("utf-8", "replace")
verb = cmd.split(" ", 1)[0].upper()
if verb == "EHLO":
caps = ["250-fake.test", "250-8BITMIME"]
if self.offer_tls and not tls and self.certs is not None:
caps.append("250-STARTTLS")
caps.append("250 SMTPUTF8")
sock.sendall("\r\n".join(caps).encode() + b"\r\n")
elif verb == "HELO":
send("250 fake.test")
elif verb == "STARTTLS":
if not self.offer_tls or self.certs is None or tls:
send("454 TLS not available")
continue
send("220 Ready to start TLS")
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(str(self.certs.cert), str(self.certs.key))
try:
sock = ctx.wrap_socket(conn, server_side=True)
except (ssl.SSLError, OSError):
with self._lock:
self.tls_failures += 1
return
tls, buf, mail_from, rcpts = True, b"", "", []
elif verb == "MAIL":
mail_from = cmd.split(":", 1)[1].split()[0].strip("<>")
rcpts = []
send("250 OK")
elif verb == "RCPT":
addr = cmd.split(":", 1)[1].split()[0].strip("<>")
if addr.lower() in self.refuse:
send("550 No such user")
else:
rcpts.append(addr)
send("250 OK")
elif verb == "DATA":
if not rcpts:
send("554 No valid recipients")
continue
send("354 End data with <CR><LF>.<CR><LF>")
while b"\r\n.\r\n" not in b"\r\n" + buf:
chunk = sock.recv(65536)
if not chunk:
return
buf += chunk
full = b"\r\n" + buf
data, rest = full.split(b"\r\n.\r\n", 1)
buf = rest
with self._lock:
self.messages.append(Message(mail_from, list(rcpts), data[2:], tls))
send("250 OK queued")
elif verb in ("RSET", "NOOP"):
send("250 OK")
elif verb == "QUIT":
send("221 Bye")
return
else:
send("502 Command not implemented")
+133
View File
@@ -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()
+32 -38
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from fakedir import FAKE_PASSWORD, FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, PIN, app_conn, needs_pg, pin_signin, register_device, set_pin, sign_in
from psycopg.types.json import Jsonb
from packetstation.main import create_app
from packetstation.settings import Settings
@@ -34,53 +35,46 @@ def test_group_map_flags_missing_and_extra(pg_client: TestClient, fake_dir: Fake
assert weld["role"] == "shop:03:lead" and weld["dept_code"] == "03"
def test_ad_refresh_counts_and_flags(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
def test_ad_refresh_requests_a_reconcile_run(pg_client: TestClient, pg_settings: Settings) -> None:
"""POST /ad/refresh asks the worker for an ad-reconcile run; the counts are the reconcile's (test_reconcile)."""
sign_in(pg_client, "admin.ann")
res = pg_client.post("/api/admin/ad/refresh").json()
assert res == {
"groups_checked": 49,
"groups_missing": ["PS-14-Electrical-Lead"],
"users_seen": 16,
"created": 15,
"updated": 1,
r = pg_client.post("/api/admin/ad/refresh")
assert r.status_code == 202, r.text
assert r.json() == {"requested": True, "pending": 1, "job": "ad-reconcile"}
assert pg_client.post("/api/admin/ad/refresh").json()["pending"] == 2
empty = pg_client.get("/api/admin/ad/reconcile").json()
assert empty["run"] is None and empty["pending"] == 2
assert empty["report"] == {
"groups_missing": [],
"groups_extra": [],
"users_seen": 0,
"created": 0,
"updated": 0,
"deactivated": 0,
"emp_missing": ["noemp.nora"],
"emp_duplicate": ["9001"],
"sessions_ended": 0,
"emp_missing": [],
"emp_duplicate": [],
}
users = {u["sam_account"]: u for u in pg_client.get("/api/admin/users").json()}
assert "norole.nick" not in users and len(users) == 16
assert users["dup.dave"]["flags"]["emp_duplicate"] and users["dup.dina"]["flags"]["emp_duplicate"]
assert users["noemp.nora"]["flags"]["emp_missing"] and users["noemp.nora"]["emp_no"] is None
assert users["disabled.dan"]["active"] is False and users["disabled.dan"]["flags"]["disabled"]
assert users["nested.ned"]["roles"] == ["shop:10:worker"]
assert users["both.bo"]["roles"] == ["shop:03:supervisor"]
assert users["multi.mia"]["roles"] == ["clerical", "shop:06:supervisor"]
assert [u["sam_account"] for u in pg_client.get("/api/admin/users", params={"q": "dup"}).json()] == [
"dup.dave",
"dup.dina",
]
# Clara signs in elsewhere, then AD disables her; the next refresh deactivates her and ends the session
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as other:
sign_in(other, "clerk.clara")
fake_dir.user("clerk.clara").disabled = True
fake_dir.users.remove(fake_dir.user("wh.wade")) # gone from AD
fake_dir.user("qc.quinn").groups = [] # still in AD, in no group
res = pg_client.post("/api/admin/ad/refresh").json()
assert res["deactivated"] == 2 and res["users_seen"] == 14
assert other.get("/api/auth/me").json()["code"] == "no_session"
users = {u["sam_account"]: u for u in pg_client.get("/api/admin/users").json()}
assert users["clerk.clara"]["active"] is False and users["wh.wade"]["active"] is False
assert users["qc.quinn"]["active"] is True and users["qc.quinn"]["roles"] == []
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT count(*) FROM job_requests WHERE job = 'ad-reconcile'").fetchone()[0] == 2
acts = conn.execute("SELECT action FROM audit_events WHERE action LIKE 'ad.%' ORDER BY id").fetchall()
assert [a[0] for a in acts] == ["ad.refresh", "ad.user_deactivated", "ad.user_deactivated", "ad.refresh"]
assert [a[0] for a in acts] == ["ad.refresh_requested", "ad.refresh_requested"]
conn.execute(
"INSERT INTO job_runs (job, trigger, finished_at, status, counts) VALUES ('ad-reconcile', 'request',"
" now(), 'ok', %s)",
(Jsonb({"users_seen": 16, "created": 15, "groups_missing": ["PS-14-Electrical-Lead"], "x": 1}),),
)
conn.execute("INSERT INTO job_runs (job, trigger, status) VALUES ('ad-reconcile', 'schedule', 'running')")
got = pg_client.get("/api/admin/ad/reconcile").json()
assert got["run"]["status"] == "ok" and got["run"]["trigger"] == "request"
assert got["report"]["users_seen"] == 16 and got["report"]["created"] == 15
assert got["report"]["groups_missing"] == ["PS-14-Electrical-Lead"] and "x" not in got["report"]
def test_user_detail_and_lookup(pg_client: TestClient, fake_dir: FakeDirectory) -> None:
sign_in(pg_client, "multi.mia") # the user row exists
pg_client.post("/api/auth/signout")
sign_in(pg_client, "admin.ann")
pg_client.post("/api/admin/ad/refresh")
mia = next(u for u in pg_client.get("/api/admin/users", params={"q": "mia"}).json())
detail = pg_client.get(f"/api/admin/users/{mia['id']}").json()
assert detail["groups"] == ["PS-06-CodeBldg2-Supervisor", "PS-Clerical"] and detail["groups_source"] == "ad"
+223
View File
@@ -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
+125
View File
@@ -0,0 +1,125 @@
"""Every phase 9 Admin endpoint as every role: an Admin password session gets through, an Admin PIN session and every
other role get 403, and no session gets 401."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, pin_signin, register_device, set_pin, sign_in
from packetstation.domain.checklists import DEFAULT_TEMPLATES
from packetstation.main import create_app
from packetstation.settings import Settings
pytestmark = needs_pg
OTHER_ROLES = ("clerk.clara", "clerk.leo", "wh.wade", "qc.quinn", "pm.paula", "weld.andy", "lead.lou", "sup.wes")
def tiny_pdf(text: str = "No stamp here") -> bytes:
import io
from reportlab.pdfgen.canvas import Canvas
buf = io.BytesIO()
c = Canvas(buf)
c.drawString(40, 40, text)
c.save()
return buf.getvalue()
@pytest.fixture(autouse=True)
def restore_group_map() -> Iterator[None]:
"""Adding a department rebuilds the process-wide group map; put the seed map back afterwards."""
from packetstation.auth import groups
yield
groups.use_departments(groups.DEPARTMENTS)
def endpoints(andy: str, ck_version: int) -> list[tuple[str, str, dict[str, Any], int]]:
"""(method, path, request kwargs, the Admin's status)."""
return [
("GET", "/api/admin/departments", {}, 200),
("PATCH", "/api/admin/departments/03", {"json": {"name": "Weld Parts"}}, 200),
(
"POST",
"/api/admin/departments",
{"json": {"code": "15", "slug": "Machining", "name": "Machining", "kind": "shop", "stations": ["Mill 1"]}},
201,
),
("GET", "/api/admin/users", {}, 200),
("PATCH", f"/api/admin/users/{andy}/profile", {"json": {"welder_stamp": "A12"}}, 200),
("POST", "/api/admin/users/import", {"json": {"csv": "employee_id,welder_stamp\n3003,A13\n"}}, 200),
("GET", "/api/admin/checklists", {}, 200),
("PUT", "/api/admin/checklists", {"json": {"value": DEFAULT_TEMPLATES, "version": ck_version}}, 200),
("GET", "/api/admin/security/settings", {}, 200),
("PUT", "/api/admin/security/settings", {"json": {"values": {}, "version": 0}}, 200),
("GET", "/api/admin/security/alerts", {}, 200),
("GET", "/api/admin/security/integrity", {}, 200),
("GET", "/api/admin/access-log", {}, 200),
("GET", "/api/admin/access-log.csv", {}, 200),
("GET", "/api/admin/presence", {}, 200),
("POST", "/api/admin/stamp-verify", {"json": {"text": "0123456789abcdef"}}, 200),
("POST", "/api/admin/stamp-verify/pdf", {"files": {"file": ("a.pdf", tiny_pdf(), "application/pdf")}}, 200),
("GET", "/api/admin/directory-flags", {}, 200),
("POST", "/api/admin/ad/refresh", {}, 202),
("GET", "/api/admin/ad/reconcile", {}, 200),
]
def call(client: TestClient, method: str, path: str, kw: dict[str, Any]) -> Any:
return client.request(method, path, **kw)
def test_every_new_admin_endpoint_as_every_role(
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory
) -> None:
andy = str(fake_dir.user("weld.andy").guid)
sign_in(pg_client, "weld.andy") # the user row exists
pg_client.post("/api/auth/signout")
with app_conn(pg_settings) as conn:
row = conn.execute("SELECT version FROM settings WHERE key = 'checklist_templates'").fetchone()
eps = endpoints(andy, int(row[0]) if row else 0)
# Anonymous
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as anon:
for method, path, kw, _ in eps:
r = call(anon, method, path, kw)
assert r.status_code == 401, (method, path, r.status_code)
# Every other role
for sam in OTHER_ROLES:
assert sign_in(pg_client, sam).status_code == 200, sam
for method, path, kw, _ in eps:
r = call(pg_client, method, path, kw)
assert r.status_code == 403 and r.json()["code"] == "forbidden", (sam, method, path, r.status_code)
pg_client.post("/api/auth/signout")
# An Admin on a PIN session
register_device(pg_client)
set_pin(pg_client, "admin.ann")
assert pin_signin(pg_client, "1001").status_code == 200
for method, path, kw, _ in eps:
r = call(pg_client, method, path, kw)
assert r.status_code == 403 and r.json()["code"] == "password_session_required", (method, path)
pg_client.post("/api/auth/signout")
# An Admin on a password session
assert sign_in(pg_client, "admin.ann").status_code == 200
for method, path, kw, status in eps:
r = call(pg_client, method, path, kw)
assert r.status_code == status, (method, path, r.status_code, r.text)
# Every refusal to a signed-in person was audited once per path (no bodies)
with app_conn(pg_settings) as conn:
n = conn.execute(
"SELECT count(*) FROM audit_events WHERE action = 'policy.denied' AND detail->>'code' = 'forbidden'"
).fetchone()[0]
assert n == len(OTHER_ROLES) * len(eps)
details = conn.execute("SELECT detail FROM audit_events WHERE action = 'policy.denied' LIMIT 1").fetchone()[0]
assert set(details) == {"method", "path", "code"}
+236
View File
@@ -0,0 +1,236 @@
"""Admin Security: alerts, the integrity result, the access log and its CSV, who is in, the stamp check."""
from __future__ import annotations
import csv
import io
from datetime import UTC, datetime, timedelta
from typing import Any
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import PIN, app_conn, needs_pg, pin_signin, register_device, set_pin, sign_in
from psycopg.types.json import Jsonb
from packetstation.auth import ratelimit
from packetstation.pdf import stamps
from packetstation.settings import Settings
pytestmark = needs_pg
FORMULA = "=cmd|' /C calc'!A0"
def test_access_log_filters_pages_and_csv(
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory
) -> None:
assert sign_in(pg_client, "clerk.clara").status_code == 200
assert sign_in(pg_client, "clerk.clara", "wrong").status_code == 401
assert sign_in(pg_client, FORMULA, "wrong").status_code == 401
clara = str(fake_dir.user("clerk.clara").guid)
with app_conn(pg_settings) as conn:
ratelimit.record(conn, account="wh.wade", ip="10.1.2.3", kind="password", ok=False, reason="bad_password")
sign_in(pg_client, "admin.ann")
rows = pg_client.get("/api/admin/access-log").json()["rows"]
sources = {r["source"] for r in rows}
assert sources == {"attempt", "audit"}
assert all(
set(r) == {"id", "at", "source", "kind", "ok", "account", "user_id", "display_name", "ip", "detail"}
for r in rows
)
failed = pg_client.get("/api/admin/access-log", params={"ok": "false", "source": "x"}).json()["rows"]
assert failed and all(r["ok"] is False for r in failed)
assert {r["account"] for r in failed if r["source"] == "attempt"} == {"clerk.clara", FORMULA.lower(), "wh.wade"}
mine = pg_client.get("/api/admin/access-log", params={"user_id": clara}).json()["rows"]
assert mine and all(r["user_id"] == clara for r in mine)
assert {r["kind"] for r in mine} >= {"password", "auth.signin"}
q = pg_client.get("/api/admin/access-log", params={"q": "Clara"}).json()["rows"]
assert q and all(r["display_name"] == "Clara Clerk" or r["account"] == "clerk.clara" for r in q)
kind = pg_client.get("/api/admin/access-log", params={"kind": "auth.signin"}).json()["rows"]
assert kind and all(r["kind"] == "auth.signin" and r["source"] == "audit" for r in kind)
ip = pg_client.get("/api/admin/access-log", params={"ip": "10.1.2.0/24"}).json()["rows"]
assert [r["account"] for r in ip] == ["wh.wade"] and ip[0]["ip"] == "10.1.2.3"
assert pg_client.get("/api/admin/access-log", params={"ip": "nope"}).status_code == 422
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
assert pg_client.get("/api/admin/access-log", params={"since": future}).json()["rows"] == []
assert pg_client.get("/api/admin/access-log", params={"until": future}).json()["rows"]
# Pages: no row twice, none missed (attempt and audit rows share a timestamp inside one transaction)
every = pg_client.get("/api/admin/access-log", params={"limit": 1000}).json()
assert every["next_before"] is None
seen: list[str] = []
before = ""
while True:
page = pg_client.get("/api/admin/access-log", params={"limit": 3, "before": before}).json()
seen += [r["id"] for r in page["rows"]]
if not page["next_before"]:
break
before = page["next_before"]
assert seen == [r["id"] for r in every["rows"]]
assert pg_client.get("/api/admin/access-log", params={"before": "garbage"}).status_code == 422
# The CSV: the same rows, the formula guard, logged and audited
r = pg_client.get("/api/admin/access-log.csv", params={"ok": "false"})
assert r.status_code == 200 and r.headers["content-type"].startswith("text/csv")
assert "attachment" in r.headers["content-disposition"]
table = list(csv.reader(io.StringIO(r.text)))
assert table[0] == ["at", "source", "kind", "ok", "account", "display_name", "user_id", "ip", "detail"]
accounts = [row[4] for row in table[1:]]
assert "'" + FORMULA.lower() in accounts and FORMULA.lower() not in accounts
assert all(row[3] == "no" for row in table[1:])
with app_conn(pg_settings) as conn:
log = conn.execute("SELECT kind, what FROM print_log").fetchall()
assert log == [("access-log-csv", f"Access log CSV ({len(table) - 1} rows)")]
detail = conn.execute("SELECT detail FROM audit_events WHERE action = 'admin.access_log_exported'").fetchone()[
0
]
assert detail["rows"] == len(table) - 1 and detail["filters"] == {"ok": "False"}
def _print_row(settings: Settings, user_id: Any, *, sig_override: str | None = None) -> tuple[int, str, str]:
who = {"id": str(user_id), "display_name": "Ann Admin", "emp_no": "1001"}
now = datetime.now(UTC)
text = stamps.controlled_stamp(who, now, mode="report")
line, sig = stamps.signed_stamp(settings, text, packet_id=None, kind="report", seal_hash_hex=None, who=who, now=now)
if sig_override is not None:
line, sig = stamps.with_sig(text, sig_override), sig_override
with app_conn(settings) as conn:
pid = conn.execute(
"INSERT INTO print_log (by, by_name, kind, what, pages, stamp, stamp_sig)"
" VALUES (%s, 'Ann Admin', 'report-pdf', 'Production status report', 2, %s, %s) RETURNING id",
(user_id, line, sig),
).fetchone()[0]
return int(pid), line, sig
def _pdf(text: str) -> bytes:
from reportlab.pdfgen.canvas import Canvas
buf = io.BytesIO()
c = Canvas(buf)
c.setFont("Helvetica", 6)
c.drawString(20, 20, text)
c.drawString(20, 60, "Some other text on the page")
c.save()
return buf.getvalue()
def test_stamp_verify(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
sign_in(pg_client, "admin.ann")
ann = fake_dir.user("admin.ann").guid
pid, line, sig = _print_row(pg_settings, ann)
def check(text: str) -> dict[str, Any]:
r = pg_client.post("/api/admin/stamp-verify", json={"text": text})
assert r.status_code == 200, r.text
out: dict[str, Any] = r.json()
return out
ok = check(line)
assert ok["found"] is True and ok["valid"] is True, ok
assert ok["print"] == {
"id": pid,
"at": ok["print"]["at"],
"by_name": "Ann Admin",
"kind": "report-pdf",
"what": "Production status report",
"pages": 2,
"packet_id": None,
"packet_label": None,
}
assert check(sig)["valid"] is True # the 16 hex alone
assert check(sig.upper())["valid"] is True
assert check(" " + line.replace(" - ", " - ") + "\n")["valid"] is True # spacing does not matter
tail = line[line.index("printed by") :]
assert check(tail)["valid"] is True # a partial copy from the end
tampered = check(line.replace("Ann Admin", "Bob Admin"))
assert tampered["found"] is True and tampered["valid"] is False and "differs" in tampered["reason"]
other_sig = ("0" if sig[0] != "0" else "1") + sig[1:]
unknown = check(line.replace(sig, other_sig))
assert unknown["found"] is False and unknown["valid"] is False and unknown["print"] is None
nothing = check("no stamp in here")
assert nothing["found"] is False and nothing["reason"] == "No stamp signature in the text."
# A print_log row whose signature was never made with the seal key
_, forged_line, _ = _print_row(pg_settings, ann, sig_override="deadbeefdeadbeef")
forged = check(forged_line)
assert forged["found"] is True and forged["valid"] is False and "does not match" in forged["reason"]
# From a PDF
r = pg_client.post("/api/admin/stamp-verify/pdf", files={"file": ("copy.pdf", _pdf(line), "application/pdf")})
assert r.status_code == 200, r.text
found = r.json()["stamps"]
assert len(found) == 1 and found[0]["valid"] is True and found[0]["print"]["id"] == pid
r = pg_client.post(
"/api/admin/stamp-verify/pdf",
files={"file": ("copy.pdf", _pdf(line.replace("Ann Admin", "Bob Admin")), "application/pdf")},
)
assert r.json()["stamps"][0]["valid"] is False
r = pg_client.post("/api/admin/stamp-verify/pdf", files={"file": ("x.pdf", b"not a pdf", "application/pdf")})
assert r.status_code == 422 and r.json()["code"] == "not_pdf"
with app_conn(pg_settings) as conn:
n = conn.execute("SELECT count(*) FROM audit_events WHERE action = 'admin.stamp_verified'").fetchone()[0]
assert n == 11
def test_alerts_integrity_and_presence(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
# Three wrong PINs at sign-in, then a session ended after three wrong unlock PINs
register_device(pg_client)
set_pin(pg_client, "weld.andy")
for _ in range(3):
assert pin_signin(pg_client, "3003", "000000").status_code == 401
with app_conn(pg_settings) as conn:
conn.execute("UPDATE user_pins SET failed_count = 0")
assert pin_signin(pg_client, "3003", PIN).status_code == 200
pg_client.post("/api/auth/lock")
for _ in range(3):
pg_client.post("/api/auth/unlock", json={"factor": "pin", "secret": "111111"})
# A denial
sign_in(pg_client, "clerk.clara")
assert pg_client.get("/api/admin/users").status_code == 403
with app_conn(pg_settings) as conn:
run = conn.execute(
"INSERT INTO job_runs (job, trigger, finished_at, status, counts) VALUES ('integrity', 'schedule', now(),"
" 'degraded', %s) RETURNING id",
(Jsonb({"seal": 1, "asset_hash": 0}),),
).fetchone()[0]
conn.execute(
"INSERT INTO integrity_findings (run_id, kind, subject, detail) VALUES (%s, 'seal', 'packet:7', %s)",
(run, Jsonb({"why": "hash differs"})),
)
conn.execute("INSERT INTO job_runs (job, trigger, status) VALUES ('integrity', 'schedule', 'running')")
sign_in(pg_client, "admin.ann")
alerts = pg_client.get("/api/admin/security/alerts", params={"hours": 1}).json()
kinds = {a["kind"] for a in alerts}
assert {"pin_failures", "stepup_wrong_pin", "denied", "integrity"} <= kinds, kinds
pins = next(a for a in alerts if a["kind"] == "pin_failures")
assert pins["display_name"] == "Andy Welder" and pins["detail"]["wrong_pins"] >= 3
steps = [a for a in alerts if a["kind"] == "stepup_wrong_pin"]
assert len(steps) == 3 and steps[0]["detail"]["where"] == "unlock"
denied = next(a for a in alerts if a["kind"] == "denied")
assert denied["display_name"] == "Clara Clerk" and denied["detail"]["path"] == "/api/admin/users"
assert [a["at"] for a in alerts] == sorted((a["at"] for a in alerts), reverse=True)
integ = pg_client.get("/api/admin/security/integrity").json()
assert integ["run"]["id"] == run and integ["run"]["status"] == "degraded"
assert integ["run"]["counts"] == {"seal": 1, "asset_hash": 0}
assert integ["findings"][0]["kind"] == "seal" and integ["findings"][0]["subject"] == "packet:7"
assert integ["findings"][0]["detail"] == {"why": "hash differs"}
who = pg_client.get("/api/admin/presence").json()
assert who["at"]
names = {s["display_name"]: s for s in who["sessions"]}
assert "Ann Admin" in names and names["Ann Admin"]["kind"] == "password" and names["Ann Admin"]["locked"] is False
assert "Andy Welder" not in names # that session ended
with app_conn(pg_settings) as conn:
conn.execute(
"UPDATE sessions SET last_seen_at = now() - interval '20 minutes' WHERE user_id = %s",
(fake_dir.user("clerk.clara").guid,),
)
# Clara is a desk role (60 minutes), so she is still in; a shop worker idle 20 minutes is not
assert "Clara Clerk" in {s["display_name"] for s in pg_client.get("/api/admin/presence").json()["sessions"]}
+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"
+287
View File
@@ -0,0 +1,287 @@
"""scripts/backup/ps-backup.sh with a fake `docker` on PATH.
No Docker and no database: the fake answers `docker ps`, `docker exec ... pg_dump`, `pg_dumpall` and
`pg_restore --list`, and records every call. Runs on Linux (CI, docker-1) and on Windows when a
Git Bash or Cygwin bash with cygpath is installed; skipped otherwise.
"""
from __future__ import annotations
import os
import re
import shutil
import stat
import subprocess
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[2]
BACKUP = REPO / "scripts" / "backup" / "ps-backup.sh"
RESTORE = REPO / "scripts" / "backup" / "ps-restore.sh"
WINDOWS = sys.platform == "win32"
FAKE_DOCKER = r"""#!/usr/bin/env bash
# Fake docker for the backup script tests. Behaviour comes from FAKE_* variables.
printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG"
cmd="$1"; shift
case "$cmd" in
ps)
if [[ -n "${FAKE_PS_IDS+x}" ]]; then
[[ -n "$FAKE_PS_IDS" ]] && printf '%s\n' $FAKE_PS_IDS
else
echo pgfake
fi
exit 0 ;;
exec)
[[ "$1" == "-i" ]] && shift
container="$1"; shift
tool="$1"; shift
case "$tool" in
pg_dump)
[[ "$container" == "${FAKE_DUMP_FAIL_CONTAINER:-none}" ]] && { echo "pg_dump: error: fake" >&2; exit 1; }
[[ -n "${FAKE_DUMP_EMPTY:-}" ]] && exit 0
printf 'PGDMP fake dump of %s\n' "$container"
exit 0 ;;
pg_dumpall)
printf 'CREATE ROLE ps_app;\nALTER ROLE ps_app WITH LOGIN;\n'
exit 0 ;;
pg_restore)
input="$(cat)"
[[ -n "${FAKE_RESTORE_FAIL:-}" ]] && { echo "pg_restore: error: input file is too short" >&2; exit 1; }
[[ "$input" == PGDMP* ]] || { echo "pg_restore: error: not a dump" >&2; exit 1; }
printf ';\n; Archive created at 2026-10-21\n;\n215; 1259 16390 TABLE public packets ps_migrate\n'
printf '3801; 0 16390 TABLE DATA public packets ps_migrate\n'
exit 0 ;;
esac ;;
esac
echo "fake docker: unexpected call: $cmd $*" >&2
exit 99
"""
def _find_bash() -> str | None:
if not WINDOWS:
return shutil.which("bash")
candidates = [
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files\Git\usr\bin\bash.exe",
shutil.which("bash") or "",
]
for c in candidates:
if not c or not Path(c).exists() or "system32" in c.lower():
continue
try:
out = subprocess.run(
[c, "-c", "cygpath -u 'C:\\' && command -v find stat mktemp"],
capture_output=True,
text=True,
timeout=30,
)
except OSError:
continue
if out.returncode == 0:
return c
return None
BASH = _find_bash()
pytestmark = pytest.mark.skipif(BASH is None, reason="no usable bash (Linux bash, or Git Bash with cygpath)")
def _posix(p: Path) -> str:
if not WINDOWS:
return str(p)
assert BASH is not None
out = subprocess.run([BASH, "-c", 'cygpath -u "$1"', "_", str(p)], capture_output=True, text=True, check=True)
return out.stdout.strip()
@pytest.fixture()
def fake(tmp_path: Path) -> dict[str, object]:
bindir = tmp_path / "bin"
bindir.mkdir()
docker = bindir / "docker"
docker.write_text(FAKE_DOCKER, encoding="utf-8", newline="\n")
docker.chmod(docker.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
log = tmp_path / "docker.log"
log.touch()
env = {k: v for k, v in os.environ.items() if not k.startswith("FAKE_")}
env["FAKE_DOCKER_LOG"] = _posix(log)
env["DOCKER"] = _posix(docker)
env["PATH"] = str(bindir) + os.pathsep + env.get("PATH", "")
env.pop("KEEP", None)
env.pop("PS_BACKUP_CONFIG", None)
return {"env": env, "log": log, "tmp": tmp_path}
def run_backup(fake: dict[str, object], *args: str, **extra_env: str) -> subprocess.CompletedProcess[str]:
env = dict(fake["env"]) # type: ignore[call-overload]
env.update(extra_env)
assert BASH is not None
return subprocess.run([BASH, _posix(BACKUP), *args], capture_output=True, text=True, env=env, timeout=120)
def stack_args(name: str, folder: Path, container: str = "project:packetstation-test") -> list[str]:
return ["--stack", name, container, "packetstation", _posix(folder)]
def dumps(folder: Path) -> list[str]:
return sorted(p.name for p in folder.glob("ps-*.dump"))
def no_partials(folder: Path) -> bool:
return not [p for p in folder.iterdir() if p.name.endswith(".partial")]
MARKER = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z (ps-\d{8}T\d{6}Z\.dump)(.*)$")
def test_success_writes_dump_globals_and_marker(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
res = run_backup(fake, *stack_args("test", folder))
assert res.returncode == 0, res.stderr
names = dumps(folder)
assert len(names) == 1
dump = folder / names[0]
assert dump.read_text().startswith("PGDMP")
globals_file = folder / names[0].replace(".dump", ".globals.sql")
assert "CREATE ROLE" in globals_file.read_text()
m = MARKER.match((folder / "last-success").read_text().strip())
assert m and m.group(1) == names[0]
assert not (folder / "last-failure").exists()
assert no_partials(folder)
if not WINDOWS:
assert stat.S_IMODE(dump.stat().st_mode) == 0o600
assert stat.S_IMODE(globals_file.stat().st_mode) == 0o600
assert stat.S_IMODE((folder / "last-success").stat().st_mode) == 0o644
calls = fake["log"].read_text() # type: ignore[attr-defined]
assert "label=com.docker.compose.project=packetstation-test" in calls
assert "exec pgfake pg_dump -U postgres -d packetstation -Fc" in calls
assert "pg_dumpall -U postgres --globals-only --no-role-passwords" in calls
assert "exec -i pgfake pg_restore --list" in calls
def test_rotation_keeps_seven(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
folder.mkdir()
old = [f"ps-202601{d:02d}T020000Z" for d in range(1, 9)] # 8 older dumps
for base in old:
(folder / f"{base}.dump").write_text("PGDMP old")
(folder / f"{base}.globals.sql").write_text("-- old")
(folder / "ps-20251231T020000Z.globals.sql").write_text("-- orphan globals")
res = run_backup(fake, *stack_args("test", folder))
assert res.returncode == 0, res.stderr
names = dumps(folder)
assert len(names) == 7
# The newest six old ones and today's stay; the two oldest go with their globals.
assert names[:6] == [f"{b}.dump" for b in old[2:]]
assert not (folder / f"{old[0]}.globals.sql").exists()
assert not (folder / f"{old[1]}.globals.sql").exists()
assert not (folder / "ps-20251231T020000Z.globals.sql").exists()
assert sorted(p.name for p in folder.glob("*.globals.sql")) == [n.replace(".dump", ".globals.sql") for n in names]
def test_keep_is_configurable(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
folder.mkdir()
for d in range(1, 5):
(folder / f"ps-202601{d:02d}T020000Z.dump").write_text("PGDMP old")
res = run_backup(fake, "--keep", "2", *stack_args("test", folder))
assert res.returncode == 0, res.stderr
assert len(dumps(folder)) == 2
def test_failed_restore_list_writes_last_failure(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
folder.mkdir()
(folder / "ps-20260101T020000Z.dump").write_text("PGDMP old")
(folder / "last-success").write_text("2026-01-01T02:00:05Z ps-20260101T020000Z.dump\n")
res = run_backup(fake, *stack_args("test", folder), FAKE_RESTORE_FAIL="1")
assert res.returncode != 0
failure = (folder / "last-failure").read_text().strip()
m = MARKER.match(failure)
assert m, failure
assert "pg_restore --list" in m.group(2)
# Nothing new kept, nothing half-written, the old success left alone.
assert dumps(folder) == ["ps-20260101T020000Z.dump"]
assert not list(folder.glob("ps-*.globals.sql"))
assert no_partials(folder)
assert (folder / "last-success").read_text().startswith("2026-01-01")
assert "FAILED" in res.stderr
def test_failed_pg_dump_and_empty_dump(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
res = run_backup(fake, *stack_args("test", folder, container="pgdirect"), FAKE_DUMP_FAIL_CONTAINER="pgdirect")
assert res.returncode != 0
assert "pg_dump failed" in (folder / "last-failure").read_text()
assert dumps(folder) == []
assert no_partials(folder)
res = run_backup(fake, *stack_args("test", folder, container="pgdirect"), FAKE_DUMP_EMPTY="1")
assert res.returncode != 0
assert "pg_dump wrote nothing" in (folder / "last-failure").read_text()
assert not (folder / "last-success").exists()
def test_no_container_for_project(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
res = run_backup(fake, *stack_args("test", folder), FAKE_PS_IDS="")
assert res.returncode != 0
assert "no running postgres container" in (folder / "last-failure").read_text()
res = run_backup(fake, *stack_args("test", folder), FAKE_PS_IDS="aaa bbb")
assert res.returncode != 0
assert "more than one postgres container" in (folder / "last-failure").read_text()
def test_one_stack_fails_the_other_still_runs(fake: dict[str, object]) -> None:
good = fake["tmp"] / "good" # type: ignore[operator]
bad = fake["tmp"] / "bad" # type: ignore[operator]
res = run_backup(
fake,
*stack_args("bad", bad, container="pgbad"),
*stack_args("good", good, container="pggood"),
FAKE_DUMP_FAIL_CONTAINER="pgbad",
)
assert res.returncode == 1
assert (bad / "last-failure").exists() and not (bad / "last-success").exists()
assert (good / "last-success").exists() and not (good / "last-failure").exists()
assert len(dumps(good)) == 1
def test_config_file_and_stack_selection(fake: dict[str, object]) -> None:
tmp: Path = fake["tmp"] # type: ignore[assignment]
a, b = tmp / "a", tmp / "b"
conf = tmp / "backup.conf"
conf.write_text(
"# name container database folder\n"
f"alpha pga packetstation {_posix(a)}\n"
"\n"
f"beta pgb packetstation {_posix(b)}\n",
encoding="utf-8",
newline="\n",
)
res = run_backup(fake, "--config", _posix(conf), "beta")
assert res.returncode == 0, res.stderr
assert not a.exists()
assert len(dumps(b)) == 1
res = run_backup(fake, "--config", _posix(conf), "gamma")
assert res.returncode == 2
res = run_backup(fake, "--config", _posix(tmp / "missing.conf"))
assert res.returncode == 2
def test_scripts_are_strict_and_parse() -> None:
assert BASH is not None
for script in (BACKUP, RESTORE):
text = script.read_text(encoding="utf-8")
assert "\r" not in text
assert "set -euo pipefail" in text
assert "umask 077" in text
res = subprocess.run([BASH, "-n", _posix(script)], capture_output=True, text=True)
assert res.returncode == 0, res.stderr
+164
View File
@@ -0,0 +1,164 @@
"""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
+3 -1
View File
@@ -29,7 +29,9 @@ def test_jobs_list_and_run_now(pg_client: TestClient, pg_settings: Settings) ->
r = pg_client.get("/api/jobs")
assert r.status_code == 200, r.text
body = r.json()
assert [j["job"] for j in body["jobs"]] == ["drawing-lookup", "housekeeping", "visual-feed"]
jobs = [j["job"] for j in body["jobs"]]
assert {"drawing-lookup", "housekeeping", "visual-feed", "ad-reconcile", "notify-send"} <= set(jobs)
assert jobs == sorted(jobs)
assert body["heartbeat"]["stale"] is True and body["heartbeat"]["at"] is None
assert all(j["last_run"] is None and j["pending"] == 0 for j in body["jobs"])
+332
View File
@@ -0,0 +1,332 @@
"""The Notifications endpoints: the Admin's rules, group search, test send and sent log, and Account's view and
opt-out. One database for the module; the fake relay takes the test sends."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakesmtp import Certs, FakeSmtp, can_make_ca, make_ca
from fastapi.testclient import TestClient
from notifyhelpers import GROUP_BASE_DN, OUTSIDE, NotifyDir, add_users, clean, fresh_db
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, sign_in
from packetstation.settings import Settings
pytestmark = [needs_pg, pytest.mark.skipif(not can_make_ca(), reason="no way to make a test CA")]
ADMIN = "/api/admin/notifications"
ACCOUNT = "/api/account/notifications"
@pytest.fixture(scope="module")
def certs(tmp_path_factory: pytest.TempPathFactory) -> Certs:
return make_ca(tmp_path_factory.mktemp("ca"), "relay")
@pytest.fixture(scope="module")
def relay(certs: Certs) -> Iterator[FakeSmtp]:
with FakeSmtp(certs) as server:
yield server
@pytest.fixture(scope="module")
def base(tmp_path_factory: pytest.TempPathFactory, relay: FakeSmtp, certs: Certs) -> Iterator[Settings]:
yield from fresh_db(
tmp_path_factory.mktemp("db"),
smtp_host=relay.host,
smtp_port=relay.port,
smtp_ca_bundle=certs.ca,
notify_dry_run=True,
)
@pytest.fixture(scope="module")
def ndir() -> NotifyDir:
return NotifyDir()
def make_client(settings: Settings, directory: NotifyDir) -> TestClient:
from packetstation import guard
from packetstation.api import notifications
from packetstation.main import create_app
guard_load = guard.load_visual_settings
from pshelpers import fake_visual
guard.load_visual_settings = lambda: fake_visual(False) # type: ignore[assignment]
try:
app = create_app(settings, directory=directory)
finally:
guard.load_visual_settings = guard_load # type: ignore[assignment]
if not any(getattr(r, "path", "") == ADMIN for r in app.router.routes):
# main.py registers the router; until it does, put the routes ahead of the static catch-all
app.router.routes[0:0] = notifications.router.routes
return TestClient(app, headers=ORIGIN_HEADERS)
@pytest.fixture(scope="module")
def client(base: Settings, ndir: NotifyDir) -> Iterator[TestClient]:
with make_client(base, ndir) as c:
yield c
@pytest.fixture(scope="module")
def ids(base: Settings, ndir: NotifyDir, client: TestClient) -> dict[str, str]:
return add_users(base, ndir)
@pytest.fixture
def c(client: TestClient, base: Settings, ids: dict[str, str], relay: FakeSmtp, ndir: NotifyDir) -> TestClient:
clean(base)
relay.messages.clear()
ndir.down = False
client.cookies.clear()
return client
def as_user(client: TestClient, sam: str) -> None:
r = sign_in(client, sam)
assert r.status_code == 200, r.text
def rule(client: TestClient, event_type: str) -> dict[str, Any]:
r = client.get(ADMIN)
assert r.status_code == 200, r.text
return next(x for x in r.json()["rules"] if x["event_type"] == event_type)
def body_of(r: dict[str, Any], **over: Any) -> dict[str, Any]:
out = {
"enabled": r["enabled"],
"delivery": r["delivery"],
"digest_time": r["digest_time"],
"param": r["param"]["value"] if r["param"] else None,
"recipients": [
{k: v for k, v in x.items() if k in ("kind", "group_dn", "group_name", "user_id", "address") and v}
for x in r["recipients"]
],
"exclusions": [x["user_id"] for x in r["exclusions"] if not x["self"]],
"version": r["version"],
}
out.update(over)
return out
# ---------------------------------------------------------------- access
def test_admin_only(c: TestClient) -> None:
calls = [
("get", ADMIN, None),
("put", f"{ADMIN}/feed_failed", {}),
("get", f"{ADMIN}/groups?q=ps", None),
("post", f"{ADMIN}/feed_failed/test", {}),
("get", f"{ADMIN}/sent", None),
]
for method, path, body in calls:
r = c.request(method, path, json=body)
assert r.status_code == 401, (path, r.status_code)
as_user(c, "clerk.clara")
for method, path, body in calls:
r = c.request(method, path, json=body)
assert r.status_code == 403 and r.json()["code"] == "forbidden", (path, r.text)
assert c.get(ACCOUNT).status_code == 200
# ---------------------------------------------------------------- rules
def test_get_seeds_every_rule(c: TestClient, base: Settings) -> None:
as_user(c, "admin.ann")
body = c.get(ADMIN).json()
assert body["dry_run"] is True and body["subject_prefix"] == "[Packet Station TEST]"
assert body["smtp"] == {"host": base.smtp_host, "port": base.smtp_port, "from": base.smtp_from, "starttls": True}
assert len(body["rules"]) == 13
r = next(x for x in body["rules"] if x["event_type"] == "drawing_failing")
assert r["label"] == "Drawing lookup failing" and r["param"] == {"name": "hours", "value": 24, "default": 24}
assert r["enabled"] is True and r["delivery"] == "immediate" and r["digest_time"] == "07:00"
assert r["security"] is False and r["dept_scoped"] is False and r["version"] == 1 and r["last_sent"] is None
(rc,) = r["recipients"]
assert (
rc["kind"] == "group" and rc["group_dn"] == f"CN=PS-Admins,{GROUP_BASE_DN}" and rc["group_name"] == "PS-Admins"
)
sec = next(x for x in body["rules"] if x["event_type"] == "security_alert")
assert sec["security"] is True and sec["param"] is None
def test_update_rule(c: TestClient, base: Settings, ids: dict[str, str], ndir: NotifyDir) -> None:
as_user(c, "admin.ann")
r = rule(c, "qc_reject")
new = body_of(
r,
delivery="digest",
digest_time="06:30",
recipients=[
{"kind": "group", "group_dn": ndir.dn(OUTSIDE)},
{"kind": "user", "user_id": ids["pm.paula"]},
{"kind": "mailbox", "address": "[email protected]"},
{"kind": "dept_supervisor"},
],
exclusions=[ids["sup.wes"]],
)
put = c.put(f"{ADMIN}/qc_reject", json=new)
assert put.status_code == 200, put.text
out = put.json()
assert out["delivery"] == "digest" and out["digest_time"] == "06:30" and out["version"] == 2
kinds = [(x["kind"], x["group_name"], x["display_name"], x["address"]) for x in out["recipients"]]
assert kinds == [
("group", OUTSIDE, None, None),
("user", None, "Paula PM", None),
("mailbox", None, None, "[email protected]"),
("dept_supervisor", None, None, None),
]
assert out["exclusions"] == [{"user_id": ids["sup.wes"], "display_name": "Wes Super", "self": False}]
# Stale version
again = c.put(f"{ADMIN}/qc_reject", json=new)
assert again.status_code == 409 and again.json()["current"]["version"] == 2
with app_conn(base) as conn:
a = conn.execute(
"SELECT detail FROM audit_events WHERE action = 'admin.notification_rule_updated' ORDER BY id DESC"
).fetchone()[0]
assert a["event_type"] == "qc_reject" and a["before"]["delivery"] == "immediate"
assert a["after"]["delivery"] == "digest" and len(a["after"]["recipients"]) == 4
# A param on drawing_failing; none on a type without one
d = rule(c, "drawing_failing")
ok = c.put(f"{ADMIN}/drawing_failing", json=body_of(d, param=6))
assert ok.status_code == 200 and ok.json()["param"]["value"] == 6
bad = c.put(f"{ADMIN}/feed_failed", json=body_of(rule(c, "feed_failed"), param=3))
assert bad.status_code == 422
@pytest.mark.parametrize(
("event_type", "over", "text"),
[
("feed_failed", {"recipients": [{"kind": "mailbox", "address": "not an address"}]}, "email"),
("feed_failed", {"recipients": [{"kind": "group", "group_dn": "CN=X,DC=evil,DC=com"}]}, "domain"),
("feed_failed", {"recipients": [{"kind": "dept_supervisor"}]}, "department"),
("security_alert", {"exclusions": ["00000000-0000-0000-0000-000000000001"]}, "security"),
("feed_failed", {"digest_time": "25:00"}, "HH:MM"),
("feed_failed", {"exclusions": ["00000000-0000-0000-0000-000000000001"]}, "known user"),
],
)
def test_update_rule_refused(c: TestClient, event_type: str, over: dict[str, Any], text: str) -> None:
as_user(c, "admin.ann")
r = c.put(f"{ADMIN}/{event_type}", json=body_of(rule(c, event_type), **over))
assert r.status_code == 422, r.text
assert text in r.json()["detail"]
def test_unknown_event_type(c: TestClient) -> None:
as_user(c, "admin.ann")
assert c.post(f"{ADMIN}/nope/test", json={}).status_code == 404
def test_group_search(c: TestClient, ndir: NotifyDir) -> None:
as_user(c, "admin.ann")
found = c.get(f"{ADMIN}/groups", params={"q": "ops"}).json()
assert found == [{"dn": ndir.dn(OUTSIDE), "name": OUTSIDE, "description": f"group {OUTSIDE}"}]
assert c.get(f"{ADMIN}/groups", params={"q": "o"}).status_code == 422
ndir.down = True
r = c.get(f"{ADMIN}/groups", params={"q": "ops"})
assert r.status_code == 503 and r.json()["code"] == "ldap_unavailable"
# ---------------------------------------------------------------- test send and the log
def test_test_send_goes_even_in_dry_run(c: TestClient, base: Settings, relay: FakeSmtp, ids: dict[str, str]) -> None:
as_user(c, "admin.ann")
r = c.post(f"{ADMIN}/backup_stale/test", json={})
assert r.status_code == 200, r.text
out = r.json()
assert out["result"] == "sent" and out["error"] is None and out["sent_id"]
assert out["subject"] == "[Packet Station TEST] Test: Backup failed or stale"
assert [x["address"] for x in out["recipients"]] == ["[email protected]"]
to = c.post(f"{ADMIN}/backup_stale/test", json={"to": "[email protected]"}).json()
assert to["result"] == "sent" and [x["address"] for x in to["recipients"]] == ["[email protected]"]
assert [m.rcpts for m in relay.messages] == [["[email protected]"], ["[email protected]"]]
assert c.post(f"{ADMIN}/backup_stale/test", json={"to": "nope"}).status_code == 422
with app_conn(base) as conn:
n = conn.execute("SELECT count(*) FROM audit_events WHERE action = 'admin.notification_test'").fetchone()[0]
assert n >= 2
assert rule(c, "backup_stale")["last_sent"]["result"] == "sent"
log = c.get(f"{ADMIN}/sent", params={"event_type": "backup_stale", "limit": 1}).json()
assert len(log["rows"]) == 1 and log["rows"][0]["test"] is True and log["next_before"] == log["rows"][0]["id"]
nxt = c.get(f"{ADMIN}/sent", params={"event_type": "backup_stale", "limit": 1, "before": log["next_before"]})
assert nxt.json()["rows"][0]["id"] < log["rows"][0]["id"]
row = log["rows"][0]
assert set(row) == {"id", "at", "event_type", "digest", "test", "subject", "recipients", "result", "error"}
def test_test_send_with_mail_off(base: Settings, ndir: NotifyDir, ids: dict[str, str]) -> None:
clean(base)
off = base.model_copy(update={"smtp_host": ""})
with make_client(off, ndir) as c:
as_user(c, "admin.ann")
out = c.post(f"{ADMIN}/feed_failed/test", json={"to": "[email protected]"}).json()
assert out["result"] == "failed" and out["error"] == "Mail is off: SMTP_HOST is empty."
# ---------------------------------------------------------------- account
def mine(c: TestClient) -> dict[str, dict[str, Any]]:
r = c.get(ACCOUNT)
assert r.status_code == 200, r.text
return {x["event_type"]: x for x in r.json()}
def test_account_view_and_opt_out(c: TestClient, base: Settings, ids: dict[str, str], ndir: NotifyDir) -> None:
as_user(c, "admin.ann")
q = rule(c, "qc_reject")
recips = [*body_of(q)["recipients"], {"kind": "dept_supervisor"}, {"kind": "group", "group_dn": ndir.dn(OUTSIDE)}]
assert c.put(f"{ADMIN}/qc_reject", json=body_of(q, recipients=recips)).status_code == 200
f = rule(c, "feed_failed")
assert c.put(f"{ADMIN}/feed_failed", json=body_of(f, enabled=False)).status_code == 200
ann = mine(c)
assert "feed_failed" not in ann # off
assert ann["security_alert"] == {
"event_type": "security_alert",
"label": "Security alert",
"security": True,
"delivery": "immediate",
"reaches": True,
"why": ["group PS-Admins"],
"opted_out": False,
"can_opt_out": False,
}
r = c.put(f"{ACCOUNT}/security_alert", json={"opt_out": True})
assert r.status_code == 403
r = c.put(f"{ACCOUNT}/qc_reject", json={"opt_out": True})
assert r.status_code == 200, r.text
assert r.json()["opted_out"] is True and r.json()["reaches"] is False
assert c.put(f"{ACCOUNT}/qc_reject", json={"opt_out": False}).json()["opted_out"] is False
with app_conn(base) as conn:
acts = conn.execute(
"SELECT detail FROM audit_events WHERE action = 'account.notification_opt_out' ORDER BY id"
).fetchall()
assert [a[0]["opt_out"] for a in acts][-2:] == [True, False]
as_user(c, "sup.wes")
wes = mine(c)
assert wes["qc_reject"]["reaches"] is True and wes["qc_reject"]["why"] == ["Supervisor group of 03"]
assert wes["security_alert"]["reaches"] is False and wes["security_alert"]["why"] == []
as_user(c, "qc.quinn")
quinn = mine(c)
assert quinn["qc_reject"]["why"] == [f"group {OUTSIDE}"]
ndir.down = True
down = mine(c)
assert down["qc_reject"]["reaches"] is False and down["qc_reject"]["why"] == ["directory unavailable"]
def test_admin_exclusion_shows_on_account(c: TestClient, ids: dict[str, str]) -> None:
as_user(c, "admin.ann")
b = rule(c, "backup_stale")
assert c.put(f"{ADMIN}/backup_stale", json=body_of(b, exclusions=[ids["admin.ann"]])).status_code == 200
item = mine(c)["backup_stale"]
assert item["reaches"] is False and "left out by an Admin" in item["why"] and item["opted_out"] is False
+563
View File
@@ -0,0 +1,563 @@
"""Notifications on a real database: raise and resolve, seed, recipient resolution, delivery through the fake relay,
dry run, the digest, the watch checks, the version note, the feed hook and the two jobs.
One database for the module (the tunnel is slow); each test starts from empty notification tables.
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
import pytest
from fakesmtp import Certs, FakeSmtp, can_make_ca, make_ca
from notifyhelpers import OUTSIDE, NotifyDir, add_users, clean, fresh_db, sent_since
from pshelpers import app_conn, needs_pg
from packetstation.feed.config import FeedSettings
from packetstation.notify import digest, raise_event, resolve_event, seed, send, watch
from packetstation.notify.routing import Resolver, load_rule
from packetstation.settings import Settings
from packetstation.worker.jobs import JobContext, Stack
pytestmark = [needs_pg, pytest.mark.skipif(not can_make_ca(), reason="no way to make a test CA")]
@pytest.fixture(scope="module")
def certs(tmp_path_factory: pytest.TempPathFactory) -> Certs:
return make_ca(tmp_path_factory.mktemp("ca"), "relay")
@pytest.fixture(scope="module")
def relay(certs: Certs) -> Iterator[FakeSmtp]:
with FakeSmtp(certs) as server:
yield server
@pytest.fixture(scope="module")
def base(tmp_path_factory: pytest.TempPathFactory, relay: FakeSmtp, certs: Certs) -> Iterator[Settings]:
yield from fresh_db(
tmp_path_factory.mktemp("db"), smtp_host=relay.host, smtp_port=relay.port, smtp_ca_bundle=certs.ca
)
@pytest.fixture(scope="module")
def ndir() -> NotifyDir:
return NotifyDir()
@pytest.fixture(scope="module")
def ids(base: Settings, ndir: NotifyDir) -> dict[str, str]:
return add_users(base, ndir)
@pytest.fixture
def s(base: Settings, ids: dict[str, str], relay: FakeSmtp, ndir: NotifyDir) -> Settings:
relay.messages.clear()
ndir.down = False
return base
@pytest.fixture
def marker(s: Settings) -> int:
return clean(s)
def one(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def set_recipients(conn: Any, event_type: str, rows: list[dict[str, Any]]) -> None:
conn.execute("DELETE FROM notification_recipients WHERE event_type = %s", (event_type,))
for r in rows:
conn.execute(
"INSERT INTO notification_recipients (event_type, kind, group_dn, group_name, user_id, address)"
" VALUES (%s, %s, %s, %s, %s, %s)",
(event_type, r["kind"], r.get("group_dn"), r.get("group_name"), r.get("user_id"), r.get("address")),
)
def ctx_for(conn: Any, settings: Settings, directory: Any) -> JobContext:
stack = Stack()
stack.cache["directory"] = directory
return JobContext(
conn=conn,
settings=settings,
feed_settings=FeedSettings(),
run_id=0,
trigger="cli",
started_at=datetime.now(ZoneInfo(settings.site_tz)),
log=logging.getLogger("test.notify"),
stack=stack,
)
# ---------------------------------------------------------------- raise and resolve
def test_condition_dedupes_and_resolve_adds_recovery(s: Settings, marker: int) -> None:
with app_conn(s) as conn:
first = raise_event(conn, "backup_stale", subject="prod", title="Backup is stale", condition=True)
assert first is not None
again = raise_event(conn, "backup_stale", subject="prod", title="Backup is stale", body="b2", condition=True)
assert again is None
row = conn.execute(
"SELECT raised_count, resolved_at, kind, event_type, body FROM notification_events WHERE id = %s", (first,)
).fetchone()
assert row[0] == 2 and row[1] is None and row[2] == row[3] == "backup_stale" and row[4] == "b2"
rec = resolve_event(conn, "backup_stale", subject="prod")
assert rec is not None
r = conn.execute(
"SELECT recovery_of, title, resolved_at, severity FROM notification_events WHERE id = %s", (rec,)
).fetchone()
assert r[0] == first and r[1] == "Cleared: Backup is stale" and r[2] is not None and r[3] == "info"
assert conn.execute("SELECT resolved_at FROM notification_events WHERE id = %s", (first,)).fetchone()[0]
assert resolve_event(conn, "backup_stale", subject="prod") is None
# Open again after it cleared: a new row, a new mail
assert raise_event(conn, "backup_stale", subject="prod", title="Backup is stale", condition=True) is not None
def test_one_shot_dedupe_and_unknown_type(s: Settings, marker: int) -> None:
with app_conn(s) as conn:
a = raise_event(conn, "security_alert", subject="ann", title="Cooldown", dedupe_minutes=15)
b = raise_event(conn, "security_alert", subject="ann", title="Cooldown", dedupe_minutes=15)
c = raise_event(conn, "security_alert", subject="bob", title="Cooldown", dedupe_minutes=15)
d = raise_event(conn, "security_alert", subject="ann", title="Cooldown")
assert a and c and d and b is None
assert conn.execute("SELECT resolved_at FROM notification_events WHERE id = %s", (a,)).fetchone()[0]
assert raise_event(conn, "no_such_type", subject="x", title="x") is None
assert resolve_event(conn, "no_such_type", subject="x") is None
def test_seed_to_admins_is_idempotent(s: Settings, marker: int) -> None:
with app_conn(s) as conn:
added = seed(conn, s)
assert len(added) == 13
rows = conn.execute(
"SELECT r.event_type, r.enabled, r.delivery::text, r.digest_time::text, r.param, c.kind::text,"
" c.group_dn, c.group_name FROM notification_rules r JOIN notification_recipients c USING (event_type)"
).fetchall()
assert len(rows) == 13
for r in rows:
assert r[1] is True and r[2] == "immediate" and r[3] == "07:00:00" and r[5] == "group"
assert r[6] == f"CN=PS-Admins,{s.ldap_group_base_dn}" and r[7] == "PS-Admins"
params = {r[0]: r[4] for r in rows}
assert params["drawing_failing"] == 24 and params["co_waiting"] == 3 and params["qc_reject"] is None
conn.execute("UPDATE notification_rules SET enabled = false WHERE event_type = 'feed_failed'")
conn.execute("DELETE FROM notification_recipients WHERE event_type = 'feed_failed'")
assert seed(conn, s) == []
assert (
conn.execute("SELECT enabled FROM notification_rules WHERE event_type = 'feed_failed'").fetchone()[0]
is False
)
n = conn.execute("SELECT count(*) FROM notification_recipients WHERE event_type = 'feed_failed'").fetchone()[0]
assert n == 0
# ---------------------------------------------------------------- recipients
def test_resolve_recipients(s: Settings, marker: int, ndir: NotifyDir, ids: dict[str, str]) -> None:
with app_conn(s) as conn:
seed(conn, s)
set_recipients(
conn,
"qc_reject",
[
{"kind": "group", "group_dn": f"CN=PS-Admins,{s.ldap_group_base_dn}", "group_name": "PS-Admins"},
{"kind": "group", "group_dn": ndir.dn(OUTSIDE), "group_name": OUTSIDE},
{"kind": "user", "user_id": ids["pm.paula"]},
{"kind": "user", "user_id": ids["disabled.dan"]},
{"kind": "mailbox", "address": "[email protected]"},
{"kind": "mailbox", "address": "[email protected]"},
{"kind": "dept_supervisor"},
],
)
rule = load_rule(conn, "qc_reject")
assert rule is not None
got = Resolver(conn, s, ndir).resolve(rule, "03")
by = {r.address: r for r in got}
assert by["[email protected]"].why == "group PS-Admins" # once, the group wins over the mailbox
assert by["[email protected]"].user_id == ids["admin.ann"]
assert by["[email protected]"].why == f"group {OUTSIDE}"
assert by["[email protected]"].why == "user"
assert "[email protected]" not in by # not active
assert by["[email protected]"].why == "mailbox" and by["[email protected]"].user_id is None
sup = {a for a, r in by.items() if r.why == "Supervisor group of 03"}
assert sup == {"[email protected]", "[email protected]"}
assert len(got) == len(by)
# No department: the Supervisor recipient resolves to nobody
none_dept = {r.address for r in Resolver(conn, s, ndir).resolve(rule, None)}
assert "[email protected]" not in none_dept
# Exclusions drop by id, and by the person's email (the mailbox row with Ann's address too)
conn.execute(
"INSERT INTO notification_exclusions (event_type, user_id) VALUES ('qc_reject', %s), ('qc_reject', %s)",
(ids["admin.ann"], ids["sup.wes"]),
)
rule = load_rule(conn, "qc_reject")
assert rule is not None
after = {r.address for r in Resolver(conn, s, ndir).resolve(rule, "03")}
assert "[email protected]" not in after and "[email protected]" not in after
assert "[email protected]" in after and "[email protected]" in after
def test_group_members_disabled_or_without_mail_are_left_out(s: Settings, marker: int, ndir: NotifyDir) -> None:
with app_conn(s) as conn:
seed(conn, s)
set_recipients(conn, "feed_failed", [{"kind": "group", "group_dn": ndir.dn("PS-03-WeldParts-Worker")}])
rule = load_rule(conn, "feed_failed")
assert rule is not None
got = {r.address for r in Resolver(conn, s, ndir).resolve(rule, None)}
assert "[email protected]" in got and "[email protected]" in got
assert "[email protected]" not in got
# ---------------------------------------------------------------- delivery
def test_immediate_send_through_the_relay(s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir) -> None:
with app_conn(s) as conn:
seed(conn, s)
eid = raise_event(
conn, "feed_failed", subject="visual-feed", title="visual-feed run failed", body="boom", condition=True
)
counts = send.dispatch_pending(conn, s, ndir)
assert counts == {"sent": 1}
row = conn.execute(
"SELECT dispatched_at, dispatch_result FROM notification_events WHERE id = %s", (eid,)
).fetchone()
assert row[0] is not None and row[1] == "sent"
assert send.dispatch_pending(conn, s, ndir) == {}
(m,) = relay.messages
assert m.tls and m.header("Subject") == "[Packet Station TEST] visual-feed run failed"
assert m.header("To") == "[email protected]"
assert m.rcpts == ["[email protected]"]
assert "boom" in m.text and "Feed run failed" in m.text
(rec,) = sent_since(s, marker)
assert rec["result"] == "sent" and rec["event_ids"] == [eid] and not rec["digest"] and not rec["test"]
assert rec["recipients"] == [
{"address": "[email protected]", "user_id": str(ndir.user("admin.ann").guid), "why": "group PS-Admins"}
]
def test_recovery_notice_goes_to_the_same_rule(s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir) -> None:
with app_conn(s) as conn:
seed(conn, s)
raise_event(conn, "backup_stale", subject="prod", title="Backup prod is stale", condition=True)
send.dispatch_pending(conn, s, ndir)
resolve_event(conn, "backup_stale", subject="prod", body="A fresh dump is there.")
raise_event(conn, "backup_stale", subject="prod", title="Backup prod is stale", condition=True)
assert send.dispatch_pending(conn, s, ndir) == {"sent": 2}
assert [m.header("Subject") for m in relay.messages] == [
"[Packet Station TEST] Backup prod is stale",
"[Packet Station TEST] Cleared: Backup prod is stale",
"[Packet Station TEST] Backup prod is stale",
]
def test_dry_run_records_and_test_send_still_sends(
s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir, ids: dict[str, str]
) -> None:
dry = s.model_copy(update={"notify_dry_run": True})
with app_conn(s) as conn:
seed(conn, s)
eid = raise_event(conn, "version_deployed", subject="build:x", title="Version x deployed")
assert send.dispatch_pending(conn, dry, ndir) == {"dry_run": 1}
assert relay.messages == []
assert conn.execute("SELECT dispatch_result FROM notification_events WHERE id = %s", (eid,)).fetchone()[0] == (
"dry_run"
)
out = send.send_test(conn, dry, ndir, "version_deployed", requested_by=ids["admin.ann"])
assert out["result"] == "sent" and out["error"] is None
assert out["subject"] == "[Packet Station TEST] Test: Version deployed"
assert [r["address"] for r in out["recipients"]] == ["[email protected]"]
to = send.send_test(conn, dry, ndir, "version_deployed", to="[email protected]")
assert to["result"] == "sent" and to["recipients"] == [
{"address": "[email protected]", "user_id": None, "why": "test address"}
]
assert [m.rcpts for m in relay.messages] == [["[email protected]"], ["[email protected]"]]
rows = sent_since(s, marker)
assert [(r["result"], r["test"]) for r in rows] == [("dry_run", False), ("sent", True), ("sent", True)]
assert str(rows[1]["requested_by"]) == ids["admin.ann"]
def test_mail_off_records_failed(s: Settings, marker: int, ndir: NotifyDir) -> None:
off = s.model_copy(update={"smtp_host": ""})
with app_conn(s) as conn:
seed(conn, s)
out = send.send_test(conn, off, ndir, "feed_failed")
assert out["result"] == "failed" and out["error"] == "Mail is off: SMTP_HOST is empty."
eid = raise_event(conn, "feed_failed", subject="drawing-lookup", title="t", condition=True)
assert send.dispatch_pending(conn, off, ndir) == {"failed": 1}
# Recorded and not retried
assert conn.execute("SELECT dispatched_at FROM notification_events WHERE id = %s", (eid,)).fetchone()[0]
assert [(r["result"], r["error"]) for r in sent_since(s, marker)] == [
("failed", "Mail is off: SMTP_HOST is empty."),
("failed", "Mail is off: SMTP_HOST is empty."),
]
def test_rule_off_no_recipients_and_digest_queue(s: Settings, marker: int, ndir: NotifyDir) -> None:
with app_conn(s) as conn:
seed(conn, s)
conn.execute("UPDATE notification_rules SET enabled = false WHERE event_type = 'group_missing'")
conn.execute("UPDATE notification_rules SET delivery = 'digest' WHERE event_type = 'ad_reconcile_report'")
conn.execute("DELETE FROM notification_recipients WHERE event_type = 'backup_stale'")
a = raise_event(conn, "group_missing", subject="PS-X", title="t", condition=True)
b = raise_event(conn, "ad_reconcile_report", subject="ad", title="t")
c = raise_event(conn, "backup_stale", subject="prod", title="t", condition=True)
assert send.dispatch_pending(conn, s, ndir) == {"rule_off": 1, "queued": 1, "no_recipients": 1}
res = dict(conn.execute("SELECT id, dispatch_result FROM notification_events").fetchall())
assert res == {a: "rule_off", b: "queued", c: "no_recipients"}
assert conn.execute("SELECT event_id FROM notification_outbox").fetchall() == [(b,)]
assert [r["result"] for r in sent_since(s, marker)] == ["no_recipients"]
def test_directory_down_retries_then_fails(s: Settings, marker: int, ndir: NotifyDir) -> None:
ndir.down = True
with app_conn(s) as conn:
seed(conn, s)
eid = raise_event(conn, "integrity_failure", subject="integrity", title="t", condition=True)
assert send.dispatch_pending(conn, s, ndir) == {"retry": 1}
assert send.dispatch_pending(conn, s, ndir) == {"retry": 1}
row = conn.execute(
"SELECT dispatched_at, dispatch_result FROM notification_events WHERE id = %s", (eid,)
).fetchone()
assert row == (None, "directory_retry:2")
assert send.dispatch_pending(conn, s, ndir) == {"failed": 1}
row = conn.execute(
"SELECT dispatched_at, dispatch_result FROM notification_events WHERE id = %s", (eid,)
).fetchone()
assert row[0] is not None and row[1] == "failed"
rows = sent_since(s, marker)
assert [(r["result"], r["error"]) for r in rows] == [("failed", "directory unavailable")] * 3
# ---------------------------------------------------------------- digest
def test_digest_one_message_per_address(
s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir, ids: dict[str, str]
) -> None:
tz = ZoneInfo(s.site_tz)
with app_conn(s) as conn:
seed(conn, s)
for et in ("ad_reconcile_report", "group_missing"):
conn.execute(
"UPDATE notification_rules SET delivery = 'digest', digest_time = '07:00' WHERE event_type = %s", (et,)
)
set_recipients(conn, "group_missing", [{"kind": "user", "user_id": ids["pm.paula"]}])
conn.execute(
"INSERT INTO notification_recipients (event_type, kind, user_id)"
" VALUES ('ad_reconcile_report', 'user', %s)",
(ids["pm.paula"],),
)
e1 = raise_event(conn, "ad_reconcile_report", subject="ad", title="Reconcile: 2 added")
e2 = raise_event(conn, "group_missing", subject="PS-A", title="Group PS-A missing", condition=True)
e3 = raise_event(conn, "group_missing", subject="PS-B", title="Group PS-B missing", condition=True)
assert send.dispatch_pending(conn, s, ndir) == {"queued": 3}
# Queued at "now"; a digest time later today has not passed yet for them
now = datetime.now(tz)
conn.execute("UPDATE notification_outbox SET queued_at = %s", (now - timedelta(days=1),))
before = datetime.combine(now.date(), datetime.min.time(), tzinfo=tz) + timedelta(hours=6, minutes=59)
assert digest.run_digest(conn, s, ndir, before) == {"due": 0}
at = before + timedelta(minutes=2)
counts = digest.run_digest(conn, s, ndir, at)
assert counts["messages"] == 2 and counts["sent"] == 2 and counts["left"] == 0
assert conn.execute("SELECT count(*) FROM notification_outbox WHERE sent_at IS NULL").fetchone()[0] == 0
assert digest.run_digest(conn, s, ndir, at) == {"due": 0}
by = {tuple(m.rcpts): m for m in relay.messages}
assert set(by) == {("[email protected]",), ("[email protected]",)}
# Paula is on both rules: one message with all three. Ann (PS-Admins, the seed) only on the reconcile rule.
paula = by[("[email protected]",)]
assert paula.header("Subject") == "[Packet Station TEST] Daily digest: 3 notifications"
for t in ("Reconcile: 2 added", "Group PS-A missing", "Group PS-B missing"):
assert t in paula.text
ann = by[("[email protected]",)]
assert ann.header("Subject") == "[Packet Station TEST] Daily digest: 1 notification"
assert "Reconcile: 2 added" in ann.text and "PS-A" not in ann.text
rows = sent_since(s, marker)
assert len(rows) == 2 and all(r["digest"] and r["event_type"] == "digest" for r in rows)
got = {r["recipients"][0]["address"]: sorted(r["event_ids"]) for r in rows}
assert got == {"[email protected]": [e1], "[email protected]": sorted([e1, e2, e3])}
def test_digest_failure_leaves_rows_and_skips_done_addresses(
s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir, ids: dict[str, str]
) -> None:
tz = ZoneInfo(s.site_tz)
with app_conn(s) as conn:
seed(conn, s)
conn.execute(
"UPDATE notification_rules SET delivery = 'digest', digest_time = '00:00'"
" WHERE event_type = 'ad_reconcile_report'"
)
conn.execute(
"INSERT INTO notification_recipients (event_type, kind, address) VALUES"
" ('ad_reconcile_report', 'mailbox', '[email protected]')"
)
raise_event(conn, "ad_reconcile_report", subject="ad", title="Reconcile")
send.dispatch_pending(conn, s, ndir)
now = datetime.now(tz)
conn.execute("UPDATE notification_outbox SET queued_at = %s", (now - timedelta(days=1),))
at = now
relay.refuse.add("[email protected]")
try:
counts = digest.run_digest(conn, s, ndir, at)
assert counts["sent"] == 1 and counts["failed"] == 1 and counts["left"] == 1
assert conn.execute("SELECT count(*) FROM notification_outbox WHERE sent_at IS NULL").fetchone()[0] == 1
# Backoff after a failure
assert digest.run_digest(conn, s, ndir, at + timedelta(minutes=5))["backoff"] is True
finally:
relay.refuse.clear()
counts = digest.run_digest(conn, s, ndir, at + timedelta(minutes=31))
assert counts["messages"] == 1 and counts["sent"] == 1 # Ann already has it
assert conn.execute("SELECT count(*) FROM notification_outbox WHERE sent_at IS NULL").fetchone()[0] == 0
assert [m.rcpts for m in relay.messages] == [["[email protected]"], ["[email protected]"]]
# ---------------------------------------------------------------- watch checks, version, feed hook
def _packet_with_co(conn: Any, dept: str, days: int) -> int:
pid = conn.execute("INSERT INTO projects (number, name) VALUES ('L77001', 'P') RETURNING id").fetchone()[0]
uid = conn.execute("INSERT INTO units (project_id, lot) VALUES (%s, '001') RETURNING id", (pid,)).fetchone()[0]
pk = conn.execute(
"INSERT INTO packets (unit_id, dept_code, title) VALUES (%s, %s, 'Nozzle') RETURNING id", (uid, dept)
).fetchone()[0]
co = conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, issued_at) VALUES (%s, 1, 'manual',"
" now() - make_interval(days => %s)) RETURNING id",
(pk, days),
).fetchone()[0]
return int(co)
def test_watch_drawings_and_change_orders(s: Settings, marker: int, ndir: NotifyDir) -> None:
with app_conn(s) as conn:
seed(conn, s)
conn.execute(
"INSERT INTO drawing_lookups (dwg, ok, fail_count, failing_since, note) VALUES"
" ('D-1', false, 9, now() - interval '30 hours', 'not found'),"
" ('D-2', false, 2, now() - interval '2 hours', 'timeout')"
)
assert watch.check_drawings(conn) == {"drawings_failing": 1}
title, body, dept = conn.execute(
"SELECT title, body, dept_code FROM notification_events WHERE kind = 'drawing_failing'"
).fetchone()
assert title == "1 drawing lookup failing for more than 24 hours" and "D-1" in body and "D-2" not in body
conn.execute("UPDATE notification_rules SET param = 1 WHERE event_type = 'drawing_failing'")
assert watch.check_drawings(conn) == {"drawings_failing": 2}
assert (
conn.execute("SELECT count(*) FROM notification_events WHERE kind = 'drawing_failing'").fetchone()[0] == 1
)
conn.execute("UPDATE drawing_lookups SET failing_since = NULL")
assert watch.check_drawings(conn) == {"drawings_failing": 0, "drawings_cleared": True}
co = _packet_with_co(conn, "04", 5)
out = watch.check_change_orders(conn)
assert out == {"co_waiting": 1, "co_raised": 1, "co_cleared": 0}
subj, dept, title = conn.execute(
"SELECT subject, dept_code, title FROM notification_events WHERE kind = 'co_waiting'"
).fetchone()
assert subj == f"co:{co}" and dept == "04" and title.startswith("Change order CO-1 waiting 5 days")
assert watch.check_change_orders(conn)["co_raised"] == 0
conn.execute("UPDATE change_orders SET review = 'approved' WHERE id = %s", (co,))
assert watch.check_change_orders(conn) == {"co_waiting": 0, "co_raised": 0, "co_cleared": 1}
rec = conn.execute(
"SELECT dept_code, title FROM notification_events WHERE kind = 'co_waiting' AND recovery_of IS NOT NULL"
).fetchone()
assert rec[0] == "04" and rec[1].startswith("Cleared: Change order CO-1")
conn.execute("DELETE FROM change_orders")
conn.execute("DELETE FROM packets")
def test_note_build(s: Settings, marker: int) -> None:
with app_conn(s) as conn:
first = watch.note_build(conn, "sha-aaa")
assert first is not None
assert watch.note_build(conn, "sha-aaa") is None
second = watch.note_build(conn, "sha-bbb")
assert second is not None
body = conn.execute("SELECT body FROM notification_events WHERE id = %s", (second,)).fetchone()[0]
assert "sha-bbb" in body and "sha-aaa" in body
value = conn.execute("SELECT value FROM settings WHERE key = 'notify.last_build'").fetchone()[0]
assert value == {"build": "sha-bbb"}
def test_feed_run_hook(s: Settings, marker: int) -> None:
def open_kinds(conn: Any) -> set[tuple[str, str]]:
return {
(r[0], r[1])
for r in conn.execute("SELECT kind, subject FROM notification_events WHERE resolved_at IS NULL").fetchall()
}
with app_conn(s) as conn:
watch.feed_run(conn, "visual-feed", "failed", ["VisualError: down"], 7)
watch.feed_run(conn, "drawing-lookup", "failed", [], 8)
assert open_kinds(conn) == {("feed_failed", "visual-feed"), ("feed_failed", "drawing-lookup")}
watch.feed_run(conn, "visual-feed", "degraded", ["one row bad"], 9)
assert open_kinds(conn) == {("feed_degraded", "visual-feed"), ("feed_failed", "drawing-lookup")}
watch.feed_run(conn, "drawing-lookup", "degraded", ["x"], 10)
watch.feed_run(conn, "visual-feed", "ok", [], 11)
assert open_kinds(conn) == set()
watch.feed_run(conn, "visual-feed", "skipped", [], 12)
watch.feed_run(conn, "housekeeping", "failed", [], 13)
assert open_kinds(conn) == set()
# ---------------------------------------------------------------- the jobs and the quota
def test_notify_send_job(s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir) -> None:
from packetstation.worker.jobs import notify_digest, notify_send
assert notify_send.SPEC.name == "notify-send" and not notify_send.SPEC.pausable
assert notify_digest.SPEC.name == "notify-digest" and not notify_digest.SPEC.pausable
build = s.model_copy(update={"ps_build": "sha-job"})
with app_conn(s) as conn:
ctx = ctx_for(conn, build, ndir)
counts = notify_send.run(ctx)
assert counts["seeded"] == 13 and counts["version_deployed"] == "sha-job" and counts["sent"] == 1
assert counts["drawings_failing"] == 0 and counts["co_waiting"] == 0
again = notify_send.run(ctx)
assert "version_deployed" not in again and "drawings_failing" not in again # watch waits 15 minutes
assert notify_digest.run(ctx_for(conn, build, ndir)) == {"due": 0}
assert [m.header("Subject") for m in relay.messages] == ["[Packet Station TEST] Version sha-job deployed"]
def test_check_quota_through_notify(s: Settings, marker: int, monkeypatch: pytest.MonkeyPatch) -> None:
from packetstation.domain import assets
warn = {"total": 100, "used": 90, "free": 10, "pct": 90.0, "warn": True}
ok = {"total": 100, "used": 10, "free": 90, "pct": 10.0, "warn": False}
monkeypatch.setattr(assets, "usage_for", lambda *a, **k: dict(warn))
with app_conn(s) as conn:
assert assets.check_quota(conn, s)["alert"] == "opened"
assert assets.check_quota(conn, s)["alert"] is None
row = conn.execute(
"SELECT event_type, title, detail, raised_count FROM notification_events WHERE kind = 'assets_quota'"
).fetchone()
assert row[0] == "assets_quota" and row[1] == "Assets storage is 90.0% full" and row[2]["pct"] == 90.0
assert row[3] == 1 # check_quota raises only when nothing is open
monkeypatch.setattr(assets, "usage_for", lambda *a, **k: dict(ok))
out = assets.check_quota(conn, s)
assert out["alert"] == "resolved" and out["pct"] == 10.0
assert (
conn.execute(
"SELECT count(*) FROM notification_events WHERE kind = 'assets_quota' AND recovery_of IS NOT NULL"
).fetchone()[0]
== 1
)
assert assets.check_quota(conn, s)["alert"] is None
def test_digest_json_detail_is_kept(s: Settings, marker: int) -> None:
with app_conn(s) as conn:
eid = raise_event(conn, "ad_reconcile_report", subject="ad", title="t", detail={"when": datetime(2026, 1, 2)})
got = conn.execute("SELECT detail FROM notification_events WHERE id = %s", (eid,)).fetchone()[0]
assert got == {"when": "2026-01-02 00:00:00"}
+52
View File
@@ -0,0 +1,52 @@
"""qc_reject is raised in the write's transaction: a Q.C. reject of a packet (its department) and an ITP line reject
(the department working to the line's page, when only one does)."""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fakedir import FakeDirectory
from pshelpers import needs_pg
from qchelpers import at_qc, open_qc_shop, reject, reject_line, start_itp
from shophelpers import Shop, make_unit, ok, one
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_qc_shop(pg_settings, fake_dir)
def test_qc_and_itp_rejects_raise_qc_reject(shop: Shop) -> None:
pkt = at_qc(shop)
ok(reject(shop, pkt, "Weld 2 undercut too deep"))
row = one(
shop.settings,
"SELECT kind, event_type, subject, dept_code, title, body, resolved_at, dispatched_at"
" FROM notification_events WHERE event_type = 'qc_reject'",
)
assert row[0] == row[1] == "qc_reject"
dept = one(shop.settings, "SELECT dept_code FROM packets WHERE id = %s", (pkt["id"],))[0]
assert row[2] == f"packet:{pkt['id']}" and row[3] == dept == "03"
assert row[4].startswith(f"Q.C. rejected packet {pkt['id']}")
assert "Weld 2 undercut too deep" in row[5] and "Quinn QC" in row[5]
assert row[6] is not None and row[7] is None # a one-shot, waiting for notify-send
_, unit_id = make_unit(shop.settings)
itp = start_itp(shop, unit_id)
ok(reject_line(shop("qc.quinn"), itp, "1.4", note="Manual is rev C"))
row = one(
shop.settings,
"SELECT subject, dept_code, title, body, detail FROM notification_events"
" WHERE event_type = 'qc_reject' AND subject <> %s",
(f"packet:{pkt['id']}",),
)
assert row[0] == f"itp:{itp['id']}:1.4"
assert row[1] is None # no packet of that unit works to the page
assert row[2].startswith(f"ITP {itp['no']} line 1.4 rejected")
assert "NCR # pending" in row[3] and "Manual is rev C" in row[3]
assert row[4]["line"] == "1.4" and row[4]["itp_id"] == itp["id"]
+133
View File
@@ -0,0 +1,133 @@
"""notify/smtp.py against the fake relay: STARTTLS verified with a CA made here, no plain-text fallback, the
envelope-only recipients, the subject prefix, and mail off when SMTP_HOST is empty. No database."""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fakesmtp import Certs, FakeSmtp, can_make_ca, make_ca
from pshelpers import make_settings
from packetstation.notify import EVENT_TYPES, SECURITY_TYPES, smtp
from packetstation.notify.routing import address_ok, cn_of, dn_under, supervisor_depts, supervisor_group
from packetstation.settings import Settings
needs_ca = pytest.mark.skipif(not can_make_ca(), reason="neither cryptography nor openssl to make a test CA")
@pytest.fixture(scope="module")
def certs(tmp_path_factory: pytest.TempPathFactory) -> Certs:
if not can_make_ca():
pytest.skip("no way to make a test CA")
return make_ca(tmp_path_factory.mktemp("ca"), "good")
@pytest.fixture(scope="module")
def other_ca(tmp_path_factory: pytest.TempPathFactory) -> Certs:
if not can_make_ca():
pytest.skip("no way to make a test CA")
return make_ca(tmp_path_factory.mktemp("ca2"), "other")
@pytest.fixture
def relay(certs: Certs) -> Iterator[FakeSmtp]:
with FakeSmtp(certs) as server:
yield server
def mail_settings(tmp: Path, server: FakeSmtp, ca: Path | None, **over: object) -> Settings:
return make_settings(
tmp, smtp_host=server.host, smtp_port=server.port, smtp_ca_bundle=ca, smtp_from="[email protected]", **over
)
def test_catalog() -> None:
assert len(EVENT_TYPES) == 13
assert SECURITY_TYPES == {"security_alert", "signin_burst", "integrity_failure"}
assert EVENT_TYPES["drawing_failing"].param == "hours" and EVENT_TYPES["drawing_failing"].param_default == 24
assert EVENT_TYPES["co_waiting"].param == "days" and EVENT_TYPES["co_waiting"].param_default == 3
assert {k for k, t in EVENT_TYPES.items() if t.dept_scoped} == {"co_waiting", "qc_reject"}
def test_helpers() -> None:
assert address_ok("[email protected]") and address_ok("[email protected]")
for bad in ("", "nobody", "a@b", "a [email protected]", "<[email protected]>", "[email protected], [email protected]"):
assert not address_ok(bad), bad
assert cn_of("CN=PS-Admins,OU=PacketStation,DC=PESCO,DC=com") == "PS-Admins"
assert dn_under("CN=X,OU=Groups,DC=PESCO,DC=com", "DC=pesco,DC=com")
assert not dn_under("CN=X,DC=evil,DC=com", "DC=PESCO,DC=com")
assert not dn_under("CN=X,DC=PESCO,DC=com", "")
assert supervisor_group("03") == "PS-03-WeldParts-Supervisor"
assert supervisor_group("QC") == "PS-QC-Supervisor"
assert supervisor_group("PM") is None and supervisor_group(None) is None
assert supervisor_depts(["clerical", "shop:06:supervisor", "shop:03:lead", "qc_supervisor"]) == ["06", "QC"]
def test_subject_prefix(tmp_path: Path) -> None:
assert smtp.subject_prefix(make_settings(tmp_path)) == "[Packet Station TEST]"
prod = make_settings(tmp_path, stack="prod")
assert smtp.full_subject(prod, "Feed run failed") == "[Packet Station] Feed run failed"
def test_mail_off_without_host(tmp_path: Path) -> None:
s = make_settings(tmp_path, smtp_host="")
with pytest.raises(smtp.MailError, match=r"^Mail is off: SMTP_HOST is empty\.$"):
smtp.send(s, "x", "y", ["[email protected]"])
@needs_ca
def test_starttls_verified_and_envelope_only(tmp_path: Path, relay: FakeSmtp, certs: Certs) -> None:
s = mail_settings(tmp_path, relay, certs.ca)
assert smtp.send(s, "[Packet Station TEST] Hello", "Body line", ["[email protected]", "[email protected]"]) is None
assert len(relay.messages) == 1
m = relay.messages[0]
assert m.tls is True
assert m.mail_from == "[email protected]"
assert m.rcpts == ["[email protected]", "[email protected]"]
assert m.header("To") == "[email protected]"
assert "[email protected]" not in m.text.split("\r\n\r\n", 1)[0]
assert m.header("Subject") == "[Packet Station TEST] Hello"
assert m.header("Auto-Submitted") == "auto-generated"
@needs_ca
def test_wrong_ca_fails_with_no_fallback(tmp_path: Path, relay: FakeSmtp, other_ca: Certs) -> None:
s = mail_settings(tmp_path, relay, other_ca.ca)
with pytest.raises(smtp.MailError, match="TLS"):
smtp.send(s, "x", "y", ["[email protected]"])
assert relay.messages == []
assert relay.connections == 1 # no second, plain-text try
@needs_ca
def test_no_starttls_offered_is_refused(tmp_path: Path, certs: Certs) -> None:
with FakeSmtp(certs, offer_tls=False) as plain:
s = mail_settings(tmp_path, plain, certs.ca)
with pytest.raises(smtp.MailError, match="STARTTLS"):
smtp.send(s, "x", "y", ["[email protected]"])
assert plain.messages == []
@needs_ca
def test_partly_refused_is_sent_with_a_note(tmp_path: Path, certs: Certs) -> None:
with FakeSmtp(certs, refuse={"[email protected]"}) as server:
s = mail_settings(tmp_path, server, certs.ca)
note = smtp.send(s, "x", "y", ["[email protected]", "[email protected]"])
assert note is not None and "[email protected]" in note
assert server.messages[0].rcpts == ["[email protected]"]
with pytest.raises(smtp.MailError, match="refused"):
smtp.send(s, "x", "y", ["[email protected]"])
def test_relay_down(tmp_path: Path) -> None:
import socket
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
s = make_settings(tmp_path, smtp_host="127.0.0.1", smtp_port=port)
with pytest.raises(smtp.MailError):
smtp.send(s, "x", "y", ["[email protected]"])
+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
+217
View File
@@ -0,0 +1,217 @@
"""The ad-reconcile job against the fake directory: the report, users, flags, ended sessions and events."""
from __future__ import annotations
import uuid
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo
import pytest
from fakedir import BASE_DN, NESTED_GROUP, OU_DN, FakeDirectory
from pshelpers import app_conn, needs_pg
from packetstation.auth import directory as userdir
from packetstation.auth import groups, sessions
from packetstation.feed.config import FeedSettings
from packetstation.settings import Settings
from packetstation.worker import jobs, runs
from packetstation.worker.jobs import ad_reconcile, load_jobs
from packetstation.worker.scheduler import Scheduler
TZ = ZoneInfo("America/Denver")
class Events:
"""Records raise_event and resolve_event calls in place of the notify package."""
def __init__(self) -> None:
self.raised: list[tuple[str, dict[str, Any]]] = []
self.resolved: list[tuple[str, dict[str, Any]]] = []
def raise_event(self, conn: Any, event_type: str, **kw: Any) -> int:
self.raised.append((event_type, kw))
return len(self.raised)
def resolve_event(self, conn: Any, event_type: str, **kw: Any) -> None:
self.resolved.append((event_type, kw))
def of(self, event_type: str) -> list[dict[str, Any]]:
return [kw for t, kw in self.raised if t == event_type]
@pytest.fixture
def events(monkeypatch: pytest.MonkeyPatch) -> Events:
ev = Events()
monkeypatch.setattr(jobs, "raise_event", ev.raise_event)
monkeypatch.setattr(jobs, "resolve_event", ev.resolve_event)
return ev
def scheduler(settings: Settings, d: FakeDirectory) -> Scheduler:
sch = Scheduler(settings, build="pytest", jobs={"ad-reconcile": ad_reconcile.SPEC})
sch.stack.cache["directory"] = d
return sch
def reconcile(settings: Settings, d: FakeDirectory, trigger: str = "schedule") -> dict[str, Any]:
return runs.run_job(scheduler(settings, d), "ad-reconcile", trigger)
def live_session(settings: Settings, conn: Any, user_id: uuid.UUID) -> uuid.UUID:
_, row = sessions.create(
conn, settings, settings.session_key(), user_id=user_id, kind="password", roles=["clerical"], groups=[]
)
sid: uuid.UUID = row["id"]
return sid
# ---------------------------------------------------------------- no database
def test_registered_with_cadence() -> None:
reg = load_jobs()
for name in ("ad-reconcile", "session-refresh", "integrity", "backup-check"):
assert name in reg and reg[name].pausable is False, name
fs = FeedSettings()
at = datetime(2026, 10, 21, 1, 0, tzinfo=TZ)
assert reg["ad-reconcile"].cadence(fs, at) == 30 * 60
assert reg["integrity"].cadence(fs, at) == 3 * 3600
assert reg["backup-check"].cadence(fs, at) == 6 * 3600 + 15 * 60
def test_fake_directory_additions() -> None:
d = FakeDirectory()
detail = d.list_groups_detail()
names = [n for n, _ in detail]
assert "PS-Extra-Legacy" in names and "PS-14-Electrical-Lead" not in names
assert dict(detail)["PS-Admins"] == f"CN=PS-Admins,{OU_DN}"
weld10 = {u.sam_account for u in d.members_of_dn(dict(detail)["PS-10-WeldAssembly-Worker"])}
assert weld10 == {"nested.ned"}
weld03 = {u.sam_account for u in d.members_of_dn(dict(detail)["PS-03-WeldParts-Worker"])}
assert "disabled.dan" in weld03 # disabled accounts are listed too
assert d.members_of_dn(f"CN=Nope,{OU_DN}") == []
found = d.search_groups("helpdesk")
helpdesk = {"dn": f"CN=IT-Helpdesk,OU=Groups,OU=PESCO_FARMINGTON,{BASE_DN}", "name": "IT-Helpdesk"}
assert found == [{**helpdesk, "description": "IT help desk"}]
assert [g["name"] for g in d.search_groups("nested")] == [NESTED_GROUP]
assert len(d.search_groups("PS-", limit=5)) == 5
ann, ghost = d.user("admin.ann").guid, uuid.uuid4()
got = d.users_by_guid([ann, ghost])
assert got[ghost] is None and got[ann] is not None and got[ann].sam_account == "admin.ann"
def test_report_text() -> None:
res = userdir.RefreshResult(created=2, created_names=["a", "b"], groups_extra=["X"], emp_duplicate=["9001"])
text = ad_reconcile.report_text(res)
assert text is not None
assert text[0] == "AD reconcile: 2 added, 1 extra group, 1 duplicate employee ID"
assert "Added: a, b." in text[1] and "9001" in text[1]
assert ad_reconcile.report_text(userdir.RefreshResult(updated=5)) is None
# ---------------------------------------------------------------- PostgreSQL
@needs_pg
def test_reconcile_run(pg_settings: Settings, events: Events) -> None:
d = FakeDirectory()
row = reconcile(pg_settings, d)
assert row["status"] == "ok", row["errors"]
c = row["counts"]
assert c["groups_missing"] == ["PS-14-Electrical-Lead"] and c["groups_extra"] == ["PS-Extra-Legacy"]
assert c["groups_checked"] == 49 and c["users_seen"] == 16 and c["created"] == 16 and c["deactivated"] == 0
assert c["emp_missing"] == ["noemp.nora"] and c["emp_duplicate"] == ["9001"] and c["sessions_ended"] == 0
assert [kw["subject"] for kw in events.of("group_missing")] == ["PS-14-Electrical-Lead"]
assert events.of("group_missing")[0]["condition"] is True
resolved = {kw["subject"] for t, kw in events.resolved if t == "group_missing"}
assert "PS-Admins" in resolved and "PS-14-Electrical-Lead" not in resolved
(report,) = events.of("ad_reconcile_report")
assert report["title"].startswith("AD reconcile: 16 added") and "PS-Extra-Legacy" in report["body"]
with app_conn(pg_settings) as conn:
users = {
r[0]: (r[1], r[2])
for r in conn.execute("SELECT sam_account, active, directory_flags FROM users").fetchall()
}
assert "norole.nick" not in users
assert users["disabled.dan"][0] is False and users["disabled.dan"][1]["disabled"] is True
assert users["dup.dave"][1]["emp_duplicate"] and users["dup.dina"][1]["emp_duplicate"]
assert users["noemp.nora"][1]["emp_missing"] and not users["clerk.clara"][1]["emp_duplicate"]
assert users["clerk.clara"][1]["gone"] is False
ned = d.user("nested.ned").guid
assert userdir.roles_of(conn, ned) == ["shop:10:worker"]
# Sessions for the people about to change
clara, wade, quinn, ann = (d.user(s).guid for s in ("clerk.clara", "wh.wade", "qc.quinn", "admin.ann"))
sid = {u: live_session(pg_settings, conn, u) for u in (clara, wade, quinn, ann)}
d.user("clerk.clara").disabled = True # disabled in AD, still in her group
d.users.remove(d.user("wh.wade")) # gone from AD
d.user("qc.quinn").groups = [] # still in AD, no group
d.ou_groups.append("PS-14-Electrical-Lead") # IT created the missing group
events.raised.clear()
events.resolved.clear()
with app_conn(pg_settings) as conn:
conn.execute("INSERT INTO job_requests (job, requested_by) VALUES ('ad-reconcile', %s)", (ann,))
row = reconcile(pg_settings, d)
assert row["status"] == "ok" and row["trigger"] == "request" and row["requested_by"] == [ann]
c = row["counts"]
assert c["deactivated"] == 2 and c["sessions_ended"] == 3 and c["users_seen"] == 14 and c["created"] == 0
assert c["groups_missing"] == []
assert events.of("group_missing") == []
elec = "PS-14-Electrical-Lead"
assert ("group_missing", {"subject": elec, "title": f"Group missing from the OU: {elec}"}) in events.resolved
(report,) = events.of("ad_reconcile_report")
assert "2 deactivated" in report["title"] and "clerk.clara" in report["body"] and "wh.wade" in report["body"]
with app_conn(pg_settings) as conn:
ended = dict(conn.execute("SELECT id, revoked_reason FROM sessions").fetchall())
assert ended[sid[clara]] == "ad_disabled" and ended[sid[wade]] == "ad_gone"
assert ended[sid[quinn]] == "roles_lost" and ended[sid[ann]] is None
state = {r[0]: (r[1], r[2]) for r in conn.execute("SELECT id, active, directory_flags FROM users")}
assert state[wade][0] is False and state[wade][1]["gone"] is True
assert state[clara][0] is False and state[clara][1]["disabled"] is True
assert state[quinn][0] is True and userdir.roles_of(conn, quinn) == []
acts = conn.execute(
"SELECT action, detail->>'reason', detail->>'by' FROM audit_events"
" WHERE action IN ('auth.session_ended', 'ad.user_deactivated', 'ad.refresh') ORDER BY id"
).fetchall()
assert [a[0] for a in acts].count("ad.refresh") == 2
session_ends = sorted((a[1], a[2]) for a in acts if a[0] == "auth.session_ended")
assert session_ends == [
("ad_disabled", "ad-reconcile"),
("ad_gone", "ad-reconcile"),
("roles_lost", "ad-reconcile"),
]
@needs_pg
def test_reconcile_down_or_empty_changes_nothing(pg_settings: Settings, events: Events) -> None:
d = FakeDirectory()
assert reconcile(pg_settings, d)["status"] == "ok"
d.down = True
row = reconcile(pg_settings, d)
assert row["status"] == "failed" and "LdapUnavailable" in row["errors"][0]
d.down = False
d.ou_groups = ["PS-Extra-Legacy"] # a wrong group base DN: no mapped group at all
row = reconcile(pg_settings, d)
assert row["status"] == "failed" and "ReconcileRefused" in row["errors"][0]
d.ou_groups = list(groups.group_names())
d.users = [] # every group empty
row = reconcile(pg_settings, d)
assert row["status"] == "failed" and "ReconcileRefused" in row["errors"][0]
# Each stopped run raises the one "did not run" condition (the notify dedupe keeps it to one mail)
stopped = [kw for kw in events.of("ad_reconcile_report") if kw["subject"] == ad_reconcile.FAILED_SUBJECT]
assert len(stopped) == 3 and all(kw["condition"] is True for kw in stopped)
assert stopped[0]["title"] == "AD reconcile did not run" and "LdapUnavailable" in stopped[0]["body"]
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT count(*) FROM users WHERE active").fetchone()[0] == 15
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'ad.refresh'").fetchone()[0] == 1
def test_cadence_is_site_time() -> None:
fs = FeedSettings()
day = date(2026, 10, 21)
assert ad_reconcile.cadence(fs, datetime(day.year, day.month, day.day, 1, 29, tzinfo=TZ)) == 60
assert ad_reconcile.cadence(fs, datetime(day.year, day.month, day.day, 1, 30, tzinfo=TZ)) == 24 * 3600
+118
View File
@@ -0,0 +1,118 @@
"""The directory additions and the ad-reconcile job against the CI Samba domain controller (deploy/samba).
Skipped unless the TEST_LDAP_* values are set, like test_ldap_dc.py. The seed leaves PS-14-Electrical-Lead out,
adds PS-Extra-Legacy, nests Shop-Welders-Nested into PS-10-WeldAssembly-Worker, disables disabled.dan, gives
dup.dave and dup.dina the same employeeID and noemp.nora none. Nothing here writes to the directory.
"""
from __future__ import annotations
import uuid
from pathlib import Path
from typing import Any
import pytest
from pshelpers import app_conn, make_settings, needs_pg
from test_ldap_dc import MISSING, ldap_values
from packetstation.auth import directory as userdir
from packetstation.auth import groups, sessions
from packetstation.auth.ldap import AdUser, Directory
from packetstation.settings import Settings
from packetstation.worker import jobs, runs
from packetstation.worker.jobs import ad_reconcile
from packetstation.worker.scheduler import Scheduler
pytestmark = pytest.mark.skipif(bool(MISSING), reason=f"no test domain controller ({', '.join(MISSING)} not set)")
@pytest.fixture
def directory(tmp_path: Path) -> Directory:
return Directory(make_settings(tmp_path, **ldap_values()))
def test_list_groups_detail(directory: Directory) -> None:
detail = directory.list_groups_detail()
names = {n.lower() for n, _ in detail}
assert "ps-14-electrical-lead" not in names and "ps-extra-legacy" in names
assert sum(1 for n in groups.group_names() if n.lower() in names) == 49
base = directory.settings.ldap_group_base_dn.lower()
assert all(dn.lower().endswith(base) and dn.lower().startswith(f"cn={n.lower()},") for n, dn in detail)
def test_members_of_dn_nested_and_disabled(directory: Directory) -> None:
dn = dict(directory.list_groups_detail())
assert {u.sam_account for u in directory.members_of_dn(dn["PS-10-WeldAssembly-Worker"])} == {"nested.ned"}
weld = {u.sam_account: u for u in directory.members_of_dn(dn["PS-03-WeldParts-Worker"])}
assert {"weld.andy", "both.bo", "dup.dave", "disabled.dan"} <= set(weld)
assert weld["disabled.dan"].disabled is True and weld["weld.andy"].disabled is False
assert directory.members_of_dn("CN=PS-No-Such-Group," + directory.settings.ldap_group_base_dn) == []
def test_search_groups_and_users_by_guid(directory: Directory) -> None:
found = directory.search_groups("Welders-Nest")
assert [g["name"] for g in found] == ["Shop-Welders-Nested"]
assert found[0]["description"] and found[0]["dn"].lower().endswith(directory.settings.ldap_base_dn.lower())
assert len(directory.search_groups("PS-", limit=3)) == 3
ann = directory.find_user("admin.ann")
assert ann is not None
ghost = uuid.uuid4()
got = directory.users_by_guid([ann.guid, ghost])
assert got[ghost] is None and got[ann.guid] is not None and got[ann.guid].sam_account == "admin.ann"
def _run(settings: Settings, d: Directory) -> dict[str, Any]:
sch = Scheduler(settings, build="pytest", jobs={"ad-reconcile": ad_reconcile.SPEC})
sch.stack.cache["directory"] = d
return runs.run_job(sch, "ad-reconcile", "schedule")
def _session(settings: Settings, conn: Any, user_id: uuid.UUID) -> uuid.UUID:
_, row = sessions.create(
conn, settings, settings.session_key(), user_id=user_id, kind="password", roles=["clerical"], groups=[]
)
sid: uuid.UUID = row["id"]
return sid
@needs_pg
def test_reconcile_against_dc(pg_settings: Settings, directory: Directory, monkeypatch: pytest.MonkeyPatch) -> None:
raised: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(jobs, "raise_event", lambda conn, t, **kw: raised.append((t, kw)))
monkeypatch.setattr(jobs, "resolve_event", lambda conn, t, **kw: None)
row = _run(pg_settings, directory)
assert row["status"] == "ok", row["errors"]
c = row["counts"]
assert c["groups_missing"] == ["PS-14-Electrical-Lead"] and c["groups_extra"] == ["PS-Extra-Legacy"]
assert c["groups_checked"] == 49 and c["users_seen"] == 16
assert "noemp.nora" in c["emp_missing"] and c["emp_duplicate"] == ["9001"]
assert ("group_missing", "PS-14-Electrical-Lead") in [(t, kw["subject"]) for t, kw in raised]
assert any(t == "ad_reconcile_report" for t, _ in raised)
dan = directory.find_user("disabled.dan")
nick = directory.find_user("norole.nick")
ned = directory.find_user("nested.ned")
assert dan is not None and nick is not None and ned is not None
ghost = AdUser(uuid.uuid4(), "CN=ghost,DC=pstest,DC=local", "ghost.gary", None, "Gary Ghost", "9999", None, False)
with app_conn(pg_settings) as conn:
flags = dict(conn.execute("SELECT sam_account, directory_flags FROM users").fetchall())
assert flags["dup.dave"]["emp_duplicate"] and flags["dup.dina"]["emp_duplicate"]
assert flags["noemp.nora"]["emp_missing"] and flags["disabled.dan"]["disabled"]
assert userdir.roles_of(conn, ned.guid) == ["shop:10:worker"]
assert conn.execute("SELECT active FROM users WHERE id = %s", (dan.guid,)).fetchone()[0] is False
# A disabled account that kept a session, an account in no group, an account AD no longer has
userdir.upsert_user(conn, nick, groups=[])
userdir.upsert_user(conn, ghost, groups=[])
sid = {u: _session(pg_settings, conn, u) for u in (dan.guid, nick.guid, ghost.guid)}
row = _run(pg_settings, directory)
assert row["status"] == "ok", row["errors"]
assert row["counts"]["sessions_ended"] == 3 and row["counts"]["deactivated"] == 1 # the ghost
with app_conn(pg_settings) as conn:
why = dict(conn.execute("SELECT id, revoked_reason FROM sessions").fetchall())
assert why[sid[dan.guid]] == "ad_disabled" and why[sid[nick.guid]] == "roles_lost"
assert why[sid[ghost.guid]] == "ad_gone"
gary = conn.execute("SELECT active, directory_flags FROM users WHERE id = %s", (ghost.guid,)).fetchone()
assert gary[0] is False and gary[1]["gone"] is True
assert conn.execute("SELECT active FROM users WHERE id = %s", (nick.guid,)).fetchone()[0] is True
+16 -1
View File
@@ -70,6 +70,11 @@ APPEND_INSERTS = {
"print_log": (
"INSERT INTO print_log (packet_id, kind, what, by) VALUES (%(packet)s, 'traveler', 'Job traveler', %(user)s)"
),
"notifications_sent": (
"INSERT INTO notifications_sent (event_type, subject, result, requested_by)"
" VALUES ('security_alert', 'Test', 'dry_run', %(user)s)"
),
"integrity_findings": "INSERT INTO integrity_findings (run_id, kind, subject) VALUES (1, 'audit_chain', '1')",
}
APPEND_ONLY = list(APPEND_INSERTS)
@@ -402,7 +407,17 @@ def test_feed_tables_and_app_grants(pg_settings: Settings) -> None:
for priv in ("SELECT", "INSERT", "UPDATE", "DELETE"):
assert one(conn, "SELECT has_table_privilege('ps_app', %s, %s)", (table, priv)) is True, (table, priv)
jobs = [r[0] for r in conn.execute("SELECT job FROM job_schedule ORDER BY job").fetchall()]
assert jobs == ["drawing-lookup", "housekeeping", "visual-feed"]
assert jobs == [
"ad-reconcile",
"backup-check",
"drawing-lookup",
"housekeeping",
"integrity",
"notify-digest",
"notify-send",
"session-refresh",
"visual-feed",
]
assert one(conn, "SELECT target FROM visual_resource_map WHERE resource = 'WELD-PART'") == "03"
assert one(conn, "SELECT target FROM visual_resource_map WHERE resource = 'Electical'") == "14"
assert one(conn, "SELECT count(*) FROM visual_resource_map WHERE confirmed") == 0
+172
View File
@@ -0,0 +1,172 @@
"""The security settings overlay: bounds, compare-and-set, and the effect on the rate limiter, the idle lock, the
session length and the security alerts."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import app_conn, make_settings, needs_pg, sign_in
from packetstation.auth import secsettings
from packetstation.settings import Settings
@pytest.fixture
def raised(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""Record notify.raise_event calls instead of writing events (agent A's module is tested on its own)."""
from packetstation import notify
calls: list[dict[str, Any]] = []
def fake(conn: Any, event_type: str, **kw: Any) -> int:
calls.append({"event_type": event_type, **kw})
return len(calls)
monkeypatch.setattr(notify, "raise_event", fake)
return calls
# ---------------------------------------------------------------- pure
def test_validate_bounds_and_unknown_keys() -> None:
assert secsettings.validate({"session_idle_minutes": 60, "signin_account_cooldown_minutes": 0}) == {
"session_idle_minutes": 60,
"signin_account_cooldown_minutes": 0,
}
with pytest.raises(secsettings.SecSettingsError) as e:
secsettings.validate(
{
"session_idle_minutes": 61,
"signin_account_limit": 16,
"signin_ip_limit": "50",
"roles_refresh_minutes": True,
"pin_max_failures": 3,
}
)
assert len(e.value.errors) == 5
assert "session_idle_minutes: a whole number from 1 to 60" in e.value.errors
assert "pin_max_failures: not a security setting" in e.value.errors
# Always under the domain's lockout at 20
assert secsettings.BOUNDS["signin_account_limit"][1] < 20
def test_stored_values_drop_bad_keys_and_defaults_come_from_env(tmp_path: Path) -> None:
assert secsettings.stored_values({"session_lock_minutes": 5, "session_idle_minutes": 0, "x": 1}) == {
"session_lock_minutes": 5
}
assert secsettings.stored_values(["not", "a", "dict"]) == {}
s = make_settings(tmp_path, session_idle_minutes=20, signin_ip_limit=40)
d = secsettings.defaults(s)
assert d["session_idle_minutes"] == 20 and d["signin_ip_limit"] == 40 and d["signin_burst_threshold"] == 20
assert set(d) == set(secsettings.BOUNDS)
# ---------------------------------------------------------------- database
pg = needs_pg
@pg
def test_settings_api_bounds_cas_and_effect(
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, raised: list[dict[str, Any]]
) -> None:
sign_in(pg_client, "admin.ann")
got = pg_client.get("/api/admin/security/settings").json()
assert got["version"] == 0 and got["values"] == got["defaults"]
assert got["values"]["signin_account_limit"] == 5 and got["bounds"]["signin_account_limit"] == [1, 15]
assert set(got["bounds"]) == set(secsettings.BOUNDS)
r = pg_client.put("/api/admin/security/settings", json={"values": {"session_lock_minutes": 31}, "version": 0})
assert r.status_code == 422 and r.json()["code"] == "bad_settings"
assert r.json()["errors"] == ["session_lock_minutes: a whole number from 1 to 30"]
r = pg_client.put("/api/admin/security/settings", json={"values": {"nope": 3}, "version": 0})
assert r.status_code == 422
r = pg_client.put(
"/api/admin/security/settings",
json={"values": {"signin_account_limit": 2, "session_lock_minutes": 1, "session_pin_hours": 2}, "version": 0},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["version"] == 1 and body["values"]["signin_account_limit"] == 2
assert body["values"]["session_idle_minutes"] == body["defaults"]["session_idle_minutes"]
stale = pg_client.put("/api/admin/security/settings", json={"values": {"signin_ip_limit": 50}, "version": 0})
assert stale.status_code == 409 and stale.json()["code"] == "version_conflict"
assert stale.json()["current"]["version"] == 1
with app_conn(pg_settings) as conn:
row = conn.execute(
"SELECT detail FROM audit_events WHERE action = 'admin.security_settings_updated'"
).fetchone()[0]
assert row["changed"]["signin_account_limit"] == {"from": 5, "to": 2}
assert row["before"]["session_lock_minutes"] == 3 and row["after"]["session_lock_minutes"] == 1
eff = secsettings.effective(conn, pg_settings)
assert eff.signin_account_limit == 2 and eff.version == 1 and eff.settings is not None
assert eff.settings.signin_account_limit == 2 and pg_settings.signin_account_limit == 5
# An overlaid copy passed again comes back as it is
assert secsettings.overlay(conn, eff.settings) is eff.settings
# The rate limiter: two failures now start the cooldown (the environment says five)
pg_client.post("/api/auth/signout")
assert sign_in(pg_client, "clerk.clara", "wrong").status_code == 401
assert sign_in(pg_client, "clerk.clara", "wrong").status_code == 401
r = sign_in(pg_client, "clerk.clara")
assert r.status_code == 429 and r.json()["code"] == "rate_limited"
assert [c["event_type"] for c in raised] == ["security_alert"]
assert raised[0]["subject"] == "account:clerk.clara" and raised[0]["dedupe_minutes"] == 15
from packetstation.notify import EVENT_TYPES
assert "security_alert" in EVENT_TYPES and "signin_burst" in EVENT_TYPES
with app_conn(pg_settings) as conn:
lock = conn.execute("SELECT subject_id, detail FROM audit_events WHERE action = 'auth.lockout'").fetchall()
assert len(lock) == 1 and lock[0][0] == "account:clerk.clara" and lock[0][1]["minutes"] == 10
# The idle lock: one minute now (the environment says three); PIN sessions last two hours
sign_in(pg_client, "wh.wade")
assert pg_client.get("/api/auth/me").json()["session"]["lock_minutes"] == 1
with app_conn(pg_settings) as conn:
conn.execute("UPDATE sessions SET last_seen_at = now() - interval '90 seconds' WHERE revoked_at IS NULL")
r = pg_client.get("/api/account")
assert r.status_code == 423 and r.json()["code"] == "locked"
# The Security tab lists the lockout and the admin change
pg_client.post("/api/auth/signout")
sign_in(pg_client, "admin.ann")
kinds = {a["kind"] for a in pg_client.get("/api/admin/security/alerts").json()}
assert {"lockout", "admin_change"} <= kinds
assert pg_client.get("/api/admin/security/alerts", params={"hours": 169}).status_code == 422
@pg
def test_session_hours_and_burst(
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, raised: list[dict[str, Any]]
) -> None:
sign_in(pg_client, "admin.ann")
r = pg_client.put(
"/api/admin/security/settings",
json={"values": {"session_password_hours": 2, "signin_burst_threshold": 5}, "version": 0},
)
assert r.status_code == 200, r.text
sign_in(pg_client, "clerk.clara")
with app_conn(pg_settings) as conn:
hours = conn.execute(
"SELECT extract(epoch FROM s.expires_at - s.created_at) / 3600 FROM sessions s JOIN users u"
" ON u.id = s.user_id WHERE u.sam_account = 'clerk.clara'"
).fetchone()[0]
assert round(float(hours)) == 2
# Five failures across five accounts reach the burst threshold
for sam in ("a1", "a2", "a3", "a4", "a5"):
assert sign_in(pg_client, sam, "x").status_code == 401
bursts = [c for c in raised if c["event_type"] == "signin_burst"]
assert len(bursts) == 1 and bursts[0]["subject"] == "burst" and bursts[0]["detail"]["failures"] == 5
assert sign_in(pg_client, "a6", "x").status_code == 401
assert len([c for c in raised if c["event_type"] == "signin_burst"]) == 1 # audited once per 30 minutes
sign_in(pg_client, "admin.ann")
alerts = pg_client.get("/api/admin/security/alerts").json()
assert any(a["kind"] == "signin_burst" and a["detail"]["reason"] == "burst" for a in alerts)
+97
View File
@@ -0,0 +1,97 @@
"""The session-refresh job: roles rebuilt for live sessions, disabled, gone and roleless users signed out."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
import pytest
from fakedir import FakeDirectory
from pshelpers import app_conn, needs_pg
from packetstation.auth import directory as userdir
from packetstation.auth import sessions
from packetstation.feed.config import FeedSettings
from packetstation.settings import Settings
from packetstation.worker import runs
from packetstation.worker.jobs import ad_reconcile, session_refresh
from packetstation.worker.scheduler import Scheduler
def run(settings: Settings, d: FakeDirectory, name: str = "session-refresh") -> dict[str, Any]:
spec = session_refresh.SPEC if name == "session-refresh" else ad_reconcile.SPEC
sch = Scheduler(settings, build="pytest", jobs={name: spec})
sch.stack.cache["directory"] = d
return runs.run_job(sch, name, "schedule")
def session_for(settings: Settings, conn: Any, user_id: uuid.UUID, roles: list[str]) -> uuid.UUID:
_, row = sessions.create(
conn, settings, settings.session_key(), user_id=user_id, kind="password", roles=roles, groups=[]
)
conn.execute("UPDATE sessions SET roles_checked_at = now() - interval '1 hour' WHERE id = %s", (row["id"],))
sid: uuid.UUID = row["id"]
return sid
def test_cadence_follows_the_last_run(monkeypatch: pytest.MonkeyPatch) -> None:
now = datetime(2026, 10, 21, 10, tzinfo=ZoneInfo("America/Denver"))
monkeypatch.setattr(session_refresh, "_minutes", 15)
assert session_refresh.cadence(FeedSettings(), now) == 900
session_refresh._set_minutes(30)
assert session_refresh.cadence(FeedSettings(), now) == 1800
@needs_pg
def test_session_refresh(pg_settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None:
d = FakeDirectory()
monkeypatch.setattr("packetstation.worker.jobs.raise_event", lambda *a, **k: None)
monkeypatch.setattr("packetstation.worker.jobs.resolve_event", lambda *a, **k: None)
assert run(pg_settings, d, "ad-reconcile")["status"] == "ok"
# Nobody signed in: nothing to do
row = run(pg_settings, d)
assert row["status"] == "ok" and row["counts"]["users"] == 0
g = {s: d.user(s).guid for s in ("clerk.clara", "wh.wade", "qc.quinn", "lead.lou", "sup.wes")}
with app_conn(pg_settings) as conn:
sid = {s: session_for(pg_settings, conn, uid, ["clerical"]) for s, uid in g.items()}
conn.execute("UPDATE sessions SET revoked_at = now() WHERE id = %s", (sid["sup.wes"],))
d.user("lead.lou").groups = ["PS-03-WeldParts-Supervisor"] # promoted
d.user("clerk.clara").disabled = True
d.users.remove(d.user("wh.wade"))
d.user("qc.quinn").groups = []
# AD down: degraded, nothing ended or changed
d.down = True
row = run(pg_settings, d)
assert row["status"] == "degraded" and row["counts"]["ad"] == "unavailable"
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT count(*) FROM sessions WHERE revoked_at IS NULL").fetchone()[0] == 4
d.down = False
row = run(pg_settings, d)
assert row["status"] == "ok", row["errors"]
c = row["counts"]
assert c["users"] == 4 and c["refreshed"] == 1 and c["deactivated"] == 2 and c["roles_lost"] == 1
assert c["sessions_ended"] == 3 and c["roles_changed"] == 2 # Lou promoted, Quinn lost QC
with app_conn(pg_settings) as conn:
state = dict(conn.execute("SELECT id, revoked_reason FROM sessions").fetchall())
assert state[sid["clerk.clara"]] == "ad_disabled" and state[sid["wh.wade"]] == "ad_gone"
assert state[sid["qc.quinn"]] == "roles_lost" and state[sid["lead.lou"]] is None
snap, fresh = conn.execute(
"SELECT roles_snapshot, roles_checked_at > now() - interval '1 minute' FROM sessions WHERE id = %s",
(sid["lead.lou"],),
).fetchone()
assert snap["roles"] == ["shop:03:supervisor"] and snap["groups"] == ["PS-03-WeldParts-Supervisor"] and fresh
assert userdir.roles_of(conn, g["lead.lou"]) == ["shop:03:supervisor"]
active = dict(conn.execute("SELECT id, active FROM users").fetchall())
assert active[g["clerk.clara"]] is False and active[g["wh.wade"]] is False and active[g["qc.quinn"]] is True
ends = conn.execute(
"SELECT detail->>'reason' FROM audit_events WHERE action = 'auth.session_ended'"
" AND detail->>'by' = 'session-refresh' ORDER BY 1"
).fetchall()
assert [e[0] for e in ends] == ["ad_disabled", "ad_gone", "roles_lost"]