backup
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
1812
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user