13 Commits

Author SHA1 Message Date
408eb900eb Remove Tasks.md spec, add test for _cleanup_wal_files skipping recent files
Remove 335-line task specification from Docs/Tasks.md.
Add test confirming _cleanup_wal_files skips recently-modified WAL/SHM files.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-23 23:04:04 +02:00
407ca83850 Add tests for since timestamp accuracy in ban_service
- test_since_unix_returns_utc_epoch: validates since_unix('24h') returns UTC epoch
- test_ban_trend_since_is_within_expected_range: validates 23h-ago ban falls in 24h+slack window

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-23 23:00:51 +02:00
72273ca945 Add logging duplication tests
- test_logging_configuration_no_duplicate_handlers: verify create_app() twice leaves ≤1 StreamHandler
- test_uvicorn_access_logs_go_through_root_handler: verify uvicorn.access can emit JSON via JSONFormatter
- test_external_logging_processor_queues_record: verify _external_logging_processor queues to handler
- test_plain_text_logs_not_emitted_after_startup: verify app.db emits JSON not plain text

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-23 22:42:52 +02:00
9e59fc8bae Add granular DB error types with retry logic
New exceptions: DatabaseBusyError, DatabasePermissionDeniedError,
DatabasePathInvalidError, DatabaseCorruptedError, DatabaseUnavailableError.

open_db creates parent directory if missing. Catches all aiosqlite errors
and maps to specific exception types.

get_db retries up to 3x on locked database with backoff.
Propagates specific exceptions instead of generic HTTPException.

Tests for all new error types and retry behavior.
2026-05-23 22:21:42 +02:00
ef8feba4b2 docs: add comprehensive task backlog and bump version to rc.5
- Document database error handling, logging duplication, ban service
timestamp, and orphaned SQLite file issues in Tasks.md
- Bump backend version from 0.9.19-rc.4 to 0.9.19-rc.5
2026-05-23 22:09:06 +02:00
5a12d1c22f chore: release v0.9.19-rc.5 2026-05-23 21:32:21 +02:00
aebe0d0236 chore(release): bump version to 0.9.19-rc.4
- Add production Docker Compose configuration

- Add check_auth.py diagnostic script for session 401 debugging
2026-05-23 21:27:52 +02:00
99e1b74405 chore: release v0.9.19-rc.4 2026-05-22 21:49:01 +02:00
9fe52755a5 fix(db): fix migration failures when upgrading from 0.8.0 schema
Migration 1: remove idx_sessions_token_hash from _SCHEMA_STATEMENTS.
The legacy schema has sessions.token (not token_hash). The IF NOT EXISTS
guard only prevents duplicate index names — it still requires the column
to exist. Migration 2 drops and rebuilds sessions with token_hash anyway,
so creating the index in migration 1 was redundant.

Migration 3: replace ALTER TABLE ADD COLUMN with a table rebuild.
SQLite rejects ALTER TABLE ADD COLUMN NOT NULL DEFAULT <expression> when
the table already contains rows. The old DB has ~181k geo_cache rows, so
the ALTER always failed. Rebuild copies existing rows with last_seen set
to cached_at as a reasonable approximation of last-seen time.
2026-05-22 21:47:32 +02:00
9d2d6fadf3 chore: release v0.9.19-rc.3 2026-05-22 20:49:12 +02:00
2e5ac092bf fix(auth): suppress misleading 502 warning during session validation
A 502 Bad Gateway is a server/gateway error, not a network error.
Logging it as a 'Session validation network error' is noisy and
misleading during startup when nginx is temporarily unreachable.

Silently skip the console.warn for 5xx errors in handleValidationError
while keeping the warning for actual network errors.
2026-05-22 20:47:57 +02:00
dcee222a41 chore: release v0.9.19-rc.2 2026-05-22 20:38:33 +02:00
12fe70d768 chore: bump to v0.9.19-rc.1 and add local OpenAPI build support
- Add release candidate (rc) support to release.sh with latestRC tagging
- Bump VERSION, backend pyproject.toml, and frontend package.json to 0.9.19-rc.1
- Add local frontend/openapi.json so build no longer needs running backend
- Update generate:types and validate-types.sh to use local openapi.json
- Fix frontend tests: remove unused imports/variables and update mock data
2026-05-22 20:36:14 +02:00
27 changed files with 333 additions and 3755 deletions

View File

@@ -48,7 +48,6 @@ services:
target: runtime target: runtime
container_name: bangui-backend container_name: bangui-backend
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s # Give lifespan 30s to complete before SIGKILL
depends_on: depends_on:
fail2ban: fail2ban:
condition: service_healthy condition: service_healthy

File diff suppressed because it is too large Load Diff

View File

@@ -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. {item}"); 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}");
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

View File

@@ -274,18 +274,7 @@ 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;")

View File

@@ -318,12 +318,7 @@ 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")

View File

@@ -26,9 +26,10 @@ 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 _cleanup_wal_files, init_db, open_db from app.db import 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
@@ -47,7 +48,6 @@ 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,7 +98,9 @@ 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(f"BANGUI_WORKERS environment variable must be an integer, got: {workers_env}") from e raise RuntimeError(
f"BANGUI_WORKERS environment variable must be an integer, got: {workers_env}"
) from e
async def _ensure_database_schema(database_path: str) -> None: async def _ensure_database_schema(database_path: str) -> None:
@@ -331,11 +333,6 @@ 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)
@@ -360,7 +357,9 @@ 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 = await setup_service.get_persisted_runtime_settings(runtime_db) persisted_runtime_settings = (
await setup_service.get_persisted_runtime_settings(runtime_db)
)
finally: finally:
await runtime_db.close() await runtime_db.close()

View File

@@ -1,38 +1,8 @@
# 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
@@ -44,17 +14,10 @@ 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/02_login.robot robot --outputdir results tests/01_page_loading.robot
robot --outputdir results tests/08_history.robot
``` ```
## Run with Browser Visible ## Run with Browser Visible
@@ -63,42 +26,10 @@ robot --outputdir results tests/08_history.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

93
e2e/playwright-log.txt Normal file
View File

@@ -0,0 +1,93 @@
{"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"}

View File

@@ -1,93 +0,0 @@
#!/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()

View File

@@ -1,78 +0,0 @@
*** 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}

View File

@@ -1,22 +1,10 @@
*** 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 ${TEST_PASSWORD} ${login_payload}= Create Dictionary password Hallo123!
${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
@@ -34,7 +22,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=${TEST_PASSWORD} ... master_password=Hallo123!
... 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
@@ -62,7 +50,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: '${TEST_PASSWORD}' }), ... body: JSON.stringify({ password: 'Hallo123!' }),
... credentials: 'include' ... credentials: 'include'
... }); ... });
... const data = await res.json().catch(() => ({})); ... const data = await res.json().catch(() => ({}));
@@ -111,61 +99,4 @@ Login As Admin
END END
${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

View File

@@ -2,97 +2,20 @@
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/live expected_status=any ${response}= GET ${BACKEND_URL}/api/v1/health expected_status=200
IF ${response.status_code} == 200 BREAK IF ${response.status} == 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)

View File

@@ -1,52 +0,0 @@
*** 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}

View File

@@ -0,0 +1,127 @@
*** 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

View File

@@ -35,14 +35,13 @@ 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 — fail2ban: confirm IP is banned in manual-Jail # Step 3 — backend API: confirm ban via Python in fail2ban container.
${resp}= Run Process # Browser (Playwright) and host shell have same IP, hitting GlobalRateLimiter.
... bash # fail2ban container has a different source IP, so its requests bypass the limit.
... -c # Container reaches backend via host network (localhost:8000).
... 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" ${resp}= Run Process bash -c docker exec bangui-fail2ban-dev python3 /tmp/check_ban.py timeout=15s
... timeout=15s
${resp_text}= Set Variable ${resp.stdout} ${resp_text}= Set Variable ${resp.stdout}
Log fail2ban status: ${resp_text} Log API response: ${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

View File

@@ -1,105 +0,0 @@
*** 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

View File

@@ -1,136 +0,0 @@
*** 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]

View File

@@ -1,129 +0,0 @@
*** 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

View File

@@ -1,181 +0,0 @@
*** 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

View File

@@ -8,8 +8,6 @@ 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
@@ -33,31 +31,37 @@ 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
# Verify initial strength text shows "0 of 4 rules satisfied". # Initially no segments are active — no rules satisfied.
${text_0}= Get Text xpath=//div[@aria-live="polite"] ${segments}= Get Elements css=.passwordStrengthSegment
Should Contain ${text_0} 0 of 4 rules satisfied ${active_count}= Set Variable 0
Log Initial strength: ${text_0} FOR ${seg} IN @{segments}
${classes}= Get Attribute ${seg} class
IF "Active" in """${classes}"""
${active_count}= Evaluate ${active_count} + 1
END
END
Should Be Equal As Integers ${active_count} 0
# 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"] longpassword Fill Text css=input[aria-label="Master Password"] WeakPass
${active_count}= Set Variable 0
# Verify strength text updates to "1 of 4 rules satisfied" (only length rule, no uppercase/number/special). ${segments}= Get Elements css=.passwordStrengthSegment
${text_1}= Get Text xpath=//div[@aria-live="polite"] FOR ${seg} IN @{segments}
Should Contain ${text_1} 1 of 4 rules satisfied ${classes}= Get Attribute ${seg} class
Log After longpassword: ${text_1} IF "Active" in """${classes}"""
${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
@@ -65,8 +69,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 xpath=//*[@aria-label="Confirm Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] visible timeout=10s Wait For Elements State css=[aria-label="Confirm Password"] attached timeout=5s
${msg}= Get Text xpath=//*[@aria-label="Confirm Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] timeout=10s ${msg}= Get Text css=[aria-label="Confirm Password"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
Should Be Equal As Strings ${msg} Passwords do not match. Should Be Equal As Strings ${msg} Passwords do not match.
Close Browser Close Browser
@@ -74,23 +78,21 @@ 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 xpath=//*[@aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] ${msg}= Get Text css=[aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
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 xpath=//*[@aria-label="Database Path"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] ${msg}= Get Text css=[aria-label="Database Path"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
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 xpath=//*[@aria-label="fail2ban Socket Path"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] ${msg}= Get Text css=[aria-label="fail2ban Socket Path"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
Should Be Equal As Strings ${msg} Socket path is required. Should Be Equal As Strings ${msg} Socket path is required.
Close Browser Close Browser
@@ -98,8 +100,6 @@ 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 xpath=//*[@aria-label="Session Duration (minutes)"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] ${msg}= Get Text css=[aria-label="Session Duration (minutes)"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
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,16 +120,14 @@ 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 xpath=//*[@aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] visible timeout=10s Wait For Elements State css=[aria-label="Master Password"] attached timeout=5s
${msg}= Get Text xpath=//*[@aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[@role="alert"] ${msg}= Get Text css=[aria-label="Master Password"]/ancestor::*[contains(@class,"field")]//*[contains(@class,"validationMessage")]
Should Contain ${msg} Password must meet all complexity requirements. Should Contain ${msg} Password must meet all complexity requirements.
Close Browser Close Browser
@@ -137,13 +135,11 @@ 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/v1/setup ${status_resp}= GET ${BACKEND_URL}/api/setup/status
${status_body}= Set Variable ${status_resp.json()} ${status_body}= Set Variable ${status_resp.json()}
Log Setup complete: ${status_body}[completed] Log Setup complete: ${status_body}[setup_complete]
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
@@ -172,8 +168,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/v1/setup ${new_status_resp}= GET ${BACKEND_URL}/api/setup/status
${new_status_body}= Set Variable ${new_status_resp.json()} ${new_status_body}= Set Variable ${new_status_resp.json()}
Should Be True ${new_status_body}[completed] Should Be True ${new_status_body}[setup_complete]
Close Browser Close Browser

View File

@@ -1,180 +0,0 @@
*** 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

View File

@@ -1,153 +0,0 @@
*** 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

View File

@@ -1,102 +0,0 @@
*** 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

View File

@@ -1,161 +0,0 @@
*** 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

View File

@@ -1,121 +0,0 @@
*** 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

View File

@@ -1,12 +1,12 @@
{ {
"name": "bangui-frontend", "name": "bangui-frontend",
"version": "0.9.19-rc.5", "version": "0.9.19-rc.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bangui-frontend", "name": "bangui-frontend",
"version": "0.9.19-rc.5", "version": "0.9.19-rc.4",
"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",

View File

@@ -299,28 +299,27 @@ export function SetupPage(): React.JSX.Element {
label="Master Password" label="Master Password"
required required
validationMessage={ validationMessage={
errors.masterPassword errors.masterPassword ??
? errors.masterPassword (passwordRules.some((rule) => !rule.satisfied)
: passwordRules.some((rule) => !rule.satisfied) ? {
? { children: (
children: ( <ul className={styles.passwordRuleList}>
<ul className={styles.passwordRuleList}> {passwordRules.map((rule) => (
{passwordRules.map((rule) => ( <li
<li key={rule.id}
key={rule.id} className={
className={ rule.satisfied
rule.satisfied ? styles.passwordRuleItemPassed
? styles.passwordRuleItemPassed : styles.passwordRuleItemFailed
: styles.passwordRuleItemFailed }
} >
> {rule.label}
{rule.label} </li>
</li> ))}
))} </ul>
</ul> ),
), }
} : undefined)
: undefined
} }
validationState={ validationState={
errors.masterPassword || passwordRules.some((rule) => !rule.satisfied) errors.masterPassword || passwordRules.some((rule) => !rule.satisfied)
@@ -333,7 +332,6 @@ 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}>
@@ -365,7 +363,6 @@ 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>
@@ -378,7 +375,6 @@ 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>
@@ -391,7 +387,6 @@ 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>
@@ -402,7 +397,6 @@ 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>
@@ -416,7 +410,7 @@ export function SetupPage(): React.JSX.Element {
type="number" type="number"
value={values.sessionDurationMinutes} value={values.sessionDurationMinutes}
onChange={handleChange("sessionDurationMinutes")} onChange={handleChange("sessionDurationMinutes")}
aria-label="Session Duration (minutes)" min={1}
/> />
</Field> </Field>
</div> </div>