"""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