Add typed serial settings operations

Route bounded admin mutations through the existing administration
dispatcher,
covering apply, lifecycle, persistence, authorization, and result
tracking.
Add the browser controls, automatic result refresh, regression coverage,
and
phase documentation.
This commit is contained in:
2026-09-08 00:25:31 +02:00
parent 5a2aa0d4d8
commit 42548f6334
27 changed files with 1398 additions and 42 deletions
+8 -1
View File
@@ -31,6 +31,13 @@ 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;
static unsigned serial_settings_preceding_runs, queue_send_wait;
static void web_serial_settings_execute(uint32_t id) {
assert(!lock_depth);
serial_settings_executed = id;
serial_settings_preceding_runs = runs;
}
static bool principal_current = true, queue_full, owner_drained = true;
static TaskHandle_t current_task = (void *)1;
static jmp_buf loop_done;
@@ -58,7 +65,7 @@ static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1
static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q)
{ (void)b; q->size = s; q->capacity = n; assert(n*s <= sizeof(q->bytes)); return q; }
static int xQueueSend(QueueHandle_t q, const void *p, unsigned t)
{ (void)t; if (queue_full || q->count==q->capacity) return 0;
{ queue_send_wait=t; if (queue_full || q->count==q->capacity) return 0;
memcpy(q->bytes+q->count*q->size,p,q->size); ++q->count; return 1; }
static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size);
+27
View File
@@ -313,5 +313,32 @@ int main(void)
assert(!lock_depth);
test_currentness();
test_dispatch_currentness();
unsigned before_serial = runs;
assert(admin_ssh_console_submit_serial_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_serial_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_serial_settings(1) == ESP_ERR_TIMEOUT);
queue_full = false;
admin_request_t preceding_uart = {.origin = ADMIN_REQUEST_UART0};
assert(xQueueSend(s_request_queue, &preceding_uart, 0));
queue_send_wait = portMAX_DELAY;
assert(admin_ssh_console_submit_serial_settings(17) == ESP_OK && !serial_settings_executed);
assert(queue_send_wait == 0);
admin_request_t typed;
memcpy(&typed, s_request_queue->bytes + sizeof(typed), sizeof(typed));
assert(typed.origin == ADMIN_REQUEST_SERIAL_SETTINGS && typed.serial_settings_id == 17);
assert(s_request_queue->capacity == 4 && sizeof(typed.line) == 257);
pump(worker_task);
assert(serial_settings_executed == 17 && runs == before_serial + 1 && !s_request_queue->count);
assert(serial_settings_preceding_runs == before_serial + 1);
for (unsigned i = 0; i < s_request_queue->capacity; ++i)
assert(xQueueSend(s_request_queue, &preceding_uart, 0));
queue_send_wait = portMAX_DELAY;
assert(admin_ssh_console_submit_serial_settings(18) == ESP_ERR_TIMEOUT);
assert(queue_send_wait == 0 && s_request_queue->count == 4 && serial_settings_executed == 17);
pump(worker_task);
assert(runs == before_serial + 5 && serial_settings_executed == 17 && !s_request_queue->count);
puts("PASS: typed Serial admission uses zero wait on success/full queue, preserves all four queued UART requests and FIFO execution, no command-string dispatch");
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");
}
+30 -11
View File
@@ -37,8 +37,8 @@ def define(path, name):
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 14:
raise RuntimeError('Review URI extraction: expected 12 descriptors and two tables')
if len(uri_tables) != 16:
raise RuntimeError('Review URI extraction: expected 14 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()
@@ -95,6 +95,7 @@ static unsigned registration_calls, registration_fail_at, registered_count, unre
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[32];
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; }
@@ -107,6 +108,7 @@ HANDLER(root_handler) HANDLER(status_handler) HANDLER(ticket_handler)
HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
HANDLER(serial_settings_handler)
HANDLER(web_serial_settings_handler)
static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; }
static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; }
static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; }
@@ -118,7 +120,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 == 17 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 19 && 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->servercert_len == 1 && config->servercert[0] == 1);
@@ -151,9 +153,17 @@ static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_u
assert(!strcmp(uri->uri, "/api/settings/serial"));
return httpd_register_uri_handler(s, uri);
}
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!strcmp(uri->uri, "/api/settings/serial-operation") && uri->handler == web_serial_settings_handler);
if (++operation_calls == operation_fail_at) return ESP_ERR_NO_MEM;
registered[registered_count++] = uri;
return ESP_OK;
}
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);
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
(!strcmp(uri, "/api/settings/serial-operation") && method == HTTP_GET));
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
@@ -217,6 +227,7 @@ static void reset(void) {
serial_attaches = serial_detaches = admin_attaches = admin_detaches = admin_stoppeds = 0;
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
operation_calls = operation_fail_at = 0;
}
static void fresh_registration(void) { registration_calls = registered_count = 0; }
static void start(void) {
@@ -250,7 +261,7 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 17 && registration_calls == 18 && settings_calls == 1);
reset(); start(); assert(registered_count == 19 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin");
assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket);
@@ -302,7 +313,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 == 15 && unregister_calls == failure - 17);
assert(registered_count == 17 && 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 == websocket_handler);
@@ -311,13 +322,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 == 17 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 19 && 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 == 16);
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 18);
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");
@@ -329,7 +340,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 == 17 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 19 && 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;
@@ -351,13 +362,21 @@ 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 == 16);
assert(settings_calls == 1 && registered_count == 18);
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);
assert(web_server_stop() == ESP_OK);
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
puts("12 lifecycle groups passed (16 required fatal positions, 3 optional routes, plus failed unregister)");
for (unsigned failure = 1; failure <= 2; ++failure) {
reset(); operation_fail_at = failure; start();
assert(registered_count == 17 && 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);
}
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
puts("13 lifecycle groups passed (16 required fatal positions, 5 optional routes, plus failed unregister)");
return 0;
}
'''
+26
View File
@@ -44,6 +44,7 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
admin = "--admin" in sys.argv
settings = "--settings" in sys.argv
serial_settings = "--serial-settings" in sys.argv
if admin:
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
HEADERS["esp_heap_caps.h"] = """#pragma once
@@ -113,7 +114,31 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
settings_source += function(config_source[config_source.index('const char *serial_config_' + name + '_to_string'):], 'serial_config_' + name + '_to_string') + '\n'
for name in ('set_common_headers', 'send_plain_error', 'authorize_or_respond', 'safe_string', 'serial_settings_handler'):
settings_source += function(server_source, name) + '\n'
# Exercise the serial projection of /api/status without doubling every
# unrelated subsystem. Copy its acquisition, format and arguments verbatim.
status = function(server_source, 'status_handler')
assert 'serial_service_get_config(' not in status, 'status must not use the blocking config getter'
assert 'serial_service_is_running(' not in status, 'status must use snapshot running state'
assert status.count('serial_service_get_snapshot(') == 1
acquisition = status[status.index(' bool serial_config_available ='):
status.index(' serial_service_get_counters(')]
serial_format = status[status.index(' " \\"serial\\":'):
status.index(' " \\"broker\\":')]
argument_start = status.index(' wifi_available ? (unsigned int)wifi.ap_client_count : 0U,')
argument_start = status.index('\n', argument_start) + 1
serial_arguments = status[argument_start:status.index(' broker_available ? "true"')].rstrip().removesuffix(',')
settings_source += '\nstatic int status_serial_projection(char *response, size_t capacity) {\n'
settings_source += ' serial_service_snapshot_t serial_snapshot = {0};\n'
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 serial_settings:
config_source = (ROOT / 'src/serial_config.c').read_text()
names = ['serial_config_defaults', 'serial_config_validate']
names += ['serial_config_parse_' + name for name in ('data_bits', 'parity', 'stop_bits', 'flow_control', 'dtr_behavior')]
(tmp / 'serial_config_production.h').write_text('\n'.join(function(config_source, name) for name in names))
console_source = (ROOT / 'src/serial_console.c').read_text()
(tmp / 'serial_console_production.h').write_text('\n'.join(function(console_source, name) for name in ('parse_unsigned', 'set_parameter', 'command_serial')))
sources = [HERE / "test.c", tmp / "installed_httpd.c"]
sources += [ROOT / "src" / name for name in ["web_session_store.c", "web_auth_parse.c", "web_cookie_auth.c", "web_httpd_adapter.c"]]
if admin:
@@ -129,6 +154,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g", "-DHOST_OPENSSL",
*(["-DHOST_ADMIN"] if admin else []),
*(["-DHOST_SETTINGS"] if settings else []),
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings 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)
@@ -0,0 +1,264 @@
/* Real HTTP policy/store/parser/operation owner; deterministic serial/NVS/queue. */
#include "serial_service.h"
#include <errno.h>
#include <stdlib.h>
#define ESP_ERR_TIMEOUT 0x107
#include "serial_config_production.h"
#include "../../src/web_serial_settings.c"
static serial_config_t working, persisted;
static bool on_dispatcher, have_stored = true, queue_fail;
static unsigned api_calls, apply_calls, save_calls, load_calls, start_calls, stop_calls;
static unsigned apply_fail_at;
static esp_err_t get_error, save_error, load_error, start_error, stop_error;
static uint32_t queued_id;
static void (*apply_hook)(void);
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id) {
assert(!host_lock_depth && !on_dispatcher && id);
if (queue_fail) return ESP_ERR_TIMEOUT;
queued_id = id; return ESP_OK;
}
static void serial_api(void) { assert(on_dispatcher && !host_lock_depth); ++api_calls; }
esp_err_t serial_service_apply_config(const serial_config_t *config) {
serial_api(); ++apply_calls;
assert(serial_config_validate(config) == ESP_OK);
if (apply_hook) { void (*hook)(void) = apply_hook; apply_hook = NULL; hook(); }
if (apply_fail_at == apply_calls) return ESP_FAIL;
working = *config; return ESP_OK;
}
esp_err_t serial_service_get_config(serial_config_t *config) {
serial_api(); *config = working; return get_error;
}
esp_err_t serial_service_start(void) { serial_api(); ++start_calls; return start_error; }
esp_err_t serial_service_stop(void) { serial_api(); ++stop_calls; return stop_error; }
esp_err_t serial_config_save(const serial_config_t *config) {
serial_api(); ++save_calls;
if (save_error == ESP_OK) { persisted = *config; have_stored = true; }
return save_error;
}
esp_err_t serial_config_load(serial_config_t *config, bool *stored) {
serial_api(); ++load_calls; *stored = have_stored;
if (have_stored) *config = persisted; else serial_config_defaults(config);
return load_error;
}
esp_err_t serial_config_reset_storage(void) {
serial_config_t defaults; serial_config_defaults(&defaults); return serial_config_save(&defaults);
}
static void operation_begin(const issued_t *identity, const char *body) {
begin("/api/settings/serial-operation", body ? HTTP_POST : HTTP_GET, body);
same_origin();
if (body) add("Content-Type", "application/json");
if (identity) {
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", identity->token);
add("Cookie", cookies);
if (body) add("X-CSRF-Token", identity->view.csrf);
}
}
static void operation_expect(const char *status) {
unsigned before = api_calls;
esp_err_t error = web_serial_settings_handler(&req);
assert(error == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
if (strcmp(response_status, status)) fprintf(stderr, "Expected %s, got %s\n", status, response_status);
assert(!strcmp(response_status, status) && api_calls == before);
assert(strlen(output) < 96); zero(scratch, sizeof(scratch));
}
static void execute(void) {
assert(queued_id); on_dispatcher = true;
web_serial_settings_execute(queued_id); on_dispatcher = false;
}
static void submit(const issued_t *identity, const char *body) {
operation_begin(identity, body); operation_expect("202 Accepted");
assert(s_operation.state == PENDING && queued_id == s_operation.id);
}
static void action(const issued_t *identity, const char *name) {
char body[40]; snprintf(body, sizeof(body), "{\"action\":\"%s\"}", name);
submit(identity, body); execute();
}
static const char apply_body[] = "{\"action\":\"apply\",\"baud\":230400,\"data_bits\":\"7\",\"parity\":\"even\",\"stop_bits\":\"2\",\"flow\":\"rts-cts\",\"dtr\":\"on-connect\",\"rts_threshold\":96}";
static void revoke_during_apply(void) { web_session_store_invalidate(s_operation.session); }
static issued_t executing_identity;
static void request_during_apply(void) {
serial_operation_t executing = s_operation;
operation_begin(&executing_identity, "{\"action\":\"reset\"}");
operation_expect("503 Service Unavailable");
assert(!memcmp(&executing, &s_operation, sizeof(executing)));
operation_begin(&executing_identity, NULL); operation_expect("200 OK");
assert(strstr(output, "\"state\":\"pending\""));
}
static int show_status(void) { return 0; }
static int show_counters(void) { return 0; }
static void print_config(const serial_config_t *config) { (void)config; }
static void print_usage(void) {}
bool serial_service_is_running(void) { return false; }
void serial_service_clear_counters(void) {}
const char *esp_err_to_name(esp_err_t error) { (void)error; return "fake error"; }
#include "serial_console_production.h"
static void serial_settings_tests(void) {
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
receive_fragment = 64;
serial_config_defaults(&working); persisted = working;
operation_begin(NULL, "{\"action\":\"save\"}"); operation_expect("401 Unauthorized");
operation_begin(&user, "{\"action\":\"save\"}"); operation_expect("403 Forbidden");
operation_begin(&user, NULL); operation_expect("403 Forbidden");
for (unsigned mode = 0; mode < 9; ++mode) {
operation_begin(&admin, "{\"action\":\"save\"}");
if (mode == 0) req.content_len = aux.remaining_len = 257;
if (mode == 1) req.uri = "/api/settings/serial-operation?command=save";
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) { begin("/api/settings/serial-operation", HTTP_POST, "{\"action\":\"save\"}"); same_origin(); }
if (mode == 8) add("Sec-Fetch-Site", "cross-site");
unsigned before = s_next_id;
(void)web_serial_settings_handler(&req);
assert(strncmp(response_status, "4", 1) == 0 && s_next_id == before && !api_calls);
}
puts("PASS Serial mutation security: cookie/admin/current policy, duplicate headers, CSRF/Origin, body/query/method/framing bounds before admission");
const char *invalid[] = {"{}", "[]", "{\"action\":\"serial save\"}", "{\"action\":\"save\",\"action\":\"stop\"}",
"{\"action\":\"apply\"}", "{\"action\":\"save\",\"baud\":110}", "{\"action\":\"save\",}",
"{\"action\":\"save\"}x", "{\"action\":true}", "{\"action\":\"sa\\u0076e\"}", "{\"command\":\"save\"}",
"{\"baud\":1e3,\"action\":\"apply\"}", "{\"baud\":-1,\"action\":\"apply\"}", "{\"baud\":0110,\"action\":\"apply\"}",
"{\"baud\":42949672960,\"action\":\"apply\"}", "{\"baud\":1.1,\"action\":\"apply\"}"};
for (unsigned i = 0; i < sizeof(invalid) / sizeof(*invalid); ++i) {
operation_begin(&admin, invalid[i]); operation_expect("400 Bad Request");
}
serial_operation_t parsed;
assert(parse(apply_body, strlen(apply_body), &parsed));
char bad[256];
const char *fields[] = {"230400", "\"7\"", "\"even\"", "\"2\"", "\"rts-cts\"", "\"on-connect\"", "96"};
const char *replacements[] = {"109", "\"9\"", "\"mark\"", "\"1.5\"", "\"xon-xoff\"", "\"bad\"", "128"};
for (unsigned i = 0; i < 7; ++i) {
const char *at = strstr(apply_body, fields[i]); assert(at);
snprintf(bad, sizeof(bad), "%.*s%s%s", (int)(at - apply_body), apply_body, replacements[i], at + strlen(fields[i]));
operation_begin(&admin, bad); operation_expect("400 Bad Request");
}
for (size_t size = 0; size < strlen(apply_body); ++size) assert(!parse(apply_body, size, &parsed));
for (unsigned bits = 7; bits <= 8; ++bits)
for (unsigned parity = 0; parity < 3; ++parity)
for (unsigned stops = 1; stops <= 2; ++stops)
for (unsigned flow = 0; flow < 2; ++flow)
for (unsigned dtr = 0; dtr < 3; ++dtr)
for (unsigned baud = 0; baud < 2; ++baud)
for (unsigned threshold = 0; threshold < 2; ++threshold) {
const char *parities[] = {"none", "even", "odd"};
const char *flows[] = {"none", "rts-cts"};
const char *dtrs[] = {"inactive", "active", "on-connect"};
int size = snprintf(bad, sizeof(bad),
"{\"rts_threshold\":%u,\"dtr\":\"%s\",\"flow\":\"%s\",\"stop_bits\":\"%u\","
"\"parity\":\"%s\",\"data_bits\":\"%u\",\"baud\":%u,\"action\":\"apply\"}",
threshold ? 127U : 1U, dtrs[dtr], flows[flow], stops,
parities[parity], bits, baud ? 1000000U : 110U);
assert(size > 0 && (size_t)size < sizeof(bad) && parse(bad, (size_t)size, &parsed));
assert(parsed.config.baud_rate == (baud ? 1000000U : 110U));
assert(parsed.config.rts_threshold == (threshold ? 127U : 1U));
}
memcpy(bad, apply_body, sizeof(apply_body));
assert(!parse(bad, sizeof(apply_body), &parsed)); /* NUL is not JSON whitespace. */
char *control = strstr(bad, "on-connect"); assert(control); *control = '\1';
assert(!parse(bad, strlen(apply_body), &parsed));
receive_fragment = 1; operation_begin(&admin, apply_body); operation_expect("400 Bad Request"); assert(body_offset == 4);
receive_fragment = 64; recv_fail = true; operation_begin(&admin, apply_body); operation_expect("400 Bad Request"); recv_fail = false;
assert(!api_calls);
puts("PASS Serial JSON: exact seven-field Apply/action-only schema, enum/range rejection, truncation, unknown/duplicate/escaped/oversized numbers, four-read bound");
char full_body[257];
memset(full_body, ' ', sizeof(full_body) - 1);
memcpy(full_body, apply_body, strlen(apply_body)); full_body[256] = 0;
queue_fail = true; operation_begin(&admin, full_body); operation_expect("503 Service Unavailable"); queue_fail = false;
assert(body_offset == 256);
puts("PASS Serial boundaries: 288 valid framing/range combinations, action-last order, binary/control rejection, exact 256-byte/four-read body, rejected unread bodies close");
assert(s_operation.state == IDLE);
submit(&admin, apply_body); uint32_t first = queued_id;
operation_begin(&other, "{\"action\":\"stop\"}"); operation_expect("503 Service Unavailable"); assert(queued_id == first);
operation_begin(&other, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"idle\""));
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "pending"));
execute(); assert(s_operation.state == OK && working.baud_rate == 230400 && persisted.baud_rate == 115200);
zero(&s_operation.principal, sizeof(s_operation.principal)); zero(&s_operation.config, sizeof(s_operation.config));
unsigned before = api_calls; execute(); assert(api_calls == before);
action(&admin, "save"); assert(persisted.baud_rate == 230400 && s_operation.state == OK);
action(&admin, "defaults"); assert(working.baud_rate == 115200 && persisted.baud_rate == 230400);
action(&admin, "load"); assert(working.baud_rate == 230400);
have_stored = false; action(&admin, "load"); assert(s_operation.state == LOADED_DEFAULTS && working.baud_rate == 115200);
action(&admin, "start"); action(&admin, "stop"); assert(start_calls == 1 && stop_calls == 1);
action(&admin, "reset"); assert(s_operation.state == OK && persisted.baud_rate == 115200);
puts("PASS Serial ownership: no serial/NVS on HTTPD, single pending slot, isolated results, replay fence, explicit apply/save/load/default/reset/start/stop semantics");
working.baud_rate = 460800; persisted.baud_rate = 230400; save_error = ESP_FAIL;
action(&admin, "reset"); assert(s_operation.state == FAILED && working.baud_rate == 460800 && persisted.baud_rate == 230400);
apply_fail_at = apply_calls + 2; action(&admin, "reset"); assert(s_operation.state == ROLLBACK_FAILED);
save_error = ESP_OK; apply_fail_at = apply_calls + 1; before = save_calls;
action(&admin, "reset"); assert(s_operation.state == FAILED && save_calls == before);
apply_fail_at = 0; get_error = ESP_FAIL; action(&admin, "save"); assert(s_operation.state == FAILED && save_calls == before); get_error = ESP_OK;
load_error = ESP_FAIL; before = apply_calls; action(&admin, "load"); assert(s_operation.state == FAILED && apply_calls == before); load_error = ESP_OK;
start_error = stop_error = ESP_FAIL; action(&admin, "start"); assert(s_operation.state == FAILED); action(&admin, "stop"); assert(s_operation.state == FAILED);
start_error = stop_error = ESP_OK;
puts("PASS Serial failure ordering: apply/get/load/save/lifecycle failures, reset commit failure rollback and explicit failed rollback");
for (unsigned i = 0; i < ACTION_COUNT; ++i) {
working.baud_rate = 460800; persisted.baud_rate = 230400; have_stored = true;
if (i == APPLY) { submit(&admin, apply_body); execute(); }
else action(&admin, s_actions[i]);
serial_config_t typed_working = working, typed_persisted = persisted;
working.baud_rate = 460800; persisted.baud_rate = 230400;
on_dispatcher = true;
if (i == APPLY) {
const char *names[] = {"baud", "data-bits", "parity", "stop-bits", "flow", "dtr", "rts-threshold"};
const char *values[] = {"230400", "7", "even", "2", "rts-cts", "on-connect", "96"};
for (unsigned j = 0; j < 7; ++j) assert(set_parameter(names[j], values[j]) == 0);
} else {
char *args[] = {"serial", (char *)s_actions[i]}; assert(command_serial(2, args) == 0);
}
on_dispatcher = false;
assert(!memcmp(&working, &typed_working, sizeof(working)) && !memcmp(&persisted, &typed_persisted, sizeof(persisted)));
}
puts("PASS Serial CLI comparison: exact canonical command/set handlers and typed actions produce matching working/persisted config (UART/NVS doubled)");
submit(&admin, apply_body);
serial_operation_t pending = s_operation;
before = api_calls;
on_dispatcher = true;
web_serial_settings_execute(0); web_serial_settings_execute(first);
on_dispatcher = false;
assert(api_calls == before && !memcmp(&pending, &s_operation, sizeof(pending)));
executing_identity = admin; apply_hook = request_during_apply; execute();
assert(s_operation.state == OK && api_calls == before + 1);
operation_begin(&other, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"idle\""));
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"ok\""));
puts("PASS Serial interleaving: obsolete/zero IDs cannot execute replacement, executing slot rejects reset and remains pollable, completed result isolated by session");
submit(&admin, apply_body); before = api_calls; now += 30000000LL; execute(); assert(s_operation.state == CANCELLED && api_calls == before);
submit(&admin, apply_body); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED && api_calls == before);
admin = mint(&alice); submit(&admin, apply_body); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED && api_calls == before);
submit(&other, apply_body); apply_hook = revoke_during_apply; execute(); assert(s_operation.state == OK); /* admitted work can finish */
operation_begin(&other, NULL); operation_expect("401 Unauthorized");
web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); admin = mint(&alice);
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "idle"));
submit(&admin, apply_body); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); before = api_calls; execute(); assert(s_operation.state == CANCELLED && api_calls == before);
puts("PASS Serial currentness: queue deadline, session revocation, database failure, stop/restart IDs and admitted-work completion after revocation");
admin = mint(&alice); submit(&admin, apply_body); before = api_calls;
stale_user = alice.user_id; execute(); stale_user = 0;
assert(s_operation.state == CANCELLED && api_calls == before);
zero(&s_operation.principal, sizeof(s_operation.principal)); zero(&s_operation.config, sizeof(s_operation.config));
admin = mint(&alice); now = admin.view.expires_at_us - 1;
submit(&admin, apply_body);
now = admin.view.expires_at_us;
assert(now < s_operation.deadline); execute();
assert(s_operation.state == CANCELLED && api_calls == before);
puts("PASS Serial queued authorization: missed account revocation and absolute cookie expiry cancel without serial/NVS calls and wipe retained request data");
admin = mint(&alice); send_fail = true; submit(&admin, apply_body); send_fail = false; execute(); assert(s_operation.state == OK);
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"ok\""));
for (unsigned failure = 1; failure <= 3; ++failure) {
operation_begin(&admin, NULL); server.config.max_resp_headers = failure - 1;
before = sends; assert(web_serial_settings_handler(&req) != ESP_OK && sends == before);
}
server.config.max_resp_headers = 8;
s_next_id = UINT32_MAX; operation_begin(&admin, apply_body); operation_expect("503 Service Unavailable");
puts("PASS Serial result transport: lost acknowledgement does not cancel/replay, bounded headers/response, no ID wrap");
}
+51 -1
View File
@@ -12,6 +12,7 @@ static atomic_bool s_running;
static serial_config_t s_config;
static int s_state_mutex;
static unsigned serial_takes;
static bool change_after_snapshot;
static web_server_counters_t s_counters;
static unsigned allocations, allocation_fail_at, frees;
static httpd_uri_t *routes[2];
@@ -28,7 +29,13 @@ static int xSemaphoreTake(int mutex, unsigned wait) {
if (serial_busy) return 0;
serial_locked = true; return pdTRUE;
}
static void xSemaphoreGive(int mutex) { (void)mutex; assert(serial_locked); serial_locked = false; }
static void xSemaphoreGive(int mutex) {
(void)mutex; assert(serial_locked); serial_locked = false;
if (change_after_snapshot) {
s_running = !s_running;
s_config.baud_rate = 110;
}
}
static void increment_counter(uint64_t *counter) { ++*counter; }
esp_err_t httpd_resp_send(httpd_req_t *r, const char *body, int length) {
assert(!serial_locked && length >= 0 && length < 256 && (size_t)length == strlen(body));
@@ -54,6 +61,38 @@ static void settings_expect(const char *status) {
assert(strlen(output) < 128 && !body_offset);
zero(scratch, sizeof(scratch));
}
static void status_snapshot_tests(void) {
char response[256];
serial_config_t saved_config = s_config;
bool saved_running = s_running;
for (unsigned running = 0; running < 2; ++running) {
s_config = saved_config; s_running = running;
unsigned before = serial_takes;
change_after_snapshot = true;
int length = status_serial_projection(response, sizeof(response));
change_after_snapshot = false;
assert(length > 0 && (size_t)length < sizeof(response));
assert(serial_takes == before + 1 && !serial_locked);
assert(s_running == !running && s_config.baud_rate == 110);
assert(strstr(response, running
? "\"running\":true,\"config_available\":true,\"baud\":230400"
: "\"running\":false,\"config_available\":true,\"baud\":230400"));
}
for (unsigned unavailable = 0; unavailable < 2; ++unavailable)
for (unsigned running = 0; running < 2; ++running) {
s_running = running; s_config = saved_config;
serial_busy = unavailable == 0; s_initialized = unavailable == 0;
unsigned before = serial_takes;
int length = status_serial_projection(response, sizeof(response));
assert(length > 0 && (size_t)length < sizeof(response));
assert(serial_takes == before + (unavailable == 0) && !serial_locked);
assert(strstr(response, "\"running\":null,\"config_available\":false,\"baud\":0"));
assert(strstr(response, "\"data_bits\":\"unknown\""));
}
s_initialized = true; serial_busy = false;
s_config = saved_config; s_running = saved_running;
puts("PASS Status serial projection: same-snapshot running/config despite post-unlock changes, busy/uninitialized => null for either live state, zero-wait acquisition and no blocking getter");
}
static void settings_tests(void) {
httpd_uri_t existing = {.uri = "/", .method = HTTP_GET, .handler = serial_settings_handler};
httpd_uri_t route = {.uri = "/api/settings/serial", .method = HTTP_GET, .handler = serial_settings_handler};
@@ -84,6 +123,16 @@ static void settings_tests(void) {
}
assert(web_httpd_register_optional_get(NULL, &route) == ESP_ERR_INVALID_ARG);
assert(web_httpd_register_optional_get(&server, NULL) == ESP_ERR_INVALID_ARG);
other = route; other.method = HTTP_POST;
for (unsigned failure = 1; failure <= 2; ++failure) {
allocations = frees = 0; allocation_fail_at = failure;
assert(web_httpd_register_optional(&server, &other) == ESP_ERR_NO_MEM && !routes[1]);
assert(allocations == failure && frees == failure - 1);
}
allocation_fail_at = 0;
assert(web_httpd_register_optional(&server, &other) == ESP_OK);
assert(web_httpd_register_optional(&server, &other) == ESP_ERR_INVALID_STATE);
assert(httpd_unregister_uri_handler(&server, other.uri, HTTP_POST) == ESP_OK && !routes[1]);
puts("PASS Settings registration: both allocation failures leave table intact, duplicate/full/shape bounds, installed IDF unregister frees successful ownership");
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob);
s_initialized = true; s_running = true;
@@ -101,6 +150,7 @@ static void settings_tests(void) {
assert(serial_service_get_snapshot(&value) == ESP_OK && value.running);
assert(!memcmp(&value.config, &s_config, sizeof(s_config)) && !serial_locked);
puts("PASS Settings snapshot: exact nonblocking production body, failure clearing and lock-consistent copy");
status_snapshot_tests();
settings_begin(NULL); settings_expect("401 Unauthorized");
settings_begin(&user); settings_expect("403 Forbidden");
+8 -1
View File
@@ -18,6 +18,7 @@ static size_t body_offset;
static unsigned password_calls, cookie_count, sends, upgrades;
static unsigned fail_header, setter_calls;
static bool send_fail, recv_fail;
static size_t receive_fragment = 7;
static void (*password_hook)(void);
static char response_status[48];
static struct httpd_req_aux aux;
@@ -46,7 +47,7 @@ esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
}
int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
(void)r; if (recv_fail) return -1;
if (size > 7) size = 7; /* Fragment every login body. */
if (size > receive_fragment) size = receive_fragment;
memcpy(out, request_body + body_offset, size); body_offset += size;
aux.remaining_len -= size; return (int)size;
}
@@ -122,6 +123,9 @@ static void auth_reset(void) {
#ifdef HOST_SETTINGS
#include "settings_test.c"
#endif
#ifdef HOST_SERIAL_SETTINGS
#include "serial_settings_test.c"
#endif
int main(void) {
assert(store_tests() == 0); auth_reset();
@@ -272,6 +276,9 @@ int main(void) {
#endif
#ifdef HOST_SETTINGS
settings_tests();
#endif
#ifdef HOST_SERIAL_SETTINGS
serial_settings_tests();
#endif
return 0;
}
+51 -2
View File
@@ -48,11 +48,60 @@ Coverage:
- Read-only admin Settings/Serial: 10 selection cycles preserve both sockets/IDs,
drain hidden output and block terminal input; exact eight-field/256-byte schema,
explicit refresh, error/timeout containment, late cancellation/restore/identity,
and concurrent serial reconnect without superseding admission. **21 Node groups total.**
and concurrent serial reconnect without superseding admission.
- 8D.9 typed Serial actions: draft validation, Reset-only confirmation, bounded
JSON/CSRF, explicit working/persisted effects, automatic bounded result checks,
failure/timeout containment, session fencing and preserved sockets/writer identity.
- Lost-acknowledgement and replaced-result uncertainty survives repeated result
checks, failed reads, refresh and navigation; a newly acknowledged explicit
submission starts a new result context.
- Immediate completion on the first GET, pending then completion, 10-attempt
exhaustion and manual recovery, 15-second overall abort during fetch/body reads,
delayed timers/replies, automatic read errors, visible stale snapshots during
refresh and after refresh failure for every terminal outcome, late refresh
cancellation, no routine confirmations and Reset cancellation. Navigation,
pagehide/restore, logout, expiry and changed identity cancel checks without
automatic resumption.
- Repeated current Settings selection is a no-op during submission, between and
during result checks, and during completion refresh: requests, timers, visible
values/control state, final outcome and socket/writer identity remain intact.
**35 Node groups total.**
## Automatic result-check budget
After a valid POST acknowledgement, the UI waits **1,000 ms** before the first
result GET and between completed pending-result checks. It makes **at most 10
GET attempts** and uses an independent **15,000 ms overall deadline**, measured
with the monotonic browser clock from acknowledgement. Each attempt first
revalidates the session; that time is included in the deadline. There is only
one automatic check in flight. Delayed timer callbacks and replies also check
this deadline. Expiry actively aborts the in-flight request and releases the UI
for manual recovery; late completions cannot update the view.
The first limit reached stops automatic checking. A read error also stops it.
The budget does not cancel backend work and is not a server execution deadline.
**POST is never automatically retried.** Lost acknowledgement requires explicit
Check Result recovery; manual checks do not restart automatic polling. Exhausted
or cancelled polling never resumes on navigation or bfcache restoration.
Every known terminal result, including failure/cancellation, triggers one working
snapshot refresh while retaining the operation outcome and any uncertainty
warning. Snapshot refresh is outside the auto-check budget and retains the
existing 15-second per-request bound (session validation and snapshot GET are
separate requests). Settings remain visible but conflicting controls are disabled
during work; old snapshots are explicitly stale during pending/uncertain work or
a failed refresh. A successful refresh replaces the browser draft. Only Reset
asks for confirmation, specifically because it overwrites saved configuration.
Tests use a deterministic clock and individually fired timer callbacks, including
callbacks invoked after cancellation and fetch/body doubles that ignore abort.
These deliberately exercise fences beyond normal browser cancellation behavior.
## Integration and known gaps
This covers 8D.3 session behavior, the 8D.6 selector and 8D.8 Settings. The renderer
This covers 8D.3 session behavior, the 8D.6 selector, 8D.8 Settings and the 8D.9
Serial UI. Operation responses are fetch doubles, not end-to-end execution of
`web_serial_settings.c`, dispatcher work, serial reconfiguration or NVS persistence. The renderer
still relies on its caller to authenticate resources; protected asset failures
must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is
implemented here. Existing 8D.5 server authorization/protocols are unchanged.
+343 -5
View File
@@ -13,9 +13,10 @@ 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'} = {}) {
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': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': []};
const fits = [];
let serial = 0;
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
const emit = (key, event = {}) => { for (const fn of events[key] || []) fn(event); };
const timeout = (fn, ms, interval = false) => { timers.set(++serial, {fn, ms, interval}); return serial; };
@@ -32,7 +33,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
loadAddon() {} open() {} focus() {} resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; }
write(bytes, callback) { this.writes.push([...bytes]); if (this.holdWrites) (this.pending ||= []).push(callback); else callback?.(); }
}
const window = {addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
const window = {confirm: () => true, addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
setTimeout: timeout, clearTimeout: id => timers.delete(id),
setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id),
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
@@ -46,7 +47,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
}},
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date, WebSocket: Socket,
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket,
fetch: async (url, options) => {
// Apply the Origin regression guard to every mutation, including logout.
assert.ok(Object.hasOwn(queues, url));
@@ -69,7 +70,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
};
return {nodes, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
click: id => nodes[id].click(), window};
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
}
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
let passed = 0;
@@ -458,5 +459,342 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
assert.match(c.nodes['settings-detail'].textContent, /Refresh to retry/);
c.click('refresh-settings'); await tick(); assert.equal(c.nodes['settings-values'].hidden, false);
});
await test('Serial typed actions: draft, confirmation, CSRF, explicit results and working/persisted semantics', async () => {
const b = browser({role: 'admin'}); b.start(); await tick(); b.sockets[0].emit('open');
b.click('select-settings'); await tick();
const path = '/api/settings/serial-operation';
assert.equal(b.nodes['edit-baud'].value, '230400');
const baseline = b.calls.length;
b.nodes['edit-baud'].value = '460800'; assert.equal(b.calls.length, baseline);
b.window.confirm = () => false; b.click('serial-reset'); await tick(); assert.equal(b.calls.length, baseline);
b.window.confirm = () => true;
for (const [i, action] of ['apply', 'save', 'load', 'defaults', 'reset', 'start', 'stop'].entries()) {
if (i) { b.click('refresh-settings'); await tick(); }
b.queues[path].push(json({id: i + 1, action, state: 'pending'}));
b.click('serial-' + action); await tick();
const post = b.calls.filter(c => c.url === path && c.method === 'POST').at(-1);
assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
assert.ok(Buffer.byteLength(post.body) <= 256);
const payload = JSON.parse(post.body); assert.equal(payload.action, action);
if (action === 'apply') {
assert.equal(payload.baud, 460800); assert.equal(payload.rts_threshold, 96); assert.equal(Object.keys(payload).length, 8);
} else assert.deepEqual(payload, {action});
assert.ok(b.nodes['serial-apply'].disabled && b.nodes['serial-result'].disabled);
assert.ok(!b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden);
assert.match(b.nodes['settings-detail'].textContent, /stale/);
const count = b.calls.length; b.click('serial-save'); await tick(); assert.equal(b.calls.length, count);
b.queues[path].push(json({id: i + 1, action, state: 'ok'})); b.fire(1000); await tick();
assert.ok(!b.nodes['serial-apply'].disabled && !b.nodes['serial-edit'].hidden);
assert.match(b.nodes['serial-operation-detail'].textContent, /completed.*RAM.*NVS/);
}
assert.equal(b.sockets.length, 1); assert.ok(!b.sockets[0].closed && !b.sockets[0].sent.length);
});
await test('Serial validation and capacity failures never auto-retry or transmit command strings', async () => {
const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick();
const path = '/api/settings/serial-operation';
for (const [key, value] of [['baud', '0'], ['baud', '1000001'], ['baud', '1e3'], ['baud', '-1'], ['parity', 'mark'], ['rts_threshold', '128']]) {
b.click('refresh-settings'); await tick(); b.nodes['edit-' + key].value = value;
const count = b.calls.length; b.click('serial-apply'); await tick(); assert.equal(b.calls.length, count);
}
b.click('refresh-settings'); await tick();
for (const status of [400, 403, 429, 503]) {
b.queues[path].push(failure(status)); b.click('serial-save'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /Check Result/);
const count = b.calls.length; await tick(); b.click('serial-save'); await tick(); assert.equal(b.calls.length, count);
assert.ok(!b.nodes['serial-operation-detail'].textContent.includes('SECRET'));
b.queues[path].push(json({id: 0, action: 'none', state: 'idle'})); b.click('serial-result'); await tick();
b.click('refresh-settings'); await tick();
}
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 4);
b.queues[path].push(json({id: 1, action: 'save', state: 'pending'})); b.click('serial-save'); await tick();
b.queues[path].push(json({id: 1, action: 'save', state: 'ok'})); b.fire(1000); await tick();
b.click('refresh-settings'); await tick();
b.queues[path].push(() => { throw new Error('lost'); }); b.click('serial-reset'); await tick();
b.queues[path].push(json({id: 1, action: 'save', state: 'ok'})); b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /acknowledgement was lost.*earlier operation/);
assert.ok(b.sockets.every(s => !s.closed));
});
await test('Serial navigation fences delayed acknowledgement without losing sockets, lease or uncertain-work gate', async () => {
const b = browser({role: 'admin'}); b.start(); await tick(); const serial = b.sockets[0]; serial.emit('open');
serial.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})});
b.click('select-admin'); b.click('admin-toggle'); await tick(); b.sockets[1].emit('open');
b.click('select-settings'); await tick(); const d = deferred(), path = '/api/settings/serial-operation';
b.queues[path].push(d.promise); b.click('serial-reset'); await tick();
const post = b.calls.find(c => c.url === path); b.click('select-serial'); assert.ok(post.signal.aborted);
d.resolve(json({id: 44, action: 'reset', state: 'pending'})); await tick();
b.click('select-settings'); await tick(); assert.ok(b.nodes['serial-reset'].disabled);
b.queues[path].push(json({id: 44, action: 'reset', state: 'ok'})); b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed));
assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8');
assert.deepEqual(serial.sent, []);
});
await test('Serial result bounds, failure explanations, timeout and no result replay', async () => {
const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick();
const path = '/api/settings/serial-operation';
for (const state of ['loaded_defaults', 'failed', 'rollback_failed', 'cancelled', 'pending']) {
b.queues[path].push(json({id: 42, action: 'reset', state})); b.click('serial-result'); await tick();
assert.ok(!b.nodes['serial-operation-detail'].textContent.includes('unknown. Check'));
}
for (const response of [new Response(' '.repeat(97)), json({id: 0, action: 'save', state: 'ok'}),
json({id: 42, action: 'save', state: 'ok', extra: 1}), json({id: 42, action: '<img>', state: 'ok'}),
json({id: -1, action: 'none', state: 'idle'}), new Response(Uint8Array.of(255))]) {
b.queues[path].push(response); b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /outcome unknown/);
}
b.queues[path].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('timeout')))));
b.click('serial-result'); await tick(); b.fire(15000); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /No automatic retry/);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 0);
});
await test('Serial mutation security: current-session identity, 401 and pagehide cancel work safely', async () => {
for (const mode of ['identity', '401', 'pagehide']) {
const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick();
const path = '/api/settings/serial-operation', d = deferred();
if (mode === 'identity') b.queues['/api/session'].push(session({role: 'admin', username: 'replacement'}));
else b.queues[path].push(mode === '401' ? failure(401) : d.promise);
b.click('serial-save'); await tick();
if (mode === 'identity') { assert.deepEqual(b.redirects, ['/']); assert.equal(b.calls.filter(c => c.url === path).length, 0); }
if (mode === '401') assert.deepEqual(b.redirects, ['/login']);
if (mode === 'pagehide') {
b.emit('pagehide'); const text = b.nodes['serial-operation-detail'].textContent;
d.resolve(json({id: 42, action: 'save', state: 'pending'})); await tick();
assert.equal(b.nodes['serial-operation-detail'].textContent, text);
}
assert.ok(b.sockets.every(s => s.closed) && b.nodes['serial-settings'].hidden);
}
});
await test('Serial uncertain outcomes survive repeated result reads, refresh and navigation', async () => {
for (const lostAck of [true, false]) {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
b.queues[path].push(lostAck ? () => { throw new Error('lost'); } : json({id: 41, action: 'save', state: 'pending'}));
b.click('serial-save'); await tick();
const warning = lostAck ? /acknowledgement was lost/ : /Previous result was replaced.*unknown/;
for (let i = 0; i < 2; ++i) {
b.queues[path].push(json({id: 42, action: 'reset', state: 'ok'}));
if (!lostAck && i === 0) b.fire(1000); else b.click('serial-result');
await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
}
b.queues[path].push(failure(503)); b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
assert.match(b.nodes['serial-operation-detail'].textContent, /No automatic retry/);
b.click('refresh-settings'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
b.click('select-serial'); b.click('select-settings'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
b.queues[path].push(json({id: 42, action: 'reset', state: 'ok'}));
b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
b.click('refresh-settings'); await tick();
b.queues[path].push(json({id: 43, action: 'save', state: 'pending'}));
b.click('serial-save'); await tick();
assert.doesNotMatch(b.nodes['serial-operation-detail'].textContent, warning);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 2);
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed));
assert.ok(b.sockets.every(s => !s.sent.length));
}
});
await test('Automatic checks: pending then completion refreshes working config, retains outcome and isolates sockets', async () => {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
const before = b.calls.filter(c => c.url === '/api/settings/serial').length;
b.queues[path].push(json({id: 50, action: 'apply', state: 'pending'}));
b.nodes['edit-baud'].value = '460800'; b.click('serial-apply');
assert.match(b.nodes['serial-operation-detail'].textContent, /Applying/); await tick();
for (const state of ['pending', 'pending', 'ok']) {
assert.ok(b.nodes['edit-baud'].disabled && b.nodes['serial-stop'].disabled);
assert.ok(!b.nodes['settings-values'].hidden && !b.nodes['serial-edit'].hidden);
b.queues[path].push(json({id: 50, action: 'apply', state}));
if (state === 'ok') b.queues['/api/settings/serial'].push(json({...serialSettings(), baud: 460800}));
b.elapse(1000); b.fire(1000); await tick();
}
assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 3);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, before + 1);
assert.equal(b.nodes['setting-baud'].textContent, '460800');
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/);
assert.ok(!b.nodes['edit-baud'].disabled && !b.nodes['serial-apply'].disabled);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
});
await test('Repeated current Settings selection preserves submission, polling and completion refresh', async () => {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick(); b.nodes['edit-baud'].value = '460800';
const repeatedSelection = async request => {
const count = b.calls.length, timers = [...b.timers];
const view = () => Object.fromEntries(Object.entries(b.nodes).map(([id, node]) =>
[id, [node.hidden, node.disabled, node.value, node.textContent, node['aria-pressed']]]));
const before = view();
for (let i = 0; i < 3; ++i) { b.click('select-settings'); await tick(); }
assert.equal(b.calls.length, count);
assert.deepEqual([...b.timers], timers);
assert.deepEqual(view(), before);
if (request) assert.ok(!request.signal.aborted);
assert.ok(!b.nodes['serial-settings'].hidden && !b.nodes['settings-values'].hidden && !b.nodes['serial-edit'].hidden);
};
const post = deferred(); b.queues[path].push(post.promise);
b.click('serial-apply'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /Applying/);
await repeatedSelection(b.calls.filter(c => c.url === path).at(-1));
post.resolve(json({id: 57, action: 'apply', state: 'pending'})); await tick();
await repeatedSelection();
const pending = deferred(); b.queues[path].push(pending.promise);
b.fire(1000); await tick();
await repeatedSelection(b.calls.filter(c => c.url === path).at(-1));
pending.resolve(json({id: 57, action: 'apply', state: 'pending'})); await tick();
await repeatedSelection();
const refresh = deferred(); b.queues['/api/settings/serial'].push(refresh.promise);
b.queues[path].push(json({id: 57, action: 'apply', state: 'ok'}));
b.fire(1000); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
assert.match(b.nodes['settings-detail'].textContent, /Reading.*stale/);
await repeatedSelection(b.calls.filter(c => c.url === '/api/settings/serial').at(-1));
refresh.resolve(json({...serialSettings(), baud: 460800})); await tick();
assert.equal(b.nodes['setting-baud'].textContent, '460800');
assert.equal(b.nodes['edit-baud'].value, '460800');
assert.ok(!b.nodes['serial-apply'].disabled);
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/);
await repeatedSelection();
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 2);
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 2);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8');
});
await test('Ten automatic GET attempts exhaust budget; manual recovery completes without POST retry', async () => {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
b.queues[path].push(json({id: 51, action: 'save', state: 'pending'})); b.click('serial-save'); await tick();
for (let i = 0; i < 10; ++i) {
b.queues[path].push(json({id: 51, action: 'save', state: 'pending'}));
b.elapse(1000); b.fire(1000); await tick();
}
assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 10);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
assert.match(b.nodes['serial-operation-detail'].textContent, /Automatic checking stopped.*uncertain.*Check Result/);
assert.ok(b.nodes['serial-save'].disabled && !b.nodes['serial-result'].disabled);
assert.match(b.nodes['settings-detail'].textContent, /stale/);
b.queues[path].push(json({id: 51, action: 'save', state: 'ok'})); b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
assert.ok(!b.nodes['serial-save'].disabled);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
});
await test('15s overall deadline aborts slow checks and fences late bodies and delayed timers', async () => {
for (const mode of ['fetch', 'body', 'delayed-timer', 'late-response']) {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
b.queues[path].push(json({id: 52, action: 'stop', state: 'pending'})); b.click('serial-stop'); await tick();
const d = deferred(); let stream;
if (mode !== 'delayed-timer') {
b.queues[path].push(mode === 'body' ? new Response(new ReadableStream({start(c) { stream = c; }})) : d.promise);
b.elapse(1000); b.fire(1000); await tick(); b.elapse(14000);
if (mode === 'late-response') { d.resolve(json({id: 52, action: 'stop', state: 'ok'})); await tick(); }
else b.fire(15000);
assert.ok(b.calls.filter(c => c.url === path).at(-1).signal.aborted);
} else { b.elapse(15000); b.fire(1000); }
await tick();
const text = b.nodes['serial-operation-detail'].textContent;
assert.match(text, /Automatic checking stopped/);
assert.ok(!b.nodes['serial-result'].disabled);
b.queues[path].push(json({id: 52, action: 'stop', state: 'ok'})); b.click('serial-result'); await tick();
const recovered = b.nodes['serial-operation-detail'].textContent;
if (stream) { stream.enqueue(new TextEncoder().encode(JSON.stringify({id: 52, action: 'stop', state: 'failed'}))); stream.close(); }
else d.resolve(json({id: 52, action: 'stop', state: 'failed'}));
await tick(); assert.equal(b.nodes['serial-operation-detail'].textContent, recovered);
assert.match(recovered, /completed/);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
}
});
await test('Known terminal outcomes always refresh; failed refresh preserves visible stale snapshot and outcome', async () => {
for (const state of ['ok', 'failed', 'rollback_failed', 'cancelled', 'loaded_defaults']) {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
b.queues[path].push(json({id: 53, action: 'reset', state: 'pending'})); b.click('serial-reset'); await tick();
const d = deferred(); b.queues['/api/settings/serial'].push(d.promise);
b.queues[path].push(json({id: 53, action: 'reset', state})); b.fire(1000); await tick();
const outcome = b.nodes['serial-operation-detail'].textContent;
assert.ok(b.nodes['edit-baud'].disabled && !b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden);
assert.match(b.nodes['settings-detail'].textContent, /stale/);
d.resolve(failure(503)); await tick();
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
assert.equal(b.nodes['setting-baud'].textContent, '230400');
assert.ok(!b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden && !b.nodes['refresh-settings'].disabled);
assert.match(b.nodes['settings-detail'].textContent, /stale.*Refresh/);
b.click('refresh-settings'); await tick();
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/);
}
});
await test('Automatic timers and in-flight checks cancel on navigation, pagehide, logout and identity change', async () => {
for (const mode of ['navigation', 'pagehide', 'logout', 'identity', 'expiry']) for (const inFlight of [false, true]) {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
b.queues[path].push(json({id: 54, action: 'start', state: 'pending'})); b.click('serial-start'); await tick();
const callbacks = [...b.timers.values()].filter(t => t.ms === 1000 || t.ms === 15000).map(t => t.fn);
const d = deferred();
if (inFlight) { b.queues[path].push(d.promise); b.fire(1000); await tick(); }
if (mode === 'navigation') b.click('select-serial');
if (mode === 'pagehide') b.emit('pagehide');
if (mode === 'expiry') b.window.sakSessionExpired();
if (mode === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); b.click('sign-out'); }
if (mode === 'identity') {
b.queues['/api/session'].push(session({role: 'admin', username: 'replacement'}));
b.click('connection-toggle'); b.click('connection-toggle');
}
await tick();
const text = b.nodes['serial-operation-detail'].textContent, count = b.calls.filter(c => c.url === path).length;
if (inFlight) assert.ok(b.calls.filter(c => c.url === path).at(-1).signal.aborted);
for (const callback of callbacks) callback();
d.resolve(json({id: 54, action: 'start', state: 'ok'})); await tick();
assert.equal(b.nodes['serial-operation-detail'].textContent, text);
assert.equal(b.calls.filter(c => c.url === path).length, count);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
if (mode === 'navigation') { b.click('select-settings'); await tick(); assert.equal(b.calls.filter(c => c.url === path).length, count); }
if (mode === 'pagehide') { b.emit('pageshow', {persisted: true}); await tick(); assert.equal(b.calls.filter(c => c.url === path).length, count); }
}
});
await test('Automatic read errors stop checking; cancelled completion refresh cannot overwrite a newer view', async () => {
const path = '/api/settings/serial-operation';
for (const response of [failure(503), new Response(' '.repeat(97)), () => { throw new Error('network'); }]) {
const b = await adminBrowser(); b.click('select-settings'); await tick();
b.queues[path].push(json({id: 55, action: 'save', state: 'pending'}), response);
b.click('serial-save'); await tick(); b.fire(1000); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /Check Result.*No automatic retry/);
assert.match(b.nodes['settings-detail'].textContent, /stale/);
assert.ok(!b.nodes['serial-result'].disabled && b.nodes['serial-save'].disabled);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
}
const b = await adminBrowser(); b.click('select-settings'); await tick();
const d = deferred(); b.queues['/api/settings/serial'].push(d.promise);
b.queues[path].push(json({id: 56, action: 'load', state: 'pending'}), json({id: 56, action: 'load', state: 'ok'}));
b.click('serial-load'); await tick(); b.fire(1000); await tick();
const outcome = b.nodes['serial-operation-detail'].textContent;
const read = b.calls.filter(c => c.url === '/api/settings/serial').at(-1);
b.click('select-serial'); assert.ok(read.signal.aborted);
b.click('select-settings'); await tick();
d.resolve(json({...serialSettings(), baud: 110})); await tick();
assert.equal(b.nodes['setting-baud'].textContent, '230400');
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
});
await test('Routine actions never confirm; Reset cancellation has no request or state change', async () => {
const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick();
const confirms = []; b.window.confirm = message => { confirms.push(message); return false; };
for (const [i, action] of ['apply', 'start', 'stop', 'load', 'defaults', 'save'].entries()) {
b.queues[path].push(json({id: i + 1, action, state: 'pending'}), json({id: i + 1, action, state: 'ok'}));
b.click('serial-' + action); await tick(); b.fire(1000); await tick();
}
assert.deepEqual(confirms, []);
const count = b.calls.length, text = b.nodes['serial-operation-detail'].textContent;
b.click('serial-reset'); await tick(); assert.equal(b.calls.length, count);
assert.equal(b.nodes['serial-operation-detail'].textContent, text);
assert.equal(confirms.length, 1); assert.match(confirms[0], /overwrites saved NVS configuration/);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 6);
});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });