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..734b521 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,24 @@ 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. + ## Contributing Contributions to this repository are welcome. Please follow the standard GitHub pull request process to propose changes. 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/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/main.py b/main.py index 54d20e2..eb59851 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 @@ -12,44 +13,59 @@ from apis import devices, push from entities import EntityBase from push import shutdown_push_handler +from utils import getenv @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() -app = FastAPI(lifespan=lifespan) +def create_app() -> FastAPI: + """ + Builds the FastAPI application. -# 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 -) + 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) -# List of routers -routers: list[APIRouter] = [devices.router, push.router] + 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/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/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/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/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/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) 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_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 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) 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}")