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

@@ -26,7 +26,7 @@ _SETUP_PAYLOAD = {
async def _do_setup(client: AsyncClient) -> None:
"""Run the setup wizard so auth endpoints are reachable."""
resp = await client.post("/api/setup", json=_SETUP_PAYLOAD)
resp = await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
assert resp.status_code == 201
@@ -36,7 +36,7 @@ async def _login(client: AsyncClient, password: str = "Mysecretpass1!") -> str:
Note: The token is returned in the HttpOnly cookie, not in the JSON body.
For testing Bearer token auth, we extract it from the cookie.
"""
resp = await client.post("/api/auth/login", json={"password": password})
resp = await client.post("/api/v1/auth/login", json={"password": password})
assert resp.status_code == 200
token = resp.cookies.get(SESSION_COOKIE_NAME)
assert token is not None
@@ -57,7 +57,7 @@ class TestLogin:
"""Login returns 200 and sets a session cookie for the correct password."""
await _do_setup(client)
response = await client.post(
"/api/auth/login", json={"password": "Mysecretpass1!"}
"/api/v1/auth/login", json={"password": "Mysecretpass1!"}
)
assert response.status_code == 200
body = response.json()
@@ -69,7 +69,7 @@ class TestLogin:
"""Login sets the bangui_session HttpOnly cookie."""
await _do_setup(client)
response = await client.post(
"/api/auth/login", json={"password": "Mysecretpass1!"}
"/api/v1/auth/login", json={"password": "Mysecretpass1!"}
)
assert response.status_code == 200
assert SESSION_COOKIE_NAME in response.cookies
@@ -85,7 +85,7 @@ class TestLogin:
client._transport.app.state.settings.session_cookie_secure = True
await _do_setup(client)
response = await client.post(
"/api/auth/login", json={"password": "Mysecretpass1!"}
"/api/v1/auth/login", json={"password": "Mysecretpass1!"}
)
assert response.status_code == 200
set_cookie = response.headers.get("set-cookie", "")
@@ -97,14 +97,14 @@ class TestLogin:
"""Login returns 401 for an incorrect password."""
await _do_setup(client)
response = await client.post(
"/api/auth/login", json={"password": "wrongpassword"}
"/api/v1/auth/login", json={"password": "wrongpassword"}
)
assert response.status_code == 401
async def test_login_rejects_empty_password(self, client: AsyncClient) -> None:
"""Login returns 422 when password field is missing."""
await _do_setup(client)
response = await client.post("/api/auth/login", json={})
response = await client.post("/api/v1/auth/login", json={})
assert response.status_code == 422
async def test_login_rate_limit_returns_429_after_5_attempts(
@@ -117,13 +117,13 @@ class TestLogin:
# First failed attempt is allowed
response = await client.post(
"/api/auth/login", json={"password": "wrongpassword"}
"/api/v1/auth/login", json={"password": "wrongpassword"}
)
assert response.status_code == 401
# Second attempt immediately after is blocked by 1s penalty
response = await client.post(
"/api/auth/login", json={"password": "wrongpassword"}
"/api/v1/auth/login", json={"password": "wrongpassword"}
)
assert response.status_code == 429
assert response.json()["detail"] == "Too many login attempts. Please try again later."
@@ -142,11 +142,11 @@ class TestLogin:
limiter.reset()
# First attempt fails
response = await client.post("/api/auth/login", json={"password": "wrong"})
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
assert response.status_code == 401
# Second immediate attempt is rate-limited
response = await client.post("/api/auth/login", json={"password": "wrong"})
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
assert response.status_code == 429
assert "retry-after" in response.headers
assert response.headers["retry-after"] == "60"
@@ -160,12 +160,12 @@ class TestLogin:
limiter.reset()
# Make 1 failed attempt with default IP
response = await client.post("/api/auth/login", json={"password": "wrong"})
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
assert response.status_code == 401
# 2nd attempt is blocked
response = await client.post(
"/api/auth/login", json={"password": "correct"}
"/api/v1/auth/login", json={"password": "correct"}
)
assert response.status_code == 429
@@ -183,12 +183,12 @@ class TestLogin:
limiter.reset()
# Make 1 failed attempt (enough to trigger exponential backoff)
response = await client.post("/api/auth/login", json={"password": "wrong"})
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
assert response.status_code == 401
# 2nd attempt is blocked
response = await client.post(
"/api/auth/login", json={"password": "wrong"}
"/api/v1/auth/login", json={"password": "wrong"}
)
assert response.status_code == 429
@@ -197,7 +197,7 @@ class TestLogin:
# Now a fresh login attempt should succeed (use correct password)
response = await client.post(
"/api/auth/login", json={"password": "Mysecretpass1!"}
"/api/v1/auth/login", json={"password": "Mysecretpass1!"}
)
assert response.status_code == 200
@@ -208,25 +208,25 @@ class TestLogin:
limiter.reset()
# 1st failure: 1 * 2^1 = 2s penalty
response = await client.post("/api/auth/login", json={"password": "wrong"})
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
assert response.status_code == 401
state = limiter.get_state()
assert state["127.0.0.1"] == 1
# 2nd attempt blocked immediately by 2s penalty
response = await client.post("/api/auth/login", json={"password": "wrong"})
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
assert response.status_code == 429
# After 2.1s, the penalty expires and we can try again
# (this will record a 2nd failure, creating a 1 * 2^2 = 4s penalty)
await asyncio.sleep(2.1)
response = await client.post("/api/auth/login", json={"password": "wrong"})
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
assert response.status_code == 401
state = limiter.get_state()
assert state["127.0.0.1"] == 2
# Now blocked by 4s penalty
response = await client.post("/api/auth/login", json={"password": "wrong"})
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
assert response.status_code == 429
@@ -242,7 +242,7 @@ class TestLogout:
"""Logout returns 200 with a confirmation message."""
await _do_setup(client)
await _login(client)
response = await client.post("/api/auth/logout")
response = await client.post("/api/v1/auth/logout")
assert response.status_code == 200
assert "message" in response.json()
@@ -250,7 +250,7 @@ class TestLogout:
"""Logout clears the bangui_session cookie."""
await _do_setup(client)
await _login(client) # sets cookie on client
response = await client.post("/api/auth/logout")
response = await client.post("/api/v1/auth/logout")
assert response.status_code == 200
# Cookie should be set to empty / deleted in the Set-Cookie header.
set_cookie = response.headers.get("set-cookie", "")
@@ -259,7 +259,7 @@ class TestLogout:
async def test_logout_is_idempotent(self, client: AsyncClient) -> None:
"""Logout succeeds even when called without a session token."""
await _do_setup(client)
response = await client.post("/api/auth/logout")
response = await client.post("/api/v1/auth/logout")
assert response.status_code == 200
async def test_session_invalid_after_logout(
@@ -269,7 +269,7 @@ class TestLogout:
await _do_setup(client)
token = await _login(client)
await client.post("/api/auth/logout")
await client.post("/api/v1/auth/logout")
# Now try to use the invalidated token via Bearer header. The health
# endpoint is unprotected so we validate against a hypothetical
@@ -277,7 +277,7 @@ class TestLogout:
# Here we just confirm the token is no longer in the DB by trying
# to re-use it on logout (idempotent — still 200, not an error).
response = await client.post(
"/api/auth/logout",
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
@@ -295,7 +295,7 @@ class TestRequireAuth:
self, client: AsyncClient
) -> None:
"""Health endpoint is accessible without authentication."""
response = await client.get("/api/health")
response = await client.get("/api/v1/health")
assert response.status_code == 200
async def test_session_cache_is_disabled_by_default(
@@ -317,11 +317,11 @@ class TestRequireAuth:
with patch.object(session_repo, "get_session", side_effect=_tracking):
resp1 = await client.get(
"/api/dashboard/status",
"/api/v1/dashboard/status",
headers={"Authorization": f"Bearer {token}"},
)
resp2 = await client.get(
"/api/dashboard/status",
"/api/v1/dashboard/status",
headers={"Authorization": f"Bearer {token}"},
)
@@ -346,7 +346,7 @@ class TestValidateSession:
token = await _login(client)
# Use Bearer token to authenticate
response = await client.get(
"/api/auth/session",
"/api/v1/auth/session",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
@@ -357,7 +357,7 @@ class TestValidateSession:
) -> None:
"""Validate session returns 401 when no token is present."""
await _do_setup(client)
response = await client.get("/api/auth/session")
response = await client.get("/api/v1/auth/session")
assert response.status_code == 401
async def test_validate_session_returns_401_with_invalid_token(
@@ -366,7 +366,7 @@ class TestValidateSession:
"""Validate session returns 401 for an invalid or expired token."""
await _do_setup(client)
response = await client.get(
"/api/auth/session",
"/api/v1/auth/session",
headers={"Authorization": "Bearer invalidtoken"},
)
assert response.status_code == 401
@@ -379,7 +379,7 @@ class TestValidateSession:
token = await _login(client)
# httpx should automatically send the cookie, but use Bearer token as fallback
response = await client.get(
"/api/auth/session",
"/api/v1/auth/session",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
@@ -392,11 +392,11 @@ class TestValidateSession:
await _do_setup(client)
token = await _login(client)
await client.post(
"/api/auth/logout",
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {token}"},
)
response = await client.get(
"/api/auth/session",
"/api/v1/auth/session",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 401
@@ -449,11 +449,11 @@ class TestRequireAuthSessionCache:
with patch.object(session_repo, "get_session", side_effect=_tracking):
resp1 = await client.get(
"/api/dashboard/status",
"/api/v1/dashboard/status",
headers={"Authorization": f"Bearer {token}"},
)
resp2 = await client.get(
"/api/dashboard/status",
"/api/v1/dashboard/status",
headers={"Authorization": f"Bearer {token}"},
)
@@ -475,7 +475,7 @@ class TestRequireAuthSessionCache:
assert client._transport.app.state.session_cache.get(token) is None
await client.get(
"/api/dashboard/status",
"/api/v1/dashboard/status",
headers={"Authorization": f"Bearer {token}"},
)
@@ -491,17 +491,17 @@ class TestRequireAuthSessionCache:
# Warm the cache.
await client.get(
"/api/dashboard/status",
"/api/v1/dashboard/status",
headers={"Authorization": f"Bearer {token}"},
)
assert client._transport.app.state.session_cache.get(token) is not None
# Logout must evict the entry.
await client.post(
"/api/auth/logout",
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {token}"},
)
assert client._transport.app.state.session_cache.get(token) is None
response = await client.get("/api/health")
response = await client.get("/api/v1/health")
assert response.status_code == 200

View File

@@ -49,9 +49,9 @@ async def bans_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
await ac.post("/api/setup", json=_SETUP_PAYLOAD)
await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD)
login = await ac.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
assert login.status_code == 200
@@ -87,7 +87,7 @@ class TestGetActiveBans:
"app.routers.bans.ban_service.get_active_bans",
AsyncMock(return_value=mock_response),
):
resp = await bans_client.get("/api/bans/active")
resp = await bans_client.get("/api/v1/bans/active")
assert resp.status_code == 200
data = resp.json()
@@ -100,7 +100,7 @@ class TestGetActiveBans:
resp = await AsyncClient(
transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/bans/active")
).get("/api/v1/bans/active")
assert resp.status_code == 401
async def test_empty_when_no_bans(self, bans_client: AsyncClient) -> None:
@@ -110,7 +110,7 @@ class TestGetActiveBans:
"app.routers.bans.ban_service.get_active_bans",
AsyncMock(return_value=mock_response),
):
resp = await bans_client.get("/api/bans/active")
resp = await bans_client.get("/api/v1/bans/active")
assert resp.status_code == 200
assert resp.json()["total"] == 0
@@ -135,7 +135,7 @@ class TestGetActiveBans:
"app.routers.bans.ban_service.get_active_bans",
AsyncMock(return_value=mock_response),
):
resp = await bans_client.get("/api/bans/active")
resp = await bans_client.get("/api/v1/bans/active")
ban = resp.json()["bans"][0]
assert "ip" in ban
@@ -160,7 +160,7 @@ class TestBanIp:
AsyncMock(return_value=None),
):
resp = await bans_client.post(
"/api/bans",
"/api/v1/bans",
json={"ip": "1.2.3.4", "jail": "sshd"},
)
@@ -174,7 +174,7 @@ class TestBanIp:
AsyncMock(side_effect=ValueError("Invalid IP address: 'bad'")),
):
resp = await bans_client.post(
"/api/bans",
"/api/v1/bans",
json={"ip": "bad", "jail": "sshd"},
)
@@ -189,7 +189,7 @@ class TestBanIp:
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await bans_client.post(
"/api/bans",
"/api/v1/bans",
json={"ip": "1.2.3.4", "jail": "ghost"},
)
@@ -200,7 +200,7 @@ class TestBanIp:
resp = await AsyncClient(
transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/bans", json={"ip": "1.2.3.4", "jail": "sshd"})
).post("/api/v1/bans", json={"ip": "1.2.3.4", "jail": "sshd"})
assert resp.status_code == 401
@@ -220,7 +220,7 @@ class TestUnbanIp:
):
resp = await bans_client.request(
"DELETE",
"/api/bans",
"/api/v1/bans",
json={"ip": "1.2.3.4", "unban_all": True},
)
@@ -235,7 +235,7 @@ class TestUnbanIp:
):
resp = await bans_client.request(
"DELETE",
"/api/bans",
"/api/v1/bans",
json={"ip": "1.2.3.4", "jail": "sshd"},
)
@@ -250,7 +250,7 @@ class TestUnbanIp:
):
resp = await bans_client.request(
"DELETE",
"/api/bans",
"/api/v1/bans",
json={"ip": "bad", "unban_all": True},
)
@@ -266,7 +266,7 @@ class TestUnbanIp:
):
resp = await bans_client.request(
"DELETE",
"/api/bans",
"/api/v1/bans",
json={"ip": "1.2.3.4", "jail": "ghost"},
)
@@ -287,7 +287,7 @@ class TestUnbanAll:
"app.routers.bans.jail_service.unban_all_ips",
AsyncMock(return_value=3),
):
resp = await bans_client.request("DELETE", "/api/bans/all")
resp = await bans_client.request("DELETE", "/api/v1/bans/all")
assert resp.status_code == 200
data = resp.json()
@@ -300,7 +300,7 @@ class TestUnbanAll:
"app.routers.bans.jail_service.unban_all_ips",
AsyncMock(return_value=0),
):
resp = await bans_client.request("DELETE", "/api/bans/all")
resp = await bans_client.request("DELETE", "/api/v1/bans/all")
assert resp.status_code == 200
assert resp.json()["count"] == 0
@@ -318,7 +318,7 @@ class TestUnbanAll:
)
),
):
resp = await bans_client.request("DELETE", "/api/bans/all")
resp = await bans_client.request("DELETE", "/api/v1/bans/all")
assert resp.status_code == 502
@@ -327,5 +327,5 @@ class TestUnbanAll:
resp = await AsyncClient(
transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).request("DELETE", "/api/bans/all")
).request("DELETE", "/api/v1/bans/all")
assert resp.status_code == 401

View File

@@ -129,11 +129,11 @@ async def bl_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
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
@@ -155,13 +155,13 @@ class TestListBlocklists:
"app.routers.blocklist.blocklist_service.list_sources",
new=AsyncMock(return_value=_make_source_list().sources),
):
resp = await bl_client.get("/api/blocklists")
resp = await bl_client.get("/api/v1/blocklists")
assert resp.status_code == 200
async def test_returns_401_unauthenticated(self, client: AsyncClient) -> None:
"""Unauthenticated request returns 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
resp = await client.get("/api/blocklists")
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
resp = await client.get("/api/v1/blocklists")
assert resp.status_code == 401
async def test_response_contains_sources_key(self, bl_client: AsyncClient) -> None:
@@ -170,7 +170,7 @@ class TestListBlocklists:
"app.routers.blocklist.blocklist_service.list_sources",
new=AsyncMock(return_value=[_make_source()]),
):
resp = await bl_client.get("/api/blocklists")
resp = await bl_client.get("/api/v1/blocklists")
body = resp.json()
assert "sources" in body
assert isinstance(body["sources"], list)
@@ -191,7 +191,7 @@ class TestCreateBlocklist:
new=AsyncMock(return_value=_make_source()),
):
resp = await bl_client.post(
"/api/blocklists",
"/api/v1/blocklists",
json={"name": "Test", "url": "https://test.test/", "enabled": True},
)
assert resp.status_code == 201
@@ -205,7 +205,7 @@ class TestCreateBlocklist:
new=AsyncMock(return_value=_make_source(42)),
):
resp = await bl_client.post(
"/api/blocklists",
"/api/v1/blocklists",
json={"name": "Test", "url": "https://test.test/", "enabled": True},
)
assert resp.json()["id"] == 42
@@ -226,7 +226,7 @@ class TestUpdateBlocklist:
new=AsyncMock(return_value=updated),
):
resp = await bl_client.put(
"/api/blocklists/1",
"/api/v1/blocklists/1",
json={"enabled": False},
)
assert resp.status_code == 200
@@ -238,7 +238,7 @@ class TestUpdateBlocklist:
new=AsyncMock(return_value=None),
):
resp = await bl_client.put(
"/api/blocklists/999",
"/api/v1/blocklists/999",
json={"enabled": False},
)
assert resp.status_code == 404
@@ -256,7 +256,7 @@ class TestDeleteBlocklist:
"app.routers.blocklist.blocklist_service.delete_source",
new=AsyncMock(return_value=True),
):
resp = await bl_client.delete("/api/blocklists/1")
resp = await bl_client.delete("/api/v1/blocklists/1")
assert resp.status_code == 204
async def test_delete_returns_404_for_missing(self, bl_client: AsyncClient) -> None:
@@ -265,7 +265,7 @@ class TestDeleteBlocklist:
"app.routers.blocklist.blocklist_service.delete_source",
new=AsyncMock(return_value=False),
):
resp = await bl_client.delete("/api/blocklists/999")
resp = await bl_client.delete("/api/v1/blocklists/999")
assert resp.status_code == 404
@@ -284,7 +284,7 @@ class TestPreviewBlocklist:
"app.routers.blocklist.blocklist_service.preview_source",
new=AsyncMock(return_value=_make_preview()),
):
resp = await bl_client.get("/api/blocklists/1/preview")
resp = await bl_client.get("/api/v1/blocklists/1/preview")
assert resp.status_code == 200
async def test_preview_returns_404_for_missing(self, bl_client: AsyncClient) -> None:
@@ -293,7 +293,7 @@ class TestPreviewBlocklist:
"app.routers.blocklist.blocklist_service.get_source",
new=AsyncMock(return_value=None),
):
resp = await bl_client.get("/api/blocklists/999/preview")
resp = await bl_client.get("/api/v1/blocklists/999/preview")
assert resp.status_code == 404
async def test_preview_returns_502_on_download_error(
@@ -307,7 +307,7 @@ class TestPreviewBlocklist:
"app.routers.blocklist.blocklist_service.preview_source",
new=AsyncMock(side_effect=ValueError("Connection refused")),
):
resp = await bl_client.get("/api/blocklists/1/preview")
resp = await bl_client.get("/api/v1/blocklists/1/preview")
assert resp.status_code == 502
async def test_preview_response_shape(self, bl_client: AsyncClient) -> None:
@@ -319,7 +319,7 @@ class TestPreviewBlocklist:
"app.routers.blocklist.blocklist_service.preview_source",
new=AsyncMock(return_value=_make_preview()),
):
resp = await bl_client.get("/api/blocklists/1/preview")
resp = await bl_client.get("/api/v1/blocklists/1/preview")
body = resp.json()
assert "entries" in body
assert "valid_count" in body
@@ -339,7 +339,7 @@ class TestRunImport:
"app.routers.blocklist.blocklist_service.import_all",
new=AsyncMock(return_value=_make_import_result()),
):
resp = await bl_client.post("/api/blocklists/import")
resp = await bl_client.post("/api/v1/blocklists/import")
assert resp.status_code == 200
async def test_import_response_shape(self, bl_client: AsyncClient) -> None:
@@ -348,7 +348,7 @@ class TestRunImport:
"app.routers.blocklist.blocklist_service.import_all",
new=AsyncMock(return_value=_make_import_result()),
):
resp = await bl_client.post("/api/blocklists/import")
resp = await bl_client.post("/api/v1/blocklists/import")
body = resp.json()
assert "total_imported" in body
assert "total_skipped" in body
@@ -368,7 +368,7 @@ class TestGetSchedule:
"app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime",
new=AsyncMock(return_value=_make_schedule_info()),
):
resp = await bl_client.get("/api/blocklists/schedule")
resp = await bl_client.get("/api/v1/blocklists/schedule")
assert resp.status_code == 200
async def test_schedule_response_has_config(self, bl_client: AsyncClient) -> None:
@@ -377,7 +377,7 @@ class TestGetSchedule:
"app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime",
new=AsyncMock(return_value=_make_schedule_info()),
):
resp = await bl_client.get("/api/blocklists/schedule")
resp = await bl_client.get("/api/v1/blocklists/schedule")
body = resp.json()
assert "config" in body
assert "next_run_at" in body
@@ -403,7 +403,7 @@ class TestGetSchedule:
"app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime",
new=AsyncMock(return_value=info_with_errors),
):
resp = await bl_client.get("/api/blocklists/schedule")
resp = await bl_client.get("/api/v1/blocklists/schedule")
body = resp.json()
assert "last_run_errors" in body
assert body["last_run_errors"] is True
@@ -433,7 +433,7 @@ class TestUpdateSchedule:
new=AsyncMock(return_value=new_info),
):
resp = await bl_client.put(
"/api/blocklists/schedule",
"/api/v1/blocklists/schedule",
json={
"frequency": "hourly",
"interval_hours": 12,
@@ -453,19 +453,19 @@ class TestUpdateSchedule:
class TestImportLog:
async def test_log_returns_200(self, bl_client: AsyncClient) -> None:
"""GET /api/blocklists/log returns 200."""
resp = await bl_client.get("/api/blocklists/log")
resp = await bl_client.get("/api/v1/blocklists/log")
assert resp.status_code == 200
async def test_log_response_shape(self, bl_client: AsyncClient) -> None:
"""Log response has items, total, page, page_size."""
resp = await bl_client.get("/api/blocklists/log")
resp = await bl_client.get("/api/v1/blocklists/log")
body = resp.json()
for key in ("items", "total", "page", "page_size"):
assert key in body
async def test_log_empty_when_no_runs(self, bl_client: AsyncClient) -> None:
"""Log returns empty items list when no import runs have occurred."""
resp = await bl_client.get("/api/blocklists/log")
resp = await bl_client.get("/api/v1/blocklists/log")
body = resp.json()
assert body["total"] == 0
assert body["items"] == []

File diff suppressed because it is too large Load Diff

View File

@@ -26,14 +26,14 @@ _SETUP_PAYLOAD = {
async def _do_setup(client: AsyncClient) -> None:
"""Run the setup wizard so auth endpoints are reachable."""
resp = await client.post("/api/setup", json=_SETUP_PAYLOAD)
resp = await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
assert resp.status_code == 201
async def _login(client: AsyncClient, password: str = "Mysecretpass1!") -> str:
"""Helper: perform login and return the session token."""
resp = await client.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": password},
headers={"X-BanGUI-Request": "1"},
)
@@ -58,7 +58,7 @@ class TestCsrfProtection:
# POST with correct CSRF header should succeed (endpoint may fail for other reasons)
response = await client.post(
"/api/auth/logout",
"/api/v1/auth/logout",
cookies={SESSION_COOKIE_NAME: token},
headers={"X-BanGUI-Request": "1"},
)
@@ -74,7 +74,7 @@ class TestCsrfProtection:
# POST without CSRF header should be rejected
response = await client.post(
"/api/auth/logout",
"/api/v1/auth/logout",
cookies={SESSION_COOKIE_NAME: token},
headers={}, # Explicitly omit X-BanGUI-Request
)
@@ -92,7 +92,7 @@ class TestCsrfProtection:
# POST with wrong CSRF header value should be rejected
response = await client.post(
"/api/auth/logout",
"/api/v1/auth/logout",
cookies={SESSION_COOKIE_NAME: token},
headers={"X-BanGUI-Request": "invalid"},
)
@@ -107,7 +107,7 @@ class TestCsrfProtection:
# POST with Bearer token but no CSRF header should succeed
response = await client.post(
"/api/auth/logout",
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {token}"},
)
# Expect 200 (logout succeeds) not 403 (CSRF check should be skipped)
@@ -122,7 +122,7 @@ class TestCsrfProtection:
# GET without CSRF header should succeed (safe method)
response = await client.get(
"/api/auth/session",
"/api/v1/auth/session",
cookies={SESSION_COOKIE_NAME: token},
headers={}, # Explicitly omit X-BanGUI-Request
)
@@ -138,7 +138,7 @@ class TestCsrfProtection:
# OPTIONS without CSRF header should succeed (safe method)
response = await client.options(
"/api/auth/session",
"/api/v1/auth/session",
cookies={SESSION_COOKIE_NAME: token},
headers={},
)
@@ -154,7 +154,7 @@ class TestCsrfProtection:
# HEAD without CSRF header should succeed (safe method)
response = await client.head(
"/api/auth/session",
"/api/v1/auth/session",
cookies={SESSION_COOKIE_NAME: token},
headers={},
)
@@ -172,7 +172,7 @@ class TestCsrfProtection:
# The endpoint may fail for other reasons (no ban to delete), but not 403 CSRF
response = await client.request(
"DELETE",
"/api/bans",
"/api/v1/bans",
content='{"ip": "192.0.2.1", "jail": "sshd"}',
cookies={SESSION_COOKIE_NAME: token},
headers={"X-BanGUI-Request": "1"},
@@ -190,7 +190,7 @@ class TestCsrfProtection:
# DELETE without CSRF header should be rejected
response = await client.request(
"DELETE",
"/api/bans",
"/api/v1/bans",
content='{"ip": "192.0.2.1", "jail": "sshd"}',
cookies={SESSION_COOKIE_NAME: token},
headers={},
@@ -206,7 +206,7 @@ class TestCsrfProtection:
# PUT with correct CSRF header should not be rejected by CSRF middleware
response = await client.put(
"/api/blocklists/schedule",
"/api/v1/blocklists/schedule",
json={"enabled": False},
cookies={SESSION_COOKIE_NAME: token},
headers={"X-BanGUI-Request": "1"},
@@ -223,7 +223,7 @@ class TestCsrfProtection:
# PUT without CSRF header should be rejected
response = await client.put(
"/api/blocklists/schedule",
"/api/v1/blocklists/schedule",
json={"enabled": False},
cookies={SESSION_COOKIE_NAME: token},
headers={},
@@ -240,7 +240,7 @@ class TestCsrfProtection:
# PATCH with correct CSRF header should not be rejected by CSRF middleware
# (endpoint may not exist, but CSRF check should pass)
response = await client.patch(
"/api/auth/logout",
"/api/v1/auth/logout",
cookies={SESSION_COOKIE_NAME: token},
headers={"X-BanGUI-Request": "1"},
)
@@ -256,7 +256,7 @@ class TestCsrfProtection:
# PATCH without CSRF header should be rejected
response = await client.patch(
"/api/auth/logout",
"/api/v1/auth/logout",
cookies={SESSION_COOKIE_NAME: token},
headers={},
)
@@ -271,7 +271,7 @@ class TestCsrfProtection:
# POST without any authentication should bypass CSRF check
# (the endpoint itself will reject it with 401, not 403)
response = await client.post(
"/api/auth/logout",
"/api/v1/auth/logout",
headers={},
)
# Should be 401 (auth required) not 403 (CSRF failed)
@@ -289,7 +289,7 @@ class TestCsrfProtection:
# POST with Bearer token via Authorization header and no CSRF header
# should NOT be rejected by CSRF middleware
response = await client.post(
"/api/auth/logout",
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {token}"},
)
# Should succeed (200) not fail with 403

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"] == []

View File

@@ -146,7 +146,7 @@ async def test_auth_login_uses_injected_auth_service(tmp_path: Path) -> None:
base_url="http://test",
) as client:
response = await client.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": "ignored"},
)
@@ -185,7 +185,7 @@ async def test_jail_list_uses_injected_jail_service_and_auth(tmp_path: Path) ->
base_url="http://test",
) as client:
response = await client.get(
"/api/jails",
"/api/v1/jails",
headers={"Cookie": f"{SESSION_COOKIE_NAME}=fake-token"},
)

View File

@@ -68,9 +68,9 @@ async def file_config_client(tmp_path: Path) -> AsyncClient: # type: ignore[mis
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
await ac.post("/api/setup", json=_SETUP_PAYLOAD)
await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD)
login = await ac.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
assert login.status_code == 200
@@ -115,7 +115,7 @@ class TestListJailConfigFiles:
"app.routers.file_config.raw_config_io_service.list_jail_config_files",
AsyncMock(return_value=_jail_files_resp()),
):
resp = await file_config_client.get("/api/config/jail-files")
resp = await file_config_client.get("/api/v1/config/jail-files")
assert resp.status_code == 200
data = resp.json()
@@ -129,7 +129,7 @@ class TestListJailConfigFiles:
"app.routers.file_config.raw_config_io_service.list_jail_config_files",
AsyncMock(side_effect=ConfigDirError("not found")),
):
resp = await file_config_client.get("/api/config/jail-files")
resp = await file_config_client.get("/api/v1/config/jail-files")
assert resp.status_code == 503
@@ -137,7 +137,7 @@ class TestListJailConfigFiles:
resp = await AsyncClient(
transport=ASGITransport(app=file_config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/config/jail-files")
).get("/api/v1/config/jail-files")
assert resp.status_code == 401
@@ -160,7 +160,7 @@ class TestGetJailConfigFile:
"app.routers.file_config.raw_config_io_service.get_jail_config_file",
AsyncMock(return_value=content),
):
resp = await file_config_client.get("/api/config/jail-files/sshd.conf")
resp = await file_config_client.get("/api/v1/config/jail-files/sshd.conf")
assert resp.status_code == 200
assert resp.json()["content"] == "[sshd]\nenabled = true\n"
@@ -170,7 +170,7 @@ class TestGetJailConfigFile:
"app.routers.file_config.raw_config_io_service.get_jail_config_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
):
resp = await file_config_client.get("/api/config/jail-files/missing.conf")
resp = await file_config_client.get("/api/v1/config/jail-files/missing.conf")
assert resp.status_code == 404
@@ -181,7 +181,7 @@ class TestGetJailConfigFile:
"app.routers.file_config.raw_config_io_service.get_jail_config_file",
AsyncMock(side_effect=ConfigFileNameError("bad name")),
):
resp = await file_config_client.get("/api/config/jail-files/bad.txt")
resp = await file_config_client.get("/api/v1/config/jail-files/bad.txt")
assert resp.status_code == 400
@@ -198,7 +198,7 @@ class TestSetJailConfigEnabled:
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/jail-files/sshd.conf/enabled",
"/api/v1/config/jail-files/sshd.conf/enabled",
json={"enabled": False},
)
@@ -210,7 +210,7 @@ class TestSetJailConfigEnabled:
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
):
resp = await file_config_client.put(
"/api/config/jail-files/missing.conf/enabled",
"/api/v1/config/jail-files/missing.conf/enabled",
json={"enabled": True},
)
@@ -235,7 +235,7 @@ class TestGetFilterFileRaw:
"app.routers.file_config.raw_config_io_service.get_filter_file",
AsyncMock(return_value=_conf_file_content("nginx")),
):
resp = await file_config_client.get("/api/config/filters/nginx/raw")
resp = await file_config_client.get("/api/v1/config/filters/nginx/raw")
assert resp.status_code == 200
assert resp.json()["name"] == "nginx"
@@ -245,7 +245,7 @@ class TestGetFilterFileRaw:
"app.routers.file_config.raw_config_io_service.get_filter_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.get("/api/config/filters/missing/raw")
resp = await file_config_client.get("/api/v1/config/filters/missing/raw")
assert resp.status_code == 404
@@ -262,7 +262,7 @@ class TestUpdateFilterFile:
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/filters/nginx/raw",
"/api/v1/config/filters/nginx/raw",
json={"content": "[Definition]\nfailregex = test\n"},
)
@@ -274,7 +274,7 @@ class TestUpdateFilterFile:
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/filters/nginx/raw",
"/api/v1/config/filters/nginx/raw",
json={"content": "x"},
)
@@ -293,7 +293,7 @@ class TestCreateFilterFile:
AsyncMock(return_value="myfilter.conf"),
):
resp = await file_config_client.post(
"/api/config/filters/raw",
"/api/v1/config/filters/raw",
json={"name": "myfilter", "content": "[Definition]\n"},
)
@@ -306,7 +306,7 @@ class TestCreateFilterFile:
AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")),
):
resp = await file_config_client.post(
"/api/config/filters/raw",
"/api/v1/config/filters/raw",
json={"name": "myfilter", "content": "[Definition]\n"},
)
@@ -318,7 +318,7 @@ class TestCreateFilterFile:
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
):
resp = await file_config_client.post(
"/api/config/filters/raw",
"/api/v1/config/filters/raw",
json={"name": "../escape", "content": "[Definition]\n"},
)
@@ -345,7 +345,7 @@ class TestListActionFiles:
"app.routers.config.action_config_service.list_actions",
AsyncMock(return_value=resp_data),
):
resp = await file_config_client.get("/api/config/actions")
resp = await file_config_client.get("/api/v1/config/actions")
assert resp.status_code == 200
assert resp.json()["actions"][0]["name"] == "iptables"
@@ -369,7 +369,7 @@ class TestCreateActionFile:
AsyncMock(return_value=created),
):
resp = await file_config_client.post(
"/api/config/actions",
"/api/v1/config/actions",
json={"name": "myaction", "actionban": "echo ban <ip>"},
)
@@ -390,7 +390,7 @@ class TestGetActionFileRaw:
"app.routers.file_config.raw_config_io_service.get_action_file",
AsyncMock(return_value=_conf_file_content("iptables")),
):
resp = await file_config_client.get("/api/config/actions/iptables/raw")
resp = await file_config_client.get("/api/v1/config/actions/iptables/raw")
assert resp.status_code == 200
assert resp.json()["name"] == "iptables"
@@ -400,7 +400,7 @@ class TestGetActionFileRaw:
"app.routers.file_config.raw_config_io_service.get_action_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.get("/api/config/actions/missing/raw")
resp = await file_config_client.get("/api/v1/config/actions/missing/raw")
assert resp.status_code == 404
@@ -411,7 +411,7 @@ class TestGetActionFileRaw:
"app.routers.file_config.raw_config_io_service.get_action_file",
AsyncMock(side_effect=ConfigDirError("no dir")),
):
resp = await file_config_client.get("/api/config/actions/iptables/raw")
resp = await file_config_client.get("/api/v1/config/actions/iptables/raw")
assert resp.status_code == 503
@@ -430,7 +430,7 @@ class TestUpdateActionFileRaw:
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/actions/iptables/raw",
"/api/v1/config/actions/iptables/raw",
json={"content": "[Definition]\nactionban = iptables -I INPUT -s <ip> -j DROP\n"},
)
@@ -442,7 +442,7 @@ class TestUpdateActionFileRaw:
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/actions/iptables/raw",
"/api/v1/config/actions/iptables/raw",
json={"content": "x"},
)
@@ -454,7 +454,7 @@ class TestUpdateActionFileRaw:
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.put(
"/api/config/actions/missing/raw",
"/api/v1/config/actions/missing/raw",
json={"content": "x"},
)
@@ -466,7 +466,7 @@ class TestUpdateActionFileRaw:
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
):
resp = await file_config_client.put(
"/api/config/actions/escape/raw",
"/api/v1/config/actions/escape/raw",
json={"content": "x"},
)
@@ -485,7 +485,7 @@ class TestCreateJailConfigFile:
AsyncMock(return_value="myjail.conf"),
):
resp = await file_config_client.post(
"/api/config/jail-files",
"/api/v1/config/jail-files",
json={"name": "myjail", "content": "[myjail]\nenabled = true\n"},
)
@@ -498,7 +498,7 @@ class TestCreateJailConfigFile:
AsyncMock(side_effect=ConfigFileExistsError("myjail.conf")),
):
resp = await file_config_client.post(
"/api/config/jail-files",
"/api/v1/config/jail-files",
json={"name": "myjail", "content": "[myjail]\nenabled = true\n"},
)
@@ -510,7 +510,7 @@ class TestCreateJailConfigFile:
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
):
resp = await file_config_client.post(
"/api/config/jail-files",
"/api/v1/config/jail-files",
json={"name": "../escape", "content": "[Definition]\n"},
)
@@ -524,7 +524,7 @@ class TestCreateJailConfigFile:
AsyncMock(side_effect=ConfigDirError("no dir")),
):
resp = await file_config_client.post(
"/api/config/jail-files",
"/api/v1/config/jail-files",
json={"name": "anyjail", "content": "[anyjail]\nenabled = false\n"},
)
@@ -545,7 +545,7 @@ class TestGetParsedFilter:
"app.routers.file_config.raw_config_io_service.get_parsed_filter_file",
AsyncMock(return_value=cfg),
):
resp = await file_config_client.get("/api/config/filters/nginx/parsed")
resp = await file_config_client.get("/api/v1/config/filters/nginx/parsed")
assert resp.status_code == 200
data = resp.json()
@@ -558,7 +558,7 @@ class TestGetParsedFilter:
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.get(
"/api/config/filters/missing/parsed"
"/api/v1/config/filters/missing/parsed"
)
assert resp.status_code == 404
@@ -570,7 +570,7 @@ class TestGetParsedFilter:
"app.routers.file_config.raw_config_io_service.get_parsed_filter_file",
AsyncMock(side_effect=ConfigDirError("no dir")),
):
resp = await file_config_client.get("/api/config/filters/nginx/parsed")
resp = await file_config_client.get("/api/v1/config/filters/nginx/parsed")
assert resp.status_code == 503
@@ -587,7 +587,7 @@ class TestUpdateParsedFilter:
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/filters/nginx/parsed",
"/api/v1/config/filters/nginx/parsed",
json={"failregex": ["^<HOST> "]},
)
@@ -599,7 +599,7 @@ class TestUpdateParsedFilter:
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.put(
"/api/config/filters/missing/parsed",
"/api/v1/config/filters/missing/parsed",
json={"failregex": []},
)
@@ -611,7 +611,7 @@ class TestUpdateParsedFilter:
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/filters/nginx/parsed",
"/api/v1/config/filters/nginx/parsed",
json={"failregex": ["^<HOST> "]},
)
@@ -633,7 +633,7 @@ class TestGetParsedAction:
AsyncMock(return_value=cfg),
):
resp = await file_config_client.get(
"/api/config/actions/iptables/parsed"
"/api/v1/config/actions/iptables/parsed"
)
assert resp.status_code == 200
@@ -647,7 +647,7 @@ class TestGetParsedAction:
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.get(
"/api/config/actions/missing/parsed"
"/api/v1/config/actions/missing/parsed"
)
assert resp.status_code == 404
@@ -660,7 +660,7 @@ class TestGetParsedAction:
AsyncMock(side_effect=ConfigDirError("no dir")),
):
resp = await file_config_client.get(
"/api/config/actions/iptables/parsed"
"/api/v1/config/actions/iptables/parsed"
)
assert resp.status_code == 503
@@ -678,7 +678,7 @@ class TestUpdateParsedAction:
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/actions/iptables/parsed",
"/api/v1/config/actions/iptables/parsed",
json={"actionban": "iptables -I INPUT -s <ip> -j DROP"},
)
@@ -690,7 +690,7 @@ class TestUpdateParsedAction:
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.put(
"/api/config/actions/missing/parsed",
"/api/v1/config/actions/missing/parsed",
json={"actionban": ""},
)
@@ -702,7 +702,7 @@ class TestUpdateParsedAction:
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/actions/iptables/parsed",
"/api/v1/config/actions/iptables/parsed",
json={"actionban": "iptables -I INPUT -s <ip> -j DROP"},
)
@@ -725,7 +725,7 @@ class TestGetParsedJailFile:
AsyncMock(return_value=cfg),
):
resp = await file_config_client.get(
"/api/config/jail-files/sshd.conf/parsed"
"/api/v1/config/jail-files/sshd.conf/parsed"
)
assert resp.status_code == 200
@@ -739,7 +739,7 @@ class TestGetParsedJailFile:
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
):
resp = await file_config_client.get(
"/api/config/jail-files/missing.conf/parsed"
"/api/v1/config/jail-files/missing.conf/parsed"
)
assert resp.status_code == 404
@@ -752,7 +752,7 @@ class TestGetParsedJailFile:
AsyncMock(side_effect=ConfigDirError("no dir")),
):
resp = await file_config_client.get(
"/api/config/jail-files/sshd.conf/parsed"
"/api/v1/config/jail-files/sshd.conf/parsed"
)
assert resp.status_code == 503
@@ -770,7 +770,7 @@ class TestUpdateParsedJailFile:
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/jail-files/sshd.conf/parsed",
"/api/v1/config/jail-files/sshd.conf/parsed",
json={"jails": {"sshd": {"enabled": False}}},
)
@@ -782,7 +782,7 @@ class TestUpdateParsedJailFile:
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
):
resp = await file_config_client.put(
"/api/config/jail-files/missing.conf/parsed",
"/api/v1/config/jail-files/missing.conf/parsed",
json={"jails": {}},
)
@@ -794,7 +794,7 @@ class TestUpdateParsedJailFile:
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/jail-files/sshd.conf/parsed",
"/api/v1/config/jail-files/sshd.conf/parsed",
json={"jails": {"sshd": {"enabled": True}}},
)

View File

@@ -54,9 +54,9 @@ async def geo_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
async with AsyncClient(transport=transport, base_url="http://test") as ac:
setup_payload = _SETUP_PAYLOAD.copy()
setup_payload["database_path"] = settings.database_path
await ac.post("/api/setup", json=setup_payload)
await ac.post("/api/v1/setup", json=setup_payload)
login = await ac.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
assert login.status_code == 200
@@ -85,7 +85,7 @@ class TestGeoLookup:
"app.routers.geo.jail_service.lookup_ip",
AsyncMock(return_value=result),
):
resp = await geo_client.get("/api/geo/lookup/1.2.3.4")
resp = await geo_client.get("/api/v1/geo/lookup/1.2.3.4")
assert resp.status_code == 200
data = resp.json()
@@ -107,7 +107,7 @@ class TestGeoLookup:
"app.routers.geo.jail_service.lookup_ip",
AsyncMock(return_value=result),
):
resp = await geo_client.get("/api/geo/lookup/8.8.8.8")
resp = await geo_client.get("/api/v1/geo/lookup/8.8.8.8")
assert resp.status_code == 200
assert resp.json()["currently_banned_in"] == []
@@ -123,7 +123,7 @@ class TestGeoLookup:
"app.routers.geo.jail_service.lookup_ip",
AsyncMock(return_value=result),
):
resp = await geo_client.get("/api/geo/lookup/1.2.3.4")
resp = await geo_client.get("/api/v1/geo/lookup/1.2.3.4")
assert resp.status_code == 200
assert resp.json()["geo"] is None
@@ -134,7 +134,7 @@ class TestGeoLookup:
"app.routers.geo.jail_service.lookup_ip",
AsyncMock(side_effect=ValueError("Invalid IP address: 'bad_ip'")),
):
resp = await geo_client.get("/api/geo/lookup/bad_ip")
resp = await geo_client.get("/api/v1/geo/lookup/bad_ip")
assert resp.status_code == 400
assert "detail" in resp.json()
@@ -145,7 +145,7 @@ class TestGeoLookup:
resp = await AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
).get("/api/geo/lookup/1.2.3.4")
).get("/api/v1/geo/lookup/1.2.3.4")
assert resp.status_code == 401
async def test_ipv6_address(self, geo_client: AsyncClient) -> None:
@@ -159,7 +159,7 @@ class TestGeoLookup:
"app.routers.geo.jail_service.lookup_ip",
AsyncMock(return_value=result),
):
resp = await geo_client.get("/api/geo/lookup/2001:db8::1")
resp = await geo_client.get("/api/v1/geo/lookup/2001:db8::1")
assert resp.status_code == 200
assert resp.json()["ip"] == "2001:db8::1"
@@ -179,7 +179,7 @@ class TestReResolve:
"app.routers.geo.geo_service.re_resolve_all",
AsyncMock(return_value={"resolved": 0, "total": 0}),
):
resp = await geo_client.post("/api/geo/re-resolve")
resp = await geo_client.post("/api/v1/geo/re-resolve")
assert resp.status_code == 200
data = resp.json()
@@ -188,7 +188,7 @@ class TestReResolve:
async def test_empty_when_no_unresolved_ips(self, geo_client: AsyncClient) -> None:
"""Returns resolved=0, total=0 when geo_cache has no NULL country_code rows."""
resp = await geo_client.post("/api/geo/re-resolve")
resp = await geo_client.post("/api/v1/geo/re-resolve")
assert resp.status_code == 200
assert resp.json() == {"resolved": 0, "total": 0}
@@ -209,7 +209,7 @@ class TestReResolve:
"lookup_batch",
new_callable=lambda: AsyncMock(return_value=geo_result),
):
resp = await geo_client.post("/api/geo/re-resolve")
resp = await geo_client.post("/api/v1/geo/re-resolve")
assert resp.status_code == 200
data = resp.json()
@@ -222,7 +222,7 @@ class TestReResolve:
resp = await AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
).post("/api/geo/re-resolve")
).post("/api/v1/geo/re-resolve")
assert resp.status_code == 401
@@ -246,7 +246,7 @@ class TestGeoStats:
"app.routers.geo.geo_service.cache_stats",
AsyncMock(return_value=stats),
):
resp = await geo_client.get("/api/geo/stats")
resp = await geo_client.get("/api/v1/geo/stats")
assert resp.status_code == 200
data = resp.json()
@@ -257,7 +257,7 @@ class TestGeoStats:
async def test_stats_empty_cache(self, geo_client: AsyncClient) -> None:
"""GET /api/geo/stats returns all zeros on a fresh database."""
resp = await geo_client.get("/api/geo/stats")
resp = await geo_client.get("/api/v1/geo/stats")
assert resp.status_code == 200
data = resp.json()
@@ -274,7 +274,7 @@ class TestGeoStats:
await db.execute("INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)", ("8.8.8.8",))
await db.commit()
resp = await geo_client.get("/api/geo/stats")
resp = await geo_client.get("/api/v1/geo/stats")
assert resp.status_code == 200
assert resp.json()["unresolved"] >= 2
@@ -285,5 +285,5 @@ class TestGeoStats:
resp = await AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
).get("/api/geo/stats")
).get("/api/v1/geo/stats")
assert resp.status_code == 401

View File

@@ -10,7 +10,7 @@ from app.models.server import ServerStatus
async def test_health_check_returns_200_when_online(client: AsyncClient) -> None:
"""``GET /api/health`` must return HTTP 200 when fail2ban is online."""
client._transport.app.state.server_status = ServerStatus(online=True)
response = await client.get("/api/health")
response = await client.get("/api/v1/health")
assert response.status_code == 200
@@ -18,7 +18,7 @@ async def test_health_check_returns_200_when_online(client: AsyncClient) -> None
async def test_health_check_returns_503_when_offline(client: AsyncClient) -> None:
"""``GET /api/health`` must return HTTP 503 when fail2ban is offline."""
client._transport.app.state.server_status = ServerStatus(online=False)
response = await client.get("/api/health")
response = await client.get("/api/v1/health")
assert response.status_code == 503
@@ -26,7 +26,7 @@ async def test_health_check_returns_503_when_offline(client: AsyncClient) -> Non
async def test_health_check_returns_ok_status_when_online(client: AsyncClient) -> None:
"""``GET /api/health`` must contain ``status: ok`` when fail2ban is online."""
client._transport.app.state.server_status = ServerStatus(online=True)
response = await client.get("/api/health")
response = await client.get("/api/v1/health")
data: dict[str, str] = response.json()
assert data["status"] == "ok"
assert data["fail2ban"] == "online"
@@ -36,7 +36,7 @@ async def test_health_check_returns_ok_status_when_online(client: AsyncClient) -
async def test_health_check_returns_unavailable_when_offline(client: AsyncClient) -> None:
"""``GET /api/health`` must contain ``status: unavailable`` when fail2ban is offline."""
client._transport.app.state.server_status = ServerStatus(online=False)
response = await client.get("/api/health")
response = await client.get("/api/v1/health")
data: dict[str, str] = response.json()
assert data["status"] == "unavailable"
assert data["fail2ban"] == "offline"
@@ -45,6 +45,6 @@ async def test_health_check_returns_unavailable_when_offline(client: AsyncClient
@pytest.mark.asyncio
async def test_health_check_content_type_is_json(client: AsyncClient) -> None:
"""``GET /api/health`` must set the ``Content-Type`` header to JSON."""
response = await client.get("/api/health")
response = await client.get("/api/v1/health")
assert "application/json" in response.headers.get("content-type", "")

View File

@@ -114,11 +114,11 @@ async def history_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
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
@@ -144,15 +144,15 @@ class TestHistoryList:
"app.routers.history.history_service.list_history",
new=AsyncMock(return_value=_make_history_list()),
):
response = await history_client.get("/api/history")
response = await history_client.get("/api/v1/history")
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/history")
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/v1/history")
assert response.status_code == 401
async def test_response_shape(self, history_client: AsyncClient) -> None:
@@ -162,7 +162,7 @@ class TestHistoryList:
"app.routers.history.history_service.list_history",
new=AsyncMock(return_value=mock_response),
):
response = await history_client.get("/api/history")
response = await history_client.get("/api/v1/history")
body = response.json()
assert "items" in body
@@ -192,7 +192,7 @@ class TestHistoryList:
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?jail=nginx")
await history_client.get("/api/v1/history?jail=nginx")
_args, kwargs = mock_fn.call_args
assert kwargs.get("jail") == "nginx"
@@ -204,7 +204,7 @@ class TestHistoryList:
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?ip=192.168")
await history_client.get("/api/v1/history?ip=192.168")
_args, kwargs = mock_fn.call_args
assert kwargs.get("ip_filter") == "192.168"
@@ -216,7 +216,7 @@ class TestHistoryList:
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?range=7d")
await history_client.get("/api/v1/history?range=7d")
_args, kwargs = mock_fn.call_args
assert kwargs.get("range_") == "7d"
@@ -228,7 +228,7 @@ class TestHistoryList:
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?origin=blocklist")
await history_client.get("/api/v1/history?origin=blocklist")
_args, kwargs = mock_fn.call_args
assert kwargs.get("origin") == "blocklist"
@@ -240,7 +240,7 @@ class TestHistoryList:
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?source=archive")
await history_client.get("/api/v1/history?source=archive")
_args, kwargs = mock_fn.call_args
assert kwargs.get("source") == "archive"
@@ -254,7 +254,7 @@ class TestHistoryList:
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history/archive")
await history_client.get("/api/v1/history/archive")
_args, kwargs = mock_fn.call_args
assert kwargs.get("source") == "archive"
@@ -272,7 +272,7 @@ class TestHistoryList:
)
),
):
response = await history_client.get("/api/history")
response = await history_client.get("/api/v1/history")
body = response.json()
assert body["items"] == []
@@ -295,15 +295,15 @@ class TestIpHistory:
"app.routers.history.history_service.get_ip_detail",
new=AsyncMock(return_value=_make_ip_detail("1.2.3.4")),
):
response = await history_client.get("/api/history/1.2.3.4")
response = await history_client.get("/api/v1/history/1.2.3.4")
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/history/1.2.3.4")
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/v1/history/1.2.3.4")
assert response.status_code == 401
async def test_returns_404_for_unknown_ip(
@@ -314,7 +314,7 @@ class TestIpHistory:
"app.routers.history.history_service.get_ip_detail",
new=AsyncMock(return_value=None),
):
response = await history_client.get("/api/history/9.9.9.9")
response = await history_client.get("/api/v1/history/9.9.9.9")
assert response.status_code == 404
async def test_response_shape(self, history_client: AsyncClient) -> None:
@@ -324,7 +324,7 @@ class TestIpHistory:
"app.routers.history.history_service.get_ip_detail",
new=AsyncMock(return_value=mock_detail),
):
response = await history_client.get("/api/history/1.2.3.4")
response = await history_client.get("/api/v1/history/1.2.3.4")
body = response.json()
assert body["ip"] == "1.2.3.4"
@@ -376,7 +376,7 @@ class TestIpHistory:
"app.routers.history.history_service.get_ip_detail",
new=AsyncMock(return_value=mock_detail),
):
response = await history_client.get("/api/history/10.0.0.1")
response = await history_client.get("/api/v1/history/10.0.0.1")
assert response.status_code == 200
body = response.json()

View File

@@ -49,9 +49,9 @@ async def jails_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
await ac.post("/api/setup", json=_SETUP_PAYLOAD)
await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD)
login = await ac.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
assert login.status_code == 200
@@ -129,7 +129,7 @@ class TestGetJails:
"app.routers.jails.jail_service.list_jails",
AsyncMock(return_value=mock_response),
):
resp = await jails_client.get("/api/jails")
resp = await jails_client.get("/api/v1/jails")
assert resp.status_code == 200
data = resp.json()
@@ -141,7 +141,7 @@ class TestGetJails:
resp = await AsyncClient(
transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/jails")
).get("/api/v1/jails")
assert resp.status_code == 401
async def test_response_shape(self, jails_client: AsyncClient) -> None:
@@ -151,7 +151,7 @@ class TestGetJails:
"app.routers.jails.jail_service.list_jails",
AsyncMock(return_value=mock_response),
):
resp = await jails_client.get("/api/jails")
resp = await jails_client.get("/api/v1/jails")
jail = resp.json()["items"][0]
assert "name" in jail
@@ -176,7 +176,7 @@ class TestGetJailDetail:
"app.routers.jails.jail_service.get_jail",
AsyncMock(return_value=_detail()),
):
resp = await jails_client.get("/api/jails/sshd")
resp = await jails_client.get("/api/v1/jails/sshd")
assert resp.status_code == 200
data = resp.json()
@@ -193,7 +193,7 @@ class TestGetJailDetail:
"app.routers.jails.jail_service.get_jail",
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await jails_client.get("/api/jails/ghost")
resp = await jails_client.get("/api/v1/jails/ghost")
assert resp.status_code == 404
@@ -212,7 +212,7 @@ class TestStartJail:
"app.routers.jails.jail_service.start_jail",
AsyncMock(return_value=None),
):
resp = await jails_client.post("/api/jails/sshd/start")
resp = await jails_client.post("/api/v1/jails/sshd/start")
assert resp.status_code == 200
assert resp.json()["jail"] == "sshd"
@@ -225,7 +225,7 @@ class TestStartJail:
"app.routers.jails.jail_service.start_jail",
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await jails_client.post("/api/jails/ghost/start")
resp = await jails_client.post("/api/v1/jails/ghost/start")
assert resp.status_code == 404
@@ -237,7 +237,7 @@ class TestStartJail:
"app.routers.jails.jail_service.start_jail",
AsyncMock(side_effect=JailOperationError("already running")),
):
resp = await jails_client.post("/api/jails/sshd/start")
resp = await jails_client.post("/api/v1/jails/sshd/start")
assert resp.status_code == 409
@@ -256,7 +256,7 @@ class TestStopJail:
"app.routers.jails.jail_service.stop_jail",
AsyncMock(return_value=None),
):
resp = await jails_client.post("/api/jails/sshd/stop")
resp = await jails_client.post("/api/v1/jails/sshd/stop")
assert resp.status_code == 200
@@ -270,7 +270,7 @@ class TestStopJail:
"app.routers.jails.jail_service.stop_jail",
AsyncMock(return_value=None),
):
resp = await jails_client.post("/api/jails/sshd/stop")
resp = await jails_client.post("/api/v1/jails/sshd/stop")
assert resp.status_code == 200
@@ -290,7 +290,7 @@ class TestToggleIdle:
AsyncMock(return_value=None),
):
resp = await jails_client.post(
"/api/jails/sshd/idle",
"/api/v1/jails/sshd/idle",
content="true",
headers={"Content-Type": "application/json"},
)
@@ -304,7 +304,7 @@ class TestToggleIdle:
AsyncMock(return_value=None),
):
resp = await jails_client.post(
"/api/jails/sshd/idle",
"/api/v1/jails/sshd/idle",
content="false",
headers={"Content-Type": "application/json"},
)
@@ -326,7 +326,7 @@ class TestReloadJail:
"app.routers.jails.jail_service.reload_jail",
AsyncMock(return_value=None),
):
resp = await jails_client.post("/api/jails/sshd/reload")
resp = await jails_client.post("/api/v1/jails/sshd/reload")
assert resp.status_code == 200
assert resp.json()["jail"] == "sshd"
@@ -346,7 +346,7 @@ class TestReloadAll:
"app.routers.jails.jail_service.reload_all",
AsyncMock(return_value=None),
):
resp = await jails_client.post("/api/jails/reload-all")
resp = await jails_client.post("/api/v1/jails/reload-all")
assert resp.status_code == 200
assert resp.json()["jail"] == "*"
@@ -366,7 +366,7 @@ class TestIgnoreIpEndpoints:
"app.routers.jails.jail_service.get_ignore_list",
AsyncMock(return_value=["127.0.0.1"]),
):
resp = await jails_client.get("/api/jails/sshd/ignoreip")
resp = await jails_client.get("/api/v1/jails/sshd/ignoreip")
assert resp.status_code == 200
assert resp.json() == {"items": ["127.0.0.1"], "total": 1}
@@ -378,7 +378,7 @@ class TestIgnoreIpEndpoints:
AsyncMock(return_value=None),
):
resp = await jails_client.post(
"/api/jails/sshd/ignoreip",
"/api/v1/jails/sshd/ignoreip",
json={"ip": "192.168.1.0/24"},
)
@@ -391,7 +391,7 @@ class TestIgnoreIpEndpoints:
AsyncMock(side_effect=ValueError("Invalid IP address or network: 'bad'")),
):
resp = await jails_client.post(
"/api/jails/sshd/ignoreip",
"/api/v1/jails/sshd/ignoreip",
json={"ip": "bad"},
)
@@ -405,7 +405,7 @@ class TestIgnoreIpEndpoints:
):
resp = await jails_client.request(
"DELETE",
"/api/jails/sshd/ignoreip",
"/api/v1/jails/sshd/ignoreip",
json={"ip": "127.0.0.1"},
)
@@ -419,7 +419,7 @@ class TestIgnoreIpEndpoints:
"app.routers.jails.jail_service.get_ignore_list",
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await jails_client.get("/api/jails/ghost/ignoreip")
resp = await jails_client.get("/api/v1/jails/ghost/ignoreip")
assert resp.status_code == 404
@@ -431,7 +431,7 @@ class TestIgnoreIpEndpoints:
"app.routers.jails.jail_service.get_ignore_list",
AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")),
):
resp = await jails_client.get("/api/jails/sshd/ignoreip")
resp = await jails_client.get("/api/v1/jails/sshd/ignoreip")
assert resp.status_code == 502
@@ -444,7 +444,7 @@ class TestIgnoreIpEndpoints:
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await jails_client.post(
"/api/jails/ghost/ignoreip",
"/api/v1/jails/ghost/ignoreip",
json={"ip": "1.2.3.4"},
)
@@ -459,7 +459,7 @@ class TestIgnoreIpEndpoints:
AsyncMock(side_effect=JailOperationError("fail2ban rejected")),
):
resp = await jails_client.post(
"/api/jails/sshd/ignoreip",
"/api/v1/jails/sshd/ignoreip",
json={"ip": "1.2.3.4"},
)
@@ -474,7 +474,7 @@ class TestIgnoreIpEndpoints:
AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")),
):
resp = await jails_client.post(
"/api/jails/sshd/ignoreip",
"/api/v1/jails/sshd/ignoreip",
json={"ip": "1.2.3.4"},
)
@@ -490,7 +490,7 @@ class TestIgnoreIpEndpoints:
):
resp = await jails_client.request(
"DELETE",
"/api/jails/ghost/ignoreip",
"/api/v1/jails/ghost/ignoreip",
json={"ip": "1.2.3.4"},
)
@@ -506,7 +506,7 @@ class TestIgnoreIpEndpoints:
):
resp = await jails_client.request(
"DELETE",
"/api/jails/sshd/ignoreip",
"/api/v1/jails/sshd/ignoreip",
json={"ip": "1.2.3.4"},
)
@@ -522,7 +522,7 @@ class TestIgnoreIpEndpoints:
):
resp = await jails_client.request(
"DELETE",
"/api/jails/sshd/ignoreip",
"/api/v1/jails/sshd/ignoreip",
json={"ip": "1.2.3.4"},
)
@@ -544,7 +544,7 @@ class TestToggleIgnoreSelf:
AsyncMock(return_value=None),
):
resp = await jails_client.post(
"/api/jails/sshd/ignoreself",
"/api/v1/jails/sshd/ignoreself",
content="true",
headers={"Content-Type": "application/json"},
)
@@ -559,7 +559,7 @@ class TestToggleIgnoreSelf:
AsyncMock(return_value=None),
):
resp = await jails_client.post(
"/api/jails/sshd/ignoreself",
"/api/v1/jails/sshd/ignoreself",
content="false",
headers={"Content-Type": "application/json"},
)
@@ -576,7 +576,7 @@ class TestToggleIgnoreSelf:
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await jails_client.post(
"/api/jails/ghost/ignoreself",
"/api/v1/jails/ghost/ignoreself",
content="true",
headers={"Content-Type": "application/json"},
)
@@ -592,7 +592,7 @@ class TestToggleIgnoreSelf:
AsyncMock(side_effect=JailOperationError("fail2ban rejected")),
):
resp = await jails_client.post(
"/api/jails/sshd/ignoreself",
"/api/v1/jails/sshd/ignoreself",
content="true",
headers={"Content-Type": "application/json"},
)
@@ -608,7 +608,7 @@ class TestToggleIgnoreSelf:
AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")),
):
resp = await jails_client.post(
"/api/jails/sshd/ignoreself",
"/api/v1/jails/sshd/ignoreself",
content="true",
headers={"Content-Type": "application/json"},
)
@@ -632,7 +632,7 @@ class TestFail2BanConnectionErrors:
"app.routers.jails.jail_service.list_jails",
AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")),
):
resp = await jails_client.get("/api/jails")
resp = await jails_client.get("/api/v1/jails")
assert resp.status_code == 502
@@ -644,7 +644,7 @@ class TestFail2BanConnectionErrors:
"app.routers.jails.jail_service.get_jail",
AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")),
):
resp = await jails_client.get("/api/jails/sshd")
resp = await jails_client.get("/api/v1/jails/sshd")
assert resp.status_code == 502
@@ -656,7 +656,7 @@ class TestFail2BanConnectionErrors:
"app.routers.jails.jail_service.reload_all",
AsyncMock(side_effect=JailOperationError("reload failed")),
):
resp = await jails_client.post("/api/jails/reload-all")
resp = await jails_client.post("/api/v1/jails/reload-all")
assert resp.status_code == 409
@@ -668,7 +668,7 @@ class TestFail2BanConnectionErrors:
"app.routers.jails.jail_service.reload_all",
AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")),
):
resp = await jails_client.post("/api/jails/reload-all")
resp = await jails_client.post("/api/v1/jails/reload-all")
assert resp.status_code == 502
@@ -680,7 +680,7 @@ class TestFail2BanConnectionErrors:
"app.routers.jails.jail_service.start_jail",
AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")),
):
resp = await jails_client.post("/api/jails/sshd/start")
resp = await jails_client.post("/api/v1/jails/sshd/start")
assert resp.status_code == 502
@@ -692,7 +692,7 @@ class TestFail2BanConnectionErrors:
"app.routers.jails.jail_service.stop_jail",
AsyncMock(side_effect=JailOperationError("stop failed")),
):
resp = await jails_client.post("/api/jails/sshd/stop")
resp = await jails_client.post("/api/v1/jails/sshd/stop")
assert resp.status_code == 409
@@ -704,7 +704,7 @@ class TestFail2BanConnectionErrors:
"app.routers.jails.jail_service.stop_jail",
AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")),
):
resp = await jails_client.post("/api/jails/sshd/stop")
resp = await jails_client.post("/api/v1/jails/sshd/stop")
assert resp.status_code == 502
@@ -717,7 +717,7 @@ class TestFail2BanConnectionErrors:
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await jails_client.post(
"/api/jails/ghost/idle",
"/api/v1/jails/ghost/idle",
content="true",
headers={"Content-Type": "application/json"},
)
@@ -733,7 +733,7 @@ class TestFail2BanConnectionErrors:
AsyncMock(side_effect=JailOperationError("idle failed")),
):
resp = await jails_client.post(
"/api/jails/sshd/idle",
"/api/v1/jails/sshd/idle",
content="true",
headers={"Content-Type": "application/json"},
)
@@ -749,7 +749,7 @@ class TestFail2BanConnectionErrors:
AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")),
):
resp = await jails_client.post(
"/api/jails/sshd/idle",
"/api/v1/jails/sshd/idle",
content="true",
headers={"Content-Type": "application/json"},
)
@@ -764,7 +764,7 @@ class TestFail2BanConnectionErrors:
"app.routers.jails.jail_service.reload_jail",
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await jails_client.post("/api/jails/ghost/reload")
resp = await jails_client.post("/api/v1/jails/ghost/reload")
assert resp.status_code == 404
@@ -776,7 +776,7 @@ class TestFail2BanConnectionErrors:
"app.routers.jails.jail_service.reload_jail",
AsyncMock(side_effect=JailOperationError("reload failed")),
):
resp = await jails_client.post("/api/jails/sshd/reload")
resp = await jails_client.post("/api/v1/jails/sshd/reload")
assert resp.status_code == 409
@@ -788,7 +788,7 @@ class TestFail2BanConnectionErrors:
"app.routers.jails.jail_service.reload_jail",
AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")),
):
resp = await jails_client.post("/api/jails/sshd/reload")
resp = await jails_client.post("/api/v1/jails/sshd/reload")
assert resp.status_code == 502
@@ -834,7 +834,7 @@ class TestGetJailBannedIps:
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(return_value=self._mock_response()),
):
resp = await jails_client.get("/api/jails/sshd/banned")
resp = await jails_client.get("/api/v1/jails/sshd/banned")
assert resp.status_code == 200
data = resp.json()
@@ -848,7 +848,7 @@ class TestGetJailBannedIps:
"""GET /api/jails/sshd/banned?search=1.2.3 passes search to service."""
mock_fn = AsyncMock(return_value=self._mock_response(items=[{"ip": "1.2.3.4"}], total=1))
with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn):
resp = await jails_client.get("/api/jails/sshd/banned?search=1.2.3")
resp = await jails_client.get("/api/v1/jails/sshd/banned?search=1.2.3")
assert resp.status_code == 200
_args, call_kwargs = mock_fn.call_args
@@ -860,7 +860,7 @@ class TestGetJailBannedIps:
return_value=self._mock_response(page=2, page_size=10, total=0, items=[])
)
with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn):
resp = await jails_client.get("/api/jails/sshd/banned?page=2&page_size=10")
resp = await jails_client.get("/api/v1/jails/sshd/banned?page=2&page_size=10")
assert resp.status_code == 200
_args, call_kwargs = mock_fn.call_args
@@ -869,17 +869,17 @@ class TestGetJailBannedIps:
async def test_400_when_page_is_zero(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page=0 returns 400."""
resp = await jails_client.get("/api/jails/sshd/banned?page=0")
resp = await jails_client.get("/api/v1/jails/sshd/banned?page=0")
assert resp.status_code == 400
async def test_400_when_page_size_exceeds_max(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page_size=200 returns 400."""
resp = await jails_client.get("/api/jails/sshd/banned?page_size=200")
resp = await jails_client.get("/api/v1/jails/sshd/banned?page_size=200")
assert resp.status_code == 400
async def test_400_when_page_size_is_zero(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page_size=0 returns 400."""
resp = await jails_client.get("/api/jails/sshd/banned?page_size=0")
resp = await jails_client.get("/api/v1/jails/sshd/banned?page_size=0")
assert resp.status_code == 400
async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None:
@@ -890,7 +890,7 @@ class TestGetJailBannedIps:
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await jails_client.get("/api/jails/ghost/banned")
resp = await jails_client.get("/api/v1/jails/ghost/banned")
assert resp.status_code == 404
@@ -904,7 +904,7 @@ class TestGetJailBannedIps:
side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")
),
):
resp = await jails_client.get("/api/jails/sshd/banned")
resp = await jails_client.get("/api/v1/jails/sshd/banned")
assert resp.status_code == 502
@@ -916,7 +916,7 @@ class TestGetJailBannedIps:
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(return_value=self._mock_response()),
):
resp = await jails_client.get("/api/jails/sshd/banned")
resp = await jails_client.get("/api/v1/jails/sshd/banned")
item = resp.json()["items"][0]
assert "ip" in item
@@ -931,6 +931,6 @@ class TestGetJailBannedIps:
resp = await AsyncClient(
transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/jails/sshd/banned")
).get("/api/v1/jails/sshd/banned")
assert resp.status_code == 401

View File

@@ -48,9 +48,9 @@ async def server_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
await ac.post("/api/setup", json=_SETUP_PAYLOAD)
await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD)
login = await ac.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
assert login.status_code == 200
@@ -88,7 +88,7 @@ class TestGetServerSettings:
"app.routers.server.server_service.get_settings",
AsyncMock(return_value=mock_response),
):
resp = await server_client.get("/api/server/settings")
resp = await server_client.get("/api/v1/server/settings")
assert resp.status_code == 200
data = resp.json()
@@ -101,7 +101,7 @@ class TestGetServerSettings:
resp = await AsyncClient(
transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/server/settings")
).get("/api/v1/server/settings")
assert resp.status_code == 401
async def test_502_on_connection_error(self, server_client: AsyncClient) -> None:
@@ -112,7 +112,7 @@ class TestGetServerSettings:
"app.routers.server.server_service.get_settings",
AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")),
):
resp = await server_client.get("/api/server/settings")
resp = await server_client.get("/api/v1/server/settings")
assert resp.status_code == 502
@@ -132,7 +132,7 @@ class TestUpdateServerSettings:
AsyncMock(return_value=None),
):
resp = await server_client.put(
"/api/server/settings",
"/api/v1/server/settings",
json={"log_level": "DEBUG"},
)
@@ -147,7 +147,7 @@ class TestUpdateServerSettings:
AsyncMock(side_effect=ServerOperationError("set failed")),
):
resp = await server_client.put(
"/api/server/settings",
"/api/v1/server/settings",
json={"log_level": "DEBUG"},
)
@@ -158,7 +158,7 @@ class TestUpdateServerSettings:
resp = await AsyncClient(
transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).put("/api/server/settings", json={"log_level": "DEBUG"})
).put("/api/v1/server/settings", json={"log_level": "DEBUG"})
assert resp.status_code == 401
async def test_502_on_connection_error(self, server_client: AsyncClient) -> None:
@@ -170,7 +170,7 @@ class TestUpdateServerSettings:
AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")),
):
resp = await server_client.put(
"/api/server/settings",
"/api/v1/server/settings",
json={"log_level": "INFO"},
)
@@ -191,7 +191,7 @@ class TestFlushLogs:
"app.routers.server.server_service.flush_logs",
AsyncMock(return_value="OK"),
):
resp = await server_client.post("/api/server/flush-logs")
resp = await server_client.post("/api/v1/server/flush-logs")
assert resp.status_code == 200
assert resp.json()["message"] == "OK"
@@ -204,7 +204,7 @@ class TestFlushLogs:
"app.routers.server.server_service.flush_logs",
AsyncMock(side_effect=ServerOperationError("flushlogs failed")),
):
resp = await server_client.post("/api/server/flush-logs")
resp = await server_client.post("/api/v1/server/flush-logs")
assert resp.status_code == 400
@@ -213,7 +213,7 @@ class TestFlushLogs:
resp = await AsyncClient(
transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/server/flush-logs")
).post("/api/v1/server/flush-logs")
assert resp.status_code == 401
async def test_502_on_connection_error(self, server_client: AsyncClient) -> None:
@@ -224,6 +224,6 @@ class TestFlushLogs:
"app.routers.server.server_service.flush_logs",
AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")),
):
resp = await server_client.post("/api/server/flush-logs")
resp = await server_client.post("/api/v1/server/flush-logs")
assert resp.status_code == 502

View File

@@ -69,14 +69,14 @@ class TestGetSetupStatus:
async def test_returns_not_completed_on_fresh_db(self, client: AsyncClient) -> None:
"""Status endpoint reports setup not done on a fresh database."""
response = await client.get("/api/setup")
response = await client.get("/api/v1/setup")
assert response.status_code == 200
assert response.json() == {"completed": False}
async def test_returns_completed_after_setup(self, client: AsyncClient) -> None:
"""Status endpoint reports setup done after POST /api/setup."""
await client.post(
"/api/setup",
"/api/v1/setup",
json={
"master_password": "Supersecret1!",
"database_path": "bangui.db",
@@ -85,7 +85,7 @@ class TestGetSetupStatus:
"session_duration_minutes": 60,
},
)
response = await client.get("/api/setup")
response = await client.get("/api/v1/setup")
assert response.status_code == 200
assert response.json() == {"completed": True}
@@ -96,7 +96,7 @@ class TestPostSetup:
async def test_accepts_valid_payload(self, client: AsyncClient) -> None:
"""Setup endpoint returns 201 for a valid first-run payload."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={
"master_password": "Supersecret1!",
"database_path": "bangui.db",
@@ -112,7 +112,7 @@ class TestPostSetup:
async def test_rejects_short_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords shorter than 8 characters."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "short"},
)
assert response.status_code == 422
@@ -120,7 +120,7 @@ class TestPostSetup:
async def test_rejects_missing_uppercase_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords missing an uppercase character."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "lowercase1!"},
)
assert response.status_code == 422
@@ -132,7 +132,7 @@ class TestPostSetup:
async def test_rejects_missing_number_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords missing a numeric character."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "NoNumbers!"},
)
assert response.status_code == 422
@@ -146,7 +146,7 @@ class TestPostSetup:
) -> None:
"""Setup endpoint rejects passwords missing a required special character."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "NoSpecial1"},
)
assert response.status_code == 422
@@ -164,10 +164,10 @@ class TestPostSetup:
"timezone": "UTC",
"session_duration_minutes": 60,
}
first = await client.post("/api/setup", json=payload)
first = await client.post("/api/v1/setup", json=payload)
assert first.status_code == 201
second = await client.post("/api/setup", json=payload)
second = await client.post("/api/v1/setup", json=payload)
assert second.status_code == 409
async def test_accepts_defaults_for_optional_fields(
@@ -175,7 +175,7 @@ class TestPostSetup:
) -> None:
"""Setup endpoint uses defaults when optional fields are omitted."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "Supersecret1!"},
)
assert response.status_code == 201
@@ -195,7 +195,7 @@ class TestPostSetupRuntimeState:
"session_duration_minutes": 90,
}
response = await client.post("/api/setup", json=payload)
response = await client.post("/api/v1/setup", json=payload)
assert response.status_code == 201
assert app.state.runtime_settings is not None
assert app.state.runtime_settings.database_path == payload["database_path"]
@@ -213,30 +213,30 @@ class TestSetupRedirectMiddleware:
) -> None:
"""Non-setup API requests redirect to /api/setup on a fresh instance."""
response = await client.get(
"/api/auth/login",
"/api/v1/auth/login",
follow_redirects=False,
)
# Middleware issues 307 redirect to /api/setup
assert response.status_code == 307
assert response.headers["location"] == "/api/setup"
assert response.headers["location"] == "/api/v1/setup"
async def test_health_always_reachable_before_setup(
self, client: AsyncClient
) -> None:
"""Health endpoint is always reachable even before setup."""
response = await client.get("/api/health")
response = await client.get("/api/v1/health")
assert response.status_code == 200
async def test_no_redirect_after_setup(self, client: AsyncClient) -> None:
"""Protected endpoints are reachable (no redirect) after setup."""
await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "Supersecret1!"},
)
# /api/auth/login should now be reachable (returns 405 GET not allowed,
# not a setup redirect)
response = await client.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": "wrong"},
follow_redirects=False,
)
@@ -249,20 +249,20 @@ class TestGetTimezone:
async def test_returns_utc_before_setup(self, client: AsyncClient) -> None:
"""Timezone endpoint returns 'UTC' on a fresh database (no setup yet)."""
response = await client.get("/api/setup/timezone")
response = await client.get("/api/v1/setup/timezone")
assert response.status_code == 200
assert response.json() == {"timezone": "UTC"}
async def test_returns_configured_timezone(self, client: AsyncClient) -> None:
"""Timezone endpoint returns the value set during setup."""
await client.post(
"/api/setup",
"/api/v1/setup",
json={
"master_password": "Supersecret1!",
"timezone": "Europe/Berlin",
},
)
response = await client.get("/api/setup/timezone")
response = await client.get("/api/v1/setup/timezone")
assert response.status_code == 200
assert response.json() == {"timezone": "Europe/Berlin"}
@@ -271,7 +271,7 @@ class TestGetTimezone:
) -> None:
"""Timezone endpoint is reachable before setup (no redirect)."""
response = await client.get(
"/api/setup/timezone",
"/api/v1/setup/timezone",
follow_redirects=False,
)
# Should return 200, not a 307 redirect, because /api/setup paths
@@ -296,7 +296,7 @@ class TestSetupCompleteCaching:
app, client = app_and_client
assert isinstance(app, FastAPI)
resp = await client.post("/api/setup", json=_SETUP_PAYLOAD)
resp = await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
assert resp.status_code == 201
assert app.state.setup_complete_cached is True
@@ -315,8 +315,8 @@ class TestSetupCompleteCaching:
assert isinstance(app, FastAPI)
# Do setup and warm the cache.
await client.post("/api/setup", json=_SETUP_PAYLOAD)
await client.post("/api/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]})
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
await client.post("/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]})
assert app.state.setup_complete_cached is True
call_count = 0
@@ -328,7 +328,7 @@ class TestSetupCompleteCaching:
with patch("app.services.setup_service.is_setup_complete", side_effect=_counting):
await client.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
@@ -510,10 +510,10 @@ class TestSetupRedirectMiddlewareDbNone:
async with AsyncClient(
transport=transport, base_url="http://test"
) as ac:
response = await ac.get("/api/auth/login", follow_redirects=False)
response = await ac.get("/api/v1/auth/login", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/api/setup"
assert response.headers["location"] == "/api/v1/setup"
async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None:
"""Health endpoint is always reachable even when db is not initialised."""
@@ -531,7 +531,7 @@ class TestSetupRedirectMiddlewareDbNone:
async with AsyncClient(
transport=transport, base_url="http://test"
) as ac:
response = await ac.get("/api/health")
response = await ac.get("/api/v1/health")
assert response.status_code == 200