From 9cb936c8ffbad484503ae44b30d381e90c5f423b Mon Sep 17 00:00:00 2001 From: Josh Caponigro Date: Wed, 19 Aug 2026 23:27:37 -0500 Subject: [PATCH 1/6] Require bearer API key on push and admin endpoints, make clear a DELETE --- apis/devices.py | 8 ++-- apis/push.py | 2 + auth.py | 33 +++++++++++++++++ main.py | 6 +++ tests/conftest.py | 6 ++- tests/test_auth.py | 79 ++++++++++++++++++++++++++++++++++++++++ tests/test_device_api.py | 6 ++- 7 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 auth.py create mode 100644 tests/test_auth.py diff --git a/apis/devices.py b/apis/devices.py index 2e28868..7f2398e 100644 --- a/apis/devices.py +++ b/apis/devices.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends +from auth import require_api_key from models import Device from services import DeviceService @@ -25,7 +26,7 @@ def register_device(device: Device, device_service: DeviceService = Depends()): return device_service.register_device(device) -@router.get("/all", response_model=list[Device]) +@router.get("/all", response_model=list[Device], dependencies=[Depends(require_api_key)]) def get_registered_devices( device_service: DeviceService = Depends(), ): @@ -41,12 +42,11 @@ def get_registered_devices( return device_service.get_registered_devices() -# FOR TESTING PURPOSES ONLY -@router.get("/clear", response_model=None) +@router.delete("", response_model=None, dependencies=[Depends(require_api_key)]) def clear_registered_devices( device_service: DeviceService = Depends(), ): """ - Clears all registered devices from the device service. + Deletes all registered devices. """ return device_service.clear_registered_devices() diff --git a/apis/push.py b/apis/push.py index 3da1bce..1ac092d 100644 --- a/apis/push.py +++ b/apis/push.py @@ -1,11 +1,13 @@ from fastapi import APIRouter, Depends +from auth import require_api_key from models import Message from services import PushService router = APIRouter( prefix="/push", tags=["push"], + dependencies=[Depends(require_api_key)], responses={404: {"description": "Not found"}}, ) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..abdfdd5 --- /dev/null +++ b/auth.py @@ -0,0 +1,33 @@ +"""API key authentication for protected endpoints.""" + +import secrets + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from utils import getenv + +_bearer_scheme = HTTPBearer(auto_error=False) + + +def require_api_key( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), +) -> None: + """ + FastAPI dependency that rejects requests without a valid API key. + + Clients authenticate with an `Authorization: Bearer ` header. + The comparison is constant-time to avoid leaking key material through + response-timing differences. + + Raises: + HTTPException: 401 if the header is missing or the key does not match. + """ + if credentials is None or not secrets.compare_digest( + credentials.credentials.encode(), getenv("API_KEY").encode() + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/main.py b/main.py index 54d20e2..6e1e2c1 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,7 @@ It configures middleware, adds sub-routers, and defines application-level health checks. """ +import os from contextlib import asynccontextmanager from fastapi import APIRouter, FastAPI @@ -16,6 +17,11 @@ @asynccontextmanager async def lifespan(app: FastAPI): + if not os.getenv("API_KEY"): + raise RuntimeError( + "API_KEY environment variable must be set; protected endpoints " + "require clients to send it as 'Authorization: Bearer '" + ) EntityBase.metadata.create_all(database.engine) yield shutdown_push_handler() diff --git a/tests/conftest.py b/tests/conftest.py index 8366256..f23e5bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ os.environ.setdefault("APNS_TEAM_ID", "TESTTEAM12") os.environ.setdefault("APNS_APP_BUNDLE_ID", "com.example.test") os.environ.setdefault("APNS_AUTH_KEY_PATH", str(FIXTURES_DIR / "apns_test_key.p8")) +os.environ.setdefault("API_KEY", "test-api-key") import httpx import pytest @@ -50,7 +51,7 @@ def test_engine(): @pytest.fixture def client(test_engine, monkeypatch): - """TestClient wired to the in-memory database.""" + """Authenticated TestClient wired to the in-memory database.""" monkeypatch.setattr(database, "engine", test_engine) def override_db_session(): @@ -58,7 +59,8 @@ def override_db_session(): yield session app.dependency_overrides[db_session] = override_db_session - with TestClient(app) as test_client: + headers = {"Authorization": f"Bearer {os.environ['API_KEY']}"} + with TestClient(app, headers=headers) as test_client: yield test_client app.dependency_overrides.clear() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..821201a --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,79 @@ +"""Tests for API key authentication on protected endpoints.""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +import database +from database import db_session +from main import app + +WRONG_KEY_HEADERS = {"Authorization": "Bearer wrong-key"} + + +@pytest.fixture +def anon_client(test_engine, monkeypatch): + """TestClient that sends no Authorization header.""" + monkeypatch.setattr(database, "engine", test_engine) + + def override_db_session(): + with Session(test_engine) as session: + yield session + + app.dependency_overrides[db_session] = override_db_session + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("POST", "/push/send"), + ("GET", "/devices/all"), + ("DELETE", "/devices"), + ], +) +def test_protected_route_rejects_missing_credentials(anon_client, method, path): + response = anon_client.request(method, path, json={"recipients": [], "body": "x"}) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Bearer" + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("POST", "/push/send"), + ("GET", "/devices/all"), + ("DELETE", "/devices"), + ], +) +def test_protected_route_rejects_wrong_key(anon_client, method, path): + response = anon_client.request( + method, path, headers=WRONG_KEY_HEADERS, json={"recipients": [], "body": "x"} + ) + + assert response.status_code == 401 + + +def test_register_does_not_require_credentials(anon_client): + response = anon_client.post("/devices/register", json={"token": "abc123"}) + + assert response.status_code == 200 + + +def test_health_does_not_require_credentials(anon_client): + assert anon_client.get("/health").status_code == 200 + + +def test_correct_key_is_accepted(client): + assert client.get("/devices/all").status_code == 200 + + +def test_startup_fails_without_api_key(test_engine, monkeypatch): + monkeypatch.setattr(database, "engine", test_engine) + monkeypatch.delenv("API_KEY") + + with pytest.raises(RuntimeError, match="API_KEY"), TestClient(app): + pass diff --git a/tests/test_device_api.py b/tests/test_device_api.py index 54d67da..141f8d6 100644 --- a/tests/test_device_api.py +++ b/tests/test_device_api.py @@ -56,7 +56,7 @@ def test_clear_removes_all_registered_devices(client): client.post("/devices/register", json={"token": "token-1"}) client.post("/devices/register", json={"token": "token-2"}) - response = client.get("/devices/clear") + response = client.delete("/devices") assert response.status_code == 200 assert client.get("/devices/all").json() == [] @@ -109,3 +109,7 @@ def stale_scalar(*args, **kwargs): with Session(test_engine) as session: devices = DeviceService(session=session).get_registered_devices() assert len(devices) == 1 + + +def test_old_clear_route_is_gone(client): + assert client.get("/devices/clear").status_code in (404, 405) From 0eda74ce1ec31c44d4515dd5fdd54b0c961f047c Mon Sep 17 00:00:00 2001 From: Josh Caponigro Date: Wed, 19 Aug 2026 23:28:13 -0500 Subject: [PATCH 2/6] Disable CORS unless CORS_ORIGINS is configured --- main.py | 56 +++++++++++++++++++++++++++------------------- tests/test_cors.py | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 23 deletions(-) create mode 100644 tests/test_cors.py diff --git a/main.py b/main.py index 6e1e2c1..eb59851 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,7 @@ from apis import devices, push from entities import EntityBase from push import shutdown_push_handler +from utils import getenv @asynccontextmanager @@ -27,35 +28,44 @@ async def lifespan(app: FastAPI): shutdown_push_handler() -app = FastAPI(lifespan=lifespan) - -# Configure as needed -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allows all origins - allow_credentials=True, - allow_methods=["*"], # Allows all methods - allow_headers=["*"], # Allows all headers -) - -# List of routers -routers: list[APIRouter] = [devices.router, push.router] +def create_app() -> FastAPI: + """ + Builds the FastAPI application. + + Cross-origin requests are disabled unless the CORS_ORIGINS environment + variable lists the allowed origins (comma-separated). iOS apps do not use + CORS; only enable it when serving a web frontend. + """ + app = FastAPI(lifespan=lifespan) + + cors_origins = [ + origin.strip() for origin in getenv("CORS_ORIGINS", "").split(",") if origin.strip() + ] + if cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) -# Add routers to app -for router in routers: - app.include_router(router) + routers: list[APIRouter] = [devices.router, push.router] + for router in routers: + app.include_router(router) + @app.get("/health") + async def health(): + return {"message": "OK"} -# Application-Level Health Checks -@app.get("/health") -async def health(): - return {"message": "OK"} + @app.get("/") + async def root(): + return {"message": "Hello World"} + return app -@app.get("/") -async def root(): - return {"message": "Hello World"} +app = create_app() if __name__ == "__main__": import uvicorn diff --git a/tests/test_cors.py b/tests/test_cors.py new file mode 100644 index 0000000..7cd444e --- /dev/null +++ b/tests/test_cors.py @@ -0,0 +1,40 @@ +"""Tests for CORS configuration.""" + +from fastapi.testclient import TestClient + +from main import create_app + +PREFLIGHT_HEADERS = { + "Origin": "https://example.com", + "Access-Control-Request-Method": "POST", +} + + +def test_no_cors_headers_by_default(monkeypatch): + monkeypatch.delenv("CORS_ORIGINS", raising=False) + client = TestClient(create_app()) + + response = client.options("/devices/register", headers=PREFLIGHT_HEADERS) + + assert "access-control-allow-origin" not in response.headers + + +def test_configured_origin_is_allowed(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", "https://example.com, https://other.example") + client = TestClient(create_app()) + + response = client.options("/devices/register", headers=PREFLIGHT_HEADERS) + + assert response.headers["access-control-allow-origin"] == "https://example.com" + + +def test_unlisted_origin_is_rejected(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", "https://example.com") + client = TestClient(create_app()) + + response = client.options( + "/devices/register", + headers={"Origin": "https://evil.example", "Access-Control-Request-Method": "POST"}, + ) + + assert "access-control-allow-origin" not in response.headers From 631ef004e1fb2f8764072c3df21f98bb6c3a244c Mon Sep 17 00:00:00 2001 From: Josh Caponigro Date: Wed, 19 Aug 2026 23:29:27 -0500 Subject: [PATCH 3/6] Gate SQL echo behind DB_ECHO, share strict boolean env parsing --- database.py | 7 ++++--- push/config.py | 9 ++------- tests/test_env.py | 36 ++++++++++++++++++++++++++++++++++++ utils/__init__.py | 4 ++-- utils/env.py | 26 ++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 12 deletions(-) diff --git a/database.py b/database.py index 6a4e83d..1f59571 100644 --- a/database.py +++ b/database.py @@ -3,7 +3,7 @@ import sqlalchemy from sqlalchemy.orm import Session -from utils import getenv +from utils import getenv, getenv_bool def _engine_str(name: str = getenv("DB_NAME")) -> str: @@ -25,8 +25,9 @@ def _engine_str(name: str = getenv("DB_NAME")) -> str: return f"{dialect}://{user}:{password}@{host}:{port}/{name}" -engine = sqlalchemy.create_engine(_engine_str(), echo=True) -"""Application-level SQLAlchemy database engine.""" +# Application-level SQLAlchemy database engine. SQL statement logging would +# include device tokens, so it is opt-in via DB_ECHO for local debugging only. +engine = sqlalchemy.create_engine(_engine_str(), echo=getenv_bool("DB_ECHO")) def db_session(): diff --git a/push/config.py b/push/config.py index 325abc1..786ec2e 100644 --- a/push/config.py +++ b/push/config.py @@ -1,4 +1,4 @@ -from utils import getenv +from utils import getenv, getenv_bool from .apn_handler import TokenCredentials @@ -61,12 +61,7 @@ def get_use_sandbox(cls) -> bool: ValueError: If the variable is set to an unrecognized value, rather than silently falling back to the production environment. """ - value = getenv("APNS_USE_SANDBOX", "false").strip().lower() - if value in ("1", "true", "yes", "on"): - return True - if value in ("0", "false", "no", "off", ""): - return False - raise ValueError(f"APNS_USE_SANDBOX must be a boolean value, got {value!r}") + return getenv_bool("APNS_USE_SANDBOX") @classmethod def get_token_credentials(cls) -> TokenCredentials: diff --git a/tests/test_env.py b/tests/test_env.py index 85e7f69..3e1e9e0 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -22,3 +22,39 @@ def test_getenv_honors_falsy_default(monkeypatch): monkeypatch.delenv("SOME_TEST_VAR", raising=False) assert getenv("SOME_TEST_VAR", "") == "" + + +@pytest.mark.parametrize("value", ["1", "true", "YES ", " on"]) +def test_getenv_bool_parses_truthy_values(monkeypatch, value): + from utils import getenv_bool + + monkeypatch.setenv("SOME_TEST_VAR", value) + + assert getenv_bool("SOME_TEST_VAR") is True + + +@pytest.mark.parametrize("value", ["0", "false", "NO", "off", ""]) +def test_getenv_bool_parses_falsy_values(monkeypatch, value): + from utils import getenv_bool + + monkeypatch.setenv("SOME_TEST_VAR", value) + + assert getenv_bool("SOME_TEST_VAR") is False + + +def test_getenv_bool_returns_default_when_unset(monkeypatch): + from utils import getenv_bool + + monkeypatch.delenv("SOME_TEST_VAR", raising=False) + + assert getenv_bool("SOME_TEST_VAR") is False + assert getenv_bool("SOME_TEST_VAR", default=True) is True + + +def test_getenv_bool_rejects_unrecognized_values(monkeypatch): + from utils import getenv_bool + + monkeypatch.setenv("SOME_TEST_VAR", "banana") + + with pytest.raises(ValueError): + getenv_bool("SOME_TEST_VAR") diff --git a/utils/__init__.py b/utils/__init__.py index 534f24f..f6caa95 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,3 +1,3 @@ -from .env import getenv +from .env import getenv, getenv_bool -__all__ = ["getenv"] +__all__ = ["getenv", "getenv_bool"] diff --git a/utils/env.py b/utils/env.py index 8eb6e5d..9bc6039 100644 --- a/utils/env.py +++ b/utils/env.py @@ -30,3 +30,29 @@ def getenv(variable: str, default=_MISSING) -> str: if default is not _MISSING: return default raise NameError(f"Error: {variable} Environment Variable not Defined") + + +def getenv_bool(variable: str, default: bool = False) -> bool: + """ + Get the specified environment variable as a boolean. + + Args: + variable (str): The name of the environment variable to retrieve. + default (bool): Value to return if the variable is not defined. + + Returns: + bool: The parsed value. + + Raises: + ValueError: If the variable is set to an unrecognized value, rather + than silently falling back to the default. + """ + value = os.getenv(variable) + if value is None: + return default + normalized = value.strip().lower() + if normalized in ("1", "true", "yes", "on"): + return True + if normalized in ("0", "false", "no", "off", ""): + return False + raise ValueError(f"{variable} must be a boolean value, got {value!r}") From b2da5893abe7c5ffd77153dc122dc36ca3d25cb4 Mon Sep 17 00:00:00 2001 From: Josh Caponigro Date: Wed, 19 Aug 2026 23:32:56 -0500 Subject: [PATCH 4/6] Add integration test suite, CI Postgres service, sandbox e2e script, security docs --- .env.template | 11 +- .github/workflows/test.yml | 25 +++- README.md | 45 +++++++- pytest.ini | 2 + scripts/e2e_apns_sandbox.py | 35 ++++++ tests/integration/test_server.py | 188 +++++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 9 deletions(-) create mode 100644 scripts/e2e_apns_sandbox.py create mode 100644 tests/integration/test_server.py diff --git a/.env.template b/.env.template index 98fa351..3e8e4ec 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,9 @@ +# SECURITY +# Clients must send this on protected endpoints as: Authorization: Bearer +API_KEY=CHANGE_ME +# Comma-separated origins allowed to make cross-origin requests; leave unset to disable CORS +# CORS_ORIGINS=https://example.com + # DB SETTINGS DB_HOST=localhost DB_PORT=5432 @@ -11,4 +17,7 @@ APNS_TEAM_ID=YOUR_TEAM_ID APNS_APP_BUNDLE_ID=YOUR_APP_BUNDLE_ID APNS_AUTH_KEY_PATH=PATH_TO_YOUR_AUTH_KEY # Set to true to target the APNs sandbox (development builds) -APNS_USE_SANDBOX=false \ No newline at end of file +APNS_USE_SANDBOX=false + +# Set to true to log SQL statements (development only; statements include device tokens) +DB_ECHO=false \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b5096c..7439340 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,5 @@ -# Runs lint, type checks, and tests on every push and pull request to main. +# Runs lint, type checks, unit tests, and Postgres-backed integration tests +# on every push and pull request to main. name: Python application @@ -16,6 +17,26 @@ jobs: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + INTEGRATION_DB_HOST: localhost + INTEGRATION_DB_PORT: "5432" + INTEGRATION_DB_NAME: postgres + INTEGRATION_DB_USERNAME: postgres + INTEGRATION_DB_PASSWORD: postgres + steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 @@ -33,6 +54,6 @@ jobs: ruff check . ruff format --check . - name: Type-check with mypy - run: mypy apis entities models push services utils main.py database.py + run: mypy apis entities models push services utils main.py database.py auth.py - name: Test with pytest run: pytest diff --git a/README.md b/README.md index 4183397..05fe41a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,19 @@ To install this repository, follow these steps: ## Configuration Configure the application by creating an .env file based off the template. Set the necessary parameters like database connection parameters and APNs identifiers. -Set `APNS_USE_SANDBOX=true` when testing with development builds; their device tokens are only valid against the APNs sandbox environment. +- `API_KEY` (required): the secret protected endpoints require. The server refuses to start without it. +- `APNS_USE_SANDBOX`: set to `true` when testing with development builds; their device tokens are only valid against the APNs sandbox environment. +- `CORS_ORIGINS`: comma-separated origins allowed to make cross-origin requests. Unset by default, which disables CORS entirely — iOS apps do not use CORS; only set this when serving a web frontend. +- `DB_ECHO`: set to `true` to log SQL statements during development. Off by default because statements include device tokens. + +## Authentication +Endpoints that send pushes or expose device data require the API key: + +``` +Authorization: Bearer +``` + +Requests without a valid key receive `401 Unauthorized`. `/devices/register` is deliberately open: it is called by the iOS app itself, and shipping the key inside the app binary would expose it. The worst an unauthenticated caller can do is register junk tokens, which APNs pruning removes on the next push. ## Running the Server To start the server, run the following command: @@ -73,15 +85,18 @@ To implement push notifications in an iOS application, follow the steps below: #### Retrieve Devices Information - **Endpoint**: `/devices/all` - **Method**: `GET` +- **Auth**: Requires API key -#### Clear Devices Information -- **Endpoint**: `/devices/clear` -- **Method**: `GET` +#### Delete All Devices +- **Endpoint**: `/devices` +- **Method**: `DELETE` +- **Auth**: Requires API key ## Push Endpoints #### Send a Push Notification - **Endpoint**: `/push/send` - **Method**: `POST` +- **Auth**: Requires API key - **Body**: ```json { @@ -111,11 +126,29 @@ The `Device` entity and its model represent a device registered with the server. - `model`: The model of the device. (Optional, String) - `localizedModel`: The model of the device as a localized string. (Optional, String) -### FastAPI CORS Middleware -This middleware was left in the project to allow for cross-origin requests during development. This decision was made to enable CORS with frontend applications during development. However, it is not recommended to enable CORS in production environments as it can lead to security vulnerabilities. +### CORS +Cross-origin requests are disabled by default. To develop a web frontend against the server, set `CORS_ORIGINS` to the exact origins you serve it from (never a wildcard in production). **Note**: CORS is a browser security feature that prevents cross-origin requests. It does not affect requests from iOS applications. +## Testing +Run the unit and API test suite: +```bash +pytest +``` + +Integration tests boot the real server against a real Postgres and drive it over HTTP. Point them at any Postgres instance (for example, a disposable container): +```bash +docker run -d --name pnsf-test-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16-alpine +INTEGRATION_DB_HOST=localhost pytest tests/integration +``` +CI runs both suites, plus ruff and mypy, on every push and pull request. + +To verify real APNs connectivity with your own credentials (this sends one request to Apple's sandbox): +```bash +APNS_USE_SANDBOX=true python scripts/e2e_apns_sandbox.py +``` + ## Contributing Contributions to this repository are welcome. Please follow the standard GitHub pull request process to propose changes. diff --git a/pytest.ini b/pytest.ini index c7b23ec..a0fb376 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,5 @@ [pytest] pythonpath = . testpaths = tests +markers = + integration: boots the real server against real Postgres (needs INTEGRATION_DB_HOST) diff --git a/scripts/e2e_apns_sandbox.py b/scripts/e2e_apns_sandbox.py new file mode 100644 index 0000000..2f81149 --- /dev/null +++ b/scripts/e2e_apns_sandbox.py @@ -0,0 +1,35 @@ +"""Manual end-to-end check against Apple's real APNs sandbox. + +Sends one notification through the real PushHandler using the APNs credentials +from the environment (or .env). Not part of the automated test suite because +it performs a real network call to Apple. + +Usage: + python scripts/e2e_apns_sandbox.py [device_token] + +With real credentials and a sandbox device token from a development build, the +device receives the notification and the result is "Success". With throwaway +credentials the expected result is "InvalidProviderToken" — Apple rejecting +the key still proves connectivity, HTTP/2, JWT signing, and response parsing. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from push import PushHandler +from push.config import PushConfig + +if not PushConfig.get_use_sandbox(): + sys.exit("Set APNS_USE_SANDBOX=true; this script only targets the sandbox.") + +device_token = sys.argv[1] if len(sys.argv) > 1 else "deadbeef00" +results = PushHandler().send_multiple_push( + to_device_tokens=[device_token], body="PushNotificationServerFramework e2e test" +) +print(results) +result = results[device_token] +if result not in ("Success", "InvalidProviderToken", "ExpiredProviderToken", "BadDeviceToken"): + sys.exit(f"Unexpected result: {result}") +print("PASS: reached the APNs sandbox and parsed its response.") diff --git a/tests/integration/test_server.py b/tests/integration/test_server.py new file mode 100644 index 0000000..d4b1238 --- /dev/null +++ b/tests/integration/test_server.py @@ -0,0 +1,188 @@ +"""Integration tests: the real server process against a real Postgres database. + +These boot `uvicorn main:app` as a subprocess and drive it over HTTP, so they +cover startup schema creation, authentication, the full device lifecycle, and +graceful shutdown with no test doubles. They require a reachable Postgres +instance, configured via INTEGRATION_DB_* environment variables (see README), +and are skipped when INTEGRATION_DB_HOST is not set. +""" + +import os +import secrets +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path + +import httpx +import pytest +import sqlalchemy + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not os.getenv("INTEGRATION_DB_HOST"), + reason="INTEGRATION_DB_HOST not set; see README for running integration tests", + ), +] + +REPO_ROOT = Path(__file__).parent.parent.parent +API_KEY = secrets.token_hex(16) + + +def _integration_env() -> dict[str, str]: + env = os.environ.copy() + env.update( + { + "DB_HOST": os.environ["INTEGRATION_DB_HOST"], + "DB_PORT": os.environ.get("INTEGRATION_DB_PORT", "5432"), + "DB_NAME": os.environ.get("INTEGRATION_DB_NAME", "postgres"), + "DB_USERNAME": os.environ.get("INTEGRATION_DB_USERNAME", "postgres"), + "DB_PASSWORD": os.environ.get("INTEGRATION_DB_PASSWORD", "postgres"), + "API_KEY": API_KEY, + "APNS_KEY_ID": "TESTKEY123", + "APNS_TEAM_ID": "TESTTEAM12", + "APNS_APP_BUNDLE_ID": "com.example.test", + "APNS_AUTH_KEY_PATH": str(REPO_ROOT / "tests" / "fixtures" / "apns_test_key.p8"), + "APNS_USE_SANDBOX": "true", + } + ) + return env + + +def _drop_devices_table(env: dict[str, str]) -> None: + url = ( + f"postgresql+psycopg2://{env['DB_USERNAME']}:{env['DB_PASSWORD']}" + f"@{env['DB_HOST']}:{env['DB_PORT']}/{env['DB_NAME']}" + ) + engine = sqlalchemy.create_engine(url) + with engine.begin() as connection: + connection.execute(sqlalchemy.text("DROP TABLE IF EXISTS devices")) + engine.dispose() + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@pytest.fixture(scope="module") +def server(): + """The real application booted as a subprocess against real Postgres.""" + env = _integration_env() + _drop_devices_table(env) + port = _free_port() + + process = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "main:app", "--port", str(port)], + cwd=REPO_ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + base_url = f"http://127.0.0.1:{port}" + try: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + if process.poll() is not None: + pytest.fail(f"server exited during startup:\n{process.stdout.read()}") + try: + if httpx.get(f"{base_url}/health", timeout=1).status_code == 200: + break + except httpx.TransportError: + time.sleep(0.2) + else: + pytest.fail("server did not become healthy within 20s") + + yield base_url + finally: + process.send_signal(signal.SIGINT) + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + pytest.fail("server did not shut down gracefully within 15s") + assert process.returncode == 0, f"unclean shutdown:\n{process.stdout.read()}" + + +@pytest.fixture +def api(server): + """HTTP client authenticated with the server's API key.""" + with httpx.Client(base_url=server, headers={"Authorization": f"Bearer {API_KEY}"}) as client: + yield client + + +@pytest.fixture +def anon_api(server): + """HTTP client with no credentials.""" + with httpx.Client(base_url=server) as client: + yield client + + +def test_startup_creates_schema_and_serves_health(api): + assert api.get("/health").json() == {"message": "OK"} + assert api.get("/devices/all").status_code == 200 + + +def test_device_lifecycle(api, anon_api): + registered = anon_api.post( + "/devices/register", + json={"token": "integration-token", "name": "Integration Phone"}, + ) + assert registered.status_code == 200 + body = registered.json() + assert body["id"] is not None + assert body["created_at"] is not None + + reregistered = anon_api.post( + "/devices/register", json={"token": "integration-token", "systemVersion": "18.0"} + ) + assert reregistered.status_code == 200 + assert reregistered.json()["id"] == body["id"] + assert reregistered.json()["name"] == "Integration Phone" + assert reregistered.json()["systemVersion"] == "18.0" + + tokens = {device["token"] for device in api.get("/devices/all").json()} + assert "integration-token" in tokens + + assert api.delete("/devices").status_code == 200 + assert api.get("/devices/all").json() == [] + + +def test_registration_rejects_missing_token(anon_api): + assert anon_api.post("/devices/register", json={"name": "no token"}).status_code == 422 + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("GET", "/devices/all"), + ("DELETE", "/devices"), + ("POST", "/push/send"), + ], +) +def test_protected_routes_require_credentials(anon_api, method, path): + response = anon_api.request(method, path, json={"recipients": [], "body": "x"}) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Bearer" + + +def test_protected_routes_reject_wrong_key(server): + with httpx.Client(base_url=server, headers={"Authorization": "Bearer wrong-key"}) as client: + assert client.get("/devices/all").status_code == 401 + + +def test_push_send_with_no_recipients_succeeds_authenticated(api): + response = api.post("/push/send", json={"recipients": [], "body": "hello"}) + + assert response.status_code == 200 + assert response.json() == {} + + +def test_old_clear_route_is_gone(api): + assert api.get("/devices/clear").status_code in (404, 405) From b2405b235d85ef12fb07d5ed71db53ef06f6a435 Mon Sep 17 00:00:00 2001 From: Josh Caponigro Date: Wed, 19 Aug 2026 23:41:13 -0500 Subject: [PATCH 5/6] Replace sandbox script with opt-in pytest e2e suite --- README.md | 5 ++- push/apn_handler/errors.py | 72 ++++++++++++++++++---------------- pytest.ini | 1 + scripts/e2e_apns_sandbox.py | 35 ----------------- tests/e2e/test_apns_sandbox.py | 60 ++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 71 deletions(-) delete mode 100644 scripts/e2e_apns_sandbox.py create mode 100644 tests/e2e/test_apns_sandbox.py diff --git a/README.md b/README.md index 05fe41a..4904005 100644 --- a/README.md +++ b/README.md @@ -144,9 +144,10 @@ INTEGRATION_DB_HOST=localhost pytest tests/integration ``` CI runs both suites, plus ruff and mypy, on every push and pull request. -To verify real APNs connectivity with your own credentials (this sends one request to Apple's sandbox): +End-to-end tests against Apple's real APNs sandbox are opt-in because they make live network calls. The connectivity test works with any credentials; verifying actual delivery additionally needs your real APNs key configured and a sandbox device token from a development build: ```bash -APNS_USE_SANDBOX=true python scripts/e2e_apns_sandbox.py +APNS_SANDBOX_E2E=1 pytest -m apns_sandbox +APNS_SANDBOX_E2E=1 APNS_E2E_DEVICE_TOKEN= pytest -m apns_sandbox ``` ## Contributing diff --git a/push/apn_handler/errors.py b/push/apn_handler/errors.py index 93131ea..50333bf 100644 --- a/push/apn_handler/errors.py +++ b/push/apn_handler/errors.py @@ -159,43 +159,47 @@ class Shutdown(APNsException): """The server is shutting down.""" +REASON_TO_EXCEPTION: dict[str, type[APNsException]] = { + "BadCollapseId": BadCollapseId, + "BadDeviceToken": BadDeviceToken, + "BadExpirationDate": BadExpirationDate, + "BadMessageId": BadMessageId, + "BadPriority": BadPriority, + "BadTopic": BadTopic, + "DeviceTokenNotForTopic": DeviceTokenNotForTopic, + "DuplicateHeaders": DuplicateHeaders, + "IdleTimeout": IdleTimeout, + "MissingDeviceToken": MissingDeviceToken, + "MissingTopic": MissingTopic, + "PayloadEmpty": PayloadEmpty, + "TopicDisallowed": TopicDisallowed, + "BadCertificate": BadCertificate, + "BadCertificateEnvironment": BadCertificateEnvironment, + "ExpiredProviderToken": ExpiredProviderToken, + "Forbidden": Forbidden, + "InvalidProviderToken": InvalidProviderToken, + "MissingProviderToken": MissingProviderToken, + "BadPath": BadPath, + "MethodNotAllowed": MethodNotAllowed, + "Unregistered": Unregistered, + "ExpiredToken": ExpiredToken, + "InvalidPushType": InvalidPushType, + "BadEnvironmentKeyIdInToken": BadEnvironmentKeyIdInToken, + "UnrelatedKeyIdInToken": UnrelatedKeyIdInToken, + "PayloadTooLarge": PayloadTooLarge, + "TooManyProviderTokenUpdates": TooManyProviderTokenUpdates, + "TooManyRequests": TooManyRequests, + "InternalServerError": InternalServerError, + "ServiceUnavailable": ServiceUnavailable, + "Shutdown": Shutdown, +} +"""Every error reason documented by Apple, mapped to its exception class.""" + + def exception_class_for_reason(reason: str) -> type[APNsException]: """Map an APNs 'reason' string to its exception class. Falls back to APNsException for reasons this module does not know about, so new reasons introduced by Apple do not break error handling. """ - return { - "BadCollapseId": BadCollapseId, - "BadDeviceToken": BadDeviceToken, - "BadExpirationDate": BadExpirationDate, - "BadMessageId": BadMessageId, - "BadPriority": BadPriority, - "BadTopic": BadTopic, - "DeviceTokenNotForTopic": DeviceTokenNotForTopic, - "DuplicateHeaders": DuplicateHeaders, - "IdleTimeout": IdleTimeout, - "MissingDeviceToken": MissingDeviceToken, - "MissingTopic": MissingTopic, - "PayloadEmpty": PayloadEmpty, - "TopicDisallowed": TopicDisallowed, - "BadCertificate": BadCertificate, - "BadCertificateEnvironment": BadCertificateEnvironment, - "ExpiredProviderToken": ExpiredProviderToken, - "Forbidden": Forbidden, - "InvalidProviderToken": InvalidProviderToken, - "MissingProviderToken": MissingProviderToken, - "BadPath": BadPath, - "MethodNotAllowed": MethodNotAllowed, - "Unregistered": Unregistered, - "ExpiredToken": ExpiredToken, - "InvalidPushType": InvalidPushType, - "BadEnvironmentKeyIdInToken": BadEnvironmentKeyIdInToken, - "UnrelatedKeyIdInToken": UnrelatedKeyIdInToken, - "PayloadTooLarge": PayloadTooLarge, - "TooManyProviderTokenUpdates": TooManyProviderTokenUpdates, - "TooManyRequests": TooManyRequests, - "InternalServerError": InternalServerError, - "ServiceUnavailable": ServiceUnavailable, - "Shutdown": Shutdown, - }.get(reason, APNsException) + return REASON_TO_EXCEPTION.get(reason, APNsException) diff --git a/pytest.ini b/pytest.ini index a0fb376..656f45a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,3 +3,4 @@ pythonpath = . testpaths = tests markers = integration: boots the real server against real Postgres (needs INTEGRATION_DB_HOST) + apns_sandbox: calls Apple's real APNs sandbox (opt in with APNS_SANDBOX_E2E=1) diff --git a/scripts/e2e_apns_sandbox.py b/scripts/e2e_apns_sandbox.py deleted file mode 100644 index 2f81149..0000000 --- a/scripts/e2e_apns_sandbox.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Manual end-to-end check against Apple's real APNs sandbox. - -Sends one notification through the real PushHandler using the APNs credentials -from the environment (or .env). Not part of the automated test suite because -it performs a real network call to Apple. - -Usage: - python scripts/e2e_apns_sandbox.py [device_token] - -With real credentials and a sandbox device token from a development build, the -device receives the notification and the result is "Success". With throwaway -credentials the expected result is "InvalidProviderToken" — Apple rejecting -the key still proves connectivity, HTTP/2, JWT signing, and response parsing. -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from push import PushHandler -from push.config import PushConfig - -if not PushConfig.get_use_sandbox(): - sys.exit("Set APNS_USE_SANDBOX=true; this script only targets the sandbox.") - -device_token = sys.argv[1] if len(sys.argv) > 1 else "deadbeef00" -results = PushHandler().send_multiple_push( - to_device_tokens=[device_token], body="PushNotificationServerFramework e2e test" -) -print(results) -result = results[device_token] -if result not in ("Success", "InvalidProviderToken", "ExpiredProviderToken", "BadDeviceToken"): - sys.exit(f"Unexpected result: {result}") -print("PASS: reached the APNs sandbox and parsed its response.") diff --git a/tests/e2e/test_apns_sandbox.py b/tests/e2e/test_apns_sandbox.py new file mode 100644 index 0000000..6522c0e --- /dev/null +++ b/tests/e2e/test_apns_sandbox.py @@ -0,0 +1,60 @@ +"""End-to-end tests against Apple's real APNs sandbox. + +These make real network calls to api.sandbox.push.apple.com, so they are +opt-in: set APNS_SANDBOX_E2E=1 to run them. They use whatever APNs credentials +the environment provides (the checked-in throwaway key by default). + +The connectivity test passes with any well-formed credentials: Apple's +response proves DNS, TLS, HTTP/2, JWT signing, and response parsing regardless +of whether the key is registered. Verifying actual delivery additionally +requires real credentials and a sandbox device token from a development build, +supplied via APNS_E2E_DEVICE_TOKEN. +""" + +import os + +import pytest + +from push import PushHandler +from push.apn_handler.errors import REASON_TO_EXCEPTION + +pytestmark = [ + pytest.mark.apns_sandbox, + pytest.mark.skipif( + not os.getenv("APNS_SANDBOX_E2E"), + reason="APNS_SANDBOX_E2E not set; these tests call Apple's real sandbox", + ), +] + + +@pytest.fixture +def sandbox_handler(monkeypatch): + """A PushHandler pinned to the sandbox, regardless of ambient environment.""" + monkeypatch.setenv("APNS_USE_SANDBOX", "true") + handler = PushHandler() + yield handler + handler.close() + + +def test_sandbox_round_trip_returns_a_reason_apple_defines(sandbox_handler): + results = sandbox_handler.send_multiple_push( + to_device_tokens=["deadbeef00"], body="e2e connectivity check" + ) + + result = results["deadbeef00"] + # Any documented reason (or Success) proves the request reached Apple and + # was parsed; transport failures and unparseable responses surface as + # "ConnectionFailed" or "HTTPError" instead and must fail here. + assert result == "Success" or result in REASON_TO_EXCEPTION + + +def test_delivery_to_real_device(sandbox_handler): + device_token = os.getenv("APNS_E2E_DEVICE_TOKEN") + if not device_token: + pytest.skip("APNS_E2E_DEVICE_TOKEN not set; needs real credentials and a device") + + results = sandbox_handler.send_multiple_push( + to_device_tokens=[device_token], body="PushNotificationServerFramework delivery test" + ) + + assert results == {device_token: "Success"} From 4f1101b14fd054b40802013ca74db48e93df2cfe Mon Sep 17 00:00:00 2001 From: Josh Caponigro Date: Wed, 19 Aug 2026 23:44:59 -0500 Subject: [PATCH 6/6] Remove APNs sandbox e2e tests --- README.md | 6 ---- pytest.ini | 1 - tests/e2e/test_apns_sandbox.py | 60 ---------------------------------- 3 files changed, 67 deletions(-) delete mode 100644 tests/e2e/test_apns_sandbox.py diff --git a/README.md b/README.md index 4904005..734b521 100644 --- a/README.md +++ b/README.md @@ -144,12 +144,6 @@ INTEGRATION_DB_HOST=localhost pytest tests/integration ``` CI runs both suites, plus ruff and mypy, on every push and pull request. -End-to-end tests against Apple's real APNs sandbox are opt-in because they make live network calls. The connectivity test works with any credentials; verifying actual delivery additionally needs your real APNs key configured and a sandbox device token from a development build: -```bash -APNS_SANDBOX_E2E=1 pytest -m apns_sandbox -APNS_SANDBOX_E2E=1 APNS_E2E_DEVICE_TOKEN= pytest -m apns_sandbox -``` - ## Contributing Contributions to this repository are welcome. Please follow the standard GitHub pull request process to propose changes. diff --git a/pytest.ini b/pytest.ini index 656f45a..a0fb376 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,3 @@ pythonpath = . testpaths = tests markers = integration: boots the real server against real Postgres (needs INTEGRATION_DB_HOST) - apns_sandbox: calls Apple's real APNs sandbox (opt in with APNS_SANDBOX_E2E=1) diff --git a/tests/e2e/test_apns_sandbox.py b/tests/e2e/test_apns_sandbox.py deleted file mode 100644 index 6522c0e..0000000 --- a/tests/e2e/test_apns_sandbox.py +++ /dev/null @@ -1,60 +0,0 @@ -"""End-to-end tests against Apple's real APNs sandbox. - -These make real network calls to api.sandbox.push.apple.com, so they are -opt-in: set APNS_SANDBOX_E2E=1 to run them. They use whatever APNs credentials -the environment provides (the checked-in throwaway key by default). - -The connectivity test passes with any well-formed credentials: Apple's -response proves DNS, TLS, HTTP/2, JWT signing, and response parsing regardless -of whether the key is registered. Verifying actual delivery additionally -requires real credentials and a sandbox device token from a development build, -supplied via APNS_E2E_DEVICE_TOKEN. -""" - -import os - -import pytest - -from push import PushHandler -from push.apn_handler.errors import REASON_TO_EXCEPTION - -pytestmark = [ - pytest.mark.apns_sandbox, - pytest.mark.skipif( - not os.getenv("APNS_SANDBOX_E2E"), - reason="APNS_SANDBOX_E2E not set; these tests call Apple's real sandbox", - ), -] - - -@pytest.fixture -def sandbox_handler(monkeypatch): - """A PushHandler pinned to the sandbox, regardless of ambient environment.""" - monkeypatch.setenv("APNS_USE_SANDBOX", "true") - handler = PushHandler() - yield handler - handler.close() - - -def test_sandbox_round_trip_returns_a_reason_apple_defines(sandbox_handler): - results = sandbox_handler.send_multiple_push( - to_device_tokens=["deadbeef00"], body="e2e connectivity check" - ) - - result = results["deadbeef00"] - # Any documented reason (or Success) proves the request reached Apple and - # was parsed; transport failures and unparseable responses surface as - # "ConnectionFailed" or "HTTPError" instead and must fail here. - assert result == "Success" or result in REASON_TO_EXCEPTION - - -def test_delivery_to_real_device(sandbox_handler): - device_token = os.getenv("APNS_E2E_DEVICE_TOKEN") - if not device_token: - pytest.skip("APNS_E2E_DEVICE_TOKEN not set; needs real credentials and a device") - - results = sandbox_handler.send_multiple_push( - to_device_tokens=[device_token], body="PushNotificationServerFramework delivery test" - ) - - assert results == {device_token: "Success"}