The estate's one channel exit

Services that need to reach a person hand the message here instead of each
carrying a SendGrid key. Email goes to SendGrid, SMS to Twilio; whatsapp and
push answer 501 rather than pretending.

It binds loopback and the docker bridge only. An authenticated relay on the
public internet is an open spam relay the moment the token leaks, and that
token is copied into every consumer's environment.

The log names a channel, a redacted address and the provider's answer. Never
the subject, never the body.
This commit is contained in:
nirpa
2026-08-18 15:41:28 -04:00
commit e4d6230dd1
17 changed files with 913 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.env
.git
.venv
tests
__pycache__
.pytest_cache
README.md
+19
View File
@@ -0,0 +1,19 @@
# Copy to .env on the host and chmod 600. Values come from the estate's
# existing accounts — this service issues no credentials of its own.
# The port this is published on, from the registry at $SHRE_PORTS_PATH.
CHANNEL_EXIT_PORT=
# The shared secret every consumer presents as `Authorization: Bearer ...`.
# Generate with: openssl rand -hex 32
CHANNEL_EXIT_TOKEN=
# SendGrid. The key needs mail.send and nothing more.
SENDGRID_API_KEY=
# Must be a verified sender on that SendGrid account or every send 403s.
EMAIL_FROM=
# Twilio.
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_FROM=
+28
View File
@@ -0,0 +1,28 @@
# The delivery gate. Nothing reaches main that has not passed it.
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest # label maps to act-22.04 on this estate's runner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Test
run: python -m pytest -q
- name: The image has to build too
run: docker build -t channel-exit:ci .
+5
View File
@@ -0,0 +1,5 @@
.env
.venv/
__pycache__/
*.pyc
.pytest_cache/
+30
View File
@@ -0,0 +1,30 @@
# The provider calls are stdlib urllib, so the image carries nothing beyond the
# web server itself. A service that holds the estate's SendGrid and Twilio
# credentials is the last place to want a wide dependency tree.
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /srv
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
# Nothing here needs root, and this process can send mail as the whole estate.
RUN useradd --system --uid 10002 --home /srv channelexit \
&& chown -R channelexit:channelexit /srv
USER channelexit
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4).status == 200 else 1)"
# 0.0.0.0 inside the container only. What keeps this off the internet is the
# published bind address in the compose file, not this line.
CMD ["uvicorn", "--factory", "app.main:asgi", "--host", "0.0.0.0", "--port", "8080"]
+70
View File
@@ -0,0 +1,70 @@
# channel-exit
The estate's one way out to a person's inbox or phone.
Services that need to reach a human POST here instead of carrying a SendGrid
key of their own. Credentials live in one place, one service can be turned off
or rate-limited, and a bug in some worker cannot become a bill.
## The contract
```
POST /send
Authorization: Bearer <CHANNEL_EXIT_TOKEN>
Content-Type: application/json
{"channel": "email|sms|whatsapp|push", "to": "...", "subject": "...", "body": "..."}
```
| Reply | When |
| --- | --- |
| `200` | the provider accepted it — `{"ok": true, "provider_status": 202, "id": "..."}` |
| `400` | unknown channel, or `to` is not an email / not E.164, or nothing to send |
| `401` | missing or wrong bearer token |
| `501` | `whatsapp` and `push` — no provider for them in this estate |
| `502` | the provider refused or was unreachable; its status is in the body |
| `503` | that channel's credentials are not configured here |
`GET /healthz` needs no token and reports which channels are wired.
The shape is fixed by its first consumer, `reminders-service`
(`app/alerts.py: gateway_sender`), which treats any 2xx as delivered.
## Two rules
**It listens on loopback and the docker bridge, and nowhere else.** An
authenticated relay reachable from the internet is an open spam relay the
moment the token leaks — and that token is copied into every consumer's
environment. It gets no `0.0.0.0` binding and no cloudflared route, ever.
**It does not log what it was asked to send.** The log records channel, a
redacted address, the provider's status and message id. Never a subject, never
a body, never a credential.
## Consumers
From the host: `http://127.0.0.1:<CHANNEL_EXIT_PORT>/send`
From another container: `http://172.17.0.1:<CHANNEL_EXIT_PORT>/send` — a
consumer's own `127.0.0.1` is that consumer, so a loopback gateway URL fails
every delivery while looking correctly configured.
## Running it
```sh
cp .env.example .env && chmod 600 .env # fill in, then
docker compose up -d --build
```
Every value in `.env` is required except `PROVIDER_TIMEOUT`. `CHANNEL_EXIT_TOKEN`
has no default: the service refuses to start without one rather than coming up
as an unauthenticated relay.
## Tests
```sh
pip install -r requirements-dev.txt && python -m pytest -q
```
The suite replaces `urllib.request.urlopen` with something that raises, so no
test can send a real message however it is written.
View File
+51
View File
@@ -0,0 +1,51 @@
"""What the relay needs to know, and what it refuses to start without.
The bearer token is the only thing standing between this service and an open
spam relay, so an unset token is a startup failure rather than a service that
accepts everything. Provider credentials are the opposite: a missing SendGrid
key must not stop SMS working, so each channel is asked at send time whether it
is configured and answers for itself.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
def _env(name: str, default: str = "") -> str:
return (os.environ.get(name, default) or "").strip()
@dataclass(frozen=True)
class Settings:
# The shared secret every caller must present. No default: a relay that
# falls back to a well-known token is worse than one that will not boot.
token: str = field(default_factory=lambda: _env("CHANNEL_EXIT_TOKEN"))
sendgrid_key: str = field(default_factory=lambda: _env("SENDGRID_API_KEY"))
# SendGrid rejects a From that is not a verified sender, so this is an
# operator setting and never anything a caller can influence.
email_from: str = field(default_factory=lambda: _env("EMAIL_FROM"))
twilio_sid: str = field(default_factory=lambda: _env("TWILIO_ACCOUNT_SID"))
twilio_token: str = field(default_factory=lambda: _env("TWILIO_AUTH_TOKEN"))
twilio_from: str = field(default_factory=lambda: _env("TWILIO_FROM"))
timeout: float = field(default_factory=lambda: float(_env("PROVIDER_TIMEOUT", "15") or 15))
@property
def email_ready(self) -> bool:
return bool(self.sendgrid_key and self.email_from)
@property
def sms_ready(self) -> bool:
return bool(self.twilio_sid and self.twilio_token and self.twilio_from)
def require_token(self) -> None:
if not self.token:
raise RuntimeError("CHANNEL_EXIT_TOKEN is unset; refusing to run an unauthenticated relay")
def get_settings() -> Settings:
return Settings()
+176
View File
@@ -0,0 +1,176 @@
"""The estate's one way out to a person's inbox or phone.
Every service that needs to reach a human hands the message here instead of
carrying a SendGrid key of its own. That is the whole point: the credentials
live in one place, one service can be rate-limited or turned off, and a bug in
some reminder worker cannot become a bill.
Which makes the two rules below non-negotiable.
1. **It never listens anywhere but loopback.** An authenticated relay reachable
from the internet is an open relay the moment the token leaks, and the token
travels in plaintext to every consumer. The bind address, not the token, is
what keeps this off the public internet — so it is not published through any
tunnel, and the compose file binds 127.0.0.1 explicitly.
2. **It never logs what it was asked to send.** Reminders are private. The log
records that a message went to a redacted address on a channel and what the
provider said, which is everything needed to debug a delivery and nothing
that would put someone's private reminder in a log file.
The request shape is fixed by its first consumer, reminders-service:
{"channel": "email|sms|whatsapp|push", "to": ..., "subject": ..., "body": ...}
"""
from __future__ import annotations
import hmac
import json
import logging
import re
from typing import Any
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app import providers
from app.config import Settings, get_settings
log = logging.getLogger("channel-exit")
CHANNELS = {"email", "sms", "whatsapp", "push"}
# Deliberately narrow. This is not RFC 5322 — it is the subset SendGrid will
# accept, and rejecting an odd-but-legal address is a better failure than
# handing a provider something that makes it 400 the whole request.
EMAIL = re.compile(r"^[^@\s,;<>\"]+@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$")
# E.164: a plus, a non-zero country digit, up to fifteen digits in total.
# Anything looser and a typo becomes a message to a stranger.
E164 = re.compile(r"^\+[1-9]\d{7,14}$")
MAX_BODY = 100_000
def redact(channel: str, address: str) -> str:
"""Enough of an address to recognise a delivery, not enough to reach it."""
if channel == "email" and "@" in address:
local, _, domain = address.partition("@")
return f"{local[:1]}***@{domain}"
if len(address) > 4:
return f"***{address[-4:]}"
return "***"
def _error(status: int, message: str) -> JSONResponse:
return JSONResponse({"ok": False, "error": message}, status_code=status)
def _authorised(request: Request, settings: Settings) -> bool:
header = request.headers.get("authorization", "")
scheme, _, presented = header.partition(" ")
if scheme.lower() != "bearer" or not presented:
return False
# Constant-time: a plain == leaks the token one character at a time to
# anyone who can measure the reply.
return hmac.compare_digest(presented.strip(), settings.token)
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
settings.require_token()
app = FastAPI(title="channel-exit", docs_url=None, redoc_url=None, openapi_url=None)
@app.get("/healthz")
def healthz() -> dict[str, Any]:
# Which channels are wired is operational fact, not a secret, and it is
# the first thing anyone debugging a silent delivery wants to know.
return {"ok": True, "channels": {"email": settings.email_ready, "sms": settings.sms_ready}}
@app.post("/send")
async def send(request: Request) -> Any:
if not _authorised(request, settings):
return _error(401, "a bearer token is required")
raw = await request.body()
if len(raw) > MAX_BODY:
return _error(413, "message too large")
try:
payload = json.loads(raw or b"{}")
except ValueError:
return _error(400, "body must be JSON")
if not isinstance(payload, dict):
return _error(400, "body must be a JSON object")
channel = str(payload.get("channel") or "").strip().lower()
to = str(payload.get("to") or "").strip()
subject = str(payload.get("subject") or "").strip()
body = str(payload.get("body") or "")
if channel not in CHANNELS:
return _error(400, f"unknown channel; expected one of {', '.join(sorted(CHANNELS))}")
if channel in {"whatsapp", "push"}:
# 501 and not 503: these are not misconfigured here, they have no
# provider in this estate at all. A consumer should stop asking.
return _error(501, f"{channel} is not configured on this gateway; only email and sms are wired")
if not to:
return _error(400, "'to' is required")
if not subject and not body:
return _error(400, "nothing to send: 'subject' and 'body' are both empty")
if channel == "email":
if not EMAIL.match(to) or len(to) > 254:
return _error(400, "'to' must be an email address for channel 'email'")
if not settings.email_ready:
return _error(503, "email is not configured on this gateway")
outcome = providers.send_email(
to=to,
subject=subject,
body=body,
api_key=settings.sendgrid_key,
sender=settings.email_from,
timeout=settings.timeout,
)
else:
if not E164.match(to):
return _error(400, "'to' must be an E.164 phone number (e.g. +15551234567) for channel 'sms'")
if not settings.sms_ready:
return _error(503, "sms is not configured on this gateway")
outcome = providers.send_sms(
to=to,
subject=subject,
body=body,
account_sid=settings.twilio_sid,
auth_token=settings.twilio_token,
sender=settings.twilio_from,
timeout=settings.timeout,
)
log.info(
"channel=%s to=%s ok=%s provider_status=%s id=%s detail=%s",
channel,
redact(channel, to),
outcome.ok,
outcome.status,
outcome.message_id or "-",
outcome.detail if not outcome.ok else "-",
)
if not outcome.ok:
# 502, because the failure is upstream: the caller's request was
# fine and retrying it here would be pointless. The provider's
# status travels in the body so the caller can tell a bad address
# from an outage without reading our logs.
return JSONResponse(
{"ok": False, "provider_status": outcome.status, "error": outcome.detail},
status_code=502,
)
return {"ok": True, "channel": channel, "provider_status": outcome.status, "id": outcome.message_id}
return app
def asgi() -> FastAPI:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
return create_app()
+153
View File
@@ -0,0 +1,153 @@
"""Talking to SendGrid and Twilio.
Kept behind one tiny HTTP seam (`post`) for two reasons. It is the only thing
tests need to replace, so a test can prove the right thing was sent without a
network or a live credential — and nothing in this estate should be able to
send a real message because someone ran the suite.
Nothing here raises for a provider refusal. A refusal is data: the caller has
to log it and tell its consumer, and an exception carrying a provider payload
is exactly the kind of thing that ends up in a log with a key in it.
"""
from __future__ import annotations
import base64
import json
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Callable
SENDGRID_URL = "https://api.sendgrid.com/v3/mail/send"
TWILIO_URL = "https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json"
# An SMS is billed per 160-character segment, so a caller with a runaway body
# is a bill, not just a long message. Truncating is the kinder failure: the
# person still gets the reminder, and nobody pays for a novel.
SMS_LIMIT = 1200
@dataclass(frozen=True)
class Response:
status: int
body: str
headers: dict[str, str]
@dataclass(frozen=True)
class Outcome:
"""What happened, in terms safe to log."""
ok: bool
status: int
detail: str
message_id: str = ""
Post = Callable[..., Response]
def post(url: str, *, data: bytes, headers: dict[str, str], timeout: float) -> Response:
request = urllib.request.Request(url, method="POST", data=data, headers=headers)
try:
with urllib.request.urlopen(request, timeout=timeout) as reply:
return Response(reply.status, reply.read().decode("utf-8", "replace"), dict(reply.headers))
except urllib.error.HTTPError as error:
# A 4xx from a provider is an answer, not a crash: it carries the
# reason the message was rejected, which is what we want to report.
return Response(error.code, error.read().decode("utf-8", "replace"), dict(error.headers or {}))
def send_email(
*,
to: str,
subject: str,
body: str,
api_key: str,
sender: str,
timeout: float,
post: Post = post,
) -> Outcome:
payload = {
"personalizations": [{"to": [{"email": to}]}],
"from": {"email": sender},
# SendGrid rejects an empty subject outright. A reminder with no
# subject is still worth delivering, so it gets a neutral one rather
# than a 400 the caller cannot act on.
"subject": subject or "Notification",
"content": [{"type": "text/plain", "value": body}],
}
reply = post(
SENDGRID_URL,
data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
timeout=timeout,
)
ok = 200 <= reply.status < 300
return Outcome(
ok=ok,
status=reply.status,
detail="accepted" if ok else _reason(reply.body),
message_id=reply.headers.get("X-Message-Id", ""),
)
def send_sms(
*,
to: str,
subject: str,
body: str,
account_sid: str,
auth_token: str,
sender: str,
timeout: float,
post: Post = post,
) -> Outcome:
# One channel contract, two shapes of message: callers compose a subject
# and a body because email has both. An SMS has neither, so they are joined
# rather than one of them being silently dropped.
text = f"{subject}\n\n{body}" if subject and body else (subject or body)
text = text[:SMS_LIMIT]
reply = post(
TWILIO_URL.format(sid=urllib.parse.quote(account_sid, safe="")),
data=urllib.parse.urlencode({"To": to, "From": sender, "Body": text}).encode(),
headers={
"Authorization": "Basic " + base64.b64encode(f"{account_sid}:{auth_token}".encode()).decode(),
"Content-Type": "application/x-www-form-urlencoded",
},
timeout=timeout,
)
ok = 200 <= reply.status < 300
message_id = ""
if ok:
try:
message_id = json.loads(reply.body).get("sid", "") or ""
except (ValueError, AttributeError):
pass
return Outcome(ok=ok, status=reply.status, detail="accepted" if ok else _reason(reply.body), message_id=message_id)
def _reason(body: str) -> str:
"""The provider's own words, trimmed — never the body we tried to send.
Both providers answer a rejection in JSON, and both put something usable in
`message`. Anything else is truncated rather than logged whole, because an
error body is the one place a provider might echo back what we sent it.
"""
try:
parsed = json.loads(body)
except ValueError:
return body.strip()[:200]
if isinstance(parsed, dict):
if parsed.get("message"):
code = parsed.get("code")
return f"{parsed['message']}" + (f" ({code})" if code else "")
errors = parsed.get("errors")
if isinstance(errors, list) and errors:
first = errors[0]
if isinstance(first, dict) and first.get("message"):
return str(first["message"])[:200]
return str(parsed)[:200]
+39
View File
@@ -0,0 +1,39 @@
# How this runs on the host. There is one service and no database: the relay
# keeps nothing, which is deliberate — an outbox of everyone's reminders is a
# liability, and the providers already have delivery logs.
#
# The two bind addresses below are the security boundary of this whole service.
# Neither is routable from outside the machine:
#
# 127.0.0.1 — for a human on the host, and for the health check.
# 172.17.0.1 — the docker bridge, so sibling containers can reach it by an
# address that exists for them. `127.0.0.1` inside a consumer's
# container is that container, so a consumer configured with a
# loopback gateway URL fails every delivery and looks fine.
#
# It must never be given a 0.0.0.0 binding or a cloudflared route. An
# authenticated relay on the public internet is an open spam relay the moment
# the token leaks, and that token is copied into every consumer's environment.
services:
channel-exit:
image: ${CHANNEL_EXIT_IMAGE:-channel-exit:local}
build: .
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:${CHANNEL_EXIT_PORT:?allocate a port in the 5400-5999 range}:8080"
- "${DOCKER_BRIDGE_IP:-172.17.0.1}:${CHANNEL_EXIT_PORT:?}:8080"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4).status == 200 else 1)"]
interval: 15s
timeout: 5s
start_period: 10s
retries: 3
logging:
# Bounded, because this log names who was messaged and when. It should
# not accumulate on disk indefinitely.
driver: json-file
options:
max-size: "10m"
max-file: "3"
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8.0
httpx>=0.27
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.115
uvicorn[standard]>=0.30
View File
+50
View File
@@ -0,0 +1,50 @@
"""Shared fixtures, and one guarantee.
The autouse fixture below is the important part: a suite for a service whose
entire job is sending messages to real people must not be able to send one.
Anything that reaches for a socket fails loudly instead of quietly costing
money or waking someone up.
"""
from __future__ import annotations
import urllib.request
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
TOKEN = "test-token"
@pytest.fixture(autouse=True)
def no_network(monkeypatch: pytest.MonkeyPatch) -> None:
def refuse(*args, **kwargs):
raise AssertionError("a test tried to reach the network")
monkeypatch.setattr(urllib.request, "urlopen", refuse)
@pytest.fixture
def settings() -> Settings:
return Settings(
token=TOKEN,
sendgrid_key="sg-key",
email_from="[email protected]",
twilio_sid="AC123",
twilio_token="twilio-secret",
twilio_from="+15550001111",
timeout=1.0,
)
@pytest.fixture
def client(settings: Settings) -> TestClient:
return TestClient(create_app(settings))
@pytest.fixture
def auth() -> dict[str, str]:
return {"Authorization": f"Bearer {TOKEN}"}
+118
View File
@@ -0,0 +1,118 @@
"""What actually goes on the wire to SendGrid and Twilio.
Asserted at the seam rather than mocked at the module boundary, because the
shape of these two requests is the part a provider silently rejects: a From
that is not the verified sender, a field Twilio spells `To` and not `to`.
"""
from __future__ import annotations
import base64
import json
import urllib.parse
from app import providers
def recorder(status: int, body: str = "{}", reply_headers: dict | None = None):
calls: list[dict] = []
def fake_post(url: str, *, data: bytes, headers: dict, timeout: float):
calls.append({"url": url, "data": data, "headers": headers, "timeout": timeout})
return providers.Response(status, body, reply_headers or {})
fake_post.calls = calls # type: ignore[attr-defined]
return fake_post
def test_email_is_addressed_from_the_verified_sender() -> None:
post = recorder(202, "", {"X-Message-Id": "abc"})
outcome = providers.send_email(
to="[email protected]",
subject="Due",
body="Bins",
api_key="sg-key",
sender="[email protected]",
timeout=5,
post=post,
)
sent = json.loads(post.calls[0]["data"])
assert post.calls[0]["url"] == providers.SENDGRID_URL
assert sent["from"]["email"] == "[email protected]"
assert sent["personalizations"][0]["to"] == [{"email": "[email protected]"}]
assert sent["content"][0]["type"] == "text/plain"
assert post.calls[0]["headers"]["Authorization"] == "Bearer sg-key"
assert outcome.ok and outcome.status == 202 and outcome.message_id == "abc"
def test_an_email_with_no_subject_still_sends() -> None:
post = recorder(202)
providers.send_email(
to="[email protected]", subject="", body="Bins", api_key="k", sender="[email protected]", timeout=5, post=post
)
assert json.loads(post.calls[0]["data"])["subject"] == "Notification"
def test_sendgrid_refusal_is_reported_not_raised() -> None:
post = recorder(403, json.dumps({"errors": [{"message": "The from address does not match a verified Sender"}]}))
outcome = providers.send_email(
to="[email protected]", subject="s", body="b", api_key="k", sender="[email protected]", timeout=5, post=post
)
assert not outcome.ok
assert outcome.status == 403
assert "verified Sender" in outcome.detail
def test_sms_is_form_encoded_with_twilio_field_names() -> None:
post = recorder(201, json.dumps({"sid": "SM9"}))
outcome = providers.send_sms(
to="+15551234567",
subject="Due",
body="Bins",
account_sid="AC123",
auth_token="secret",
sender="+15550001111",
timeout=5,
post=post,
)
fields = dict(urllib.parse.parse_qsl(post.calls[0]["data"].decode()))
assert post.calls[0]["url"].endswith("/Accounts/AC123/Messages.json")
assert fields == {"To": "+15551234567", "From": "+15550001111", "Body": "Due\n\nBins"}
expected = "Basic " + base64.b64encode(b"AC123:secret").decode()
assert post.calls[0]["headers"]["Authorization"] == expected
assert outcome.ok and outcome.message_id == "SM9"
def test_a_runaway_body_is_truncated_rather_than_billed_by_the_segment() -> None:
post = recorder(201, json.dumps({"sid": "SM9"}))
providers.send_sms(
to="+15551234567",
subject="",
body="x" * 50_000,
account_sid="AC1",
auth_token="t",
sender="+1555",
timeout=5,
post=post,
)
fields = dict(urllib.parse.parse_qsl(post.calls[0]["data"].decode()))
assert len(fields["Body"]) == providers.SMS_LIMIT
def test_twilio_refusal_carries_its_code() -> None:
post = recorder(400, json.dumps({"message": "To and From cannot be the same", "code": 21266}))
outcome = providers.send_sms(
to="+1555", subject="s", body="b", account_sid="AC1", auth_token="t", sender="+1555", timeout=5, post=post
)
assert not outcome.ok
assert outcome.status == 400
assert "21266" in outcome.detail
def test_an_unparseable_error_body_is_truncated_not_dumped() -> None:
post = recorder(500, "<html>" + "x" * 5000)
outcome = providers.send_email(
to="[email protected]", subject="s", body="b", api_key="k", sender="[email protected]", timeout=5, post=post
)
assert not outcome.ok
assert len(outcome.detail) <= 200
+162
View File
@@ -0,0 +1,162 @@
"""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