Add Typed Display Settings Administration
Implements admin-only Display settings with generation-checked Apply, Save, Load, Defaults, and Reset operations across the web UI, CLI, SSH dispatcher, and local UI owner. Adds bounded HTTP handling, session-isolated operation results, browser lifecycle support, and comprehensive host tests and documentation.
This commit is contained in:
@@ -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, network_settings_executed;
|
||||
static uint32_t serial_settings_executed, account_settings_executed, network_settings_executed, display_settings_executed;
|
||||
static void web_display_settings_execute(uint32_t id) { assert(!lock_depth); display_settings_executed = id; }
|
||||
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;
|
||||
|
||||
@@ -364,5 +364,20 @@ int main(void)
|
||||
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");
|
||||
assert(admin_ssh_console_submit_display_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_display_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_display_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_serial_settings(41) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_network_settings(42) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_account_settings(43) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_display_settings(44) == ESP_OK && queue_send_wait == 0);
|
||||
assert(admin_ssh_console_submit_display_settings(45) == ESP_ERR_TIMEOUT && s_request_queue->count == 4);
|
||||
pump(worker_task);
|
||||
assert(serial_settings_executed == 41 && network_settings_executed == 42 && account_settings_executed == 43 && display_settings_executed == 44);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: typed Display IDs share all four unchanged queue slots; full/not-ready admission fails, routing never invokes 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");
|
||||
}
|
||||
|
||||
@@ -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) != 24:
|
||||
raise RuntimeError('Review URI extraction: expected 22 descriptors and two tables')
|
||||
if len(uri_tables) != 27:
|
||||
raise RuntimeError('Review URI extraction: expected 25 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()
|
||||
@@ -134,6 +134,26 @@ 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)
|
||||
HANDLER(web_display_settings_handler) HANDLER(web_display_operation_handler)
|
||||
static unsigned display_calls, display_allocations, display_fail_at;
|
||||
static esp_err_t display_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);
|
||||
++display_calls;
|
||||
if (display_calls == 1) {
|
||||
assert(!strcmp(uri->uri, "/api/settings/display") && uri->method == HTTP_GET);
|
||||
assert(uri->handler == web_display_settings_handler);
|
||||
} else {
|
||||
assert(!strcmp(uri->uri, "/api/settings/display-operation"));
|
||||
assert(uri->method == (display_calls == 2 ? HTTP_GET : HTTP_POST));
|
||||
assert(uri->handler == web_display_operation_handler && display_calls <= 3);
|
||||
}
|
||||
/* Model the adapter's staged descriptor/name allocations, before publication. */
|
||||
for (unsigned allocation = 0; allocation < 2; ++allocation)
|
||||
if (++display_allocations == display_fail_at) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
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);
|
||||
@@ -169,7 +189,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 == 27 && config->port_secure == 443);
|
||||
assert(config->httpd.max_uri_handlers == 30 && 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);
|
||||
@@ -207,6 +227,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_display_settings_handler || uri->handler == web_display_operation_handler) return display_register(s, uri);
|
||||
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);
|
||||
@@ -214,6 +235,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_display_operation_handler) return display_register(s, 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);
|
||||
@@ -240,7 +262,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")) && 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")) && method == HTTP_GET));
|
||||
++unregister_calls;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
||||
@@ -309,12 +331,14 @@ static void reset(void) {
|
||||
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
|
||||
operation_calls = operation_fail_at = 0;
|
||||
network_calls = network_allocations = network_fail_at = 0;
|
||||
display_calls = display_allocations = display_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;
|
||||
network_calls = network_allocations = 0;
|
||||
display_calls = display_allocations = 0;
|
||||
}
|
||||
static void start(void) {
|
||||
assert(web_server_start() == ESP_OK);
|
||||
@@ -336,6 +360,14 @@ static const httpd_uri_t *method_route(const char *uri, int method) {
|
||||
}
|
||||
return found;
|
||||
}
|
||||
static void display_complete(void) {
|
||||
assert(display_calls == 3 && display_allocations == 6);
|
||||
assert(route("/api/settings/display")->handler == web_display_settings_handler);
|
||||
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
|
||||
const httpd_uri_t *r = method_route("/api/settings/display-operation", method);
|
||||
assert(r && r->handler == web_display_operation_handler);
|
||||
}
|
||||
}
|
||||
static void network_complete(void) {
|
||||
assert(network_calls == 3 && network_allocations == 6);
|
||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
||||
@@ -378,7 +410,7 @@ int main(void) {
|
||||
}
|
||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||
|
||||
reset(); start(); assert(registered_count == 27 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
reset(); start(); assert(registered_count == 30 && 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);
|
||||
@@ -432,7 +464,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 == 25 && unregister_calls == failure - 17);
|
||||
assert(registered_count == 28 && 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);
|
||||
@@ -441,13 +473,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 == 27 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(registered_count == 30 && 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 == 26);
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 29);
|
||||
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");
|
||||
@@ -459,7 +491,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 == 27 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
assert(registered_count == 30 && 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;
|
||||
@@ -481,7 +513,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 == 26);
|
||||
assert(settings_calls == 1 && registered_count == 29);
|
||||
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);
|
||||
@@ -489,7 +521,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 == 25 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(registered_count == 28 && 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);
|
||||
@@ -497,7 +529,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 ? 24 : 25));
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 27 : 28));
|
||||
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);
|
||||
@@ -505,17 +537,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 == 27 && account_calls == 3);
|
||||
assert(registered_count == 30 && account_calls == 3);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
||||
assert(registered_count == 26 && auth_live && serial_live && admin_owned);
|
||||
assert(registered_count == 29 && 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 == 26 && account_calls == 3);
|
||||
assert(generation_calls == 1 && registered_count == 29 && 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);
|
||||
@@ -527,12 +559,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 == 27);
|
||||
assert(generation_calls == 2 && registered_count == 30);
|
||||
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 == 26 && account_calls == 3 && generation_calls == 1);
|
||||
assert(keys_calls == 1 && registered_count == 29 && 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);
|
||||
@@ -547,7 +579,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 == 27);
|
||||
assert(keys_calls == 2 && registered_count == 30);
|
||||
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");
|
||||
@@ -572,7 +604,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 ? 24 : 25));
|
||||
assert(registered_count == (failed_route == 1 ? 27 : 28));
|
||||
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));
|
||||
@@ -580,13 +612,13 @@ int main(void) {
|
||||
other_domains_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 27); network_complete();
|
||||
assert(registered_count == 30); 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(registered_count == 29 && 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));
|
||||
@@ -596,10 +628,42 @@ 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 == 27); network_complete();
|
||||
assert(registered_count == 30); 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 = 1; failure <= 6; ++failure) {
|
||||
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 ? 27 : 28));
|
||||
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));
|
||||
assert(!!method_route("/api/settings/display", HTTP_GET) == (failed_route != 1));
|
||||
other_domains_complete(); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
display_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 30); 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 == 29 && 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));
|
||||
other_domains_complete(); network_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; display_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 30); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Display 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;
|
||||
@@ -607,10 +671,10 @@ int main(void) {
|
||||
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);
|
||||
start(); network_complete(); display_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)");
|
||||
puts("23 lifecycle groups passed (16 required fatal positions, 16 optional routes, Network/Display allocation positions and failed unregister)");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/* Production HTTP/store/owner/config/CLI; deterministic storage and scheduling. */
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
#include "local_status_ui.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#define ESP_ERR_TIMEOUT 0x107
|
||||
#include "../../src/local_ui_config.c"
|
||||
static portMUX_TYPE s_timing_mux;
|
||||
static local_ui_config_t s_config;
|
||||
static bool s_config_available, s_config_busy, s_diagnostic_hold_active;
|
||||
static uint32_t s_config_generation, s_external_activity_sequence, s_diagnostic_hold_started;
|
||||
static void *s_diagnostic_gate;
|
||||
#define portENTER_CRITICAL taskENTER_CRITICAL
|
||||
#define portEXIT_CRITICAL taskEXIT_CRITICAL
|
||||
#define xTaskGetTickCount() 42U
|
||||
#define pdMS_TO_TICKS(v) (v)
|
||||
#define xSemaphoreTake(a,b) ((void)(a), (void)(b), 1)
|
||||
#define xSemaphoreGive(a) ((void)(a), 1)
|
||||
#define pdTRUE 1
|
||||
#include "display_owner_production.h"
|
||||
#include "../../src/web_display_settings.c"
|
||||
static int show_status(void) { return 0; }
|
||||
const char *esp_err_to_name(esp_err_t e) { (void)e; return "test error"; }
|
||||
#include "display_console_production.h"
|
||||
|
||||
static bool on_dispatcher, have_stored, queue_fail;
|
||||
static local_ui_config_t persisted, staged;
|
||||
static esp_err_t storage_error;
|
||||
static unsigned storage_calls, storage_fail_at, storage_step;
|
||||
static esp_err_t storage_result(void) { return ++storage_step == storage_fail_at ? ESP_FAIL : ESP_OK; }
|
||||
static uint32_t queued_id;
|
||||
static void (*storage_hook)(void);
|
||||
esp_err_t nvs_flash_init(void) {
|
||||
assert(on_dispatcher && !host_lock_depth); ++storage_calls;
|
||||
if (storage_hook) { void (*h)(void) = storage_hook; storage_hook = NULL; h(); }
|
||||
storage_step = 0;
|
||||
return storage_error == ESP_OK ? storage_result() : storage_error;
|
||||
}
|
||||
esp_err_t nvs_open(const char *ns, int mode, nvs_handle_t *h) {
|
||||
assert(!strcmp(ns, LOCAL_UI_CONFIG_NVS_NAMESPACE)); *h = 1;
|
||||
if (storage_result() != ESP_OK) return ESP_FAIL;
|
||||
return !have_stored && mode == NVS_READONLY ? ESP_ERR_NVS_NOT_FOUND : ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_get_blob(nvs_handle_t h, const char *key, void *out, size_t *size) {
|
||||
assert(h == 1 && !strcmp(key, LOCAL_UI_CONFIG_NVS_BLOB_KEY));
|
||||
if (storage_result() != ESP_OK) return ESP_FAIL;
|
||||
if (out) memcpy(out, &persisted, sizeof(persisted));
|
||||
*size = sizeof(persisted); return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_set_blob(nvs_handle_t h, const char *key, const void *in, size_t size) {
|
||||
assert(h == 1 && !strcmp(key, LOCAL_UI_CONFIG_NVS_BLOB_KEY) && size == sizeof(staged));
|
||||
if (storage_result() != ESP_OK) return ESP_FAIL;
|
||||
memcpy(&staged, in, size); return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_commit(nvs_handle_t h) { assert(h == 1); if (storage_result() != ESP_OK) return ESP_FAIL; persisted = staged; have_stored = true; return ESP_OK; }
|
||||
void nvs_close(nvs_handle_t h) { assert(h == 1); }
|
||||
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id) {
|
||||
assert(id && !on_dispatcher && !host_lock_depth);
|
||||
if (queue_fail) return ESP_ERR_TIMEOUT;
|
||||
queued_id = id; return ESP_OK;
|
||||
}
|
||||
static void operation_begin(const issued_t *identity, const char *body) {
|
||||
begin("/api/settings/display-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 display_expect(const char *status, bool snapshot) {
|
||||
unsigned before = storage_calls;
|
||||
esp_err_t e = snapshot ? web_display_settings_handler(&req) : web_display_operation_handler(&req);
|
||||
assert(e == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
if (strcmp(response_status, status)) fprintf(stderr, "expected %s got %s: %s\n", status, response_status, output);
|
||||
assert(!strcmp(response_status, status) && storage_calls == before);
|
||||
assert(strlen(output) < 128); zero(scratch, sizeof(scratch));
|
||||
}
|
||||
static void execute(void) { on_dispatcher = true; web_display_settings_execute(queued_id); on_dispatcher = false; }
|
||||
static void submit(const issued_t *who, const char *body) {
|
||||
operation_begin(who, body); display_expect("202 Accepted", false); assert(s_operation.state == PENDING);
|
||||
}
|
||||
static void action(const issued_t *who, const char *name) {
|
||||
char body[96]; snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":%u}", name, s_config_generation);
|
||||
submit(who, body); execute();
|
||||
}
|
||||
static void concurrent(void) {
|
||||
local_ui_config_t config; uint32_t generation;
|
||||
assert(s_config_busy && local_status_ui_get_settings(&config, &generation) == ESP_ERR_TIMEOUT);
|
||||
assert(local_status_ui_apply_config(&s_config) == ESP_ERR_TIMEOUT);
|
||||
assert(local_status_ui_update_settings(LOCAL_UI_SETTINGS_RESET, 0, NULL, NULL) == ESP_ERR_TIMEOUT);
|
||||
uint32_t activity = s_external_activity_sequence, original = s_config_generation;
|
||||
local_status_ui_hold_for_diagnostics();
|
||||
assert(s_external_activity_sequence == activity + 1 && s_config_generation == original);
|
||||
}
|
||||
static void revoke(void) { web_session_store_invalidate(s_operation.session); }
|
||||
static void display_settings_tests(void) {
|
||||
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
|
||||
local_ui_config_defaults(&s_config); s_config_available = true; s_config_generation = 1; receive_fragment = 64;
|
||||
const char *apply = "{\"action\":\"apply\",\"generation\":1,\"dim_seconds\":0,\"off_seconds\":86400}";
|
||||
operation_begin(NULL, apply); display_expect("401 Unauthorized", false);
|
||||
operation_begin(&user, apply); display_expect("403 Forbidden", false);
|
||||
operation_begin(&user, NULL); display_expect("403 Forbidden", false);
|
||||
for (unsigned mode = 0; mode < 8; ++mode) {
|
||||
operation_begin(&admin, apply);
|
||||
if (mode == 0) req.content_len = aux.remaining_len = 257;
|
||||
if (mode == 1) req.uri = "/api/settings/display-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");
|
||||
(void)web_display_operation_handler(&req);
|
||||
assert(response_status[0] == '4' && !s_next_id && !storage_calls);
|
||||
}
|
||||
puts("PASS Display HTTP security: admin/cookie/Origin/CSRF, duplicates, query/body/framing bounds");
|
||||
const char *invalid[] = {"{}", "[]", "{\"action\":\"save\"}", "{\"action\":\"save\",\"generation\":0}",
|
||||
"{\"action\":\"save\",\"generation\":1,\"dim_seconds\":2}", "{\"action\":\"save\",\"generation\":01}",
|
||||
"{\"action\":\"save\",\"generation\":1e2}", "{\"action\":\"save\",\"generation\":1.0}",
|
||||
"{\"action\":\"save\",\"generation\":-1}", "{\"action\":\"save\",\"generation\":4294967296}",
|
||||
"{\"action\":\"save\",\"generation\":1,\"generation\":1}", "{\"action\":\"sa\\u0076e\",\"generation\":1}",
|
||||
"{\"action\":\"apply\",\"generation\":1,\"dim_seconds\":10,\"off_seconds\":10}",
|
||||
"{\"action\":\"apply\",\"generation\":1,\"dim_seconds\":86401,\"off_seconds\":0}"};
|
||||
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
|
||||
operation_begin(&admin, invalid[i]); display_expect("400 Bad Request", false);
|
||||
}
|
||||
display_operation_t parsed;
|
||||
for (size_t n = 0; n < strlen(apply); ++n) assert(!parse(apply, n, &parsed));
|
||||
assert(parse(apply, strlen(apply), &parsed)); assert(!parse(apply, strlen(apply)+1, &parsed));
|
||||
for (unsigned dim = 0; dim < 3; ++dim) for (unsigned off = 0; off < 3; ++off) {
|
||||
unsigned values[] = {0, 1, 86400}; char body[160];
|
||||
snprintf(body, sizeof(body), "{\"off_seconds\":%u,\"dim_seconds\":%u,\"generation\":4294967295,\"action\":\"apply\"}", values[off], values[dim]);
|
||||
assert(parse(body, strlen(body), &parsed) == (!values[off] || !values[dim] || values[off] > values[dim]));
|
||||
}
|
||||
receive_fragment = 1; operation_begin(&admin, apply); display_expect("400 Bad Request", false); assert(body_offset == 4); receive_fragment = 64;
|
||||
char full[257]; memset(full, ' ', 256); memcpy(full, apply, strlen(apply)); full[256] = 0;
|
||||
queue_fail = true; operation_begin(&admin, full); display_expect("503 Service Unavailable", false); queue_fail = false;
|
||||
assert(body_offset == 256 && s_operation.state == IDLE);
|
||||
puts("PASS Display parser: exact schema, integer limits/order/zero, truncations, four receives and exact 256-byte admission");
|
||||
|
||||
for (unsigned mode = 0; mode < 5; ++mode) {
|
||||
operation_begin(mode == 0 ? NULL : mode == 1 ? &user : &admin, NULL); req.uri = "/api/settings/display";
|
||||
s_config_available = mode != 3; s_config_busy = mode == 4;
|
||||
display_expect(mode == 0 ? "401 Unauthorized" : mode == 1 ? "403 Forbidden" : mode >= 3 ? "503 Service Unavailable" : "200 OK", true);
|
||||
if (mode == 2) assert(!strcmp(output, "{\"generation\":1,\"dim_seconds\":300,\"off_seconds\":600}"));
|
||||
}
|
||||
s_config_available = true; s_config_busy = false;
|
||||
puts("PASS Display snapshot: bounded RAM-only, unavailable UI/contention; no panel or storage dependency");
|
||||
submit(&admin, apply); uint32_t first = queued_id;
|
||||
operation_begin(&other, apply); display_expect("503 Service Unavailable", false);
|
||||
operation_begin(&other, NULL); display_expect("200 OK", false); assert(strstr(output, "idle"));
|
||||
execute(); assert(s_operation.state == OK && s_config_generation == 2 && s_config.dim_timeout_seconds == 0 && !have_stored);
|
||||
zero(&s_operation.principal, sizeof(s_operation.principal)); zero(&s_operation.config, sizeof(s_operation.config));
|
||||
execute(); assert(s_config_generation == 2);
|
||||
submit(&admin, apply); execute(); assert(s_operation.state == CONFLICT && s_config_generation == 2);
|
||||
action(&admin, "save"); assert(have_stored && persisted.off_timeout_seconds == 86400 && s_config_generation == 2);
|
||||
action(&admin, "defaults"); assert(s_config.off_timeout_seconds == 600 && persisted.off_timeout_seconds == 86400);
|
||||
action(&admin, "load"); assert(s_config.off_timeout_seconds == 86400);
|
||||
have_stored = false; action(&admin, "load"); assert(s_operation.state == LOADED_DEFAULTS && s_config.off_timeout_seconds == 600 && !have_stored);
|
||||
have_stored = true; persisted.version = 99; action(&admin, "load"); assert(s_operation.state == LOADED_DEFAULTS && persisted.version == 99);
|
||||
action(&admin, "reset"); assert(s_operation.state == OK && persisted.version == 1);
|
||||
/* A simulated reboot invokes the real boot loader, not browser drafts. */
|
||||
local_ui_config_t boot; bool stored; on_dispatcher = true;
|
||||
assert(local_ui_config_load(&boot, &stored) == ESP_OK && stored && !memcmp(&boot, &persisted, sizeof(boot))); on_dispatcher = false;
|
||||
puts("PASS Display persistence: Apply/Save/Defaults/Load/fallback/Reset, real config loader reboot projection and stale generation/replay isolation");
|
||||
|
||||
on_dispatcher = true; char *set[] = {"display", "set", "dim-seconds", "10"}; assert(command_display(4, set) == 0); on_dispatcher = false;
|
||||
uint32_t selected = s_config_generation;
|
||||
char body[96]; snprintf(body, sizeof(body), "{\"action\":\"save\",\"generation\":%u}", selected); submit(&admin, body);
|
||||
on_dispatcher = true; set[3] = "20"; assert(command_display(4, set) == 0); on_dispatcher = false;
|
||||
execute(); assert(s_operation.state == CONFLICT && s_config.dim_timeout_seconds == 20 && persisted.dim_timeout_seconds == 300);
|
||||
storage_hook = concurrent; action(&admin, "save"); assert(s_operation.state == OK && persisted.dim_timeout_seconds == 20);
|
||||
storage_error = ESP_FAIL; selected = s_config_generation; action(&admin, "reset");
|
||||
assert(s_operation.state == FAILED && s_config_generation == selected && s_config.dim_timeout_seconds == 20);
|
||||
action(&admin, "load"); assert(s_operation.state == FAILED && s_config_generation == selected); storage_error = ESP_OK;
|
||||
for (unsigned failure = 1; failure <= 4; ++failure) {
|
||||
storage_fail_at = failure;
|
||||
local_ui_config_t old_working = s_config, old_saved = persisted;
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
action(&admin, i == 0 ? "save" : i == 1 ? "reset" : "load");
|
||||
assert(s_operation.state == FAILED && !s_config_busy && s_config_generation == selected);
|
||||
assert(!memcmp(&old_working, &s_config, sizeof(s_config)) && !memcmp(&old_saved, &persisted, sizeof(persisted)));
|
||||
}
|
||||
}
|
||||
storage_fail_at = 0;
|
||||
for (unsigned i = SAVE; i < ACTION_COUNT; ++i) {
|
||||
on_dispatcher = true; char *args[] = {"display", (char *)s_actions[i]}; assert(command_display(2, args) == 0); on_dispatcher = false;
|
||||
}
|
||||
s_config_generation = UINT32_MAX;
|
||||
assert(local_status_ui_apply_config(&s_config) == ESP_ERR_INVALID_STATE); s_config_generation = selected + 10;
|
||||
puts("PASS Display canonical concurrency: CLI generation conflicts, storage reservation, concurrent activity/diagnostic hold, failed persistence leaves RAM unchanged, no wrap");
|
||||
|
||||
snprintf(body, sizeof(body), "{\"action\":\"save\",\"generation\":%u}", s_config_generation);
|
||||
submit(&admin, body); unsigned before = storage_calls;
|
||||
web_display_settings_execute(0); web_display_settings_execute(first); assert(storage_calls == before && s_operation.state == PENDING);
|
||||
now += 30000000; execute(); assert(s_operation.state == CANCELLED && storage_calls == before);
|
||||
submit(&admin, body); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin, body); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED); admin = mint(&alice);
|
||||
submit(&admin, body); stale_user = alice.user_id; execute(); stale_user = 0; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); now = admin.view.expires_at_us - 1; submit(&admin, body); now = admin.view.expires_at_us; execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin, body); storage_hook = revoke; execute(); assert(s_operation.state == OK);
|
||||
operation_begin(&admin, NULL); display_expect("401 Unauthorized", false);
|
||||
admin = mint(&alice); submit(&admin, body); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); operation_begin(&admin, NULL); display_expect("200 OK", false); assert(strstr(output, "idle"));
|
||||
puts("PASS Display session lifecycle: deadline, expiry/revocation/missed notification/database failure, admitted completion, stop/restart fencing");
|
||||
send_fail = true; submit(&admin, body); send_fail = false; execute(); assert(s_operation.state == OK);
|
||||
operation_begin(&admin, NULL); display_expect("200 OK", false); assert(strstr(output, "ok"));
|
||||
s_next_id = UINT32_MAX; operation_begin(&admin, body); display_expect("503 Service Unavailable", false);
|
||||
puts("PASS Display operation results: lost acknowledgement retains result, no automatic replay, nonwrapping operation IDs");
|
||||
}
|
||||
@@ -59,6 +59,24 @@ admin = "--admin" in sys.argv
|
||||
settings = "--settings" in sys.argv
|
||||
serial_settings = "--serial-settings" in sys.argv
|
||||
accounts = "--accounts" 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'
|
||||
HEADERS["nvs.h"] = '''#pragma once
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
typedef int nvs_handle_t;
|
||||
#define NVS_READONLY 0
|
||||
#define NVS_READWRITE 1
|
||||
#define ESP_ERR_NVS_NOT_FOUND 0x1102
|
||||
#define ESP_ERR_NVS_TYPE_MISMATCH 0x1103
|
||||
#define ESP_ERR_NVS_INVALID_LENGTH 0x110c
|
||||
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);
|
||||
'''
|
||||
network = "--network" in sys.argv
|
||||
if network:
|
||||
HEADERS["esp_wifi_types.h"] = "#pragma once\ntypedef int wifi_auth_mode_t;\n"
|
||||
@@ -186,6 +204,13 @@ 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 display:
|
||||
ui_source = (ROOT / 'src/local_status_ui.c').read_text()
|
||||
names = ('local_status_ui_get_config', 'local_status_ui_get_settings', 'local_status_ui_update_settings', 'local_status_ui_apply_config', 'local_status_ui_hold_for_diagnostics')
|
||||
(tmp / 'display_owner_production.h').write_text('\n'.join(function(ui_source, name) for name in names))
|
||||
console_source = (ROOT / 'src/local_ui_console.c').read_text()
|
||||
names = ('print_usage', 'print_config', 'parse_timeout', 'apply_parameter', 'command_display')
|
||||
(tmp / 'display_console_production.h').write_text('\n'.join(function(console_source, name) for name in names))
|
||||
if network:
|
||||
wifi_source = (ROOT / 'src/wifi_config.c').read_text()
|
||||
mdns_source = (ROOT / 'src/mdns_config.c').read_text()
|
||||
@@ -221,6 +246,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings else []),
|
||||
*(["-DHOST_ACCOUNTS"] if accounts else []),
|
||||
*(["-DHOST_NETWORK"] if network else []),
|
||||
*(["-DHOST_DISPLAY"] if display 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)
|
||||
|
||||
@@ -143,6 +143,9 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_NETWORK
|
||||
#include "network_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_DISPLAY
|
||||
#include "display_settings_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -302,6 +305,9 @@ int main(void) {
|
||||
#endif
|
||||
#ifdef HOST_NETWORK
|
||||
network_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_DISPLAY
|
||||
display_settings_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -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': []};
|
||||
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': []};
|
||||
const fits = [];
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
@@ -1251,5 +1251,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
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});
|
||||
await require('./display.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; });
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'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/display', op = path + '-operation';
|
||||
const fixture = (extra = {}) => ({generation: 7, dim_seconds: 300, off_seconds: 600, ...extra});
|
||||
const reply = (action = 'apply', state = 'pending', id = 42, status = 200) => new Response(JSON.stringify({id, action, state}), {status});
|
||||
const ack = action => reply(action, 'pending', 42, 202);
|
||||
const n = (b, id) => b.nodes['display-' + id];
|
||||
const posts = b => b.calls.filter(c => c.url === op && c.method === 'POST');
|
||||
const gets = b => b.calls.filter(c => c.url === op && c.method === 'GET');
|
||||
const reads = b => b.calls.filter(c => c.url === path);
|
||||
async function open(value = fixture()) {
|
||||
const b = await adminBrowser(); b.click('select-settings'); await tick();
|
||||
b.queues[path].push(json(value)); b.click('settings-display'); await tick(); return b;
|
||||
}
|
||||
async function complete(b, action, state = 'ok') {
|
||||
b.queues[op].push(reply(action, state)); b.queues[path].push(json(fixture({generation: 8})));
|
||||
b.fire(1000); await tick();
|
||||
}
|
||||
await test('Display admin-only entry, actual label/value controls, absent-panel policy and navigation preserves terminals/lease', async () => {
|
||||
for (const id of ['settings-display','display-values','display-edit-dim_seconds','display-edit-off_seconds','display-refresh','display-result','display-apply','display-save','display-load','display-defaults','display-reset']) assert.ok(html.includes('id="' + id + '"'));
|
||||
assert.match(html, /absent panel/); assert.match(html, /0–86400/);
|
||||
const u = browser(); u.start(); await tick(); u.click('settings-display'); await tick(); assert.equal(reads(u).length, 0);
|
||||
const b = await open(); assert.equal(n(b,'dim_seconds').textContent, '300'); assert.equal(n(b,'edit-off_seconds').value, '600');
|
||||
assert.equal(n(b,'settings').hidden, false); assert.equal(b.nodes['network-settings'].hidden, true);
|
||||
const count = b.calls.length; b.click('settings-display'); b.click('select-settings'); await tick(); assert.equal(b.calls.length, count);
|
||||
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('settings-serial'); await tick(); assert.ok(b.sockets.every(s => !s.closed)); assert.equal(b.sockets.length, 2);
|
||||
});
|
||||
await test('Display strict snapshots reject extra/missing/type/range/order/status/oversized data and disable stale edits', async () => {
|
||||
for (const value of [null, {}, fixture({generation:0}), fixture({generation:4294967296}), fixture({dim_seconds:'0'}), fixture({off_seconds:86401}), fixture({dim_seconds:600}), fixture({extra:1})]) {
|
||||
const b = await open(value); assert.ok(n(b,'apply').disabled); assert.match(n(b,'detail').textContent, /unavailable|invalid/);
|
||||
}
|
||||
const b = await open(); b.queues[path].push(new Response('x'.repeat(129))); b.click('display-refresh'); await tick(); assert.ok(n(b,'apply').disabled);
|
||||
b.queues[path].push(new Response(JSON.stringify(fixture()), {status:202})); b.click('display-refresh'); await tick(); assert.ok(n(b,'apply').disabled);
|
||||
});
|
||||
await test('Display typed Apply validates integer/zero/timeout order; bounded POST carries selected generation and CSRF', async () => {
|
||||
const b = await open();
|
||||
for (const [dim,off] of [['-1','600'],['1.5','600'],['1e2','600'],['01','600'],['86401','0'],['600','600'],['601','600'],['','600']]) {
|
||||
n(b,'edit-dim_seconds').value = dim; n(b,'edit-off_seconds').value = off; b.click('display-apply'); await tick(); assert.equal(posts(b).length,0);
|
||||
}
|
||||
n(b,'edit-dim_seconds').value = '0'; n(b,'edit-off_seconds').value = '86400'; b.queues[op].push(ack('apply')); b.click('display-apply'); await tick();
|
||||
const post = posts(b)[0]; assert.deepEqual(JSON.parse(post.body), {action:'apply',generation:7,dim_seconds:0,off_seconds:86400});
|
||||
assert.equal(post.headers['X-CSRF-Token'],token); assert.equal(post.mode,'cors'); assert.ok(post.body.length <= 256);
|
||||
assert.ok(n(b,'apply').disabled); assert.equal(n(b,'values').hidden,false);
|
||||
b.click('display-apply'); await tick(); assert.equal(posts(b).length,1);
|
||||
await complete(b,'apply'); assert.equal(reads(b).length,2); assert.equal(n(b,'apply').disabled,false); assert.match(n(b,'operation-detail').textContent,/completed/);
|
||||
});
|
||||
await test('Display explicit Save/Load/Defaults/Reset use working generation not drafts; only Reset confirms', async () => {
|
||||
for (const action of ['save','load','defaults','reset']) {
|
||||
const b = await open(); n(b,'edit-dim_seconds').value='123'; let confirmations=0;
|
||||
b.window.confirm=()=>{++confirmations; return false;};
|
||||
if (action==='reset') {b.click('display-reset'); await tick(); assert.equal(posts(b).length,0); assert.equal(confirmations,1);}
|
||||
b.window.confirm=()=>{++confirmations; return true;}; b.queues[op].push(ack(action)); b.click('display-'+action); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7}); assert.equal(confirmations,action==='reset'?2:0);
|
||||
await complete(b,action,action==='load'?'loaded_defaults':'ok'); assert.equal(reads(b).length,2);
|
||||
}
|
||||
});
|
||||
await test('Display terminal failure/conflict/cancellation refreshes once without replay or success claims', async () => {
|
||||
for (const state of ['failed','conflict','cancelled']) {
|
||||
const b=await open(); b.queues[op].push(ack('save')); b.click('display-save'); await tick(); await complete(b,'save',state);
|
||||
assert.equal(posts(b).length,1); assert.equal(reads(b).length,2); assert.doesNotMatch(n(b,'operation-detail').textContent,/Operation completed/);
|
||||
}
|
||||
});
|
||||
await test('Display ten-poll and fifteen-second deadline bounds include delayed session work', async () => {
|
||||
const b=await open(); b.queues[op].push(ack('save')); b.click('display-save'); await tick();
|
||||
for(let i=0;i<10;i++){b.queues[op].push(reply('save')); b.fire(1000); await tick();}
|
||||
assert.equal(gets(b).length,10); assert.equal(posts(b).length,1); assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
|
||||
assert.match(n(b,'operation-detail').textContent,/Automatic checking stopped/);
|
||||
const c=await open(); c.queues[op].push(ack('save')); c.click('display-save'); await tick();
|
||||
const d=deferred(); c.queues['/api/session'].push(d.promise); c.fire(1000); await tick(); c.elapse(15000); c.fire(15000); await tick();
|
||||
d.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(gets(c).length,0); assert.equal(posts(c).length,1);
|
||||
});
|
||||
await test('Display lost ACK/result replacement/same-ID action mismatch preserve uncertainty and stop automatic following', async () => {
|
||||
const b=await open(); b.queues[op].push(()=>{throw Error('lost');}); b.click('display-save'); await tick();
|
||||
assert.equal(posts(b).length,1); assert.match(n(b,'operation-detail').textContent,/unknown/);
|
||||
b.queues[op].push(reply('save','ok')); b.queues[path].push(json(fixture())); b.click('display-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/acknowledgement was lost/);
|
||||
for(const changed of [reply('save','pending',43), reply('reset','ok',42)]) {
|
||||
const c=await open(); c.queues[op].push(ack('save')); c.click('display-save'); await tick(); c.queues[op].push(changed); c.fire(1000); await tick();
|
||||
assert.ok(![...c.timers.values()].some(t=>t.ms===1000||t.ms===15000)); assert.match(n(c,'operation-detail').textContent,/unknown/); assert.equal(posts(c).length,1);
|
||||
}
|
||||
});
|
||||
await test('Display operation rejects malformed status/schema/ID/action/state and impossible loaded-defaults replies', async () => {
|
||||
for(const r of [reply('save','pending',42,200),reply('save','ok',42,202),reply('reset','pending',42,202),new Response(JSON.stringify({id:42,action:'save',state:'pending',extra:1}),{status:202})]) {
|
||||
const b=await open(); b.queues[op].push(r); b.click('display-save'); await tick(); assert.match(n(b,'operation-detail').textContent,/unknown/); assert.equal(gets(b).length,0);
|
||||
}
|
||||
const b=await open(); b.queues[op].push(reply('save','loaded_defaults')); b.click('display-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/unknown/);
|
||||
});
|
||||
await test('Display navigation fences pending read/POST/result and never resumes/replays on return', async () => {
|
||||
for(const stage of ['snapshot','post','result']) {
|
||||
const b=await open(); const d=deferred();
|
||||
if(stage==='snapshot'){b.queues[path].push(d.promise); b.click('display-refresh');}
|
||||
else if(stage==='post'){b.queues[op].push(d.promise); b.click('display-save');}
|
||||
else {b.queues[op].push(ack('save')); b.click('display-save'); await tick(); b.queues[op].push(d.promise); b.fire(1000);}
|
||||
await tick(); b.click('settings-serial'); await tick();
|
||||
const count=posts(b).length; d.resolve(stage==='snapshot'?json(fixture({generation:99})):stage==='post'?ack('save'):reply('save','ok')); await tick();
|
||||
assert.equal(n(b,'edit-dim_seconds').value,''); assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
|
||||
b.queues[path].push(json(fixture())); b.click('settings-display'); await tick(); assert.equal(posts(b).length,count); assert.ok(b.sockets.every(s=>!s.closed));
|
||||
}
|
||||
});
|
||||
await test('Display endpoint401 and identity replacement close both routes; stale401 after navigation cannot expire current view', async () => {
|
||||
for(const route of [path,op]) {
|
||||
const b=await open(); b.queues[route].push(failure(401)); b.click(route===path?'display-refresh':'display-result'); await tick();
|
||||
assert.deepEqual(b.redirects,['/login']); assert.ok(b.sockets.every(s=>s.closed)); assert.equal(n(b,'edit-dim_seconds').value,'');
|
||||
}
|
||||
const b=await open(); const d=deferred(); b.queues[path].push(d.promise); b.click('display-refresh'); await tick(); b.click('settings-serial'); await tick(); d.resolve(failure(401)); await tick(); assert.equal(b.redirects.length,0);
|
||||
const c=await open(); c.queues['/api/session'].push(session({role:'admin',username:'replacement'})); c.click('display-save'); await tick(); assert.deepEqual(c.redirects,['/']); assert.equal(posts(c).length,0);
|
||||
});
|
||||
await test('Display pagehide/expiry/logout fence drafts and in-flight work without backend cancellation claims', async () => {
|
||||
for(const event of ['pagehide','expiry','logout']) {
|
||||
const b=await open(); const d=deferred(); b.queues[op].push(d.promise); b.click('display-save'); await tick();
|
||||
if(event==='pagehide') b.emit('pagehide'); else if(event==='expiry') b.window.sakSessionExpired(); else {b.queues['/api/logout'].push(new Response(null,{status:204})); b.click('sign-out');}
|
||||
await tick(); d.resolve(ack('save')); await tick(); assert.ok(b.sockets.every(s=>s.closed)); assert.equal(n(b,'edit-dim_seconds').value,''); assert.equal(posts(b).length,1);
|
||||
assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -45,10 +45,10 @@ def check_layout(html):
|
||||
if cls in classes(node):
|
||||
return node
|
||||
raise AssertionError(cls)
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary'):
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values'):
|
||||
assert ids[ident]['tag'] == 'dl'
|
||||
assert 'settings-values' in classes(ids[ident])
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings'):
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-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')
|
||||
@@ -59,9 +59,9 @@ def check_layout(html):
|
||||
ancestor(n, 'settings-edit')
|
||||
except AssertionError:
|
||||
ancestor(n, 'serial-edit')
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh'):
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh'):
|
||||
assert ids[ident]['text'] == 'Refresh'
|
||||
for ident in ('serial-result', 'account-result', 'network-result'):
|
||||
for ident in ('serial-result', 'account-result', 'network-result', 'display-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'])
|
||||
@@ -90,7 +90,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 three settings views')
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all four settings views')
|
||||
|
||||
|
||||
def check_browser_layout(html, tmp, executable):
|
||||
@@ -103,13 +103,13 @@ def check_browser_layout(html, tmp, executable):
|
||||
fixture = re.sub(r'<link\b[^>]*>|<img\b[^>]*>', '', fixture)
|
||||
probe = r'''
|
||||
const cases = [];
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings']) {
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) {
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
|
||||
cases.push(new Promise(resolve => {
|
||||
frame.onload = () => {
|
||||
const d = frame.contentDocument, win = frame.contentWindow;
|
||||
d.getElementById('serial-settings').hidden = false;
|
||||
for (const id of ['serial-settings-content', 'account-settings', 'network-settings']) d.getElementById(id).hidden = id !== view;
|
||||
for (const id of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) d.getElementById(id).hidden = id !== view;
|
||||
const section = d.getElementById(view);
|
||||
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
|
||||
section.querySelectorAll('dl').forEach(dl => {
|
||||
@@ -166,6 +166,6 @@ def check_browser_layout(html, tmp, executable):
|
||||
assert result.returncode == 0, result.stderr
|
||||
parsed = Document(result.stdout)
|
||||
results = json.loads(parsed.ids['layout-results']['text'])
|
||||
assert len(results) == 9
|
||||
assert len(results) == 12
|
||||
assert all(not case['errors'] for case in results), results
|
||||
print('PASS Chromium layout: all three settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
|
||||
print('PASS Chromium layout: all four settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
|
||||
Reference in New Issue
Block a user