Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"]}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"]
|
||||
@@ -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]"])
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user