Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions models/device.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from datetime import datetime

from pydantic import BaseModel
from pydantic import BaseModel, Field


class DeviceRegistration(BaseModel):
Expand All @@ -16,7 +16,9 @@ class DeviceRegistration(BaseModel):
localizedModel (str): The localized model of the device.
"""

token: str
# Bounded to the devices.token column (String(255)) so oversized input
# fails validation instead of raising a database error.
token: str = Field(min_length=1, max_length=255)
name: str | None = None
systemName: str | None = None
systemVersion: str | None = None
Expand Down
89 changes: 89 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Fixtures for integration tests: the real server against real Postgres.

One server (booted by server_harness.boot_server) is shared by the whole
session; tests that need a different environment or a restart boot their own
via the harness. Tests require a reachable Postgres instance configured via
the INTEGRATION_DB_* environment variables and are skipped when
INTEGRATION_DB_HOST is not set.
"""

import os
from collections.abc import Iterator
from pathlib import Path

import httpx
import pytest

from tests.integration.server_harness import (
API_KEY,
boot_server,
drop_devices_table,
integration_env,
)


def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
# The hook receives every collected item, not just this directory's.
integration_dir = Path(__file__).parent
skip = pytest.mark.skipif(
not os.getenv("INTEGRATION_DB_HOST"),
reason="INTEGRATION_DB_HOST not set; see README for running integration tests",
)
for item in items:
if integration_dir in item.path.parents:
item.add_marker(pytest.mark.integration)
item.add_marker(skip)


@pytest.fixture(scope="session")
def server_tmp_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
return tmp_path_factory.mktemp("integration-servers")


@pytest.fixture(scope="session")
def server(server_tmp_dir: Path) -> Iterator[str]:
"""One real server shared by the whole session, on a fresh database."""
env = integration_env()
drop_devices_table(env)
with boot_server(server_tmp_dir, env) as running:
yield running.base_url


@pytest.fixture
def clean_devices(server: str) -> None:
"""Start every test from an empty devices table, so tests stay
order-independent as the suite grows."""
response = httpx.delete(
f"{server}/devices", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=5
)
assert response.status_code == 200


@pytest.fixture
def api(server: str, clean_devices: None) -> Iterator[httpx.Client]:
"""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: str, clean_devices: None) -> Iterator[httpx.Client]:
"""HTTP client with no credentials."""
with httpx.Client(base_url=server) as client:
yield client


BAD_AUTHORIZATIONS: dict[str, str] = {
"wrong key": "Bearer wrong-key",
"truncated key": f"Bearer {API_KEY[:-1]}",
"wrong case key": f"Bearer {API_KEY.upper()}",
"wrong scheme": f"Basic {API_KEY}",
"scheme only": "Bearer",
"key without scheme": API_KEY,
}


@pytest.fixture(params=BAD_AUTHORIZATIONS.values(), ids=BAD_AUTHORIZATIONS.keys())
def bad_authorization(request: pytest.FixtureRequest) -> str:
"""Authorization header values that must never authenticate."""
return request.param
162 changes: 162 additions & 0 deletions tests/integration/server_harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""Machinery for booting the real server as a subprocess during integration tests.

The server is launched exactly as the README documents (`python main.py`), so
the entrypoint, startup schema creation, and graceful shutdown are all under
test. `boot_server` waits for `/health` and asserts a clean SIGINT shutdown on
exit; `boot_expecting_startup_failure` asserts a fast non-zero exit instead,
for tests that boot deliberately misconfigured servers.
"""

import contextlib
import os
import secrets
import signal
import socket
import subprocess
import sys
import time
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path

import httpx
import pytest
import sqlalchemy

REPO_ROOT = Path(__file__).parent.parent.parent
API_KEY = secrets.token_hex(16)

STARTUP_TIMEOUT = 20
SHUTDOWN_TIMEOUT = 15


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",
}
)
env.pop("CORS_ORIGINS", None)
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]


@dataclass
class Server:
"""A running server subprocess and how to reach it."""

process: subprocess.Popen[bytes]
base_url: str
log_path: Path

def logs(self) -> str:
return self.log_path.read_text()

def stop(self) -> int:
"""Request graceful shutdown (SIGINT) and return the exit code."""
if self.process.poll() is None:
self.process.send_signal(signal.SIGINT)
try:
self.process.wait(timeout=SHUTDOWN_TIMEOUT)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
pytest.fail(f"server did not shut down within {SHUTDOWN_TIMEOUT}s:\n{self.logs()}")
return self.process.returncode


@contextlib.contextmanager
def boot_server(tmp_dir: Path, env: dict[str, str] | None = None) -> Iterator[Server]:
"""Boot `python main.py` and wait until `/health` answers.

Yields a Server; on exit the server is shut down with SIGINT and a clean
exit code is asserted, so graceful shutdown is verified on every boot.
"""
env = env or integration_env()
port = _free_port()
env["PORT"] = str(port)

# Server output goes to a file, not a pipe: an undrained pipe blocks the
# server once its buffer fills, which would hang the suite as it grows.
log_path = tmp_dir / f"server-{port}.log"
with open(log_path, "w") as log_file:
process = subprocess.Popen(
[sys.executable, "main.py"],
cwd=REPO_ROOT,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
)
server = Server(process, f"http://127.0.0.1:{port}", log_path)
try:
deadline = time.monotonic() + STARTUP_TIMEOUT
while time.monotonic() < deadline:
if process.poll() is not None:
pytest.fail(f"server exited during startup:\n{server.logs()}")
try:
if httpx.get(f"{server.base_url}/health", timeout=1).status_code == 200:
break
except httpx.TransportError:
pass
time.sleep(0.2)
else:
pytest.fail(f"server did not become healthy within {STARTUP_TIMEOUT}s")

yield server
finally:
returncode = server.stop()
assert returncode == 0, f"unclean shutdown (exit {returncode}):\n{server.logs()}"


def boot_expecting_startup_failure(tmp_dir: Path, env: dict[str, str]) -> str:
"""Boot the server with a broken environment and return its log output.

Fails the test if the process serves traffic or survives past the startup
window instead of exiting with a non-zero code.
"""
port = _free_port()
env["PORT"] = str(port)
log_path = tmp_dir / f"server-fail-{port}.log"
with open(log_path, "w") as log_file:
process = subprocess.Popen(
[sys.executable, "main.py"],
cwd=REPO_ROOT,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
)
try:
process.wait(timeout=STARTUP_TIMEOUT)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
pytest.fail(f"server kept running despite broken config:\n{log_path.read_text()}")
assert process.returncode != 0, "expected startup to fail with a non-zero exit code"
return log_path.read_text()
92 changes: 92 additions & 0 deletions tests/integration/test_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Integration tests for server lifecycle: restarts, startup failures, CORS.

Each test here boots its own server subprocess (or several) rather than using
the session-wide one, because they exercise boot-time behavior: schema
creation against an existing database, refusing to start on broken
configuration, and CORS middleware wiring from the environment.
"""

from pathlib import Path

import httpx

from tests.integration.server_harness import (
API_KEY,
boot_expecting_startup_failure,
boot_server,
integration_env,
)


def test_data_survives_a_server_restart(server_tmp_dir: Path) -> None:
# Cleared through the API rather than dropped: the session-wide server
# shares this database and must keep its table.
env = integration_env()

with boot_server(server_tmp_dir, dict(env)) as first:
auth = {"Authorization": f"Bearer {API_KEY}"}
assert httpx.delete(f"{first.base_url}/devices", headers=auth).status_code == 200
response = httpx.post(
f"{first.base_url}/devices/register",
json={"token": "durable-token", "name": "Survivor"},
)
assert response.status_code == 200

with boot_server(server_tmp_dir, dict(env)) as second:
devices = httpx.get(
f"{second.base_url}/devices/all",
headers={"Authorization": f"Bearer {API_KEY}"},
).json()
assert [(device["token"], device["name"]) for device in devices] == [
("durable-token", "Survivor")
]


def test_startup_fails_fast_without_api_key(server_tmp_dir: Path) -> None:
env = integration_env()
del env["API_KEY"]

logs = boot_expecting_startup_failure(server_tmp_dir, env)

assert "API_KEY environment variable must be set" in logs


def test_startup_rejects_wildcard_cors_origins(server_tmp_dir: Path) -> None:
env = integration_env()
env["CORS_ORIGINS"] = "https://app.example.com, *"

logs = boot_expecting_startup_failure(server_tmp_dir, env)

assert "CORS_ORIGINS must list explicit origins" in logs


def test_cors_configured_server_only_allows_listed_origins(server_tmp_dir: Path) -> None:
env = integration_env()
env["CORS_ORIGINS"] = "https://app.example.com"

with boot_server(server_tmp_dir, env) as running:
preflight = httpx.options(
f"{running.base_url}/devices/all",
headers={
"Origin": "https://app.example.com",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization",
},
)
assert preflight.status_code == 200
assert preflight.headers["access-control-allow-origin"] == "https://app.example.com"
assert preflight.headers["access-control-allow-credentials"] == "true"

cross_origin_get = httpx.get(
f"{running.base_url}/health", headers={"Origin": "https://app.example.com"}
)
assert cross_origin_get.headers["access-control-allow-origin"] == "https://app.example.com"

rejected_preflight = httpx.options(
f"{running.base_url}/devices/all",
headers={
"Origin": "https://evil.example.com",
"Access-Control-Request-Method": "GET",
},
)
assert "access-control-allow-origin" not in rejected_preflight.headers
Loading
Loading