Files
v2x-server/tests/test_devices_api.py
T
gnickensandClaude Opus 5 0526d34e42 Scaffold FastAPI + MySQL + Keycloak service with devcontainer
Sets up the project skeleton:

- FastAPI app factory with lifespan, request-id middleware, and RFC 9457
  problem+json error handlers
- Async SQLAlchemy 2.0 over MySQL (asyncmy), with a constraint naming
  convention in place before the first migration and async Alembic
- Keycloak as a pure resource server: OIDC discovery, cached JWKS with
  rotation-aware refresh, and require_roles dependencies
- Devcontainer running MySQL 8.4 and Keycloak 26.7 as compose siblings,
  with the realm (clients, roles, test users) imported on first boot
- Test suite covering the endpoints plus the token validator itself,
  exercised against a locally generated RSA keypair
- uv packaging, ruff, mypy --strict, pre-commit, Gitea CI, prod Dockerfile

Two Keycloak-in-containers traps are handled explicitly and documented in
the README: the issuer/internal-URL split (the browser sees localhost:8080,
the API sees keycloak:8080) and the audience mapper that stops Keycloak
issuing tokens with aud=account.

The devices resource is a placeholder proving the routing -> auth -> ORM ->
migration path end to end; replace it with the real domain.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 12:46:52 -04:00

162 lines
6.4 KiB
Python

"""Endpoint behaviour: authorization gates, CRUD, and error shapes."""
from __future__ import annotations
from collections.abc import Callable
import pytest
from httpx import AsyncClient
from v2x_server.auth.principal import Principal
PREFIX = "/api/v1/devices"
AsUser = Callable[..., Principal]
def _payload(**overrides: object) -> dict[str, object]:
body: dict[str, object] = {
"name": "roadside-unit-1",
"serial": "RSU-0001",
"status": "active",
}
body.update(overrides)
return body
class TestAuthorization:
async def test_anonymous_request_is_401(self, client: AsyncClient) -> None:
response = await client.get(PREFIX)
assert response.status_code == 401
assert response.headers["www-authenticate"].startswith("Bearer")
async def test_authenticated_without_role_is_403(
self, client: AsyncClient, as_user: AsUser
) -> None:
# Authenticated, but holds no role this API recognises. 403 rather than
# 401: retrying with the same token will never help.
as_user()
response = await client.get(PREFIX)
assert response.status_code == 403
async def test_viewer_can_read(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("viewer")
response = await client.get(PREFIX)
assert response.status_code == 200
async def test_viewer_cannot_write(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("viewer")
response = await client.post(PREFIX, json=_payload())
assert response.status_code == 403
async def test_operator_can_write(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("operator")
response = await client.post(PREFIX, json=_payload())
assert response.status_code == 201
async def test_operator_cannot_delete(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("operator")
created = await client.post(PREFIX, json=_payload())
response = await client.delete(f"{PREFIX}/{created.json()['id']}")
assert response.status_code == 403
async def test_admin_can_delete(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("admin")
created = await client.post(PREFIX, json=_payload())
response = await client.delete(f"{PREFIX}/{created.json()['id']}")
assert response.status_code == 204
class TestCrud:
async def test_create_then_read_roundtrip(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("operator")
created = await client.post(PREFIX, json=_payload(description="corner of 5th"))
assert created.status_code == 201
body = created.json()
assert body["serial"] == "RSU-0001"
assert body["id"] > 0
assert body["created_at"]
fetched = await client.get(f"{PREFIX}/{body['id']}")
assert fetched.status_code == 200
assert fetched.json() == body
async def test_duplicate_serial_is_409(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("operator")
await client.post(PREFIX, json=_payload())
duplicate = await client.post(PREFIX, json=_payload(name="different name"))
assert duplicate.status_code == 409
assert duplicate.headers["content-type"].startswith("application/problem+json")
async def test_patch_only_touches_supplied_fields(
self, client: AsyncClient, as_user: AsUser
) -> None:
as_user("operator")
created = (await client.post(PREFIX, json=_payload(description="original"))).json()
patched = await client.patch(f"{PREFIX}/{created['id']}", json={"status": "maintenance"})
assert patched.status_code == 200
assert patched.json()["status"] == "maintenance"
# Untouched fields survive the PATCH.
assert patched.json()["description"] == "original"
assert patched.json()["name"] == created["name"]
async def test_missing_device_is_404(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("viewer")
response = await client.get(f"{PREFIX}/999999")
assert response.status_code == 404
assert response.json()["title"] == "Not Found"
async def test_invalid_body_is_422_with_details(
self, client: AsyncClient, as_user: AsUser
) -> None:
as_user("operator")
response = await client.post(PREFIX, json={"name": "", "serial": ""})
assert response.status_code == 422
assert response.json()["errors"]
async def test_pagination_and_filtering(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("admin")
for index in range(5):
status = "active" if index % 2 == 0 else "inactive"
await client.post(
PREFIX, json=_payload(name=f"rsu-{index}", serial=f"S-{index}", status=status)
)
page = (await client.get(PREFIX, params={"limit": 2, "offset": 0})).json()
assert page["total"] == 5
assert len(page["items"]) == 2
filtered = (await client.get(PREFIX, params={"status": "inactive"})).json()
assert filtered["total"] == 2
assert {item["status"] for item in filtered["items"]} == {"inactive"}
class TestIdentity:
async def test_me_reports_roles(self, client: AsyncClient, as_user: AsUser) -> None:
as_user("viewer", "operator", username="vera")
response = await client.get("/api/v1/me")
assert response.status_code == 200
assert response.json()["username"] == "vera"
assert sorted(response.json()["realm_roles"]) == ["operator", "viewer"]
class TestHealth:
async def test_healthz_needs_no_auth_and_no_dependencies(self, client: AsyncClient) -> None:
response = await client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
async def test_every_response_carries_a_request_id(self, client: AsyncClient) -> None:
response = await client.get("/healthz")
assert response.headers["x-request-id"]
async def test_supplied_request_id_is_echoed(self, client: AsyncClient) -> None:
response = await client.get("/healthz", headers={"X-Request-ID": "abc123"})
assert response.headers["x-request-id"] == "abc123"
@pytest.mark.parametrize("path", ["/openapi.json", "/docs"])
async def test_docs_are_reachable(client: AsyncClient, path: str) -> None:
assert (await client.get(path)).status_code == 200