from __future__ import annotations from collections.abc import Callable from datetime import UTC, datetime from typing import cast import pytest from backup_tool.db.models import ( NotificationDelivery, NotificationEmailSettings, NotificationEvent, ) from backup_tool.ids import new_uuid7 from backup_tool.notifications.email import SMTPClient, deliver_email from backup_tool.notifications.events import emit_event from sqlalchemy import select PASSWORD = "correct horse battery staple" async def _setup(client) -> str: response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) assert response.status_code == 201 return client.cookies["backup_tool_csrf"] @pytest.mark.asyncio async def test_filters_manual_test_retry_and_history(app_client) -> None: client, _ = app_client csrf = await _setup(client) created = await client.post( "/api/v2/notifications/subscriptions", json={ "channel": "email", "event_filters": ["schedule.*", "notification.test_requested"], "destination": {"recipients": ["operator@example.test"]}, }, headers={"X-CSRF-Token": csrf}, ) assert created.status_code == 201 subscription_id = created.json()["id"] app = client._transport.app async with app.state.sessions() as db: await emit_event( db, "execution.queued", correlation_id=str(new_uuid7()), resource={}, deduplication_key="filtered-out", ) scheduled = await emit_event( db, "schedule.created", correlation_id=str(new_uuid7()), resource={}, deduplication_key="filtered-in", ) await db.commit() statement = select(NotificationDelivery).where( NotificationDelivery.event_id == scheduled.id ) deliveries = list((await db.scalars(statement)).all()) assert len(deliveries) == 1 delivery = deliveries[0] delivery.state = "failed" delivery.terminal_reason = "http_permanent" await db.commit() delivery_id = delivery.id tested = await client.post( f"/api/v2/notifications/subscriptions/{subscription_id}/test", headers={"X-CSRF-Token": csrf, "Idempotency-Key": "test-one"}, ) assert tested.status_code == 202 retried = await client.post( f"/api/v2/notifications/deliveries/{delivery_id}/retry", headers={"X-CSRF-Token": csrf, "Idempotency-Key": "retry-one"}, ) assert retried.status_code == 202 replayed = await client.post( f"/api/v2/notifications/deliveries/{delivery_id}/retry", headers={"X-CSRF-Token": csrf, "Idempotency-Key": "retry-one"}, ) assert replayed.status_code == 202 assert replayed.json() == retried.json() history = await client.get("/api/v2/notifications/deliveries") assert history.status_code == 200 row = next(item for item in history.json()["items"] if item["id"] == delivery_id) assert row["state"] == "retry" @pytest.mark.asyncio async def test_manual_test_bypasses_filters_and_targets_only_selected_subscription( app_client, ) -> None: client, _ = app_client csrf = await _setup(client) selected = await client.post( "/api/v2/notifications/subscriptions", json={ "channel": "email", "event_filters": ["execution.failed"], "destination": {"recipients": ["selected@example.test"]}, }, headers={"X-CSRF-Token": csrf}, ) other = await client.post( "/api/v2/notifications/subscriptions", json={ "channel": "email", "event_filters": ["notification.test_requested"], "destination": {"recipients": ["other@example.test"]}, }, headers={"X-CSRF-Token": csrf}, ) assert selected.status_code == other.status_code == 201 response = await client.post( f"/api/v2/notifications/subscriptions/{selected.json()['id']}/test", headers={"X-CSRF-Token": csrf, "Idempotency-Key": "selected-test"}, ) assert response.status_code == 202 app = client._transport.app async with app.state.sessions() as db: rows = list( ( await db.scalars( select(NotificationDelivery.subscription_id).where( NotificationDelivery.event_id == response.json()["event_id"] ) ) ).all() ) assert rows == [selected.json()["id"]] @pytest.mark.asyncio async def test_disabled_subscription_does_not_receive_future_events(app_client) -> None: client, _ = app_client csrf = await _setup(client) created = await client.post( "/api/v2/notifications/subscriptions", json={ "channel": "email", "event_filters": ["execution.*"], "destination": {"recipients": ["operator@example.test"]}, }, headers={"X-CSRF-Token": csrf}, ) subscription = created.json() disabled = await client.patch( f"/api/v2/notifications/subscriptions/{subscription['id']}", json={"state": "disabled"}, headers={"X-CSRF-Token": csrf, "If-Match": created.headers["ETag"]}, ) assert disabled.status_code == 200 app = client._transport.app async with app.state.sessions() as db: event = await emit_event( db, "execution.queued", correlation_id=str(new_uuid7()), resource={}, occurred_at=datetime.now(UTC), ) await db.commit() assert ( await db.scalar( select(NotificationDelivery.id).where(NotificationDelivery.event_id == event.id) ) is None ) @pytest.mark.asyncio async def test_email_uses_ehlo_starttls_then_auth_with_hermetic_fake() -> None: calls: list[str] = [] class FakeSMTP: def __init__(self, *_args, **_kwargs) -> None: calls.append("connect") def __enter__(self): return self def __exit__(self, *_args) -> None: calls.append("close") def ehlo(self) -> None: calls.append("ehlo") def starttls(self, *, context) -> None: assert context.check_hostname calls.append("starttls") def login(self, username: str, password: str) -> None: assert username == "operator" assert password == "smtp-password" calls.append("auth") def send_message(self, message) -> None: assert "smtp-password" not in message.as_string() calls.append("send") event = NotificationEvent( id=str(new_uuid7()), type="execution.queued", schema_version=1, occurred_at=datetime.now(UTC), correlation_id=str(new_uuid7()), severity="info", resource_refs={}, payload={}, canonical_envelope="{}", ) settings = NotificationEmailSettings( id=1, host="smtp.example.test", port=587, username="operator", password_secret_id=str(new_uuid7()), sender="sender@example.test", max_attempts=5, rate_limit_per_minute=60, ) result = await deliver_email( settings, "smtp-password", event, ["operator@example.test"], smtp_factory=cast(Callable[..., SMTPClient], FakeSMTP), ) assert result.response_class == "smtp_2xx" assert calls == ["connect", "ehlo", "starttls", "ehlo", "auth", "send", "close"]