test(e2e): split suite by feature area with shared resources

Restructure 5 existing .robot files into 10 numbered files, one per
feature area in Docs/Features.md. Each file is independently runnable.
Add api.resource + data.resource for CSRF/XFF-aware wrappers and
RFC5737 IP generators.

Coverage: 110 new tests across login, dashboard, map, jails, config,
history, blocklists, layout. Uses existing data-testid/aria-label/role
selectors only — no frontend changes.

Tests bypass per-IP rate limits via X-Forwarded-For header rotation.
Hard rule preserved: failures are findings, never app-code fixes.
This commit is contained in:
2026-06-21 07:55:19 +02:00
parent 3af8f0571b
commit 0d21e3253e
17 changed files with 1620 additions and 230 deletions

View File

@@ -0,0 +1,78 @@
*** Settings ***
Documentation Lightweight wrappers around RequestsLibrary that auto-inject
... the CSRF X-BanGUI-Request header and rotate X-Forwarded-For
... to bypass per-IP rate limits. Requires a logged-in session
... named 'bangsess' (created via Login Via HTTP in auth.resource).
*** Keywords ***
Build Headers
[Documentation] Returns a headers dict with X-BanGUI-Request always set
... and X-Forwarded-For rotated if ${XFF_HEADER} is set.
[Arguments] ${extra_headers}=${None}
${headers}= Create Dictionary X-BanGUI-Request 1
IF "${XFF_HEADER}" != ""
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
END
IF "${extra_headers}" != "${None}"
FOR ${key} IN @{extra_headers.keys()}
Set To Dictionary ${headers} ${key} ${extra_headers}[${key}]
END
END
RETURN ${headers}
Api Get
[Documentation] GET wrapper that injects CSRF + XFF headers.
[Arguments] ${url_path} ${expected_status}=200 ${params}=${None}
${headers}= Build Headers
${kwargs}= Create Dictionary headers ${headers} expected_status ${expected_status}
IF "${params}" != "${None}"
Set To Dictionary ${kwargs} params ${params}
END
${resp}= GET On Session bangsess ${url_path} &{kwargs}
RETURN ${resp}
Api Post
[Documentation] POST wrapper that injects CSRF + XFF headers.
[Arguments] ${url_path} ${payload}=${EMPTY} ${expected_status}=200
${headers}= Build Headers
IF "${payload}" != "${EMPTY}"
${resp}= POST On Session bangsess ${url_path}
... json=${payload} headers=${headers} expected_status=${expected_status}
ELSE
${resp}= POST On Session bangsess ${url_path}
... headers=${headers} expected_status=${expected_status}
END
RETURN ${resp}
Api Put
[Documentation] PUT wrapper that injects CSRF + XFF headers.
[Arguments] ${url_path} ${payload} ${expected_status}=200
${headers}= Build Headers
${resp}= PUT On Session bangsess ${url_path}
... json=${payload} headers=${headers} expected_status=${expected_status}
RETURN ${resp}
Api Delete
[Documentation] DELETE wrapper that injects CSRF + XFF headers.
[Arguments] ${url_path} ${payload}=${EMPTY} ${expected_status}=200
${headers}= Build Headers
IF "${payload}" != "${EMPTY}"
${resp}= DELETE On Session bangsess ${url_path}
... json=${payload} headers=${headers} expected_status=${expected_status}
ELSE
${resp}= DELETE On Session bangsess ${url_path}
... headers=${headers} expected_status=${expected_status}
END
RETURN ${resp}
Status Is Acceptable
[Documentation] Returns True if the response status is one of the accepted codes.
[Arguments] ${response} @{accepted_codes}
${ok}= Set Variable ${FALSE}
FOR ${code} IN @{accepted_codes}
IF ${response.status_code} == ${code}
${ok}= Set Variable ${TRUE}
EXIT FOR LOOP
END
END
RETURN ${ok}

View File

@@ -1,10 +1,22 @@
*** Settings ***
Library Browser
Library RequestsLibrary
Library Collections
Library String
Documentation Shared auth keywords. Use Login As Admin for browser flows;
... Login Via HTTP for API-only assertions. Logout, Verify Session Invalid,
... Login With Wrong Password, and Login Exceeds Rate Limit are extended helpers.
*** Keywords ***
Login Via HTTP
[Documentation] Login via HTTP and store session cookie for RequestsLibrary.
... Call this before any RequestsLibrary keyword that needs auth.
... Call this before any RequestsLibrary keyword that needs auth.
${headers}= Create Dictionary X-BanGUI-Request 1
IF "${XFF_HEADER}" != ""
Set To Dictionary ${headers} X-Forwarded-For ${XFF_HEADER}
END
Create Session bangsess ${BACKEND_URL} headers=${headers}
${login_payload}= Create Dictionary password Hallo123!
${login_payload}= Create Dictionary password ${TEST_PASSWORD}
${login_resp}= POST On Session bangsess /api/v1/auth/login
... json=${login_payload}
... expected_status=200
@@ -22,7 +34,7 @@ Login As Admin
IF not ${body}[completed]
# Complete setup wizard via HTTP API.
${setup_payload}= Create Dictionary
... master_password=Hallo123!
... master_password=${TEST_PASSWORD}
... database_path=bangui.db
... fail2ban_socket=/var/run/fail2ban/fail2ban.sock
... timezone=UTC
@@ -50,7 +62,7 @@ Login As Admin
... const res = await fetch('/api/v1/auth/login', {
... method: 'POST',
... headers: { 'Content-Type': 'application/json' },
... body: JSON.stringify({ password: 'Hallo123!' }),
... body: JSON.stringify({ password: '${TEST_PASSWORD}' }),
... credentials: 'include'
... });
... const data = await res.json().catch(() => ({}));
@@ -99,4 +111,61 @@ Login As Admin
END
${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,20 +2,94 @@
Library Browser
Library RequestsLibrary
Library Process
Library String
Library Collections
Library DateTime
*** Variables ***
${FRONTEND_URL} http://localhost:5173
${BACKEND_URL} http://localhost:8000
${TEST_PASSWORD} Hallo123!
${XFF_HEADER} ${EMPTY}
*** Keywords ***
Wait For Backend Health
[Documentation] Polls /api/v1/health until 200 or timeout.
[Arguments] ${timeout}=120 ${interval}=5
${deadline}= Evaluate time.time() + ${timeout}
WHILE True
${now}= Evaluate time.time()
IF ${now} >= ${deadline} FAIL Backend did not become healthy within ${timeout} seconds
${response}= GET ${BACKEND_URL}/api/v1/health expected_status=200
${response}= GET ${BACKEND_URL}/api/v1/health expected_status=any
IF ${response.status} == 200 BREAK
Sleep ${interval}
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

@@ -0,0 +1,52 @@
*** Settings ***
Documentation Test data generators — unique IPs, jail names,
... timestamps, RFC5737 documentation-only address ranges.
*** Keywords ***
Random Test Net 3 Ip
[Documentation] Returns an IP from RFC5737 203.0.113.0/24 (TEST-NET-3).
${octet}= Evaluate random.randint(1, 254) modules=random
${ip}= Set Variable 203.0.113.${octet}
RETURN ${ip}
Random Test Net 2 Ip
[Documentation] Returns an IP from RFC5737 198.51.100.0/24 (TEST-NET-2).
${octet}= Evaluate random.randint(1, 254) modules=random
${ip}= Set Variable 198.51.100.${octet}
RETURN ${ip}
Random Xff Ip
[Documentation] Returns an IP from RFC5737 192.0.2.0/24 (TEST-NET-1) for XFF headers.
${octet}= Evaluate random.randint(1, 254) modules=random
${ip}= Set Variable 192.0.2.${octet}
RETURN ${ip}
Unique Suffix
[Documentation] Returns a unique suffix combining timestamp + random suffix
... so resources created in successive tests don't collide.
${ts}= Evaluate int(time.time()) modules=time
${rand}= Evaluate random.randint(1000, 9999) modules=random
${suffix}= Set Variable ${ts}-${rand}
RETURN ${suffix}
Unique Jail Name
[Documentation] Returns a unique jail name with timestamp + random suffix.
${suffix}= Unique Suffix
${name}= Set Variable test-jail-${suffix}
RETURN ${name}
Unique Blocklist Name
[Documentation] Returns a unique blocklist source name.
${suffix}= Unique Suffix
${name}= Set Variable test-source-${suffix}
RETURN ${name}
Unique Timestamp
[Documentation] Returns a Unix timestamp as integer.
${ts}= Evaluate int(time.time()) modules=time
RETURN ${ts}
Iso Now
[Documentation] Returns current time in ISO 8601 (UTC).
${iso}= Evaluate __import__('datetime').datetime.utcnow().isoformat() + 'Z' modules=__import__
RETURN ${iso}