Add Typed Admin Network Settings

This commit is contained in:
2026-09-08 20:57:27 +02:00
parent d9ac1319aa
commit 989821b7c4
31 changed files with 2568 additions and 62 deletions
+2 -1
View File
@@ -31,7 +31,8 @@ typedef int *SemaphoreHandle_t;
#define pdMS_TO_TICKS(x) (x)
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
static unsigned lock_depth, ticks, runs, actions;
static uint32_t serial_settings_executed, account_settings_executed;
static uint32_t serial_settings_executed, account_settings_executed, network_settings_executed;
static void web_network_settings_execute(uint32_t id) { assert(!lock_depth); network_settings_executed = id; }
static void web_account_settings_execute(uint32_t id) { assert(!lock_depth); account_settings_executed = id; }
static unsigned serial_settings_preceding_runs, queue_send_wait;
static void web_serial_settings_execute(uint32_t id) {
+13
View File
@@ -351,5 +351,18 @@ int main(void)
pump(worker_task);
assert(serial_settings_executed == 21 && account_settings_executed == 22 && runs == before_serial + 5);
puts("PASS: typed Accounts uses same bounded queue with nonblocking admission and isolated dispatcher routing");
assert(admin_ssh_console_submit_network_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_network_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_network_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
queue_full = false;
assert(admin_ssh_console_submit_serial_settings(31) == ESP_OK);
assert(admin_ssh_console_submit_network_settings(32) == ESP_OK && queue_send_wait == 0);
assert(admin_ssh_console_submit_account_settings(33) == ESP_OK);
pump(worker_task);
assert(serial_settings_executed == 31 && network_settings_executed == 32 && account_settings_executed == 33);
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
puts("PASS: typed Network queues only an ID, shares unchanged queue, executes outside lock without command runner");
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
}
+120 -20
View File
@@ -37,8 +37,8 @@ def define(path, name):
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 21:
raise RuntimeError('Review URI extraction: expected 19 descriptors and two tables')
if len(uri_tables) != 24:
raise RuntimeError('Review URI extraction: expected 22 descriptors and two tables')
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
source.index('static esp_err_t ensure_mutex(void)')]
header = (ROOT / 'src/web_server.h').read_text()
@@ -133,6 +133,26 @@ static void web_httpd_idle_stopped(httpd_handle_t s) {
HANDLER(serial_settings_handler)
HANDLER(web_serial_settings_handler) HANDLER(web_account_settings_handler)
HANDLER(web_account_generate_password_handler) HANDLER(web_account_keys_handler)
HANDLER(web_network_snapshot_handler) HANDLER(web_network_operation_handler)
static unsigned network_calls, network_allocations, network_fail_at;
static esp_err_t network_register(httpd_handle_t s, const httpd_uri_t *uri) {
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx);
++network_calls;
if (network_calls == 1) {
assert(!strcmp(uri->uri, "/api/settings/network") && uri->method == HTTP_GET);
assert(uri->handler == web_network_snapshot_handler);
} else {
assert(!strcmp(uri->uri, "/api/settings/network-operation"));
assert(uri->method == (network_calls == 2 ? HTTP_GET : HTTP_POST));
assert(uri->handler == web_network_operation_handler && network_calls <= 3);
}
/* Model the adapter's staged descriptor/name allocations, before publication. */
for (unsigned allocation = 0; allocation < 2; ++allocation)
if (++network_allocations == network_fail_at) return ESP_ERR_NO_MEM;
registered[registered_count++] = uri;
return ESP_OK;
}
static unsigned keys_calls;
static bool keys_fail;
static unsigned account_calls, account_fail_at;
@@ -149,7 +169,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
assert(config->httpd.max_uri_handlers == 24 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 27 && config->port_secure == 443);
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
assert(config->tls_handshake_timeout_ms == 5000);
assert(config->user_cb == tls_session_callback);
@@ -187,11 +207,14 @@ static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
}
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
assert(uri->method == HTTP_GET);
if (uri->handler == web_network_snapshot_handler || uri->handler == web_network_operation_handler)
return network_register(s, uri);
if (uri->handler == web_account_settings_handler) return account_register(s, uri);
assert(!strcmp(uri->uri, "/api/settings/serial"));
return httpd_register_uri_handler(s, uri);
}
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
if (uri->handler == web_network_operation_handler) return network_register(s, uri);
if (uri->handler == web_account_keys_handler) {
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!strcmp(uri->uri, "/api/settings/accounts/keys") && uri->method == HTTP_POST);
@@ -217,7 +240,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation")) && method == HTTP_GET));
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation")) && method == HTTP_GET));
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
@@ -285,10 +308,14 @@ static void reset(void) {
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
operation_calls = operation_fail_at = 0;
network_calls = network_allocations = network_fail_at = 0;
account_calls = account_fail_at = generation_calls = keys_calls = 0;
generation_fail = keys_fail = false;
}
static void fresh_registration(void) { registration_calls = registered_count = 0; }
static void fresh_registration(void) {
registration_calls = registered_count = 0;
network_calls = network_allocations = 0;
}
static void start(void) {
assert(web_server_start() == ESP_OK);
assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached);
@@ -301,6 +328,37 @@ static const httpd_uri_t *route(const char *uri) {
}
assert(found); return found;
}
static const httpd_uri_t *method_route(const char *uri, int method) {
const httpd_uri_t *found = NULL;
for (unsigned i = 0; i < registered_count; ++i)
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
assert(!found); found = registered[i];
}
return found;
}
static void network_complete(void) {
assert(network_calls == 3 && network_allocations == 6);
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
const httpd_uri_t *r = method_route("/api/settings/network-operation", method);
assert(r && r->handler == web_network_operation_handler);
}
}
static void other_domains_complete(void) {
assert(auth_live && ssl_live && serial_live && admin_owned && idle_owned);
assert(!auth_stops && !ssl_stops && !s_counters.start_failures);
assert(route("/api/session")->handler == web_cookie_auth_handler);
assert(route("/ws/serial")->handler == traced_websocket_handler);
assert(route("/ws/admin")->handler == traced_admin_upgrade_handler);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
assert(method_route("/api/settings/serial-operation", method)->handler == web_serial_settings_handler);
assert(method_route("/api/settings/account-operation", method)->handler == web_account_settings_handler);
}
}
int main(void) {
reset(); mutex_fail = true;
assert(web_server_init() == ESP_ERR_NO_MEM && !s_initialized && !serial_inits);
@@ -320,7 +378,7 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 24 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
reset(); start(); assert(registered_count == 27 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
@@ -374,7 +432,7 @@ int main(void) {
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
assert(registered_count == 22 && unregister_calls == failure - 17);
assert(registered_count == 25 && unregister_calls == failure - 17);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
assert(route("/ws/serial")->handler == traced_websocket_handler);
@@ -383,13 +441,13 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 24 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 27 && admin_attaches == 1 && s_counters.starts == 2);
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
}
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
reset(); registration_fail_at = 18; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 23);
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 26);
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket");
@@ -401,7 +459,7 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 24 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 27 && admin_attaches == 1 && web_server_stop() == ESP_OK);
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
@@ -423,7 +481,7 @@ int main(void) {
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
reset(); settings_fail = true; start();
assert(settings_calls == 1 && registered_count == 23);
assert(settings_calls == 1 && registered_count == 26);
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
settings_fail = false; fresh_registration(); start();
assert(route("/api/settings/serial")->handler == serial_settings_handler);
@@ -431,7 +489,7 @@ int main(void) {
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
for (unsigned failure = 1; failure <= 2; ++failure) {
reset(); operation_fail_at = failure; start();
assert(registered_count == 22 && operation_calls == failure && unregister_calls == failure - 1);
assert(registered_count == 25 && operation_calls == failure && unregister_calls == failure - 1);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
assert(web_server_stop() == ESP_OK);
@@ -439,7 +497,7 @@ int main(void) {
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
for (unsigned failure = 1; failure <= 3; ++failure) {
reset(); account_calls = 0; account_fail_at = failure; start();
assert(account_calls == failure && registered_count == (failure == 1 ? 21 : 22));
assert(account_calls == failure && registered_count == (failure == 1 ? 24 : 25));
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(auth_live && serial_live && admin_owned);
@@ -447,17 +505,17 @@ int main(void) {
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
assert(web_server_stop() == ESP_OK);
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
assert(registered_count == 24 && account_calls == 3);
assert(registered_count == 27 && account_calls == 3);
assert(web_server_stop() == ESP_OK);
}
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
assert(registered_count == 23 && auth_live && serial_live && admin_owned);
assert(registered_count == 26 && auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
reset(); generation_fail = true; start();
assert(generation_calls == 1 && registered_count == 23 && account_calls == 3);
assert(generation_calls == 1 && registered_count == 26 && account_calls == 3);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
@@ -469,12 +527,12 @@ int main(void) {
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
generation_fail = false; fresh_registration(); start();
assert(generation_calls == 2 && registered_count == 24);
assert(generation_calls == 2 && registered_count == 27);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
reset(); keys_fail = true; start();
assert(keys_calls == 1 && registered_count == 23 && account_calls == 3 && generation_calls == 1);
assert(keys_calls == 1 && registered_count == 26 && account_calls == 3 && generation_calls == 1);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
@@ -489,7 +547,7 @@ int main(void) {
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
keys_fail = false; fresh_registration(); start();
assert(keys_calls == 2 && registered_count == 24);
assert(keys_calls == 2 && registered_count == 27);
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
@@ -510,7 +568,49 @@ int main(void) {
assert(web_server_stop() == ESP_OK && !idle_owned && idle_stoppeds == 1);
fresh_registration(); start(); assert(web_server_stop() == ESP_OK);
puts("PASS idle submit fence failure forbids SSL destruction; failed stop retains ownership until retry/restart");
puts("18 lifecycle groups passed (16 required fatal positions, 10 optional routes, plus failed unregister)");
for (unsigned failure = 1; failure <= 6; ++failure) {
reset(); network_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(network_calls == failed_route && network_allocations == failure);
assert(registered_count == (failed_route == 1 ? 24 : 25));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/network-operation", HTTP_GET));
assert(!method_route("/api/settings/network-operation", HTTP_POST));
assert(!!method_route("/api/settings/network", HTTP_GET) == (failed_route != 1));
other_domains_complete();
assert(web_server_stop() == ESP_OK);
network_fail_at = 0; fresh_registration(); start();
assert(registered_count == 27); network_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); network_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 26 && unregister_calls == 1);
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
assert(!method_route("/api/settings/network-operation", HTTP_POST));
other_domains_complete();
ssl_stop_error = ESP_FAIL;
assert(web_server_stop() == ESP_FAIL && s_server == SERVER);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
assert(registered_count == 27); network_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
for (unsigned failure = 0; failure < 8; ++failure) {
reset();
if (failure == 0) settings_fail = true;
else if (failure <= 2) operation_fail_at = failure;
else if (failure <= 5) account_fail_at = failure - 2;
else if (failure == 6) generation_fail = true;
else keys_fail = true;
start(); network_complete(); assert(web_server_stop() == ESP_OK);
}
puts("PASS every other settings route failure leaves the complete Network domain available");
puts("21 lifecycle groups passed (16 required fatal positions, 13 optional routes, all six Network allocation positions, plus failed unregister)");
return 0;
}
'''
@@ -0,0 +1,225 @@
/* Real HTTP policy, store, parser and backend; deterministic owner boundaries.
* Real mutex-local Wi-Fi/mDNS behavior is covered in tests/web_network_settings. */
#include "../../src/web_network_settings.c"
#include "network_parse_production.h"
static bool dispatcher, queue_fail, snapshot_fail, timer_create_fail, timer_start_fail;
static unsigned mutations, projections, timer_creates, timer_starts;
static uint32_t queued;
static esp_err_t owner_error, mdns_queue_error;
static bool stored_mdns = true;
static void (*timer_callback)(void *);
static void (*owner_hook)(void);
static void (*queue_hook)(void);
static unsigned expected_action;
static void input_wiped(void) {
zero(&s_operation.patch,sizeof(s_operation.patch));
zero(&s_operation.mdns,sizeof(s_operation.mdns));
zero(&s_operation.principal,sizeof(s_operation.principal));
assert(!s_operation.generation);
}
int esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *out) {
assert(!host_lock_depth); ++timer_creates;
if (timer_create_fail) return ESP_FAIL;
timer_callback=args->callback; *out=(void *)1; return ESP_OK;
}
int esp_timer_start_periodic(esp_timer_handle_t handle,uint64_t period) {
assert(handle && period==1000000 && !host_lock_depth); ++timer_starts;
return timer_start_fail ? ESP_FAIL : ESP_OK;
}
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id) {
assert(!dispatcher && !host_lock_depth && id);
if(queue_hook) { void (*hook)(void)=queue_hook; queue_hook=NULL; hook(); }
if(queue_fail) return ESP_ERR_TIMEOUT;
queued=id; return ESP_OK;
}
static esp_err_t owner(unsigned action) {
assert(dispatcher && !host_lock_depth && action==expected_action && s_operation.executing);
input_wiped(); ++mutations;
if(owner_hook) { void (*hook)(void)=owner_hook; owner_hook=NULL; hook(); }
return owner_error;
}
esp_err_t wifi_manager_patch_current(uint32_t generation,const wifi_manager_patch_t *patch) {
assert(generation==7 && patch->fields);
if(expected_action==PROFILE_PATCH) assert(patch->profile==0); else assert(patch->profile==-1);
return owner(expected_action);
}
esp_err_t wifi_manager_save_current(uint32_t generation) { assert(generation==7); return owner(WIFI_SAVE); }
esp_err_t wifi_manager_load_current(uint32_t generation) { assert(generation==7); return owner(WIFI_LOAD); }
esp_err_t wifi_manager_start(void) { return owner(START); }
esp_err_t wifi_manager_stop(void) { return owner(STOP); }
esp_err_t wifi_manager_reconnect(void) { return owner(RECONNECT); }
esp_err_t wifi_manager_next_profile(void) { return owner(NEXT_PROFILE); }
esp_err_t wifi_manager_mdns_reannounce(void) { assert(dispatcher); return mdns_queue_error; }
esp_err_t mdns_service_update_current(uint32_t generation,mdns_settings_action_t action,const mdns_config_t *config,bool *stored) {
assert(generation==7 && action==(mdns_settings_action_t)(expected_action-MDNS_SET));
if(action==MDNS_SETTINGS_SET) assert(mdns_config_validate(config)==ESP_OK);
*stored=stored_mdns; return owner(expected_action);
}
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *out) {
assert(!dispatcher && !host_lock_depth); ++projections;
if(snapshot_fail) return ESP_ERR_TIMEOUT;
memset(out,0,sizeof(*out)); out->runtime.config_generation=UINT32_MAX;
out->ap_ssid_len=32; memset(out->ap_ssid,0xff,32); out->ap_policy=WIFI_CONFIG_AP_POLICY_FALLBACK;
out->ap_channel=11; out->ap_password_configured=true;
for(unsigned i=0;i<4;++i) {
out->profiles[i].ssid_len=32; memset(out->profiles[i].ssid,0xff,32);
out->profiles[i].priority=255; out->profiles[i].security=WIFI_CONFIG_SECURITY_MIXED;
}
out->runtime.active_profile=-1; out->runtime.last_error=INT32_MIN;
memset(&out->runtime.ip,255,4); out->runtime.ap_client_count=255;
return ESP_OK;
}
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *out) {
assert(!dispatcher && !host_lock_depth); memset(out,0,sizeof(*out));
out->config_generation=UINT32_MAX; out->last_error=INT32_MIN;
memset(out->suffix,'s',55); strcpy(out->hostname,"sak-"); memset(out->hostname+4,'s',55);
return ESP_OK;
}
const char *wifi_manager_state_to_string(wifi_manager_state_t state) { (void)state; return "waiting-ip"; }
static void network_begin(const issued_t *identity,const char *body) {
begin("/api/settings/network-operation",body?HTTP_POST:HTTP_GET,body); same_origin();
if(body) add("Content-Type","application/json");
if(identity) {
char cookie[100]; snprintf(cookie,sizeof(cookie),"__Host-sak-session=%s",identity->token); add("Cookie",cookie);
if(body) add("X-CSRF-Token",identity->view.csrf);
}
}
static void network_expect(const char *status,bool snapshot_route) {
unsigned before=mutations;
esp_err_t result=snapshot_route ? web_network_snapshot_handler(&req) : web_network_operation_handler(&req);
assert(result==((send_fail || aux.remaining_len || fail_header)?ESP_FAIL:ESP_OK));
if(strcmp(status,response_status)) fprintf(stderr,"expected %s got %s (%s)\n",status,response_status,output);
assert(!strcmp(status,response_status) && mutations==before);
assert(!strstr(output,"supersecret") && !strstr(output,"psk") && !strstr(output,"password_len"));
zero(scratch,sizeof(scratch));
bool no_store=false;
for(unsigned i=0;i<aux.resp_hdrs_count;++i)
if(!strcmp(response_headers[i].field,"Cache-Control") && !strcmp(response_headers[i].value,"no-store")) no_store=true;
if(!fail_header) assert(no_store);
}
static const char patch_body[]="{\"action\":\"profile-patch\",\"generation\":7,\"profile\":0,\"password\":\"supersecret\"}";
static void submit_network(const issued_t *identity,const char *body) {
network_begin(identity,body); network_expect("202 Accepted",false);
expected_action=s_operation.action;
}
static void execute_network(void) { dispatcher=true; web_network_settings_execute(queued); dispatcher=false; }
static void expire_queued(void) { now=s_operation.deadline; timer_callback(NULL); }
static void executing_tick(void) {
assert(s_operation.executing); input_wiped(); now=s_operation.deadline;
timer_callback(NULL); assert(s_operation.state==PENDING);
}
static bool parsed(const char *body) {
network_operation_t op={0}; bool valid=parse_request(body,strlen(body),&op); secure_wipe(&op,sizeof(op)); return valid;
}
static void network_settings_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob); receive_fragment=768;
network_begin(NULL,NULL); network_expect("401 Unauthorized",false);
network_begin(&user,NULL); network_expect("403 Forbidden",false);
network_begin(&user,patch_body); network_expect("403 Forbidden",false);
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
assert(strlen(output)<2048 && strstr(output,"\\u00ff") && strstr(output,"\"generation\":4294967295"));
printf("PASS Network maximum escaped snapshot: %zu bytes, no secret fields\n",strlen(output));
snapshot_fail=true; network_begin(&admin,NULL); network_expect("503 Service Unavailable",true); snapshot_fail=false;
network_begin(&user,NULL); network_expect("403 Forbidden",true);
network_begin(&admin,NULL); req.uri="/api/settings/network?secret=x"; network_expect("400 Bad Request",true);
network_begin(&admin,patch_body); req.uri="/api/settings/network-operation?x=1"; network_expect("400 Bad Request",false);
network_begin(&admin,patch_body); add("X-CSRF-Token",admin.view.csrf); network_expect("400 Bad Request",false);
network_begin(&admin,patch_body); add("Origin",origin); network_expect("400 Bad Request",false);
begin("/api/settings/network-operation",HTTP_POST,patch_body); same_origin(); add("Content-Type","application/json");
char cookie[100]; snprintf(cookie,sizeof(cookie),"__Host-sak-session=%s",admin.token); add("Cookie",cookie);
network_expect("403 Forbidden",false);
network_begin(&admin,NULL); req.content_len=1; aux.remaining_len=1; network_expect("400 Bad Request",false);
char oversized[770]; memset(oversized,' ',769); oversized[769]=0;
network_begin(&admin,oversized); network_expect("400 Bad Request",false);
puts("PASS Network current-admin, CSRF, duplicate headers, body/query bounds, no-store and zero snapshot writes");
const char *invalid[]={"{}", "{\"action\":\"defaults\"}", "{\"action\":\"reset\"}",
"{\"action\":\"start\",\"generation\":7}", "{\"action\":\"wifi-save\",\"generation\":0}",
"{\"action\":\"wifi-save\",\"generation\":4294967296}", "{\"action\":\"wifi-save\",\"generation\":07}",
"{\"action\":\"wifi-save\",\"generation\":7.0}", "{\"action\":\"wifi-patch\",\"generation\":7}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"enabled\":true}",
"{\"action\":\"profile-patch\",\"generation\":7,\"profile\":4,\"enabled\":true}",
"{\"action\":\"profile-patch\",\"generation\":7,\"profile\":0,\"priority\":256}",
"{\"action\":\"profile-patch\",\"generation\":7,\"profile\":0,\"enabled\":1}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"short\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"supersecret\",\"clear_password\":true}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"clear_password\":false}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"\\u0100\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"\\ud800\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"é\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"ap_policy\":\"off\\u0000x\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"channel\":0}",
"{\"action\":\"mdns-set\",\"generation\":7,\"suffix\":\"-bad\"}",
"{\"action\":\"mdns-set\",\"generation\":7,\"suffix\":\"BAD\"}",
"{\"action\":\"start\",\"action\":\"stop\"}", "{\"action\":\"start\",}",
"{\"action\":\"start\",\"unknown\":{}}", "{\"action\":\"start\"}x"};
for(unsigned i=0;i<sizeof(invalid)/sizeof(*invalid);++i) {
assert(!parsed(invalid[i])); network_begin(&admin,invalid[i]); network_expect("400 Bad Request",false);
}
/* Every possible byte survives the codec; 32 decoded bytes, not JSON length. */
for(unsigned byte=0;byte<256;++byte) {
uint8_t raw=(uint8_t)byte; char encoded[16]; size_t used=0;
assert(append_ssid(encoded,sizeof(encoded),&used,&raw,1));
parser_t p={.body=encoded,.size=used}; uint8_t decoded[1]; size_t n;
assert(byte_string(&p,decoded,1,&n) && n==1 && decoded[0]==raw && p.pos==used);
}
char body[768], ssid[199];
for(unsigned n=31;n<=33;++n) {
for(unsigned i=0;i<n;++i) memcpy(ssid+i*6,"\\u0000",6);
ssid[n*6]=0;
snprintf(body,sizeof(body),"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"%s\"}",ssid);
assert(parsed(body)==(n<=32));
}
for(unsigned n=7;n<=64;++n) {
char pass[385]; for(unsigned i=0;i<n;++i) memcpy(pass+i*6,"\\u0022",6); pass[n*6]=0;
snprintf(body,sizeof(body),"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"%s\"}",pass);
assert(parsed(body)==(n>=8 && n<=63));
}
receive_fragment=1; network_begin(&admin,patch_body); network_expect("400 Bad Request",false); receive_fragment=768;
recv_fail=true; network_begin(&admin,patch_body); network_expect("400 Bad Request",false); recv_fail=false;
puts("PASS Network strict parser, 256-byte SSID roundtrip, 32-byte SSID and 8..63 password bounds, four receives");
timer_create_fail=true; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false); timer_create_fail=false;
timer_start_fail=true; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false); timer_start_fail=false;
submit_network(&admin,patch_body); assert(s_operation.patch.password_len==11 && timer_creates==2 && timer_starts==2);
network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false);
network_begin(&user,NULL); network_expect("403 Forbidden",false);
issued_t other=mint(&alice); network_begin(&other,NULL); network_expect("200 OK",false); assert(strstr(output,"\"state\":\"idle\""));
uint32_t old=queued; unsigned before=mutations;
now=s_operation.deadline-1; timer_callback(NULL); assert(s_operation.state==PENDING);
++now; timer_callback(NULL); assert(s_operation.state==CANCELLED); input_wiped();
submit_network(&admin,patch_body); dispatcher=true; web_network_settings_execute(old); dispatcher=false;
assert(s_operation.state==PENDING && mutations==before); execute_network(); assert(s_operation.state==ACCEPTED); input_wiped();
network_begin(&admin,NULL); network_expect("200 OK",false); assert(strstr(output,"\"state\":\"accepted\""));
queue_fail=true; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false); queue_fail=false;
zero(&s_operation,sizeof(s_operation));
queue_hook=expire_queued; submit_network(&admin,patch_body); assert(s_operation.state==CANCELLED); before=mutations; execute_network(); assert(mutations==before);
submit_network(&admin,patch_body); db_hook=executing_tick; execute_network(); assert(s_operation.state==CANCELLED && mutations==before);
submit_network(&admin,patch_body); owner_hook=executing_tick; execute_network(); assert(s_operation.state==ACCEPTED); input_wiped();
submit_network(&admin,patch_body); web_session_store_invalidate(admin.view.id); execute_network(); assert(s_operation.state==CANCELLED); input_wiped();
admin=mint(&alice); submit_network(&admin,patch_body); stale_user=alice.user_id; execute_network(); stale_user=0; assert(s_operation.state==CANCELLED);
admin=mint(&alice); submit_network(&admin,patch_body); db_fail=true; execute_network(); db_fail=false; assert(s_operation.state==CANCELLED);
puts("PASS Network timer failure/expiry, old IDs, session isolation, executing reservation, logout/revocation/deadline and wiped shared input");
auth_reset(); admin=mint(&alice);
const char *bodies[]={"{\"action\":\"wifi-patch\",\"generation\":7,\"ap_policy\":\"always\"}",patch_body,
"{\"action\":\"wifi-save\",\"generation\":7}","{\"action\":\"wifi-load\",\"generation\":7}",
"{\"action\":\"start\"}","{\"action\":\"stop\"}","{\"action\":\"reconnect\"}","{\"action\":\"next-profile\"}",
"{\"action\":\"mdns-set\",\"generation\":7,\"suffix\":\"new-name\"}","{\"action\":\"mdns-save\",\"generation\":7}",
"{\"action\":\"mdns-load\",\"generation\":7}","{\"action\":\"mdns-defaults\",\"generation\":7}"};
for(unsigned i=0;i<ACTION_COUNT;++i) {
submit_network(&admin,bodies[i]); assert(expected_action==i); execute_network();
assert(s_operation.state==((i==WIFI_SAVE || i==MDNS_SAVE)?OK:ACCEPTED));
}
const esp_err_t errors[]={ESP_ERR_NOT_FOUND,ESP_ERR_INVALID_ARG,ESP_ERR_TIMEOUT,ESP_FAIL};
const unsigned states[]={STALE,INVALID,FAILED,FAILED};
for(unsigned i=0;i<4;++i) {
owner_error=errors[i]; submit_network(&admin,patch_body); execute_network(); assert(s_operation.state==states[i]); input_wiped();
}
owner_error=ESP_OK; stored_mdns=false; submit_network(&admin,bodies[MDNS_LOAD]); execute_network(); assert(s_operation.state==LOADED_DEFAULTS);
mdns_queue_error=ESP_ERR_TIMEOUT; submit_network(&admin,bodies[MDNS_SET]); execute_network(); assert(s_operation.state==APPLIED_NOT_QUEUED); mdns_queue_error=ESP_OK;
send_fail=true; submit_network(&admin,patch_body); send_fail=false; execute_network(); assert(s_operation.state==ACCEPTED);
s_next_id=UINT32_MAX; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false);
puts("PASS Network all typed actions, owner queue/NVS error mapping, truthful async status, mDNS partial apply, lost ACK and nonwrapping IDs");
}
+17
View File
@@ -51,6 +51,17 @@ admin = "--admin" in sys.argv
settings = "--settings" in sys.argv
serial_settings = "--serial-settings" in sys.argv
accounts = "--accounts" in sys.argv
network = "--network" in sys.argv
if network:
HEADERS["esp_wifi_types.h"] = "#pragma once\ntypedef int wifi_auth_mode_t;\n"
HEADERS["esp_err.h"] += "\n#define ESP_ERR_TIMEOUT 0x107\n"
HEADERS["esp_timer.h"] += """
#include <stdbool.h>
typedef void *esp_timer_handle_t;
typedef struct { void (*callback)(void *); const char *name; bool skip_unhandled_events; } esp_timer_create_args_t;
int esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *);
int esp_timer_start_periodic(esp_timer_handle_t, uint64_t);
"""
if accounts:
HEADERS["mbedtls/base64.h"] = """
#pragma once
@@ -167,6 +178,11 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
settings_source += ' serial_service_counters_t serial_counters = {0};\n' + acquisition
settings_source += ' return snprintf(response, capacity,\n' + serial_format + ',\n' + serial_arguments + ');\n}\n'
(tmp / 'settings_production.h').write_text(settings_source)
if network:
wifi_source = (ROOT / 'src/wifi_config.c').read_text()
mdns_source = (ROOT / 'src/mdns_config.c').read_text()
names = ['wifi_config_parse_ap_policy', 'wifi_config_ap_policy_to_string', 'wifi_config_parse_security', 'wifi_config_security_to_string']
(tmp / 'network_parse_production.h').write_text('\n'.join(function(wifi_source, name) for name in names) + '\n' + '\n'.join(function(mdns_source, name) for name in ('suffix_character_is_valid', 'mdns_config_validate')))
if accounts:
db_source = (ROOT / 'src/user_database.c').read_text()
alphabet_start = db_source.index('static const uint8_t s_generated_alphabet')
@@ -196,6 +212,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
*(["-DHOST_SETTINGS"] if settings else []),
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings else []),
*(["-DHOST_ACCOUNTS"] if accounts else []),
*(["-DHOST_NETWORK"] if network else []),
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
"-o", str(tmp / "test")], check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20)
+12 -1
View File
@@ -18,7 +18,12 @@
static struct httpd_data server = {.config.max_resp_headers = 8};
static struct sock_db socket_state;
static struct resp_hdr response_headers[8];
static char scratch[1024], output[1024], cookie_values[2][200];
static char scratch[1024], cookie_values[2][200];
#ifdef HOST_NETWORK
static char output[2048];
#else
static char output[1024];
#endif
static const char *request_body;
static size_t body_offset;
static unsigned password_calls, cookie_count, sends, upgrades;
@@ -135,6 +140,9 @@ static void auth_reset(void) {
#ifdef HOST_ACCOUNTS
#include "account_settings_test.c"
#endif
#ifdef HOST_NETWORK
#include "network_settings_test.c"
#endif
int main(void) {
assert(store_tests() == 0); auth_reset();
@@ -291,6 +299,9 @@ int main(void) {
#endif
#ifdef HOST_ACCOUNTS
account_settings_tests();
#endif
#ifdef HOST_NETWORK
network_settings_tests();
#endif
return 0;
}
+236
View File
@@ -0,0 +1,236 @@
# 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):
```json
{
"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:
```json
{"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.
```json
{"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:
```sh
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.
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Real config modules/service and verbatim manager mutation paths, host RTOS/NVS.
The full manager driver/event loop is NOT simulated. Extracted functions include
both canonical legacy apply/lifecycle admission and new mutex-local APIs, so the
regressions exercise the actual shared transaction rather than a second model.
"""
import os
from pathlib import Path
import subprocess
import tempfile
import sys
os.environ['CCACHE_DISABLE'] = '1'
sys.dont_write_bytecode = True
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
HEADERS = {
'esp_err.h': '''#pragma once
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NO_MEM 0x101
#define ESP_ERR_INVALID_ARG 0x102
#define ESP_ERR_INVALID_STATE 0x103
#define ESP_ERR_INVALID_SIZE 0x104
#define ESP_ERR_NOT_FOUND 0x105
#define ESP_ERR_TIMEOUT 0x107
#define ESP_ERR_NVS_NOT_FOUND 0x1102
#define ESP_ERR_NVS_TYPE_MISMATCH 0x1103
#define ESP_ERR_NVS_INVALID_LENGTH 0x110c
''',
'esp_wifi_types.h': '#pragma once\ntypedef int wifi_auth_mode_t;\n',
'freertos/FreeRTOS.h': '''#pragma once
#include <stdint.h>
#define pdTRUE 1
#define portMAX_DELAY UINT32_MAX
''',
'freertos/semphr.h': '''#pragma once
#include <stdint.h>
typedef int *SemaphoreHandle_t;
SemaphoreHandle_t xSemaphoreCreateMutex(void);
int xSemaphoreTake(SemaphoreHandle_t,uint32_t);
int xSemaphoreGive(SemaphoreHandle_t);
''',
'nvs.h': '''#pragma once
#include <stddef.h>
#include "esp_err.h"
typedef int nvs_handle_t;
#define NVS_READONLY 0
#define NVS_READWRITE 1
esp_err_t nvs_open(const char *,int,nvs_handle_t *);
esp_err_t nvs_get_blob(nvs_handle_t,const char *,void *,size_t *);
esp_err_t nvs_set_blob(nvs_handle_t,const char *,const void *,size_t);
esp_err_t nvs_commit(nvs_handle_t);
void nvs_close(nvs_handle_t);
''',
'nvs_flash.h': '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n',
'esp_mac.h': '''#pragma once
#include <stdint.h>
#include "esp_err.h"
#define ESP_MAC_WIFI_STA 0
#define ESP_MAC_WIFI_SOFTAP 1
esp_err_t esp_read_mac(uint8_t *,int);
''',
'mdns.h': '''#pragma once
#include "esp_err.h"
esp_err_t mdns_init(void);
esp_err_t mdns_hostname_set(const char *);
esp_err_t mdns_instance_name_set(const char *);
void mdns_free(void);
''',
}
def function(source, name):
start = source.index(name + '(')
start = source.rfind('\n', 0, start) + 1
return source[start:source.index('\n}', start) + 2]
source = (ROOT / 'src/wifi_manager.c').read_text()
names = ['count_queue_drop', 'enqueue_message', 'wifi_manager_get_snapshot',
'profiles_equal', 'config_requires_radio_restart', 'apply_config_locked',
'wifi_manager_apply_working_config', 'wifi_manager_get_settings',
'generation_matches', 'wifi_manager_patch_current', 'wifi_manager_save_current',
'wifi_manager_load_current', 'enqueue_lifecycle_command', 'wifi_manager_start',
'wifi_manager_stop', 'wifi_manager_reconnect', 'wifi_manager_next_profile',
'wifi_manager_mdns_reannounce']
# Guard both ownership and no expansion of the real policy owner.
assert '#define WIFI_MANAGER_QUEUE_LENGTH 16U' in source
assert '#define WIFI_MANAGER_TASK_STACK_SIZE 6144U' in source
assert 'wifi_config_load(' not in function(source, 'wifi_manager_load_current')
with tempfile.TemporaryDirectory(prefix='web-network-settings-') as directory:
tmp = Path(directory)
for name, contents in HEADERS.items():
path = tmp / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents)
(tmp / 'manager_production.h').write_text('\n'.join(function(source, name) for name in names))
sanitizer = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if '--sanitize' in sys.argv else []
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', '-g', *sanitizer,
'-I' + str(tmp), '-I' + str(ROOT / 'src'), str(HERE / 'test.c'),
*[str(ROOT / 'src' / name) for name in ('wifi_config.c', 'mdns_config.c', 'mdns_service.c')],
'-o', str(tmp / 'test')], check=True, timeout=30)
subprocess.run([str(tmp / 'test')], check=True, timeout=20)
+214
View File
@@ -0,0 +1,214 @@
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "wifi_manager.h"
#include "mdns_service.h"
#include "secure_random.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "nvs.h"
static int wifi_mutex, mdns_mutex;
static SemaphoreHandle_t s_mutex=&wifi_mutex;
static struct { wifi_app_config_t config; wifi_manager_snapshot_t snapshot; } s_shared;
typedef enum { MESSAGE_COMMAND_START, MESSAGE_COMMAND_STOP, MESSAGE_COMMAND_APPLY,
MESSAGE_COMMAND_RECONNECT, MESSAGE_COMMAND_NEXT_PROFILE, MESSAGE_COMMAND_MDNS_REANNOUNCE } manager_message_type_t;
typedef struct { manager_message_type_t type; } manager_message_t;
static unsigned queued, random_calls, wiped_candidates, commits, hostname_calls;
static bool queue_fail, snapshot_contention;
static esp_err_t nvs_error, commit_error, hostname_error;
static manager_message_type_t last_message;
static void lock_shared(void) { assert(!wifi_mutex); wifi_mutex=1; }
static void unlock_shared(void) { assert(wifi_mutex); wifi_mutex=0; }
static int s_drop_mux, s_queue;
static uint64_t s_queue_drops;
#define portENTER_CRITICAL(mux) do { assert((mux)==&s_drop_mux && !s_drop_mux); s_drop_mux=1; } while (0)
#define portEXIT_CRITICAL(mux) do { assert((mux)==&s_drop_mux && s_drop_mux); s_drop_mux=0; } while (0)
static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait) {
assert(queue==s_queue && !wait && wifi_mutex && !s_drop_mux);
if(queue_fail) return 0;
++queued; last_message=message->type; return pdTRUE;
}
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &mdns_mutex; }
int xSemaphoreTake(SemaphoreHandle_t mutex,uint32_t wait) {
if(wait==0 && (snapshot_contention || *mutex)) return 0;
assert(!*mutex); *mutex=1; return pdTRUE;
}
int xSemaphoreGive(SemaphoreHandle_t mutex) { assert(*mutex); *mutex=0; return pdTRUE; }
void secure_wipe(void *data,size_t size) {
if(size==sizeof(wifi_app_config_t)) ++wiped_candidates;
volatile uint8_t *p=data; while(size--) *p++=0;
}
esp_err_t secure_random_fill(void *data,size_t size) { ++random_calls; memset(data,17,size); return ESP_OK; }
esp_err_t esp_read_mac(uint8_t *mac,int interface) { (void)interface; memset(mac,0x12,6); return ESP_OK; }
static struct { uint8_t bytes[528]; size_t size; bool present; } blobs[2];
esp_err_t nvs_flash_init(void) { return nvs_error; }
esp_err_t nvs_open(const char *name,int mode,nvs_handle_t *handle) {
*handle=!strcmp(name,MDNS_CONFIG_NVS_NAMESPACE);
if(mode==NVS_READONLY && !blobs[*handle].present) return ESP_ERR_NVS_NOT_FOUND;
return ESP_OK;
}
esp_err_t nvs_get_blob(nvs_handle_t handle,const char *key,void *out,size_t *size) {
assert(!strcmp(key,"config"));
if(!blobs[handle].present) return ESP_ERR_NVS_NOT_FOUND;
if(!out) { *size=blobs[handle].size; return ESP_OK; }
if(*size<blobs[handle].size) return ESP_ERR_NVS_INVALID_LENGTH;
*size=blobs[handle].size; memcpy(out,blobs[handle].bytes,*size); return ESP_OK;
}
esp_err_t nvs_set_blob(nvs_handle_t handle,const char *key,const void *data,size_t size) {
assert(!strcmp(key,"config") && size<=528 && (wifi_mutex || mdns_mutex));
if(commit_error) return ESP_OK;
memcpy(blobs[handle].bytes,data,size); blobs[handle].size=size; blobs[handle].present=true; return ESP_OK;
}
esp_err_t nvs_commit(nvs_handle_t handle) { (void)handle; ++commits; return commit_error; }
void nvs_close(nvs_handle_t handle) { (void)handle; }
static char announced_hostname[60];
esp_err_t mdns_init(void) { return ESP_OK; }
esp_err_t mdns_hostname_set(const char *hostname) {
assert(!mdns_mutex); ++hostname_calls; strcpy(announced_hostname,hostname); return hostname_error;
}
esp_err_t mdns_instance_name_set(const char *name) { assert(name); return ESP_OK; }
void mdns_free(void) {}
#include "manager_production.h"
static uint32_t generation(void) { return s_shared.snapshot.config_generation; }
static esp_err_t patch(wifi_manager_patch_t *p) { return wifi_manager_patch_current(generation(),p); }
static void same_secret(const uint8_t *secret,const char *expected) { assert(!memcmp(secret,expected,strlen(expected))); }
int main(void) {
assert(wifi_config_defaults(&s_shared.config)==ESP_OK); s_shared.snapshot.config_generation=1;
s_shared.snapshot.active_profile=-1; s_shared.snapshot.state=WIFI_MANAGER_STATE_STOPPED;
wifi_manager_settings_t projection;
snapshot_contention=true; memset(&projection,0xff,sizeof(projection));
assert(wifi_manager_get_settings(&projection)==ESP_ERR_TIMEOUT);
for(unsigned i=0;i<sizeof(projection);++i) assert(((uint8_t *)&projection)[i]==0);
snapshot_contention=false;
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.ap_password_configured);
assert(!projection.profiles[0].password_configured && projection.runtime.active_profile==-1);
wifi_manager_patch_t p={.profile=0,.fields=WIFI_PATCH_SSID,.ssid_len=32};
for(unsigned i=0;i<32;++i) p.ssid[i]=(uint8_t)(i*8);
assert(patch(&p)==ESP_OK && queued==0 && !s_shared.config.profiles[0].enabled);
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.profiles[0].ssid_len==32);
assert(!memcmp(projection.profiles[0].ssid,p.ssid,32));
p.fields=WIFI_PATCH_PASSWORD; p.password_len=11; memcpy(p.password,"supersecret",11);
assert(patch(&p)==ESP_OK && queued==0); same_secret(s_shared.config.profiles[0].psk,"supersecret");
uint32_t stale=generation();
p.password_len=12; memcpy(p.password,"replacement!",12); assert(patch(&p)==ESP_OK);
wifi_app_config_t before=s_shared.config;
p.fields=WIFI_PATCH_PRIORITY; p.priority=255;
assert(wifi_manager_patch_current(stale,&p)==ESP_ERR_NOT_FOUND && !memcmp(&before,&s_shared.config,sizeof(before)));
assert(patch(&p)==ESP_OK); same_secret(s_shared.config.profiles[0].psk,"replacement!");
/* Omitted password in a fresh patch cannot restore the caller's old copy. */
p.fields=WIFI_PATCH_ENABLED; p.enabled=1;
queue_fail=true; before=s_shared.config; stale=generation();
assert(patch(&p)==ESP_ERR_TIMEOUT && generation()==stale && !memcmp(&before,&s_shared.config,sizeof(before)));
assert(s_queue_drops==1 && s_shared.snapshot.counters.queue_drops==0);
wifi_manager_snapshot_t runtime;
assert(wifi_manager_get_snapshot(&runtime)==ESP_OK);
assert(wifi_manager_get_settings(&projection)==ESP_OK);
assert(projection.runtime.counters.queue_drops==1 &&
projection.runtime.counters.queue_drops==runtime.counters.queue_drops && !s_drop_mux);
puts("PASS real manager: failed owner enqueue updates settings queue_drops consistently with runtime snapshot");
queue_fail=false; assert(patch(&p)==ESP_OK && queued==1 && last_message==MESSAGE_COMMAND_APPLY);
assert(s_shared.snapshot.state==WIFI_MANAGER_STATE_STOPPED && !s_shared.snapshot.started);
p.fields=WIFI_PATCH_PASSWORD; p.password_len=0;
before=s_shared.config; assert(patch(&p)==ESP_ERR_INVALID_ARG && !memcmp(&before,&s_shared.config,sizeof(before)));
p.fields=WIFI_PATCH_PASSWORD|WIFI_PATCH_ENABLED; p.enabled=0;
assert(patch(&p)==ESP_OK && s_shared.config.profiles[0].psk_len==0 && queued==2);
for(unsigned i=0;i<63;++i) assert(s_shared.config.profiles[0].psk[i]==0);
p.fields=WIFI_PATCH_ENABLED; p.enabled=1; assert(patch(&p)==ESP_ERR_INVALID_ARG);
p.profile=-1; p.fields=WIFI_PATCH_PASSWORD; p.password_len=0;
assert(patch(&p)==ESP_ERR_INVALID_ARG);
p.fields=WIFI_PATCH_POLICY; p.ap_policy=WIFI_CONFIG_AP_POLICY_OFF; assert(patch(&p)==ESP_OK);
p.fields=WIFI_PATCH_PASSWORD; assert(patch(&p)==ESP_ERR_INVALID_ARG);
p.fields=WIFI_PATCH_CHANNEL; p.ap_channel=12; assert(patch(&p)==ESP_ERR_INVALID_ARG);
p.profile=4; assert(patch(&p)==ESP_ERR_INVALID_ARG);
p.profile=-1; p.fields=WIFI_PATCH_ENABLED; assert(patch(&p)==ESP_ERR_INVALID_ARG);
p.fields=UINT32_MAX; assert(patch(&p)==ESP_ERR_INVALID_ARG);
puts("PASS real manager: zero-wait secret-free projection, byte SSIDs, stale generation, omission preserves CURRENT PSK, disabled clear and canonical AP/enabled constraints");
unsigned staged_queue=queued;
for(unsigned i=0;i<4;++i) {
p=(wifi_manager_patch_t){.profile=(int8_t)i,.fields=WIFI_PATCH_SSID|WIFI_PATCH_PRIORITY|WIFI_PATCH_SECURITY,
.ssid_len=1,.ssid={(uint8_t)('a'+i)},.priority=(uint8_t)(255-i),.security=WIFI_CONFIG_SECURITY_WPA3};
assert(patch(&p)==ESP_OK && queued==staged_queue);
}
assert(wifi_manager_get_settings(&projection)==ESP_OK);
for(unsigned i=0;i<4;++i) {
assert(projection.profiles[i].priority==255-i && projection.profiles[i].security==WIFI_CONFIG_SECURITY_WPA3);
assert(projection.profiles[i].ssid_len==1 && projection.profiles[i].ssid[0]=='a'+i);
}
p=(wifi_manager_patch_t){.profile=-1,.fields=WIFI_PATCH_PASSWORD,.password_len=12,.password="AP-replaced!"};
assert(patch(&p)==ESP_OK); same_secret(s_shared.config.ap_psk,"AP-replaced!");
stale=generation();
p.fields=WIFI_PATCH_SSID; p.ssid_len=3; memcpy(p.ssid,"AP!",3); assert(patch(&p)==ESP_OK);
same_secret(s_shared.config.ap_psk,"AP-replaced!");
assert(wifi_manager_load_current(stale)==ESP_ERR_NOT_FOUND);
puts("PASS real manager: four stable profiles, disabled security/priority staging, AP replacement and omission preserve");
/* Legacy/local apply and start/stop participate in the same generation. */
before=s_shared.config; stale=generation();
before.ap_channel=3; assert(wifi_manager_apply_working_config(&before)==ESP_OK && generation()!=stale);
p.profile=-1; p.fields=WIFI_PATCH_BOOT; p.enabled_at_boot=0;
assert(wifi_manager_patch_current(stale,&p)==ESP_ERR_NOT_FOUND);
stale=generation(); assert(wifi_manager_stop()==ESP_OK && generation()!=stale && !s_shared.config.enabled_at_boot);
assert(wifi_manager_save_current(stale)==ESP_ERR_NOT_FOUND);
queue_fail=true; stale=generation(); assert(wifi_manager_start()==ESP_ERR_TIMEOUT && generation()==stale && !s_shared.config.enabled_at_boot); queue_fail=false;
assert(wifi_manager_start()==ESP_OK && s_shared.config.enabled_at_boot);
assert(wifi_manager_reconnect()==ESP_OK && last_message==MESSAGE_COMMAND_RECONNECT);
assert(wifi_manager_next_profile()==ESP_OK && last_message==MESSAGE_COMMAND_NEXT_PROFILE);
assert(wifi_manager_mdns_reannounce()==ESP_OK && last_message==MESSAGE_COMMAND_MDNS_REANNOUNCE);
unsigned random_before=random_calls;
before=s_shared.config; stale=generation();
assert(wifi_manager_load_current(stale)==ESP_ERR_NVS_NOT_FOUND && generation()==stale && random_calls==random_before);
assert(!memcmp(&before,&s_shared.config,sizeof(before)));
assert(wifi_manager_save_current(generation())==ESP_OK && blobs[0].present);
p.fields=WIFI_PATCH_CHANNEL; p.ap_channel=9; assert(patch(&p)==ESP_OK);
stale=generation(); queue_fail=true;
assert(wifi_manager_load_current(stale)==ESP_ERR_TIMEOUT && generation()==stale && s_shared.config.ap_channel==9); queue_fail=false;
assert(wifi_manager_load_current(stale)==ESP_OK && s_shared.config.ap_channel==3 && random_calls==random_before);
nvs_error=ESP_FAIL; assert(wifi_manager_save_current(generation())==ESP_FAIL);
before=s_shared.config; assert(wifi_manager_load_current(generation())==ESP_FAIL && !memcmp(&before,&s_shared.config,sizeof(before))); nvs_error=ESP_OK;
commit_error=ESP_FAIL; assert(wifi_manager_save_current(generation())==ESP_FAIL); commit_error=ESP_OK;
blobs[0].bytes[0]=0; assert(wifi_manager_load_current(generation())==ESP_ERR_INVALID_ARG);
blobs[0].size=527; assert(wifi_manager_load_current(generation())==ESP_ERR_INVALID_SIZE && random_calls==random_before);
assert(wiped_candidates>10);
s_shared.snapshot.config_generation=UINT32_MAX;
assert(patch(&p)==ESP_ERR_INVALID_STATE);
assert(wifi_manager_stop()==ESP_ERR_INVALID_STATE && s_shared.config.enabled_at_boot);
assert(wifi_manager_apply_working_config(&before)==ESP_ERR_INVALID_STATE);
puts("PASS real manager: legacy/local generations, atomic queue failures, asynchronous lifecycle, conditional NVS failures/load without RNG, candidate wipe and no generation wrap");
mdns_config_t config; mdns_config_defaults(&config); assert(mdns_service_init(&config)==ESP_OK);
mdns_service_snapshot_t m;
snapshot_contention=true; assert(mdns_service_get_settings(&m)==ESP_ERR_TIMEOUT); snapshot_contention=false;
assert(mdns_service_get_settings(&m)==ESP_OK && m.config_generation==1);
bool stored;
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"first"); config.suffix_len=5;
assert(mdns_service_update_current(1,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
assert(mdns_service_update_current(1,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_ERR_NOT_FOUND);
assert(mdns_service_start()==ESP_OK && !strcmp(announced_hostname,"sak-first"));
mdns_service_stop();
assert(mdns_service_get_settings(&m)==ESP_OK && !m.announced);
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"offline"); config.suffix_len=7;
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
unsigned calls=hostname_calls;
assert(mdns_service_start()==ESP_OK && hostname_calls==calls+1 && !strcmp(announced_hostname,"sak-offline"));
assert(mdns_service_get_settings(&m)==ESP_OK);
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_OK);
strcpy(config.suffix,"another"); assert(mdns_service_set_config(&config)==ESP_OK);
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_LOAD,NULL,&stored)==ESP_ERR_NOT_FOUND);
assert(mdns_service_get_settings(&m)==ESP_OK);
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_LOAD,NULL,&stored)==ESP_OK && stored);
assert(mdns_service_get_settings(&m)==ESP_OK && !strcmp(m.suffix,"offline"));
assert(mdns_service_reannounce()==ESP_OK && !strcmp(announced_hostname,"sak-offline"));
blobs[1].present=false;
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_LOAD,NULL,&stored)==ESP_OK && !stored);
assert(mdns_service_get_settings(&m)==ESP_OK && !strcmp(m.suffix,"121212121212"));
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_DEFAULTS,NULL,&stored)==ESP_OK);
assert(mdns_service_get_settings(&m)==ESP_OK);
commit_error=ESP_FAIL; assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_FAIL); commit_error=ESP_OK;
hostname_error=ESP_FAIL; assert(mdns_service_reannounce()==ESP_FAIL);
assert(mdns_service_get_settings(&m)==ESP_OK && m.last_error==ESP_FAIL);
puts("PASS real mDNS service: zero wait, conditional set/save/load/default, legacy races, NVS errors and offline suffix reannouncement regression");
}
+3 -2
View File
@@ -1,7 +1,7 @@
'use strict';
const assert = require('node:assert/strict');
const vm = require('node:vm');
const {script, loader} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
const {script, loader, html} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
const token = 'a'.repeat(64);
const json = value => new Response(JSON.stringify(value));
const session = (extra = {}) => json({username: '<img>', role: 'user', csrf: token, expires_in: 3600, ...extra});
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -1225,5 +1225,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
stale.resolve(json({users:[{username:'replaced',role:'user',user_id:90,auth_generation:99}]})); await tick();
assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret);
});
await require('./network.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });
+382
View File
@@ -0,0 +1,382 @@
'use strict';
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
const path = '/api/settings/network', operation = path + '-operation';
const fixture = () => ({wifi: {generation: 7, enabled_at_boot: true,
ap: {policy: 'fallback', channel: 6, ssid: 'access', password_configured: true},
profiles: Array.from({length: 4}, (_, index) => ({index, enabled: index === 0, priority: index * 10,
security: 'mixed', ssid: index === 0 ? 'office' : '', password_configured: index === 0}))},
runtime: {started: true, state: 'connecting', active_profile: 0, ip: '0.0.0.0', ap_running: true, ap_clients: 1, last_error: 0},
mdns: {generation: 3, suffix: 'example', hostname: 'sak-example', announced: false, last_error: 0}});
const reply = (id = 42, state = 'pending', action = 'wifi-patch', status = 200, error = 0) => new Response(JSON.stringify({id, action, state, error}), {status});
const ack = action => reply(42, 'pending', action, 202);
const n = (b, id) => b.nodes['network-' + id];
const input = (b, id, value, event = 'input') => { n(b, id).value = value; n(b, id)[event](); };
const target = (b, value) => input(b, 'target', value, 'change');
const secret = (b, value = 'a safe PSK') => { input(b, 'password-mode', 'replace', 'change'); input(b, 'password', value); };
const clean = b => { assert.equal(n(b, 'password').value, ''); assert.equal(n(b, 'password-mode').value, 'keep'); assert.ok(n(b, 'password').disabled); assert.ok(![...b.timers.values()].some(t => t.ms === 60000)); };
const posts = b => b.calls.filter(c => c.url === operation && c.method === 'POST');
const gets = b => b.calls.filter(c => c.url === operation && c.method === 'GET');
const reads = b => b.calls.filter(c => c.url === path);
const safe = (b, text = 'SECRET') => { for (const node of Object.values(b.nodes)) assert.ok(!node.textContent.includes(text)); };
async function open(value = fixture()) {
const b = await adminBrowser(); b.click('select-settings'); await tick();
b.queues[path].push(json(value)); b.click('settings-network'); await tick();
return b;
}
async function complete(b, action, state = 'accepted', value = fixture(), error = 0) {
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
b.fire(1000); await tick();
}
await test('Network authored HTML has actual AP/four profiles/mDNS controls and truthful persistence/recovery policy', async () => {
for (const id of ['settings-network','network-target','network-ssid','network-ssid-mode','network-apply','network-result','network-wifi-save','network-wifi-load','network-start','network-stop','network-reconnect','network-next-profile','network-mdns-set','network-mdns-save','network-mdns-load','network-mdns-defaults']) assert.ok(html.includes('id="' + id + '"'), id);
for (let i = 0; i < 4; i++) assert.ok(html.includes('value="' + i + '">STA profile ' + i));
// Allow at least one excess character so native maxlength cannot silently turn an oversized paste into a valid credential/SSID.
assert.match(html, /id="network-password" type="password" maxlength="64" autocomplete="new-password" disabled/);
assert.match(html, /id="network-ssid" maxlength="256"/);
for (const text of ['NOT browser drafts', 'No Wi-Fi defaults/reset', 'Accepted is NOT connected', 'UART0', 'native USB', 'STA-only responder', 'not secure flash erasure', 'blank never clears', '60 seconds']) assert.ok(html.toLowerCase().includes(text.toLowerCase()), text);
assert.ok(!html.includes('id="network-wifi-defaults"') && !html.includes('id="network-wifi-reset"'));
});
await test('Network is admin-only; navigation retains terminals, writer lease, hidden binary drains and no routine confirmation', async () => {
const u = browser(); u.start(); await tick(); u.click('settings-network'); await tick(); assert.equal(reads(u).length, 0);
const b = await open(); const count = b.calls.length;
b.window.confirm = () => { throw new Error('No fetch confirmations'); };
b.click('settings-network'); b.click('select-settings'); await tick(); assert.equal(b.calls.length, count);
assert.equal(n(b, 'edit').hidden, false); assert.equal(b.nodes['serial-settings-content'].hidden, true); assert.equal(b.nodes['account-settings'].hidden, true);
assert.match(n(b, 'summary').textContent, /STA 3/); assert.match(n(b, 'summary').textContent, /Runtime: connecting/);
assert.equal(n(b, 'password-clear').hidden, true); clean(b);
for (let i = 0; i < 2; ++i) {
b.sockets[i].emit('message', {data: Uint8Array.of(0, 255, i).buffer}); assert.deepEqual(b.terminals[i].writes.at(-1), [0, 255, i]);
b.terminals[i].input('not to terminal'); assert.equal(b.sockets[i].sent.length, 0);
}
b.click('settings-accounts'); await tick(); b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
assert.match(b.nodes['input-state'].textContent, /writer lease retained/);
});
await test('Network summary shows readable quoted ASCII SSIDs and lossless hex fallback', async () => {
const v = fixture(); v.wifi.ap.ssid = 'ESP32-SAK-FA7059';
const names = ['Moppelkotze', 'Voltage-legacy', 'TKRZ', ''];
v.wifi.profiles.forEach((p, i) => { p.ssid = names[i]; });
const b = await open(v);
for (const name of [v.wifi.ap.ssid, ...names]) assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify(name)));
assert.ok(!n(b, 'summary').textContent.includes('SSID hex:'));
for (const name of [' leading and trailing ', 'a"b\\c', '~'.repeat(32)]) {
v.wifi.ap.ssid = name; b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify(name)));
assert.equal(n(b, 'ssid').value, name);
}
for (const bytes of ['A\0\xff', '\r\n\t', '\x7f', '\xc3\xa9']) {
v.wifi.ap.ssid = bytes; v.wifi.profiles[0].ssid = bytes;
b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
const hex = Array.from(bytes, c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ');
assert.equal(n(b, 'summary').textContent.split('SSID hex: ' + hex).length - 1, 2);
}
assert.equal(posts(b).length, 0);
});
await test('Network strict nested snapshot shape rejects secret fields, types, ranges, duplicates and inconsistent canonical values', async () => {
const edits = [v => v.password = 'SECRET', v => v.wifi.password = 'SECRET', v => v.wifi.ap.password = 'SECRET', v => v.wifi.profiles[1].password = 'SECRET',
v => delete v.runtime.ip, v => v.wifi.generation = 0, v => v.mdns.generation = 4294967296, v => v.wifi.enabled_at_boot = 1,
v => v.wifi.ap.policy = 'open', v => v.wifi.ap.channel = 12, v => v.wifi.ap.ssid = '', v => v.wifi.ap.password_configured = false,
v => v.wifi.ap.ssid = '\u0100', v => v.wifi.ap.ssid = '\ud800', v => v.wifi.ap.ssid = 'x'.repeat(33),
v => v.wifi.profiles.pop(), v => v.wifi.profiles.push(v.wifi.profiles[0]), v => v.wifi.profiles[1].index = 0,
v => v.wifi.profiles[1].enabled = 'false', v => v.wifi.profiles[1].priority = 256, v => v.wifi.profiles[1].priority = 0.5,
v => v.wifi.profiles[1].security = 'open', v => v.wifi.profiles[1].enabled = true, v => v.wifi.profiles[0].password_configured = false,
v => v.wifi.profiles[1].password_configured = true, v => v.wifi.profiles[0].password_configured = 'yes',
v => v.runtime.state = '<img>', v => v.runtime.started = 1, v => v.runtime.active_profile = 4, v => v.runtime.active_profile = -2,
v => v.runtime.ip = '256.1.1.1', v => v.runtime.ip = '<img>', v => v.runtime.ap_running = 1, v => v.runtime.ap_clients = 256, v => v.runtime.last_error = 2147483648,
v => v.mdns.suffix = 'A', v => v.mdns.suffix = '-x', v => v.mdns.suffix = 'x-', v => v.mdns.hostname = 'not-matching', v => v.mdns.announced = 0, v => v.mdns.last_error = null];
const b = await open(), before = n(b, 'summary').textContent;
for (const change of edits) {
const v = fixture(); change(v); b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
assert.match(n(b, 'detail').textContent, /stale.*invalid/); assert.equal(n(b, 'summary').textContent, before); assert.ok(n(b, 'apply').disabled); safe(b);
}
for (const value of [null, [], {}, {wifi: null}, {wifi: [], runtime: {}, mdns: {}}]) {
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); assert.ok(n(b, 'apply').disabled);
}
});
await test('Network snapshot 2048-byte/UTF-8/HTTP bounds and maximal escaped SSIDs remain safe text', async () => {
const b = await open();
for (const response of [new Response(' '.repeat(2049)), new Response(Uint8Array.of(255)), new Response('{'), failure(503), new Response(JSON.stringify(fixture()), {status: 202})]) {
b.queues[path].push(response); b.click('network-refresh'); await tick(); assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled); safe(b);
}
const v = fixture(); v.wifi.ap.ssid = '\xff'.repeat(32); v.wifi.generation = v.mdns.generation = 4294967295;
for (const p of v.wifi.profiles) p.ssid = '\xff'.repeat(32);
v.mdns.suffix = 'a'.repeat(55); v.mdns.hostname = 'sak-' + v.mdns.suffix;
const encoded = JSON.stringify(v).replace(/[\x7f-\uffff]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
assert.ok(Buffer.byteLength(encoded) < 2048); b.queues[path].push(new Response(encoded)); b.click('network-refresh'); await tick();
assert.equal(n(b, 'edit').hidden, false); assert.equal(n(b, 'apply').disabled, false); assert.equal(n(b, 'ssid-mode').value, 'hex');
v.wifi.ap.ssid = '<img onerror="x">'; b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
assert.equal(n(b, 'ssid').value, '<img onerror="x">'); assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify('<img onerror="x">')));
console.log('Network escaped snapshot fixture bytes:', Buffer.byteLength(encoded));
});
await test('Network ordinary Unicode text is UTF-8 once, escaped as byte codepoints, while unchanged existing UTF-8 is omitted', async () => {
for (const text of ['café', '東京📡', 'é'.repeat(16), 'a"b\\c', '\ufeffoffice']) {
const b = await open(); input(b, 'ssid', text); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
const post = posts(b)[0]; assert.ok(post); assert.ok(!/[^\x00-\x7f]/.test(post.body));
assert.deepEqual(Buffer.from(JSON.parse(post.body).ssid, 'latin1'), Buffer.from(text, 'utf8'));
assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
}
const v = fixture(); v.wifi.ap.ssid = Buffer.from('café📡', 'utf8').toString('latin1'); const b = await open(v);
assert.equal(n(b, 'ssid').value, 'café📡'); n(b, 'channel').value = '7'; b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
assert.deepEqual(JSON.parse(posts(b)[0].body), {action: 'wifi-patch', generation: 7, channel: 7});
});
await test('Network arbitrary bytes, embedded zero, invalid UTF-8 and BOM have lossless mode conversion or explicit refusal', async () => {
for (const bytes of ['A\0\xff', '\xc0\xaf', '\xed\xa0\x80', '\xff\xfe', '\r\n\t', '\xef\xbb\xbfhello']) {
const v = fixture(); v.wifi.ap.ssid = bytes; const b = await open(v);
if (bytes === '\xef\xbb\xbfhello') { assert.equal(n(b, 'ssid').value, '\ufeffhello'); input(b, 'ssid-mode', 'hex', 'change'); }
else {
assert.equal(n(b, 'ssid-mode').value, 'hex'); const before = n(b, 'ssid').value;
input(b, 'ssid-mode', 'text', 'change'); assert.equal(n(b, 'ssid-mode').value, 'hex'); assert.equal(n(b, 'ssid').value, before);
}
input(b, 'ssid', n(b, 'ssid').value + ' 42'); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
assert.equal(JSON.parse(posts(b)[0].body).ssid, bytes + 'B');
}
const b = await open(); input(b, 'ssid', 'café'); input(b, 'ssid-mode', 'hex', 'change'); assert.equal(n(b, 'ssid').value, '63 61 66 c3 a9');
input(b, 'ssid-mode', 'text', 'change'); assert.equal(n(b, 'ssid').value, 'café');
});
await test('Network SSID max32 decoded bytes and invalid hex/surrogates never submit or truncate', async () => {
for (const text of ['x'.repeat(33), 'é'.repeat(17), '📡'.repeat(9), '\ud800', '\udc00', 'x'.repeat(10000)]) {
const b = await open(); input(b, 'ssid', text); b.click('network-apply'); await tick(); assert.equal(posts(b).length, 0); assert.match(n(b, 'operation-detail').textContent, /Not submitted/);
}
for (const hex of ['f', 'gg', '0x41', '41 42', '41\t42', 'ff'.repeat(33), 'ff '.repeat(100)]) {
const b = await open(); input(b, 'ssid-mode', 'hex', 'change'); input(b, 'ssid', hex); b.click('network-apply'); await tick(); assert.equal(posts(b).length, 0);
}
const b = await open(); input(b, 'ssid-mode', 'hex', 'change'); input(b, 'ssid', '00'.repeat(32)); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
assert.equal(JSON.parse(posts(b)[0].body).ssid, '\0'.repeat(32)); assert.ok(Buffer.byteLength(posts(b)[0].body) <= 768);
});
await test('Network every byte00..FF round-trips without secret export or UTF-8 reinterpretation', async () => {
for (let start = 0; start < 256; start += 32) {
const bytes = String.fromCharCode(...Array.from({length:32}, (_, i) => start + i));
const v = fixture(); v.wifi.ap.ssid = bytes; const b = await open(v);
if (n(b, 'ssid-mode').value !== 'hex') input(b, 'ssid-mode', 'hex', 'change');
const expected = [...bytes].reverse().join(''); input(b, 'ssid', [...expected].map(c => c.charCodeAt(0).toString(16).padStart(2,'0')).join(' '));
b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
const wire = posts(b)[0].body; assert.ok(!/[^\x00-\x7f]/.test(wire)); assert.equal(JSON.parse(wire).ssid, expected);
}
});
await test('Network four stable profile targets and canonical input number/enum limits are typed and bounded', async () => {
for (let i = 0; i < 4; ++i) {
const b = await open(); target(b, String(i)); input(b, 'ssid', 'profile-' + i); input(b, 'priority', '255'); input(b, 'security', 'wpa3', 'change');
n(b, 'enabled').checked = true; n(b, 'enabled').change(); secret(b, 'p'.repeat(63)); b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'profile-patch',generation:7,profile:i,...(i === 0 ? {} : {enabled:true}),priority:255,security:'wpa3',ssid:'profile-' + i,password:'p'.repeat(63)});
assert.ok(Buffer.byteLength(posts(b)[0].body) <= 768);
}
for (const [id, values] of [['channel',['0','12','1.0','01','1e1','-1','9999']], ['priority',['-1','256','1.5','1e2','00']], ['security',['open','wpa2','<img>']], ['policy',['open','<img>']]]) {
for (const value of values) {
const b = await open(); if (['priority','security'].includes(id)) target(b,'1');
input(b,id,value,['policy','security'].includes(id) ? 'change' : 'input'); b.click('network-apply'); await tick(); assert.equal(posts(b).length,0);
}
}
const b = await open(); input(b,'ssid','é'.repeat(16)); assert.match(n(b,'ssid-detail').textContent,/32 \/ 32 bytes.*UTF-8/);
input(b,'ssid','é'.repeat(17)); assert.match(n(b,'ssid-detail').textContent,/exceeds 32 bytes/);
});
await test('Network all canonical runtime states and signed diagnostic endpoints remain truthful', async () => {
for (const state of ['stopped','starting','connecting','waiting-ip','online','backoff','ap-only','error','unknown']) {
const v=fixture(); v.runtime.state=state; v.runtime.active_profile=-1; v.runtime.last_error=-2147483648; v.mdns.last_error=2147483647;
const b=await open(v); assert.match(n(b,'summary').textContent,new RegExp('Runtime: '+state)); assert.equal(n(b,'apply').disabled,false);
assert.match(n(b,'summary').textContent,/-2147483648/); assert.match(n(b,'summary').textContent,/2147483647/);
}
});
await test('Network Keep defaults and omission of unchanged fields prevent accidental secret mutations', async () => {
const b = await open(); n(b, 'password').value = 'autofill must not replace'; input(b, 'channel', '7');
b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'wifi-patch', generation:7, channel:7}); clean(b);
const c = await open(); c.click('network-apply'); await tick(); assert.equal(posts(c).length, 0); assert.match(n(c, 'operation-detail').textContent, /No selected-target changes/);
target(c, '2'); input(c, 'priority', '255'); c.queues[operation].push(ack('profile-patch')); c.click('network-apply'); await tick();
assert.deepEqual(JSON.parse(posts(c)[0].body), {action:'profile-patch', generation:7, profile:2, priority:255});
});
await test('Network replacement ASCII bounds, escaped password and full escaped byte request remain below768 and wipe immediately', async () => {
for (const password of [' '.repeat(8), 'valid"\\pass', '\\'.repeat(63)]) {
const b = await open(); input(b, 'ssid-mode', 'hex', 'change'); input(b, 'ssid', 'ff '.repeat(31) + 'ff'); input(b, 'policy', 'always', 'change'); input(b, 'channel', '11'); n(b, 'boot').checked = false; n(b, 'boot').change();
secret(b, password); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); clean(b); await tick();
const post = posts(b)[0]; assert.equal(JSON.parse(post.body).password, password); assert.equal(JSON.parse(post.body).ssid, '\xff'.repeat(32)); assert.ok(Buffer.byteLength(post.body) <= 768); safe(b, password);
if (password.length === 63) console.log('Network fully escaped AP patch fixture bytes:', Buffer.byteLength(post.body));
}
for (const password of ['', 'a'.repeat(7), 'a'.repeat(64), 'é'.repeat(8), 'test\npass', 'test\x7fpass']) {
const b = await open(); secret(b, password); b.click('network-apply'); await tick(); clean(b); assert.equal(posts(b).length, 0);
}
});
await test('Network explicit STA disable+clear, empty SSID constraints, AP no-clear and enabled STA PSK constraints', async () => {
for (const which of ['ap', '0']) {
const b = await open(); target(b, which); n(b, 'password-mode').value = 'clear'; b.click('network-apply'); await tick(); clean(b); assert.equal(posts(b).length, 0);
}
const b = await open(); target(b, '0'); n(b, 'enabled').checked = false; n(b, 'enabled').change(); input(b, 'ssid', ''); input(b, 'password-mode', 'clear', 'change');
b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'profile-patch',generation:7,profile:0,enabled:false,ssid:'',clear_password:true});
const c = await open(); target(c, '1'); n(c, 'enabled').checked = true; n(c, 'enabled').change(); input(c, 'ssid', 'new office'); c.click('network-apply'); await tick(); assert.equal(posts(c).length, 0);
secret(c); c.queues[operation].push(ack('profile-patch')); c.click('network-apply'); await tick(); assert.equal(JSON.parse(posts(c)[0].body).enabled, true);
const d = await open(); input(d, 'ssid', ''); d.click('network-apply'); await tick(); assert.equal(posts(d).length, 0);
});
await test('Network transient PSK cleanup covers target, draft context, domain/view, refresh, logout, pagehide, session and timeout', async () => {
for (const mode of ['target','ssid','ssid-mode','priority','channel','boot','policy','enabled','security','suffix','password-mode','domain','view','refresh','pagehide','logout','identity','401','timeout']) {
const b = await open(); secret(b); assert.equal(n(b, 'password').value, 'a safe PSK');
if (mode === 'target') target(b, '1');
else if (['ssid','priority','channel','suffix'].includes(mode)) n(b, mode).input();
else if (mode === 'password-mode') input(b, 'password-mode', 'keep', 'change');
else if (['ssid-mode','boot','policy','enabled','security'].includes(mode)) n(b, mode).change();
else if (mode === 'domain') b.click('settings-accounts');
else if (mode === 'view') b.click('select-serial');
else if (mode === 'refresh') { b.queues[path].push(json(fixture())); b.click('network-refresh'); }
else if (mode === 'pagehide') b.emit('pagehide');
else if (mode === 'logout') b.click('sign-out');
else if (mode === 'identity' || mode === '401') { b.queues['/api/session'].push(mode === 'identity' ? session({role:'admin',username:'replacement'}) : failure(401)); b.click('network-refresh'); }
else { b.elapse(60000); b.fire(60000); }
await tick(); clean(b); safe(b, 'a safe PSK');
}
});
await test('Network delayed secret expiry/context mismatch rejects replacement, confirmation cancellation wipes without a request', async () => {
for (const mode of ['time','target','draft']) {
const b = await open(); secret(b);
if (mode === 'time') b.elapse(60000); else if (mode === 'target') n(b, 'target').value = '0'; else n(b, 'channel').value = '7';
b.click('network-apply'); await tick(); clean(b); assert.equal(posts(b).length, 0);
}
const b = await open(); secret(b); b.window.confirm = () => false; const count = b.calls.length; b.click('network-apply'); await tick(); clean(b); assert.equal(b.calls.length, count);
});
await test('Network exact generation/action fields, Save device-working-not-draft and RAM-only mDNS semantics', async () => {
for (const action of ['wifi-save','wifi-load','start','stop','reconnect','next-profile','mdns-set','mdns-save','mdns-load','mdns-defaults']) {
const b = await open(); input(b, 'ssid', 'UNAPPLIED DRAFT'); input(b, 'suffix', 'new-suffix'); secret(b);
const confirms = []; b.window.confirm = text => { confirms.push(text); return true; };
b.queues[operation].push(ack(action)); b.click('network-' + action); clean(b); await tick();
assert.deepEqual(JSON.parse(posts(b)[0].body), {action, ...(action.startsWith('wifi-') ? {generation:7} : action.startsWith('mdns-') ? {generation:3} : {}), ...(action === 'mdns-set' ? {suffix:'new-suffix'} : {})});
assert.equal(confirms.length, ['wifi-load','start','stop','reconnect','next-profile','mdns-load','mdns-defaults'].includes(action) ? 1 : 0);
if (['wifi-load','start','stop','reconnect','next-profile'].includes(action)) assert.match(confirms[0], /HTTPS.*BOTH.*NOT online.*UART0.*USB/);
const state = action.endsWith('-save') ? 'ok' : 'accepted'; await complete(b, action, state);
assert.equal(reads(b).length, 2); assert.ok(b.sockets.every(s => !s.closed));
assert.equal(n(b, 'ssid').value, 'access');
}
for (const suffix of ['', 'Upper', '-bad', 'bad-', 'a'.repeat(56), 'a.b', 'é', 'bad suffix']) {
const b = await open(); input(b, 'suffix', suffix); b.click('network-mdns-set'); await tick(); assert.equal(posts(b).length, 0);
}
});
await test('Network confirms only disruptive selected patch: disabled STA staging and boot-only do not prompt', async () => {
for (const mode of ['boot','disabled','enabled','ap']) {
const b = await open(), confirmations = []; b.window.confirm = s => { confirmations.push(s); return true; };
if (mode === 'boot') { n(b, 'boot').checked = false; n(b, 'boot').change(); }
else if (mode === 'ap') input(b, 'channel', '7');
else { target(b, mode === 'disabled' ? '1' : '0'); input(b, 'priority', '42'); }
const action = ['disabled','enabled'].includes(mode) ? 'profile-patch' : 'wifi-patch'; b.queues[operation].push(ack(action)); b.click('network-apply'); await tick();
assert.equal(confirmations.length, ['ap','enabled'].includes(mode) ? 1 : 0); assert.equal(posts(b).length, 1);
}
});
await test('Network pending leaves visible stale settings, single flight and accepted does not claim online', async () => {
const b = await open(), old = n(b, 'summary').textContent; input(b, 'channel', '7'); b.queues[operation].push(ack('wifi-patch'));
b.click('network-apply'); await tick(); const count = b.calls.length; b.click('network-apply'); b.click('network-refresh'); b.click('network-result'); await tick(); assert.equal(b.calls.length, count);
assert.equal(n(b, 'summary').textContent, old); assert.equal(n(b, 'edit').hidden, false); assert.ok(n(b, 'ssid').disabled); assert.match(n(b, 'detail').textContent, /stale/);
await complete(b, 'wifi-patch'); assert.match(n(b, 'operation-detail').textContent, /Accepted:.*NOT association, DHCP, online/); assert.match(n(b, 'summary').textContent, /Runtime: connecting/);
assert.equal(posts(b).length, 1); assert.equal(reads(b).length, 2); assert.equal(n(b, 'apply').disabled, false);
});
await test('Network all known terminal results refresh once; stale and applied_not_queued never auto-retry or imply rollback', async () => {
for (const [state, action] of [['failed','wifi-load'],['cancelled','stop'],['stale','wifi-patch'],['invalid','profile-patch'],['loaded_defaults','mdns-load'],['applied_not_queued','mdns-set'],['ok','mdns-save'],['accepted','reconnect']]) {
const b = await open(); b.queues[operation].push(reply(42,state,action,200,state === 'cancelled' ? 0 : 259)); const v = fixture(); v.mdns.last_error = 259; v.runtime.state = 'error'; v.runtime.last_error = 259;
b.queues[path].push(json(v)); b.click('network-result'); await tick(); assert.equal(reads(b).length, 2); assert.equal(posts(b).length, 0); assert.match(n(b, 'operation-detail').textContent, new RegExp('Error: ' + (state === 'cancelled' ? 0 : 259)));
if (state === 'stale') assert.match(n(b, 'operation-detail').textContent, /Generation stale.*No automatic retry/);
if (state === 'applied_not_queued') assert.match(n(b, 'operation-detail').textContent, /RAM changed.*queue failed.*NOT rolled back/);
if (state === 'loaded_defaults') assert.match(n(b, 'operation-detail').textContent, /mDNS Load.*defaults in RAM.*NVS unchanged/);
assert.match(n(b, 'summary').textContent, /Runtime: error/); assert.match(n(b, 'summary').textContent, /last error 259/);
}
const b = await open(); b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(failure(503)); b.click('network-result'); await tick();
assert.match(n(b, 'operation-detail').textContent, /Accepted/); assert.match(n(b, 'detail').textContent, /stale/); assert.equal(n(b, 'edit').hidden, false); assert.ok(n(b, 'apply').disabled);
});
await test('Network operation strict status/four-field128-byte shape/action/state/id/error validation rejects malformed ACKs and results', async () => {
const invalid = [null, [], {}, {id:42,action:'stop',state:'pending'}, {id:42,action:'stop',state:'pending',error:'SECRET'}, {id:42,action:'stop',state:'pending',error:0,password:'SECRET'},
{id:0,action:'stop',state:'idle',error:0}, {id:42,action:'none',state:'pending',error:0}, {id:42,action:'stop',state:'online',error:0}, {id:4294967296,action:'stop',state:'accepted',error:0},
{id:42,action:'stop',state:'ok',error:0}, {id:42,action:'wifi-save',state:'accepted',error:0}, {id:42,action:'stop',state:'loaded_defaults',error:0}, {id:42,action:'wifi-patch',state:'applied_not_queued',error:0},
{id:42,action:'stop',state:'pending',error:1}, {id:42,action:'stop',state:'failed',error:2147483648}];
const b = await open();
for (const value of invalid) { b.queues[operation].push(json(value)); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /unknown/); safe(b); }
for (const response of [new Response(' '.repeat(129)), new Response(Uint8Array.of(255)), reply(42,'pending','stop',202)]) { b.queues[operation].push(response); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /unknown/); }
for (const response of [reply(42,'pending','stop',200), reply(42,'accepted','stop',202), reply(42,'pending','start',202)]) {
const c = await open(); c.queues[operation].push(response); c.click('network-stop'); await tick(); assert.match(n(c, 'operation-detail').textContent, /unknown/); assert.equal(gets(c).length, 0); assert.equal(posts(c).length, 1);
}
});
await test('Network auto-check has ten GET maximum then manual-only recovery without mutation replay', async () => {
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick();
for (let i = 0; i < 10; ++i) { b.queues[operation].push(reply(42,'pending','stop')); b.elapse(1000); b.fire(1000); await tick(); }
assert.equal(gets(b).length, 10); assert.equal(posts(b).length, 1); assert.match(n(b, 'operation-detail').textContent, /Automatic checking stopped/); assert.equal(n(b, 'result').disabled, false); assert.ok(n(b, 'stop').disabled);
b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick(); assert.equal(reads(b).length, 2); assert.equal(posts(b).length, 1);
});
await test('Network fifteen-second auto deadline bounds delayed session and result reads and rejects late completion', async () => {
for (const where of ['session','result']) {
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); const d = deferred();
b.queues[where === 'session' ? '/api/session' : operation].push(d.promise); b.fire(1000); await tick();
b.elapse(15000); b.fire(15000); await tick(); d.resolve(where === 'session' ? session({role:'admin'}) : reply(42,'accepted','stop')); await tick();
assert.match(n(b, 'operation-detail').textContent, /unknown/); assert.equal(n(b, 'result').disabled, false); assert.equal(reads(b).length, 1); assert.equal(posts(b).length, 1);
}
});
await test('Network lost ACK, rejected POST and failed GET stop checking; manual latest result carries persistent uncertainty', async () => {
for (const response of [() => { throw new Error('SECRET lost ACK'); }, failure(400), failure(403), failure(503)]) {
const b = await open(); secret(b); b.queues[operation].push(response); b.click('network-apply'); await tick(); clean(b); safe(b);
assert.equal(posts(b).length, 1); assert.equal(gets(b).length, 0); assert.ok(n(b, 'apply').disabled); assert.match(n(b, 'operation-detail').textContent, /No automatic mutation retry/);
b.queues[operation].push(reply(41,'accepted','start')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick();
assert.match(n(b, 'operation-detail').textContent, /Acknowledgement lost.*earlier request/); assert.equal(posts(b).length, 1);
b.queues[operation].push(reply(41,'accepted','start')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /Acknowledgement lost/);
}
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.queues[operation].push(failure(503)); b.fire(1000); await tick();
assert.equal(posts(b).length, 1); assert.equal(gets(b).length, 1); assert.equal(n(b, 'result').disabled, false); assert.match(n(b, 'operation-detail').textContent, /manually/);
});
await test('Network replaced operation ID stops auto-following; same ID action mismatch is invalid; idle never proves cancellation', async () => {
for (const replacement of [reply(43,'pending','start'), reply(43,'accepted','start'), reply(0,'idle','none')]) {
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.queues[operation].push(replacement); b.queues[path].push(json(fixture())); b.fire(1000); await tick();
assert.match(n(b, 'operation-detail').textContent, /Previous result replaced.*unknown/); assert.equal(gets(b).length, 1); assert.equal(posts(b).length, 1);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000));
}
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.queues[operation].push(reply(42,'accepted','start')); b.fire(1000); await tick();
assert.match(n(b, 'operation-detail').textContent, /unknown/); assert.equal(reads(b).length, 1); assert.ok(n(b, 'stop').disabled);
});
await test('Network navigation fences pending snapshot, POST and GET headers/bodies; never resumes or replays on return', async () => {
for (const phase of ['snapshot','post','get']) for (const streamed of [false,true]) for (const exit of ['domain','view','pagehide']) {
const b = await open(); let stream; const d = deferred(); const response = streamed ? new Response(new ReadableStream({start(c) { stream = c; }}), {status: phase === 'post' ? 202 : 200}) : d.promise;
if (phase === 'snapshot') { b.queues[path].push(response); b.click('network-refresh'); }
else { b.queues[operation].push(phase === 'post' ? response : ack('stop')); b.click('network-stop'); await tick(); if (phase === 'get') { b.queues[operation].push(response); b.fire(1000); } }
await tick(); const request = b.calls.filter(c => c.url === (phase === 'snapshot' ? path : operation)).at(-1);
if (exit === 'domain') b.click('settings-accounts'); else if (exit === 'view') b.click('select-serial'); else b.emit('pagehide');
assert.ok(request.signal.aborted); const detail = n(b, 'operation-detail')?.textContent;
const result = phase === 'snapshot' ? fixture() : {id:42,action:'stop',state:phase === 'post' ? 'pending' : 'accepted',error:0};
if (streamed) { stream.enqueue(new TextEncoder().encode(JSON.stringify(result))); stream.close(); } else d.resolve(new Response(JSON.stringify(result), {status:phase === 'post' ? 202 : 200}));
await tick(); assert.equal(n(b, 'summary').textContent, ''); assert.equal(n(b, 'operation-detail')?.textContent, detail); clean(b);
const mutations = posts(b).length, resultReads = gets(b).length;
if (exit !== 'pagehide') {
b.queues[path].push(json(fixture())); b.click(exit === 'domain' ? 'settings-network' : 'select-settings'); await tick();
assert.equal(posts(b).length, mutations); assert.equal(gets(b).length, resultReads); assert.ok(b.sockets.every(s => !s.closed));
}
}
});
await test('Network pre-submit session cancellation wipes secrets and cannot submit after target or session identity changes', async () => {
for (const mode of ['target','context','domain','pagehide','identity','401']) {
const b = await open(); secret(b); const d = deferred(); b.queues['/api/session'].push(d.promise); b.click('network-apply'); clean(b); await tick();
if (mode === 'target') target(b, '1'); else if (mode === 'context') input(b, 'suffix', 'new-context'); else if (mode === 'domain') b.click('settings-accounts'); else if (mode === 'pagehide') b.emit('pagehide');
d.resolve(mode === '401' ? failure(401) : session({role:'admin', ...(mode === 'identity' ? {username:'newadmin'} : {})})); await tick();
assert.equal(posts(b).length, 0); clean(b); safe(b, 'a safe PSK');
if (mode === 'identity' || mode === '401') { assert.deepEqual(b.redirects,[mode === 'identity' ? '/' : '/login']); assert.ok(b.sockets.every(s => s.closed)); }
}
});
await test('Network endpoint401/session replacement wipe and close both routes without a success claim', async () => {
for (const where of ['snapshot','post','get','identity']) {
const b = await open(); secret(b);
if (where === 'snapshot') { b.queues[path].push(failure(401)); b.click('network-refresh'); }
else if (where === 'identity') { b.queues['/api/session'].push(session({role:'admin',csrf:'b'.repeat(64)})); b.click('network-apply'); }
else { b.queues[operation].push(where === 'post' ? failure(401) : ack('wifi-patch')); b.click('network-apply'); await tick(); if (where === 'get') { b.queues[operation].push(failure(401)); b.fire(1000); } }
await tick(); clean(b); assert.ok(b.sockets.every(s => s.closed)); assert.deepEqual(b.redirects, [where === 'identity' ? '/' : '/login']); assert.equal(n(b, 'summary').textContent, '');
assert.doesNotMatch(n(b, 'operation-detail')?.textContent || '', /Accepted:|completed successfully/); assert.equal(b.timers.size, 0);
}
});
await test('Network request timeouts and stale errors release single-flight state without retry or late login navigation', async () => {
for (const kind of ['snapshot','post','get']) {
const b = await open(); const response = o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('SECRET timeout'))));
if (kind === 'snapshot') { b.queues[path].push(response); b.click('network-refresh'); }
else if (kind === 'post') { b.queues[operation].push(response); b.click('network-stop'); }
else { b.queues[operation].push(response); b.click('network-result'); }
await tick(); b.fire(15000); await tick(); assert.equal(n(b, 'result').disabled, false); assert.ok(n(b, 'stop').disabled); safe(b);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000)); assert.equal(posts(b).length, kind === 'post' ? 1 : 0);
}
const b = await open(), d = deferred(); b.queues[path].push(d.promise); b.click('network-refresh'); await tick(); b.click('settings-accounts'); await tick(); d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); assert.ok(b.sockets.every(s => !s.closed));
});
await test('Network request ownership remains independent from account/serial outcomes and reconnect session validation', async () => {
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.click('settings-accounts'); await tick();
const account = '/api/settings/account-operation'; b.queues[account].push(json({id:99,action:'role',state:'pending'})); b.click('account-change-role'); await tick(); b.click('settings-network'); await tick();
b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /stop: Accepted/);
assert.match(b.nodes['account-operation-detail'].textContent, /pending or unknown/); assert.equal(posts(b).length, 1);
const c = await open(), d = deferred(); c.queues['/api/session'].push(d.promise); c.click('network-refresh'); await tick(); c.click('connection-toggle'); c.click('connection-toggle'); await tick();
d.resolve(session({role:'admin'})); await tick(); assert.equal(c.sockets.length, 3); assert.ok(!c.sockets[1].closed); assert.equal(posts(c).length, 0);
});
};
+1
View File
@@ -93,6 +93,7 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
(tmp / 'rendered.json').write_text(json.dumps(rendered))
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)
print('PASS C/HTML: all resource headers/failures, no-store app/document, exact loader CSP, safe fallback')
print(f'Rendered response bytes: HTML={len(rendered["html"].encode())}, app.js={len(rendered["script"].encode())}, inline loader={len(rendered["loader"].encode())}')
if __name__ == '__main__':