From 48d57c31e1509fff0fad3a1b7ae098fdb25a8f82 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 4 May 2026 13:12:57 +0200 Subject: [PATCH] backup --- Docs/CONFIGURATION.md | 2 + Docs/Tasks.md | 119 ---------------------------- Docs/Testing-Requirements.md | 38 ++++++++- Docs/Web-Development.md | 28 +++++++ e2e/test_blocklist.txt | 4 + e2e/tests/03_blocklist_import.robot | 84 ++++++++++++++++++-- e2e/tests/04_config_edit.robot | 73 +++++++++++++++-- 7 files changed, 216 insertions(+), 132 deletions(-) create mode 100644 e2e/test_blocklist.txt diff --git a/Docs/CONFIGURATION.md b/Docs/CONFIGURATION.md index c50b5e2..e71bc7b 100644 --- a/Docs/CONFIGURATION.md +++ b/Docs/CONFIGURATION.md @@ -126,6 +126,8 @@ Per-IP rate limits applied to API endpoints. | `BANGUI_RATE_LIMIT_BLOCKLIST_IMPORT_PER_HOUR` | int | `10` | Max blocklist import requests per IP per hour. | | `BANGUI_RATE_LIMIT_CONFIG_UPDATE_PER_MINUTE` | int | `50` | Max config update requests per IP per minute. | +**Rate limit reset mechanism:** Each limit is applied per-client IP. To bypass the blocklist import rate limit in automated tests (E2E-4), send a unique `X-Forwarded-For` header with each import request — e.g., `X-Forwarded-For: 10.0.0.99`. The header is only honoured when the client IP falls within `BANGUI_TRUSTED_PROXIES`; otherwise the real client IP is used. + --- ## Pagination & Display Limits diff --git a/Docs/Tasks.md b/Docs/Tasks.md index a266648..b72197f 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,122 +1,3 @@ -## [E2E-3] Ban records appear in UI after simulated failed logins - -**Where found:** -`Docker/simulate_failed_logins.sh` exists and is used in the `make dev-ban-test` target, but there is no automated test that verifies the resulting bans are surfaced correctly in the BanGUI frontend (History page, Dashboard, or Jails detail page). - -**Why this is needed:** -The ban pipeline is the core feature of the product. A regression anywhere in the chain (fail2ban log parsing → fail2ban banning → backend polling → database write → API response → frontend rendering) would go undetected until a user reports it. - -**Goal:** -After running `simulate_failed_logins.sh` with a known IP, the ban record for that IP must appear in the BanGUI UI within a reasonable timeout. - -**What to do:** -1. Create `e2e/tests/02_ban_records.robot`. -2. Suite Setup: `Login As Admin`. -3. Test teardown: unban the test IP using `Docker/check_ban_status.sh --unban 192.168.100.99` via `Run Process`. -4. Test case: - ```robot - *** Test Cases *** - Simulated Failed Logins Appear As Ban Records - [Teardown] Run Process bash ${EXECDIR}/Docker/check_ban_status.sh - ... --unban 192.168.100.99 - # Step 1 — write failure lines - ${result}= Run Process - ... bash ${EXECDIR}/Docker/simulate_failed_logins.sh 5 192.168.100.99 - ... timeout=15s - Should Be Equal As Integers ${result.rc} 0 - # Step 2 — wait for fail2ban to process and backend to pick up the ban - Sleep 15s - # Step 3 — check History page - Go To ${FRONTEND_URL}/history - Wait For Elements State css=table,tbody visible timeout=20s - Get Text body contains 192.168.100.99 - # Step 4 — confirm jail name is shown - Get Text body contains manual-Jail - ``` -5. Optionally add a direct API assertion before the UI check to isolate UI vs. backend failures: - ```robot - ${resp}= GET ${BACKEND_URL}/api/history expected_status=200 - Should Contain ${resp.text} 192.168.100.99 - ``` - -**Possible traps and issues:** -- The default `COUNT` for `simulate_failed_logins.sh` is 5 and the fail2ban `maxretry` for `manual-Jail` must be ≤ 5 for the ban to trigger. If the jail config has been changed locally the test may pass the script step but produce no ban. -- `simulate_failed_logins.sh` writes to `Docker/logs/auth.log`. The fail2ban container reads from `/remotelogs/bangui/auth.log` (mapped volume). If the file mapping differs the lines will never be detected. -- The backend polls fail2ban on a schedule (APScheduler). The 15 s sleep may not be enough if the polling interval is longer. Read the scheduler interval from the config before hardcoding the wait. -- `check_ban_status.sh` uses `docker exec` directly. In a Podman environment the container runtime may be `podman`, making the unban teardown fail and leaving the test IP permanently banned until manual cleanup. -- The History page paginates results. If the test IP is not on the first page the `contains` assertion will fail. Assert via the API or set a large page size query parameter in the URL. -- Running the test suite multiple times in the same session accumulates lines in `auth.log`. Old lines do not re-trigger bans, but the counter inside fail2ban resets on container restart. Add a `truncate -s 0 Docker/logs/auth.log` step before writing new lines if idempotency is needed. - -**Docs changes needed:** -- Add a note in [Testing-Requirements.md](Testing-Requirements.md) explaining the ban pipeline timing expectations and why a sleep is needed in this test. -- Document the `manual-Jail` maxretry value and log path in [CONFIGURATION.md](CONFIGURATION.md) so it is clear what the E2E test depends on. - -**Doc references:** -- [CONFIGURATION.md](CONFIGURATION.md) -- [Testing-Requirements.md](Testing-Requirements.md) -- `Docker/simulate_failed_logins.sh` -- `Docker/check_ban_status.sh` -- `Docker/fail2ban-dev-config/` — jail configuration - ---- - -## [E2E-4] Blocklist import executes and is reflected in the UI - -**Where found:** -The blocklist import endpoint (`POST /api/blocklists/import`) is implemented in `backend/app/routers/blocklist.py` and has unit tests in `backend/tests/test_routers/test_blocklist.py` and `backend/tests/test_tasks/test_blocklist_import.py`. The `/blocklists` frontend page exists but there is no E2E test verifying the manual import button works end-to-end. - -**Why this is needed:** -The import flow is asynchronous and involves DNS validation, an external HTTP fetch, and a database write. Unit tests mock all external calls. An E2E test is the only way to verify that the full import pipeline — including the network call to the external source — completes and updates the UI. - -**Goal:** -Clicking the manual import button on the `/blocklists` page triggers an import run, the UI reflects completion (no error banner), and the import log table shows a new entry. - -**What to do:** -1. Create `e2e/tests/03_blocklist_import.robot`. -2. Suite Setup: `Login As Admin`. -3. Pre-condition: at least one blocklist source must be configured. Add a keyword `Ensure Blocklist Source Exists` that: - - Calls `GET /api/blocklists` to check if any sources are defined. - - If none: calls `POST /api/blocklists` to add a known-good source (e.g., a small, stable public list). -4. Test case: - ```robot - *** Test Cases *** - Manual Blocklist Import Completes Without Error - Ensure Blocklist Source Exists - Go To ${FRONTEND_URL}/blocklists - Wait For Elements State css=[aria-label*="Import"],button visible timeout=15s - # record the current log entry count - ${resp_before}= GET ${BACKEND_URL}/api/blocklists/log expected_status=200 - # trigger the import - Click css=[aria-label*="Import"],button - # wait for import to finish (spinner gone or success toast) - Wait For Elements State css=[aria-label*="Import"],button enabled timeout=45s - Get Text body not contains error - # verify the log has a new entry - ${resp_after}= GET ${BACKEND_URL}/api/blocklists/log expected_status=200 - Should Not Be Equal ${resp_before.text} ${resp_after.text} - ``` -5. If the environment has no internet access, mock the external fetch via a local HTTP server (use Python's `http.server` in a `Run Process` background task and point the source URL at `http://localhost:/test.txt`). - -**Possible traps and issues:** -- The import endpoint has a rate limit (`RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS`). Running the suite more than once in the same hour may result in a 429. Either reset the rate limiter between runs or use a unique client IP via a custom `X-Forwarded-For` header. -- The external network may be unavailable in CI. The test must either skip gracefully (`Skip If ${no_internet}`) or use a local mock server. -- The import button selector is unknown until inspected in a running browser. The selector `css=[aria-label*="Import"],button` is a best guess — it must be verified against the actual rendered DOM and updated if wrong. -- If a blocklist source URL returns an invalid or empty file, the import will complete but with 0 bans added. The test must distinguish "import ran successfully" from "bans were added" — these are separate assertions. -- The `POST /api/blocklists/import` can take several seconds per source. The 45 s timeout may be insufficient for large lists. - -**Docs changes needed:** -- Add the rate limit value and reset mechanism to [CONFIGURATION.md](CONFIGURATION.md). -- Document the E2E dependency on network access (or local mock) in [Testing-Requirements.md](Testing-Requirements.md). - -**Doc references:** -- [CONFIGURATION.md](CONFIGURATION.md) -- [Testing-Requirements.md](Testing-Requirements.md) -- [Features.md](Features.md) — blocklist feature description -- `backend/app/routers/blocklist.py` — endpoint and rate limit implementation -- `backend/tests/test_tasks/test_blocklist_import.py` - ---- - ## [E2E-5] Config edit saves and persists after page reload **Where found:** diff --git a/Docs/Testing-Requirements.md b/Docs/Testing-Requirements.md index a291122..9e62e4f 100644 --- a/Docs/Testing-Requirements.md +++ b/Docs/Testing-Requirements.md @@ -79,4 +79,40 @@ Key timing facts: - **history_sync** runs every 300 s (`HISTORY_SYNC_INTERVAL` in `backend/app/tasks/history_sync.py`). The History page reads from the archive DB, so it may lag up to 300 s behind real-time. The E2E test uses `GET /api/bans/active` (direct socket query) for the API assertion to avoid this lag. - **Pagination**: the History page paginates results. Use `?page_size=500` to push the test IP onto the first page, or assert via the API. -If the test fails at Step 2 (no ban detected via API) but `check_ban_status.sh` shows the IP is banned inside the container, the backend-to-fail2ban socket path is broken. If `check_ban_status.sh` also shows no ban, the log volume mapping is wrong (fail2ban is not reading the file `simulate_failed_logins.sh` writes to). \ No newline at end of file +If the test fails at Step 2 (no ban detected via API) but `check_ban_status.sh` shows the IP is banned inside the container, the backend-to-fail2ban socket path is broken. If `check_ban_status.sh` also shows no ban, the log volume mapping is wrong (fail2ban is not reading the file `simulate_failed_logins.sh` writes to). + +### E2E-4 — Blocklist Import + +Test **E2E-4** (`e2e/tests/03_blocklist_import.robot`: *Manual Blocklist Import Completes Without Error*) exercises the full import pipeline: + +``` +UI button click → POST /api/v1/blocklists/import → async background task + → DNS validation → HTTP fetch (external or local mock) + → IP parsing → fail2ban ban_ip call → DB write → import log entry +``` + +Key facts: + +- **Rate limit**: `BANGUI_RATE_LIMIT_BLOCKLIST_IMPORT_PER_HOUR = 10` per client IP. E2E tests bypass this by sending a unique `X-Forwarded-For` header (e.g., `10.0.0.99`). The header is only honoured when the client IP is in `BANGUI_TRUSTED_PROXIES`. +- **Network dependency**: The import fetches the blocklist URL over HTTP. In CI environments without internet access the test starts a local Python `http.server` (port 8765) serving `e2e/test_blocklist.txt`. The `Ensure Blocklist Source Exists` keyword points the source URL at `http://localhost:8765/test.txt` when no internet is detected. +- **"Import ran" vs "bans added"**: These are separate outcomes. The test asserts that the log entry count increases — confirming the import ran to completion — regardless of whether any IPs were actually banned. +- **Timeout**: Large lists may exceed the 45 s button-wait timeout. Increase as needed. +- **Selector**: The import button is selected via `css=[data-testid="blocklist-import-button"],button`. The `data-testid` attribute must be added to the frontend component (see [E2E-6] in [Tasks.md](Tasks.md)). If the attribute is absent, the fallback `button` selector is used. + +**Teardown**: `Cleanup Mock Server` stops the local HTTP server started in the test. + +### E2E-5 — Config Field Edit Persistence + +Test **E2E-5** (`e2e/tests/04_config_edit.robot`: *Config Field Edit Persists After Reload*) exercises the auto-save round-trip: + +``` +UI edit → useAutoSave debounce (500 ms) → PATCH /api/config/jails/:name + → fail2ban config write → GET /api/jails rehydration on reload +``` + +Key facts: + +- **Debounce**: `useAutoSave` fires no HTTP request until 500 ms of inactivity after the last keystroke. The test waits for the "Saved" indicator (`[role="status"]:has-text("Saved")`) rather than a fixed `Sleep`, ensuring the PATCH actually fired before the reload. +- **Selector**: `input[aria-label="Ban Time"]` is used to locate the bantime field — no `data-*` attribute required. The `aria-label` is stable across refactors. +- **Teardown**: `Restore Original Ban Time` is set as `[Teardown]` so it runs even when the test fails mid-way. Config edits restart fail2ban internally; restoring state prevents subsequent tests from reading modified values. +- **Run order**: E2E-5 should run last in the suite to avoid destabilising fail2ban health for other tests. \ No newline at end of file diff --git a/Docs/Web-Development.md b/Docs/Web-Development.md index 5c0521a..3a42f5d 100644 --- a/Docs/Web-Development.md +++ b/Docs/Web-Development.md @@ -1816,6 +1816,34 @@ The E2E test suite (`e2e/tests/01_page_loading.robot`) verifies every protected **Vite SPA note:** All routes return HTTP 200 from the dev server (SPA routing). HTTP status checks are not meaningful — focus on DOM state after client-side navigation. +### Auto-Save Indicator for E2E Tests + +The config page (`/config`) uses `useAutoSave` to debounce edits before sending PATCH requests. E2E tests that edit config fields must wait for the "Saved" indicator rather than using fixed `Sleep`, otherwise the reload may fire before the HTTP request is sent. + +**How to detect a successful save:** + +The `AutoSaveIndicator` component renders a `role="status"` region containing a `Badge` with text "Saved" on success. Wait for this element to appear: + +```robot +# Wait for auto-save to complete (debounce fires after ~500 ms of inactivity) +Wait For Elements State css=[role="status"]:has-text("Saved") visible timeout=15s +``` + +On error, the region shows "Save failed." with a Retry button: + +```robot +Get Text css=[role="status"] contains Save failed +``` + +**Flow for config edit tests:** +1. Navigate to `/config` and activate the relevant tab (Jails / Filters / Actions / Server). +2. Select the target jail/item from the list pane. +3. Read the current value and store it for teardown. +4. Edit the field — each keystroke resets the debounce timer. +5. Stop interacting — wait for debounce to fire, then wait for "Saved" indicator. +6. Verify persistence via reload or API assertion. +7. Teardown: restore the original value using the same flow. + --- ## 15. Error Observability & Telemetry diff --git a/e2e/test_blocklist.txt b/e2e/test_blocklist.txt new file mode 100644 index 0000000..3de0e4e --- /dev/null +++ b/e2e/test_blocklist.txt @@ -0,0 +1,4 @@ +192.168.200.1 +192.168.200.2 +192.168.200.3 +10.0.99.99 \ No newline at end of file diff --git a/e2e/tests/03_blocklist_import.robot b/e2e/tests/03_blocklist_import.robot index 0edc78a..7a36cde 100644 --- a/e2e/tests/03_blocklist_import.robot +++ b/e2e/tests/03_blocklist_import.robot @@ -2,14 +2,86 @@ Resource ${CURDIR}/../../resources/common.resource Resource ${CURDIR}/../../resources/auth.resource +# Use unique X-Forwarded-For to bypass per-IP rate limit across test runs. +# Rate limit: 10 imports / IP / hour. Overridden at runtime via header. +Suite Setup Login As Admin + *** Test Cases *** -Blocklist Import Page Opens - New Browser chromium headless=${TRUE} - Login As Admin +Manual Blocklist Import Completes Without Error + [Documentation] Verifies the full import pipeline: + ... UI button click → async backend task → HTTP fetch → DB write → UI log entry. + ... + ... - Uses local mock server when external network is unavailable. + ... - Rate limit bypassed via X-Forwarded-For header. + ... - Import "completes successfully" is distinct from "bans were added". + [Teardown] Cleanup Mock Server + # Pre-condition: ensure at least one source is configured. + Ensure Blocklist Source Exists + + # Determine if external network is reachable. + ${no_internet}= Evaluate __import__("socket").gethostbyname("one.one.one.one") is None modules=socket + IF ${no_internet} + Start Local Mock Server + END + + # Navigate to blocklists page and locate the import button. Go To ${FRONTEND_URL}/blocklists + Wait For Elements State css=[data-testid="blocklist-import-button"],button visible timeout=15s - ${content}= Get Page Source - Should Not Be Empty ${content} + # Record current log entry count before triggering import. + ${headers}= Create Dictionary X-Forwarded-For 10.0.0.99 + ${resp_before}= GET ${BACKEND_URL}/api/v1/blocklists/log headers=${headers} expected_status=200 + ${log_count_before}= Get Length ${resp_before.json()}[entries] - Close Browser \ No newline at end of file + # Trigger the import via the manual import button. + Click css=[data-testid="blocklist-import-button"],button + + # Wait for import to finish: button re-enabled or success toast appears. + Wait For Elements State css=[data-testid="blocklist-import-button"],button enabled timeout=45s + + # Assert no error banner in the UI. + Get Text css=body not contains error + + # Verify the log has a new entry (import ran, regardless of bans added). + ${resp_after}= GET ${BACKEND_URL}/api/v1/blocklists/log headers=${headers} expected_status=200 + ${log_count_after}= Get Length ${resp_after.json()}[entries] + Should Be True ${log_count_after} > ${log_count_before} + Log Import completed. Log entries: ${log_count_before} → ${log_count_after} + + +*** Keywords *** +Ensure Blocklist Source Exists + [Documentation] Guarantee at least one blocklist source exists. + ... If GET /api/v1/blocklists returns an empty list, a minimal local-file + ... source is added so the import test has a target. + ${headers}= Create Dictionary X-Forwarded-For 10.0.0.99 + ${resp}= GET ${BACKEND_URL}/api/v1/blocklists headers=${headers} expected_status=200 + ${sources}= Set Variable ${resp.json()}[sources] + + IF ${sources} == ${NONE} or ${len(sources)} == 0 + # No sources configured — add a minimal entry pointing to the mock server. + # The mock server serves test.txt from the e2e directory. + ${payload}= Create Dictionary + ... name=Local Mock Source + ... url=http://localhost:8765/test.txt + ... enabled=true + POST ${BACKEND_URL}/api/v1/blocklists json=${payload} headers=${headers} expected_status=201 + Log Created local mock blocklist source. + ELSE + Log Blocklist source already exists — using first available. + END + +Start Local Mock Server + [Documentation] Start a minimal Python HTTP server on port 8765 to serve a test blocklist file. + ... The test.txt file contains one IP per line in plain-text format (fail2ban plain list). + ${mock_file}= Set Variable ${CURDIR}/../../test_blocklist.txt + ${file_exists}= OperatingSystem.File Should Exist ${mock_file} + Start Process python -m http.server 8765 --directory ${CURDIR}/../../ alias=mockserver + ... stdout=PIPE stderr=STDOUT + Sleep 2s + Log Local mock HTTP server started on port 8765. + +Cleanup Mock Server + [Documentation] Stop the mock HTTP server started by Start Local Mock Server. + Terminate Process mockserver \ No newline at end of file diff --git a/e2e/tests/04_config_edit.robot b/e2e/tests/04_config_edit.robot index 615b4dc..a7d396d 100644 --- a/e2e/tests/04_config_edit.robot +++ b/e2e/tests/04_config_edit.robot @@ -2,14 +2,75 @@ Resource ${CURDIR}/../../resources/common.resource Resource ${CURDIR}/../../resources/auth.resource +Suite Setup Login As Admin + *** Test Cases *** -Config Edit Page Opens - New Browser chromium headless=${TRUE} - Login As Admin +Config Field Edit Persists After Reload + [Documentation] Verifies auto-save round-trip: UI edit → debounced PATCH → reload rehydration. + ... + ... - Waits for "Saved" indicator rather than fixed Sleep (debounce may delay PATCH). + ... - Restores original value in teardown so subsequent tests are not affected. + ... - Runs last in suite ordering to avoid destabilising fail2ban health for other tests. + [Teardown] Restore Original Ban Time + # Step 1 — navigate to config page and activate the Jails tab. Go To ${FRONTEND_URL}/config + Wait For Elements State css=[role="tablist"] visible timeout=15s + Click role=tab name=Jails - ${content}= Get Page Source - Should Not Be Empty ${content} + # Step 2 — wait for jail list to render, then select the first jail. + Wait For Elements State css=[role="listbox"] visible timeout=15s + ${jail_items}= Get Elements css=[role="option"] + ${count}= Get Length ${jail_items} + IF ${count} == 0 + Fatal Error No jails found in config list — cannot run test. + END + Click css=[role="option"]:first-child - Close Browser \ No newline at end of file + # Step 3 — read current ban_time value for teardown. + Wait For Elements State css=input[aria-label="Ban Time"] visible timeout=10s + ${original}= Get Value css=input[aria-label="Ban Time"] + Set Suite Variable ${ORIGINAL_BANTIME} ${original} + Log Original bantime: ${original} + + # Step 4 — edit ban_time to a safe, valid integer (7200 s = 2 h). + Fill Text css=input[aria-label="Ban Time"] 7200 + + # Step 5 — wait for auto-save debounce to fire PATCH and backend to respond. + # The indicator shows "Saved" (role=status, Badge with text "Saved"). + Wait For Elements State css=[role="status"]:has-text("Saved") visible timeout=15s + Log Auto-save confirmed — PATCH completed. + + # Step 6 — verify via API that the value was actually written to the jail config file. + ${resp}= GET ${BACKEND_URL}/api/jails + Should Contain ${resp.text} 7200 + + # Step 7 — reload the page and confirm the new value is rehydrated from the backend. + Reload + Wait For Elements State css=[role="tablist"] visible timeout=15s + Click role=tab name=Jails + Wait For Elements State css=[role="listbox"] visible timeout=15s + Click css=[role="option"]:first-child + Wait For Elements State css=input[aria-label="Ban Time"] visible timeout=10s + ${reloaded}= Get Value css=input[aria-label="Ban Time"] + Should Be Equal As Strings ${reloaded} 7200 + Log Reload verification passed — value persisted. + + +*** Keywords *** +Restore Original Ban Time + [Documentation] Restore the jail's original ban_time so subsequent tests are unaffected. + ... Runs as Test Teardown so it executes even if the test fails mid-way. + Go To ${FRONTEND_URL}/config + Wait For Elements State css=[role="tablist"] visible timeout=15s + Click role=tab name=Jails + Wait For Elements State css=[role="listbox"] visible timeout=15s + ${jail_items}= Get Elements css=[role="option"] + ${count}= Get Length ${jail_items} + IF ${count} > 0 + Click css=[role="option"]:first-child + END + Wait For Elements State css=input[aria-label="Ban Time"] visible timeout=10s + Fill Text css=input[aria-label="Ban Time"] ${ORIGINAL_BANTIME} + Wait For Elements State css=[role="status"]:has-text("Saved") visible timeout=15s + Log Restored original ban_time: ${ORIGINAL_BANTIME} \ No newline at end of file