163 lines
6.2 KiB
Python
163 lines
6.2 KiB
Python
"""The gate: who may send, on what channel, to what address.
|
|||
|
|
|
||
|
|
Every case here is a way this service could become an open relay, a bill, or a
|
||
|
|
message to the wrong person.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
|
||
|
|
from app import providers
|
||
|
|
from app.config import Settings
|
||
|
|
from app.main import create_app, redact
|
||
|
|
from tests.conftest import TOKEN
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def sent(monkeypatch: pytest.MonkeyPatch) -> list[dict]:
|
||
|
|
"""Record what would have gone to a provider, and send nothing."""
|
||
|
|
calls: list[dict] = []
|
||
|
|
|
||
|
|
def fake_email(**kwargs):
|
||
|
|
calls.append({"provider": "sendgrid", **kwargs})
|
||
|
|
return providers.Outcome(ok=True, status=202, detail="accepted", message_id="msg-1")
|
||
|
|
|
||
|
|
def fake_sms(**kwargs):
|
||
|
|
calls.append({"provider": "twilio", **kwargs})
|
||
|
|
return providers.Outcome(ok=True, status=201, detail="accepted", message_id="SM1")
|
||
|
|
|
||
|
|
monkeypatch.setattr(providers, "send_email", fake_email)
|
||
|
|
monkeypatch.setattr(providers, "send_sms", fake_sms)
|
||
|
|
return calls
|
||
|
|
|
||
|
|
|
||
|
|
def body(**over) -> dict:
|
||
|
|
payload = {"channel": "email", "to": "[email protected]", "subject": "Due", "body": "Take the bins out"}
|
||
|
|
payload.update(over)
|
||
|
|
return payload
|
||
|
|
|
||
|
|
|
||
|
|
def test_healthz_needs_no_token_and_reports_wiring(client: TestClient) -> None:
|
||
|
|
reply = client.get("/healthz")
|
||
|
|
assert reply.status_code == 200
|
||
|
|
assert reply.json()["channels"] == {"email": True, "sms": True}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"headers",
|
||
|
|
[{}, {"Authorization": "Bearer "}, {"Authorization": f"Bearer {TOKEN}x"}, {"Authorization": TOKEN}],
|
||
|
|
ids=["absent", "empty", "wrong", "unprefixed"],
|
||
|
|
)
|
||
|
|
def test_a_send_without_the_right_bearer_is_refused(client: TestClient, sent: list, headers: dict) -> None:
|
||
|
|
reply = client.post("/send", json=body(), headers=headers)
|
||
|
|
assert reply.status_code == 401
|
||
|
|
assert sent == [] # and nothing was handed to a provider
|
||
|
|
|
||
|
|
|
||
|
|
def test_service_refuses_to_start_without_a_token() -> None:
|
||
|
|
with pytest.raises(RuntimeError):
|
||
|
|
create_app(Settings(token=""))
|
||
|
|
|
||
|
|
|
||
|
|
def test_email_goes_to_sendgrid_from_the_operator_sender(client: TestClient, auth: dict, sent: list) -> None:
|
||
|
|
reply = client.post("/send", json=body(), headers=auth)
|
||
|
|
assert reply.status_code == 200
|
||
|
|
assert reply.json() == {"ok": True, "channel": "email", "provider_status": 202, "id": "msg-1"}
|
||
|
|
assert sent[0]["provider"] == "sendgrid"
|
||
|
|
assert sent[0]["to"] == "[email protected]"
|
||
|
|
assert sent[0]["sender"] == "[email protected]"
|
||
|
|
|
||
|
|
|
||
|
|
def test_sms_goes_to_twilio(client: TestClient, auth: dict, sent: list) -> None:
|
||
|
|
reply = client.post("/send", json=body(channel="sms", to="+15551234567"), headers=auth)
|
||
|
|
assert reply.status_code == 200
|
||
|
|
assert sent[0]["provider"] == "twilio"
|
||
|
|
assert sent[0]["sender"] == "+15550001111"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("channel", ["whatsapp", "push"])
|
||
|
|
def test_unwired_channels_say_so_rather_than_pretending(client: TestClient, auth: dict, sent: list, channel: str) -> None:
|
||
|
|
reply = client.post("/send", json=body(channel=channel, to="+15551234567"), headers=auth)
|
||
|
|
assert reply.status_code == 501
|
||
|
|
assert channel in reply.json()["error"]
|
||
|
|
assert sent == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_unknown_channel_is_rejected(client: TestClient, auth: dict, sent: list) -> None:
|
||
|
|
reply = client.post("/send", json=body(channel="carrier-pigeon"), headers=auth)
|
||
|
|
assert reply.status_code == 400
|
||
|
|
assert sent == []
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"address",
|
||
|
|
["+15551234567", "person", "person@", "@example.test", "a [email protected]", "person@example", "[email protected]", ""],
|
||
|
|
)
|
||
|
|
def test_email_channel_rejects_anything_that_is_not_an_email(client: TestClient, auth: dict, sent: list, address: str) -> None:
|
||
|
|
reply = client.post("/send", json=body(to=address), headers=auth)
|
||
|
|
assert reply.status_code == 400
|
||
|
|
assert sent == []
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"number",
|
||
|
|
["15551234567", "+0155512345", "+1555", "[email protected]", "+1555123456789012", "+1 555 123 4567", ""],
|
||
|
|
)
|
||
|
|
def test_sms_channel_rejects_anything_that_is_not_e164(client: TestClient, auth: dict, sent: list, number: str) -> None:
|
||
|
|
reply = client.post("/send", json=body(channel="sms", to=number), headers=auth)
|
||
|
|
assert reply.status_code == 400
|
||
|
|
assert sent == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_empty_message_is_not_worth_sending(client: TestClient, auth: dict, sent: list) -> None:
|
||
|
|
reply = client.post("/send", json=body(subject="", body=""), headers=auth)
|
||
|
|
assert reply.status_code == 400
|
||
|
|
assert sent == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_body_that_is_not_json_is_rejected(client: TestClient, auth: dict) -> None:
|
||
|
|
reply = client.post("/send", content=b"not json", headers={**auth, "Content-Type": "application/json"})
|
||
|
|
assert reply.status_code == 400
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_channel_without_credentials_answers_503(auth: dict, sent: list) -> None:
|
||
|
|
client = TestClient(create_app(Settings(token=TOKEN, sendgrid_key="", email_from="")))
|
||
|
|
reply = client.post("/send", json=body(), headers=auth)
|
||
|
|
assert reply.status_code == 503
|
||
|
|
assert sent == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_provider_refusal_surfaces_as_502_with_its_status(client: TestClient, auth: dict, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
|
|
monkeypatch.setattr(
|
||
|
|
providers,
|
||
|
|
"send_email",
|
||
|
|
lambda **_: providers.Outcome(ok=False, status=403, detail="sender not verified"),
|
||
|
|
)
|
||
|
|
reply = client.post("/send", json=body(), headers=auth)
|
||
|
|
assert reply.status_code == 502
|
||
|
|
assert reply.json()["provider_status"] == 403
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_message_never_reaches_the_log(client: TestClient, auth: dict, sent: list, caplog) -> None:
|
||
|
|
with caplog.at_level("INFO"):
|
||
|
|
client.post("/send", json=body(subject="Divorce hearing", body="10am, courthouse"), headers=auth)
|
||
|
|
logged = caplog.text
|
||
|
|
assert "Divorce hearing" not in logged
|
||
|
|
assert "courthouse" not in logged
|
||
|
|
assert "[email protected]" not in logged # nor the full address
|
||
|
|
assert "p***@example.test" in logged
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("channel", "address", "expected"),
|
||
|
|
[
|
||
|
|
("email", "[email protected]", "p***@example.test"),
|
||
|
|
("sms", "+15551234567", "***4567"),
|
||
|
|
("sms", "+123", "***"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_redaction_keeps_enough_to_recognise_a_delivery(channel: str, address: str, expected: str) -> None:
|
||
|
|
assert redact(channel, address) == expected
|