feat: implement API versioning /api/v1/

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

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

View File

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