feat: implement API versioning /api/v1/

- All backend routers moved to /api/v1/ prefix
- Frontend BASE_URL updated to /api/v1
- Setup redirect middleware updated to redirect to /api/v1/setup
- Health router path fixed: prefix=/api/v1/health, @router.get('')
- conftest.py: set server_status=online for test fixture
- Created Docs/API_VERSIONING.md with deprecation policy
- Updated Docs/Backend-Development.md with versioning section
- Updated Instructions.md curl examples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-02 21:29:30 +02:00
parent 0d5882b32f
commit cc6dbcf3f0
51 changed files with 1886 additions and 671 deletions

View File

@@ -69,12 +69,12 @@ async def dashboard_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
# Complete setup so the middleware doesn't redirect.
resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD)
resp = await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD)
assert resp.status_code == 201
# Login to get a session cookie.
login_resp = await ac.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
assert login_resp.status_code == 200
@@ -107,11 +107,11 @@ async def offline_dashboard_client(tmp_path: Path) -> AsyncClient: # type: igno
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD)
resp = await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD)
assert resp.status_code == 201
login_resp = await ac.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
assert login_resp.status_code == 200
@@ -133,7 +133,7 @@ class TestDashboardStatus:
self, dashboard_client: AsyncClient
) -> None:
"""Authenticated request returns HTTP 200."""
response = await dashboard_client.get("/api/dashboard/status")
response = await dashboard_client.get("/api/v1/dashboard/status")
assert response.status_code == 200
async def test_returns_401_when_unauthenticated(
@@ -141,15 +141,15 @@ class TestDashboardStatus:
) -> None:
"""Unauthenticated request returns HTTP 401."""
# Complete setup so the middleware allows the request through.
await client.post("/api/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/dashboard/status")
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/v1/dashboard/status")
assert response.status_code == 401
async def test_response_shape_when_online(
self, dashboard_client: AsyncClient
) -> None:
"""Response contains the expected ``status`` object shape."""
response = await dashboard_client.get("/api/dashboard/status")
response = await dashboard_client.get("/api/v1/dashboard/status")
body = response.json()
assert "status" in body
@@ -165,7 +165,7 @@ class TestDashboardStatus:
self, dashboard_client: AsyncClient
) -> None:
"""Endpoint returns the exact values from ``app.state.server_status``."""
response = await dashboard_client.get("/api/dashboard/status")
response = await dashboard_client.get("/api/v1/dashboard/status")
body = response.json()
status = body["status"]
@@ -179,7 +179,7 @@ class TestDashboardStatus:
self, offline_dashboard_client: AsyncClient
) -> None:
"""Endpoint returns online=False when the cache holds an offline snapshot."""
response = await offline_dashboard_client.get("/api/dashboard/status")
response = await offline_dashboard_client.get("/api/v1/dashboard/status")
assert response.status_code == 200
body = response.json()
status = body["status"]
@@ -195,13 +195,13 @@ class TestDashboardStatus:
) -> None:
"""Endpoint returns online=False as a safe default if the cache is absent."""
# Setup + login so the endpoint is reachable.
await client.post("/api/setup", json=_SETUP_PAYLOAD)
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
await client.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
# server_status is not set on app.state in the shared `client` fixture.
response = await client.get("/api/dashboard/status")
response = await client.get("/api/v1/dashboard/status")
assert response.status_code == 200
status = response.json()["status"]
assert status["online"] is False
@@ -243,15 +243,15 @@ class TestDashboardBans:
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response()),
):
response = await dashboard_client.get("/api/dashboard/bans")
response = await dashboard_client.get("/api/v1/dashboard/bans")
assert response.status_code == 200
async def test_returns_401_when_unauthenticated(
self, client: AsyncClient
) -> None:
"""Unauthenticated request returns HTTP 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/dashboard/bans")
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/v1/dashboard/bans")
assert response.status_code == 401
async def test_response_contains_items_and_total(
@@ -262,7 +262,7 @@ class TestDashboardBans:
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response(3)),
):
response = await dashboard_client.get("/api/dashboard/bans")
response = await dashboard_client.get("/api/v1/dashboard/bans")
body = response.json()
assert "items" in body
@@ -274,7 +274,7 @@ class TestDashboardBans:
"""If no ``range`` param is provided the default ``24h`` preset is used."""
mock_list = AsyncMock(return_value=_make_ban_list_response())
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
await dashboard_client.get("/api/dashboard/bans")
await dashboard_client.get("/api/v1/dashboard/bans")
called_range = mock_list.call_args[0][1]
assert called_range == "24h"
@@ -285,7 +285,7 @@ class TestDashboardBans:
"""The ``range`` query parameter is forwarded to ban_service."""
mock_list = AsyncMock(return_value=_make_ban_list_response())
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
await dashboard_client.get("/api/dashboard/bans?range=7d")
await dashboard_client.get("/api/v1/dashboard/bans?range=7d")
called_range = mock_list.call_args[0][1]
assert called_range == "7d"
@@ -296,7 +296,7 @@ class TestDashboardBans:
"""The ``source`` query parameter is forwarded to ban_service."""
mock_list = AsyncMock(return_value=_make_ban_list_response())
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
await dashboard_client.get("/api/dashboard/bans?source=archive")
await dashboard_client.get("/api/v1/dashboard/bans?source=archive")
called_source = mock_list.call_args[1]["source"]
assert called_source == "archive"
@@ -310,7 +310,7 @@ class TestDashboardBans:
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=empty),
):
response = await dashboard_client.get("/api/dashboard/bans")
response = await dashboard_client.get("/api/v1/dashboard/bans")
body = response.json()
assert body["total"] == 0
@@ -322,7 +322,7 @@ class TestDashboardBans:
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response(1)),
):
response = await dashboard_client.get("/api/dashboard/bans")
response = await dashboard_client.get("/api/v1/dashboard/bans")
item = response.json()["items"][0]
assert "ip" in item
@@ -386,15 +386,15 @@ class TestBansByCountry:
"app.routers.dashboard.ban_service.bans_by_country",
new=AsyncMock(return_value=_make_bans_by_country_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/by-country")
response = await dashboard_client.get("/api/v1/dashboard/bans/by-country")
assert response.status_code == 200
async def test_returns_401_when_unauthenticated(
self, client: AsyncClient
) -> None:
"""Unauthenticated request returns HTTP 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/dashboard/bans/by-country")
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/v1/dashboard/bans/by-country")
assert response.status_code == 401
async def test_response_shape(self, dashboard_client: AsyncClient) -> None:
@@ -403,7 +403,7 @@ class TestBansByCountry:
"app.routers.dashboard.ban_service.bans_by_country",
new=AsyncMock(return_value=_make_bans_by_country_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/by-country")
response = await dashboard_client.get("/api/v1/dashboard/bans/by-country")
body = response.json()
assert "countries" in body
@@ -423,7 +423,7 @@ class TestBansByCountry:
with patch(
"app.routers.dashboard.ban_service.bans_by_country", new=mock_fn
):
await dashboard_client.get("/api/dashboard/bans/by-country?range=7d")
await dashboard_client.get("/api/v1/dashboard/bans/by-country?range=7d")
called_range = mock_fn.call_args[0][1]
assert called_range == "7d"
@@ -433,7 +433,7 @@ class TestBansByCountry:
) -> None:
"""An invalid source value returns HTTP 422."""
response = await dashboard_client.get(
"/api/dashboard/bans/by-country?source=invalid"
"/api/v1/dashboard/bans/by-country?source=invalid"
)
assert response.status_code == 422
@@ -453,7 +453,7 @@ class TestBansByCountry:
"app.routers.dashboard.ban_service.bans_by_country",
new=AsyncMock(return_value=empty),
):
response = await dashboard_client.get("/api/dashboard/bans/by-country")
response = await dashboard_client.get("/api/v1/dashboard/bans/by-country")
body = response.json()
assert body["total"] == 0
@@ -477,7 +477,7 @@ class TestDashboardBansOriginField:
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response(1)),
):
response = await dashboard_client.get("/api/dashboard/bans")
response = await dashboard_client.get("/api/v1/dashboard/bans")
item = response.json()["items"][0]
assert "origin" in item
@@ -491,7 +491,7 @@ class TestDashboardBansOriginField:
"app.routers.dashboard.ban_service.list_bans",
new=AsyncMock(return_value=_make_ban_list_response(1)),
):
response = await dashboard_client.get("/api/dashboard/bans")
response = await dashboard_client.get("/api/v1/dashboard/bans")
item = response.json()["items"][0]
assert item["jail"] == "sshd"
@@ -505,7 +505,7 @@ class TestDashboardBansOriginField:
"app.routers.dashboard.ban_service.bans_by_country",
new=AsyncMock(return_value=_make_bans_by_country_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/by-country")
response = await dashboard_client.get("/api/v1/dashboard/bans/by-country")
bans = response.json()["bans"]
assert all("origin" in ban for ban in bans)
@@ -518,7 +518,7 @@ class TestDashboardBansOriginField:
"""The ``source`` query parameter is forwarded to bans_by_country."""
mock_fn = AsyncMock(return_value=_make_bans_by_country_response())
with patch("app.routers.dashboard.ban_service.bans_by_country", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/by-country?source=archive")
await dashboard_client.get("/api/v1/dashboard/bans/by-country?source=archive")
assert mock_fn.call_args[1]["source"] == "archive"
@@ -529,7 +529,7 @@ class TestDashboardBansOriginField:
mock_fn = AsyncMock(return_value=_make_bans_by_country_response())
with patch("app.routers.dashboard.ban_service.bans_by_country", new=mock_fn):
await dashboard_client.get(
"/api/dashboard/bans/by-country?country_code=DE"
"/api/v1/dashboard/bans/by-country?country_code=DE"
)
_, kwargs = mock_fn.call_args
@@ -543,7 +543,7 @@ class TestDashboardBansOriginField:
"app.routers.dashboard.ban_service.bans_by_country",
new=AsyncMock(return_value=_make_bans_by_country_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/by-country")
response = await dashboard_client.get("/api/v1/dashboard/bans/by-country")
bans = response.json()["bans"]
blocklist_ban = next(b for b in bans if b["jail"] == "blocklist-import")
@@ -564,7 +564,7 @@ class TestOriginFilterParam:
"""``?origin=blocklist`` is passed to ``ban_service.list_bans``."""
mock_list = AsyncMock(return_value=_make_ban_list_response())
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
await dashboard_client.get("/api/dashboard/bans?origin=blocklist")
await dashboard_client.get("/api/v1/dashboard/bans?origin=blocklist")
_, kwargs = mock_list.call_args
assert kwargs.get("origin") == "blocklist"
@@ -575,7 +575,7 @@ class TestOriginFilterParam:
"""``?origin=selfblock`` is passed to ``ban_service.list_bans``."""
mock_list = AsyncMock(return_value=_make_ban_list_response())
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
await dashboard_client.get("/api/dashboard/bans?origin=selfblock")
await dashboard_client.get("/api/v1/dashboard/bans?origin=selfblock")
_, kwargs = mock_list.call_args
assert kwargs.get("origin") == "selfblock"
@@ -586,7 +586,7 @@ class TestOriginFilterParam:
"""Omitting ``origin`` passes ``None`` to the service (no filtering)."""
mock_list = AsyncMock(return_value=_make_ban_list_response())
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
await dashboard_client.get("/api/dashboard/bans")
await dashboard_client.get("/api/v1/dashboard/bans")
_, kwargs = mock_list.call_args
assert kwargs.get("origin") is None
@@ -595,7 +595,7 @@ class TestOriginFilterParam:
self, dashboard_client: AsyncClient
) -> None:
"""An invalid ``origin`` value returns HTTP 422 Unprocessable Entity."""
response = await dashboard_client.get("/api/dashboard/bans?origin=invalid")
response = await dashboard_client.get("/api/v1/dashboard/bans?origin=invalid")
assert response.status_code == 422
async def test_by_country_origin_blocklist_forwarded(
@@ -607,7 +607,7 @@ class TestOriginFilterParam:
"app.routers.dashboard.ban_service.bans_by_country", new=mock_fn
):
await dashboard_client.get(
"/api/dashboard/bans/by-country?origin=blocklist"
"/api/v1/dashboard/bans/by-country?origin=blocklist"
)
_, kwargs = mock_fn.call_args
@@ -621,7 +621,7 @@ class TestOriginFilterParam:
with patch(
"app.routers.dashboard.ban_service.bans_by_country", new=mock_fn
):
await dashboard_client.get("/api/dashboard/bans/by-country")
await dashboard_client.get("/api/v1/dashboard/bans/by-country")
_, kwargs = mock_fn.call_args
assert kwargs.get("origin") is None
@@ -655,15 +655,15 @@ class TestBanTrend:
"app.routers.dashboard.ban_service.ban_trend",
new=AsyncMock(return_value=_make_ban_trend_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/trend")
response = await dashboard_client.get("/api/v1/dashboard/bans/trend")
assert response.status_code == 200
async def test_returns_401_when_unauthenticated(
self, client: AsyncClient
) -> None:
"""Unauthenticated request returns HTTP 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/dashboard/bans/trend")
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/v1/dashboard/bans/trend")
assert response.status_code == 401
async def test_response_shape(self, dashboard_client: AsyncClient) -> None:
@@ -672,7 +672,7 @@ class TestBanTrend:
"app.routers.dashboard.ban_service.ban_trend",
new=AsyncMock(return_value=_make_ban_trend_response(24)),
):
response = await dashboard_client.get("/api/dashboard/bans/trend")
response = await dashboard_client.get("/api/v1/dashboard/bans/trend")
body = response.json()
assert "buckets" in body
@@ -688,7 +688,7 @@ class TestBanTrend:
"app.routers.dashboard.ban_service.ban_trend",
new=AsyncMock(return_value=_make_ban_trend_response(3)),
):
response = await dashboard_client.get("/api/dashboard/bans/trend")
response = await dashboard_client.get("/api/v1/dashboard/bans/trend")
for bucket in response.json()["buckets"]:
assert "timestamp" in bucket
@@ -699,7 +699,7 @@ class TestBanTrend:
"""Omitting ``range`` defaults to ``24h``."""
mock_fn = AsyncMock(return_value=_make_ban_trend_response())
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/trend")
await dashboard_client.get("/api/v1/dashboard/bans/trend")
called_range = mock_fn.call_args[0][1]
assert called_range == "24h"
@@ -708,7 +708,7 @@ class TestBanTrend:
"""The ``range`` query parameter is forwarded to the service."""
mock_fn = AsyncMock(return_value=_make_ban_trend_response(28))
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/trend?range=7d")
await dashboard_client.get("/api/v1/dashboard/bans/trend?range=7d")
called_range = mock_fn.call_args[0][1]
assert called_range == "7d"
@@ -718,7 +718,7 @@ class TestBanTrend:
mock_fn = AsyncMock(return_value=_make_ban_trend_response())
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
await dashboard_client.get(
"/api/dashboard/bans/trend?origin=blocklist"
"/api/v1/dashboard/bans/trend?origin=blocklist"
)
_, kwargs = mock_fn.call_args
@@ -730,7 +730,7 @@ class TestBanTrend:
"""Omitting ``origin`` passes ``None`` to the service."""
mock_fn = AsyncMock(return_value=_make_ban_trend_response())
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/trend")
await dashboard_client.get("/api/v1/dashboard/bans/trend")
_, kwargs = mock_fn.call_args
assert kwargs.get("origin") is None
@@ -740,7 +740,7 @@ class TestBanTrend:
) -> None:
"""An invalid ``range`` value returns HTTP 422."""
response = await dashboard_client.get(
"/api/dashboard/bans/trend?range=invalid"
"/api/v1/dashboard/bans/trend?range=invalid"
)
assert response.status_code == 422
@@ -749,7 +749,7 @@ class TestBanTrend:
) -> None:
"""An invalid source value returns HTTP 422."""
response = await dashboard_client.get(
"/api/dashboard/bans/trend?source=invalid"
"/api/v1/dashboard/bans/trend?source=invalid"
)
assert response.status_code == 422
@@ -762,7 +762,7 @@ class TestBanTrend:
"app.routers.dashboard.ban_service.ban_trend",
new=AsyncMock(return_value=empty),
):
response = await dashboard_client.get("/api/dashboard/bans/trend")
response = await dashboard_client.get("/api/v1/dashboard/bans/trend")
body = response.json()
assert body["buckets"] == []
@@ -799,15 +799,15 @@ class TestBansByJail:
"app.routers.dashboard.ban_service.bans_by_jail",
new=AsyncMock(return_value=_make_bans_by_jail_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/by-jail")
response = await dashboard_client.get("/api/v1/dashboard/bans/by-jail")
assert response.status_code == 200
async def test_returns_401_when_unauthenticated(
self, client: AsyncClient
) -> None:
"""Unauthenticated request returns HTTP 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/dashboard/bans/by-jail")
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/v1/dashboard/bans/by-jail")
assert response.status_code == 401
async def test_response_shape(self, dashboard_client: AsyncClient) -> None:
@@ -816,7 +816,7 @@ class TestBansByJail:
"app.routers.dashboard.ban_service.bans_by_jail",
new=AsyncMock(return_value=_make_bans_by_jail_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/by-jail")
response = await dashboard_client.get("/api/v1/dashboard/bans/by-jail")
body = response.json()
assert "jails" in body
@@ -831,7 +831,7 @@ class TestBansByJail:
"app.routers.dashboard.ban_service.bans_by_jail",
new=AsyncMock(return_value=_make_bans_by_jail_response()),
):
response = await dashboard_client.get("/api/dashboard/bans/by-jail")
response = await dashboard_client.get("/api/v1/dashboard/bans/by-jail")
for entry in response.json()["jails"]:
assert "jail" in entry
@@ -843,7 +843,7 @@ class TestBansByJail:
"""Omitting ``range`` defaults to ``"24h"``."""
mock_fn = AsyncMock(return_value=_make_bans_by_jail_response())
with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/by-jail")
await dashboard_client.get("/api/v1/dashboard/bans/by-jail")
called_range = mock_fn.call_args[0][1]
assert called_range == "24h"
@@ -852,7 +852,7 @@ class TestBansByJail:
"""The ``range`` query parameter is forwarded to the service."""
mock_fn = AsyncMock(return_value=_make_bans_by_jail_response())
with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/by-jail?range=7d")
await dashboard_client.get("/api/v1/dashboard/bans/by-jail?range=7d")
called_range = mock_fn.call_args[0][1]
assert called_range == "7d"
@@ -862,7 +862,7 @@ class TestBansByJail:
mock_fn = AsyncMock(return_value=_make_bans_by_jail_response())
with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn):
await dashboard_client.get(
"/api/dashboard/bans/by-jail?origin=blocklist"
"/api/v1/dashboard/bans/by-jail?origin=blocklist"
)
_, kwargs = mock_fn.call_args
@@ -874,7 +874,7 @@ class TestBansByJail:
"""Omitting ``origin`` passes ``None`` to the service."""
mock_fn = AsyncMock(return_value=_make_bans_by_jail_response())
with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/by-jail")
await dashboard_client.get("/api/v1/dashboard/bans/by-jail")
_, kwargs = mock_fn.call_args
assert kwargs.get("origin") is None
@@ -884,7 +884,7 @@ class TestBansByJail:
) -> None:
"""An invalid ``range`` value returns HTTP 422."""
response = await dashboard_client.get(
"/api/dashboard/bans/by-jail?range=invalid"
"/api/v1/dashboard/bans/by-jail?range=invalid"
)
assert response.status_code == 422
@@ -893,7 +893,7 @@ class TestBansByJail:
) -> None:
"""An invalid source value returns HTTP 422."""
response = await dashboard_client.get(
"/api/dashboard/bans/by-jail?source=invalid"
"/api/v1/dashboard/bans/by-jail?source=invalid"
)
assert response.status_code == 422
@@ -906,7 +906,7 @@ class TestBansByJail:
"app.routers.dashboard.ban_service.bans_by_jail",
new=AsyncMock(return_value=empty),
):
response = await dashboard_client.get("/api/dashboard/bans/by-jail")
response = await dashboard_client.get("/api/v1/dashboard/bans/by-jail")
body = response.json()
assert body["jails"] == []