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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user