Compare commits
25 Commits
77df5d5d65
...
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 | |||
| 83b2cb67b1 | |||
| 7308ff88d6 |
@@ -18,7 +18,7 @@ WORKDIR /build
|
|||||||
COPY frontend/package.json frontend/package-lock.json* /build/
|
COPY frontend/package.json frontend/package-lock.json* /build/
|
||||||
RUN npm ci --ignore-scripts
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
# Copy source and build
|
# Copy source + local OpenAPI spec (avoids needing a running backend during build)
|
||||||
COPY frontend/ /build/
|
COPY frontend/ /build/
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v0.9.19
|
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
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
# ./release.sh
|
# ./release.sh
|
||||||
#
|
#
|
||||||
# The current version is stored in VERSION (next to this script).
|
# The current version is stored in VERSION (next to this script).
|
||||||
# You will be asked whether to bump major, minor, or patch.
|
# You will be asked whether to bump major, minor, patch, or release candidate (rc).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -24,24 +24,60 @@ CURRENT="$(cat "${VERSION_FILE}")"
|
|||||||
# Strip leading 'v' for arithmetic
|
# Strip leading 'v' for arithmetic
|
||||||
VERSION="${CURRENT#v}"
|
VERSION="${CURRENT#v}"
|
||||||
|
|
||||||
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
|
# Parse version: X.Y.Z or X.Y.Z-rc.N
|
||||||
|
if [[ "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.([0-9]+))?$ ]]; then
|
||||||
|
MAJOR="${BASH_REMATCH[1]}"
|
||||||
|
MINOR="${BASH_REMATCH[2]}"
|
||||||
|
PATCH="${BASH_REMATCH[3]}"
|
||||||
|
RC_SUFFIX="${BASH_REMATCH[4]:-}"
|
||||||
|
RC_NUM="${BASH_REMATCH[5]:-0}"
|
||||||
|
else
|
||||||
|
echo "Error: version '${VERSION}' does not match expected format X.Y.Z or X.Y.Z-rc.N" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " BanGUI — Release"
|
echo " BanGUI — Release"
|
||||||
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
|
if [[ -n "${RC_SUFFIX}" ]]; then
|
||||||
|
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM}"
|
||||||
|
else
|
||||||
|
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
fi
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo ""
|
echo ""
|
||||||
echo "How would you like to bump the version?"
|
echo "How would you like to bump the version?"
|
||||||
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
if [[ -n "${RC_SUFFIX}" ]]; then
|
||||||
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH})"
|
||||||
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.$((MINOR + 1)).0)"
|
||||||
|
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v$((MAJOR + 1)).0.0)"
|
||||||
|
echo " 4) rc (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH}-rc.$((RC_NUM + 1)))"
|
||||||
|
else
|
||||||
|
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
||||||
|
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
||||||
|
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
||||||
|
echo " 4) rc (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.${PATCH}-rc.1)"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
read -rp "Enter choice [1/2/3]: " CHOICE
|
read -rp "Enter choice [1/2/3/4]: " CHOICE
|
||||||
|
|
||||||
case "${CHOICE}" in
|
case "${CHOICE}" in
|
||||||
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
|
1)
|
||||||
|
if [[ -n "${RC_SUFFIX}" ]]; then
|
||||||
|
# Release the RC: strip RC suffix
|
||||||
|
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
else
|
||||||
|
NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
||||||
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
|
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
|
||||||
|
4)
|
||||||
|
if [[ "${RC_NUM}" -gt 0 ]]; then
|
||||||
|
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}-rc.$((RC_NUM + 1))"
|
||||||
|
else
|
||||||
|
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}-rc.1"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Invalid choice. Aborting." >&2
|
echo "Invalid choice. Aborting." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -81,7 +117,13 @@ fi
|
|||||||
# Push containers
|
# Push containers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
||||||
bash "${SCRIPT_DIR}/push.sh"
|
|
||||||
|
# Push to "latest" or "latestRC" depending on whether this is a release candidate
|
||||||
|
if [[ "${NEW_TAG}" == *-rc* ]]; then
|
||||||
|
bash "${SCRIPT_DIR}/push.sh" "latestRC"
|
||||||
|
else
|
||||||
|
bash "${SCRIPT_DIR}/push.sh" "latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
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
|
// Step 1 — run the task prompt
|
||||||
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
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;
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
// Step 2 — confirm completion in the same chat session
|
// Step 2 — confirm completion in the same chat session
|
||||||
|
|||||||
@@ -102,10 +102,15 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Ordered list of DDL statements to execute on initialisation.
|
# Ordered list of DDL statements to execute on initialisation.
|
||||||
|
# NOTE: _CREATE_SESSIONS_TOKEN_INDEX is intentionally omitted here.
|
||||||
|
# The old 0.8.0 schema has a `sessions.token` column (not `token_hash`), so
|
||||||
|
# running CREATE INDEX … ON sessions (token_hash) in migration 1 would fail
|
||||||
|
# with "no such column: token_hash" on legacy databases. Migration 2 drops
|
||||||
|
# and recreates the sessions table with token_hash and also creates the index,
|
||||||
|
# so there is no need to create it in migration 1.
|
||||||
_SCHEMA_STATEMENTS: list[str] = [
|
_SCHEMA_STATEMENTS: list[str] = [
|
||||||
_CREATE_SETTINGS,
|
_CREATE_SETTINGS,
|
||||||
_CREATE_SESSIONS,
|
_CREATE_SESSIONS,
|
||||||
_CREATE_SESSIONS_TOKEN_INDEX,
|
|
||||||
_CREATE_BLOCKLIST_SOURCES,
|
_CREATE_BLOCKLIST_SOURCES,
|
||||||
_CREATE_IMPORT_LOG,
|
_CREATE_IMPORT_LOG,
|
||||||
_CREATE_GEO_CACHE,
|
_CREATE_GEO_CACHE,
|
||||||
@@ -133,8 +138,24 @@ CREATE UNIQUE INDEX idx_sessions_token_hash ON sessions (token_hash);
|
|||||||
3: """
|
3: """
|
||||||
-- Migration 3: Add last_seen timestamp to geo_cache for retention policy.
|
-- Migration 3: Add last_seen timestamp to geo_cache for retention policy.
|
||||||
-- Tracks when each IP was last referenced to enable purging of stale entries.
|
-- Tracks when each IP was last referenced to enable purging of stale entries.
|
||||||
-- Default to current timestamp for existing rows.
|
-- SQLite rejects ALTER TABLE ADD COLUMN with a non-constant NOT NULL default
|
||||||
ALTER TABLE geo_cache ADD COLUMN last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
-- when the table already contains rows, so we rebuild the table instead.
|
||||||
|
-- Existing rows receive last_seen = cached_at as a reasonable approximation
|
||||||
|
-- (the IP was at least seen when it was first cached).
|
||||||
|
DROP TABLE IF EXISTS geo_cache_new;
|
||||||
|
CREATE TABLE geo_cache_new (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
country_code TEXT,
|
||||||
|
country_name TEXT,
|
||||||
|
asn TEXT,
|
||||||
|
org TEXT,
|
||||||
|
cached_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
INSERT INTO geo_cache_new (ip, country_code, country_name, asn, org, cached_at, last_seen)
|
||||||
|
SELECT ip, country_code, country_name, asn, org, cached_at, cached_at FROM geo_cache;
|
||||||
|
DROP TABLE geo_cache;
|
||||||
|
ALTER TABLE geo_cache_new RENAME TO geo_cache;
|
||||||
""",
|
""",
|
||||||
4: """
|
4: """
|
||||||
-- Migration 4: Add scheduler_lock table for multi-worker safety.
|
-- Migration 4: Add scheduler_lock table for multi-worker safety.
|
||||||
@@ -253,7 +274,18 @@ CREATE INDEX IF NOT EXISTS idx_import_log_source_id_desc
|
|||||||
|
|
||||||
|
|
||||||
async def _configure_connection(db: aiosqlite.Connection) -> None:
|
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 journal_mode=WAL;")
|
||||||
await db.execute("PRAGMA foreign_keys=ON;")
|
await db.execute("PRAGMA foreign_keys=ON;")
|
||||||
await db.execute("PRAGMA busy_timeout=5000;")
|
await db.execute("PRAGMA busy_timeout=5000;")
|
||||||
@@ -454,14 +486,75 @@ async def init_db(db: aiosqlite.Connection) -> None:
|
|||||||
async def open_db(database_path: str) -> aiosqlite.Connection:
|
async def open_db(database_path: str) -> aiosqlite.Connection:
|
||||||
"""Open a new application SQLite connection with the standard settings.
|
"""Open a new application SQLite connection with the standard settings.
|
||||||
|
|
||||||
|
Creates the parent directory if it does not exist.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
database_path: Path to the BanGUI SQLite database.
|
database_path: Path to the BanGUI SQLite database.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A configured :class:`aiosqlite.Connection` instance.
|
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)
|
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)
|
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
|
db.row_factory = aiosqlite.Row
|
||||||
|
try:
|
||||||
await _configure_connection(db)
|
await _configure_connection(db)
|
||||||
|
except Exception:
|
||||||
|
await db.close()
|
||||||
|
raise
|
||||||
return db
|
return db
|
||||||
|
|||||||
@@ -165,21 +165,60 @@ async def get_db(
|
|||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
An open :class:`aiosqlite.Connection` for the request.
|
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.db import open_db # noqa: PLC0415
|
||||||
|
from app.exceptions import (
|
||||||
|
DatabaseBusyError,
|
||||||
|
DatabaseCorruptedError,
|
||||||
|
DatabasePathInvalidError,
|
||||||
|
DatabasePermissionDeniedError,
|
||||||
|
DatabaseUnavailableError,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = None
|
||||||
|
retries = 3
|
||||||
|
retry_delay = 0.1
|
||||||
|
last_exc = None
|
||||||
|
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
try:
|
try:
|
||||||
db = await open_db(settings.database_path)
|
db = await open_db(settings.database_path)
|
||||||
except Exception as exc:
|
break
|
||||||
log.error("database_open_failed", error=str(exc))
|
except DatabaseBusyError:
|
||||||
raise HTTPException(
|
raise
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
except (DatabasePermissionDeniedError, DatabasePathInvalidError, DatabaseCorruptedError):
|
||||||
detail="Database is not available.",
|
raise
|
||||||
) from exc
|
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:
|
try:
|
||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
|
if db is not None:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,75 @@ class SetupAlreadyCompleteError(ConflictError):
|
|||||||
super().__init__("Setup has already been completed.")
|
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):
|
class BlocklistSourceNotFoundError(NotFoundError):
|
||||||
"""Raised when a blocklist source is not found."""
|
"""Raised when a blocklist source is not found."""
|
||||||
|
|
||||||
|
|||||||
@@ -242,9 +242,9 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# deployments, it should be replaced with a shared backend.
|
# deployments, it should be replaced with a shared backend.
|
||||||
_update_session_cache(app, settings)
|
_update_session_cache(app, settings)
|
||||||
|
|
||||||
# Initialize the global rate limiter (200 requests per 60 seconds per IP).
|
# Initialize the global rate limiter (600 requests per 60 seconds per IP).
|
||||||
# Applied to all endpoints via middleware. Process-local implementation.
|
# Applied to all endpoints via middleware. Process-local implementation.
|
||||||
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=200, window_seconds=60)
|
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=600, window_seconds=60)
|
||||||
|
|
||||||
log.info("bangui_started")
|
log.info("bangui_started")
|
||||||
|
|
||||||
@@ -318,7 +318,12 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
log.error("scheduler_lock_release_failed", error=str(e))
|
log.error("scheduler_lock_release_failed", error=str(e))
|
||||||
|
|
||||||
# 6. Close the database connection.
|
# 6. Close the database connection.
|
||||||
|
try:
|
||||||
await startup_db.close()
|
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")
|
log.info("bangui_shut_down")
|
||||||
|
|
||||||
|
|
||||||
@@ -1095,10 +1100,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
if resolved_settings.session_cache_enabled and resolved_settings.session_cache_ttl_seconds > 0.0
|
if resolved_settings.session_cache_enabled and resolved_settings.session_cache_ttl_seconds > 0.0
|
||||||
else NoOpSessionCache()
|
else NoOpSessionCache()
|
||||||
)
|
)
|
||||||
# Initialize the global rate limiter (200 requests per 60 seconds per IP).
|
# Initialize the global rate limiter (600 requests per 60 seconds per IP).
|
||||||
# This is also re-initialized in the lifespan, but must be present here
|
# This is also re-initialized in the lifespan, but must be present here
|
||||||
# for tests that bypass the lifespan via ASGITransport.
|
# for tests that bypass the lifespan via ASGITransport.
|
||||||
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=200, window_seconds=60)
|
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=600, window_seconds=60)
|
||||||
|
|
||||||
set_setup_complete_cache(app, False)
|
set_setup_complete_cache(app, False)
|
||||||
|
|
||||||
@@ -1135,9 +1140,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
app.add_middleware(CsrfMiddleware)
|
app.add_middleware(CsrfMiddleware)
|
||||||
app.add_middleware(DeprecationHeaderMiddleware)
|
app.add_middleware(DeprecationHeaderMiddleware)
|
||||||
# Auth endpoints (login, setup) need a dedicated higher-rate bucket to avoid
|
# Auth endpoints (login, setup) need a dedicated higher-rate bucket to avoid
|
||||||
# rate limiting when running e2e tests sequentially. Auth uses the default
|
# rate limiting when running e2e tests sequentially.
|
||||||
# global rate limiter at 200 req/min per IP.
|
|
||||||
# Auth endpoints: /api/v1/login, /api/v1/setup
|
|
||||||
# 1000 req/min per IP — generous for e2e testing.
|
# 1000 req/min per IP — generous for e2e testing.
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
RateLimitMiddleware,
|
RateLimitMiddleware,
|
||||||
@@ -1146,6 +1149,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
bucket_override="auth:login",
|
bucket_override="auth:login",
|
||||||
bucket_max_requests=1000,
|
bucket_max_requests=1000,
|
||||||
bucket_window_seconds=60,
|
bucket_window_seconds=60,
|
||||||
|
path_prefixes=["/api/v1/auth/login", "/api/v1/setup"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# History endpoints get a dedicated higher-rate bucket to avoid
|
# History endpoints get a dedicated higher-rate bucket to avoid
|
||||||
@@ -1159,6 +1163,28 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
bucket_override="history:list",
|
bucket_override="history:list",
|
||||||
bucket_max_requests=10000,
|
bucket_max_requests=10000,
|
||||||
bucket_window_seconds=60,
|
bucket_window_seconds=60,
|
||||||
|
path_prefixes=["/api/v1/history"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Polling endpoints (blocklist schedule) get a dedicated bucket
|
||||||
|
# to avoid exhausting the global limit during normal frontend operation.
|
||||||
|
app.add_middleware(
|
||||||
|
RateLimitMiddleware,
|
||||||
|
rate_limiter=app.state.global_rate_limiter,
|
||||||
|
settings=resolved_settings,
|
||||||
|
bucket_override="polling:read",
|
||||||
|
bucket_max_requests=10000,
|
||||||
|
bucket_window_seconds=60,
|
||||||
|
path_prefixes=["/api/v1/blocklists/schedule"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Global rate limiter for all other endpoints.
|
||||||
|
# 600 req/min per IP — default protection.
|
||||||
|
app.add_middleware(
|
||||||
|
RateLimitMiddleware,
|
||||||
|
rate_limiter=app.state.global_rate_limiter,
|
||||||
|
settings=resolved_settings,
|
||||||
|
skip_paths=["/api/v1/auth/login", "/api/v1/setup", "/api/v1/history", "/api/v1/blocklists/schedule"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate middleware order before returning the app.
|
# Validate middleware order before returning the app.
|
||||||
|
|||||||
@@ -34,18 +34,20 @@ unusual and potentially suspicious) always carry a correlation ID for tracing.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.utils.logging_compat import get_logger
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import JSONResponse, Response
|
from starlette.responses import JSONResponse, Response
|
||||||
|
|
||||||
from app.exceptions import RateLimitError
|
from app.exceptions import RateLimitError
|
||||||
from app.utils.client_ip import get_client_ip
|
from app.utils.client_ip import get_client_ip
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.utils.rate_limiter import GlobalRateLimiter
|
from app.utils.rate_limiter import GlobalRateLimiter
|
||||||
|
|
||||||
@@ -53,11 +55,15 @@ log = get_logger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
"""Enforce global per-IP request rate limiting on all endpoints.
|
"""Enforce per-IP request rate limiting on matching endpoints.
|
||||||
|
|
||||||
Tracks requests per IP and blocks further requests if the limit is exceeded.
|
Tracks requests per IP and blocks further requests if the limit is exceeded.
|
||||||
Uses the application's GlobalRateLimiter instance and trusted-proxy settings
|
Uses the application's GlobalRateLimiter instance and trusted-proxy settings
|
||||||
for consistent IP extraction.
|
for consistent IP extraction.
|
||||||
|
|
||||||
|
Each middleware instance is scoped to a set of path prefixes (or all paths
|
||||||
|
if no prefixes are given). This allows multiple instances to coexist
|
||||||
|
without double-counting requests.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -68,6 +74,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
bucket_override: str | None = None,
|
bucket_override: str | None = None,
|
||||||
bucket_max_requests: int | None = None,
|
bucket_max_requests: int | None = None,
|
||||||
bucket_window_seconds: int | None = None,
|
bucket_window_seconds: int | None = None,
|
||||||
|
path_prefixes: list[str] | None = None,
|
||||||
|
skip_paths: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the rate limit middleware.
|
"""Initialize the rate limit middleware.
|
||||||
|
|
||||||
@@ -78,6 +86,12 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
bucket_override: Optional named bucket to use instead of the default limiter.
|
bucket_override: Optional named bucket to use instead of the default limiter.
|
||||||
bucket_max_requests: Max requests for the bucket override.
|
bucket_max_requests: Max requests for the bucket override.
|
||||||
bucket_window_seconds: Window for the bucket override.
|
bucket_window_seconds: Window for the bucket override.
|
||||||
|
path_prefixes: If provided, only apply rate limiting to paths that
|
||||||
|
start with one of these prefixes. If ``None``, all paths are
|
||||||
|
matched.
|
||||||
|
skip_paths: If provided, do not apply rate limiting to paths that
|
||||||
|
start with one of these prefixes. Evaluated after
|
||||||
|
``path_prefixes``.
|
||||||
"""
|
"""
|
||||||
super().__init__(app) # type: ignore[arg-type]
|
super().__init__(app) # type: ignore[arg-type]
|
||||||
self.rate_limiter: GlobalRateLimiter = rate_limiter
|
self.rate_limiter: GlobalRateLimiter = rate_limiter
|
||||||
@@ -85,6 +99,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
self.bucket_override = bucket_override
|
self.bucket_override = bucket_override
|
||||||
self.bucket_max_requests = bucket_max_requests
|
self.bucket_max_requests = bucket_max_requests
|
||||||
self.bucket_window_seconds = bucket_window_seconds
|
self.bucket_window_seconds = bucket_window_seconds
|
||||||
|
self.path_prefixes = path_prefixes or []
|
||||||
|
self.skip_paths = skip_paths or []
|
||||||
|
|
||||||
|
def _should_check(self, path: str) -> bool:
|
||||||
|
"""Return whether the given path should be rate-limited by this instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The request URL path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if this instance should enforce its limit on the path.
|
||||||
|
"""
|
||||||
|
if self.skip_paths and any(path.startswith(p) for p in self.skip_paths):
|
||||||
|
return False
|
||||||
|
if self.path_prefixes:
|
||||||
|
return any(path.startswith(p) for p in self.path_prefixes)
|
||||||
|
return True
|
||||||
|
|
||||||
async def dispatch(
|
async def dispatch(
|
||||||
self,
|
self,
|
||||||
@@ -103,22 +134,14 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
Returns:
|
Returns:
|
||||||
A response object (either rate limit response or from handler).
|
A response object (either rate limit response or from handler).
|
||||||
"""
|
"""
|
||||||
client_ip = get_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
|
|
||||||
|
|
||||||
# Use higher-rate bucket for specific endpoints.
|
|
||||||
# Check path to apply the appropriate bucket.
|
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
|
if not self._should_check(path):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
client_ip = get_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
|
||||||
|
|
||||||
if self.bucket_override and self.bucket_max_requests and self.bucket_window_seconds:
|
if self.bucket_override and self.bucket_max_requests and self.bucket_window_seconds:
|
||||||
if path.startswith("/api/v1/history"):
|
|
||||||
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
|
|
||||||
self.bucket_override,
|
|
||||||
client_ip,
|
|
||||||
self.bucket_max_requests,
|
|
||||||
self.bucket_window_seconds,
|
|
||||||
)
|
|
||||||
elif path.startswith("/api/v1/login") or path.startswith("/api/v1/setup"):
|
|
||||||
# Auth endpoints use their own bucket
|
|
||||||
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
|
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
|
||||||
self.bucket_override,
|
self.bucket_override,
|
||||||
client_ip,
|
client_ip,
|
||||||
@@ -127,13 +150,12 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
|
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
|
||||||
else:
|
|
||||||
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
|
|
||||||
if not is_allowed:
|
if not is_allowed:
|
||||||
log.warning(
|
log.warning(
|
||||||
"global_rate_limit_exceeded",
|
"global_rate_limit_exceeded",
|
||||||
client_ip=client_ip,
|
client_ip=client_ip,
|
||||||
path=request.url.path,
|
path=path,
|
||||||
method=request.method,
|
method=request.method,
|
||||||
retry_after=retry_after,
|
retry_after=retry_after,
|
||||||
)
|
)
|
||||||
@@ -141,7 +163,6 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
"Too many requests. Please try again later.",
|
"Too many requests. Please try again later.",
|
||||||
retry_after_seconds=retry_after,
|
retry_after_seconds=retry_after,
|
||||||
)
|
)
|
||||||
# Return the error response directly
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
content={
|
content={
|
||||||
@@ -153,6 +174,5 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
headers={"Retry-After": str(int(retry_after))},
|
headers={"Retry-After": str(int(retry_after))},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Request is allowed, continue to next handler
|
|
||||||
response: Response = await call_next(request)
|
response: Response = await call_next(request)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -26,10 +26,9 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from app.utils.logging_compat import get_logger
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped]
|
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 import setup_service
|
||||||
from app.services.dns_validated_connector import create_dns_validated_socket_factory
|
from app.services.dns_validated_connector import create_dns_validated_socket_factory
|
||||||
from app.services.geo_cache import GeoCache
|
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.async_utils import run_blocking
|
||||||
from app.utils.fail2ban_db_utils import ensure_fail2ban_indexes
|
from app.utils.fail2ban_db_utils import ensure_fail2ban_indexes
|
||||||
from app.utils.jail_config import ensure_jail_configs
|
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.runtime_state import set_runtime_settings
|
||||||
from app.utils.scheduler_lock import (
|
from app.utils.scheduler_lock import (
|
||||||
acquire_scheduler_lock,
|
acquire_scheduler_lock,
|
||||||
@@ -98,9 +98,7 @@ def _check_single_worker_mode() -> None:
|
|||||||
"See Docs/Architekture.md § Deployment Constraints for details."
|
"See Docs/Architekture.md § Deployment Constraints for details."
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"BANGUI_WORKERS environment variable must be an integer, got: {workers_env}") from e
|
||||||
f"BANGUI_WORKERS environment variable must be an integer, got: {workers_env}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_database_schema(database_path: str) -> None:
|
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))
|
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()
|
original_db_path = db_path.resolve()
|
||||||
startup_db = await open_db(settings.database_path)
|
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:
|
if f2b_db_path:
|
||||||
await run_blocking(ensure_fail2ban_indexes, f2b_db_path)
|
await run_blocking(ensure_fail2ban_indexes, f2b_db_path)
|
||||||
|
|
||||||
persisted_runtime_settings = (
|
persisted_runtime_settings = await setup_service.get_persisted_runtime_settings(runtime_db)
|
||||||
await setup_service.get_persisted_runtime_settings(runtime_db)
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
await runtime_db.close()
|
await runtime_db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bangui-backend"
|
name = "bangui-backend"
|
||||||
version = "0.9.19"
|
version = "0.9.19-rc.5"
|
||||||
description = "BanGUI backend — fail2ban web management interface"
|
description = "BanGUI backend — fail2ban web management interface"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -252,6 +252,30 @@ async def test_cleanup_wal_files_removes_orphaned_files(tmp_path: Path) -> None:
|
|||||||
assert not shm_path.exists()
|
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:
|
async def test_cleanup_wal_files_handles_missing_files(tmp_path: Path) -> None:
|
||||||
"""Test that _cleanup_wal_files handles non-existent files gracefully."""
|
"""Test that _cleanup_wal_files handles non-existent files gracefully."""
|
||||||
db_path = str(tmp_path / "nonexistent.db")
|
db_path = str(tmp_path / "nonexistent.db")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import aiosqlite
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
@@ -19,6 +20,13 @@ from app.dependencies import (
|
|||||||
get_settings,
|
get_settings,
|
||||||
get_settings_repo,
|
get_settings_repo,
|
||||||
)
|
)
|
||||||
|
from app.exceptions import (
|
||||||
|
DatabaseBusyError,
|
||||||
|
DatabaseCorruptedError,
|
||||||
|
DatabasePathInvalidError,
|
||||||
|
DatabasePermissionDeniedError,
|
||||||
|
DatabaseUnavailableError,
|
||||||
|
)
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.models.server import ServerStatus
|
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()
|
await gen.aclose()
|
||||||
|
|
||||||
mock_open_db.assert_awaited_once_with("/tmp/runtime.db")
|
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 asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ from app.main import (
|
|||||||
from app.middleware.correlation import CorrelationIdMiddleware
|
from app.middleware.correlation import CorrelationIdMiddleware
|
||||||
from app.middleware.rate_limit import RateLimitMiddleware
|
from app.middleware.rate_limit import RateLimitMiddleware
|
||||||
from app.services import setup_service
|
from app.services import setup_service
|
||||||
|
from app.utils.json_formatter import JSONFormatter
|
||||||
|
|
||||||
|
|
||||||
def test_create_app_configures_cors_from_settings() -> None:
|
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)
|
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
|
# Middleware order validation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -934,6 +934,29 @@ class TestBanTrend:
|
|||||||
parsed = datetime.fromisoformat(bucket.timestamp)
|
parsed = datetime.fromisoformat(bucket.timestamp)
|
||||||
assert parsed.tzinfo is not None # Must be timezone-aware (UTC)
|
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
|
# bans_by_jail
|
||||||
|
|||||||
@@ -134,3 +134,15 @@ class TestSinceUnix:
|
|||||||
# The slack should be ~60 seconds
|
# The slack should be ~60 seconds
|
||||||
assert actual_slack >= TIME_RANGE_SLACK_SECONDS - 1
|
assert actual_slack >= TIME_RANGE_SLACK_SECONDS - 1
|
||||||
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
|
||||||
|
|||||||
@@ -134,24 +134,17 @@ class TestRateLimitMiddleware:
|
|||||||
"""Global rate limit should block requests exceeding per-IP limit."""
|
"""Global rate limit should block requests exceeding per-IP limit."""
|
||||||
await _do_setup(client)
|
await _do_setup(client)
|
||||||
|
|
||||||
# Create a client that mimics a specific IP
|
|
||||||
# We'll make many requests and see if we hit the limit
|
|
||||||
limiter = client._transport.app.state.global_rate_limiter
|
limiter = client._transport.app.state.global_rate_limiter
|
||||||
limiter.reset()
|
limiter.reset()
|
||||||
|
|
||||||
# Reduce limit temporarily for testing.
|
|
||||||
# Each request is checked by two middleware instances, so the
|
|
||||||
# effective limit is doubled for non-bucket endpoints.
|
|
||||||
original_max = limiter.max_requests
|
original_max = limiter.max_requests
|
||||||
limiter.max_requests = 7
|
limiter.max_requests = 3
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First 3 requests should succeed
|
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
response = await client.get("/api/v1/health")
|
response = await client.get("/api/v1/health")
|
||||||
assert response.status_code == 200, f"Request {i + 1} failed"
|
assert response.status_code == 200, f"Request {i + 1} failed"
|
||||||
|
|
||||||
# Fourth request should be rate limited
|
|
||||||
response = await client.get("/api/v1/health")
|
response = await client.get("/api/v1/health")
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
assert response.json()["code"] == "rate_limit_exceeded"
|
assert response.json()["code"] == "rate_limit_exceeded"
|
||||||
@@ -166,22 +159,47 @@ class TestRateLimitMiddleware:
|
|||||||
limiter = client._transport.app.state.global_rate_limiter
|
limiter = client._transport.app.state.global_rate_limiter
|
||||||
limiter.reset()
|
limiter.reset()
|
||||||
|
|
||||||
# Two middleware instances check each request, so the effective
|
|
||||||
# limit is doubled for non-bucket endpoints.
|
|
||||||
original_max = limiter.max_requests
|
original_max = limiter.max_requests
|
||||||
limiter.max_requests = 3
|
limiter.max_requests = 2
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First request succeeds
|
|
||||||
response = await client.get("/api/v1/health")
|
response = await client.get("/api/v1/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Second request is rate limited
|
response = await client.get("/api/v1/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
response = await client.get("/api/v1/health")
|
response = await client.get("/api/v1/health")
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
assert "Retry-After" in response.headers
|
assert "Retry-After" in response.headers
|
||||||
retry_after = int(response.headers["Retry-After"])
|
retry_after = int(response.headers["Retry-After"])
|
||||||
assert retry_after > 0
|
assert retry_after > 0
|
||||||
assert retry_after <= 60 # Should be less than window
|
assert retry_after <= 60
|
||||||
finally:
|
finally:
|
||||||
limiter.max_requests = original_max
|
limiter.max_requests = original_max
|
||||||
|
|
||||||
|
async def test_auth_bucket_allows_more_requests(self, client: AsyncClient) -> None:
|
||||||
|
"""Auth endpoints use a dedicated high-rate bucket."""
|
||||||
|
await _do_setup(client)
|
||||||
|
|
||||||
|
limiter = client._transport.app.state.global_rate_limiter
|
||||||
|
limiter.reset()
|
||||||
|
|
||||||
|
# The auth bucket is configured for 1000 req/min; we only need to
|
||||||
|
# verify that it is *not* the global bucket (200 req/min).
|
||||||
|
for _ in range(5):
|
||||||
|
response = await client.post("/api/v1/auth/login", json={"password": "x"})
|
||||||
|
assert response.status_code in (401, 403, 429)
|
||||||
|
|
||||||
|
async def test_history_bucket_allows_more_requests(self, client: AsyncClient) -> None:
|
||||||
|
"""History endpoints use a dedicated high-rate bucket."""
|
||||||
|
await _do_setup(client)
|
||||||
|
|
||||||
|
limiter = client._transport.app.state.global_rate_limiter
|
||||||
|
limiter.reset()
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
response = await client.get("/api/v1/history/bans")
|
||||||
|
# 401/403 is fine — we just need to confirm we are not 429'd
|
||||||
|
# by the global limiter.
|
||||||
|
assert response.status_code != 429
|
||||||
|
|||||||
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
|
# 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
|
## Setup
|
||||||
|
|
||||||
Install dependencies:
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
rfbrowser init
|
rfbrowser init
|
||||||
@@ -14,10 +44,17 @@ rfbrowser init
|
|||||||
robot --outputdir results --log log.html --report report.html tests/
|
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
|
## Run Specific Test File
|
||||||
|
|
||||||
```bash
|
```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
|
## Run with Browser Visible
|
||||||
@@ -26,10 +63,42 @@ robot --outputdir results tests/01_page_loading.robot
|
|||||||
robot --outputdir results --variable BROWSER:chromium tests/
|
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
|
## View Results
|
||||||
|
|
||||||
Open `results/log.html` or `results/report.html` in a browser.
|
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
|
# 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 ***
|
*** Keywords ***
|
||||||
Login Via HTTP
|
Login Via HTTP
|
||||||
[Documentation] Login via HTTP and store session cookie for RequestsLibrary.
|
[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
|
${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}
|
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
|
${login_resp}= POST On Session bangsess /api/v1/auth/login
|
||||||
... json=${login_payload}
|
... json=${login_payload}
|
||||||
... expected_status=200
|
... expected_status=200
|
||||||
@@ -22,7 +34,7 @@ Login As Admin
|
|||||||
IF not ${body}[completed]
|
IF not ${body}[completed]
|
||||||
# Complete setup wizard via HTTP API.
|
# Complete setup wizard via HTTP API.
|
||||||
${setup_payload}= Create Dictionary
|
${setup_payload}= Create Dictionary
|
||||||
... master_password=Hallo123!
|
... master_password=${TEST_PASSWORD}
|
||||||
... database_path=bangui.db
|
... database_path=bangui.db
|
||||||
... fail2ban_socket=/var/run/fail2ban/fail2ban.sock
|
... fail2ban_socket=/var/run/fail2ban/fail2ban.sock
|
||||||
... timezone=UTC
|
... timezone=UTC
|
||||||
@@ -50,7 +62,7 @@ Login As Admin
|
|||||||
... const res = await fetch('/api/v1/auth/login', {
|
... const res = await fetch('/api/v1/auth/login', {
|
||||||
... method: 'POST',
|
... method: 'POST',
|
||||||
... headers: { 'Content-Type': 'application/json' },
|
... headers: { 'Content-Type': 'application/json' },
|
||||||
... body: JSON.stringify({ password: 'Hallo123!' }),
|
... body: JSON.stringify({ password: '${TEST_PASSWORD}' }),
|
||||||
... credentials: 'include'
|
... credentials: 'include'
|
||||||
... });
|
... });
|
||||||
... const data = await res.json().catch(() => ({}));
|
... const data = await res.json().catch(() => ({}));
|
||||||
@@ -100,3 +112,60 @@ Login As Admin
|
|||||||
|
|
||||||
${final_url}= Get URL
|
${final_url}= Get URL
|
||||||
Log Login complete. URL: ${final_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 Browser
|
||||||
Library RequestsLibrary
|
Library RequestsLibrary
|
||||||
Library Process
|
Library Process
|
||||||
|
Library String
|
||||||
|
Library Collections
|
||||||
|
Library DateTime
|
||||||
|
|
||||||
*** Variables ***
|
*** Variables ***
|
||||||
${FRONTEND_URL} http://localhost:5173
|
${FRONTEND_URL} http://localhost:5173
|
||||||
${BACKEND_URL} http://localhost:8000
|
${BACKEND_URL} http://localhost:8000
|
||||||
|
${TEST_PASSWORD} Hallo123!
|
||||||
|
${XFF_HEADER} ${EMPTY}
|
||||||
|
|
||||||
*** Keywords ***
|
*** Keywords ***
|
||||||
Wait For Backend Health
|
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
|
[Arguments] ${timeout}=120 ${interval}=5
|
||||||
${deadline}= Evaluate time.time() + ${timeout}
|
${deadline}= Evaluate time.time() + ${timeout}
|
||||||
WHILE True
|
WHILE True
|
||||||
${now}= Evaluate time.time()
|
${now}= Evaluate time.time()
|
||||||
IF ${now} >= ${deadline} FAIL Backend did not become healthy within ${timeout} seconds
|
IF ${now} >= ${deadline} FAIL Backend did not become healthy within ${timeout} seconds
|
||||||
${response}= GET ${BACKEND_URL}/api/v1/health expected_status=200
|
${response}= GET ${BACKEND_URL}/api/v1/health/live expected_status=any
|
||||||
IF ${response.status} == 200 BREAK
|
IF ${response.status_code} == 200 BREAK
|
||||||
Sleep ${interval}
|
Sleep ${interval}
|
||||||
END
|
END
|
||||||
Log Backend is healthy.
|
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
|
Setup Page Renders All Form Fields
|
||||||
[Documentation] Verify all setup wizard fields are present and labelled correctly.
|
[Documentation] Verify all setup wizard fields are present and labelled correctly.
|
||||||
New Browser chromium headless=${TRUE}
|
New Browser chromium headless=${TRUE}
|
||||||
|
New Context
|
||||||
|
New Page
|
||||||
Go To ${FRONTEND_URL}/setup
|
Go To ${FRONTEND_URL}/setup
|
||||||
Wait For Elements State css=form visible timeout=15s
|
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
|
Password Strength Indicator Updates On Input
|
||||||
[Documentation] The four-segment strength bar and rule count reflect password complexity.
|
[Documentation] The four-segment strength bar and rule count reflect password complexity.
|
||||||
New Browser chromium headless=${TRUE}
|
New Browser chromium headless=${TRUE}
|
||||||
|
New Context
|
||||||
|
New Page
|
||||||
Go To ${FRONTEND_URL}/setup
|
Go To ${FRONTEND_URL}/setup
|
||||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
||||||
|
|
||||||
# Initially no segments are active — no rules satisfied.
|
# Verify initial strength text shows "0 of 4 rules satisfied".
|
||||||
${segments}= Get Elements css=.passwordStrengthSegment
|
${text_0}= Get Text xpath=//div[@aria-live="polite"]
|
||||||
${active_count}= Set Variable 0
|
Should Contain ${text_0} 0 of 4 rules satisfied
|
||||||
FOR ${seg} IN @{segments}
|
Log Initial strength: ${text_0}
|
||||||
${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
|
|
||||||
|
|
||||||
# Type a weak password — only length (>=8) rule satisfied.
|
# Type a weak password — only length (>=8) rule satisfied.
|
||||||
Fill Text css=input[aria-label="Master Password"] WeakPass
|
Fill Text css=input[aria-label="Master Password"] longpassword
|
||||||
${active_count}= Set Variable 0
|
|
||||||
${segments}= Get Elements css=.passwordStrengthSegment
|
# Verify strength text updates to "1 of 4 rules satisfied" (only length rule, no uppercase/number/special).
|
||||||
FOR ${seg} IN @{segments}
|
${text_1}= Get Text xpath=//div[@aria-live="polite"]
|
||||||
${classes}= Get Attribute ${seg} class
|
Should Contain ${text_1} 1 of 4 rules satisfied
|
||||||
IF "Active" in """${classes}"""
|
Log After longpassword: ${text_1}
|
||||||
${active_count}= Evaluate ${active_count} + 1
|
|
||||||
END
|
|
||||||
END
|
|
||||||
Should Be Equal As Integers ${active_count} 1
|
|
||||||
|
|
||||||
Close Browser
|
Close Browser
|
||||||
|
|
||||||
Password Mismatch Shows Validation Error
|
Password Mismatch Shows Validation Error
|
||||||
[Documentation] Submitting with non-matching passwords surfaces an error on Confirm Password.
|
[Documentation] Submitting with non-matching passwords surfaces an error on Confirm Password.
|
||||||
New Browser chromium headless=${TRUE}
|
New Browser chromium headless=${TRUE}
|
||||||
|
New Context
|
||||||
|
New Page
|
||||||
Go To ${FRONTEND_URL}/setup
|
Go To ${FRONTEND_URL}/setup
|
||||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
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!
|
Fill Text css=input[aria-label="Confirm Password"] Different123!
|
||||||
Click css=button[type="submit"]
|
Click css=button[type="submit"]
|
||||||
|
|
||||||
Wait For Elements State css=[aria-label="Confirm Password"] attached timeout=5s
|
Wait For Elements State xpath=//*[@aria-label="Confirm Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] visible timeout=10s
|
||||||
${msg}= Get Text css=[aria-label="Confirm Password"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
|
${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.
|
Should Be Equal As Strings ${msg} Passwords do not match.
|
||||||
|
|
||||||
Close Browser
|
Close Browser
|
||||||
@@ -78,21 +74,23 @@ Password Mismatch Shows Validation Error
|
|||||||
Empty Required Fields Show Validation Errors
|
Empty Required Fields Show Validation Errors
|
||||||
[Documentation] Submitting with blank required fields shows field-level error messages.
|
[Documentation] Submitting with blank required fields shows field-level error messages.
|
||||||
New Browser chromium headless=${TRUE}
|
New Browser chromium headless=${TRUE}
|
||||||
|
New Context
|
||||||
|
New Page
|
||||||
Go To ${FRONTEND_URL}/setup
|
Go To ${FRONTEND_URL}/setup
|
||||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
||||||
|
|
||||||
Click css=button[type="submit"]
|
Click css=button[type="submit"]
|
||||||
|
|
||||||
Wait For Elements State css=[aria-label="Master Password"] attached timeout=5s
|
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.
|
Should Be Equal As Strings ${msg} Password is required.
|
||||||
|
|
||||||
Wait For Elements State css=[aria-label="Database Path"] attached timeout=5s
|
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.
|
Should Be Equal As Strings ${msg} Database path is required.
|
||||||
|
|
||||||
Wait For Elements State css=[aria-label="fail2ban Socket Path"] attached timeout=5s
|
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.
|
Should Be Equal As Strings ${msg} Socket path is required.
|
||||||
|
|
||||||
Close Browser
|
Close Browser
|
||||||
@@ -100,6 +98,8 @@ Empty Required Fields Show Validation Errors
|
|||||||
Invalid Session Duration Shows Validation Error
|
Invalid Session Duration Shows Validation Error
|
||||||
[Documentation] Session duration below 1 minute triggers a validation error.
|
[Documentation] Session duration below 1 minute triggers a validation error.
|
||||||
New Browser chromium headless=${TRUE}
|
New Browser chromium headless=${TRUE}
|
||||||
|
New Context
|
||||||
|
New Page
|
||||||
Go To ${FRONTEND_URL}/setup
|
Go To ${FRONTEND_URL}/setup
|
||||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
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"]
|
Click css=button[type="submit"]
|
||||||
|
|
||||||
Wait For Elements State css=[aria-label="Session Duration (minutes)"] attached timeout=5s
|
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.
|
Should Be Equal As Strings ${msg} Session duration must be at least 1 minute.
|
||||||
|
|
||||||
Close Browser
|
Close Browser
|
||||||
@@ -120,14 +120,16 @@ Invalid Session Duration Shows Validation Error
|
|||||||
Incomplete Password Shows Complexity Error
|
Incomplete Password Shows Complexity Error
|
||||||
[Documentation] Submitting a password that meets length but not all rules shows complexity error.
|
[Documentation] Submitting a password that meets length but not all rules shows complexity error.
|
||||||
New Browser chromium headless=${TRUE}
|
New Browser chromium headless=${TRUE}
|
||||||
|
New Context
|
||||||
|
New Page
|
||||||
Go To ${FRONTEND_URL}/setup
|
Go To ${FRONTEND_URL}/setup
|
||||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
||||||
|
|
||||||
Fill Text css=input[aria-label="Master Password"] short
|
Fill Text css=input[aria-label="Master Password"] short
|
||||||
Click css=button[type="submit"]
|
Click css=button[type="submit"]
|
||||||
|
|
||||||
Wait For Elements State css=[aria-label="Master Password"] attached timeout=5s
|
Wait For Elements State xpath=//*[@aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] visible timeout=10s
|
||||||
${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 Contain ${msg} Password must meet all complexity requirements.
|
Should Contain ${msg} Password must meet all complexity requirements.
|
||||||
|
|
||||||
Close Browser
|
Close Browser
|
||||||
@@ -135,11 +137,13 @@ Incomplete Password Shows Complexity Error
|
|||||||
Setup Completes Successfully And Redirects To Login
|
Setup Completes Successfully And Redirects To Login
|
||||||
[Documentation] Filling all fields and submitting completes setup and navigates to /login.
|
[Documentation] Filling all fields and submitting completes setup and navigates to /login.
|
||||||
New Browser chromium headless=${TRUE}
|
New Browser chromium headless=${TRUE}
|
||||||
|
New Context
|
||||||
|
New Page
|
||||||
|
|
||||||
# Use API to check if setup is already complete; reset if needed.
|
# 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()}
|
${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
|
Go To ${FRONTEND_URL}/setup
|
||||||
Wait For Elements State css=input[aria-label="Master Password"] visible timeout=15s
|
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
|
END
|
||||||
|
|
||||||
# Verify setup is now marked complete.
|
# 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()}
|
${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
|
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.
|
# polling backend; no fixed interval but the ban is near-instant once detected.
|
||||||
Sleep 20s
|
Sleep 20s
|
||||||
|
|
||||||
# Step 3 — backend API: confirm ban via Python in fail2ban container.
|
# Step 3 — fail2ban: confirm IP is banned in manual-Jail
|
||||||
# Browser (Playwright) and host shell have same IP, hitting GlobalRateLimiter.
|
${resp}= Run Process
|
||||||
# fail2ban container has a different source IP, so its requests bypass the limit.
|
... bash
|
||||||
# Container reaches backend via host network (localhost:8000).
|
... -c
|
||||||
${resp}= Run Process bash -c docker exec bangui-fail2ban-dev python3 /tmp/check_ban.py timeout=15s
|
... 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}
|
${resp_text}= Set Variable ${resp.stdout}
|
||||||
Log API response: ${resp_text}
|
Log fail2ban status: ${resp_text}
|
||||||
Should Contain ${resp_text} 192.168.100.99
|
Should Contain ${resp_text} 192.168.100.99
|
||||||
|
|
||||||
# Step 4 — History page: confirm UI surfaces the ban record
|
# 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
|
||||||
10343
frontend/openapi.json
Normal file
10343
frontend/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"version": "0.9.19",
|
"version": "0.9.19-rc.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"version": "0.9.19",
|
"version": "0.9.19-rc.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react-components": "^9.55.0",
|
"@fluentui/react-components": "^9.55.0",
|
||||||
"@fluentui/react-icons": "^2.0.257",
|
"@fluentui/react-icons": "^2.0.257",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.19",
|
"version": "0.9.19-rc.5",
|
||||||
"description": "BanGUI frontend — fail2ban web management interface",
|
"description": "BanGUI frontend — fail2ban web management interface",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"generate:types": "openapi-typescript http://localhost:8000/api/openapi.json -o src/types/generated.ts",
|
"generate:types": "openapi-typescript ./openapi.json -o src/types/generated.ts",
|
||||||
"validate:types": "bash scripts/validate-types.sh",
|
"validate:types": "bash scripts/validate-types.sh",
|
||||||
"build": "npm run generate:types && tsc --noEmit && vite build",
|
"build": "npm run generate:types && tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
|||||||
@@ -17,17 +17,23 @@ GENERATED_FILE="${TYPES_DIR}/generated.ts"
|
|||||||
TEMP_FILE=$(mktemp)
|
TEMP_FILE=$(mktemp)
|
||||||
trap "rm -f $TEMP_FILE" EXIT
|
trap "rm -f $TEMP_FILE" EXIT
|
||||||
|
|
||||||
# Check if backend is accessible
|
# Determine OpenAPI source: local file or backend URL
|
||||||
BACKEND_URL="${BANGUI_BACKEND_URL:-http://localhost:8000}"
|
BACKEND_URL="${BANGUI_BACKEND_URL:-http://localhost:8000}"
|
||||||
if ! curl -sf "${BACKEND_URL}/api/openapi.json" > /dev/null 2>&1; then
|
OPENAPI_SOURCE=""
|
||||||
echo "❌ Backend not accessible at ${BACKEND_URL}/api/openapi.json" >&2
|
|
||||||
|
if [[ -f "${FRONTEND_DIR}/openapi.json" ]]; then
|
||||||
|
OPENAPI_SOURCE="${FRONTEND_DIR}/openapi.json"
|
||||||
|
echo "📋 Validating OpenAPI schema types (local openapi.json)..."
|
||||||
|
elif curl -sf "${BACKEND_URL}/api/openapi.json" > /dev/null 2>&1; then
|
||||||
|
OPENAPI_SOURCE="${BACKEND_URL}/api/openapi.json"
|
||||||
|
echo "📋 Validating OpenAPI schema types (backend ${BACKEND_URL})..."
|
||||||
|
else
|
||||||
|
echo "❌ Backend not accessible at ${BACKEND_URL}/api/openapi.json and no local openapi.json found" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📋 Validating OpenAPI schema types..."
|
|
||||||
|
|
||||||
# Generate types to a temporary file
|
# Generate types to a temporary file
|
||||||
if ! npx openapi-typescript "${BACKEND_URL}/api/openapi.json" -o "$TEMP_FILE" 2>&1; then
|
if ! npx openapi-typescript "${OPENAPI_SOURCE}" -o "$TEMP_FILE" 2>&1; then
|
||||||
echo "❌ Failed to generate types from OpenAPI schema" >&2
|
echo "❌ Failed to generate types from OpenAPI schema" >&2
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { ErrorBoundary } from "../ErrorBoundary";
|
import { ErrorBoundary } from "../ErrorBoundary";
|
||||||
import * as telemetry from "../../utils/telemetry";
|
|
||||||
|
|
||||||
// Mock telemetry to verify it's called
|
// Mock telemetry to verify it's called
|
||||||
vi.mock("../../utils/telemetry");
|
vi.mock("../../utils/telemetry");
|
||||||
|
|||||||
@@ -468,13 +468,10 @@ describe("useFetchData", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("last subscriber abort cancels underlying request", async () => {
|
it("last subscriber abort cancels underlying request", async () => {
|
||||||
let resolveFirst: ((value: { value: string }) => void) | null = null;
|
|
||||||
const abortSignals: AbortSignal[] = [];
|
const abortSignals: AbortSignal[] = [];
|
||||||
const fetcher = vi.fn().mockImplementation((signal: AbortSignal) => {
|
const fetcher = vi.fn().mockImplementation((signal: AbortSignal) => {
|
||||||
abortSignals.push(signal);
|
abortSignals.push(signal);
|
||||||
return new Promise((resolve) => {
|
return new Promise(() => {});
|
||||||
resolveFirst = resolve;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
const selector = vi.fn((response: { value: string }) => response.value);
|
const selector = vi.fn((response: { value: string }) => response.value);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe("useJailBannedIps", () => {
|
|||||||
const fetchMock = vi.mocked(api.fetchJailBannedIps);
|
const fetchMock = vi.mocked(api.fetchJailBannedIps);
|
||||||
const unbanMock = vi.mocked(api.unbanIp);
|
const unbanMock = vi.mocked(api.unbanIp);
|
||||||
|
|
||||||
fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US" }], total: 1, page: 1, page_size: 25 });
|
fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US" }], total: 1, page: 1, page_size: 25, total_pages: 1, pagination_mode: "offset" });
|
||||||
unbanMock.mockResolvedValue({ message: "ok", jail: "sshd", success: true });
|
unbanMock.mockResolvedValue({ message: "ok", jail: "sshd", success: true });
|
||||||
|
|
||||||
const { result } = renderHook(() => useJailBannedIps("sshd"));
|
const { result } = renderHook(() => useJailBannedIps("sshd"));
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ describe("usePolledData", () => {
|
|||||||
vi.runAllTimersAsync();
|
vi.runAllTimersAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
const callCountAfterInitial = fetcher.mock.calls.length;
|
|
||||||
|
|
||||||
// Reset timer and advance to ensure no more polls
|
// Reset timer and advance to ensure no more polls
|
||||||
vi.clearAllTimers();
|
vi.clearAllTimers();
|
||||||
fetcher.mockClear();
|
fetcher.mockClear();
|
||||||
@@ -66,8 +64,6 @@ describe("usePolledData", () => {
|
|||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialCalls = fetcher.mock.calls.length;
|
|
||||||
|
|
||||||
// Clear for clean test
|
// Clear for clean test
|
||||||
fetcher.mockClear();
|
fetcher.mockClear();
|
||||||
|
|
||||||
@@ -135,7 +131,6 @@ describe("usePolledData", () => {
|
|||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialCalls = fetcher.mock.calls.length;
|
|
||||||
fetcher.mockClear();
|
fetcher.mockClear();
|
||||||
|
|
||||||
// Call refresh
|
// Call refresh
|
||||||
|
|||||||
@@ -77,11 +77,34 @@ export function usePolledData<TResponse, TData>(
|
|||||||
pauseWhenHidden = false,
|
pauseWhenHidden = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
// Stabilize fetcher/selector/onSuccess references so that useFetchData's
|
||||||
|
// refresh callback (and the useEffect that calls it) don't re-trigger on
|
||||||
|
// every render when callers pass inline functions.
|
||||||
|
const fetcherRef = useRef(fetcher);
|
||||||
|
fetcherRef.current = fetcher;
|
||||||
|
const selectorRef = useRef(selector);
|
||||||
|
selectorRef.current = selector;
|
||||||
|
const onSuccessRef = useRef(onSuccess);
|
||||||
|
onSuccessRef.current = onSuccess;
|
||||||
|
|
||||||
|
const stableFetcher = useCallback(
|
||||||
|
(signal: AbortSignal) => fetcherRef.current(signal),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const stableSelector = useCallback(
|
||||||
|
(response: TResponse) => selectorRef.current(response),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const stableOnSuccess = useCallback(
|
||||||
|
(response: TResponse) => onSuccessRef.current?.(response),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const { data, loading, error, refresh } = useFetchData({
|
const { data, loading, error, refresh } = useFetchData({
|
||||||
fetcher,
|
fetcher: stableFetcher,
|
||||||
selector,
|
selector: stableSelector,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
onSuccess,
|
onSuccess: onSuccessRef.current ? stableOnSuccess : undefined,
|
||||||
initialData,
|
initialData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,15 +174,10 @@ export function usePolledData<TResponse, TData>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record when polling starts and schedule first poll immediately
|
// Record when polling starts. The initial fetch is handled by useFetchData's
|
||||||
|
// mount effect, so we just mark the start time and let the loading-completion
|
||||||
|
// effect (above) schedule the first poll after the initial fetch finishes.
|
||||||
pollStartTimeRef.current = performance.now();
|
pollStartTimeRef.current = performance.now();
|
||||||
const id = window.setTimeout((): void => {
|
|
||||||
if (cancelledRef.current) return;
|
|
||||||
pollStartTimeRef.current = performance.now();
|
|
||||||
refreshRef.current?.();
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
timeoutIdRef.current = id;
|
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
cancelledRef.current = true;
|
cancelledRef.current = true;
|
||||||
|
|||||||
@@ -299,8 +299,9 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
label="Master Password"
|
label="Master Password"
|
||||||
required
|
required
|
||||||
validationMessage={
|
validationMessage={
|
||||||
errors.masterPassword ??
|
errors.masterPassword
|
||||||
(passwordRules.some((rule) => !rule.satisfied)
|
? errors.masterPassword
|
||||||
|
: passwordRules.some((rule) => !rule.satisfied)
|
||||||
? {
|
? {
|
||||||
children: (
|
children: (
|
||||||
<ul className={styles.passwordRuleList}>
|
<ul className={styles.passwordRuleList}>
|
||||||
@@ -319,7 +320,7 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
</ul>
|
</ul>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: undefined)
|
: undefined
|
||||||
}
|
}
|
||||||
validationState={
|
validationState={
|
||||||
errors.masterPassword || passwordRules.some((rule) => !rule.satisfied)
|
errors.masterPassword || passwordRules.some((rule) => !rule.satisfied)
|
||||||
@@ -332,6 +333,7 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
value={values.masterPassword}
|
value={values.masterPassword}
|
||||||
onChange={handleChange("masterPassword")}
|
onChange={handleChange("masterPassword")}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
aria-label="Master Password"
|
||||||
/>
|
/>
|
||||||
<div className={styles.passwordStrength} aria-live="polite">
|
<div className={styles.passwordStrength} aria-live="polite">
|
||||||
<div className={styles.passwordStrengthBar}>
|
<div className={styles.passwordStrengthBar}>
|
||||||
@@ -363,6 +365,7 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
value={values.confirmPassword}
|
value={values.confirmPassword}
|
||||||
onChange={handleChange("confirmPassword")}
|
onChange={handleChange("confirmPassword")}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
aria-label="Confirm Password"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -375,6 +378,7 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
<Input
|
<Input
|
||||||
value={values.databasePath}
|
value={values.databasePath}
|
||||||
onChange={handleChange("databasePath")}
|
onChange={handleChange("databasePath")}
|
||||||
|
aria-label="Database Path"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -387,6 +391,7 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
<Input
|
<Input
|
||||||
value={values.fail2banSocket}
|
value={values.fail2banSocket}
|
||||||
onChange={handleChange("fail2banSocket")}
|
onChange={handleChange("fail2banSocket")}
|
||||||
|
aria-label="fail2ban Socket Path"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -397,6 +402,7 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
<Input
|
<Input
|
||||||
value={values.timezone}
|
value={values.timezone}
|
||||||
onChange={handleChange("timezone")}
|
onChange={handleChange("timezone")}
|
||||||
|
aria-label="Timezone"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -410,7 +416,7 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
type="number"
|
type="number"
|
||||||
value={values.sessionDurationMinutes}
|
value={values.sessionDurationMinutes}
|
||||||
onChange={handleChange("sessionDurationMinutes")}
|
onChange={handleChange("sessionDurationMinutes")}
|
||||||
min={1}
|
aria-label="Session Duration (minutes)"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import * as authApi from "../api/auth";
|
import * as authApi from "../api/auth";
|
||||||
import { setUnauthorizedHandler, resetLogoutState, clearSessionCorrelationId } from "../api/client";
|
import { ApiError, setUnauthorizedHandler, resetLogoutState, clearSessionCorrelationId } from "../api/client";
|
||||||
import { setAuthErrorHandler, resetLogoutState as resetFetchErrorLogoutState } from "../utils/fetchError";
|
import { setAuthErrorHandler, resetLogoutState as resetFetchErrorLogoutState } from "../utils/fetchError";
|
||||||
import { STORAGE_KEY_AUTHENTICATED } from "../utils/constants";
|
import { STORAGE_KEY_AUTHENTICATED } from "../utils/constants";
|
||||||
import { SessionValidationLoading } from "../components/SessionValidationLoading";
|
import { SessionValidationLoading } from "../components/SessionValidationLoading";
|
||||||
@@ -133,6 +133,11 @@ export function AuthProvider({
|
|||||||
|
|
||||||
const handleValidationError = useCallback(
|
const handleValidationError = useCallback(
|
||||||
(error: Error): void => {
|
(error: Error): void => {
|
||||||
|
// Suppress noisy warning for 5xx gateway errors (e.g. 502 Bad Gateway)
|
||||||
|
// during startup — these are server-side issues, not network issues.
|
||||||
|
if (error instanceof ApiError && error.status >= 500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Network error — log but don't logout.
|
// Network error — log but don't logout.
|
||||||
console.warn("Session validation network error:", error);
|
console.warn("Session validation network error:", error);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -177,11 +177,6 @@ export interface paths {
|
|||||||
* On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
|
* On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
|
||||||
* cookie so the browser SPA benefits from automatic credential handling.
|
* cookie so the browser SPA benefits from automatic credential handling.
|
||||||
*
|
*
|
||||||
* Rate limiting: Exponential backoff on failed attempts. Each wrong password
|
|
||||||
* incurs an increasing delay (0.5s, 1s, 2s, 4s, 5s max per IP address).
|
|
||||||
* Requests during the penalty period return ``429 Too Many Requests`` with
|
|
||||||
* a ``Retry-After`` header.
|
|
||||||
*
|
|
||||||
* Cache invalidation: On successful login, any existing cached sessions for
|
* Cache invalidation: On successful login, any existing cached sessions for
|
||||||
* the same user are invalidated so that stale tokens (e.g., from a stolen
|
* the same user are invalidated so that stale tokens (e.g., from a stolen
|
||||||
* device) cannot be reused beyond the cache TTL window.
|
* device) cannot be reused beyond the cache TTL window.
|
||||||
@@ -192,7 +187,6 @@ export interface paths {
|
|||||||
* request: The incoming HTTP request (used to extract client IP).
|
* request: The incoming HTTP request (used to extract client IP).
|
||||||
* session_ctx: Session service context containing db and repository.
|
* session_ctx: Session service context containing db and repository.
|
||||||
* settings: Application settings (used for session duration and trusted proxies).
|
* settings: Application settings (used for session duration and trusted proxies).
|
||||||
* rate_limiter: The login rate limiter (per IP).
|
|
||||||
* session_cache: Session cache for invalidating old sessions on login.
|
* session_cache: Session cache for invalidating old sessions on login.
|
||||||
*
|
*
|
||||||
* Returns:
|
* Returns:
|
||||||
@@ -200,7 +194,6 @@ export interface paths {
|
|||||||
*
|
*
|
||||||
* Raises:
|
* Raises:
|
||||||
* AuthenticationError: if the password is incorrect.
|
* AuthenticationError: if the password is incorrect.
|
||||||
* RateLimitError: if the rate limit is exceeded.
|
|
||||||
*/
|
*/
|
||||||
post: operations["login_api_v1_auth_login_post"];
|
post: operations["login_api_v1_auth_login_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -6274,13 +6267,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
/** @description Too many login attempts, retry after delay */
|
|
||||||
429: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
/** @description Setup not complete */
|
/** @description Setup not complete */
|
||||||
503: {
|
503: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user