backup
This commit is contained in:
@@ -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
|
||||
|
||||
119
Docs/Tasks.md
119
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:<port>/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:**
|
||||
|
||||
@@ -80,3 +80,39 @@ Key timing facts:
|
||||
- **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).
|
||||
|
||||
### 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.
|
||||
@@ -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
|
||||
|
||||
4
e2e/test_blocklist.txt
Normal file
4
e2e/test_blocklist.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
192.168.200.1
|
||||
192.168.200.2
|
||||
192.168.200.3
|
||||
10.0.99.99
|
||||
@@ -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
|
||||
# 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
|
||||
@@ -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
|
||||
# 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}
|
||||
Reference in New Issue
Block a user