test: harden portal hardware contract

This commit is contained in:
2026-09-07 08:12:13 +10:00
parent daf9f207b2
commit 9b070f45f4
6 changed files with 101 additions and 19 deletions
+9
View File
@@ -74,6 +74,11 @@ scan, a missing-route response, and desktop/mobile portal rendering with no
browser page errors. Screenshots, traces on failure, JSON results, and the HTML
report are saved under the printed XDG state-directory artifact path.
On ESP8266, an AP+STA scan can briefly move the radio off the AP channel. The
client may reconnect during that interval; the contract deliberately retries
that transport interruption and still requires a reachable portal with a
complete, valid scan result.
For interactive diagnosis, leave the temporary client connection up and remove
only that managed connection when finished:
@@ -88,6 +93,10 @@ the fixture to a real LAN. Copy the ignored template below, add local
credentials, and pass it explicitly; it is mounted read-only into the test
container and is never logged by the runner.
A retained session is deliberately never overwritten. If a previous `up` or an
interrupted `run` left one behind, run `./tools/portal-hardware down` first;
that removes only the named temporary connection recorded by the tool.
```bash
cp test/portal-station.env.example test/portal-station.env
./tools/portal-hardware run ... --station-env test/portal-station.env
+3 -4
View File
@@ -5,13 +5,12 @@ This test fixture starts only the WiFiManager captive portal. It intentionally h
Use it through the repository runner so a secondary Wi-Fi adapter is explicitly selected and protected from becoming the host default route:
```bash
./tools/test-portal-browser.sh \
./tools/portal-hardware run \
--platform esp8266 \
--port /dev/serial/by-id/... \
--wifi-interface wlx... \
--output /tmp/wifimanager-browser
--client-interface wlx...
```
The ESP8266 portal SSID is `WM Browser ESP8266`; the ESP32 SSID is `WM Browser ESP32`. Both use `default1` exclusively for local development tests.
The ESP8266 portal SSID is `WM Contract ESP8266`; the ESP32 SSID is `WM Contract ESP32`. Both use `default1` exclusively for local development tests.
The runner cleans up only the temporary connection it creates on the named secondary interface. It refuses to run if that interface is the system default route.
@@ -7,10 +7,18 @@ async function json(response) {
async function waitForScan(request) {
let result;
await expect.poll(async () => {
const response = await request.get('/api/wifi/scan-status');
expect(response.ok()).toBeTruthy();
result = await json(response);
return result.scanning;
try {
const response = await request.get('/api/wifi/scan-status');
if (!response.ok()) return true;
result = await json(response);
return result.scanning;
} catch {
// ESP8266 AP+STA scans briefly leave the AP channel. A client can lose
// its association while the radio scans, then reconnect before the
// asynchronous scan completes. Keep polling; the assertions below still
// require a reachable portal with a complete, valid result.
return true;
}
}, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(false);
return result;
}
+36 -7
View File
@@ -122,27 +122,56 @@ wm_state_file() {
printf '%s/session.env\n' "$(wm_portal_state_root)"
}
wm_require_no_active_session() {
local file
file="$(wm_state_file)"
[[ ! -e "$file" ]] || {
echo "An existing WiFiManager portal session is recorded; run ./tools/portal-hardware down first." >&2
return 1
}
}
wm_write_state() {
local interface="$1" platform="$2" uuid="$3" name="$4" root file
root="$(wm_portal_state_root)"
file="$(wm_state_file)"
install -d -m 700 "$root"
umask 077
printf 'WM_PORTAL_INTERFACE=%q\nWM_PORTAL_PLATFORM=%q\nWM_PORTAL_CONNECTION_UUID=%q\nWM_PORTAL_CONNECTION_NAME=%q\n' \
"$interface" "$platform" "$uuid" "$name" >"$file"
(
umask 077
printf 'WM_PORTAL_INTERFACE=%s\nWM_PORTAL_PLATFORM=%s\nWM_PORTAL_CONNECTION_UUID=%s\nWM_PORTAL_CONNECTION_NAME=%s\n' \
"$interface" "$platform" "$uuid" "$name" >"$file"
)
chmod 600 "$file"
}
wm_load_state() {
local file
local file key value
file="$(wm_state_file)"
[[ -f "$file" ]] || {
echo "No active WiFiManager portal session was found." >&2
return 1
}
# The state file is created above using shell-escaped values and mode 0600.
# shellcheck disable=SC1090
source "$file"
WM_PORTAL_INTERFACE=""
WM_PORTAL_PLATFORM=""
WM_PORTAL_CONNECTION_UUID=""
WM_PORTAL_CONNECTION_NAME=""
while IFS='=' read -r key value; do
case "$key" in
WM_PORTAL_INTERFACE|WM_PORTAL_PLATFORM|WM_PORTAL_CONNECTION_UUID|WM_PORTAL_CONNECTION_NAME)
printf -v "$key" '%s' "$value"
;;
'') ;;
*)
echo "Invalid WiFiManager portal session state." >&2
return 1
;;
esac
done <"$file"
[[ -n "$WM_PORTAL_INTERFACE" && -n "$WM_PORTAL_PLATFORM" && -n "$WM_PORTAL_CONNECTION_UUID" && -n "$WM_PORTAL_CONNECTION_NAME" ]] || {
echo "Incomplete WiFiManager portal session state." >&2
return 1
}
export WM_PORTAL_INTERFACE WM_PORTAL_PLATFORM WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
}
wm_clear_state() {
+6 -2
View File
@@ -13,7 +13,7 @@ Usage:
--client-interface IFACE [--take-over-client-adapter] [--output DIRECTORY]
./tools/portal-hardware run --platform esp8266|esp32 --port /dev/serial/by-id/... \
--client-interface IFACE [--take-over-client-adapter] [--keep] \
[--browser auto|require|skip] [--station-env PATH] [--output DIRECTORY]
[--browser auto|skip] [--station-env PATH] [--output DIRECTORY]
./tools/portal-hardware down
Only the named client interface may be disconnected or reconfigured. The tool
@@ -72,7 +72,7 @@ validate_run_arguments() {
echo "Serial port not found: $port" >&2
exit 1
}
[[ "$browser" == "auto" || "$browser" == "require" || "$browser" == "skip" ]] || usage
[[ "$browser" == "auto" || "$browser" == "skip" ]] || usage
[[ -z "$station_env" || -r "$station_env" ]] || {
echo "Station environment file is not readable: $station_env" >&2
exit 1
@@ -84,6 +84,7 @@ start_portal_session() {
validate_run_arguments
require_common
wm_acquire_hardware_lock
wm_require_no_active_session
wm_require_client_adapter "$client_interface" "$takeover"
prepare_output_dir
ssid="$(wm_portal_ssid "$platform")"
@@ -143,6 +144,9 @@ case "$command_name" in
export PORTAL_STATION_ENV_HOST="$(cd "$(dirname "$station_env")" && pwd)/$(basename "$station_env")"
compose_files+=(-f "$root/tests/portal-contract/compose.station.yaml")
fi
# The contract source is copied into the image; rebuild with Docker cache so
# this invocation always tests the checked-out files, not a stale image.
docker compose "${compose_files[@]}" build portal-contract
docker compose "${compose_files[@]}" run --rm portal-contract
printf 'Portal contract passed. Artifacts: %s\n' "$output_dir"
if [[ "$keep" == "yes" ]]; then
+35 -2
View File
@@ -10,6 +10,7 @@ stub_bin="$tmp/bin"
mkdir -p "$stub_bin"
export CALL_LOG="$tmp/calls.log"
export WM_HARDWARE_LOCK_FILE="$tmp/hardware.lock"
export XDG_STATE_HOME="$tmp/state"
printf '%s\n' '#!/usr/bin/env bash' \
'if [[ "$1" == "link" && "$2" == "show" ]]; then exit 0; fi' \
@@ -18,16 +19,22 @@ printf '%s\n' '#!/usr/bin/env bash' \
printf '%s\n' '#!/usr/bin/env bash' \
'printf "%s\\n" "$*" >>"$CALL_LOG"' \
'if [[ "${NMCLI_FAIL_UP:-}" == "yes" && "$1" == "connection" && "$2" == "up" ]]; then exit 7; fi' \
'if [[ "$1" == "-t" && "$2" == "-f" && "$3" == "SSID" ]]; then echo "WM Contract ESP8266"; exit 0; fi' \
'if [[ "$1" == "-g" && "$2" == "connection.uuid" ]]; then echo "stub-uuid"; exit 0; fi' \
'if [[ "$1" == "-g" ]]; then echo "--"; fi' >"$stub_bin/nmcli"
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' >"$stub_bin/pio"
printf '%s\n' '#!/usr/bin/env bash' \
'printf "docker %s\n" "$*" >>"$CALL_LOG"' \
'if [[ "$1" == "compose" && "$2" == "version" ]]; then echo "Docker Compose"; exit 0; fi' \
'exit 0' >"$stub_bin/docker"
chmod 755 "$stub_bin"/*
export PATH="$stub_bin:$PATH"
"$root/tools/portal-hardware" doctor --client-interface wlan-client >/dev/null
! grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG"
if grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG"; then
echo 'doctor unexpectedly changed a NetworkManager connection' >&2
exit 1
fi
if "$root/tools/portal-hardware" doctor --client-interface wlan-main >/dev/null 2>&1; then
echo 'default-route adapter guard did not reject the request' >&2
@@ -48,6 +55,32 @@ if wm_create_portal_connection wlan-client 'fixture portal' placeholder; then
fi
unset NMCLI_FAIL_UP
grep -Eq 'connection delete wifimanager-portal-' "$CALL_LOG"
! grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG"
# A retained session must be removed explicitly, never silently overwritten.
wm_write_state wlan-client esp8266 stale-uuid stale-name
wm_load_state
[[ "$WM_PORTAL_INTERFACE" == "wlan-client" && "$WM_PORTAL_PLATFORM" == "esp8266" ]]
[[ "$WM_PORTAL_CONNECTION_UUID" == "stale-uuid" && "$WM_PORTAL_CONNECTION_NAME" == "stale-name" ]]
mutations_before="$(grep -Ec '^(device disconnect|connection (add|modify|delete|down))' "$CALL_LOG" || true)"
if "$root/tools/portal-hardware" up --platform esp8266 --port /dev/null \
--client-interface wlan-client --take-over-client-adapter >/dev/null 2>&1; then
echo 'stale portal session was silently overwritten' >&2
exit 1
fi
mutations_after="$(grep -Ec '^(device disconnect|connection (add|modify|delete|down))' "$CALL_LOG" || true)"
[[ "$mutations_before" == "$mutations_after" ]] || {
echo 'stale portal session mutated the selected adapter' >&2
exit 1
}
wm_clear_state
# The runner must build the copied contract source before it starts the container.
"$root/tools/portal-hardware" run --platform esp8266 --port /dev/null --client-interface wlan-client --browser skip >/dev/null
build_line="$(grep -n " build portal-contract$" "$CALL_LOG" | tail -1 | cut -d: -f1)"
run_line="$(grep -n " run --rm portal-contract$" "$CALL_LOG" | tail -1 | cut -d: -f1)"
[[ -n "$build_line" && -n "$run_line" && "$build_line" -lt "$run_line" ]] || {
echo "portal contract was not rebuilt before execution" >&2
exit 1
}
echo 'portal-hardware CLI safety checks passed'