Implement HTTPS lifecycle and reboot controls

This commit is contained in:
2026-09-13 17:24:00 +02:00
parent 737bd29f9e
commit 36e80811e8
24 changed files with 1392 additions and 75 deletions
+2
View File
@@ -31,6 +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 lifecycle_settings_executed;
static void web_lifecycle_settings_execute(uint32_t id) { assert(!lock_depth); lifecycle_settings_executed = id; }
static uint32_t ssh_settings_executed;
static void web_ssh_settings_execute(uint32_t id) { assert(!lock_depth); ssh_settings_executed = id; }
static uint32_t broker_settings_executed;
+15
View File
@@ -409,5 +409,20 @@ int main(void)
assert(ssh_settings_executed == 61 && broker_settings_executed == 62 && network_settings_executed == 63 && account_settings_executed == 64);
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
puts("PASS: SSH typed ID dispatcher routing, zero-wait/full/not-ready, no command runner or capacity growth");
assert(admin_ssh_console_submit_lifecycle_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_lifecycle_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_lifecycle_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
queue_full = false;
assert(admin_ssh_console_submit_lifecycle_settings(71) == ESP_OK);
assert(admin_ssh_console_submit_ssh_settings(72) == ESP_OK);
assert(admin_ssh_console_submit_network_settings(73) == ESP_OK);
assert(admin_ssh_console_submit_account_settings(74) == ESP_OK);
assert(admin_ssh_console_submit_lifecycle_settings(75) == ESP_ERR_TIMEOUT);
pump(worker_task);
assert(lifecycle_settings_executed == 71 && ssh_settings_executed == 72 && network_settings_executed == 73 && account_settings_executed == 74);
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
puts("PASS: Lifecycle typed ID routing, zero-wait/full/not-ready; unchanged runner and four-entry queue");
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");
}
@@ -0,0 +1,151 @@
/* Compiled after real web_server lifecycle. HTTP/auth/scheduler dependencies are
* doubles; ACK/operation functions below are extracted verbatim from production. */
typedef uint64_t web_session_id_t;
typedef struct { unsigned role; } user_principal_t;
#define USER_ROLE_ADMIN 1
#define portMUX_INITIALIZER_UNLOCKED 0
typedef unsigned portMUX_TYPE;
#define taskENTER_CRITICAL(lock) do { assert(!*(lock)); ++*(lock); } while (0)
#define taskEXIT_CRITICAL(lock) do { assert(*(lock) == 1); --*(lock); } while (0)
static int64_t pipeline_now;
static bool pipeline_current = true, pipeline_queue_fail;
static unsigned pipeline_reboots, pipeline_submits;
static uint32_t pipeline_id;
static void (*validation_hook)(void);
static int64_t esp_timer_get_time(void) { return pipeline_now; }
static esp_err_t web_session_store_check_principal(web_session_id_t session, const user_principal_t *principal, bool *current) {
assert(!locked && session == 1 && principal->role == USER_ROLE_ADMIN);
if (validation_hook) { void (*hook)(void) = validation_hook; validation_hook = NULL; hook(); }
*current = pipeline_current && auth_live; return ESP_OK;
}
static esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id) {
assert(!locked && id); ++pipeline_submits;
if (pipeline_queue_fail) return ESP_ERR_TIMEOUT;
pipeline_id = id; return ESP_OK;
}
static void esp_restart(void) {
assert(!locked && s_transitioning && s_server == SERVER);
++pipeline_reboots;
}
/* PRODUCTION_PIPELINE */
static void pipeline_reset(void) {
memset(&s_operation, 0, sizeof(s_operation)); s_ack_id = 0; s_ack_server = NULL;
pipeline_now = 0; pipeline_current = true; pipeline_queue_fail = false;
pipeline_reboots = pipeline_submits = 0; pipeline_id = 0; validation_hook = NULL;
reset(); start();
}
static uint32_t pipeline_admit(unsigned action) {
assert(s_operation.state != PENDING && s_operation.state != EXECUTING && !s_ack_id);
s_operation = (lifecycle_operation_t){.id=++s_next_id, .generation=s_generation,
.session=1, .principal={USER_ROLE_ADMIN}, .ack_deadline=pipeline_now+2000000,
.deadline=pipeline_now+30000000, .action=action, .state=PENDING};
s_ack_id = s_operation.id; s_ack_server = SERVER;
return s_operation.id;
}
static void pipeline_callback(uint32_t id) {
unsigned starts = ssl_starts, stops = ssl_stops, reboots = pipeline_reboots;
ack_handoff((void *)(uintptr_t)id);
assert(ssl_starts == starts && ssl_stops == stops && pipeline_reboots == reboots);
}
static void validation_aba(void) {
assert(web_server_stop() == ESP_OK); fresh_registration(); start();
}
static void pipeline_tests(void) {
for (unsigned action = 0; action < 3; ++action) {
pipeline_reset(); uint32_t id = pipeline_admit(action), generation = s_generation;
web_lifecycle_settings_execute(id); assert(!ssl_stops && !pipeline_reboots);
pipeline_callback(id); assert(pipeline_id == id && s_operation.queued && !s_ack_id);
pipeline_callback(id); assert(pipeline_submits == 1);
if (action == 1) fresh_registration();
web_lifecycle_settings_execute(id);
assert(s_operation.state == (action == 2 ? FAILED : OK));
assert(s_generation == generation + (action == 1 ? 2 : 1));
if (action == 0) assert(!s_server && !auth_live && !ssl_live);
if (action == 1) assert(auth_live && ssl_live && !s_transitioning && ssl_starts == 2);
if (action == 2) assert(pipeline_reboots == 1 && s_transitioning && !ssl_stops);
web_lifecycle_settings_execute(id); pipeline_callback(id);
assert(pipeline_submits == 1 && pipeline_reboots == (action == 2 ? 1U : 0U));
}
puts("PASS real ACK-ID-dispatch-to-canonical stop/reserved restart/reboot; no lifecycle on callback or duplicate IDs");
for (unsigned failure = 0; failure < 4; ++failure) {
pipeline_reset(); uint32_t id = pipeline_admit(1);
if (failure == 0) idle_detach_error = ESP_ERR_TIMEOUT;
if (failure == 1) admin_detach_error = ESP_ERR_TIMEOUT;
if (failure == 2) serial_detach_error = ESP_ERR_INVALID_STATE;
if (failure == 3) ssl_stop_error = ESP_FAIL;
pipeline_callback(id); web_lifecycle_settings_execute(id);
assert(s_operation.state == FAILED && s_server == SERVER && !auth_live && ssl_starts == 1);
assert(!s_transitioning && !idle_stoppeds && !admin_stoppeds);
web_lifecycle_settings_execute(id); assert(ssl_starts == 1);
idle_detach_error = admin_detach_error = serial_detach_error = ssl_stop_error = ESP_OK;
assert(web_server_stop() == ESP_OK);
}
puts("PASS real ACK dispatcher lifecycle failure retains owners, skips restart and never mislabels admitted invalid-state as cancellation");
pipeline_reset(); uint32_t old = pipeline_admit(0); pipeline_now = 2000000;
expire_locked(pipeline_now); assert(s_operation.state == CANCELLED && s_ack_id == old);
ssl_stop_error = ESP_FAIL; assert(web_server_stop() == ESP_FAIL && s_ack_id == old);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK && !s_ack_id);
fresh_registration(); start(); uint32_t next = pipeline_admit(1);
pipeline_callback(old); assert(s_ack_id == next && !pipeline_submits);
pipeline_callback(next); fresh_registration(); web_lifecycle_settings_execute(next);
assert(s_operation.state == OK && ssl_starts == 3);
puts("PASS lost/delayed ACK reservation survives failed stop, retires only after successful destruction; same-handle callback ABA inert");
for (unsigned mode = 0; mode < 5; ++mode) {
pipeline_reset(); uint32_t id = pipeline_admit(2);
if (mode == 0) pipeline_now = 2000000;
if (mode == 1) pipeline_queue_fail = true;
pipeline_callback(id);
if (mode == 2) pipeline_current = false;
if (mode == 3) pipeline_now = 30000000;
if (mode == 4) validation_hook = validation_aba;
web_lifecycle_settings_execute(id);
assert(!pipeline_reboots && s_operation.state == (mode == 4 ? FAILED : CANCELLED));
assert(ssl_starts == (mode == 4 ? 2U : 1U));
}
puts("PASS actual reboot denied on ACK/dequeue expiry, queue failure, revoked login and canonical ABA during validation");
pipeline_reset(); uint32_t generation = s_generation;
assert(web_server_reboot_current(0) == ESP_ERR_INVALID_ARG);
mutex_busy = true; assert(web_server_reboot_current(generation) == ESP_ERR_TIMEOUT); mutex_busy = false;
assert(web_server_reboot_current(generation + 1) == ESP_ERR_INVALID_STATE);
s_transitioning = true; assert(web_server_reboot_current(generation) == ESP_ERR_INVALID_STATE); s_transitioning = false;
s_last_error = ESP_FAIL; assert(web_server_reboot_current(generation) == ESP_ERR_INVALID_STATE); s_last_error = ESP_OK;
s_generation = UINT32_MAX; assert(web_server_reboot_current(UINT32_MAX) == ESP_ERR_INVALID_STATE);
s_generation = generation; assert(!pipeline_reboots);
assert(web_server_reboot_current(generation) == ESP_FAIL && pipeline_reboots == 1);
assert(web_server_reboot_current(generation + 1) == ESP_ERR_INVALID_STATE && pipeline_reboots == 1);
assert(web_server_start() == ESP_ERR_INVALID_STATE && web_server_stop() == ESP_ERR_INVALID_STATE);
puts("PASS canonical reboot zero-wait/current-generation admission reserves transition before esp_restart and cannot duplicate on unexpected return");
for (unsigned failure = 1; failure <= 6; ++failure) {
reset(); lifecycle_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(lifecycle_calls == failed_route && lifecycle_allocations == failure);
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
assert(!method_route("/api/settings/lifecycle-operation", HTTP_GET));
assert(!!method_route("/api/settings/lifecycle", HTTP_GET) == (failed_route != 1));
other_domains_complete(); network_complete(); display_complete(); broker_complete(); ssh_complete();
assert(web_server_stop() == ESP_OK);
lifecycle_fail_at = 0; fresh_registration(); start(); assert(registered_count == 39);
assert(method_route("/api/settings/lifecycle-operation", HTTP_POST)->handler == web_lifecycle_operation_handler);
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six lifecycle route allocation positions preserve other domains and restart recovers the complete optional API");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); lifecycle_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 38 && unregister_calls == 1);
assert(method_route("/api/settings/lifecycle-operation", HTTP_GET));
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
other_domains_complete(); ssh_complete();
ssl_stop_error = ESP_FAIL; assert(web_server_stop() == ESP_FAIL && s_server == SERVER);
assert(web_server_start() == ESP_ERR_INVALID_STATE);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; lifecycle_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39 && web_server_stop() == ESP_OK);
}
puts("PASS lifecycle failed unregister leaves reads only; failed shutdown preserves ownership before successful restart");
}
+249 -46
View File
@@ -19,7 +19,7 @@ source = SOURCE.read_text()
def function(name):
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\(void\)\n\{.*?^\}',
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\([^\n]*\)\n\{.*?^\}',
source, re.M | re.S)
if not match:
raise RuntimeError('Production function shape changed: ' + name)
@@ -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) != 33:
raise RuntimeError('Review URI extraction: expected 31 descriptors and two tables')
if len(uri_tables) != 36:
raise RuntimeError('Review URI extraction: expected 34 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()
@@ -61,7 +61,8 @@ FAKES = r'''
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT };
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_INVALID_ARG };
#define pdTRUE 1
typedef void *SemaphoreHandle_t;
typedef void *httpd_handle_t;
typedef struct { int unused; } httpd_req_t;
@@ -83,10 +84,16 @@ typedef struct {
} httpd_ssl_config_t;
/* Nonproduction defaults deliberately make explicit overrides observable. */
#define HTTPD_SSL_CONFIG_DEFAULT() ((httpd_ssl_config_t){.httpd = {.max_open_sockets = 1, .lru_purge_enable = true}})
#define portMAX_DELAY 0
#define portMAX_DELAY 99
static int mutex_storage, server_storage, locked;
#define SERVER ((void *)&server_storage)
static bool mutex_fail, auth_live, ssl_live, admin_owned, serial_live;
static void esp_restart(void);
void web_lifecycle_settings_stopped(httpd_handle_t server);
static bool mutex_fail, auth_live, ssl_live, admin_owned, serial_live, mutex_busy;
static void (*unlock_hook)(void);
static esp_err_t serial_detach_error;
static unsigned ssl_stop_fail_at;
static void web_cookie_auth_clear_counters(void) {}
static esp_err_t serial_init_error, admin_init_error, admin_attach_error;
static esp_err_t auth_error, ssl_start_error, ssl_stop_error, admin_detach_error;
static unsigned serial_inits, admin_inits, auth_starts, auth_stops;
@@ -97,12 +104,18 @@ static bool unregister_fail;
static bool settings_fail;
static unsigned settings_calls;
static unsigned operation_calls, operation_fail_at;
static const httpd_uri_t *registered[36];
static const httpd_uri_t *registered[39];
static char events[128]; static size_t event_length;
static void event(char value) { assert(!locked && event_length + 1 < sizeof(events)); events[event_length++] = value; events[event_length] = 0; }
static SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!locked); return mutex_fail ? NULL : &mutex_storage; }
static void xSemaphoreTake(SemaphoreHandle_t m, int wait) { (void)wait; assert(m && !locked); locked = 1; }
static void xSemaphoreGive(SemaphoreHandle_t m) { assert(m && locked); locked = 0; }
static int xSemaphoreTake(SemaphoreHandle_t m, int wait) {
assert(m && !locked);
if (mutex_busy) { assert(wait == 0); return 0; }
locked = 1; return pdTRUE;
}
static void xSemaphoreGive(SemaphoreHandle_t m) {
assert(m && locked); locked = 0; if (unlock_hook) unlock_hook();
}
static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); }
#define HANDLER(name) static esp_err_t name(httpd_req_t *r) { (void)r; assert(!"HTTP handler must not run in lifecycle harness"); return ESP_FAIL; }
HANDLER(root_handler) HANDLER(status_handler) HANDLER(traced_ticket_handler)
@@ -154,6 +167,25 @@ static esp_err_t display_register(httpd_handle_t s, const httpd_uri_t *uri) {
registered[registered_count++] = uri;
return ESP_OK;
}
HANDLER(web_lifecycle_settings_handler) HANDLER(web_lifecycle_operation_handler)
static unsigned lifecycle_calls, lifecycle_allocations, lifecycle_fail_at;
static esp_err_t lifecycle_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);
++lifecycle_calls;
if (lifecycle_calls == 1) {
assert(!strcmp(uri->uri, "/api/settings/lifecycle") && uri->method == HTTP_GET);
assert(uri->handler == web_lifecycle_settings_handler);
} else {
assert(!strcmp(uri->uri, "/api/settings/lifecycle-operation"));
assert(uri->method == (lifecycle_calls == 2 ? HTTP_GET : HTTP_POST));
assert(uri->handler == web_lifecycle_operation_handler && lifecycle_calls <= 3);
}
for (unsigned allocation = 0; allocation < 2; ++allocation)
if (++lifecycle_allocations == lifecycle_fail_at) return ESP_ERR_NO_MEM;
registered[registered_count++] = uri;
return ESP_OK;
}
HANDLER(web_ssh_settings_handler) HANDLER(web_ssh_operation_handler)
static unsigned ssh_calls, ssh_allocations, ssh_fail_at;
static esp_err_t ssh_register(httpd_handle_t s, const httpd_uri_t *uri) {
@@ -229,7 +261,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 == 36 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 39 && 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);
@@ -257,7 +289,7 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
assert(serial_init_error != ESP_OK || serial_live);
} else assert(registration_calls < 14);
esp_err_t error = register_one(s);
if (error == ESP_OK) { assert(registered_count < 36); registered[registered_count++] = uri; }
if (error == ESP_OK) { assert(registered_count < 39); registered[registered_count++] = uri; }
return error;
}
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
@@ -267,6 +299,7 @@ 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_lifecycle_settings_handler || uri->handler == web_lifecycle_operation_handler) return lifecycle_register(s, uri);
if (uri->handler == web_ssh_settings_handler || uri->handler == web_ssh_operation_handler) return ssh_register(s, uri);
if (uri->handler == web_broker_settings_handler || uri->handler == web_broker_operation_handler) return broker_register(s, uri);
if (uri->handler == web_display_settings_handler || uri->handler == web_display_operation_handler) return display_register(s, uri);
@@ -277,6 +310,7 @@ static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_u
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_lifecycle_settings_handler || uri->handler == web_lifecycle_operation_handler) return lifecycle_register(s, uri);
if (uri->handler == web_ssh_settings_handler || uri->handler == web_ssh_operation_handler) return ssh_register(s, uri);
if (uri->handler == web_broker_settings_handler || uri->handler == web_broker_operation_handler) return broker_register(s, uri);
if (uri->handler == web_display_operation_handler) return display_register(s, uri);
@@ -306,7 +340,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") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation") || !strcmp(uri, "/api/settings/ssh-operation")) && method == HTTP_GET));
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation") || !strcmp(uri, "/api/settings/ssh-operation") || !strcmp(uri, "/api/settings/lifecycle-operation")) && method == HTTP_GET));
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
@@ -341,10 +375,13 @@ static esp_err_t web_admin_transport_detach(httpd_handle_t s) {
}
static esp_err_t web_serial_transport_detach_server(httpd_handle_t s) {
assert(s == SERVER && ssl_live && serial_live && !auth_live);
event('S'); ++serial_detaches; serial_live = false; return ESP_OK;
event('S'); ++serial_detaches;
if (serial_detach_error != ESP_OK && serial_detach_error != ESP_ERR_TIMEOUT) return serial_detach_error;
serial_live = false; return serial_detach_error;
}
static esp_err_t httpd_ssl_stop(httpd_handle_t s) {
assert(s == SERVER && ssl_live && !auth_live && idle_fenced); event('H'); ++ssl_stops;
if (ssl_stops == ssl_stop_fail_at) return ESP_FAIL;
if (ssl_stop_error == ESP_OK) ssl_live = false;
return ssl_stop_error;
}
@@ -359,6 +396,8 @@ static void clear_events(void) { event_length = 0; events[0] = 0; }
static void reset(void) {
assert(!locked);
s_server_mutex = NULL; s_server = NULL; s_initialized = s_transitioning = false;
s_generation = 1U; mutex_busy = false; unlock_hook = NULL; serial_detach_error = ESP_OK;
ssl_stop_fail_at = 0;
s_serial_transport_init_attempted = s_serial_transport_initialized = false;
s_serial_transport_attached = s_admin_transport_owned = false;
s_last_error = s_serial_transport_error = ESP_ERR_INVALID_STATE;
@@ -378,6 +417,7 @@ static void reset(void) {
display_calls = display_allocations = display_fail_at = 0;
broker_calls = broker_allocations = broker_fail_at = 0;
ssh_calls = ssh_allocations = ssh_fail_at = 0;
lifecycle_calls = lifecycle_allocations = lifecycle_fail_at = 0;
account_calls = account_fail_at = generation_calls = keys_calls = 0;
generation_fail = keys_fail = false;
}
@@ -387,6 +427,7 @@ static void fresh_registration(void) {
display_calls = display_allocations = 0;
broker_calls = broker_allocations = 0;
ssh_calls = ssh_allocations = 0;
lifecycle_calls = lifecycle_allocations = 0;
}
static void start(void) {
assert(web_server_start() == ESP_OK);
@@ -474,7 +515,7 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 36 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
reset(); start(); assert(registered_count == 39 && 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);
@@ -528,7 +569,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 == 34 && unregister_calls == failure - 17);
assert(registered_count == 37 && 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);
@@ -537,13 +578,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 == 36 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 39 && 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 == 35);
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 38);
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");
@@ -555,7 +596,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 == 36 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 39 && 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;
@@ -577,7 +618,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 == 35);
assert(settings_calls == 1 && registered_count == 38);
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);
@@ -585,7 +626,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 == 34 && operation_calls == failure && unregister_calls == failure - 1);
assert(registered_count == 37 && 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);
@@ -593,7 +634,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 ? 33 : 34));
assert(account_calls == failure && registered_count == (failure == 1 ? 36 : 37));
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);
@@ -601,17 +642,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 == 36 && account_calls == 3);
assert(registered_count == 39 && account_calls == 3);
assert(web_server_stop() == ESP_OK);
}
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
assert(registered_count == 35 && auth_live && serial_live && admin_owned);
assert(registered_count == 38 && 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 == 35 && account_calls == 3);
assert(generation_calls == 1 && registered_count == 38 && 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);
@@ -623,12 +664,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 == 36);
assert(generation_calls == 2 && registered_count == 39);
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 == 35 && account_calls == 3 && generation_calls == 1);
assert(keys_calls == 1 && registered_count == 38 && 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);
@@ -643,7 +684,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 == 36);
assert(keys_calls == 2 && registered_count == 39);
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");
@@ -668,7 +709,7 @@ int main(void) {
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 ? 33 : 34));
assert(registered_count == (failed_route == 1 ? 36 : 37));
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));
@@ -676,13 +717,13 @@ int main(void) {
other_domains_complete();
assert(web_server_stop() == ESP_OK);
network_fail_at = 0; fresh_registration(); start();
assert(registered_count == 36); network_complete();
assert(registered_count == 39); 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 == 35 && unregister_calls == 1);
assert(registered_count == 38 && 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));
@@ -692,7 +733,7 @@ int main(void) {
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 == 36); network_complete();
assert(registered_count == 39); network_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -700,7 +741,7 @@ int main(void) {
reset(); display_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(display_calls == failed_route && display_allocations == failure);
assert(registered_count == (failed_route == 1 ? 33 : 34));
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/display-operation", HTTP_GET));
assert(!method_route("/api/settings/display-operation", HTTP_POST));
@@ -708,13 +749,13 @@ int main(void) {
other_domains_complete(); network_complete();
assert(web_server_stop() == ESP_OK);
display_fail_at = 0; fresh_registration(); start();
assert(registered_count == 36); display_complete();
assert(registered_count == 39); display_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); display_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 35 && unregister_calls == 1);
assert(registered_count == 38 && unregister_calls == 1);
assert(route("/api/settings/display")->handler == web_display_settings_handler);
assert(method_route("/api/settings/display-operation", HTTP_GET)->handler == web_display_operation_handler);
assert(!method_route("/api/settings/display-operation", HTTP_POST));
@@ -724,7 +765,7 @@ int main(void) {
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; display_fail_at = 0; fresh_registration(); start();
assert(registered_count == 36); display_complete();
assert(registered_count == 39); display_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -732,7 +773,7 @@ int main(void) {
reset(); broker_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(broker_calls == failed_route && broker_allocations == failure);
assert(registered_count == (failed_route == 1 ? 33 : 34));
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/broker-operation", HTTP_GET));
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
@@ -740,13 +781,13 @@ int main(void) {
other_domains_complete(); network_complete(); display_complete();
assert(web_server_stop() == ESP_OK);
broker_fail_at = 0; fresh_registration(); start();
assert(registered_count == 36); broker_complete();
assert(registered_count == 39); broker_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Broker descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); broker_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 35 && unregister_calls == 1);
assert(registered_count == 38 && unregister_calls == 1);
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
assert(method_route("/api/settings/broker-operation", HTTP_GET)->handler == web_broker_operation_handler);
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
@@ -756,7 +797,7 @@ int main(void) {
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; broker_fail_at = 0; fresh_registration(); start();
assert(registered_count == 36); broker_complete();
assert(registered_count == 39); broker_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Broker result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -764,7 +805,7 @@ int main(void) {
reset(); ssh_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(ssh_calls == failed_route && ssh_allocations == failure);
assert(registered_count == (failed_route == 1 ? 33 : 34));
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/ssh-operation", HTTP_GET));
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
@@ -772,13 +813,13 @@ int main(void) {
other_domains_complete(); network_complete(); display_complete(); broker_complete();
assert(web_server_stop() == ESP_OK);
ssh_fail_at = 0; fresh_registration(); start();
assert(registered_count == 36); ssh_complete();
assert(registered_count == 39); ssh_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six SSH descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); ssh_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 35 && unregister_calls == 1);
assert(registered_count == 38 && unregister_calls == 1);
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
assert(method_route("/api/settings/ssh-operation", HTTP_GET)->handler == web_ssh_operation_handler);
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
@@ -788,7 +829,7 @@ int main(void) {
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; ssh_fail_at = 0; fresh_registration(); start();
assert(registered_count == 36); ssh_complete();
assert(registered_count == 39); ssh_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed SSH result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -802,18 +843,180 @@ int main(void) {
start(); network_complete(); display_complete(); broker_complete(); ssh_complete(); assert(web_server_stop() == ESP_OK);
}
puts("PASS every other settings route failure leaves the complete Network domain available");
puts("27 lifecycle groups passed (16 required fatal positions, 22 optional routes, Network/Display/Broker/SSH allocation positions and failed unregister)");
management_tests();
pipeline_tests();
puts("41 lifecycle groups passed (34 prior owner/route groups plus 7 lifecycle integration groups)");
return 0;
}
'''
MANAGEMENT_TESTS = r'''
static unsigned reserved_gaps;
static void observe_restart_gap(void) {
if (s_server || !s_transitioning) return;
unlock_hook = NULL;
++reserved_gaps;
web_server_management_snapshot_t snapshot;
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK);
assert(!snapshot.running && snapshot.transitioning && !snapshot.controllable);
unsigned starts = ssl_starts, stops = ssl_stops, auth = auth_starts;
uint32_t generation = snapshot.generation;
assert(web_server_start() == ESP_ERR_INVALID_STATE);
assert(web_server_stop() == ESP_ERR_INVALID_STATE);
assert(web_server_stop_current(generation) == ESP_ERR_INVALID_STATE);
assert(web_server_restart_current(generation) == ESP_ERR_INVALID_STATE);
assert(ssl_starts == starts && ssl_stops == stops && auth_starts == auth);
assert(s_generation == generation && s_transitioning && !s_server);
}
static void management_tests(void) {
web_server_management_snapshot_t snapshot;
reset(); memset(&snapshot, 0xa5, sizeof(snapshot));
assert(web_server_get_management_snapshot(NULL) == ESP_ERR_INVALID_ARG);
assert(web_server_get_management_snapshot(&snapshot) == ESP_ERR_INVALID_STATE);
assert(!snapshot.generation && !snapshot.running && !snapshot.controllable);
assert(web_server_stop_current(0) == ESP_ERR_INVALID_ARG);
assert(web_server_restart_current(0) == ESP_ERR_INVALID_ARG);
assert(web_server_stop_current(1) == ESP_ERR_INVALID_STATE);
assert(web_server_restart_current(1) == ESP_ERR_INVALID_STATE);
assert(web_server_init() == ESP_OK);
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK);
assert(snapshot.generation == 1 && !snapshot.running && !snapshot.controllable);
start(); assert(web_server_get_management_snapshot(&snapshot) == ESP_OK);
assert(snapshot.generation == 2 && snapshot.running && !snapshot.transitioning && snapshot.controllable);
mutex_busy = true;
assert(web_server_get_management_snapshot(&snapshot) == ESP_ERR_TIMEOUT);
assert(!snapshot.generation && !snapshot.running && !snapshot.controllable);
assert(web_server_stop_current(2) == ESP_ERR_TIMEOUT);
assert(web_server_restart_current(2) == ESP_ERR_TIMEOUT);
mutex_busy = false;
assert(s_generation == 2 && !auth_stops && !ssl_stops);
assert(web_server_stop_current(2) == ESP_OK);
puts("PASS management snapshot preinit/zero-wait contention and argument rejection without lifecycle effects");
reset(); start(); uint32_t original = s_generation;
assert(web_server_clear_counters() == ESP_OK && s_generation == original);
assert(web_server_stop() == ESP_OK);
fresh_registration(); start(); assert(s_server == SERVER && s_generation == original + 2);
clear_events();
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
assert(web_server_restart_current(original) == ESP_ERR_INVALID_STATE);
assert(!event_length && auth_live && ssl_live && s_generation == original + 2);
assert(web_server_stop_current(s_generation) == ESP_OK);
assert(web_server_stop_current(s_generation) == ESP_ERR_INVALID_STATE);
puts("PASS canonical stop/start same-handle ABA and counter-clear generation fences");
reset(); start(); original = s_generation; reserved_gaps = 0;
fresh_registration(); clear_events(); unlock_hook = observe_restart_gap;
assert(web_server_restart_current(original) == ESP_OK);
assert(reserved_gaps == 1 && !unlock_hook && !strcmp(events, "ADSHR"));
assert(s_generation == original + 2 && s_server == SERVER && !s_transitioning);
assert(auth_live && ssl_live && admin_owned && serial_live && idle_owned);
assert(ssl_starts == 2 && ssl_stops == 1 && serial_inits == 1 && registered_count == 39);
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
assert(web_server_stop_current(s_generation) == ESP_OK);
puts("PASS conditional restart reserves stop-to-start gap against canonical and conditional callers");
for (unsigned failure = 0; failure < 4; ++failure) {
reset(); start(); original = s_generation;
if (failure == 0) idle_detach_error = ESP_ERR_TIMEOUT;
if (failure == 1) admin_detach_error = ESP_ERR_TIMEOUT;
if (failure == 2) serial_detach_error = ESP_ERR_INVALID_STATE;
if (failure == 3) ssl_stop_error = ESP_FAIL;
assert(web_server_restart_current(original) != ESP_OK);
assert(s_generation == original + 1 && !s_transitioning && s_server == SERVER);
assert(ssl_starts == 1 && auth_starts == 1 && !auth_live && !idle_stoppeds && !admin_stoppeds);
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && !snapshot.controllable);
assert(web_server_start() == ESP_ERR_INVALID_STATE);
assert(web_server_init() == ESP_OK);
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && !snapshot.controllable);
clear_events();
assert(web_server_stop_current(s_generation) == ESP_ERR_INVALID_STATE);
assert(web_server_restart_current(s_generation) == ESP_ERR_INVALID_STATE);
assert(!event_length && s_generation == original + 1);
idle_detach_error = admin_detach_error = serial_detach_error = ssl_stop_error = ESP_OK;
assert(web_server_stop() == ESP_OK && !s_server && !s_transitioning);
fresh_registration(); start();
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && snapshot.controllable);
assert(web_server_stop_current(snapshot.generation) == ESP_OK);
}
puts("PASS all stop failure stages skip restart, retain ownership and require canonical cleanup even after init");
for (unsigned failure = 0; failure < 6; ++failure) {
reset(); start(); original = s_generation; fresh_registration();
if (failure == 0) auth_error = ESP_FAIL;
if (failure == 1) idle_prepare_error = ESP_ERR_NO_MEM;
if (failure == 2) ssl_start_error = ESP_FAIL;
if (failure == 3 || failure == 5) registration_fail_at = 1;
if (failure == 4) idle_attach_error = ESP_FAIL;
/* Fail only cleanup of the newly started server, not the initial stop. */
if (failure == 5) ssl_stop_fail_at = 2;
assert(web_server_restart_current(original) != ESP_OK);
assert(!s_transitioning && !auth_live);
assert((s_server != NULL) == (failure == 5) && ssl_live == (failure == 5));
assert(s_generation == original + 2);
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && !snapshot.controllable);
auth_error = idle_prepare_error = ssl_start_error = idle_attach_error = ESP_OK;
registration_fail_at = 0;
if (failure == 5) {
assert(web_server_start() == ESP_ERR_INVALID_STATE);
assert(web_server_restart_current(s_generation) == ESP_ERR_INVALID_STATE);
assert(web_server_stop() == ESP_OK);
}
fresh_registration(); start();
assert(web_server_stop_current(s_generation) == ESP_OK);
}
puts("PASS restart start-side failures retain failed-cleanup ownership and permit canonical recovery");
reset(); start(); s_generation = UINT32_MAX - 1;
fresh_registration();
assert(web_server_restart_current(UINT32_MAX - 1) == ESP_OK);
assert(s_generation == UINT32_MAX && !s_transitioning && ssl_live);
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && !snapshot.controllable);
assert(web_server_clear_counters() == ESP_OK && s_generation == UINT32_MAX);
clear_events();
assert(web_server_stop_current(UINT32_MAX) == ESP_ERR_INVALID_STATE);
assert(web_server_restart_current(UINT32_MAX) == ESP_ERR_INVALID_STATE);
assert(!event_length && s_generation == UINT32_MAX);
assert(web_server_stop() == ESP_OK);
fresh_registration(); start(); assert(s_generation == UINT32_MAX);
assert(web_server_stop() == ESP_OK);
puts("PASS saturated generation never wraps; admitted restart completes and canonical recovery stays available");
reset(); start(); original = s_generation; s_transitioning = true;
clear_events();
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && snapshot.transitioning && !snapshot.controllable);
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
assert(web_server_restart_current(original) == ESP_ERR_INVALID_STATE);
assert(web_server_stop() == ESP_ERR_INVALID_STATE && web_server_start() == ESP_ERR_INVALID_STATE);
assert(!event_length && s_generation == original);
s_transitioning = false; serial_detach_error = ESP_ERR_TIMEOUT; fresh_registration();
assert(web_server_restart_current(original) == ESP_OK);
assert(s_generation == original + 2 && ssl_live && !s_transitioning);
serial_detach_error = ESP_OK;
assert(web_server_stop_current(s_generation) == ESP_OK);
puts("PASS transitions reject stale admission; canonical serial detach timeout still permits successful restart");
}
'''
unit = FAKES + header + '\n' + constants + state + '\n'.join(uri_tables)
callback = re.search(r'^static void tls_session_callback\(.*?^\}', source, re.M | re.S)
assert callback
unit += callback.group() + '\n'
unit += function('ensure_mutex')
unit += ''.join(function(name) for name in ('web_server_init', 'web_server_start', 'web_server_stop'))
unit += TESTS
unit += ''.join(function(name) for name in (
'web_server_init', 'start_server', 'web_server_start', 'stop_server',
'web_server_stop', 'web_server_stop_current', 'web_server_restart_current',
'web_server_reboot_current', 'web_server_get_management_snapshot', 'web_server_clear_counters'))
lifecycle_source = (ROOT / 'src/web_lifecycle_settings.c').read_text()
pipeline_state = lifecycle_source[lifecycle_source.index('typedef struct {'):lifecycle_source.index('static void cancel_locked')]
pipeline_state = 'enum { IDLE, PENDING, EXECUTING, OK, FAILED, CANCELLED };\n' + pipeline_state
pipeline_functions = ''
for name in ('cancel_locked', 'expire_locked', 'ack_handoff', 'web_lifecycle_settings_stopped', 'web_lifecycle_settings_execute'):
match = re.search(r'^(?:static )?void ' + name + r'\([^\n]*\)\n\{.*?^\}', lifecycle_source, re.M | re.S)
assert match, name
pipeline_functions += match.group() + '\n'
unit += 'static void management_tests(void);\nstatic void pipeline_tests(void);\n' + TESTS + MANAGEMENT_TESTS
unit += (HERE / 'lifecycle_pipeline.c').read_text().replace('/* PRODUCTION_PIPELINE */', pipeline_state + pipeline_functions)
with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as directory:
temporary = Path(directory)
c_file = temporary / 'test.c'
+193
View File
@@ -0,0 +1,193 @@
/* Actual typed handlers, auth/parser/store; controlled HTTPD/dispatcher/owner boundaries. */
#include "../../src/web_lifecycle_settings.c"
static bool on_dispatcher, on_callback, on_handler, work_fail, queue_fail, invalidate_during_owner;
static uint32_t queued_id;
static unsigned mutations, work_calls, submit_calls, snapshots;
static void (*work)(void *);
static void *work_arg;
static esp_err_t owner_error;
static web_server_management_snapshot_t owner_snapshot = {7, true, false, true};
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *out) {
assert(!host_lock_depth && on_handler); ++snapshots; *out = owner_snapshot; return owner_error;
}
static esp_err_t owner_action(unsigned action, uint32_t generation) {
assert(on_dispatcher && !on_handler && !on_callback && !host_lock_depth);
assert(generation == 7 && s_operation.action == action && s_operation.state == EXECUTING);
++mutations;
if (invalidate_during_owner) {
web_session_store_invalidate(s_operation.session);
web_lifecycle_settings_stopped(&server);
web_lifecycle_settings_execute(s_operation.id); /* duplicate during admission */
assert(s_operation.state == EXECUTING);
}
return owner_error;
}
esp_err_t web_server_stop_current(uint32_t generation) { return owner_action(0, generation); }
esp_err_t web_server_restart_current(uint32_t generation) { return owner_action(1, generation); }
esp_err_t web_server_reboot_current(uint32_t generation) { return owner_action(2, generation); }
esp_err_t httpd_queue_work(httpd_handle_t handle, void (*callback)(void *), void *argument) {
assert(on_handler && !on_dispatcher && !on_callback && !host_lock_depth && handle == &server);
assert(!strcmp(response_status, "202 Accepted") && !send_fail && !aux.remaining_len && sends);
assert(strstr(output, "pending") && s_ack_id == (uint32_t)(uintptr_t)argument);
++work_calls;
if (work_fail) return ESP_FAIL;
assert(!work); work = callback; work_arg = argument; return ESP_OK;
}
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id) {
assert(id && on_callback && !on_dispatcher && !on_handler && !host_lock_depth); ++submit_calls;
if (queue_fail) return ESP_FAIL;
queued_id = id; return ESP_OK;
}
static void lifecycle_begin(const issued_t *identity, const char *body, bool snapshot_read) {
begin(snapshot_read ? "/api/settings/lifecycle" : "/api/settings/lifecycle-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 expect_lifecycle(const char *status, bool snapshot_read) {
unsigned before = mutations, submitted = submit_calls;
on_handler = true;
esp_err_t error = snapshot_read ? web_lifecycle_settings_handler(&req) : web_lifecycle_operation_handler(&req);
on_handler = false;
assert(error == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
assert(!strcmp(response_status, status) && mutations == before && submit_calls == submitted);
assert(strlen(output) < (snapshot_read ? 128 : 96)); zero(scratch, sizeof(scratch));
assert(!strstr(output, "principal") && !strstr(output, "csrf") && !strstr(output, "password"));
}
static const char *stop_body = "{\"action\":\"stop\",\"generation\":7}";
static void lifecycle_submit(const issued_t *identity, const char *body) {
lifecycle_begin(identity, body, false); expect_lifecycle("202 Accepted", false);
}
static void owner_callback(void) {
assert(work); void (*callback)(void *) = work; void *argument = work_arg; work = NULL;
on_callback = true; callback(argument); on_callback = false;
}
static void dispatch(void) { on_dispatcher = true; web_lifecycle_settings_execute(queued_id); on_dispatcher = false; }
static void late_validation(void) { now += 30000000; }
static void slow_send(void) {
assert(on_handler && !work && !s_operation.queued && s_ack_id == s_operation.id);
unsigned before = mutations;
web_lifecycle_settings_execute(s_operation.id);
assert(mutations == before && s_operation.state == PENDING);
now += 2000000; /* Synchronous send returns only at the ACK deadline. */
}
static void lifecycle_tests(void) {
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
receive_fragment = 64;
for (unsigned snapshot_read = 0; snapshot_read < 2; ++snapshot_read) {
lifecycle_begin(NULL, NULL, snapshot_read); expect_lifecycle("401 Unauthorized", snapshot_read);
lifecycle_begin(&user, NULL, snapshot_read); expect_lifecycle("403 Forbidden", snapshot_read);
}
lifecycle_begin(&user, stop_body, false); expect_lifecycle("403 Forbidden", false);
for (unsigned mode = 0; mode < 10; ++mode) {
lifecycle_begin(&admin, stop_body, false);
if (mode == 0) req.content_len = aux.remaining_len = 257;
if (mode == 1) req.uri = "/api/settings/lifecycle-operation?x=1";
if (mode == 2) req.method = HTTP_GET;
if (mode == 3) add("X-CSRF-Token", "duplicate");
if (mode == 4) add("Origin", "https://evil.example");
if (mode == 5) add("Transfer-Encoding", "chunked");
if (mode == 6) add("Content-Type", "text/plain");
if (mode == 7) add("Sec-Fetch-Site", "cross-site");
if (mode == 8) { begin("/api/settings/lifecycle-operation", HTTP_POST, stop_body); add("Host", "device.example"); }
if (mode == 9) { stale_user = alice.user_id; }
on_handler = true; (void)web_lifecycle_operation_handler(&req); on_handler = false; stale_user = 0;
assert(response_status[0] == '4' && !s_next_id && !mutations && !work_calls);
}
/* Stale validation may have invalidated the initial login. */
auth_reset(); admin = mint(&alice); other = mint(&alice);
puts("PASS lifecycle backend admin/original-cookie/Origin/CSRF and body/query/framing policy");
const char *invalid[] = {"{}", "[]", "{\"action\":\"stop\"}",
"{\"action\":\"stop\",\"generation\":0}", "{\"action\":\"stop\",\"generation\":4294967295}",
"{\"action\":\"stop\",\"generation\":4294967296}", "{\"action\":\"stop\",\"generation\":07}",
"{\"action\":\"stop\",\"generation\":7.0}", "{\"action\":\"stop\",\"generation\":7e0}",
"{\"action\":\"stop\",\"generation\":-7}", "{\"action\":\"stop\",\"generation\":\"7\"}",
"{\"action\":\"stop\",\"action\":\"stop\"}", "{\"action\":\"stop\",\"generation\":7,\"target\":0}",
"{\"action\":\"reset\",\"generation\":7}", "{\"action\":\"certificate-rotate\",\"generation\":7}"};
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
lifecycle_begin(&admin, invalid[i], false); expect_lifecycle("400 Bad Request", false);
}
lifecycle_operation_t parsed = {0};
for (size_t n = 0; n < strlen(stop_body); ++n) assert(!parse(stop_body, n, &parsed));
assert(parse(stop_body, strlen(stop_body), &parsed)); assert(!parse(stop_body, strlen(stop_body) + 1, &parsed));
const char *reordered = " { \"generation\":4294967294, \"action\":\"restart\" } ";
assert(parse(reordered, strlen(reordered), &parsed));
receive_fragment = 1; lifecycle_begin(&admin, stop_body, false); expect_lifecycle("400 Bad Request", false);
assert(body_offset == 4); receive_fragment = 64;
char full[257]; memset(full, ' ', 256); memcpy(full, stop_body, strlen(stop_body)); full[256] = 0;
lifecycle_submit(&admin, full); assert(body_offset == 256 && !s_operation.queued);
lifecycle_begin(&other, stop_body, false); expect_lifecycle("503 Service Unavailable", false);
lifecycle_begin(&other, NULL, false); expect_lifecycle("200 OK", false); assert(strstr(output, "idle"));
uint32_t first = s_operation.id;
web_lifecycle_settings_execute(first); assert(!mutations); owner_callback(); dispatch();
assert(mutations == 1 && s_operation.state == OK); zero(&s_operation.principal, sizeof(s_operation.principal));
dispatch(); on_callback = true; ack_handoff((void *)(uintptr_t)first); on_callback = false;
assert(mutations == 1 && submit_calls == 1);
puts("PASS lifecycle strict parser/256-byte/four-receive bounds; ACK before ID dispatch and duplicate fencing");
for (unsigned mode = 0; mode < 2; ++mode) {
owner_error = mode ? ESP_FAIL : ESP_OK;
lifecycle_begin(&admin, NULL, true); expect_lifecycle(mode ? "503 Service Unavailable" : "200 OK", true);
if (!mode) assert(!strcmp(output, "{\"generation\":7,\"running\":true,\"transitioning\":false,\"controllable\":true}"));
}
owner_error = ESP_OK;
puts("PASS lifecycle bounded scalar snapshot and optional owner failure isolation");
unsigned before = mutations, submitted = submit_calls;
work_fail = true; lifecycle_submit(&admin, stop_body); work_fail = false;
assert(s_operation.state == CANCELLED && !s_ack_id && !work); zero(&s_operation.principal, sizeof(s_operation.principal));
send_fail = true; unsigned calls = work_calls; lifecycle_submit(&admin, stop_body); send_fail = false;
assert(work_calls == calls && !s_ack_id && s_operation.state == CANCELLED);
queue_fail = true; lifecycle_submit(&admin, stop_body); owner_callback(); queue_fail = false; dispatch();
assert(s_operation.state == CANCELLED && mutations == before && submit_calls == submitted + 1);
puts("PASS lifecycle response/HTTPD queue/dispatcher queue failures cancel only before admission without retry");
lifecycle_submit(&admin, stop_body); uint32_t late = s_ack_id; now += 2000000;
lifecycle_begin(&admin, NULL, false); expect_lifecycle("200 OK", false); assert(strstr(output, "cancelled") && s_ack_id == late);
for (unsigned i = 0; i < 5; ++i) { lifecycle_begin(&admin, stop_body, false); expect_lifecycle("503 Service Unavailable", false); }
assert(s_ack_id == late); owner_callback(); assert(!s_ack_id && mutations == before);
lifecycle_submit(&admin, stop_body); on_callback = true; ack_handoff((void *)(uintptr_t)late); on_callback = false;
assert(s_ack_id == s_operation.id); owner_callback(); now += 30000000; dispatch(); assert(s_operation.state == CANCELLED);
lifecycle_submit(&admin, stop_body); owner_callback(); db_hook = late_validation; dispatch(); assert(s_operation.state == CANCELLED && mutations == before);
puts("PASS lifecycle ACK expiry/lost-callback single reservation, late callback ABA and dequeue/post-validation deadlines");
for (unsigned mode = 0; mode < 5; ++mode) {
auth_reset(); admin = mint(&alice); lifecycle_submit(&admin, stop_body); owner_callback();
if (mode == 0) web_session_store_invalidate(admin.view.id);
if (mode == 1) db_fail = true;
if (mode == 2) stale_user = alice.user_id;
if (mode == 3) now = admin.view.expires_at_us;
if (mode == 4) { hook_id = admin.view.id; db_hook = invalidate_hook; }
dispatch(); db_fail = false; stale_user = 0;
assert(s_operation.state == CANCELLED && mutations == before);
}
auth_reset(); admin = mint(&alice); lifecycle_submit(&admin, stop_body); late = s_ack_id;
web_cookie_auth_stop(); web_lifecycle_settings_stopped(&server); assert(web_cookie_auth_start() == ESP_OK);
admin = mint(&alice); owner_callback(); assert(!s_ack_id && s_operation.state == CANCELLED && mutations == before);
lifecycle_submit(&admin, stop_body); on_callback = true; ack_handoff((void *)(uintptr_t)late); on_callback = false;
assert(s_ack_id == s_operation.id); owner_callback(); dispatch(); assert(s_operation.state == OK);
puts("PASS lifecycle original-login expiry/revocation/validation races and shutdown/restart same-owner ABA");
for (unsigned action = 0; action < 3; ++action) {
auth_reset(); admin = mint(&alice); char body[80];
snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7}", s_actions[action]);
invalidate_during_owner = true; lifecycle_submit(&admin, body); owner_callback(); before = mutations; dispatch();
invalidate_during_owner = false;
assert(s_operation.state == OK && mutations == before + 1);
}
auth_reset(); admin = mint(&alice);
owner_error = ESP_ERR_INVALID_STATE; lifecycle_submit(&admin, stop_body); owner_callback(); dispatch();
assert(s_operation.state == FAILED); owner_error = ESP_OK;
puts("PASS lifecycle all actions dispatcher-only; admitted revocation is not cancellation; failure uncertainty");
before = mutations;
send_hook = slow_send; lifecycle_submit(&admin, stop_body);
assert(work && !s_operation.queued); owner_callback(); dispatch();
assert(s_operation.state == CANCELLED && mutations == before);
/* Neither callback nor dispatcher may dereference the old request, its
* connection or its body after the handler returns. Reuse all three. */
lifecycle_submit(&admin, stop_body);
lifecycle_begin(&admin, NULL, true); expect_lifecycle("200 OK", true);
memset(&req, 0xa5, sizeof(req)); memset(&aux, 0xa5, sizeof(aux));
memset(scratch, 0xa5, sizeof(scratch)); request_body = NULL;
owner_callback(); dispatch();
assert(s_operation.state == OK && mutations == before + 1);
s_next_id = UINT32_MAX; lifecycle_begin(&admin, stop_body, false); expect_lifecycle("503 Service Unavailable", false);
puts("PASS lifecycle send-return expiry, request/connection storage reuse after disconnect, and nonwrapping operation ID exhaustion");
}
+9
View File
@@ -61,6 +61,7 @@ serial_settings = "--serial-settings" in sys.argv
accounts = "--accounts" in sys.argv
broker = "--broker" in sys.argv
ssh_settings = "--ssh" in sys.argv
lifecycle = "--lifecycle" in sys.argv
display = "--display" in sys.argv
if display:
HEADERS["nvs_flash.h"] = '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n'
@@ -251,6 +252,14 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
*(["-DHOST_DISPLAY"] if display else []),
*(["-DHOST_BROKER"] if broker else []),
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
*(["-DHOST_LIFECYCLE"] if lifecycle 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)
if lifecycle:
guard = subprocess.run(["cc", "-E", "-DCONFIG_HTTPD_QUEUE_WORK_BLOCKING=1",
"-I" + str(tmp), "-I" + str(ROOT / "src"),
str(ROOT / "src/web_lifecycle_settings.c")], stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, timeout=30)
assert guard.returncode and b"Lifecycle ACK handoff requires nonblocking" in guard.stderr
print("PASS lifecycle compile-time rejection of blocking HTTPD work submission")
+8
View File
@@ -31,6 +31,7 @@ static unsigned fail_header, setter_calls;
static bool send_fail, recv_fail;
static size_t receive_fragment = 7;
static void (*password_hook)(void);
static void (*send_hook)(void);
static char response_status[48];
static struct httpd_req_aux aux;
static httpd_req_t req;
@@ -54,6 +55,7 @@ esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
snprintf(cookie_values[cookie_count++], 200, "%s", response_headers[i].value);
}
}
if (send_hook) { void (*hook)(void) = send_hook; send_hook = NULL; hook(); }
return send_fail ? ESP_FAIL : ESP_OK;
}
int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
@@ -152,6 +154,9 @@ static void auth_reset(void) {
#ifdef HOST_SSH_SETTINGS
#include "ssh_settings_test.c"
#endif
#ifdef HOST_LIFECYCLE
#include "lifecycle_test.c"
#endif
int main(void) {
assert(store_tests() == 0); auth_reset();
@@ -320,6 +325,9 @@ int main(void) {
#endif
#ifdef HOST_SSH_SETTINGS
ssh_settings_tests();
#endif
#ifdef HOST_LIFECYCLE
lifecycle_tests();
#endif
return 0;
}
+2 -1
View File
@@ -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': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': []};
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': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': [], '/api/settings/lifecycle': [], '/api/settings/lifecycle-operation': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -1372,5 +1372,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
await require('./display.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./lifecycle.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; });
+4 -4
View File
@@ -59,7 +59,7 @@ def check_layout(html):
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values', 'ssh-values'):
assert ids[ident]['tag'] == 'dl'
assert 'settings-values' in classes(ids[ident])
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings', 'ssh-settings'):
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings', 'ssh-settings', 'lifecycle-settings'):
nodes = list(descendants(ids[ident]))
assert not any(n['tag'] == 'pre' for n in nodes)
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
@@ -70,9 +70,9 @@ def check_layout(html):
ancestor(n, 'settings-edit')
except AssertionError:
ancestor(n, 'serial-edit')
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh', 'ssh-refresh'):
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh', 'ssh-refresh', 'lifecycle-refresh'):
assert ids[ident]['text'] == 'Refresh'
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result', 'ssh-result'):
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result', 'ssh-result', 'lifecycle-result'):
assert ids[ident]['text'] == 'Check Operation Result'
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
assert 'settings-check' in classes(ids[ident]['parent'])
@@ -101,7 +101,7 @@ def check_layout(html):
):
assert rule in css, rule
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all six settings views')
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all seven settings views')
def check_browser_layout(html, tmp, executable):
+97
View File
@@ -0,0 +1,97 @@
'use strict';
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
const path = '/api/settings/lifecycle', op = path + '-operation';
const fixture = (extra = {}) => ({generation:7,running:true,transitioning:false,controllable:true,...extra});
const reply = (state='pending', id=42, status=200, action='stop') => new Response(JSON.stringify({id,action,state}), {status});
const n = (b,id) => b.nodes['lifecycle-'+id], posts = b => b.calls.filter(c=>c.url===op && c.method==='POST');
async function open(v=fixture()) { const b=await adminBrowser(); b.click('select-settings'); await tick(); b.queues[path].push(json(v)); b.click('settings-lifecycle'); await tick(); return b; }
async function refresh(b,v=fixture()) { b.queues[path].push(json(v)); b.click('lifecycle-refresh'); await tick(); }
async function submit(b,action='stop') { b.queues[op].push(reply('pending',42,202,action)); b.click('lifecycle-'+action); await tick(); }
await test('Lifecycle admin-only view and existing Network link are read-only and preserve both terminal drains', async()=>{
const u=browser(); u.start(); await tick(); u.click('settings-lifecycle'); await tick(); assert.ok(!u.calls.some(c=>c.url===path));
const b=await open(); assert.equal(b.nodes['lifecycle-settings'].hidden,false); assert.equal(posts(b).length,0);
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('blocked'); assert.equal(b.sockets[i].sent.length,0); }
b.click('lifecycle-network'); await tick(); assert.equal(b.nodes['lifecycle-settings'].hidden,true); assert.equal(b.nodes['network-settings'].hidden,false);
assert.equal(posts(b).length,0); assert.ok(!b.calls.some(c=>c.url==='/api/settings/network-operation'&&c.method==='POST'));
assert.match(html,/USB remains UART1 serial access, not a web administration console/); assert.match(html,/Native USB preserves network-independent UART1 serial access, not Wi-Fi administration/);
});
await test('Lifecycle confirmations name all-client loss/reboot USB interruption and submit exact generation once',async()=>{
for(const action of ['stop','restart','reboot']) {
const b=await open(); let confirmation=''; b.window.confirm=s=>{confirmation=s;return false;}; b.click('lifecycle-'+action); await tick(); assert.equal(posts(b).length,0);
assert.match(confirmation,/ALL/); assert.match(confirmation,/unsaved|Unsaved/); assert.match(confirmation,/UART0/); assert.match(confirmation,/no automatic retry/);
assert.match(confirmation,action==='reboot'?/USB and UART operation are interrupted/:/BOTH browser terminal routes/);
b.window.confirm=()=>true; await submit(b,action); assert.equal(posts(b).length,1); assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7});
assert.equal(posts(b)[0].headers['X-CSRF-Token'],'a'.repeat(64)); b.click('lifecycle-'+action); await tick(); assert.equal(posts(b).length,1);
assert.match(n(b,'operation-detail').textContent,/do not resubmit/); assert.ok(![...b.timers.values()].some(t=>t.ms===1000 && !t.interval));
b.queues[op].push(reply('ok',42,200,action)); b.click('lifecycle-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/not proof of peer receipt/);
assert.ok(n(b,'stop').disabled); await refresh(b); assert.equal(n(b,'stop').disabled,false); assert.equal(posts(b).length,1);
}
});
await test('Lifecycle bounded snapshot schema rejects unavailable malformed transitioning saturated and contradictory state',async()=>{
for(const v of [{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({controllable:1}),fixture({extra:true}),fixture({transitioning:true}),fixture({running:false}),fixture({generation:4294967295})]) {
const b=await open(v); assert.ok(n(b,'stop').disabled && n(b,'restart').disabled && n(b,'reboot').disabled); assert.equal(posts(b).length,0);
}
for(const v of [fixture({transitioning:true,controllable:false}),fixture({generation:4294967295,controllable:false})]) {const b=await open(v);assert.ok(n(b,'stop').disabled);}
const b=await open(); b.queues[path].push(failure(503)); b.click('lifecycle-refresh'); await tick(); assert.ok(n(b,'stop').disabled); assert.equal(b.sockets.length,2);
});
await test('Lifecycle captures confirmation before delayed original-session validation and gates double click',async()=>{
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.queues[op].push(reply('pending',42,202)); b.click('lifecycle-stop'); await tick();
b.click('lifecycle-reboot'); b.click('lifecycle-refresh'); await tick(); assert.equal(posts(b).length,0);
d.resolve(session({role:'admin'})); await tick(); assert.equal(posts(b).length,1); assert.equal(JSON.parse(posts(b)[0].body).generation,7);
for(const state of ['failed','cancelled']) {b.queues[op].push(reply(state));b.click('lifecycle-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='failed'?/may already have occurred/:/before lifecycle admission/);}
assert.equal(posts(b).length,1);
});
await test('Lifecycle lost ACK/replaced result never clears pending or adopts old action results',async()=>{
for(const lost of [true,false]) {
const b=await open(); if(lost) { b.queues[op].push(()=>{throw Error('lost');}); b.click('lifecycle-stop'); await tick(); } else await submit(b);
b.queues[op].push(reply('ok',lost?42:43)); b.click('lifecycle-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/cannot be matched/);
await refresh(b); assert.ok(n(b,'stop').disabled); b.click('lifecycle-reboot'); await tick(); assert.equal(posts(b).length,1);
b.click('select-serial'); b.queues[path].push(json(fixture())); b.click('select-settings'); await tick(); assert.ok(n(b,'stop').disabled); assert.equal(posts(b).length,1);
}
const b=await open(); await submit(b); b.queues[op].push(reply('ok',42,200,'reboot')); b.click('lifecycle-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/cannot be matched/); assert.ok(n(b,'stop').disabled);
});
await test('Lifecycle whole-request deadlines fence late session/read/ACK/body completion without mutation retry',async()=>{
for(const stage of ['session','snapshot','ack','body']) {
const b=await open(), d=deferred();
if(stage==='session') b.queues['/api/session'].push(d.promise);
if(stage==='snapshot') b.queues[path].push(d.promise);
if(stage==='ack') b.queues[op].push(d.promise);
if(stage==='body') b.queues[op].push({status:202,ok:true,headers:new Headers(),body:{getReader:()=>({read:()=>d.promise,cancel:async()=>{}})}});
b.click(stage==='snapshot'?'lifecycle-refresh':'lifecycle-stop'); await tick(); b.fire(15000); await tick(); assert.match(n(b,stage==='snapshot'?'detail':'operation-detail').textContent,/timed out/);
d.resolve(stage==='session'?session({role:'admin'}):stage==='snapshot'?json(fixture({generation:99})):stage==='body'?{done:true}:reply('pending',42,202)); await tick();
assert.equal(posts(b).length,stage==='session'||stage==='snapshot'?0:1); assert.ok(n(b,'stop').disabled); assert.match(n(b,stage==='snapshot'?'detail':'operation-detail').textContent,/timed out/);
}
});
await test('Lifecycle navigation/pagehide fences late reads/results/401 and never restores a mutation',async()=>{
for(const pagehide of [false,true]) {
const b=await open(), d=deferred(); b.queues[op].push(d.promise); b.click('lifecycle-stop'); await tick();
if(pagehide) b.emit('pagehide'); else b.click('settings-serial');
d.resolve(failure(401)); await tick(); assert.equal(b.redirects.length,0); assert.equal(posts(b).length,1);
if(pagehide) {b.emit('pageshow');await tick();} else {b.queues[path].push(json(fixture()));b.click('settings-lifecycle');await tick();}
assert.equal(posts(b).length,1); assert.ok(n(b,'stop').disabled);
}
});
await test('Lifecycle original-login switch/revocation denies mutation; fresh login document has no old result restore',async()=>{
for(const response of [failure(401),session({role:'admin',username:'another'}),session({role:'admin',csrf:'b'.repeat(64)})]) {
const b=await open(); b.queues['/api/session'].push(response); b.click('lifecycle-reboot'); await tick(); assert.equal(posts(b).length,0); assert.ok(b.sockets.every(s=>s.closed));
}
const b=await open(); b.queues[op].push(failure(401)); b.click('lifecycle-stop'); await tick(); assert.equal(posts(b).length,1); assert.deepEqual(b.redirects,['/login']);
const fresh=await open(); assert.equal(posts(fresh).length,0); assert.equal(n(fresh,'stop').disabled,false);
});
await test('Lifecycle accepted restart followed by expired login closes both routes without replay or result restore',async()=>{
const b=await open(); await submit(b,'restart');
b.queues[op].push(failure(401)); b.click('lifecycle-result'); await tick();
assert.equal(posts(b).length,1); assert.deepEqual(b.redirects,['/login']);
assert.ok(b.sockets.every(s=>s.closed));
const fresh=await open(fixture({generation:9}));
assert.equal(posts(fresh).length,0);
assert.ok(!fresh.calls.some(c=>c.url===op));
assert.equal(n(fresh,'restart').disabled,false);
});
await test('Lifecycle malformed/oversized result and busy response retain uncertainty with safe error text',async()=>{
for(const response of [failure(503),new Response('x'.repeat(97),{status:202}),reply('ok',42,202),reply('pending',0,202),new Response(JSON.stringify({id:42,action:'stop',state:'pending',secret:'bad'}),{status:202})]) {
const b=await open(); b.queues[op].push(response); b.click('lifecycle-stop'); await tick(); assert.equal(posts(b).length,1); assert.ok(n(b,'stop').disabled); assert.doesNotMatch(n(b,'operation-detail').textContent,/SECRET ERROR BODY|secret|bad/); assert.match(n(b,'operation-detail').textContent,/Outcome may be unknown/);
}
});
};