Files
ESP32_Serial_Swiss_Army_Knife/tests/web_network_settings

Network backend contract and host tests — 8D.12/8D.13

Integration boundary

Compile src/web_network_settings.c. Register these three independently optional, exact method/path handlers using the existing settings registration pattern:

Method Path Handler
GET /api/settings/network web_network_snapshot_handler
GET /api/settings/network-operation web_network_operation_handler
POST /api/settings/network-operation web_network_operation_handler

This backend change does not edit web_server, web_ui, CMake, or docs/. The existing administration dispatcher now accepts a Network operation ID, not commands or credentials. Its item size, queue depth, task count and stack sizes are unchanged. Browser-shell Wi-Fi/mDNS restrictions are unchanged.

All three routes use existing cookie-auth policy: current admin, origin-bound session, duplicate/framing/Fetch-Metadata checks. POST requires matching Origin, CSRF and application/json or application/json; charset=utf-8. GET follows existing settings GET Origin policy (an absent Origin is permitted; a supplied mismatch is denied). GET is bodyless; query strings are rejected on every route. Responses use JSON, no-store, nosniff and no-referrer. No credential export route.

Snapshot

A complete example (values are illustrative, never defaults to install):

{
  "wifi": {
    "generation": 7,
    "enabled_at_boot": true,
    "ap": {"policy": "fallback", "channel": 6, "ssid": "ESP32-SAK-example", "password_configured": true},
    "profiles": [
      {"index": 0, "enabled": true, "priority": 10, "security": "mixed", "ssid": "office", "password_configured": true},
      {"index": 1, "enabled": false, "priority": 20, "security": "wpa3", "ssid": "backup", "password_configured": false},
      {"index": 2, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false},
      {"index": 3, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false}
    ]
  },
  "runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ap_running": false, "ap_clients": 0, "last_error": 0},
  "mdns": {"generation": 3, "suffix": "example", "hostname": "sak-example", "announced": true, "last_error": 0}
}

Four stable indices 0..3 are always present. active_profile:-1 means none. Runtime states are canonical stopped, starting, connecting, waiting-ip, online, backoff, ap-only, error (fallback unknown). hostname excludes .local. The existing responder is STA-only. announced is the service's expected-announcement status, not a client-observed DNS verification.

Wi-Fi working configuration and runtime are copied together under its mutex; mDNS is a separate consistent projection, not an atomic cross-domain snapshot. Both acquisitions use zero wait. Either unavailable/contended yields HTTP 503 {"error":"snapshot_unavailable"}, not guessed partial values. No driver, NVS or secret-bearing config getter runs on HTTPD.

No PSKs or PSK lengths occur in projection structs/JSON. password_configured is only a boolean, justified by disabled-profile staging/enabling validation.

SSID byte strings

SSID length is 0..32 decoded bytes, not UTF-8 characters or JSON bytes. A nonempty AP SSID and nonempty enabled-profile SSID are required. To clear a profile's SSID, its password must also be absent and the profile disabled.

The reversible wire codec accepts raw printable ASCII, JSON \", \\, \/, \b, \f, \n, \r, \t, and \u00HH (hex case-insensitive). Every decoded codepoint maps to exactly one byte. It rejects raw non-ASCII, non-byte Unicode, surrogates and malformed escapes. Snapshot encoding emits other bytes, quote and backslash as \u00hh; embedded zero and arbitrary non-UTF-8 round-trip. For example "A\u0000\u00ff" means bytes 41 00 ff.

UI must not pass ordinary JS UTF-16 strings straight through JSON.stringify for SSIDs. Encode user text as UTF-8 bytes first and encode each non-ASCII byte as \u00HH. Preserve an explicit reversible byte editing/display mode for existing arbitrary SSIDs; never silently replacement-decode and resubmit them.

POST operations

A single flat JSON object, unknown/duplicate fields rejected. No nested config, arrays, nulls, signed/fractional/exponent integers or leading-zero numbers. Booleans are JSON true/false. Generation is a nonzero uint32 from the selected domain snapshot. All optional patch fields preserve current values when omitted; at least one patch field is required. Each POST changes only one domain/target.

action Required fields besides action Optional fields
wifi-patch generation (Wi-Fi) enabled_at_boot, ap_policy (off/fallback/always), channel (1..11), ssid, password, clear_password:true
profile-patch generation (Wi-Fi), profile (0..3) enabled, priority (0..255), security (mixed/wpa3), ssid, password, clear_password:true
wifi-save generation (Wi-Fi) none
wifi-load generation (Wi-Fi) none
start none none
stop none none
reconnect none none
next-profile none none
mdns-set generation (mDNS), suffix none
mdns-save generation (mDNS) none
mdns-load generation (mDNS) none
mdns-defaults generation (mDNS) none

Examples:

{"action":"profile-patch","generation":7,"profile":0,"enabled":true,"priority":10,"security":"mixed","ssid":"office","password":"new-example-password"}
{"action":"profile-patch","generation":8,"profile":0,"enabled":false,"clear_password":true}
{"action":"wifi-patch","generation":9,"ap_policy":"always","channel":6}
{"action":"wifi-save","generation":10}
{"action":"wifi-load","generation":10}
{"action":"next-profile"}
{"action":"mdns-set","generation":3,"suffix":"lab-serial"}

Password replacement is 8..63 printable ASCII bytes; password:"" is rejected. Replacement and clear cannot coexist; clear_password:false is rejected. Clearing a disabled STA password is supported; a single patch can disable and clear. Enabled STA must retain a valid password. AP clear is rejected even with policy off: the canonical config never permits invalid/open AP credentials. mixed means WPA2-or-stronger, not an open network or WPA2-only guarantee.

Patching compares generation and merges into current secret bytes under the Wi-Fi mutex, validates the full canonical candidate, queues any required owner restart, then publishes. Queue failure leaves RAM unchanged. Stale browser edits cannot undo local start/stop or a newer CLI apply. Generations never wrap/reuse.

Edits are RAM-only. Disabled-profile-only edits do not restart the radio; enabling, disabling, enabled-profile changes and AP changes follow canonical asynchronous restart policy. enabled_at_boot alone is next-boot policy, not Start/Stop. Start/Stop intentionally also change RAM enabled_at_boot; explicit Save persists it. Reconnect and next-profile do nothing when the manager is stopped. Next means next enabled profile in canonical priority order, wrapping; no explicit-index connection-selection API was added.

Wi-Fi Save persists the selected generation under the config mutex. Wi-Fi Load reads only the existing canonical blob and conditionally installs it; missing, invalid/incompatible or failed storage never generates/installs a new AP secret or changes RAM. No Wi-Fi defaults/reset actions. mDNS suffix is 1..55 lowercase ASCII letters/digits/hyphens, no leading/trailing hyphen; hostname is sak- plus suffix. mDNS edits/default/load are RAM-only and queue owner reannouncement; Save persists. mDNS Load may select deterministic MAC defaults and reports that result. Offline suffix edits reach an already-initialized responder on the next STA IP. NVS remains unencrypted; clearing/replacing is not secure flash erasure.

Admission/results, errors and uncertainty

POST admission: HTTP 202, e.g.

{"id":42,"action":"profile-patch","state":"pending","error":0}

GET operation returns HTTP 200 with exactly the same four fields. Only the initiating login can retrieve its slot. A different admin/no retained result gets {"id":0,"action":"none","state":"idle","error":0}. No query ID: UI compares returned id to its acknowledged ID. A later admitted operation replaces the previous result. IDs never wrap; exhaustion denies admission until reboot.

state Meaning
idle No retained result for this login
pending Waiting for dispatcher or executing
accepted RAM apply / owner queue request accepted; NOT association, DHCP, online, radio completion or verified DNS
ok Explicit Wi-Fi/mDNS save returned success
failed Canonical/owner/storage error; error is numeric esp_err_t
cancelled Queued expiry or session/currentness/dequeue deadline rejection; no canonical operation admitted
stale Selected config generation no longer matches
invalid Canonical config rejects the patch/load (e.g. enabled STA clear or AP clear)
loaded_defaults mDNS Load selected deterministic RAM defaults and reannouncement was queued
applied_not_queued mDNS RAM change succeeded but manager reannouncement queue failed; refresh, do not assume rollback

error is diagnostic numeric status, not a state override: cancelled can have zero error (deadline/currentness false). No arbitrary error text or input echo. Known terminal results should trigger a fresh snapshot. Runtime failures after accepted appear in subsequent snapshots, not by rewriting the result.

HTTP errors: existing 400 invalid/framing/query/body/method, 401 authentication, 403 Origin/CSRF/admin, 503 auth-unavailable; backend-specific 400 invalid_network_request, 503 timer_unavailable, 503 busy (Retry-After: 1), 503 snapshot_unavailable. Unread body/receive failures close rather than drain. Malformed input is never queued. Syntactically valid but canonically invalid patches may receive 202 and then terminal invalid.

One static session-bound pending/result slot, executing reservation under a short portMUX, no credentials in the dispatcher queue. One firmware-lifetime one-second ESP timer inspects the current ID/deadline and wipes/cancels non-executing input at 30 seconds plus scheduler latency. Shared inputs wipe on dequeue before auth; dispatcher-local inputs wipe on every return. HTTP body/parser/operation inputs wipe, including rejection and before response IO. Already-admitted work can finish after logout/disconnect/deadline; this is not transactional session liveness or a hard wall-clock erasure guarantee. Expired IDs cannot execute replacements.

Network-changing controls need UI confirmation/recovery warnings. HTTPS/SSH and both browser WebSockets may disconnect before any ACK/result. A lost ACK, 401 or disconnect proves neither success nor cancellation. Never automatically replay. Reconnect to STA/AP and inspect configuration/runtime; UART0/native USB recovery remain independent. No terminal lease/transport changes are made by this module.

Bounds and validation

  • 768-byte POST, at most four receives, at most 13 distinct flat keys, 64-byte parser value scratch; enough for one fully escaped 32-byte SSID and 63-byte replacement plus the typed fields. No heap JSON tree/cJSON.
  • 2,048-byte snapshot buffer. Maximum escaped fixture: 1,877 payload bytes (five 32-byte SSIDs at six bytes/byte, four profiles, full-width numbers, 55-byte mDNS suffix plus hostname, longest booleans/state/security/policy).
  • 128-byte operation response buffer; one static operation and one small timer. No new task, queue/depth/stack expansion or schema migration.
  • Target RAM/stack margins and hardware behavior are not measured by host tests.

Commands run successfully:

python3 tests/web_network_settings/run.py
python3 tests/web_cookie_auth/run.py --network
python3 tests/web_cookie_auth/run.py --accounts
python3 tests/web_cookie_auth/run.py --serial-settings
python3 tests/web_cookie_auth/run.py --settings
python3 tests/web_cookie_auth/run.py --admin
python3 tests/admin_console_boundary/run.py
python3 tests/admin_console_boundary/lifecycle.py
python3 tests/admin_console_boundary/accounts.py

The new manager harness compiles verbatim production mutation/queue functions with real wifi_config.c, mdns_config.c, mdns_service.c; deterministic RTOS, NVS, radio admission and mDNS component doubles. It does not simulate the whole Wi-Fi event loop, power loss or target scheduling. Cookie tests compile the real backend/auth/session/HTTPD adapter with owner doubles and actual installed IDF header getter/response-header functions.

Sanitizer attempt (run.py --sanitize) could not link: this host lacks libasan.so.8.0.0 and libubsan.so.1.0.0. Normal suites reran successfully. No pio run, upload, erase, asset generation or commit. Integration and target validation remain with their owners.