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:
@@ -16,6 +16,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.server import ServerStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -63,6 +64,10 @@ async def client(test_settings: Settings) -> AsyncClient: # type: ignore[misc]
|
||||
"""
|
||||
app = create_app(settings=test_settings)
|
||||
|
||||
# Ensure fail2ban is reported as online for tests (mock socket is not
|
||||
# actually connected so we need to set the cached status manually).
|
||||
app.state.server_status = ServerStatus(online=True)
|
||||
|
||||
# Bootstrap the database schema before making requests. ASGITransport
|
||||
# does not run the application lifespan, so we create the test SQLite file
|
||||
# directly rather than relying on startup logic.
|
||||
|
||||
@@ -27,7 +27,7 @@ def test_correlation_middleware_generates_uuid_when_header_absent() -> None:
|
||||
|
||||
# Test with TestClient (synchronous)
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
response = client.get("/api/v1/health")
|
||||
|
||||
# Should have correlation ID header in response
|
||||
assert "X-Correlation-ID" in response.headers
|
||||
@@ -53,7 +53,7 @@ def test_correlation_middleware_preserves_header_from_request() -> None:
|
||||
|
||||
client = TestClient(app)
|
||||
test_correlation_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
response = client.get("/api/health", headers={"X-Correlation-ID": test_correlation_id})
|
||||
response = client.get("/api/v1/health", headers={"X-Correlation-ID": test_correlation_id})
|
||||
|
||||
# Should return the same correlation ID in response
|
||||
assert response.headers["X-Correlation-ID"] == test_correlation_id
|
||||
@@ -76,7 +76,7 @@ def test_correlation_middleware_stores_in_request_state() -> None:
|
||||
|
||||
# Make a request and verify correlation ID is available to handlers
|
||||
test_correlation_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
response = client.get("/api/health", headers={"X-Correlation-ID": test_correlation_id})
|
||||
response = client.get("/api/v1/health", headers={"X-Correlation-ID": test_correlation_id})
|
||||
|
||||
# The health endpoint should return 200, proving the correlation ID was processed
|
||||
assert response.status_code == 200
|
||||
@@ -100,11 +100,11 @@ def test_correlation_id_in_response_headers() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
# Test without providing header (should generate one)
|
||||
response = client.get("/api/health")
|
||||
response = client.get("/api/v1/health")
|
||||
assert "X-Correlation-ID" in response.headers
|
||||
|
||||
# Test with providing header (should preserve it)
|
||||
test_id = "test-correlation-id-12345"
|
||||
response = client.get("/api/health", headers={"X-Correlation-ID": test_id})
|
||||
response = client.get("/api/v1/health", headers={"X-Correlation-ID": test_id})
|
||||
assert response.headers["X-Correlation-ID"] == test_id
|
||||
|
||||
|
||||
@@ -504,7 +504,7 @@ async def test_concurrent_requests_use_request_scoped_db_connections(tmp_path: P
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
app.state.setup_complete_cached = True
|
||||
responses = await asyncio.gather(*(client.post("/api/auth/logout") for _ in range(5)))
|
||||
responses = await asyncio.gather(*(client.post("/api/v1/auth/logout") for _ in range(5)))
|
||||
|
||||
assert len(connections) == 5
|
||||
assert len({id(connection) for connection in connections}) == 5
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
|
||||
@@ -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}}},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_security_headers_middleware_adds_csp_header() -> None:
|
||||
|
||||
app = create_app(settings=settings)
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
response = client.get("/api/v1/health")
|
||||
|
||||
assert "Content-Security-Policy" in response.headers
|
||||
assert response.headers["Content-Security-Policy"] == "default-src 'self'"
|
||||
@@ -40,7 +40,7 @@ def test_security_headers_middleware_adds_x_frame_options() -> None:
|
||||
|
||||
app = create_app(settings=settings)
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
response = client.get("/api/v1/health")
|
||||
|
||||
assert "X-Frame-Options" in response.headers
|
||||
assert response.headers["X-Frame-Options"] == "DENY"
|
||||
@@ -60,7 +60,7 @@ def test_security_headers_middleware_adds_x_content_type_options() -> None:
|
||||
|
||||
app = create_app(settings=settings)
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
response = client.get("/api/v1/health")
|
||||
|
||||
assert "X-Content-Type-Options" in response.headers
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
@@ -80,7 +80,7 @@ def test_security_headers_middleware_adds_x_xss_protection() -> None:
|
||||
|
||||
app = create_app(settings=settings)
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
response = client.get("/api/v1/health")
|
||||
|
||||
assert "X-XSS-Protection" in response.headers
|
||||
assert response.headers["X-XSS-Protection"] == "1; mode=block"
|
||||
@@ -102,7 +102,7 @@ def test_security_headers_on_all_response_types() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
# Test on successful response
|
||||
response = client.get("/api/health")
|
||||
response = client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
assert "Content-Security-Policy" in response.headers
|
||||
assert "X-Frame-Options" in response.headers
|
||||
|
||||
@@ -17,7 +17,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
|
||||
|
||||
|
||||
@@ -146,11 +146,11 @@ class TestRateLimitMiddleware:
|
||||
try:
|
||||
# First 3 requests should succeed
|
||||
for i in range(3):
|
||||
response = await client.get("/api/health")
|
||||
response = await client.get("/api/v1/health")
|
||||
assert response.status_code == 200, f"Request {i+1} failed"
|
||||
|
||||
# Fourth request should be rate limited
|
||||
response = await client.get("/api/health")
|
||||
response = await client.get("/api/v1/health")
|
||||
assert response.status_code == 429
|
||||
assert response.json()["code"] == "rate_limit_exceeded"
|
||||
assert "Retry-After" in response.headers
|
||||
@@ -169,11 +169,11 @@ class TestRateLimitMiddleware:
|
||||
|
||||
try:
|
||||
# First request succeeds
|
||||
response = await client.get("/api/health")
|
||||
response = await client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Second request is rate limited
|
||||
response = await client.get("/api/health")
|
||||
response = await client.get("/api/v1/health")
|
||||
assert response.status_code == 429
|
||||
assert "Retry-After" in response.headers
|
||||
retry_after = int(response.headers["Retry-After"])
|
||||
|
||||
Reference in New Issue
Block a user