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]>
162 lines
6.4 KiB
Python
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
|