This commit standardizes how API responses are wrapped, solving issue #24. Problem: - Inconsistent response envelopes (jails vs items vs bans vs no wrapper) - Frontend required multiple field name variants - Integration bugs from branching logic - No clear pattern for different response types Solution: - Created response.py with base classes: PaginatedListResponse, CollectionResponse, CommandResponse - Standardized all list/collection responses to use 'items' field - Domain-specific field names for detail and aggregation responses - Updated all backends routers and mappers - Updated frontend types and hooks to match Changes: Backend: - backend/app/models/response.py (new): Base response models - backend/app/models/ban.py: Updated responses to inherit from bases - backend/app/models/jail.py: Updated JailListResponse, JailCommandResponse - backend/app/models/config.py: Updated collection responses - backend/app/services/jail_service.py: Updated return statements - backend/app/mappers/ban_mappers.py: Updated 'bans' to 'items' - backend/tests/test_mappers/test_ban_mappers.py: Updated tests Frontend: - frontend/src/types/jail.ts: Updated response interfaces - frontend/src/types/config.ts: Updated response interfaces - frontend/src/hooks/useActiveBans.ts: Updated selector - frontend/src/hooks/useJailList.ts: Updated selector - frontend/src/hooks/useJailConfigs.ts: Updated selector - frontend/src/hooks/useConfigActiveStatus.ts: Updated field access - frontend/src/hooks/useJailAdmin.ts: Updated field access Documentation: - Docs/Backend-Development.md: Added § 4.1 API Response Envelope Policy The policy defines: 1. Paginated lists use PaginatedListResponse (items, total, page, page_size) 2. Non-paginated collections use CollectionResponse (items, total) 3. Detail responses use entity-specific field names (jail, status, settings) 4. Command responses use CommandResponse (message, success, optional target) 5. Aggregations use domain-specific fields (jails, countries, buckets, bans) All responses now follow one of these patterns, reducing frontend complexity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
221 lines
7.1 KiB
Python
221 lines
7.1 KiB
Python
"""Tests for ban response mappers."""
|
|
|
|
from app.mappers import (
|
|
map_domain_active_ban_list_to_response,
|
|
map_domain_active_ban_to_response,
|
|
map_domain_bans_by_country_to_response,
|
|
map_domain_bans_by_jail_to_response,
|
|
map_domain_ban_trend_to_response,
|
|
map_domain_dashboard_ban_item_to_response,
|
|
map_domain_dashboard_ban_list_to_response,
|
|
)
|
|
from app.models.ban_domain import (
|
|
DomainActiveBan,
|
|
DomainActiveBanList,
|
|
DomainBansByCountry,
|
|
DomainBansByJail,
|
|
DomainBanTrend,
|
|
DomainBanTrendBucket,
|
|
DomainDashboardBanItem,
|
|
DomainDashboardBanList,
|
|
DomainJailBanCount,
|
|
)
|
|
|
|
|
|
class TestActiveBanMapper:
|
|
"""Test mapping from DomainActiveBan to ActiveBan."""
|
|
|
|
def test_maps_all_fields(self) -> None:
|
|
"""All fields are correctly mapped."""
|
|
domain_ban = DomainActiveBan(
|
|
ip="192.168.1.1",
|
|
jail="sshd",
|
|
banned_at="2026-04-28T07:00:00+00:00",
|
|
expires_at="2026-04-28T08:00:00+00:00",
|
|
ban_count=3,
|
|
country="DE",
|
|
)
|
|
|
|
result = map_domain_active_ban_to_response(domain_ban)
|
|
|
|
assert result.ip == "192.168.1.1"
|
|
assert result.jail == "sshd"
|
|
assert result.banned_at == "2026-04-28T07:00:00+00:00"
|
|
assert result.expires_at == "2026-04-28T08:00:00+00:00"
|
|
assert result.ban_count == 3
|
|
assert result.country == "DE"
|
|
|
|
def test_handles_null_timestamps(self) -> None:
|
|
"""Null timestamps are preserved."""
|
|
domain_ban = DomainActiveBan(
|
|
ip="10.0.0.1",
|
|
jail="test",
|
|
banned_at=None,
|
|
expires_at=None,
|
|
)
|
|
|
|
result = map_domain_active_ban_to_response(domain_ban)
|
|
|
|
assert result.banned_at is None
|
|
assert result.expires_at is None
|
|
|
|
|
|
class TestActiveBanListMapper:
|
|
"""Test mapping from DomainActiveBanList to ActiveBanListResponse."""
|
|
|
|
def test_maps_list_and_total(self) -> None:
|
|
"""List and total are correctly mapped."""
|
|
domain_list = DomainActiveBanList(
|
|
bans=[
|
|
DomainActiveBan(ip="1.1.1.1", jail="sshd", ban_count=1),
|
|
DomainActiveBan(ip="2.2.2.2", jail="httpd", ban_count=2),
|
|
],
|
|
total=2,
|
|
)
|
|
|
|
result = map_domain_active_ban_list_to_response(domain_list)
|
|
|
|
assert result.total == 2
|
|
assert len(result.items) == 2
|
|
assert result.items[0].ip == "1.1.1.1"
|
|
assert result.items[1].ip == "2.2.2.2"
|
|
|
|
def test_handles_empty_list(self) -> None:
|
|
"""Empty list is handled correctly."""
|
|
domain_list = DomainActiveBanList(bans=[], total=0)
|
|
|
|
result = map_domain_active_ban_list_to_response(domain_list)
|
|
|
|
assert result.total == 0
|
|
assert len(result.items) == 0
|
|
|
|
|
|
class TestDashboardBanItemMapper:
|
|
"""Test mapping from DomainDashboardBanItem to DashboardBanItem."""
|
|
|
|
def test_maps_all_fields(self) -> None:
|
|
"""All fields are correctly mapped."""
|
|
domain_item = DomainDashboardBanItem(
|
|
ip="203.0.113.1",
|
|
jail="sshd",
|
|
banned_at="2026-04-28T07:00:00+00:00",
|
|
service="SSH login attempt",
|
|
country_code="US",
|
|
country_name="United States",
|
|
asn="AS15169",
|
|
org="Google LLC",
|
|
ban_count=5,
|
|
origin="selfblock",
|
|
)
|
|
|
|
result = map_domain_dashboard_ban_item_to_response(domain_item)
|
|
|
|
assert result.ip == "203.0.113.1"
|
|
assert result.jail == "sshd"
|
|
assert result.banned_at == "2026-04-28T07:00:00+00:00"
|
|
assert result.service == "SSH login attempt"
|
|
assert result.country_code == "US"
|
|
assert result.country_name == "United States"
|
|
assert result.asn == "AS15169"
|
|
assert result.org == "Google LLC"
|
|
assert result.ban_count == 5
|
|
assert result.origin == "selfblock"
|
|
|
|
|
|
class TestDashboardBanListMapper:
|
|
"""Test mapping from DomainDashboardBanList to DashboardBanListResponse."""
|
|
|
|
def test_maps_pagination_and_items(self) -> None:
|
|
"""Pagination metadata and items are correctly mapped."""
|
|
domain_list = DomainDashboardBanList(
|
|
items=[
|
|
DomainDashboardBanItem(
|
|
ip="1.1.1.1",
|
|
jail="sshd",
|
|
banned_at="2026-04-28T07:00:00+00:00",
|
|
ban_count=1,
|
|
),
|
|
],
|
|
total=100,
|
|
page=2,
|
|
page_size=50,
|
|
)
|
|
|
|
result = map_domain_dashboard_ban_list_to_response(domain_list)
|
|
|
|
assert result.total == 100
|
|
assert result.page == 2
|
|
assert result.page_size == 50
|
|
assert len(result.items) == 1
|
|
assert result.items[0].ip == "1.1.1.1"
|
|
|
|
|
|
class TestBansByCountryMapper:
|
|
"""Test mapping from DomainBansByCountry to BansByCountryResponse."""
|
|
|
|
def test_maps_aggregation_and_items(self) -> None:
|
|
"""Country aggregation and companion items are correctly mapped."""
|
|
domain_data = DomainBansByCountry(
|
|
countries={"US": 10, "DE": 5, "GB": 3},
|
|
country_names={"US": "United States", "DE": "Germany", "GB": "United Kingdom"},
|
|
items=[
|
|
DomainDashboardBanItem(
|
|
ip="1.1.1.1",
|
|
jail="sshd",
|
|
banned_at="2026-04-28T07:00:00+00:00",
|
|
ban_count=1,
|
|
origin="selfblock",
|
|
),
|
|
],
|
|
total=18,
|
|
)
|
|
|
|
result = map_domain_bans_by_country_to_response(domain_data)
|
|
|
|
assert result.countries == {"US": 10, "DE": 5, "GB": 3}
|
|
assert result.country_names == {"US": "United States", "DE": "Germany", "GB": "United Kingdom"}
|
|
assert result.total == 18
|
|
assert len(result.bans) == 1
|
|
|
|
|
|
class TestBanTrendMapper:
|
|
"""Test mapping from DomainBanTrend to BanTrendResponse."""
|
|
|
|
def test_maps_buckets_and_size_label(self) -> None:
|
|
"""Buckets and size label are correctly mapped."""
|
|
domain_trend = DomainBanTrend(
|
|
buckets=[
|
|
DomainBanTrendBucket(timestamp="2026-04-28T00:00:00+00:00", count=10),
|
|
DomainBanTrendBucket(timestamp="2026-04-28T01:00:00+00:00", count=15),
|
|
],
|
|
bucket_size="1h",
|
|
)
|
|
|
|
result = map_domain_ban_trend_to_response(domain_trend)
|
|
|
|
assert result.bucket_size == "1h"
|
|
assert len(result.buckets) == 2
|
|
assert result.buckets[0].timestamp == "2026-04-28T00:00:00+00:00"
|
|
assert result.buckets[0].count == 10
|
|
|
|
|
|
class TestBansByJailMapper:
|
|
"""Test mapping from DomainBansByJail to BansByJailResponse."""
|
|
|
|
def test_maps_jail_counts(self) -> None:
|
|
"""Jail counts are correctly mapped."""
|
|
domain_data = DomainBansByJail(
|
|
jails=[
|
|
DomainJailBanCount(jail="sshd", count=50),
|
|
DomainJailBanCount(jail="httpd", count=20),
|
|
],
|
|
total=70,
|
|
)
|
|
|
|
result = map_domain_bans_by_jail_to_response(domain_data)
|
|
|
|
assert result.total == 70
|
|
assert len(result.jails) == 2
|
|
assert result.jails[0].jail == "sshd"
|
|
assert result.jails[0].count == 50
|