"""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="noreply@example.test", **over ) def test_catalog() -> None: assert len(EVENT_TYPES) == 14 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("ops@pescoinc.biz") and address_ok("a.b+c@x.example.test") for bad in ("", "nobody", "a@b", "a b@c.test", "", "a@b.test, c@d.test"): 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", ["a@example.test"]) @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", ["a@example.test", "b@example.test"]) is None assert len(relay.messages) == 1 m = relay.messages[0] assert m.tls is True assert m.mail_from == "noreply@example.test" assert m.rcpts == ["a@example.test", "b@example.test"] assert m.header("To") == "noreply@example.test" assert "a@example.test" 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", ["a@example.test"]) 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", ["a@example.test"]) assert plain.messages == [] @needs_ca def test_partly_refused_is_sent_with_a_note(tmp_path: Path, certs: Certs) -> None: with FakeSmtp(certs, refuse={"gone@example.test"}) as server: s = mail_settings(tmp_path, server, certs.ca) note = smtp.send(s, "x", "y", ["a@example.test", "gone@example.test"]) assert note is not None and "gone@example.test" in note assert server.messages[0].rcpts == ["a@example.test"] with pytest.raises(smtp.MailError, match="refused"): smtp.send(s, "x", "y", ["gone@example.test"]) 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", ["a@example.test"])