Compare commits
23 Commits
99e1b74405
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 42d5c2a01f | |||
| db17f3571b | |||
| cbddebf3b8 | |||
| 38d1594d21 | |||
| 2538c50321 | |||
| 5f33959efd | |||
| 848531c134 | |||
| 0d21e3253e | |||
| 3af8f0571b | |||
| d5a78a251a | |||
| 904db63fa2 | |||
| d737a1c319 | |||
| 9e765c6cb7 | |||
| ecb8542496 | |||
| 97f4df4a61 | |||
| 44542b93c0 | |||
| 01a4215f60 | |||
| bc49b7cd5b | |||
| fa4fe4bbdf | |||
| ee0fe9c695 | |||
| 551db0bb9c | |||
| 4a649e7347 | |||
| 025c82a982 |
@@ -1 +1 @@
|
||||
v0.9.19-rc.4
|
||||
v0.9.19-rc.5
|
||||
|
||||
106
Docker/compose.prod.yml
Normal file
106
Docker/compose.prod.yml
Normal file
@@ -0,0 +1,106 @@
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# BanGUI — Production Compose
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f Docker/compose.prod.yml up -d
|
||||
# podman compose -f Docker/compose.prod.yml up -d
|
||||
#
|
||||
# Features:
|
||||
# - Multi-stage built images (no volume-mounted source code)
|
||||
# - Frontend served by nginx with API reverse proxy
|
||||
# - Backend running uvicorn without --reload
|
||||
# - Only port 8080 exposed to host
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
name: bangui
|
||||
|
||||
services:
|
||||
# ── fail2ban ─────────────────────────────────────────────────
|
||||
fail2ban:
|
||||
image: lscr.io/linuxserver/fail2ban:latest
|
||||
container_name: bangui-fail2ban
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
network_mode: host
|
||||
environment:
|
||||
TZ: "${BANGUI_TIMEZONE:-UTC}"
|
||||
PUID: 0
|
||||
PGID: 0
|
||||
volumes:
|
||||
- ../data/fail2ban-dev-config:/config
|
||||
- fail2ban-run:/var/run/fail2ban
|
||||
- /var/log:/var/log:ro
|
||||
- ../data/log:/remotelogs/bangui
|
||||
healthcheck:
|
||||
test: ["CMD", "fail2ban-client", "ping"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
|
||||
# ── Backend (FastAPI + uvicorn) ─────────────────────────────
|
||||
backend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Docker/Dockerfile.backend
|
||||
target: runtime
|
||||
container_name: bangui-backend
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s # Give lifespan 30s to complete before SIGKILL
|
||||
depends_on:
|
||||
fail2ban:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BANGUI_DATABASE_PATH: "/data/bangui.db"
|
||||
BANGUI_FAIL2BAN_SOCKET: "/var/run/fail2ban/fail2ban.sock"
|
||||
BANGUI_FAIL2BAN_CONFIG_DIR: "/config/fail2ban"
|
||||
BANGUI_LOG_FILE: "/data/log/bangui.log"
|
||||
BANGUI_LOG_LEVEL: "${BANGUI_LOG_LEVEL:-info}"
|
||||
BANGUI_SESSION_SECRET: "${BANGUI_SESSION_SECRET:?BANGUI_SESSION_SECRET must be set — generate with: python -c 'import secrets; print(secrets.token_hex(32))'}"
|
||||
BANGUI_TIMEZONE: "${BANGUI_TIMEZONE:-UTC}"
|
||||
BANGUI_SESSION_COOKIE_SECURE: "${BANGUI_SESSION_COOKIE_SECURE:-true}"
|
||||
BANGUI_CORS_ALLOWED_ORIGINS: "${BANGUI_CORS_ALLOWED_ORIGINS:-}"
|
||||
volumes:
|
||||
- ../data:/data
|
||||
- ../fail2ban-master:/app/fail2ban-master:ro
|
||||
- fail2ban-run:/var/run/fail2ban:ro
|
||||
- ../data/fail2ban-dev-config:/config:rw
|
||||
networks:
|
||||
- bangui-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/v1/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start_period: 40s
|
||||
retries: 3
|
||||
|
||||
# ── Frontend (nginx serving built SPA) ──────────────────────
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Docker/Dockerfile.frontend
|
||||
container_name: bangui-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${BANGUI_PORT:-8080}:80"
|
||||
networks:
|
||||
- bangui-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO /dev/null http://localhost:80/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
fail2ban-run:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bangui-net:
|
||||
driver: bridge
|
||||
1951
Docs/Tasks.md
1951
Docs/Tasks.md
File diff suppressed because it is too large
Load Diff
@@ -102,7 +102,7 @@ for (int i = 0; i < items.Count; i++)
|
||||
|
||||
// Step 1 — run the task prompt
|
||||
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||
await RunCopilot(new[] { "--continue" }, $"read ./Docs/Instructions.md. fix the following test and only that one. Keep in mind that i did many refactorings and test may is obsolet or need to be changed. {item}");
|
||||
await RunCopilot(new[] { "--continue" }, $"read ./Docs/Instructions.md. fix the following test and only that one. {item}");
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
// Step 2 — confirm completion in the same chat session
|
||||
|
||||
@@ -274,7 +274,18 @@ CREATE INDEX IF NOT EXISTS idx_import_log_source_id_desc
|
||||
|
||||
|
||||
async def _configure_connection(db: aiosqlite.Connection) -> None:
|
||||
"""Apply hardening pragmas to a newly-opened SQLite connection."""
|
||||
"""Apply hardening pragmas to a newly-opened SQLite connection.
|
||||
|
||||
WAL mode is intentionally kept despite the risk of orphaned ``.wal``/``.shm``
|
||||
files after unclean shutdowns. The benefits for concurrent readers
|
||||
(readers do not block writers) outweigh the cleanup overhead, especially
|
||||
under load. BanGUI runs as a single worker, but multiple concurrent HTTP
|
||||
requests can still issue overlapping reads; DELETE mode would serialize
|
||||
those reads behind any write, degrading API performance.
|
||||
|
||||
Orphaned files are handled by :func:`_cleanup_wal_files`, which is called
|
||||
during startup before the database is opened.
|
||||
"""
|
||||
await db.execute("PRAGMA journal_mode=WAL;")
|
||||
await db.execute("PRAGMA foreign_keys=ON;")
|
||||
await db.execute("PRAGMA busy_timeout=5000;")
|
||||
@@ -475,14 +486,75 @@ async def init_db(db: aiosqlite.Connection) -> None:
|
||||
async def open_db(database_path: str) -> aiosqlite.Connection:
|
||||
"""Open a new application SQLite connection with the standard settings.
|
||||
|
||||
Creates the parent directory if it does not exist.
|
||||
|
||||
Args:
|
||||
database_path: Path to the BanGUI SQLite database.
|
||||
|
||||
Returns:
|
||||
A configured :class:`aiosqlite.Connection` instance.
|
||||
|
||||
Raises:
|
||||
DatabasePathInvalidError: If the directory cannot be created or is inaccessible.
|
||||
DatabasePermissionDeniedError: If aiosqlite.connect raises PermissionError.
|
||||
DatabaseCorruptedError: If the database file is corrupted.
|
||||
DatabaseUnavailableError: For any other unexpected error.
|
||||
"""
|
||||
await _cleanup_wal_files(database_path)
|
||||
db = await aiosqlite.connect(database_path)
|
||||
from app.exceptions import (
|
||||
DatabaseCorruptedError,
|
||||
DatabasePathInvalidError,
|
||||
DatabasePermissionDeniedError,
|
||||
DatabaseUnavailableError,
|
||||
)
|
||||
|
||||
db_dir = Path(database_path).parent
|
||||
if not db_dir.exists():
|
||||
try:
|
||||
db_dir.mkdir(parents=True, exist_ok=True)
|
||||
except PermissionError as exc:
|
||||
log.error("database_open_failed", error=str(exc), database_path=database_path)
|
||||
raise DatabasePathInvalidError(database_path) from exc
|
||||
except OSError as exc:
|
||||
log.error("database_open_failed", error=str(exc), database_path=database_path)
|
||||
raise DatabaseUnavailableError(database_path, str(exc)) from exc
|
||||
|
||||
try:
|
||||
db = await aiosqlite.connect(database_path)
|
||||
except PermissionError as exc:
|
||||
log.error("database_open_failed", error=str(exc), database_path=database_path)
|
||||
raise DatabasePermissionDeniedError(database_path) from exc
|
||||
except aiosqlite.OperationalError as exc:
|
||||
error_msg = str(exc).lower()
|
||||
sqlite_code = getattr(exc, "sqlite_errorcode", None)
|
||||
log.error(
|
||||
"database_open_failed",
|
||||
error=str(exc),
|
||||
sqlite_errorcode=sqlite_code,
|
||||
database_path=database_path,
|
||||
)
|
||||
if "database is locked" in error_msg or "busy" in error_msg:
|
||||
raise DatabaseUnavailableError(database_path, str(exc)) from exc
|
||||
if "unable to open database file" in error_msg:
|
||||
raise DatabasePathInvalidError(database_path) from exc
|
||||
raise DatabaseUnavailableError(database_path, str(exc)) from exc
|
||||
except aiosqlite.DatabaseError as exc:
|
||||
log.error(
|
||||
"database_open_failed",
|
||||
error=str(exc),
|
||||
database_path=database_path,
|
||||
)
|
||||
raise DatabaseCorruptedError(database_path) from exc
|
||||
except OSError as exc:
|
||||
log.error("database_open_failed", error=str(exc), database_path=database_path)
|
||||
raise DatabaseUnavailableError(database_path, str(exc)) from exc
|
||||
except Exception as exc:
|
||||
log.error("database_open_failed", error=str(exc), database_path=database_path)
|
||||
raise DatabaseUnavailableError(database_path, str(exc)) from exc
|
||||
|
||||
db.row_factory = aiosqlite.Row
|
||||
await _configure_connection(db)
|
||||
try:
|
||||
await _configure_connection(db)
|
||||
except Exception:
|
||||
await db.close()
|
||||
raise
|
||||
return db
|
||||
|
||||
@@ -165,22 +165,61 @@ async def get_db(
|
||||
|
||||
Yields:
|
||||
An open :class:`aiosqlite.Connection` for the request.
|
||||
|
||||
Raises:
|
||||
DatabaseBusyError: After 3 retries when database is locked by concurrent writers.
|
||||
DatabasePermissionDeniedError: When the database file cannot be accessed.
|
||||
DatabasePathInvalidError: When the database path is invalid or directory missing.
|
||||
DatabaseCorruptedError: When the database file is corrupted.
|
||||
DatabaseUnavailableError: For any other unexpected database error.
|
||||
"""
|
||||
from app.db import open_db # noqa: PLC0415
|
||||
from app.exceptions import (
|
||||
DatabaseBusyError,
|
||||
DatabaseCorruptedError,
|
||||
DatabasePathInvalidError,
|
||||
DatabasePermissionDeniedError,
|
||||
DatabaseUnavailableError,
|
||||
)
|
||||
|
||||
try:
|
||||
db = await open_db(settings.database_path)
|
||||
except Exception as exc:
|
||||
log.error("database_open_failed", error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Database is not available.",
|
||||
) from exc
|
||||
db = None
|
||||
retries = 3
|
||||
retry_delay = 0.1
|
||||
last_exc = None
|
||||
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
db = await open_db(settings.database_path)
|
||||
break
|
||||
except DatabaseBusyError:
|
||||
raise
|
||||
except (DatabasePermissionDeniedError, DatabasePathInvalidError, DatabaseCorruptedError):
|
||||
raise
|
||||
except DatabaseUnavailableError as exc:
|
||||
error_str = str(exc).lower()
|
||||
if "database is locked" in error_str or "busy" in error_str:
|
||||
last_exc = exc
|
||||
if attempt < retries:
|
||||
log.warning(
|
||||
"database_open_retry",
|
||||
attempt=attempt,
|
||||
max_retries=retries,
|
||||
database_path=settings.database_path,
|
||||
)
|
||||
import asyncio
|
||||
await asyncio.sleep(retry_delay * attempt)
|
||||
continue
|
||||
raise DatabaseBusyError(settings.database_path, retries) from exc
|
||||
raise
|
||||
|
||||
if last_exc is not None and db is None:
|
||||
raise DatabaseBusyError(settings.database_path, retries)
|
||||
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await db.close()
|
||||
if db is not None:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def get_http_session(
|
||||
|
||||
@@ -473,6 +473,75 @@ class SetupAlreadyCompleteError(ConflictError):
|
||||
super().__init__("Setup has already been completed.")
|
||||
|
||||
|
||||
class DatabaseBusyError(ServiceUnavailableError):
|
||||
"""Raised when the SQLite database is locked or busy after all retries."""
|
||||
|
||||
error_code: str = "database_busy"
|
||||
|
||||
def __init__(self, database_path: str, retries: int) -> None:
|
||||
self.database_path = database_path
|
||||
self.retries = retries
|
||||
super().__init__(
|
||||
f"Database is temporarily busy after {retries} retries."
|
||||
)
|
||||
|
||||
def get_error_metadata(self) -> ErrorMetadata:
|
||||
return {"database_path": self.database_path, "retries": self.retries}
|
||||
|
||||
|
||||
class DatabasePermissionDeniedError(ServiceUnavailableError):
|
||||
"""Raised when the database file cannot be accessed due to insufficient permissions."""
|
||||
|
||||
error_code: str = "database_permission_denied"
|
||||
|
||||
def __init__(self, database_path: str) -> None:
|
||||
self.database_path = database_path
|
||||
super().__init__("Insufficient permissions to access the database file.")
|
||||
|
||||
def get_error_metadata(self) -> ErrorMetadata:
|
||||
return {"database_path": self.database_path}
|
||||
|
||||
|
||||
class DatabasePathInvalidError(ServiceUnavailableError):
|
||||
"""Raised when the database directory does not exist or the path is invalid."""
|
||||
|
||||
error_code: str = "database_path_invalid"
|
||||
|
||||
def __init__(self, database_path: str) -> None:
|
||||
self.database_path = database_path
|
||||
super().__init__("Database directory does not exist or path is invalid.")
|
||||
|
||||
def get_error_metadata(self) -> ErrorMetadata:
|
||||
return {"database_path": self.database_path}
|
||||
|
||||
|
||||
class DatabaseCorruptedError(ServiceUnavailableError):
|
||||
"""Raised when the database file is corrupted."""
|
||||
|
||||
error_code: str = "database_corrupted"
|
||||
|
||||
def __init__(self, database_path: str) -> None:
|
||||
self.database_path = database_path
|
||||
super().__init__("Database file is corrupted.")
|
||||
|
||||
def get_error_metadata(self) -> ErrorMetadata:
|
||||
return {"database_path": self.database_path}
|
||||
|
||||
|
||||
class DatabaseUnavailableError(ServiceUnavailableError):
|
||||
"""Raised for any other unexpected database error."""
|
||||
|
||||
error_code: str = "database_unavailable"
|
||||
|
||||
def __init__(self, database_path: str, error: str) -> None:
|
||||
self.database_path = database_path
|
||||
self.error = error
|
||||
super().__init__(f"Database is not available: {error}")
|
||||
|
||||
def get_error_metadata(self) -> ErrorMetadata:
|
||||
return {"database_path": self.database_path, "error": self.error}
|
||||
|
||||
|
||||
class BlocklistSourceNotFoundError(NotFoundError):
|
||||
"""Raised when a blocklist source is not found."""
|
||||
|
||||
|
||||
@@ -318,7 +318,12 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
log.error("scheduler_lock_release_failed", error=str(e))
|
||||
|
||||
# 6. Close the database connection.
|
||||
await startup_db.close()
|
||||
try:
|
||||
await startup_db.close()
|
||||
log.debug("database_connection_closed")
|
||||
except Exception as exc:
|
||||
log.error("database_connection_close_failed", error=str(exc))
|
||||
|
||||
log.info("bangui_shut_down")
|
||||
|
||||
|
||||
|
||||
@@ -26,10 +26,9 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiohttp
|
||||
from app.utils.logging_compat import get_logger
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped]
|
||||
|
||||
from app.db import init_db, open_db
|
||||
from app.db import _cleanup_wal_files, init_db, open_db
|
||||
from app.services import setup_service
|
||||
from app.services.dns_validated_connector import create_dns_validated_socket_factory
|
||||
from app.services.geo_cache import GeoCache
|
||||
@@ -48,6 +47,7 @@ from app.tasks import (
|
||||
from app.utils.async_utils import run_blocking
|
||||
from app.utils.fail2ban_db_utils import ensure_fail2ban_indexes
|
||||
from app.utils.jail_config import ensure_jail_configs
|
||||
from app.utils.logging_compat import get_logger
|
||||
from app.utils.runtime_state import set_runtime_settings
|
||||
from app.utils.scheduler_lock import (
|
||||
acquire_scheduler_lock,
|
||||
@@ -98,9 +98,7 @@ def _check_single_worker_mode() -> None:
|
||||
"See Docs/Architekture.md § Deployment Constraints for details."
|
||||
)
|
||||
except ValueError as e:
|
||||
raise RuntimeError(
|
||||
f"BANGUI_WORKERS environment variable must be an integer, got: {workers_env}"
|
||||
) from e
|
||||
raise RuntimeError(f"BANGUI_WORKERS environment variable must be an integer, got: {workers_env}") from e
|
||||
|
||||
|
||||
async def _ensure_database_schema(database_path: str) -> None:
|
||||
@@ -333,6 +331,11 @@ async def _stage_init_database(app: FastAPI, settings: Settings) -> Any:
|
||||
|
||||
log.debug("database_directory_ensured", directory=str(db_path.parent))
|
||||
|
||||
# Clean up orphaned WAL files from previous unclean shutdowns before
|
||||
# opening the database. This prevents stale .wal/.shm files from
|
||||
# interfering with startup or triggering misleading warnings.
|
||||
await _cleanup_wal_files(settings.database_path)
|
||||
|
||||
original_db_path = db_path.resolve()
|
||||
startup_db = await open_db(settings.database_path)
|
||||
|
||||
@@ -357,9 +360,7 @@ async def _stage_init_database(app: FastAPI, settings: Settings) -> Any:
|
||||
if f2b_db_path:
|
||||
await run_blocking(ensure_fail2ban_indexes, f2b_db_path)
|
||||
|
||||
persisted_runtime_settings = (
|
||||
await setup_service.get_persisted_runtime_settings(runtime_db)
|
||||
)
|
||||
persisted_runtime_settings = await setup_service.get_persisted_runtime_settings(runtime_db)
|
||||
finally:
|
||||
await runtime_db.close()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bangui-backend"
|
||||
version = "0.9.19-rc.3"
|
||||
version = "0.9.19-rc.5"
|
||||
description = "BanGUI backend — fail2ban web management interface"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -252,6 +252,30 @@ async def test_cleanup_wal_files_removes_orphaned_files(tmp_path: Path) -> None:
|
||||
assert not shm_path.exists()
|
||||
|
||||
|
||||
async def test_cleanup_wal_files_skips_recent_files(tmp_path: Path) -> None:
|
||||
"""Test that _cleanup_wal_files skips files modified within 10 seconds."""
|
||||
db_path = str(tmp_path / "test_wal_recent.db")
|
||||
wal_path = Path(db_path + "-wal")
|
||||
shm_path = Path(db_path + "-shm")
|
||||
|
||||
# Create files with recent mtime
|
||||
wal_path.write_text("recent")
|
||||
shm_path.write_text("recent")
|
||||
recent_mtime = time.time() - 5
|
||||
os.utime(wal_path, (recent_mtime, recent_mtime))
|
||||
os.utime(shm_path, (recent_mtime, recent_mtime))
|
||||
|
||||
assert wal_path.exists()
|
||||
assert shm_path.exists()
|
||||
|
||||
# Run cleanup
|
||||
await _cleanup_wal_files(db_path)
|
||||
|
||||
# Files should NOT be removed (recent)
|
||||
assert wal_path.exists()
|
||||
assert shm_path.exists()
|
||||
|
||||
|
||||
async def test_cleanup_wal_files_handles_missing_files(tmp_path: Path) -> None:
|
||||
"""Test that _cleanup_wal_files handles non-existent files gracefully."""
|
||||
db_path = str(tmp_path / "nonexistent.db")
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiohttp
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from starlette.requests import Request
|
||||
@@ -19,6 +20,13 @@ from app.dependencies import (
|
||||
get_settings,
|
||||
get_settings_repo,
|
||||
)
|
||||
from app.exceptions import (
|
||||
DatabaseBusyError,
|
||||
DatabaseCorruptedError,
|
||||
DatabasePathInvalidError,
|
||||
DatabasePermissionDeniedError,
|
||||
DatabaseUnavailableError,
|
||||
)
|
||||
from app.main import create_app
|
||||
from app.models.server import ServerStatus
|
||||
|
||||
@@ -98,3 +106,184 @@ async def test_get_db_uses_effective_runtime_database_path(test_settings: Settin
|
||||
await gen.aclose()
|
||||
|
||||
mock_open_db.assert_awaited_once_with("/tmp/runtime.db")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database error handling tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_db_raises_database_permission_denied_on_permission_error(
|
||||
test_settings: Settings,
|
||||
) -> None:
|
||||
"""PermissionError from open_db raises DatabasePermissionDeniedError."""
|
||||
with patch(
|
||||
"app.db.open_db",
|
||||
new=AsyncMock(side_effect=DatabasePermissionDeniedError(test_settings.database_path)),
|
||||
):
|
||||
gen = get_db(settings=test_settings)
|
||||
with pytest.raises(DatabasePermissionDeniedError) as exc_info:
|
||||
await gen.__anext__()
|
||||
await gen.aclose()
|
||||
|
||||
assert exc_info.value.error_code == "database_permission_denied"
|
||||
assert exc_info.value.database_path == test_settings.database_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_db_raises_database_path_invalid_on_missing_directory(
|
||||
test_settings: Settings,
|
||||
) -> None:
|
||||
"""sqlite3.OperationalError('unable to open database file') raises DatabasePathInvalidError."""
|
||||
with patch(
|
||||
"app.db.open_db",
|
||||
new=AsyncMock(side_effect=DatabasePathInvalidError(test_settings.database_path)),
|
||||
):
|
||||
gen = get_db(settings=test_settings)
|
||||
with pytest.raises(DatabasePathInvalidError) as exc_info:
|
||||
await gen.__anext__()
|
||||
await gen.aclose()
|
||||
|
||||
assert exc_info.value.error_code == "database_path_invalid"
|
||||
assert exc_info.value.database_path == test_settings.database_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_db_retries_on_database_locked(test_settings: Settings) -> None:
|
||||
"""get_db retries up to 3 times when database is locked."""
|
||||
mock_connection = MagicMock()
|
||||
mock_connection.close = AsyncMock()
|
||||
|
||||
locked_err = DatabaseUnavailableError(
|
||||
test_settings.database_path, "database is locked"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.db.open_db",
|
||||
new=AsyncMock(side_effect=[locked_err, locked_err, mock_connection]),
|
||||
) as mock_open:
|
||||
gen = get_db(settings=test_settings)
|
||||
with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep:
|
||||
connection = await gen.__anext__()
|
||||
await gen.aclose()
|
||||
|
||||
assert mock_open.call_count == 3
|
||||
assert connection is mock_connection
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_db_fails_after_max_retries_on_database_locked(
|
||||
test_settings: Settings,
|
||||
) -> None:
|
||||
"""After 3 retries on database locked, raises DatabaseBusyError."""
|
||||
locked_err = DatabaseUnavailableError(
|
||||
test_settings.database_path, "database is locked"
|
||||
)
|
||||
|
||||
with patch("app.db.open_db", new=AsyncMock(side_effect=locked_err)) as mock_open:
|
||||
gen = get_db(settings=test_settings)
|
||||
with patch("asyncio.sleep", new=AsyncMock()):
|
||||
with pytest.raises(DatabaseBusyError) as exc_info:
|
||||
await gen.__anext__()
|
||||
await gen.aclose()
|
||||
|
||||
assert mock_open.call_count == 3
|
||||
assert exc_info.value.error_code == "database_busy"
|
||||
assert exc_info.value.retries == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_db_raises_database_corrupted_on_malformed_db(
|
||||
test_settings: Settings,
|
||||
) -> None:
|
||||
"""sqlite3.DatabaseError('database disk image is malformed') raises DatabaseCorruptedError."""
|
||||
with patch(
|
||||
"app.db.open_db",
|
||||
new=AsyncMock(side_effect=DatabaseCorruptedError(test_settings.database_path)),
|
||||
):
|
||||
gen = get_db(settings=test_settings)
|
||||
with pytest.raises(DatabaseCorruptedError) as exc_info:
|
||||
await gen.__anext__()
|
||||
await gen.aclose()
|
||||
|
||||
assert exc_info.value.error_code == "database_corrupted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_db_creates_parent_directory_if_missing(tmp_path: pytest.Path) -> None:
|
||||
"""open_db creates the parent directory when it does not exist."""
|
||||
from pathlib import Path
|
||||
|
||||
from app.db import open_db
|
||||
|
||||
db_path = str(Path(str(tmp_path)) / "subdir" / "deeper" / "bangui.db")
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.close = AsyncMock()
|
||||
mock_conn.execute = AsyncMock()
|
||||
mock_conn.commit = AsyncMock()
|
||||
|
||||
with patch("aiosqlite.connect", new=AsyncMock(return_value=mock_conn)), \
|
||||
patch("app.db._configure_connection", new=AsyncMock()):
|
||||
connection = await open_db(db_path)
|
||||
|
||||
assert connection is mock_conn
|
||||
assert Path(db_path).parent.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_db_logs_specific_sqlite_error_code() -> None:
|
||||
"""open_db logs the SQLite error code when available."""
|
||||
from app.db import open_db
|
||||
|
||||
exc = aiosqlite.OperationalError("database is locked")
|
||||
exc.sqlite_errorcode = 5 # SQLITE_BUSY
|
||||
|
||||
with patch("aiosqlite.connect", new=AsyncMock(side_effect=exc)), \
|
||||
pytest.raises(DatabaseUnavailableError):
|
||||
await open_db("/tmp/test.db")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error metadata tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_database_busy_error_metadata() -> None:
|
||||
"""DatabaseBusyError returns correct metadata."""
|
||||
err = DatabaseBusyError("/data/bangui.db", retries=3)
|
||||
assert err.error_code == "database_busy"
|
||||
metadata = err.get_error_metadata()
|
||||
assert metadata["database_path"] == "/data/bangui.db"
|
||||
assert metadata["retries"] == 3
|
||||
|
||||
|
||||
def test_database_permission_denied_error_metadata() -> None:
|
||||
"""DatabasePermissionDeniedError returns correct metadata."""
|
||||
err = DatabasePermissionDeniedError("/data/bangui.db")
|
||||
assert err.error_code == "database_permission_denied"
|
||||
assert err.get_error_metadata()["database_path"] == "/data/bangui.db"
|
||||
|
||||
|
||||
def test_database_path_invalid_error_metadata() -> None:
|
||||
"""DatabasePathInvalidError returns correct metadata."""
|
||||
err = DatabasePathInvalidError("/data/bangui.db")
|
||||
assert err.error_code == "database_path_invalid"
|
||||
assert err.get_error_metadata()["database_path"] == "/data/bangui.db"
|
||||
|
||||
|
||||
def test_database_corrupted_error_metadata() -> None:
|
||||
"""DatabaseCorruptedError returns correct metadata."""
|
||||
err = DatabaseCorruptedError("/data/bangui.db")
|
||||
assert err.error_code == "database_corrupted"
|
||||
assert err.get_error_metadata()["database_path"] == "/data/bangui.db"
|
||||
|
||||
|
||||
def test_database_unavailable_error_metadata() -> None:
|
||||
"""DatabaseUnavailableError returns correct metadata."""
|
||||
err = DatabaseUnavailableError("/data/bangui.db", "some error")
|
||||
assert err.error_code == "database_unavailable"
|
||||
metadata = err.get_error_metadata()
|
||||
assert metadata["database_path"] == "/data/bangui.db"
|
||||
assert metadata["error"] == "some error"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -22,6 +25,7 @@ from app.main import (
|
||||
from app.middleware.correlation import CorrelationIdMiddleware
|
||||
from app.middleware.rate_limit import RateLimitMiddleware
|
||||
from app.services import setup_service
|
||||
from app.utils.json_formatter import JSONFormatter
|
||||
|
||||
|
||||
def test_create_app_configures_cors_from_settings() -> None:
|
||||
@@ -556,6 +560,174 @@ async def test_concurrent_requests_use_request_scoped_db_connections(tmp_path: P
|
||||
assert all(connection.close.await_count == 1 for connection in connections)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_logging_configuration_no_duplicate_handlers(tmp_path: Path) -> None:
|
||||
"""Calling create_app() twice leaves no more than one custom StreamHandler on root."""
|
||||
fail2ban_config_dir = tmp_path / "fail2ban"
|
||||
fail2ban_config_dir.mkdir()
|
||||
|
||||
settings1 = Settings(
|
||||
database_path=str(tmp_path / "test1.db"),
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir=str(fail2ban_config_dir),
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
|
||||
create_app(settings=settings1)
|
||||
|
||||
settings2 = Settings(
|
||||
database_path=str(tmp_path / "test2.db"),
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir=str(fail2ban_config_dir),
|
||||
session_secret="test-secret-key-do-not-use-in-production-2",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
|
||||
create_app(settings=settings2)
|
||||
# _configure_logging uses basicConfig which replaces handlers on the root logger.
|
||||
# After two calls there should be at most one StreamHandler we own (plus any pytest
|
||||
# LogCaptureHandler which we exclude).
|
||||
root_stream_handlers = [
|
||||
h for h in logging.getLogger().handlers
|
||||
if isinstance(h, logging.StreamHandler) and not type(h).__name__.endswith("LogCaptureHandler")
|
||||
]
|
||||
assert len(root_stream_handlers) <= 1, (
|
||||
f"Expected at most one StreamHandler after two create_app() calls, "
|
||||
f"got {len(root_stream_handlers)}: {root_stream_handlers}"
|
||||
)
|
||||
|
||||
|
||||
def test_uvicorn_access_logs_go_through_root_handler(tmp_path: Path) -> None:
|
||||
"""uvicorn.access logs can be formatted as JSON when a handler with JSONFormatter is added."""
|
||||
fail2ban_config_dir = tmp_path / "fail2ban"
|
||||
fail2ban_config_dir.mkdir()
|
||||
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "test.db"),
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir=str(fail2ban_config_dir),
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
create_app(settings=settings)
|
||||
|
||||
# uvicorn.access does not propagate to root by default; attach a JSON handler directly.
|
||||
uvicorn_access = logging.getLogger("uvicorn.access")
|
||||
output = io.StringIO()
|
||||
handler = logging.StreamHandler(stream=output)
|
||||
handler.setFormatter(JSONFormatter())
|
||||
uvicorn_access.addHandler(handler)
|
||||
|
||||
try:
|
||||
uvicorn_access.setLevel(logging.DEBUG)
|
||||
uvicorn_access.info("GET /api/v1/health 200")
|
||||
line = output.getvalue().strip()
|
||||
assert line, "Expected non-empty log output from uvicorn.access"
|
||||
parsed = json.loads(line)
|
||||
assert "event" in parsed, "JSON log must contain 'event'"
|
||||
assert "level" in parsed, "JSON log must contain 'level'"
|
||||
assert "timestamp" in parsed, "JSON log must contain 'timestamp'"
|
||||
finally:
|
||||
uvicorn_access.removeHandler(handler)
|
||||
|
||||
|
||||
def test_external_logging_processor_queues_record(tmp_path: Path) -> None:
|
||||
"""_external_logging_processor queues a record to the external handler when present."""
|
||||
from app.main import _external_logging_processor
|
||||
|
||||
fail2ban_config_dir = tmp_path / "fail2ban"
|
||||
fail2ban_config_dir.mkdir()
|
||||
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "test.db"),
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir=str(fail2ban_config_dir),
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
create_app(settings=settings)
|
||||
|
||||
from app.main import _external_log_handler
|
||||
|
||||
if _external_log_handler is None:
|
||||
pytest.skip("No external log handler configured")
|
||||
|
||||
captured: list[dict[str, object]] = []
|
||||
original_queue_log = _external_log_handler.queue_log
|
||||
|
||||
def mock_queue_log(record: dict[str, object]) -> None:
|
||||
captured.append(record)
|
||||
|
||||
_external_log_handler.queue_log = mock_queue_log
|
||||
|
||||
try:
|
||||
record = logging.makeLogRecord({"msg": "test event", "levelname": "INFO", "name": "test.logger", "created": 0})
|
||||
_external_logging_processor(record)
|
||||
|
||||
assert len(captured) == 1, f"Expected exactly one queued record, got {len(captured)}"
|
||||
assert captured[0]["event"] == "test event"
|
||||
assert captured[0]["level"] == "info"
|
||||
finally:
|
||||
_external_log_handler.queue_log = original_queue_log
|
||||
|
||||
|
||||
def test_plain_text_logs_not_emitted_after_startup(tmp_path: Path) -> None:
|
||||
"""After create_app() completes, app.db logger output is JSON, not plain text."""
|
||||
fail2ban_config_dir = tmp_path / "fail2ban"
|
||||
fail2ban_config_dir.mkdir()
|
||||
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "test.db"),
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir=str(fail2ban_config_dir),
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
create_app(settings=settings)
|
||||
|
||||
output = io.StringIO()
|
||||
handler = logging.StreamHandler(stream=output)
|
||||
handler.setFormatter(JSONFormatter())
|
||||
db_logger = logging.getLogger("app.db")
|
||||
db_logger.addHandler(handler)
|
||||
db_logger.setLevel(logging.DEBUG)
|
||||
|
||||
try:
|
||||
db_logger.info("test_db_log")
|
||||
line = output.getvalue().strip()
|
||||
assert line, "Expected non-empty log output"
|
||||
assert not line.startswith("test_db_log "), "Log must not be plain text"
|
||||
parsed = json.loads(line)
|
||||
assert "event" in parsed, "JSON log must contain 'event'"
|
||||
finally:
|
||||
db_logger.removeHandler(handler)
|
||||
|
||||
try:
|
||||
db_logger.info("test_db_log")
|
||||
line = output.getvalue().strip()
|
||||
assert line, "Expected non-empty log output"
|
||||
assert not line.startswith("test_db_log "), "Log must not be plain text"
|
||||
parsed = json.loads(line)
|
||||
assert "event" in parsed, "JSON log must contain 'event'"
|
||||
finally:
|
||||
db_logger.removeHandler(handler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware order validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -934,6 +934,29 @@ class TestBanTrend:
|
||||
parsed = datetime.fromisoformat(bucket.timestamp)
|
||||
assert parsed.tzinfo is not None # Must be timezone-aware (UTC)
|
||||
|
||||
async def test_ban_trend_since_is_within_expected_range(self, tmp_path: Path) -> None:
|
||||
"""``since`` value is within 24h + 60s slack of the current time."""
|
||||
from app.utils.constants import TIME_RANGE_SLACK_SECONDS
|
||||
|
||||
now = int(time.time())
|
||||
# Place a ban just inside the expected range: 23 hours ago.
|
||||
# With 60s slack, since ≈ now - 24h - 60s, so 23h-ago ban should be included.
|
||||
just_inside_range = now - (23 * 3600)
|
||||
path = str(tmp_path / "test_since_range.sqlite3")
|
||||
await _create_f2b_db(
|
||||
path,
|
||||
[{"jail": "sshd", "ip": "1.2.3.4", "timeofban": just_inside_range}],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.ban_service.get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=path),
|
||||
):
|
||||
result = await ban_service.ban_trend("/fake/sock", "24h")
|
||||
|
||||
# Ban at 23h ago must appear (within 24h + 60s window).
|
||||
assert sum(b.count for b in result.buckets) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bans_by_jail
|
||||
|
||||
@@ -134,3 +134,15 @@ class TestSinceUnix:
|
||||
# The slack should be ~60 seconds
|
||||
assert actual_slack >= TIME_RANGE_SLACK_SECONDS - 1
|
||||
assert actual_slack <= TIME_RANGE_SLACK_SECONDS + 1
|
||||
|
||||
def test_since_unix_returns_utc_epoch(self) -> None:
|
||||
"""``since_unix('24h')`` returns a value within 24h + 60s of ``time.time()``."""
|
||||
before = int(time.time())
|
||||
result = since_unix("24h")
|
||||
after = int(time.time())
|
||||
|
||||
# Allow 2 second tolerance for execution time
|
||||
expected_min = before - (24 * 3600) - TIME_RANGE_SLACK_SECONDS - 2
|
||||
expected_max = after - (24 * 3600) - TIME_RANGE_SLACK_SECONDS + 2
|
||||
|
||||
assert expected_min <= result <= expected_max
|
||||
|
||||
147
check_auth.py
Normal file
147
check_auth.py
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diagnostic script for BanGUI auth/session 401 issue.
|
||||
|
||||
Tests the full auth flow against http://192.168.178.43:8080/api/v1/auth
|
||||
using password "Hallo123!".
|
||||
|
||||
Usage:
|
||||
python3 check_auth.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE_URL = "http://192.168.178.43:8080/api/v1"
|
||||
PASSWORD = "Hallo123!"
|
||||
|
||||
|
||||
def make_request(url, method="GET", data=None, headers=None, cookie=None):
|
||||
"""Make an HTTP request and return (status, headers, body, cookies)."""
|
||||
req_headers = headers or {}
|
||||
if data:
|
||||
req_headers["Content-Type"] = "application/json"
|
||||
if cookie:
|
||||
req_headers["Cookie"] = cookie
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(data).encode("utf-8") if data else None,
|
||||
headers=req_headers,
|
||||
method=method,
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
return resp.status, dict(resp.headers), body, cookies
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8")
|
||||
cookies = e.headers.get_all("Set-Cookie") or []
|
||||
return e.code, dict(e.headers), body, cookies
|
||||
except Exception as e:
|
||||
return None, {}, str(e), []
|
||||
|
||||
|
||||
def extract_cookie_value(set_cookie_headers, cookie_name):
|
||||
"""Extract cookie value from Set-Cookie headers."""
|
||||
for header in set_cookie_headers:
|
||||
if header.startswith(cookie_name + "="):
|
||||
return header.split(";")[0]
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("BanGUI Auth Diagnostic Script")
|
||||
print("Target:", BASE_URL)
|
||||
print("=" * 60)
|
||||
|
||||
# 1. Check health endpoint (no auth needed)
|
||||
print("\n[1] GET /health")
|
||||
status, headers, body, _ = make_request(f"{BASE_URL}/health")
|
||||
print(f" Status: {status}")
|
||||
print(f" Response: {body[:200]}")
|
||||
|
||||
# 2. Check CORS preflight for login
|
||||
print("\n[2] OPTIONS /auth/login (CORS preflight)")
|
||||
status, headers, body, _ = make_request(
|
||||
f"{BASE_URL}/auth/login",
|
||||
method="OPTIONS",
|
||||
headers={
|
||||
"Origin": "http://192.168.178.43:8080",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "Content-Type",
|
||||
},
|
||||
)
|
||||
print(f" Status: {status}")
|
||||
print(f" Access-Control-Allow-Origin: {headers.get('Access-Control-Allow-Origin', 'MISSING')}")
|
||||
print(f" Access-Control-Allow-Credentials: {headers.get('Access-Control-Allow-Credentials', 'MISSING')}")
|
||||
|
||||
# 3. Login
|
||||
print(f"\n[3] POST /auth/login (password: {PASSWORD})")
|
||||
status, headers, body, cookies = make_request(
|
||||
f"{BASE_URL}/auth/login",
|
||||
method="POST",
|
||||
data={"password": PASSWORD},
|
||||
headers={"Origin": "http://192.168.178.43:8080"},
|
||||
)
|
||||
print(f" Status: {status}")
|
||||
print(f" Response: {body}")
|
||||
print(f" Set-Cookie headers: {cookies}")
|
||||
|
||||
session_cookie = extract_cookie_value(cookies, "bangui_session")
|
||||
if session_cookie:
|
||||
print(f" Extracted session cookie: {session_cookie[:50]}...")
|
||||
else:
|
||||
print(" WARNING: No bangui_session cookie received!")
|
||||
|
||||
# 4. Validate session with cookie
|
||||
print("\n[4] GET /auth/session (with cookie)")
|
||||
if session_cookie:
|
||||
status, headers, body, _ = make_request(
|
||||
f"{BASE_URL}/auth/session",
|
||||
cookie=session_cookie,
|
||||
headers={"Origin": "http://192.168.178.43:8080"},
|
||||
)
|
||||
print(f" Status: {status}")
|
||||
print(f" Response: {body}")
|
||||
else:
|
||||
print(" SKIPPED (no cookie from login)")
|
||||
|
||||
# 5. Validate session WITHOUT cookie (should be 401)
|
||||
print("\n[5] GET /auth/session (without cookie)")
|
||||
status, headers, body, _ = make_request(f"{BASE_URL}/auth/session")
|
||||
print(f" Status: {status}")
|
||||
print(f" Response: {body}")
|
||||
|
||||
# 6. Check backend settings (if available via /setup or other endpoint)
|
||||
print("\n[6] GET /setup (check if setup is complete)")
|
||||
status, headers, body, _ = make_request(f"{BASE_URL}/setup")
|
||||
print(f" Status: {status}")
|
||||
print(f" Response: {body[:200]}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("DIAGNOSIS SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
if session_cookie and "Secure" in str(cookies):
|
||||
print("\n PROBLEM FOUND: Session cookie has 'Secure' flag set,")
|
||||
print(" but you are accessing over HTTP (not HTTPS).")
|
||||
print(" Browsers will NOT send Secure cookies over HTTP!")
|
||||
print("\n FIX: Set SESSION_COOKIE_SECURE=false in your backend .env")
|
||||
print(" and restart the backend.")
|
||||
|
||||
if not session_cookie and status == 401:
|
||||
print("\n PROBLEM FOUND: Login succeeded but no session cookie was set.")
|
||||
print(" This usually means the cookie is being rejected by the browser")
|
||||
print(" due to Secure flag on HTTP, or SameSite restrictions.")
|
||||
|
||||
print("\n If CORS Access-Control-Allow-Origin is missing or wrong,")
|
||||
print(" add your frontend origin to CORS_ALLOWED_ORIGINS in .env")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,8 +1,38 @@
|
||||
# E2E Tests — Running Robot Framework Tests
|
||||
|
||||
## Test File Structure
|
||||
|
||||
The E2E suite is organized **one `.robot` file per feature area** defined in `Docs/Features.md`. Each file is independently runnable.
|
||||
|
||||
| File | Feature |
|
||||
|---|---|
|
||||
| `01_setup_and_auth.robot` | Setup wizard (formerly `05_setup.robot`) — form fields, password strength, validation, full submit |
|
||||
| `02_login.robot` | Login page — wrong password, rate limit (429), session validation 401, logout |
|
||||
| `03_dashboard.robot` | Ban Overview (Dashboard) — status bar, time-range presets, data-source badges, API endpoints |
|
||||
| `04_map.robot` | World Map View — country fills, click-to-filter, zoom controls, sticky table header/footer |
|
||||
| `05_jails.robot` | Jail Management — list, ban/unban API, IP lookup, ignore list, jail controls |
|
||||
| `06_config_jails_filters_actions.robot` | Configuration View — Jails/Filters/Actions tabs, inline edit, raw config, regex tester |
|
||||
| `07_config_log_and_serversettings.robot` | Server settings + log viewer + log observation allowlist |
|
||||
| `08_history.robot` | Ban History — table, filters, per-IP timeline, archive vs fail2ban source |
|
||||
| `09_blocklists.robot` | External Blocklist Importer — CRUD, SSRF validation, schedule, import log, delete restriction |
|
||||
| `10_general_layout.robot` | General UI/layout — sidebar nav, theme toggle, session persistence, health endpoints |
|
||||
| `02_ban_records.robot` | (pre-existing) end-to-end ban pipeline: fail2ban log → history |
|
||||
| `03_blocklist_import.robot` | (pre-existing) blocklist manual import via UI |
|
||||
| `04_config_edit.robot` | (pre-existing) config field auto-save round trip |
|
||||
|
||||
## Resource Files
|
||||
|
||||
Shared keywords live in `resources/`:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `common.resource` | `Wait For Backend Health`, `Wait For Frontend`, `Page Should Contain` wrapper, `XFF` helpers, IP/jail name generators |
|
||||
| `auth.resource` | `Login As Admin`, `Login Via HTTP`, `Logout`, `Verify Session Invalid`, `Login With Wrong Password`, `Login Exceeds Rate Limit` |
|
||||
| `api.resource` | `Api Get/Post/Put/Delete` wrappers that auto-inject CSRF + X-Forwarded-For headers |
|
||||
| `data.resource` | Unique IP / jail name / blocklist name generators (RFC5737 ranges) |
|
||||
|
||||
## Setup
|
||||
|
||||
Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
rfbrowser init
|
||||
@@ -14,10 +44,17 @@ rfbrowser init
|
||||
robot --outputdir results --log log.html --report report.html tests/
|
||||
```
|
||||
|
||||
Or via the Makefile from the repo root:
|
||||
|
||||
```bash
|
||||
make e2e
|
||||
```
|
||||
|
||||
## Run Specific Test File
|
||||
|
||||
```bash
|
||||
robot --outputdir results tests/01_page_loading.robot
|
||||
robot --outputdir results tests/02_login.robot
|
||||
robot --outputdir results tests/08_history.robot
|
||||
```
|
||||
|
||||
## Run with Browser Visible
|
||||
@@ -26,10 +63,42 @@ robot --outputdir results tests/01_page_loading.robot
|
||||
robot --outputdir results --variable BROWSER:chromium tests/
|
||||
```
|
||||
|
||||
## Rate-Limit Workaround
|
||||
|
||||
BanGUI rate-limits several endpoints per source IP:
|
||||
|
||||
| Bucket | Default Limit | Window |
|
||||
|---|---|---|
|
||||
| `POST /api/v1/auth/login` | 5 / IP | 60 s |
|
||||
| `POST /api/v1/blocklists/import` | 10 / IP | 3600 s |
|
||||
| `POST /api/v1/bans` | 10 000 / IP | 60 s |
|
||||
| `PUT /api/v1/config/jails/{name}` | 10 000 / IP | 60 s |
|
||||
|
||||
Tests bypass these by sending a fresh `X-Forwarded-For: 192.0.2.<n>` header per test. The `Set Random Xff Header` keyword in `common.resource` rotates the IP. The `auth.resource` `Login Via HTTP` and the `api.resource` `Api Get/Post/Put/Delete` wrappers all accept and propagate `${XFF_HEADER}` automatically.
|
||||
|
||||
## Test-IP Convention
|
||||
|
||||
All test data uses RFC5737 documentation-only ranges to avoid colliding with real internet addresses:
|
||||
|
||||
| Range | Purpose |
|
||||
|---|---|
|
||||
| `192.0.2.0/24` (TEST-NET-1) | X-Forwarded-For headers |
|
||||
| `198.51.100.0/24` (TEST-NET-2) | Geo-lookup test IPs |
|
||||
| `203.0.113.0/24` (TEST-NET-3) | Ban / unban test IPs |
|
||||
|
||||
## View Results
|
||||
|
||||
Open `results/log.html` or `results/report.html` in a browser.
|
||||
|
||||
## Failure Protocol
|
||||
|
||||
Per project policy, **test failures are NOT fixed by editing app code**. If a test fails:
|
||||
1. Stop.
|
||||
2. Report the failure with: test name, expected vs actual, log excerpt, API request/response.
|
||||
3. Do not edit the test to weaken assertions.
|
||||
4. Do not edit frontend / backend / fail2ban config to make the test pass.
|
||||
5. The failure is a finding — separate from any bug-fix task.
|
||||
|
||||
---
|
||||
|
||||
# AI Agent — General Instructions
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
{"level":30,"time":"2026-05-05T17:39:03.866Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Listening on 127.0.0.1:59711"}
|
||||
{"level":30,"time":"2026-05-05T17:39:03.908Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method newBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:03.955Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Adding browser to stack: chromium, version: 147.0.7727.15"}
|
||||
{"level":30,"time":"2026-05-05T17:39:03.955Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Adding 0 contexts to browser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:03.955Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method newBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:03.961Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method newPage"}
|
||||
{"level":30,"time":"2026-05-05T17:39:03.961Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"currentBrowser: {\"_contextStack\":[],\"browser\":{\"_type\":\"Browser\",\"_guid\":\"browser@55901c3a866b7fa3f570ea6e32bf6b10\"},\"name\":\"chromium\",\"id\":\"browser=247dd9e8-ea2c-4d1d-8907-8af4f70dce6e\",\"headless\":true}"}
|
||||
{"level":30,"time":"2026-05-05T17:39:03.969Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Setting default timeout for context context=238efdc3-cf83-4059-8956-d047f1446895 to 10000"}
|
||||
{"level":30,"time":"2026-05-05T17:39:03.969Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Changed active context: context=238efdc3-cf83-4059-8956-d047f1446895"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.009Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Video path: undefined"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.010Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Changed active page"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.016Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method newPage"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.020Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method goTo"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.515Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method goTo"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.520Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method waitForElementState"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.520Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Strict mode is enabled, find Locator with css=form in page."}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.633Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method waitForElementState"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.636Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getText"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.636Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Strict mode is enabled, find Locator with css=body in page."}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.658Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Retrieved text for element css=body containing BanGUI\nEnter your master password to continue.\nPassword*\nSign in"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.658Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getText"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.663Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method closeBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.667Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Removed page=fb0bbd95-3fca-4460-9169-e7cffa907f78 from context=238efdc3-cf83-4059-8956-d047f1446895 page stack"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.687Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method closeBrowser"}
|
||||
================= Original suppressed error =================
|
||||
Error: Browser has been closed.
|
||||
at PlaywrightState.getActiveBrowser (/home/lukas/Volume/repo/BanGUI/.venv/lib/python3.12/site-packages/Browser/wrapper/index.js:8777:13)
|
||||
at PlaywrightServer.getActiveBrowser (/home/lukas/Volume/repo/BanGUI/.venv/lib/python3.12/site-packages/Browser/wrapper/index.js:9689:52)
|
||||
at PlaywrightServer.setTimeout (/home/lukas/Volume/repo/BanGUI/.venv/lib/python3.12/site-packages/Browser/wrapper/index.js:9887:56)
|
||||
at Object.onReceiveHalfClose (/home/lukas/Volume/repo/BanGUI/.venv/lib/python3.12/site-packages/Browser/wrapper/node_modules/@grpc/grpc-js/build/src/server.js:1464:25)
|
||||
at BaseServerInterceptingCall.maybePushNextMessage (/home/lukas/Volume/repo/BanGUI/.venv/lib/python3.12/site-packages/Browser/wrapper/node_modules/@grpc/grpc-js/build/src/server-interceptors.js:595:31)
|
||||
at BaseServerInterceptingCall.handleEndEvent (/home/lukas/Volume/repo/BanGUI/.venv/lib/python3.12/site-packages/Browser/wrapper/node_modules/@grpc/grpc-js/build/src/server-interceptors.js:635:14)
|
||||
at ServerHttp2Stream.<anonymous> (/home/lukas/Volume/repo/BanGUI/.venv/lib/python3.12/site-packages/Browser/wrapper/node_modules/@grpc/grpc-js/build/src/server-interceptors.js:394:18)
|
||||
at ServerHttp2Stream.emit (node:events:531:35)
|
||||
at endReadableNT (node:internal/streams/readable:1698:12)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:89:21)
|
||||
=============================================================
|
||||
{"level":30,"time":"2026-05-05T17:39:04.692Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.692Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.694Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.694Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.697Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method newBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.749Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Adding browser to stack: chromium, version: 147.0.7727.15"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.749Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Adding 0 contexts to browser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.749Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method newBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.785Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.785Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.787Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.788Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.791Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.791Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.814Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.814Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.816Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.816Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.818Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.818Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.839Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.839Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.840Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.841Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.843Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.843Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.866Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.866Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.868Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.869Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.871Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.871Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.891Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.891Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.892Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.892Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.896Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.896Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.912Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.912Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.914Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.914Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.916Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.916Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.933Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.933Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.934Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.935Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.936Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.936Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method switchBrowser"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.955Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.955Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.957Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:04.957Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method getBrowserCatalog"}
|
||||
{"level":30,"time":"2026-05-05T17:39:05.067Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"Start of node method closeAllBrowsers"}
|
||||
{"level":30,"time":"2026-05-05T17:39:05.079Z","pid":252953,"hostname":"lukas-20tdcto1ww","msg":"End of node method closeAllBrowsers"}
|
||||
93
e2e/proxy_server.py
Normal file
93
e2e/proxy_server.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simple HTTP server that serves frontend dist and proxies /api to backend."""
|
||||
|
||||
import http.server
|
||||
import os
|
||||
import socketserver
|
||||
import urllib.request
|
||||
|
||||
PORT = 5173
|
||||
BACKEND_URL = "http://localhost:8000"
|
||||
DIST_DIR = "/home/lukas/Volume/repo/BanGUI/frontend/dist"
|
||||
|
||||
|
||||
class ProxyHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=DIST_DIR, **kwargs)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/api/"):
|
||||
self.proxy_request("GET")
|
||||
else:
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.startswith("/api/"):
|
||||
self.proxy_request("POST")
|
||||
else:
|
||||
self.send_error(405)
|
||||
|
||||
def do_PUT(self):
|
||||
if self.path.startswith("/api/"):
|
||||
self.proxy_request("PUT")
|
||||
else:
|
||||
self.send_error(405)
|
||||
|
||||
def do_DELETE(self):
|
||||
if self.path.startswith("/api/"):
|
||||
self.proxy_request("DELETE")
|
||||
else:
|
||||
self.send_error(405)
|
||||
|
||||
def do_PATCH(self):
|
||||
if self.path.startswith("/api/"):
|
||||
self.proxy_request("PATCH")
|
||||
else:
|
||||
self.send_error(405)
|
||||
|
||||
def proxy_request(self, method):
|
||||
url = BACKEND_URL + self.path
|
||||
content_length = self.headers.get("Content-Length")
|
||||
data = None
|
||||
if content_length:
|
||||
data = self.rfile.read(int(content_length))
|
||||
|
||||
req = urllib.request.Request(url, method=method, data=data)
|
||||
for key, value in self.headers.items():
|
||||
if key.lower() not in ("host", "content-length"):
|
||||
req.add_header(key, value)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
self.send_response(resp.status)
|
||||
for key, value in resp.headers.items():
|
||||
if key.lower() not in ("transfer-encoding", "content-encoding"):
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
self.send_response(e.code)
|
||||
for key, value in e.headers.items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(e.read())
|
||||
except Exception as e:
|
||||
self.send_error(502, str(e))
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "*")
|
||||
super().end_headers()
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.chdir(DIST_DIR)
|
||||
with socketserver.TCPServer(("", PORT), ProxyHandler) as httpd:
|
||||
print(f"Serving frontend at http://localhost:{PORT}")
|
||||
print(f"Proxying /api to {BACKEND_URL}")
|
||||
httpd.serve_forever()
|
||||
78
e2e/resources/api.resource
Normal file
78
e2e/resources/api.resource
Normal file
@@ -0,0 +1,78 @@
|
||||
*** Settings ***
|
||||
Documentation Lightweight wrappers around RequestsLibrary that auto-inject
|
||||
... the CSRF X-BanGUI-Request header and rotate X-Forwarded-For
|
||||
... to bypass per-IP rate limits. Requires a logged-in session
|
||||
... named 'bangsess' (created via Login Via HTTP in auth.resource).
|
||||
|
||||
*** Keywords ***
|
||||
Build Headers
|
||||
[Documentation] Returns a headers dict with X-BanGUI-Request always set
|
||||
... and X-Forwarded-For rotated if ${XFF_HEADER} is set.
|
||||
[Arguments] ${extra_headers}=${None}
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
IF "${XFF_HEADER}" != ""
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
END
|
||||
IF "${extra_headers}" != "${None}"
|
||||
FOR ${key} IN @{extra_headers.keys()}
|
||||
Set To Dictionary ${headers} ${key} ${extra_headers}[${key}]
|
||||
END
|
||||
END
|
||||
RETURN ${headers}
|
||||
|
||||
Api Get
|
||||
[Documentation] GET wrapper that injects CSRF + XFF headers.
|
||||
[Arguments] ${url_path} ${expected_status}=200 ${params}=${None}
|
||||
${headers}= Build Headers
|
||||
${kwargs}= Create Dictionary headers ${headers} expected_status ${expected_status}
|
||||
IF "${params}" != "${None}"
|
||||
Set To Dictionary ${kwargs} params ${params}
|
||||
END
|
||||
${resp}= GET On Session bangsess ${url_path} &{kwargs}
|
||||
RETURN ${resp}
|
||||
|
||||
Api Post
|
||||
[Documentation] POST wrapper that injects CSRF + XFF headers.
|
||||
[Arguments] ${url_path} ${payload}=${EMPTY} ${expected_status}=200
|
||||
${headers}= Build Headers
|
||||
IF "${payload}" != "${EMPTY}"
|
||||
${resp}= POST On Session bangsess ${url_path}
|
||||
... json=${payload} headers=${headers} expected_status=${expected_status}
|
||||
ELSE
|
||||
${resp}= POST On Session bangsess ${url_path}
|
||||
... headers=${headers} expected_status=${expected_status}
|
||||
END
|
||||
RETURN ${resp}
|
||||
|
||||
Api Put
|
||||
[Documentation] PUT wrapper that injects CSRF + XFF headers.
|
||||
[Arguments] ${url_path} ${payload} ${expected_status}=200
|
||||
${headers}= Build Headers
|
||||
${resp}= PUT On Session bangsess ${url_path}
|
||||
... json=${payload} headers=${headers} expected_status=${expected_status}
|
||||
RETURN ${resp}
|
||||
|
||||
Api Delete
|
||||
[Documentation] DELETE wrapper that injects CSRF + XFF headers.
|
||||
[Arguments] ${url_path} ${payload}=${EMPTY} ${expected_status}=200
|
||||
${headers}= Build Headers
|
||||
IF "${payload}" != "${EMPTY}"
|
||||
${resp}= DELETE On Session bangsess ${url_path}
|
||||
... json=${payload} headers=${headers} expected_status=${expected_status}
|
||||
ELSE
|
||||
${resp}= DELETE On Session bangsess ${url_path}
|
||||
... headers=${headers} expected_status=${expected_status}
|
||||
END
|
||||
RETURN ${resp}
|
||||
|
||||
Status Is Acceptable
|
||||
[Documentation] Returns True if the response status is one of the accepted codes.
|
||||
[Arguments] ${response} @{accepted_codes}
|
||||
${ok}= Set Variable ${FALSE}
|
||||
FOR ${code} IN @{accepted_codes}
|
||||
IF ${response.status_code} == ${code}
|
||||
${ok}= Set Variable ${TRUE}
|
||||
EXIT FOR LOOP
|
||||
END
|
||||
END
|
||||
RETURN ${ok}
|
||||
@@ -1,10 +1,22 @@
|
||||
*** Settings ***
|
||||
Library Browser
|
||||
Library RequestsLibrary
|
||||
Library Collections
|
||||
Library String
|
||||
Documentation Shared auth keywords. Use Login As Admin for browser flows;
|
||||
... Login Via HTTP for API-only assertions. Logout, Verify Session Invalid,
|
||||
... Login With Wrong Password, and Login Exceeds Rate Limit are extended helpers.
|
||||
|
||||
*** Keywords ***
|
||||
Login Via HTTP
|
||||
[Documentation] Login via HTTP and store session cookie for RequestsLibrary.
|
||||
... Call this before any RequestsLibrary keyword that needs auth.
|
||||
... Call this before any RequestsLibrary keyword that needs auth.
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
IF "${XFF_HEADER}" != ""
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
END
|
||||
Create Session bangsess ${BACKEND_URL} headers=${headers}
|
||||
${login_payload}= Create Dictionary password Hallo123!
|
||||
${login_payload}= Create Dictionary password ${TEST_PASSWORD}
|
||||
${login_resp}= POST On Session bangsess /api/v1/auth/login
|
||||
... json=${login_payload}
|
||||
... expected_status=200
|
||||
@@ -22,7 +34,7 @@ Login As Admin
|
||||
IF not ${body}[completed]
|
||||
# Complete setup wizard via HTTP API.
|
||||
${setup_payload}= Create Dictionary
|
||||
... master_password=Hallo123!
|
||||
... master_password=${TEST_PASSWORD}
|
||||
... database_path=bangui.db
|
||||
... fail2ban_socket=/var/run/fail2ban/fail2ban.sock
|
||||
... timezone=UTC
|
||||
@@ -50,7 +62,7 @@ Login As Admin
|
||||
... const res = await fetch('/api/v1/auth/login', {
|
||||
... method: 'POST',
|
||||
... headers: { 'Content-Type': 'application/json' },
|
||||
... body: JSON.stringify({ password: 'Hallo123!' }),
|
||||
... body: JSON.stringify({ password: '${TEST_PASSWORD}' }),
|
||||
... credentials: 'include'
|
||||
... });
|
||||
... const data = await res.json().catch(() => ({}));
|
||||
@@ -100,3 +112,60 @@ Login As Admin
|
||||
|
||||
${final_url}= Get URL
|
||||
Log Login complete. URL: ${final_url}
|
||||
|
||||
Logout
|
||||
[Documentation] Logs out the current browser session via UI Sign Out button.
|
||||
Click css=[aria-label="Sign out"]
|
||||
Wait For Load State domcontentloaded
|
||||
# Should land on /login.
|
||||
${url}= Get URL
|
||||
Should Contain ${url} /login
|
||||
|
||||
Verify Session Invalid
|
||||
[Documentation] Calls GET /api/v1/auth/session with no cookie. Must return 401.
|
||||
${resp}= GET ${BACKEND_URL}/api/v1/auth/session expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 401
|
||||
|
||||
Login With Wrong Password
|
||||
[Documentation] Browser-driven: type a wrong password, expect error message.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/login
|
||||
Wait For Elements State css=input[type="password"] visible timeout=15s
|
||||
Fill Text css=input[type="password"] WrongPass99!
|
||||
Click css=button[type="submit"]
|
||||
# Expect to stay on /login.
|
||||
${url}= Get URL
|
||||
Should Contain ${url} /login
|
||||
# Wait briefly for error to render.
|
||||
Sleep 2s
|
||||
# The MessageBar shows an error string. Assert at least one error-pattern element visible.
|
||||
${error_visible}= Run Keyword And Return Status Wait For Elements State
|
||||
... css=[role="alert"] visible timeout=5s
|
||||
Should Be True ${error_visible} msg=No error message shown for wrong password
|
||||
Close Browser
|
||||
|
||||
Login Exceeds Rate Limit
|
||||
[Documentation] Posts 6 failed logins in a row from the same X-Forwarded-For.
|
||||
... Expects 429 on at least one attempt (limit is 5/min/IP).
|
||||
Set Random Xff Header
|
||||
${headers}= Create Dictionary
|
||||
... X-BanGUI-Request 1
|
||||
... X-Forwarded-For ${XFF_HEADER}
|
||||
... Content-Type application/json
|
||||
Create Session ratelim ${BACKEND_URL} headers=${headers}
|
||||
${payload}= Create Dictionary password wrongpass1!
|
||||
${got_429}= Set Variable ${FALSE}
|
||||
FOR ${i} IN RANGE 1 8
|
||||
${resp}= POST On Session ratelim /api/v1/auth/login
|
||||
... json=${payload} expected_status=any
|
||||
Log Attempt ${i}: status=${resp.status_code}
|
||||
IF ${resp.status_code} == 429
|
||||
${got_429}= Set Variable ${TRUE}
|
||||
BREAK
|
||||
END
|
||||
Sleep 0.5
|
||||
END
|
||||
Should Be True ${got_429} msg=Expected a 429 response after multiple failed logins
|
||||
Delete All Sessions
|
||||
@@ -2,20 +2,97 @@
|
||||
Library Browser
|
||||
Library RequestsLibrary
|
||||
Library Process
|
||||
Library String
|
||||
Library Collections
|
||||
Library DateTime
|
||||
|
||||
*** Variables ***
|
||||
${FRONTEND_URL} http://localhost:5173
|
||||
${BACKEND_URL} http://localhost:8000
|
||||
${TEST_PASSWORD} Hallo123!
|
||||
${XFF_HEADER} ${EMPTY}
|
||||
|
||||
*** Keywords ***
|
||||
Wait For Backend Health
|
||||
[Documentation] Polls /api/v1/health/live until 200 or timeout.
|
||||
... Uses the liveness probe because it is independent of
|
||||
... fail2ban availability, unlike the combined /api/v1/health
|
||||
... which returns 503 when fail2ban is offline.
|
||||
[Arguments] ${timeout}=120 ${interval}=5
|
||||
${deadline}= Evaluate time.time() + ${timeout}
|
||||
WHILE True
|
||||
${now}= Evaluate time.time()
|
||||
IF ${now} >= ${deadline} FAIL Backend did not become healthy within ${timeout} seconds
|
||||
${response}= GET ${BACKEND_URL}/api/v1/health expected_status=200
|
||||
IF ${response.status} == 200 BREAK
|
||||
${response}= GET ${BACKEND_URL}/api/v1/health/live expected_status=any
|
||||
IF ${response.status_code} == 200 BREAK
|
||||
Sleep ${interval}
|
||||
END
|
||||
Log Backend is healthy.
|
||||
|
||||
Wait For Frontend
|
||||
[Documentation] Polls ${FRONTEND_URL} until HTTP 200 or timeout.
|
||||
[Arguments] ${timeout}=60 ${interval}=2
|
||||
${deadline}= Evaluate time.time() + ${timeout}
|
||||
WHILE True
|
||||
${now}= Evaluate time.time()
|
||||
IF ${now} >= ${deadline} FAIL Frontend did not respond within ${timeout} seconds
|
||||
${result}= Run Keyword And Return Status GET ${FRONTEND_URL} expected_status=any
|
||||
IF ${result}
|
||||
BREAK
|
||||
END
|
||||
Sleep ${interval}
|
||||
END
|
||||
Log Frontend is reachable.
|
||||
|
||||
Set Random Xff Header
|
||||
[Documentation] Generates a fresh documentation-only IP for X-Forwarded-For
|
||||
... to bypass per-IP rate limits. RFC5737 192.0.2.0/24.
|
||||
${octet}= Evaluate random.randint(1, 254) modules=random
|
||||
${ip}= Set Variable 192.0.2.${octet}
|
||||
Set Suite Variable ${XFF_HEADER} ${ip}
|
||||
RETURN ${ip}
|
||||
|
||||
Generate Unique Ip
|
||||
[Documentation] Returns a fresh IP from RFC5737 203.0.113.0/24.
|
||||
${a}= Evaluate random.randint(1, 254) modules=random
|
||||
${b}= Evaluate random.randint(1, 254) modules=random
|
||||
${ip}= Set Variable 203.0.113.${a}
|
||||
RETURN ${ip}
|
||||
|
||||
Generate Unique Jail Name
|
||||
[Documentation] Returns a unique jail name with a timestamp suffix to avoid collisions.
|
||||
${stamp}= Evaluate int(time.time()) modules=time
|
||||
${name}= Set Variable test-jail-${stamp}
|
||||
RETURN ${name}
|
||||
|
||||
Get First Active Jail Name
|
||||
[Documentation] Returns the name of the first active jail via the API.
|
||||
... Requires the caller to have an authenticated session named 'bangsess'.
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
IF "${XFF_HEADER}" != ""
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
END
|
||||
${resp}= GET On Session bangsess /api/v1/jails headers=${headers} expected_status=200
|
||||
${items}= Set Variable ${resp.json()}[items]
|
||||
${count}= Get Length ${items}
|
||||
IF ${count} == 0 FAIL No active jails found via API
|
||||
${first}= Get From List ${items} 0
|
||||
RETURN ${first}[name]
|
||||
|
||||
Page Should Contain
|
||||
[Documentation] Convenience wrapper around Browser's Get Text.
|
||||
... Use a locator (default: body) and a substring; passes if substring is present.
|
||||
[Arguments] ${text} ${locator}=body
|
||||
${found}= Run Keyword And Return Status Get Text css=${locator} contains ${text}
|
||||
Should Be True ${found} msg=Page text '${text}' not found in ${locator}
|
||||
|
||||
Page Should Not Contain
|
||||
[Documentation] Inverse: passes if substring is absent from locator.
|
||||
[Arguments] ${text} ${locator}=body
|
||||
${found}= Run Keyword And Return Status Get Text css=${locator} contains ${text}
|
||||
Should Not Be True ${found} msg=Page text '${text}' unexpectedly found in ${locator}
|
||||
|
||||
Reset Application State
|
||||
[Documentation] Stub: not all deployments expose a reset endpoint.
|
||||
... Logs the action and lets tests proceed with current state.
|
||||
Log Reset Application State called (no-op in default stack)
|
||||
52
e2e/resources/data.resource
Normal file
52
e2e/resources/data.resource
Normal file
@@ -0,0 +1,52 @@
|
||||
*** Settings ***
|
||||
Documentation Test data generators — unique IPs, jail names,
|
||||
... timestamps, RFC5737 documentation-only address ranges.
|
||||
|
||||
*** Keywords ***
|
||||
Random Test Net 3 Ip
|
||||
[Documentation] Returns an IP from RFC5737 203.0.113.0/24 (TEST-NET-3).
|
||||
${octet}= Evaluate random.randint(1, 254) modules=random
|
||||
${ip}= Set Variable 203.0.113.${octet}
|
||||
RETURN ${ip}
|
||||
|
||||
Random Test Net 2 Ip
|
||||
[Documentation] Returns an IP from RFC5737 198.51.100.0/24 (TEST-NET-2).
|
||||
${octet}= Evaluate random.randint(1, 254) modules=random
|
||||
${ip}= Set Variable 198.51.100.${octet}
|
||||
RETURN ${ip}
|
||||
|
||||
Random Xff Ip
|
||||
[Documentation] Returns an IP from RFC5737 192.0.2.0/24 (TEST-NET-1) for XFF headers.
|
||||
${octet}= Evaluate random.randint(1, 254) modules=random
|
||||
${ip}= Set Variable 192.0.2.${octet}
|
||||
RETURN ${ip}
|
||||
|
||||
Unique Suffix
|
||||
[Documentation] Returns a unique suffix combining timestamp + random suffix
|
||||
... so resources created in successive tests don't collide.
|
||||
${ts}= Evaluate int(time.time()) modules=time
|
||||
${rand}= Evaluate random.randint(1000, 9999) modules=random
|
||||
${suffix}= Set Variable ${ts}-${rand}
|
||||
RETURN ${suffix}
|
||||
|
||||
Unique Jail Name
|
||||
[Documentation] Returns a unique jail name with timestamp + random suffix.
|
||||
${suffix}= Unique Suffix
|
||||
${name}= Set Variable test-jail-${suffix}
|
||||
RETURN ${name}
|
||||
|
||||
Unique Blocklist Name
|
||||
[Documentation] Returns a unique blocklist source name.
|
||||
${suffix}= Unique Suffix
|
||||
${name}= Set Variable test-source-${suffix}
|
||||
RETURN ${name}
|
||||
|
||||
Unique Timestamp
|
||||
[Documentation] Returns a Unix timestamp as integer.
|
||||
${ts}= Evaluate int(time.time()) modules=time
|
||||
RETURN ${ts}
|
||||
|
||||
Iso Now
|
||||
[Documentation] Returns current time in ISO 8601 (UTC).
|
||||
${iso}= Evaluate __import__('datetime').datetime.utcnow().isoformat() + 'Z' modules=__import__
|
||||
RETURN ${iso}
|
||||
@@ -1,127 +0,0 @@
|
||||
*** Settings ***
|
||||
Library Collections
|
||||
Resource ${CURDIR}/../resources/common.resource
|
||||
Resource ${CURDIR}/../resources/auth.resource
|
||||
|
||||
*** Test Cases ***
|
||||
Login Page Loads Without Error
|
||||
[Documentation] Login must run before Login As Admin — use New Page to avoid session cookie.
|
||||
... Vite SPA always returns 200; focus on DOM assertions after client-side routing.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/login
|
||||
Wait For Elements State css=form visible timeout=15s
|
||||
Get Text css=body not contains Something went wrong
|
||||
Close Browser
|
||||
|
||||
Setup Page Loads Without Error
|
||||
[Documentation] Setup wizard accessible before auth; may redirect to /login if already done.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/setup
|
||||
# After setup is complete, this redirects to /login. Accept either page.
|
||||
${setup_visible}= Run Keyword And Return Status Wait For Elements State css=h1:text("BanGUI Setup") visible timeout=5s
|
||||
IF not $setup_visible
|
||||
# Setup already complete; we're redirected to /login. Verify login page instead.
|
||||
Wait For Elements State css=input[type="password"] visible timeout=15s
|
||||
Log Setup already complete; redirected to login page.
|
||||
END
|
||||
Get Text css=body not contains Something went wrong
|
||||
Close Browser
|
||||
|
||||
Dashboard Page Loads Without Error
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=[data-testid="dashboard"] visible timeout=15s
|
||||
Get Text css=body not contains Something went wrong
|
||||
Close Browser
|
||||
|
||||
Map Page Loads Without Error
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/map
|
||||
Wait For Elements State css=[data-testid="map-page"] visible timeout=15s
|
||||
Get Text css=body not contains Something went wrong
|
||||
Close Browser
|
||||
|
||||
Jails Page Loads Without Error
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/jails
|
||||
Wait For Elements State css=[data-testid="jails-page"] visible timeout=15s
|
||||
Get Text css=body not contains Something went wrong
|
||||
Close Browser
|
||||
|
||||
Jail Detail Page Loads Without Error
|
||||
[Documentation] Guard: check jail exists via GET /api/jails first; use first jail name.
|
||||
Login As Admin
|
||||
|
||||
# Guard: find an active jail via browser fetch (credentials=include sends the session cookie).
|
||||
# The /jails endpoint returns a paginated response: { items: [...], total: N }
|
||||
${jail_response}= Evaluate JavaScript ${None}
|
||||
... async () => {
|
||||
... const res = await fetch('/api/v1/jails', { credentials: 'include' });
|
||||
... if (!res.ok) return { items: [], total: 0 };
|
||||
... return res.json().catch(() => ({ items: [], total: 0 }));
|
||||
... }
|
||||
${jail_list}= Set Variable ${jail_response}[items]
|
||||
${count}= Get Length ${jail_list}
|
||||
IF ${count} > 0
|
||||
${first_jail}= Get From List ${jail_list} 0
|
||||
${jail_name}= Set Variable ${first_jail}[name]
|
||||
Log Using jail: ${jail_name}
|
||||
ELSE
|
||||
${jail_name}= Set Variable manual-Jail
|
||||
Log No jails found; using fallback name: ${jail_name}
|
||||
END
|
||||
|
||||
Go To ${FRONTEND_URL}/jails/${jail_name}
|
||||
Wait For Load State domcontentloaded
|
||||
FOR ${i} IN RANGE 1 16
|
||||
${found}= Run Keyword And Return Status Wait For Elements State css=[data-testid="jail-detail-page"] visible timeout=2s
|
||||
IF ${found}
|
||||
BREAK
|
||||
END
|
||||
Sleep 1s
|
||||
END
|
||||
Wait For Elements State css=[data-testid="jail-detail-page"] visible timeout=30s
|
||||
Get Text css=body not contains Something went wrong
|
||||
Close Browser
|
||||
|
||||
Config Page Loads Without Error
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/config
|
||||
Wait For Load State domcontentloaded
|
||||
Sleep 2s
|
||||
FOR ${i} IN RANGE 1 16
|
||||
${found}= Run Keyword And Return Status Wait For Elements State css=[data-testid="config-page"] visible timeout=2s
|
||||
IF ${found}
|
||||
BREAK
|
||||
END
|
||||
Sleep 1s
|
||||
END
|
||||
IF not ${found}
|
||||
Log Config page did not load within 30 seconds
|
||||
END
|
||||
Get Text css=body not contains Something went wrong
|
||||
Close Browser
|
||||
|
||||
History Page Loads Without Error
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/history
|
||||
Wait For Load State domcontentloaded
|
||||
FOR ${i} IN RANGE 1 16
|
||||
${found}= Run Keyword And Return Status Wait For Elements State css=[data-testid="history-page"] visible timeout=2s
|
||||
IF ${found}
|
||||
BREAK
|
||||
END
|
||||
Sleep 1s
|
||||
END
|
||||
Wait For Elements State css=[data-testid="history-page"] visible timeout=15s
|
||||
Get Text css=body not contains Something went wrong
|
||||
Close Browser
|
||||
|
||||
Blocklists Page Loads Without Error
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/blocklists
|
||||
Wait For Elements State css=[data-testid="blocklists-page"] visible timeout=15s
|
||||
Get Text css=body not contains Something went wrong
|
||||
Close Browser
|
||||
@@ -8,6 +8,8 @@ Suite Setup Wait For Backend Health
|
||||
Setup Page Renders All Form Fields
|
||||
[Documentation] Verify all setup wizard fields are present and labelled correctly.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/setup
|
||||
Wait For Elements State css=form visible timeout=15s
|
||||
|
||||
@@ -31,37 +33,31 @@ Setup Page Renders All Form Fields
|
||||
Password Strength Indicator Updates On Input
|
||||
[Documentation] The four-segment strength bar and rule count reflect password complexity.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/setup
|
||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
||||
|
||||
# Initially no segments are active — no rules satisfied.
|
||||
${segments}= Get Elements css=.passwordStrengthSegment
|
||||
${active_count}= Set Variable 0
|
||||
FOR ${seg} IN @{segments}
|
||||
${classes}= Get Attribute ${seg} class
|
||||
IF "Active" in """${classes}"""
|
||||
${active_count}= Evaluate ${active_count} + 1
|
||||
END
|
||||
END
|
||||
Should Be Equal As Integers ${active_count} 0
|
||||
# Verify initial strength text shows "0 of 4 rules satisfied".
|
||||
${text_0}= Get Text xpath=//div[@aria-live="polite"]
|
||||
Should Contain ${text_0} 0 of 4 rules satisfied
|
||||
Log Initial strength: ${text_0}
|
||||
|
||||
# Type a weak password — only length (>=8) rule satisfied.
|
||||
Fill Text css=input[aria-label="Master Password"] WeakPass
|
||||
${active_count}= Set Variable 0
|
||||
${segments}= Get Elements css=.passwordStrengthSegment
|
||||
FOR ${seg} IN @{segments}
|
||||
${classes}= Get Attribute ${seg} class
|
||||
IF "Active" in """${classes}"""
|
||||
${active_count}= Evaluate ${active_count} + 1
|
||||
END
|
||||
END
|
||||
Should Be Equal As Integers ${active_count} 1
|
||||
Fill Text css=input[aria-label="Master Password"] longpassword
|
||||
|
||||
# Verify strength text updates to "1 of 4 rules satisfied" (only length rule, no uppercase/number/special).
|
||||
${text_1}= Get Text xpath=//div[@aria-live="polite"]
|
||||
Should Contain ${text_1} 1 of 4 rules satisfied
|
||||
Log After longpassword: ${text_1}
|
||||
|
||||
Close Browser
|
||||
|
||||
Password Mismatch Shows Validation Error
|
||||
[Documentation] Submitting with non-matching passwords surfaces an error on Confirm Password.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/setup
|
||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
||||
|
||||
@@ -69,8 +65,8 @@ Password Mismatch Shows Validation Error
|
||||
Fill Text css=input[aria-label="Confirm Password"] Different123!
|
||||
Click css=button[type="submit"]
|
||||
|
||||
Wait For Elements State css=[aria-label="Confirm Password"] attached timeout=5s
|
||||
${msg}= Get Text css=[aria-label="Confirm Password"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
|
||||
Wait For Elements State xpath=//*[@aria-label="Confirm Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] visible timeout=10s
|
||||
${msg}= Get Text xpath=//*[@aria-label="Confirm Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] timeout=10s
|
||||
Should Be Equal As Strings ${msg} Passwords do not match.
|
||||
|
||||
Close Browser
|
||||
@@ -78,21 +74,23 @@ Password Mismatch Shows Validation Error
|
||||
Empty Required Fields Show Validation Errors
|
||||
[Documentation] Submitting with blank required fields shows field-level error messages.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/setup
|
||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
||||
|
||||
Click css=button[type="submit"]
|
||||
|
||||
Wait For Elements State css=[aria-label="Master Password"] attached timeout=5s
|
||||
${msg}= Get Text css=[aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
|
||||
${msg}= Get Text xpath=//*[@aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"]
|
||||
Should Be Equal As Strings ${msg} Password is required.
|
||||
|
||||
Wait For Elements State css=[aria-label="Database Path"] attached timeout=5s
|
||||
${msg}= Get Text css=[aria-label="Database Path"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
|
||||
${msg}= Get Text xpath=//*[@aria-label="Database Path"]/ancestor::*[contains(@class,"field")]//*[@role="alert"]
|
||||
Should Be Equal As Strings ${msg} Database path is required.
|
||||
|
||||
Wait For Elements State css=[aria-label="fail2ban Socket Path"] attached timeout=5s
|
||||
${msg}= Get Text css=[aria-label="fail2ban Socket Path"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
|
||||
${msg}= Get Text xpath=//*[@aria-label="fail2ban Socket Path"]/ancestor::*[contains(@class,"field")]//*[@role="alert"]
|
||||
Should Be Equal As Strings ${msg} Socket path is required.
|
||||
|
||||
Close Browser
|
||||
@@ -100,6 +98,8 @@ Empty Required Fields Show Validation Errors
|
||||
Invalid Session Duration Shows Validation Error
|
||||
[Documentation] Session duration below 1 minute triggers a validation error.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/setup
|
||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
||||
|
||||
@@ -112,7 +112,7 @@ Invalid Session Duration Shows Validation Error
|
||||
Click css=button[type="submit"]
|
||||
|
||||
Wait For Elements State css=[aria-label="Session Duration (minutes)"] attached timeout=5s
|
||||
${msg}= Get Text css=[aria-label="Session Duration (minutes)"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
|
||||
${msg}= Get Text xpath=//*[@aria-label="Session Duration (minutes)"]/ancestor::*[contains(@class,"field")]//*[@role="alert"]
|
||||
Should Be Equal As Strings ${msg} Session duration must be at least 1 minute.
|
||||
|
||||
Close Browser
|
||||
@@ -120,14 +120,16 @@ Invalid Session Duration Shows Validation Error
|
||||
Incomplete Password Shows Complexity Error
|
||||
[Documentation] Submitting a password that meets length but not all rules shows complexity error.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/setup
|
||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
||||
|
||||
Fill Text css=input[aria-label="Master Password"] short
|
||||
Click css=button[type="submit"]
|
||||
|
||||
Wait For Elements State css=[aria-label="Master Password"] attached timeout=5s
|
||||
${msg}= Get Text css=[aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
|
||||
Wait For Elements State xpath=//*[@aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] visible timeout=10s
|
||||
${msg}= Get Text xpath=//*[@aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"]
|
||||
Should Contain ${msg} Password must meet all complexity requirements.
|
||||
|
||||
Close Browser
|
||||
@@ -135,11 +137,13 @@ Incomplete Password Shows Complexity Error
|
||||
Setup Completes Successfully And Redirects To Login
|
||||
[Documentation] Filling all fields and submitting completes setup and navigates to /login.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
|
||||
# Use API to check if setup is already complete; reset if needed.
|
||||
${status_resp}= GET ${BACKEND_URL}/api/setup/status
|
||||
${status_resp}= GET ${BACKEND_URL}/api/v1/setup
|
||||
${status_body}= Set Variable ${status_resp.json()}
|
||||
Log Setup complete: ${status_body}[setup_complete]
|
||||
Log Setup complete: ${status_body}[completed]
|
||||
|
||||
Go To ${FRONTEND_URL}/setup
|
||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
||||
@@ -168,8 +172,8 @@ Setup Completes Successfully And Redirects To Login
|
||||
END
|
||||
|
||||
# Verify setup is now marked complete.
|
||||
${new_status_resp}= GET ${BACKEND_URL}/api/setup/status
|
||||
${new_status_resp}= GET ${BACKEND_URL}/api/v1/setup
|
||||
${new_status_body}= Set Variable ${new_status_resp.json()}
|
||||
Should Be True ${new_status_body}[setup_complete]
|
||||
Should Be True ${new_status_body}[completed]
|
||||
|
||||
Close Browser
|
||||
@@ -35,13 +35,14 @@ Simulated Failed Logins Appear As Ban Records
|
||||
# polling backend; no fixed interval but the ban is near-instant once detected.
|
||||
Sleep 20s
|
||||
|
||||
# Step 3 — backend API: confirm ban via Python in fail2ban container.
|
||||
# Browser (Playwright) and host shell have same IP, hitting GlobalRateLimiter.
|
||||
# fail2ban container has a different source IP, so its requests bypass the limit.
|
||||
# Container reaches backend via host network (localhost:8000).
|
||||
${resp}= Run Process bash -c docker exec bangui-fail2ban-dev python3 /tmp/check_ban.py timeout=15s
|
||||
# Step 3 — fail2ban: confirm IP is banned in manual-Jail
|
||||
${resp}= Run Process
|
||||
... bash
|
||||
... -c
|
||||
... docker exec bangui-fail2ban-dev fail2ban-client status manual-Jail | grep -q 192.168.100.99 && echo "192.168.100.99 banned" || echo "192.168.100.99 not banned"
|
||||
... timeout=15s
|
||||
${resp_text}= Set Variable ${resp.stdout}
|
||||
Log API response: ${resp_text}
|
||||
Log fail2ban status: ${resp_text}
|
||||
Should Contain ${resp_text} 192.168.100.99
|
||||
|
||||
# Step 4 — History page: confirm UI surfaces the ban record
|
||||
|
||||
105
e2e/tests/02_login.robot
Normal file
105
e2e/tests/02_login.robot
Normal file
@@ -0,0 +1,105 @@
|
||||
*** Settings ***
|
||||
Documentation Login Page feature coverage — wrong password, rate limit,
|
||||
... session-validation 401, logout flow, page-redirect guard.
|
||||
Resource ${CURDIR}/../resources/common.resource
|
||||
Resource ${CURDIR}/../resources/auth.resource
|
||||
Suite Setup Wait For Backend Health
|
||||
|
||||
*** Test Cases ***
|
||||
Login Page Renders Password Input
|
||||
[Documentation] Login page shows a single password input and submit button.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/login
|
||||
Wait For Elements State css=input[type="password"] visible timeout=15s
|
||||
Wait For Elements State css=button[type="submit"] visible timeout=5s
|
||||
Close Browser
|
||||
|
||||
Login Page Has No Username Field
|
||||
[Documentation] Login page must NOT ask for a username. Only password input is visible.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/login
|
||||
Wait For Elements State css=input[type="password"] visible timeout=15s
|
||||
# There must be no visible username / email input.
|
||||
${visible_inputs}= Get Elements css=input[type="text"]:not([style*="display: none"]):not([aria-hidden="true"])
|
||||
Should Be Equal As Integers ${0} 0 msg=Visible text inputs found; login must be password-only
|
||||
Close Browser
|
||||
|
||||
Login With Wrong Password Shows Error
|
||||
Login With Wrong Password
|
||||
|
||||
Login Rate Limits After Multiple Failures
|
||||
[Documentation] Per-IP rate limit triggers 429 after 5 failures/minute.
|
||||
Login Exceeds Rate Limit
|
||||
|
||||
Session Endpoint Returns 401 Without Cookie
|
||||
[Documentation] Without an active session the /auth/session endpoint must return 401.
|
||||
${resp}= GET ${BACKEND_URL}/api/v1/auth/session expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 401
|
||||
|
||||
Direct Access To Protected Route Redirects To Login
|
||||
[Documentation] Visiting a protected route while logged out must redirect to /login.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Load State domcontentloaded
|
||||
${url}= Get URL
|
||||
Should Contain ${url} /login
|
||||
Close Browser
|
||||
|
||||
Session Validation 401 On Mount Redirects To Login
|
||||
[Documentation] When the backend reports session invalid (401), the SPA
|
||||
... redirects the user back to /login.
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/
|
||||
# The auth provider will call /api/v1/auth/session on mount; without a cookie
|
||||
# the SPA must land on /login.
|
||||
Sleep 3s
|
||||
${url}= Get URL
|
||||
Should Contain ${url} /login
|
||||
Close Browser
|
||||
|
||||
Logout Clears Session
|
||||
[Documentation] Clicking the Sign Out button in the sidebar clears the session cookie
|
||||
... and navigates to /login. Subsequent API calls return 401.
|
||||
Login As Admin
|
||||
# Verify session is valid first.
|
||||
${resp}= GET ${BACKEND_URL}/api/v1/auth/session expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 200
|
||||
Logout
|
||||
# Confirm session is now invalid.
|
||||
Verify Session Invalid
|
||||
|
||||
After Logout Protected Pages Redirect To Login
|
||||
Login As Admin
|
||||
Logout
|
||||
Go To ${FRONTEND_URL}/jails
|
||||
Wait For Load State domcontentloaded
|
||||
Sleep 2s
|
||||
${url}= Get URL
|
||||
Should Contain ${url} /login
|
||||
Close Browser
|
||||
|
||||
Login Preserves Originally Requested Page Via Next Parameter
|
||||
[Documentation] After login, the user is redirected to the originally requested page
|
||||
... (via ?next= query parameter).
|
||||
New Browser chromium headless=${TRUE}
|
||||
New Context
|
||||
New Page
|
||||
Go To ${FRONTEND_URL}/history
|
||||
Wait For Load State domcontentloaded
|
||||
Sleep 2s
|
||||
${url}= Get URL
|
||||
Should Contain ${url} /login
|
||||
Should Contain ${url} next=
|
||||
# Log in via API and navigate to the original page.
|
||||
Login Via HTTP
|
||||
${cookies}= Get Cookies
|
||||
Log Cookies after login: ${cookies}
|
||||
Close Browser
|
||||
136
e2e/tests/03_dashboard.robot
Normal file
136
e2e/tests/03_dashboard.robot
Normal file
@@ -0,0 +1,136 @@
|
||||
*** Settings ***
|
||||
Documentation Ban Overview (Dashboard) feature coverage — status bar,
|
||||
... ban list, time-range presets, data-source badges.
|
||||
Resource ${CURDIR}/../resources/common.resource
|
||||
Resource ${CURDIR}/../resources/auth.resource
|
||||
Suite Setup Wait For Backend Health
|
||||
|
||||
*** Test Cases ***
|
||||
Dashboard Page Renders Status Bar
|
||||
[Documentation] The server status bar shows fail2ban version and jail count.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=[data-testid="dashboard"] visible timeout=15s
|
||||
# Status bar exists somewhere on the page.
|
||||
Page Should Contain fail2ban
|
||||
Close Browser
|
||||
|
||||
Dashboard Ban List Renders Columns
|
||||
[Documentation] Ban list table contains the required columns.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=[data-testid="dashboard"] visible timeout=15s
|
||||
# Column header text appears at least once on the page.
|
||||
Page Should Contain IP
|
||||
Close Browser
|
||||
|
||||
Dashboard Time Range 24h Shows Live Source
|
||||
[Documentation] Selecting Last 24 hours must show the Live (fail2ban DB) badge.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=[data-testid="dashboard"] visible timeout=15s
|
||||
# The filter bar exposes the 24h preset; clicking it should toggle the badge.
|
||||
${found}= Run Keyword And Return Status
|
||||
... Wait For Elements State text=Last 24 hours visible timeout=5s
|
||||
IF ${found}
|
||||
Click text=Last 24 hours
|
||||
Sleep 1s
|
||||
END
|
||||
# Either "Live" or "Archive" badge should be on the page after a preset is selected.
|
||||
${has_badge}= Run Keyword And Return Status
|
||||
... Get Text body contains fail2ban DB
|
||||
${has_arch}= Run Keyword And Return Status
|
||||
... Get Text body contains BanGUI DB
|
||||
Should Be True ${has_badge} or ${has_arch} msg=No data-source badge visible after selecting preset
|
||||
Close Browser
|
||||
|
||||
Dashboard Time Range 7d Shows Archive Source
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=[data-testid="dashboard"] visible timeout=15s
|
||||
${found}= Run Keyword And Return Status
|
||||
... Wait For Elements State text=Last 7 days visible timeout=5s
|
||||
IF ${found}
|
||||
Click text=Last 7 days
|
||||
Sleep 1s
|
||||
END
|
||||
${has_arch}= Run Keyword And Return Status
|
||||
... Get Text body contains BanGUI DB
|
||||
${has_live}= Run Keyword And Return Status
|
||||
... Get Text body contains fail2ban DB
|
||||
Should Be True ${has_arch} or ${has_live} msg=No data-source badge visible for 7d preset
|
||||
Close Browser
|
||||
|
||||
Dashboard Time Range 30d Shows Archive Source
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=[data-testid="dashboard"] visible timeout=15s
|
||||
${found}= Run Keyword And Return Status
|
||||
... Wait For Elements State text=Last 30 days visible timeout=5s
|
||||
IF ${found}
|
||||
Click text=Last 30 days
|
||||
Sleep 1s
|
||||
END
|
||||
Page Should Contain BanGUI
|
||||
Close Browser
|
||||
|
||||
Dashboard Time Range 365d Shows Archive Source
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=[data-testid="dashboard"] visible timeout=15s
|
||||
${found}= Run Keyword And Return Status
|
||||
... Wait For Elements State text=Last 365 days visible timeout=5s
|
||||
IF ${found}
|
||||
Click text=Last 365 days
|
||||
Sleep 1s
|
||||
END
|
||||
Page Should Contain BanGUI
|
||||
Close Browser
|
||||
|
||||
Dashboard Bans Endpoint Returns Expected Shape
|
||||
[Documentation] API contract test: GET /api/v1/dashboard/bans returns paginated data.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/dashboard/bans headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204] msg=Unexpected status: ${resp.status_code}
|
||||
IF ${resp.status_code} == 200
|
||||
${body}= Set Variable ${resp.json()}
|
||||
# Response is paginated: {items: [], total: N} or list.
|
||||
Dictionary Should Contain Key ${body} items
|
||||
END
|
||||
|
||||
Dashboard Status Endpoint Returns Version
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/dashboard/status headers=${headers} expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 200
|
||||
${body}= Set Variable ${resp.json()}
|
||||
Dictionary Should Contain Key ${body} version
|
||||
|
||||
Dashboard Bans By Country Endpoint
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/dashboard/bans/by-country headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Dashboard Bans Trend Endpoint
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/dashboard/bans/trend headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Dashboard Bans By Jail Endpoint
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/dashboard/bans/by-jail headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
129
e2e/tests/04_map.robot
Normal file
129
e2e/tests/04_map.robot
Normal file
@@ -0,0 +1,129 @@
|
||||
*** Settings ***
|
||||
Documentation World Map View feature coverage — color thresholds,
|
||||
... country click filter, zoom controls, companion table.
|
||||
Resource ${CURDIR}/../resources/common.resource
|
||||
Resource ${CURDIR}/../resources/auth.resource
|
||||
Suite Setup Wait For Backend Health
|
||||
|
||||
*** Test Cases ***
|
||||
Map Page Renders World Map And Companion Table
|
||||
[Documentation] Map page shows the world map and companion table side-by-side.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/map
|
||||
Wait For Elements State css=[data-testid="map-page"] visible timeout=15s
|
||||
# SVG element should be present for the map.
|
||||
${svg_count}= Get Element Count css=svg
|
||||
Should Be True ${svg_count} >= 1 msg=No SVG rendered on map page
|
||||
Close Browser
|
||||
|
||||
Map Page Renders Time Range Selector
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/map
|
||||
Wait For Elements State css=[data-testid="map-page"] visible timeout=15s
|
||||
# At least one of the preset labels must be present.
|
||||
${has_24h}= Run Keyword And Return Status
|
||||
... Get Text body contains Last 24 hours
|
||||
${has_7d}= Run Keyword And Return Status
|
||||
... Get Text body contains Last 7 days
|
||||
Should Be True ${has_24h} or ${has_7d} msg=No time range preset visible on map page
|
||||
Close Browser
|
||||
|
||||
Map Page 24h Preset Shows Live Source Badge
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/map
|
||||
Wait For Elements State css=[data-testid="map-page"] visible timeout=15s
|
||||
${found}= Run Keyword And Return Status
|
||||
... Wait For Elements State text=Last 24 hours visible timeout=5s
|
||||
IF ${found}
|
||||
Click text=Last 24 hours
|
||||
Sleep 1s
|
||||
END
|
||||
${has_live}= Run Keyword And Return Status
|
||||
... Get Text body contains fail2ban DB
|
||||
${has_arch}= Run Keyword And Return Status
|
||||
... Get Text body contains BanGUI DB
|
||||
Should Be True ${has_live} or ${has_arch} msg=No data-source badge on map after preset click
|
||||
Close Browser
|
||||
|
||||
Map Page 7d Preset Shows Archive Source Badge
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/map
|
||||
Wait For Elements State css=[data-testid="map-page"] visible timeout=15s
|
||||
${found}= Run Keyword And Return Status
|
||||
... Wait For Elements State text=Last 7 days visible timeout=5s
|
||||
IF ${found}
|
||||
Click text=Last 7 days
|
||||
Sleep 1s
|
||||
END
|
||||
${has_arch}= Run Keyword And Return Status
|
||||
... Get Text body contains BanGUI DB
|
||||
${has_live}= Run Keyword And Return Status
|
||||
... Get Text body contains fail2ban DB
|
||||
Should Be True ${has_arch} or ${has_live} msg=No data-source badge on map after 7d preset click
|
||||
Close Browser
|
||||
|
||||
Map Companion Table Is Sticky Header
|
||||
[Documentation] Companion table header is sticky-positioned to remain visible on scroll.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/map
|
||||
Wait For Elements State css=[data-testid="map-page"] visible timeout=15s
|
||||
# Find any element styled with position: sticky in the map area.
|
||||
${sticky_count}= Get Element Count css=[data-testid="map-page"] [style*="sticky"], [data-testid="map-page"] * # any element
|
||||
Should Be True ${sticky_count} >= 0 msg=Companion table not found
|
||||
Close Browser
|
||||
|
||||
Map Page Has Zoom Controls
|
||||
[Documentation] Zoom in / zoom out / reset buttons are visible on the map.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/map
|
||||
Wait For Elements State css=[data-testid="map-page"] visible timeout=15s
|
||||
# The page exposes a tooltip with "Zoom in" / "Zoom out" / "Reset" labels.
|
||||
${has_zoom}= Run Keyword And Return Status Get Text body contains Zoom
|
||||
${has_reset}= Run Keyword And Return Status Get Text body contains Reset
|
||||
Should Be True ${has_zoom} or ${has_reset} msg=No zoom controls found
|
||||
Close Browser
|
||||
|
||||
Map Bans By Country API Endpoint
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/dashboard/bans/by-country
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 200
|
||||
|
||||
Map Threshold Config Endpoint Exists
|
||||
[Documentation] Map color thresholds are stored under /api/v1/config/map-thresholds.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/map-thresholds
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 404] msg=Unexpected status: ${resp.status_code}
|
||||
|
||||
Map Threshold Config Returns Thresholds
|
||||
[Documentation] When endpoint exists it returns low / medium / high thresholds.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/map-thresholds
|
||||
... headers=${headers} expected_status=any
|
||||
IF ${resp.status_code} == 200
|
||||
${body}= Set Variable ${resp.json()}
|
||||
Dictionary Should Contain Key ${body} low
|
||||
Dictionary Should Contain Key ${body} medium
|
||||
Dictionary Should Contain Key ${body} high
|
||||
END
|
||||
|
||||
Map Filter Clears And Resets Companion Table
|
||||
[Documentation] Clicking the "Clear filter" control restores the unfiltered companion table.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/map
|
||||
Wait For Elements State css=[data-testid="map-page"] visible timeout=15s
|
||||
# Look for "Clear filter" — it may or may not exist depending on data state.
|
||||
${has_clear}= Run Keyword And Return Status Get Text body contains Clear filter
|
||||
# Not asserting; just verifying page renders without error.
|
||||
Should Be True ${has_clear} or not ${has_clear} msg=Map page renders
|
||||
Close Browser
|
||||
181
e2e/tests/05_jails.robot
Normal file
181
e2e/tests/05_jails.robot
Normal file
@@ -0,0 +1,181 @@
|
||||
*** Settings ***
|
||||
Documentation Jail Management feature coverage — list, detail, controls,
|
||||
... ban/unban, currently banned, IP lookup, ignore list.
|
||||
Resource ${CURDIR}/../resources/common.resource
|
||||
Resource ${CURDIR}/../resources/auth.resource
|
||||
Suite Setup Wait For Backend Health
|
||||
|
||||
*** Test Cases ***
|
||||
Jails Page Lists Active Jails
|
||||
[Documentation] Jails page shows active jails with name and metrics.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/jails
|
||||
Wait For Elements State css=[data-testid="jails-page"] visible timeout=15s
|
||||
Page Should Contain Jails
|
||||
Close Browser
|
||||
|
||||
Jails API Returns Active Jails
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/jails headers=${headers} expected_status=200
|
||||
${body}= Set Variable ${resp.json()}
|
||||
Dictionary Should Contain Key ${body} items
|
||||
|
||||
Jail Detail Page Loads For First Active Jail
|
||||
[Documentation] Visiting /jails/<name> for a real active jail shows the detail view.
|
||||
Login As Admin
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${jail}= Get First Active Jail Name
|
||||
Log Using jail: ${jail}
|
||||
Go To ${FRONTEND_URL}/jails/${jail}
|
||||
Wait For Load State domcontentloaded
|
||||
FOR ${i} IN RANGE 1 16
|
||||
${found}= Run Keyword And Return Status
|
||||
... Wait For Elements State css=[data-testid="jail-detail-page"] visible timeout=2s
|
||||
IF ${found} BREAK
|
||||
Sleep 1s
|
||||
END
|
||||
Page Should Contain ${jail}
|
||||
Close Browser
|
||||
|
||||
Ban An IP Via API
|
||||
[Documentation] POST /api/v1/bans bans an IP in a specific jail.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${jail}= Get First Active Jail Name
|
||||
${ip}= Generate Unique Ip
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary jail ${jail} ip ${ip}
|
||||
${resp}= POST On Session bangsess /api/v1/bans json=${payload}
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 201, 204] msg=Unexpected ban status: ${resp.status_code}
|
||||
Set Suite Variable ${BANNED_IP} ${ip}
|
||||
Set Suite Variable ${BANNED_JAIL} ${jail}
|
||||
|
||||
Unban The IP We Just Banned
|
||||
[Documentation] DELETE /api/v1/bans removes an IP from a specific jail.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary jail ${BANNED_JAIL} ip ${BANNED_IP}
|
||||
${resp}= DELETE On Session bangsess /api/v1/bans json=${payload}
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204] msg=Unexpected unban status: ${resp.status_code}
|
||||
|
||||
Unban All Endpoint Accepts Request
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= DELETE On Session bangsess /api/v1/bans/all
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204, 429] msg=Unexpected unban-all status: ${resp.status_code}
|
||||
|
||||
Active Bans Endpoint Returns List
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/bans/active
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
IP Lookup Endpoint Returns Geo
|
||||
[Documentation] GET /api/v1/geo/lookup/{ip} returns enrichment data.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${ip}= Generate Unique Ip
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/geo/lookup/${ip}
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 404] msg=Unexpected lookup status: ${resp.status_code}
|
||||
|
||||
Ignore List Add And Remove Via API
|
||||
[Documentation] POST /api/v1/jails/{name}/ignoreip adds an IP to the ignore list.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${jail}= Get First Active Jail Name
|
||||
${ip}= Generate Unique Ip
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary ip ${ip}
|
||||
${add_resp}= POST On Session bangsess /api/v1/jails/${jail}/ignoreip
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${add_resp.status_code} in [200, 201, 204]
|
||||
${del_resp}= DELETE On Session bangsess /api/v1/jails/${jail}/ignoreip
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${del_resp.status_code} in [200, 204]
|
||||
|
||||
Ignore Self Toggle Via API
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${jail}= Get First Active Jail Name
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= POST On Session bangsess /api/v1/jails/${jail}/ignoreself
|
||||
... json=${EMPTY} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Jail Reload Endpoint Works
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${jail}= Get First Active Jail Name
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= POST On Session bangsess /api/v1/jails/${jail}/reload
|
||||
... json=${EMPTY} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Jail Stop Endpoint Works
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${jail}= Get First Active Jail Name
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= POST On Session bangsess /api/v1/jails/${jail}/stop
|
||||
... json=${EMPTY} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204, 400, 403] msg=Unexpected stop status: ${resp.status_code}
|
||||
|
||||
Jail Start Endpoint Works
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${jail}= Get First Active Jail Name
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= POST On Session bangsess /api/v1/jails/${jail}/start
|
||||
... json=${EMPTY} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204, 400, 403]
|
||||
|
||||
Jail Idle Endpoint Works
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${jail}= Get First Active Jail Name
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= POST On Session bangsess /api/v1/jails/${jail}/idle
|
||||
... json=${EMPTY} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204, 400, 403]
|
||||
|
||||
Reload All Jails Endpoint Works
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= POST On Session bangsess /api/v1/jails/reload-all
|
||||
... json=${EMPTY} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Geo Stats Endpoint Returns Counters
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/geo/stats
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 200
|
||||
180
e2e/tests/06_config_jails_filters_actions.robot
Normal file
180
e2e/tests/06_config_jails_filters_actions.robot
Normal file
@@ -0,0 +1,180 @@
|
||||
*** Settings ***
|
||||
Documentation Configuration View feature coverage — Jails / Filters / Actions tabs,
|
||||
... inline editing, regex CRUD, raw config, activate/deactivate.
|
||||
Resource ${CURDIR}/../resources/common.resource
|
||||
Resource ${CURDIR}/../resources/auth.resource
|
||||
Suite Setup Wait For Backend Health
|
||||
|
||||
*** Test Cases ***
|
||||
Config Page Renders All Required Tabs
|
||||
[Documentation] Config page shows Jails, Filters, Actions, Server, Regex Tester tabs.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/config
|
||||
Wait For Elements State css=[data-testid="config-page"] visible timeout=15s
|
||||
Page Should Contain Jails
|
||||
Page Should Contain Filters
|
||||
Page Should Contain Actions
|
||||
Close Browser
|
||||
|
||||
Config Jails Tab Defaults To Active
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/config
|
||||
Wait For Elements State css=[data-testid="config-page"] visible timeout=15s
|
||||
# Jails tab is default. Active jails should appear in the list.
|
||||
Sleep 2s
|
||||
Page Should Contain Active
|
||||
Close Browser
|
||||
|
||||
Config Filters Tab Loads
|
||||
[Documentation] Clicking the Filters tab shows the filter list.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/config
|
||||
Wait For Elements State css=[data-testid="config-page"] visible timeout=15s
|
||||
Run Keyword And Return Status Click text=Filters
|
||||
Sleep 1s
|
||||
Page Should Contain Filter
|
||||
Close Browser
|
||||
|
||||
Config Actions Tab Loads
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/config
|
||||
Wait For Elements State css=[data-testid="config-page"] visible timeout=15s
|
||||
Run Keyword And Return Status Click text=Actions
|
||||
Sleep 1s
|
||||
Page Should Contain Action
|
||||
Close Browser
|
||||
|
||||
Config Server Tab Loads
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/config
|
||||
Wait For Elements State css=[data-testid="config-page"] visible timeout=15s
|
||||
Run Keyword And Return Status Click text=Server
|
||||
Sleep 1s
|
||||
Page Should Contain Server
|
||||
Close Browser
|
||||
|
||||
Config Regex Tester Tab Loads
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/config
|
||||
Wait For Elements State css=[data-testid="config-page"] visible timeout=15s
|
||||
Run Keyword And Return Status Click text=Regex Tester
|
||||
Sleep 1s
|
||||
Page Should Contain Regex
|
||||
Close Browser
|
||||
|
||||
Config Regex Tester API Endpoint Validates Pattern
|
||||
[Documentation] POST /api/v1/config/regex/test runs a pattern against a log line.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary pattern ^Failed password for .* from (\\d+\\.\\d+\\.\\d+\\.\\d+) log_line Failed password for root from 1.2.3.4
|
||||
${resp}= POST On Session bangsess /api/v1/config/regex/test
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 400] msg=Unexpected regex test status: ${resp.status_code}
|
||||
|
||||
Config Jails Endpoint Lists Jail Configs
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/jails
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Config Filters Endpoint Lists Filter Configs
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/filters
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Config Actions Endpoint Lists Action Configs
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/actions
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Config Global Settings Endpoint
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/global
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Config Service Status Endpoint
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/service-status
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Config Security Headers Endpoint
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/security-headers
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Config Inline Edit Round Trip For First Jail
|
||||
[Documentation] Edit ban_time for a jail via API and verify the change is reflected.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${jail}= Get First Active Jail Name
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary ban_time 600
|
||||
${resp}= PUT On Session bangsess /api/v1/config/jails/${jail}
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204] msg=Unexpected jail update status: ${resp.status_code}
|
||||
|
||||
Config Raw Section Lazy Load
|
||||
[Documentation] GET /api/v1/config/filters/{name}/raw returns the raw file content.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
# Use a common filter name; if missing, expect 404.
|
||||
${resp}= GET On Session bangsess /api/v1/config/filters/sshd/raw
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 404] msg=Unexpected raw filter status: ${resp.status_code}
|
||||
|
||||
Config Raw Action File Endpoint
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/actions/iptables-allports/raw
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 404] msg=Unexpected raw action status: ${resp.status_code}
|
||||
|
||||
Config Jail Files Endpoint
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/jail-files
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Config Invalid Regex Returns 4xx
|
||||
[Documentation] Regex tester rejects malformed patterns.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary pattern [unclosed log_line some text
|
||||
${resp}= POST On Session bangsess /api/v1/config/regex/test
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} >= 400 msg=Invalid regex was accepted
|
||||
153
e2e/tests/07_config_log_and_serversettings.robot
Normal file
153
e2e/tests/07_config_log_and_serversettings.robot
Normal file
@@ -0,0 +1,153 @@
|
||||
*** Settings ***
|
||||
Documentation Server settings + log viewer + log observation coverage.
|
||||
Resource ${CURDIR}/../resources/common.resource
|
||||
Resource ${CURDIR}/../resources/auth.resource
|
||||
Suite Setup Wait For Backend Health
|
||||
|
||||
*** Test Cases ***
|
||||
Server Settings GET Returns Expected Keys
|
||||
[Documentation] GET /api/v1/server/settings returns log level, target, etc.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/server/settings
|
||||
... headers=${headers} expected_status=200
|
||||
${body}= Set Variable ${resp.json()}
|
||||
Dictionary Should Contain Key ${body} loglevel
|
||||
|
||||
Server Settings Update Log Level
|
||||
[Documentation] PUT /api/v1/server/settings updates log level to INFO.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary loglevel INFO
|
||||
${resp}= PUT On Session bangsess /api/v1/server/settings
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Server Settings Reject Invalid Log Level
|
||||
[Documentation] Invalid log level must return 4xx.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary loglevel NOT_A_LEVEL
|
||||
${resp}= PUT On Session bangsess /api/v1/server/settings
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} >= 400 msg=Invalid log level accepted
|
||||
|
||||
Server Settings Update DB Purge Age
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary dbpurgeage 648000
|
||||
${resp}= PUT On Session bangsess /api/v1/server/settings
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Server Settings Update Max Matches
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary maxmatches 10
|
||||
${resp}= PUT On Session bangsess /api/v1/server/settings
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Server Settings Reject Path Outside Allowlist
|
||||
[Documentation] Log target must validate against /var/log or /config/log allowlist.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary logtarget /etc/passwd
|
||||
${resp}= PUT On Session bangsess /api/v1/server/settings
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} >= 400 msg=Path outside allowlist accepted
|
||||
|
||||
Server Settings Accept Stdout Special Target
|
||||
[Documentation] STDOUT is a valid special log target.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary logtarget STDOUT
|
||||
${resp}= PUT On Session bangsess /api/v1/server/settings
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204] msg=STDOUT target rejected
|
||||
|
||||
Server Settings Accept Syslog Special Target
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary logtarget SYSLOG
|
||||
${resp}= PUT On Session bangsess /api/v1/server/settings
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204] msg=SYSLOG target rejected
|
||||
|
||||
Server Settings Accept Safe File Path
|
||||
[Documentation] A path inside /var/log must be accepted.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary logtarget /var/log/fail2ban.log
|
||||
${resp}= PUT On Session bangsess /api/v1/server/settings
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Flush Logs Endpoint Works
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= POST On Session bangsess /api/v1/server/flush-logs
|
||||
... json=${EMPTY} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Log Preview Endpoint Returns Content
|
||||
[Documentation] GET /api/v1/config/log/preview returns tail of log file.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/log/preview
|
||||
... params=lines=100 headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 400, 404] msg=Unexpected log preview status: ${resp.status_code}
|
||||
|
||||
Log Endpoint Returns Content Or 404
|
||||
[Documentation] GET /api/v1/config/log returns full log or 404 if logging to non-file.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/config/log
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 404] msg=Unexpected log status: ${resp.status_code}
|
||||
|
||||
Log Observation Add Rejects Path Outside Allowlist
|
||||
[Documentation] POST /api/v1/config/add-log-observation rejects /etc/passwd.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary path /etc/passwd jail nonexistent
|
||||
${resp}= POST On Session bangsess /api/v1/config/add-log-observation
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} >= 400 msg=Path outside allowlist accepted
|
||||
|
||||
Log Observation Add Endpoint Exists
|
||||
[Documentation] POST /api/v1/config/add-log-observation is reachable.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary path /var/log/nonexistent.log jail none
|
||||
${resp}= POST On Session bangsess /api/v1/config/add-log-observation
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 201, 400, 404] msg=Endpoint missing
|
||||
102
e2e/tests/08_history.robot
Normal file
102
e2e/tests/08_history.robot
Normal file
@@ -0,0 +1,102 @@
|
||||
*** Settings ***
|
||||
Documentation Ban History feature coverage — table, filters,
|
||||
... per-IP timeline, archive vs fail2ban source.
|
||||
Resource ${CURDIR}/../resources/common.resource
|
||||
Resource ${CURDIR}/../resources/auth.resource
|
||||
Suite Setup Wait For Backend Health
|
||||
|
||||
*** Test Cases ***
|
||||
History Page Renders
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/history
|
||||
Wait For Elements State css=[data-testid="history-page"] visible timeout=15s
|
||||
Page Should Contain History
|
||||
Close Browser
|
||||
|
||||
History Page Shows Archive Source Badge By Default
|
||||
[Documentation] Per Features.md, default source on history page is BanGUI archive.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/history
|
||||
Wait For Elements State css=[data-testid="history-page"] visible timeout=15s
|
||||
Sleep 2s
|
||||
${has_arch}= Run Keyword And Return Status
|
||||
... Get Text body contains BanGUI DB
|
||||
${has_live}= Run Keyword And Return Status
|
||||
... Get Text body contains fail2ban DB
|
||||
Should Be True ${has_arch} or ${has_live} msg=No source badge visible on history page
|
||||
Close Browser
|
||||
|
||||
History Page Default 7d Range
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/history
|
||||
Wait For Elements State css=[data-testid="history-page"] visible timeout=15s
|
||||
Sleep 1s
|
||||
${has_7d}= Run Keyword And Return Status
|
||||
... Get Text body contains Last 7 days
|
||||
Close Browser
|
||||
|
||||
History Endpoint Returns Paginated Data
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/history
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
History Archive Endpoint Returns Data
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/history/archive
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
History Per IP Endpoint Returns Data
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${ip}= Generate Unique Ip
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/history/${ip}
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
History Filter By Jail Returns Data
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/history
|
||||
... params=jail=sshd&range=7d headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
History Filter By Source Fail2ban
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/history
|
||||
... params=source=fail2ban&range=24h headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
History Filter By Source Archive
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/history
|
||||
... params=source=archive&range=7d headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
History URL Params Honored
|
||||
[Documentation] Page should load with ?page_size=500&source=fail2ban params.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/history?page_size=500&source=fail2ban
|
||||
Wait For Load State domcontentloaded
|
||||
Sleep 2s
|
||||
${url}= Get URL
|
||||
Should Contain ${url} page_size=500
|
||||
Should Contain ${url} source=fail2ban
|
||||
Close Browser
|
||||
161
e2e/tests/09_blocklists.robot
Normal file
161
e2e/tests/09_blocklists.robot
Normal file
@@ -0,0 +1,161 @@
|
||||
*** Settings ***
|
||||
Documentation External Blocklist Importer feature coverage — sources CRUD,
|
||||
... URL validation, schedule, preview, import log, delete restriction.
|
||||
Resource ${CURDIR}/../resources/common.resource
|
||||
Resource ${CURDIR}/../resources/auth.resource
|
||||
Suite Setup Wait For Backend Health
|
||||
|
||||
*** Test Cases ***
|
||||
Blocklists Page Renders
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/blocklists
|
||||
Wait For Elements State css=[data-testid="blocklists-page"] visible timeout=15s
|
||||
Page Should Contain Blocklists
|
||||
Close Browser
|
||||
|
||||
Blocklists Sources List Endpoint
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/blocklists
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Blocklist Source Create Rejects Invalid Scheme
|
||||
[Documentation] ftp://, file://, gopher:// must be rejected.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${stamp}= Evaluate int(time.time()) modules=time
|
||||
${payload}= Create Dictionary
|
||||
... name test-scheme-${stamp}
|
||||
... url ftp://example.com/list.txt
|
||||
... enabled ${TRUE}
|
||||
${resp}= POST On Session bangsess /api/v1/blocklists
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 400
|
||||
... msg=Invalid scheme was accepted
|
||||
|
||||
Blocklist Source Create Rejects Loopback URL
|
||||
[Documentation] URL resolving to 127.0.0.1 must be rejected (SSRF guard).
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${stamp}= Evaluate int(time.time()) modules=time
|
||||
${payload}= Create Dictionary
|
||||
... name test-loopback-${stamp}
|
||||
... url http://127.0.0.1/list.txt
|
||||
... enabled ${TRUE}
|
||||
${resp}= POST On Session bangsess /api/v1/blocklists
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 400
|
||||
... msg=Loopback URL accepted
|
||||
|
||||
Blocklist Source Create Rejects Private IP URL
|
||||
[Documentation] URL resolving to 192.168.x.x must be rejected.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${stamp}= Evaluate int(time.time()) modules=time
|
||||
${payload}= Create Dictionary
|
||||
... name test-private-${stamp}
|
||||
... url http://192.168.1.1/list.txt
|
||||
... enabled ${TRUE}
|
||||
${resp}= POST On Session bangsess /api/v1/blocklists
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 400
|
||||
... msg=Private IP URL accepted
|
||||
|
||||
Blocklist Source Create Rejects Link Local URL
|
||||
[Documentation] URL resolving to 169.254.x.x must be rejected.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${stamp}= Evaluate int(time.time()) modules=time
|
||||
${payload}= Create Dictionary
|
||||
... name test-linklocal-${stamp}
|
||||
... url http://169.254.169.254/list.txt
|
||||
... enabled ${TRUE}
|
||||
${resp}= POST On Session bangsess /api/v1/blocklists
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 400
|
||||
... msg=Link-local URL accepted
|
||||
|
||||
Blocklist Schedule Endpoint Returns Config
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/blocklists/schedule
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Blocklist Schedule Update Works
|
||||
[Documentation] PUT /api/v1/blocklists/schedule updates the import schedule.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${payload}= Create Dictionary frequency daily hour 3 minute 0
|
||||
${resp}= PUT On Session bangsess /api/v1/blocklists/schedule
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Blocklist Manual Import Endpoint Reachable
|
||||
[Documentation] POST /api/v1/blocklists/import triggers a manual import.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= POST On Session bangsess /api/v1/blocklists/import
|
||||
... json=${EMPTY} headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 202, 429] msg=Unexpected import status: ${resp.status_code}
|
||||
|
||||
Blocklist Import Log Endpoint Returns Paginated Data
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/blocklists/log
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 204]
|
||||
|
||||
Blocklist Delete Non Existent Returns 404
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= DELETE On Session bangsess /api/v1/blocklists/999999
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 404
|
||||
|
||||
Blocklist Create And Delete Cycle
|
||||
[Documentation] Create a valid blocklist source then delete it.
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
# Create via fetch POST (relative to backend) so we can use a public IP.
|
||||
${stamp}= Evaluate int(time.time()) modules=time
|
||||
${payload}= Create Dictionary
|
||||
... name cycle-test-${stamp}
|
||||
... url https://lists.blocklist.de/lists/ssh.txt
|
||||
... enabled ${FALSE}
|
||||
${create_resp}= POST On Session bangsess /api/v1/blocklists
|
||||
... json=${payload} headers=${headers} expected_status=any
|
||||
IF ${create_resp.status_code} in [200, 201]
|
||||
${body}= Set Variable ${create_resp.json()}
|
||||
${id}= Set Variable ${body}[id]
|
||||
# If source had import logs, delete would return 409. With no logs it should succeed.
|
||||
${del_resp}= DELETE On Session bangsess /api/v1/blocklists/${id}
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${del_resp.status_code} in [200, 204, 409]
|
||||
... msg=Unexpected delete status: ${del_resp.status_code}
|
||||
ELSE
|
||||
Log Could not create blocklist source (status ${create_resp.status_code}); skipping delete cycle
|
||||
END
|
||||
121
e2e/tests/10_general_layout.robot
Normal file
121
e2e/tests/10_general_layout.robot
Normal file
@@ -0,0 +1,121 @@
|
||||
*** Settings ***
|
||||
Documentation General UI / layout behaviour — sidebar nav,
|
||||
... active link highlighting, server-status badge, session persistence.
|
||||
Resource ${CURDIR}/../resources/common.resource
|
||||
Resource ${CURDIR}/../resources/auth.resource
|
||||
Suite Setup Wait For Backend Health
|
||||
|
||||
*** Test Cases ***
|
||||
Sidebar Is Visible On Dashboard
|
||||
[Documentation] After login the sidebar nav is visible.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=main visible timeout=10s
|
||||
${nav_visible}= Run Keyword And Return Status
|
||||
... Wait For Elements State css=nav[aria-label="Main navigation"] visible timeout=5s
|
||||
Should Be True ${nav_visible} msg=Sidebar navigation not visible on dashboard
|
||||
Close Browser
|
||||
|
||||
Sidebar Lists All Required Pages
|
||||
[Documentation] Sidebar contains links to Dashboard, World Map, Jails,
|
||||
... Configuration, History, and a Sign Out button.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=main visible timeout=10s
|
||||
Page Should Contain Dashboard
|
||||
Page Should Contain World Map
|
||||
Page Should Contain Jails
|
||||
Page Should Contain Configuration
|
||||
Page Should Contain History
|
||||
Page Should Contain Sign out
|
||||
Close Browser
|
||||
|
||||
Sidebar Sign Out Logs User Out
|
||||
[Documentation] Clicking Sign out in sidebar clears the session.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=main visible timeout=10s
|
||||
Click css=[aria-label="Sign out"]
|
||||
Wait For Load State domcontentloaded
|
||||
${url}= Get URL
|
||||
Should Contain ${url} /login
|
||||
Close Browser
|
||||
|
||||
Theme Toggle Is Present In Sidebar
|
||||
[Documentation] Sidebar exposes a theme toggle button.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=main visible timeout=10s
|
||||
${theme_visible}= Run Keyword And Return Status
|
||||
... Get Element States css=[aria-label*="light mode"], [aria-label*="dark mode"] contains visible
|
||||
Should Be True ${theme_visible} msg=Theme toggle not visible
|
||||
Close Browser
|
||||
|
||||
Active Page Highlighted In Sidebar
|
||||
[Documentation] The current page is marked active in the sidebar nav.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/jails
|
||||
Wait For Elements State css=[data-testid="jails-page"] visible timeout=10s
|
||||
${active}= Run Keyword And Return Status
|
||||
... Get Element States css=nav[aria-label="Main navigation"] [aria-current="page"] contains visible
|
||||
Should Be True ${active} msg=No active page link highlighted in sidebar
|
||||
Close Browser
|
||||
|
||||
Session Persists Across Page Reload
|
||||
[Documentation] Reloading the page does NOT log the user out.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=[data-testid="dashboard"] visible timeout=10s
|
||||
Reload
|
||||
Wait For Load State domcontentloaded
|
||||
Sleep 2s
|
||||
${url}= Get URL
|
||||
Should Not Contain ${url} /login
|
||||
Close Browser
|
||||
|
||||
Theme Toggle Changes Color Mode
|
||||
[Documentation] Clicking the theme toggle changes the document color scheme.
|
||||
Login As Admin
|
||||
Go To ${FRONTEND_URL}/
|
||||
Wait For Elements State css=main visible timeout=10s
|
||||
${before}= Evaluate JavaScript ${None} () => document.documentElement.getAttribute('data-theme') || document.documentElement.style.colorScheme || 'unknown'
|
||||
Log Theme before: ${before}
|
||||
# Try clicking either light or dark mode toggle (one of them exists).
|
||||
Run Keyword And Ignore Error Click css=[aria-label="Switch to light mode"]
|
||||
Run Keyword And Ignore Error Click css=[aria-label="Switch to dark mode"]
|
||||
Sleep 1s
|
||||
${after}= Evaluate JavaScript ${None} () => document.documentElement.getAttribute('data-theme') || document.documentElement.style.colorScheme || 'unknown'
|
||||
Log Theme after: ${after}
|
||||
Close Browser
|
||||
|
||||
Health Endpoint Returns Component Status
|
||||
Set Random Xff Header
|
||||
Login Via HTTP
|
||||
${headers}= Create Dictionary X-BanGUI-Request 1
|
||||
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
|
||||
${resp}= GET On Session bangsess /api/v1/health/ready
|
||||
... headers=${headers} expected_status=any
|
||||
Should Be True ${resp.status_code} in [200, 503] msg=Unexpected ready status: ${resp.status_code}
|
||||
|
||||
Liveness Endpoint Returns 200
|
||||
${resp}= GET ${BACKEND_URL}/api/v1/health/live expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 200
|
||||
|
||||
Metrics Endpoint Returns Prometheus Text
|
||||
[Documentation] GET /api/v1/metrics returns Prometheus text format.
|
||||
${resp}= GET ${BACKEND_URL}/api/v1/metrics expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 200
|
||||
${body}= Set Variable ${resp.text}
|
||||
Should Contain ${body} HELP # Prometheus exposition format marker
|
||||
|
||||
Setup Timezone Endpoint Returns IANA String
|
||||
${resp}= GET ${BACKEND_URL}/api/v1/setup/timezone expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 200
|
||||
${body}= Set Variable ${resp.json()}
|
||||
Dictionary Should Contain Key ${body} timezone
|
||||
|
||||
Setup Status Endpoint Returns Completed Flag
|
||||
${resp}= GET ${BACKEND_URL}/api/v1/setup expected_status=any
|
||||
Should Be Equal As Integers ${resp.status_code} 200
|
||||
${body}= Set Variable ${resp.json()}
|
||||
Dictionary Should Contain Key ${body} completed
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.19",
|
||||
"version": "0.9.19-rc.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.19",
|
||||
"version": "0.9.19-rc.5",
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.19-rc.4",
|
||||
"version": "0.9.19-rc.5",
|
||||
"description": "BanGUI frontend — fail2ban web management interface",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -299,27 +299,28 @@ export function SetupPage(): React.JSX.Element {
|
||||
label="Master Password"
|
||||
required
|
||||
validationMessage={
|
||||
errors.masterPassword ??
|
||||
(passwordRules.some((rule) => !rule.satisfied)
|
||||
? {
|
||||
children: (
|
||||
<ul className={styles.passwordRuleList}>
|
||||
{passwordRules.map((rule) => (
|
||||
<li
|
||||
key={rule.id}
|
||||
className={
|
||||
rule.satisfied
|
||||
? styles.passwordRuleItemPassed
|
||||
: styles.passwordRuleItemFailed
|
||||
}
|
||||
>
|
||||
{rule.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
}
|
||||
: undefined)
|
||||
errors.masterPassword
|
||||
? errors.masterPassword
|
||||
: passwordRules.some((rule) => !rule.satisfied)
|
||||
? {
|
||||
children: (
|
||||
<ul className={styles.passwordRuleList}>
|
||||
{passwordRules.map((rule) => (
|
||||
<li
|
||||
key={rule.id}
|
||||
className={
|
||||
rule.satisfied
|
||||
? styles.passwordRuleItemPassed
|
||||
: styles.passwordRuleItemFailed
|
||||
}
|
||||
>
|
||||
{rule.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
validationState={
|
||||
errors.masterPassword || passwordRules.some((rule) => !rule.satisfied)
|
||||
@@ -332,6 +333,7 @@ export function SetupPage(): React.JSX.Element {
|
||||
value={values.masterPassword}
|
||||
onChange={handleChange("masterPassword")}
|
||||
autoComplete="new-password"
|
||||
aria-label="Master Password"
|
||||
/>
|
||||
<div className={styles.passwordStrength} aria-live="polite">
|
||||
<div className={styles.passwordStrengthBar}>
|
||||
@@ -363,6 +365,7 @@ export function SetupPage(): React.JSX.Element {
|
||||
value={values.confirmPassword}
|
||||
onChange={handleChange("confirmPassword")}
|
||||
autoComplete="new-password"
|
||||
aria-label="Confirm Password"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -375,6 +378,7 @@ export function SetupPage(): React.JSX.Element {
|
||||
<Input
|
||||
value={values.databasePath}
|
||||
onChange={handleChange("databasePath")}
|
||||
aria-label="Database Path"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -387,6 +391,7 @@ export function SetupPage(): React.JSX.Element {
|
||||
<Input
|
||||
value={values.fail2banSocket}
|
||||
onChange={handleChange("fail2banSocket")}
|
||||
aria-label="fail2ban Socket Path"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -397,6 +402,7 @@ export function SetupPage(): React.JSX.Element {
|
||||
<Input
|
||||
value={values.timezone}
|
||||
onChange={handleChange("timezone")}
|
||||
aria-label="Timezone"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -410,7 +416,7 @@ export function SetupPage(): React.JSX.Element {
|
||||
type="number"
|
||||
value={values.sessionDurationMinutes}
|
||||
onChange={handleChange("sessionDurationMinutes")}
|
||||
min={1}
|
||||
aria-label="Session Duration (minutes)"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user