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

@@ -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