"""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": "qc-desk@example.test"}, {"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, "qc-desk@example.test"), ("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"]] == ["admin.ann@example.test"] to = c.post(f"{ADMIN}/backup_stale/test", json={"to": "me@example.test"}).json() assert to["result"] == "sent" and [x["address"] for x in to["recipients"]] == ["me@example.test"] assert [m.rcpts for m in relay.messages] == [["admin.ann@example.test"], ["me@example.test"]] 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": "me@example.test"}).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