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]>
This commit is contained in:
commit
0526d34e42
52 files changed
+4433
No files matched your search
@@ -0,0 +1,161 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user