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.