From b27765928aab23a83627bb3073f98edb4c96f79c Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 28 Apr 2026 20:48:00 +0200 Subject: [PATCH] Standardize API response envelopes: use items for collection responses and update tests --- Docs/Backend-Development.md | 1 + backend/app/models/jail.py | 23 +++++++- backend/app/routers/jails.py | 20 +++++-- backend/app/services/config_service.py | 4 +- backend/app/services/jail_config_service.py | 2 +- backend/tests/test_routers/test_config.py | 13 +++-- .../test_routers/test_dependency_injection.py | 2 +- backend/tests/test_routers/test_jails.py | 18 +++--- .../test_services/test_config_file_service.py | 14 ++--- .../test_services/test_config_service.py | 8 +-- frontend/src/api/__tests__/jails.test.ts | 25 +++++++++ frontend/src/api/jails.ts | 7 ++- .../__tests__/ConfigPageLogPath.test.tsx | 6 +- .../__tests__/AssignFilterDialog.test.tsx | 2 +- .../config/__tests__/JailsTab.test.tsx | 2 +- .../hooks/__tests__/useJailBannedIps.test.ts | 2 +- .../hooks/__tests__/useJailConfigs.test.ts | 4 +- .../src/hooks/__tests__/useJailDetail.test.ts | 18 +++--- .../__tests__/JailDetailIgnoreSelf.test.tsx | 12 ++-- frontend/src/types/ban.ts | 13 +---- frontend/src/types/config.ts | 12 ++-- frontend/src/types/jail.ts | 55 +++++++++---------- frontend/src/types/response.ts | 27 +++++++++ 23 files changed, 186 insertions(+), 104 deletions(-) create mode 100644 frontend/src/api/__tests__/jails.test.ts create mode 100644 frontend/src/types/response.ts diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index b3ca77e..3f70bbe 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -224,6 +224,7 @@ async def get_jails(state: JailServiceStateDep) -> JailListResponse: - Use appropriate HTTP status codes: `201` for creation, `204` for deletion with no body, `404` for not found, etc. - Protected endpoints should return `401 Unauthorized` or `403 Forbidden` when the session is invalid or expired; the frontend treats these responses as a session-expiry event and redirects the user to `/login`. - Use **HTTPException** or custom exception handlers — never return error dicts manually. +- All successful responses must use a standardized Pydantic response model. List and collection endpoints should wrap data in `items`, `total`, and optional pagination metadata. Detail endpoints must expose a single domain object under a named field (for example `jail`, `status`, or `settings`). Command endpoints must use a `CommandResponse`-style wrapper with `message` and `success`. - **GET endpoints are read-only — never call `db.commit()` or execute INSERT/UPDATE/DELETE inside a GET handler.** If a GET path produces side-effects (e.g., caching resolved data), that write belongs in a background task, a scheduled flush, or a separate POST endpoint. Users and HTTP caches assume GET is idempotent and non-mutating. ```python diff --git a/backend/app/models/jail.py b/backend/app/models/jail.py index bb08cf5..7eb2a17 100644 --- a/backend/app/models/jail.py +++ b/backend/app/models/jail.py @@ -72,12 +72,33 @@ class JailListResponse(CollectionResponse[JailSummary]): pass +class IgnoreListResponse(CollectionResponse[str]): + """Response for ``GET /api/jails/{name}/ignoreip``. + + Returns the jailed ignore list as a standard collection response. + """ + + pass + + class JailDetailResponse(BaseModel): - """Response for ``GET /api/jails/{name}``.""" + """Response for ``GET /api/jails/{name}``. + + Includes the primary jail object together with supplemental metadata + required by the UI. + """ model_config = ConfigDict(strict=True) jail: Jail + ignore_list: list[str] = Field( + default_factory=list, + description="List of IP addresses and networks currently ignored by the jail.", + ) + ignore_self: bool = Field( + default=False, + description="Whether the jail ignores the server's own IP addresses.", + ) class JailCommandResponse(CommandResponse): diff --git a/backend/app/routers/jails.py b/backend/app/routers/jails.py index e45a4c3..0e6f008 100644 --- a/backend/app/routers/jails.py +++ b/backend/app/routers/jails.py @@ -19,6 +19,7 @@ Provides CRUD and control operations for fail2ban jails: from __future__ import annotations +import asyncio from typing import Annotated from fastapi import APIRouter, Body, HTTPException, Path, status @@ -34,6 +35,7 @@ from app.dependencies import ( from app.models.ban import JailBannedIpsResponse from app.models.jail import ( IgnoreIpRequest, + IgnoreListResponse, JailCommandResponse, JailDetailResponse, JailListResponse, @@ -103,7 +105,16 @@ async def get_jail( HTTPException: 404 when the jail does not exist. HTTPException: 502 when fail2ban is unreachable. """ - return await jail_service.get_jail(socket_path, name) + jail, ignore_list, ignore_self = await asyncio.gather( + jail_service.get_jail(socket_path, name), + jail_service.get_ignore_list(socket_path, name), + jail_service.get_ignore_self(socket_path, name), + ) + return JailDetailResponse( + jail=jail, + ignore_list=ignore_list, + ignore_self=ignore_self, + ) # --------------------------------------------------------------------------- @@ -278,14 +289,14 @@ class _IgnoreSelfRequest(IgnoreIpRequest): @router.get( "/{name}/ignoreip", - response_model=list[str], + response_model=IgnoreListResponse, summary="List the ignore IPs for a jail", ) async def get_ignore_list( _auth: AuthDep, name: _NamePath, socket_path: Fail2BanSocketDep, -) -> list[str]: +) -> IgnoreListResponse: """Return the current ignore list (IP whitelist) for a fail2ban jail. Args: @@ -299,7 +310,8 @@ async def get_ignore_list( HTTPException: 404 when the jail does not exist. HTTPException: 502 when fail2ban is unreachable. """ - return await jail_service.get_ignore_list(socket_path, name) + ignore_list = await jail_service.get_ignore_list(socket_path, name) + return IgnoreListResponse(items=ignore_list, total=len(ignore_list)) @router.post( diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index 6a687cd..c3e1a8f 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -218,7 +218,7 @@ async def list_jail_configs(socket_path: str) -> JailConfigListResponse: ) if not jail_names: - return JailConfigListResponse(jails=[], total=0) + return JailConfigListResponse(items=[], total=0) responses: list[JailConfigResponse] = await asyncio.gather( *[get_jail_config(socket_path, name) for name in jail_names], @@ -227,7 +227,7 @@ async def list_jail_configs(socket_path: str) -> JailConfigListResponse: jails = [r.jail for r in responses] log.info("jail_configs_listed", count=len(jails)) - return JailConfigListResponse(jails=jails, total=len(jails)) + return JailConfigListResponse(items=jails, total=len(jails)) # --------------------------------------------------------------------------- diff --git a/backend/app/services/jail_config_service.py b/backend/app/services/jail_config_service.py index f926189..cca0c8d 100644 --- a/backend/app/services/jail_config_service.py +++ b/backend/app/services/jail_config_service.py @@ -311,7 +311,7 @@ async def list_inactive_jails( active=len(active_names), inactive=len(inactive), ) - return InactiveJailListResponse(jails=inactive, total=len(inactive)) + return InactiveJailListResponse(items=inactive, total=len(inactive)) async def activate_jail( diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index c59d315..899a36c 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -47,6 +47,7 @@ async def config_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] session_duration_minutes=60, timezone="UTC", log_level="debug", + session_cookie_secure=False, ) app = create_app(settings=settings) @@ -98,7 +99,7 @@ class TestGetJailConfigs: async def test_200_returns_jail_list(self, config_client: AsyncClient) -> None: """GET /api/config/jails returns 200 with JailConfigListResponse.""" mock_response = JailConfigListResponse( - jails=[_make_jail_config("sshd")], total=1 + items=[_make_jail_config("sshd")], total=1 ) with patch( "app.routers.jail_config.config_service.list_jail_configs", @@ -109,7 +110,7 @@ class TestGetJailConfigs: assert resp.status_code == 200 data = resp.json() assert data["total"] == 1 - assert data["jails"][0]["name"] == "sshd" + assert data["items"][0]["name"] == "sshd" async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: """GET /api/config/jails returns 401 without a valid session.""" @@ -694,7 +695,7 @@ class TestGetInactiveJails: source_file="/etc/fail2ban/jail.conf", enabled=False, ) - mock_response = InactiveJailListResponse(jails=[mock_jail], total=1) + mock_response = InactiveJailListResponse(items=[mock_jail], total=1) with patch( "app.routers.jail_config.jail_config_service.list_inactive_jails", @@ -705,7 +706,7 @@ class TestGetInactiveJails: assert resp.status_code == 200 data = resp.json() assert data["total"] == 1 - assert data["jails"][0]["name"] == "apache-auth" + assert data["items"][0]["name"] == "apache-auth" async def test_200_empty_list(self, config_client: AsyncClient) -> None: """GET /api/config/jails/inactive returns 200 with empty list.""" @@ -713,13 +714,13 @@ class TestGetInactiveJails: with patch( "app.routers.jail_config.jail_config_service.list_inactive_jails", - AsyncMock(return_value=InactiveJailListResponse(jails=[], total=0)), + AsyncMock(return_value=InactiveJailListResponse(items=[], total=0)), ): resp = await config_client.get("/api/config/jails/inactive") assert resp.status_code == 200 assert resp.json()["total"] == 0 - assert resp.json()["jails"] == [] + assert resp.json()["items"] == [] async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: """GET /api/config/jails/inactive returns 401 without a valid session.""" diff --git a/backend/tests/test_routers/test_dependency_injection.py b/backend/tests/test_routers/test_dependency_injection.py index b2d71d4..b110d42 100644 --- a/backend/tests/test_routers/test_dependency_injection.py +++ b/backend/tests/test_routers/test_dependency_injection.py @@ -66,7 +66,7 @@ class FakeAuthService: class FakeJailService: async def list_jails(self, _socket_path: str) -> JailListResponse: - return JailListResponse(jails=[], total=0) + return JailListResponse(items=[], total=0) async def get_jail(self, _socket_path: str, _name: str) -> JailListResponse: raise NotImplementedError diff --git a/backend/tests/test_routers/test_jails.py b/backend/tests/test_routers/test_jails.py index 4e29a5f..67b9733 100644 --- a/backend/tests/test_routers/test_jails.py +++ b/backend/tests/test_routers/test_jails.py @@ -34,7 +34,7 @@ async def jails_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] settings = Settings( database_path=str(tmp_path / "jails_test.db"), fail2ban_socket="/tmp/fake.sock", - session_secret="test-jails-secret", + session_secret="test-jails-secret-0000000000000000000000", session_duration_minutes=60, timezone="UTC", log_level="debug", @@ -108,7 +108,9 @@ def _detail(name: str = "sshd") -> JailDetailResponse: currently_failed=1, total_failed=50, ), - ) + ), + ignore_list=["127.0.0.1"], + ignore_self=False, ) @@ -122,7 +124,7 @@ class TestGetJails: async def test_200_when_authenticated(self, jails_client: AsyncClient) -> None: """GET /api/jails returns 200 with a JailListResponse.""" - mock_response = JailListResponse(jails=[_summary()], total=1) + mock_response = JailListResponse(items=[_summary()], total=1) with patch( "app.routers.jails.jail_service.list_jails", AsyncMock(return_value=mock_response), @@ -132,7 +134,7 @@ class TestGetJails: assert resp.status_code == 200 data = resp.json() assert data["total"] == 1 - assert data["jails"][0]["name"] == "sshd" + assert data["items"][0]["name"] == "sshd" async def test_401_when_unauthenticated(self, jails_client: AsyncClient) -> None: """GET /api/jails returns 401 without a session cookie.""" @@ -144,14 +146,14 @@ class TestGetJails: async def test_response_shape(self, jails_client: AsyncClient) -> None: """GET /api/jails response contains expected fields.""" - mock_response = JailListResponse(jails=[_summary()], total=1) + mock_response = JailListResponse(items=[_summary()], total=1) with patch( "app.routers.jails.jail_service.list_jails", AsyncMock(return_value=mock_response), ): resp = await jails_client.get("/api/jails") - jail = resp.json()["jails"][0] + jail = resp.json()["items"][0] assert "name" in jail assert "enabled" in jail assert "running" in jail @@ -359,7 +361,7 @@ class TestIgnoreIpEndpoints: """Tests for ignore-list management endpoints.""" async def test_get_ignore_list(self, jails_client: AsyncClient) -> None: - """GET /api/jails/sshd/ignoreip returns 200 with a list.""" + """GET /api/jails/sshd/ignoreip returns 200 with a wrapped response.""" with patch( "app.routers.jails.jail_service.get_ignore_list", AsyncMock(return_value=["127.0.0.1"]), @@ -367,7 +369,7 @@ class TestIgnoreIpEndpoints: resp = await jails_client.get("/api/jails/sshd/ignoreip") assert resp.status_code == 200 - assert "127.0.0.1" in resp.json() + assert resp.json() == {"items": ["127.0.0.1"], "total": 1} async def test_add_ignore_ip_returns_201(self, jails_client: AsyncClient) -> None: """POST /api/jails/sshd/ignoreip returns 201 on success.""" diff --git a/backend/tests/test_services/test_config_file_service.py b/backend/tests/test_services/test_config_file_service.py index 667ebc7..9552281 100644 --- a/backend/tests/test_services/test_config_file_service.py +++ b/backend/tests/test_services/test_config_file_service.py @@ -381,7 +381,7 @@ class TestListInactiveJails: ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") - names = [j.name for j in result.jails] + names = [j.name for j in result.items] assert "sshd" not in names assert "apache-auth" in names @@ -393,7 +393,7 @@ class TestListInactiveJails: ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") - assert result.total == len(result.jails) + assert result.total == len(result.items) async def test_empty_config_dir(self, tmp_path: Path) -> None: with patch( @@ -402,7 +402,7 @@ class TestListInactiveJails: ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") - assert result.jails == [] + assert result.items == [] assert result.total == 0 async def test_all_active_returns_empty(self, tmp_path: Path) -> None: @@ -413,7 +413,7 @@ class TestListInactiveJails: ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") - assert result.jails == [] + assert result.items == [] async def test_fail2ban_unreachable_shows_all(self, tmp_path: Path) -> None: # When fail2ban is unreachable, _get_active_jail_names returns empty set, @@ -425,7 +425,7 @@ class TestListInactiveJails: ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") - names = {j.name for j in result.jails} + names = {j.name for j in result.items} assert "sshd" in names assert "apache-auth" in names @@ -440,7 +440,7 @@ class TestListInactiveJails: new=AsyncMock(return_value=set()), ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") - jail = next(j for j in result.jails if j.name == "apache-auth") + jail = next(j for j in result.items if j.name == "apache-auth") assert jail.has_local_override is True async def test_has_local_override_false_when_no_local_file(self, tmp_path: Path) -> None: @@ -451,7 +451,7 @@ class TestListInactiveJails: new=AsyncMock(return_value=set()), ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") - jail = next(j for j in result.jails if j.name == "apache-auth") + jail = next(j for j in result.items if j.name == "apache-auth") assert jail.has_local_override is False diff --git a/backend/tests/test_services/test_config_service.py b/backend/tests/test_services/test_config_service.py index 0cb7d3e..a12161d 100644 --- a/backend/tests/test_services/test_config_service.py +++ b/backend/tests/test_services/test_config_service.py @@ -220,7 +220,7 @@ class TestListJailConfigs: assert isinstance(result, JailConfigListResponse) assert result.total == 1 - assert result.jails[0].name == "sshd" + assert result.items[0].name == "sshd" async def test_empty_when_no_jails(self) -> None: """list_jail_configs returns empty list when no jails are active.""" @@ -229,7 +229,7 @@ class TestListJailConfigs: result = await config_service.list_jail_configs(_SOCKET) assert result.total == 0 - assert result.jails == [] + assert result.items == [] async def test_multiple_jails(self) -> None: """list_jail_configs handles comma-separated jail names.""" @@ -245,7 +245,7 @@ class TestListJailConfigs: result = await config_service.list_jail_configs(_SOCKET) assert result.total == 2 - names = {j.name for j in result.jails} + names = {j.name for j in result.items} assert names == {"sshd", "nginx"} @@ -887,7 +887,7 @@ class TestConfigModuleIntegration: ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") - names = {j.name for j in result.jails} + names = {j.name for j in result.items} assert "apache-auth" in names assert "sshd" not in names diff --git a/frontend/src/api/__tests__/jails.test.ts b/frontend/src/api/__tests__/jails.test.ts new file mode 100644 index 0000000..0032e5b --- /dev/null +++ b/frontend/src/api/__tests__/jails.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import { ENDPOINTS } from "../endpoints"; +import { fetchIgnoreList } from "../jails"; +import { get } from "../client"; + +vi.mock("../client", () => ({ + get: vi.fn(), +})); + +const mockedGet = get as Mock; + +describe("fetchIgnoreList", () => { + beforeEach(() => { + mockedGet.mockReset(); + mockedGet.mockResolvedValue({ items: ["127.0.0.1"], total: 1 }); + }); + + it("requests the jail ignore list endpoint and returns the wrapped response", async () => { + const response = await fetchIgnoreList("sshd"); + + expect(get).toHaveBeenCalledWith(ENDPOINTS.jailIgnoreIp("sshd")); + expect(response).toEqual({ items: ["127.0.0.1"], total: 1 }); + }); +}); diff --git a/frontend/src/api/jails.ts b/frontend/src/api/jails.ts index de49a92..693d28b 100644 --- a/frontend/src/api/jails.ts +++ b/frontend/src/api/jails.ts @@ -9,6 +9,7 @@ import { del, get, post } from "./client"; import { ENDPOINTS } from "./endpoints"; import type { ActiveBanListResponse, + IgnoreListResponse, IpLookupResponse, JailBannedIpsResponse, JailCommandResponse, @@ -112,11 +113,11 @@ export async function reloadAllJails(): Promise { * Return the ignore list for a jail. * * @param name - Jail name. - * @returns Array of IP addresses / CIDR networks on the ignore list. + * @returns The {@link IgnoreListResponse} wrapper containing the ignore list. * @throws {ApiError} On non-2xx responses. */ -export async function fetchIgnoreList(name: string): Promise { - return get(ENDPOINTS.jailIgnoreIp(name)); +export async function fetchIgnoreList(name: string): Promise { + return get(ENDPOINTS.jailIgnoreIp(name)); } /** diff --git a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx index 7dab04d..66cc8f9 100644 --- a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx +++ b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx @@ -107,7 +107,7 @@ vi.mock("../../api/config", () => ({ createActionFile: vi.fn(), previewLog: vi.fn(), testRegex: vi.fn(), - fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [], total: 0 }), + fetchInactiveJails: vi.fn().mockResolvedValue({ items: [], total: 0 }), activateJail: vi.fn(), deactivateJail: vi.fn(), fetchParsedFilter: vi.fn(), @@ -139,7 +139,7 @@ vi.mock("../../api/config", () => ({ })); vi.mock("../../api/jails", () => ({ - fetchJails: vi.fn().mockResolvedValue({ jails: [], total: 0 }), + fetchJails: vi.fn().mockResolvedValue({ items: [], total: 0 }), })); /** Minimal jail fixture used across tests. */ @@ -185,7 +185,7 @@ async function openSshdAccordion(user: ReturnType) { describe("ConfigPage — Add Log Path", () => { beforeEach(() => { vi.clearAllMocks(); - mockFetchJailConfigs.mockResolvedValue({ jails: [MOCK_JAIL], total: 1 }); + mockFetchJailConfigs.mockResolvedValue({ items: [MOCK_JAIL], total: 1 }); mockAddLogPath.mockResolvedValue(undefined); }); diff --git a/frontend/src/components/config/__tests__/AssignFilterDialog.test.tsx b/frontend/src/components/config/__tests__/AssignFilterDialog.test.tsx index ddf7bc9..dcf3df9 100644 --- a/frontend/src/components/config/__tests__/AssignFilterDialog.test.tsx +++ b/frontend/src/components/config/__tests__/AssignFilterDialog.test.tsx @@ -36,7 +36,7 @@ const mockFetchJails = vi.mocked(fetchJails); // --------------------------------------------------------------------------- const mockJailsResponse: JailListResponse = { - jails: [ + items: [ { name: "sshd", enabled: true, diff --git a/frontend/src/components/config/__tests__/JailsTab.test.tsx b/frontend/src/components/config/__tests__/JailsTab.test.tsx index 1ac26f0..0ceedb4 100644 --- a/frontend/src/components/config/__tests__/JailsTab.test.tsx +++ b/frontend/src/components/config/__tests__/JailsTab.test.tsx @@ -12,7 +12,7 @@ vi.mock("../../../hooks/useAutoSave"); vi.mock("../../../hooks/useJailConfigs"); vi.mock("../../../hooks/useConfigActiveStatus"); vi.mock("../../../api/config", () => ({ - fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [] }), + fetchInactiveJails: vi.fn().mockResolvedValue({ items: [] }), deactivateJail: vi.fn(), deleteJailLocalOverride: vi.fn(), addLogPath: vi.fn(), diff --git a/frontend/src/hooks/__tests__/useJailBannedIps.test.ts b/frontend/src/hooks/__tests__/useJailBannedIps.test.ts index eed20e1..2dd313b 100644 --- a/frontend/src/hooks/__tests__/useJailBannedIps.test.ts +++ b/frontend/src/hooks/__tests__/useJailBannedIps.test.ts @@ -11,7 +11,7 @@ describe("useJailBannedIps", () => { const unbanMock = vi.mocked(api.unbanIp); fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US" }], total: 1, page: 1, page_size: 25 }); - unbanMock.mockResolvedValue({ message: "ok", jail: "sshd" }); + unbanMock.mockResolvedValue({ message: "ok", jail: "sshd", success: true }); const { result } = renderHook(() => useJailBannedIps("sshd")); await waitFor(() => { diff --git a/frontend/src/hooks/__tests__/useJailConfigs.test.ts b/frontend/src/hooks/__tests__/useJailConfigs.test.ts index 646516e..2fc088b 100644 --- a/frontend/src/hooks/__tests__/useJailConfigs.test.ts +++ b/frontend/src/hooks/__tests__/useJailConfigs.test.ts @@ -12,7 +12,7 @@ describe("useJailConfigs", () => { it("calls fetchJailConfigs only once on mount", async () => { vi.mocked(configApi.fetchJailConfigs).mockResolvedValue({ - jails: [ + items: [ { name: "sshd", ban_time: 600, @@ -49,7 +49,7 @@ describe("useJailConfigs", () => { it("does not trigger infinite refetch with stable onSuccess", async () => { vi.mocked(configApi.fetchJailConfigs).mockResolvedValue({ - jails: [], + items: [], total: 0, }); diff --git a/frontend/src/hooks/__tests__/useJailDetail.test.ts b/frontend/src/hooks/__tests__/useJailDetail.test.ts index 616cc32..8654612 100644 --- a/frontend/src/hooks/__tests__/useJailDetail.test.ts +++ b/frontend/src/hooks/__tests__/useJailDetail.test.ts @@ -10,12 +10,14 @@ vi.mock("../../api/jails"); const mockJail: Jail = { name: "sshd", + enabled: true, running: true, idle: false, backend: "pyinotify", log_paths: ["/var/log/auth.log"], - fail_regex: ["^\\[.*\\]\\s.*Failed password"], + fail_regex: ["^\[.*\]\s.*Failed password"], ignore_regex: [], + ignore_ips: [], date_pattern: "%b %d %H:%M:%S", log_encoding: "UTF-8", actions: [], @@ -87,13 +89,13 @@ describe("useJailCommands — write operations", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(jailsApi.startJail).mockResolvedValue({ message: "ok", jail: "sshd" }); - vi.mocked(jailsApi.stopJail).mockResolvedValue({ message: "ok", jail: "sshd" }); - vi.mocked(jailsApi.reloadJail).mockResolvedValue({ message: "ok", jail: "sshd" }); - vi.mocked(jailsApi.setJailIdle).mockResolvedValue({ message: "ok", jail: "sshd" }); - vi.mocked(jailsApi.addIgnoreIp).mockResolvedValue({ message: "ok", jail: "sshd" }); - vi.mocked(jailsApi.delIgnoreIp).mockResolvedValue({ message: "ok", jail: "sshd" }); - vi.mocked(jailsApi.toggleIgnoreSelf).mockResolvedValue({ message: "ok", jail: "sshd" }); + vi.mocked(jailsApi.startJail).mockResolvedValue({ message: "ok", jail: "sshd", success: true }); + vi.mocked(jailsApi.stopJail).mockResolvedValue({ message: "ok", jail: "sshd", success: true }); + vi.mocked(jailsApi.reloadJail).mockResolvedValue({ message: "ok", jail: "sshd", success: true }); + vi.mocked(jailsApi.setJailIdle).mockResolvedValue({ message: "ok", jail: "sshd", success: true }); + vi.mocked(jailsApi.addIgnoreIp).mockResolvedValue({ message: "ok", jail: "sshd", success: true }); + vi.mocked(jailsApi.delIgnoreIp).mockResolvedValue({ message: "ok", jail: "sshd", success: true }); + vi.mocked(jailsApi.toggleIgnoreSelf).mockResolvedValue({ message: "ok", jail: "sshd", success: true }); }); it("calls start() API and invokes onSuccess", async () => { diff --git a/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx b/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx index d454a83..bea55e3 100644 --- a/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx +++ b/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx @@ -69,11 +69,11 @@ vi.mock("../../hooks/useJailBannedIps", () => ({ // Mock API functions used by JailInfoSection control buttons to avoid side effects. vi.mock("../../api/jails", () => ({ - startJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }), - stopJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }), - reloadJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }), - setJailIdle: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }), - toggleIgnoreSelf: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }), + startJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd", success: true }), + stopJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd", success: true }), + reloadJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd", success: true }), + setJailIdle: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd", success: true }), + toggleIgnoreSelf: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd", success: true }), })); // Stub BannedIpsSection to prevent its own fetchJailBannedIps calls. @@ -92,12 +92,14 @@ import { useJailCommands } from "../../hooks/useJailCommands"; function makeJail(): Jail { return { name: "sshd", + enabled: true, running: true, idle: false, backend: "systemd", log_paths: ["/var/log/auth.log"], fail_regex: ["^Failed .+ from "], ignore_regex: [], + ignore_ips: [], date_pattern: "", log_encoding: "UTF-8", actions: ["iptables-multiport"], diff --git a/frontend/src/types/ban.ts b/frontend/src/types/ban.ts index a104757..4269350 100644 --- a/frontend/src/types/ban.ts +++ b/frontend/src/types/ban.ts @@ -4,6 +4,8 @@ * `backend/app/models/ban.py` — dashboard dashboard sections. */ +import type { PaginatedListResponse } from "./response"; + // --------------------------------------------------------------------------- // Time-range selector // --------------------------------------------------------------------------- @@ -57,16 +59,7 @@ export interface DashboardBanItem { * * Mirrors `DashboardBanListResponse` from `backend/app/models/ban.py`. */ -export interface DashboardBanListResponse { - /** Ban items for the current page. */ - items: DashboardBanItem[]; - /** Total number of bans in the selected time window. */ - total: number; - /** Current 1-based page number. */ - page: number; - /** Maximum items per page. */ - page_size: number; -} +export interface DashboardBanListResponse extends PaginatedListResponse {} // --------------------------------------------------------------------------- // Ban trend diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 55cd016..b98302f 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -2,6 +2,8 @@ * TypeScript interfaces for the configuration and server settings API. */ +import type { CollectionResponse } from "./response"; + // --------------------------------------------------------------------------- // Ban-time escalation // --------------------------------------------------------------------------- @@ -66,10 +68,7 @@ export interface JailConfigResponse { jail: JailConfig; } -export interface JailConfigListResponse { - items: JailConfig[]; - total: number; -} +export interface JailConfigListResponse extends CollectionResponse {} export interface JailConfigUpdate { ban_time?: number | null; @@ -535,10 +534,7 @@ export interface InactiveJail { has_local_override: boolean; } -export interface InactiveJailListResponse { - items: InactiveJail[]; - total: number; -} +export interface InactiveJailListResponse extends CollectionResponse {} /** * Optional override values when activating an inactive jail. diff --git a/frontend/src/types/jail.ts b/frontend/src/types/jail.ts index 6cb1bd8..5af5f5a 100644 --- a/frontend/src/types/jail.ts +++ b/frontend/src/types/jail.ts @@ -7,7 +7,16 @@ * - `backend/app/models/geo.py` (GeoDetail / IpLookupResponse) */ -import type { BantimeEscalation, BackendType, LogEncoding } from "./config"; +import type { + BantimeEscalation, + BackendType, + LogEncoding, +} from "./config"; +import type { + CollectionResponse, + PaginatedListResponse, + CommandResponse, +} from "./response"; // --------------------------------------------------------------------------- // Jail statistics @@ -64,12 +73,14 @@ export interface JailSummary { * * Mirrors `JailListResponse` from `backend/app/models/jail.py`. */ -export interface JailListResponse { - /** All known jails. */ - items: JailSummary[]; - /** Total number of jails. */ - total: number; -} +export interface JailListResponse extends CollectionResponse {} + +/** + * Response from `GET /api/jails/{name}/ignoreip`. + * + * Mirrors `IgnoreListResponse` from `backend/app/models/jail.py`. + */ +export interface IgnoreListResponse extends CollectionResponse {} // --------------------------------------------------------------------------- // Jail detail @@ -83,6 +94,8 @@ export interface JailListResponse { export interface Jail { /** Machine-readable jail name. */ name: string; + /** Whether the jail is enabled in the configuration. */ + enabled: boolean; /** Whether the jail is running. */ running: boolean; /** Whether the jail is in idle mode. */ @@ -95,8 +108,10 @@ export interface Jail { fail_regex: string[]; /** Ignore-regex patterns used to whitelist log lines. */ ignore_regex: string[]; - /** Date-pattern used for timestamp parsing, or empty string. */ - date_pattern: string; + /** IP addresses and networks explicitly ignored by the jail. */ + ignore_ips: string[]; + /** Date-pattern used for timestamp parsing, or `null` when unset. */ + date_pattern: string | null; /** Log file encoding (e.g. `"UTF-8"`). */ log_encoding: LogEncoding; /** Action names attached to this jail. */ @@ -136,9 +151,7 @@ export interface JailDetailResponse { * * Mirrors `JailCommandResponse` from `backend/app/models/jail.py`. */ -export interface JailCommandResponse { - /** Human-readable result message. */ - message: string; +export interface JailCommandResponse extends CommandResponse { /** Target jail name, or `"*"` for operations on all jails. */ jail: string; } @@ -172,12 +185,7 @@ export interface ActiveBan { * * Mirrors `ActiveBanListResponse` from `backend/app/models/ban.py`. */ -export interface ActiveBanListResponse { - /** List of all currently active bans. */ - items: ActiveBan[]; - /** Total number of active bans. */ - total: number; -} +export interface ActiveBanListResponse extends CollectionResponse {} // --------------------------------------------------------------------------- // Geo / IP lookup @@ -207,16 +215,7 @@ export interface UnbanAllResponse { * * Mirrors `JailBannedIpsResponse` from `backend/app/models/ban.py`. */ -export interface JailBannedIpsResponse { - /** Active ban entries for the current page. */ - items: ActiveBan[]; - /** Total matching entries (after applying any search filter). */ - total: number; - /** Current page number (1-based). */ - page: number; - /** Number of items per page. */ - page_size: number; -} +export interface JailBannedIpsResponse extends PaginatedListResponse {} export interface GeoDetail { /** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */ diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts new file mode 100644 index 0000000..1f189e3 --- /dev/null +++ b/frontend/src/types/response.ts @@ -0,0 +1,27 @@ +/** + * Standardized API response envelope types. + * + * These wrappers mirror the backend response envelope contract defined in + * `backend/app/models/response.py`. + */ + +export interface CollectionResponse { + /** Data items in the collection. */ + items: T[]; + /** Total number of items in the collection. */ + total: number; +} + +export interface PaginatedListResponse extends CollectionResponse { + /** Current page number (1-based). */ + page: number; + /** Number of items per page. */ + page_size: number; +} + +export interface CommandResponse { + /** Human-readable result message. */ + message: string; + /** Whether the command succeeded. */ + success: boolean; +}