This commit is contained in:
2026-05-05 18:47:56 +02:00
parent d25b56e7e1
commit 481f32bb85
15 changed files with 1887 additions and 77 deletions

View File

@@ -291,8 +291,11 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
)
# 3. Close HTTP session to release connections.
await http_session.close()
log.debug("http_session_closed")
try:
await http_session.close()
log.debug("http_session_closed")
except asyncio.CancelledError:
log.debug("http_session_close_cancelled")
# 4. Shutdown external logging handler.
if _external_log_handler:

View File

@@ -23,7 +23,7 @@ router = APIRouter(prefix="/api/v1/setup", tags=["setup"])
@router.get(
"/",
"",
response_model=SetupStatusResponse,
summary="Check whether setup has been completed",
responses={

View File

@@ -118,7 +118,7 @@ RATE_LIMIT_BANS_BAN_REQUESTS: Final[int] = 100
RATE_LIMIT_BANS_UNBAN_REQUESTS: Final[int] = 100
"""Max unban requests per IP per minute."""
RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS: Final[int] = 10
RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS: Final[int] = 100
"""Max blocklist import requests per IP per hour."""
RATE_LIMIT_CONFIG_UPDATE_REQUESTS: Final[int] = 50

View File

@@ -80,7 +80,7 @@ def _load_vendored_fail2ban_client() -> type[object]:
return CSocket
except ImportError:
vendor_root = Path(__file__).resolve().parents[4] / "fail2ban-master"
vendor_root = Path(__file__).resolve().parents[2] / "fail2ban-master"
if not vendor_root.is_dir():
raise

View File

@@ -12,6 +12,7 @@ from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.db import init_db
from app.main import _lifespan, create_app
from app.models.server import ServerStatus
from app.services import setup_service
# ---------------------------------------------------------------------------
@@ -42,10 +43,16 @@ async def app_and_client(tmp_path: Path) -> tuple[object, AsyncClient]: # type:
Yields:
A tuple of ``(FastAPI app instance, AsyncClient)``.
"""
config_dir = tmp_path / "fail2ban"
(config_dir / "jail.d").mkdir(parents=True)
(config_dir / "filter.d").mkdir(parents=True)
(config_dir / "action.d").mkdir(parents=True)
settings = Settings(
database_path=str(tmp_path / "setup_cache_test.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-setup-cache-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-setup-cache-secret-that-is-long-enough",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
@@ -115,7 +122,7 @@ class TestPostSetup:
"/api/v1/setup",
json={"master_password": "short"},
)
assert response.status_code == 422
assert response.status_code == 400
async def test_rejects_missing_uppercase_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords missing an uppercase character."""
@@ -123,11 +130,9 @@ class TestPostSetup:
"/api/v1/setup",
json={"master_password": "lowercase1!"},
)
assert response.status_code == 422
assert any(
"uppercase" in error["msg"].lower()
for error in response.json()["detail"]
)
assert response.status_code == 400
body = response.json()
assert body["code"] == "invalid_input"
async def test_rejects_missing_number_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords missing a numeric character."""
@@ -135,11 +140,9 @@ class TestPostSetup:
"/api/v1/setup",
json={"master_password": "NoNumbers!"},
)
assert response.status_code == 422
assert any(
"number" in error["msg"].lower()
for error in response.json()["detail"]
)
assert response.status_code == 400
body = response.json()
assert body["code"] == "invalid_input"
async def test_rejects_missing_special_character_password(
self, client: AsyncClient
@@ -149,11 +152,9 @@ class TestPostSetup:
"/api/v1/setup",
json={"master_password": "NoSpecial1"},
)
assert response.status_code == 422
assert any(
"special character" in error["msg"].lower()
for error in response.json()["detail"]
)
assert response.status_code == 400
body = response.json()
assert body["code"] == "invalid_input"
async def test_rejects_second_call(self, client: AsyncClient) -> None:
"""Setup endpoint returns 409 if setup has already been completed."""
@@ -354,10 +355,20 @@ class TestLifespanDatabaseDirectoryCreation:
nested_db = tmp_path / "deep" / "nested" / "bangui.db"
assert not nested_db.parent.exists()
# Settings requires the database parent to exist at construction time,
# so create the immediate parent. The intermediate directory
# (nested_db.parent.parent) is deliberately left non-existent to verify
# lifespan creates it.
nested_db.parent.mkdir(parents=True, exist_ok=True)
config_dir = tmp_path / "fail2ban"
config_dir.mkdir(parents=True, exist_ok=True)
settings = Settings(
database_path=str(nested_db),
fail2ban_socket="/tmp/fake.sock",
session_secret="test-lifespan-mkdir-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-lifespan-mkdir-secret-that-is-long-enough",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
@@ -398,10 +409,14 @@ class TestLifespanDatabaseDirectoryCreation:
db_path = tmp_path / "bangui.db"
# tmp_path already exists — this simulates a pre-existing volume.
config_dir = tmp_path / "fail2ban"
config_dir.mkdir(parents=True, exist_ok=True)
settings = Settings(
database_path=str(db_path),
fail2ban_socket="/tmp/fake.sock",
session_secret="test-lifespan-exist-ok-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-lifespan-exist-ok-secret-that-is-long-enough",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
@@ -436,10 +451,16 @@ class TestLifespanSetupCache:
async def test_startup_caches_setup_completion(self, tmp_path: Path) -> None:
"""Lifespan should populate ``setup_complete_cached`` based on the DB."""
config_dir = tmp_path / "fail2ban"
(config_dir / "jail.d").mkdir(parents=True)
(config_dir / "filter.d").mkdir(parents=True)
(config_dir / "action.d").mkdir(parents=True)
settings = Settings(
database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake.sock",
session_secret="test-lifespan-setup-cache-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-lifespan-setup-cache-secret-that-is-long",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
@@ -495,10 +516,16 @@ class TestSetupRedirectMiddlewareDbNone:
Simulates the race window where a request arrives before the lifespan
has finished initialising the database connection.
"""
config_dir = tmp_path / "fail2ban"
(config_dir / "jail.d").mkdir(parents=True)
(config_dir / "filter.d").mkdir(parents=True)
(config_dir / "action.d").mkdir(parents=True)
settings = Settings(
database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-db-none-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-db-none-secret-that-is-long-enough",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
@@ -517,15 +544,22 @@ class TestSetupRedirectMiddlewareDbNone:
async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None:
"""Health endpoint is always reachable even when db is not initialised."""
config_dir = tmp_path / "fail2ban"
(config_dir / "jail.d").mkdir(parents=True)
(config_dir / "filter.d").mkdir(parents=True)
(config_dir / "action.d").mkdir(parents=True)
settings = Settings(
database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-db-none-health-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-db-none-health-secret-that-is-long",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
app.state.server_status = ServerStatus(online=True)
transport = ASGITransport(app=app)
async with AsyncClient(

1812
backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff