Add admin serial settings view
This commit is contained in:
@@ -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) != 13:
|
||||
raise RuntimeError('Review URI extraction: expected 11 descriptors and two tables')
|
||||
if len(uri_tables) != 14:
|
||||
raise RuntimeError('Review URI extraction: expected 12 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()
|
||||
@@ -93,6 +93,8 @@ static unsigned ssl_starts, ssl_stops, serial_attaches, serial_detaches;
|
||||
static unsigned admin_attaches, admin_detaches, admin_stoppeds;
|
||||
static unsigned registration_calls, registration_fail_at, registered_count, unregister_calls;
|
||||
static bool unregister_fail;
|
||||
static bool settings_fail;
|
||||
static unsigned settings_calls;
|
||||
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; }
|
||||
@@ -104,6 +106,7 @@ static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); }
|
||||
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)
|
||||
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; }
|
||||
@@ -115,7 +118,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 == 16 && config->port_secure == 443);
|
||||
assert(config->httpd.max_uri_handlers == 17 && 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);
|
||||
@@ -128,6 +131,14 @@ static esp_err_t register_one(httpd_handle_t server) {
|
||||
return registration_calls == registration_fail_at ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
if (!strcmp(uri->uri, "/api/settings/serial")) {
|
||||
assert(s == SERVER && auth_live && ssl_live && registration_calls >= 17);
|
||||
assert(uri->method == HTTP_GET && uri->handler == serial_settings_handler);
|
||||
++settings_calls;
|
||||
if (settings_fail) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) {
|
||||
assert(registration_calls >= 16);
|
||||
assert(serial_init_error != ESP_OK || serial_live);
|
||||
@@ -136,6 +147,10 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
|
||||
if (error == ESP_OK) { assert(registered_count < 32); registered[registered_count++] = uri; }
|
||||
return error;
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(!strcmp(uri->uri, "/api/settings/serial"));
|
||||
return httpd_register_uri_handler(s, uri);
|
||||
}
|
||||
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);
|
||||
@@ -201,7 +216,7 @@ static void reset(void) {
|
||||
serial_inits = admin_inits = auth_starts = auth_stops = ssl_starts = ssl_stops = 0;
|
||||
serial_attaches = serial_detaches = admin_attaches = admin_detaches = admin_stoppeds = 0;
|
||||
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
|
||||
unregister_fail = false; clear_events();
|
||||
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
|
||||
}
|
||||
static void fresh_registration(void) { registration_calls = registered_count = 0; }
|
||||
static void start(void) {
|
||||
@@ -235,7 +250,8 @@ int main(void) {
|
||||
}
|
||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||
|
||||
reset(); start(); assert(registered_count == 16 && registration_calls == 18);
|
||||
reset(); start(); assert(registered_count == 17 && registration_calls == 18 && settings_calls == 1);
|
||||
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);
|
||||
assert(ws->method == HTTP_GET && ws->handler == web_admin_transport_upgrade_handler && !ws->is_websocket);
|
||||
@@ -286,7 +302,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 == 14 && unregister_calls == failure - 17);
|
||||
assert(registered_count == 15 && 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);
|
||||
@@ -295,13 +311,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 == 16 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(registered_count == 17 && 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 == 15);
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 16);
|
||||
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");
|
||||
@@ -313,7 +329,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 == 16 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
assert(registered_count == 17 && 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;
|
||||
@@ -334,7 +350,14 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && !auth_starts);
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
||||
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
||||
puts("11 lifecycle groups passed (16 required fatal positions, 2 optional positions, plus failed unregister)");
|
||||
reset(); settings_fail = true; start();
|
||||
assert(settings_calls == 1 && registered_count == 16);
|
||||
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)");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -15,3 +15,24 @@ Coverage includes challenge reuse/consumption/expiry, capacities without evictio
|
||||
This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, browser, multicore task or hardware test. The private struct doubles do not prove binary layout; firmware compilation uses the actual pinned headers, and the version guard requires a new audit on SDK changes. Handshake sending and transport revocation are doubled. Actual on-wire pre-101 rejection, frame routing, pipelining/early bytes, cookie/CSP/browser recovery and loaded expiry latency remain M1 target gates. No sanitizer or runtime memory-reserve result is implied.
|
||||
|
||||
See `docs/phase8d3_implementation.md` for source verification, other suite commands, build accounting and the target checklist.
|
||||
|
||||
## Read-only Serial Settings
|
||||
|
||||
```sh
|
||||
python3 tests/web_cookie_auth/run.py --settings
|
||||
```
|
||||
|
||||
Runs the existing auth/store suite plus five 8D.8 groups. Compiles exact extracted
|
||||
production server handler/helpers, serial snapshot getter and enum formatters,
|
||||
with the real cookie/store/parser/private adapter. Serial locking/state and HTTP
|
||||
IO are doubled; authorization is not. Exercises normal-role/stale/expired/revoked
|
||||
denial, DB failure, body/query/method/header/framing/Origin rejection before any
|
||||
serial read, working values, zero-wait busy/uninitialized failure, no-store and
|
||||
header/send errors. Adapter-only allocator substitution injects both staged
|
||||
registration failures; the installed IDF unregister function frees successful
|
||||
registration. No SDK files are modified. Lifecycle registration/optional failure
|
||||
orchestration is separately tested by `tests/web_admin_transport/server_lifecycle.py`.
|
||||
|
||||
This does not run the full serial task/UART driver, TLS/network dispatcher or a real
|
||||
browser. Target comparison with UART0 and runtime memory/stack validation remain
|
||||
pending in `docs/phase8d8_implementation.md`; prior M2 signoff remains accepted.
|
||||
|
||||
@@ -37,11 +37,13 @@ struct sock_db { bool ws_handshake_done; esp_err_t (*ws_handler)(httpd_req_t *);
|
||||
struct httpd_req_aux { struct sock_db *sd; char *scratch; size_t scratch_cur_size, remaining_len;
|
||||
unsigned req_hdrs_count, resp_hdrs_count; bool ws_handshake_detect;
|
||||
struct resp_hdr { const char *field, *value; } *resp_hdrs; };
|
||||
struct httpd_data { struct { unsigned max_resp_headers; } config; };
|
||||
struct httpd_data { struct { unsigned max_resp_headers, max_uri_handlers; void *uri_match_fn; } config;
|
||||
httpd_uri_t **hd_calls; };
|
||||
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
|
||||
"""
|
||||
|
||||
admin = "--admin" in sys.argv
|
||||
settings = "--settings" in sys.argv
|
||||
if admin:
|
||||
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
|
||||
HEADERS["esp_heap_caps.h"] = """#pragma once
|
||||
@@ -73,8 +75,10 @@ extracted = """
|
||||
#pragma GCC diagnostic ignored "-Wsign-compare"
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <stdlib.h>
|
||||
#include "esp_httpd_priv.h"
|
||||
#define ESP_LOGD(...) ((void)0)
|
||||
#define ESP_LOGW(...) ((void)0)
|
||||
#define MIN(a,b) ((a) < (b) ? (a) : (b))
|
||||
static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; }
|
||||
static size_t strlcpy(char *d, const char *s, size_t n) {
|
||||
@@ -88,6 +92,9 @@ for name in ["httpd_req_get_hdr_value_len", "httpd_req_get_hdr_value_str"]:
|
||||
extracted += function(txrx[txrx.index("esp_err_t httpd_resp_set_hdr"):], "httpd_resp_set_hdr")
|
||||
extracted += "\n" + function(txrx[txrx.index("static size_t httpd_recv_pending"):], "httpd_recv_pending")
|
||||
extracted += "\nsize_t host_read_pending(httpd_req_t *r, char *out, size_t n) { return httpd_recv_pending(r, out, n); }\n"
|
||||
if settings:
|
||||
uri_source = (idf / 'components/esp_http_server/src/httpd_uri.c').read_text()
|
||||
extracted += '\n' + function(uri_source[uri_source.index('esp_err_t httpd_unregister_uri_handler'):], 'httpd_unregister_uri_handler')
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
tmp = pathlib.Path(directory)
|
||||
@@ -96,12 +103,32 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
(tmp / "installed_httpd.c").write_text(extracted)
|
||||
if settings:
|
||||
# Compile exact production handler/helpers and nonblocking snapshot body.
|
||||
server_source = (ROOT / 'src/web_server.c').read_text()
|
||||
service_source = (ROOT / 'src/serial_service.c').read_text()
|
||||
config_source = (ROOT / 'src/serial_config.c').read_text()
|
||||
settings_source = function(service_source, 'serial_service_get_snapshot') + '\n'
|
||||
for name in ('data_bits', 'parity', 'stop_bits', 'flow_control', 'dtr_behavior'):
|
||||
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'
|
||||
(tmp / 'settings_production.h').write_text(settings_source)
|
||||
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:
|
||||
sources += [ROOT / "src" / name for name in ["web_admin_tickets.c", "web_admin_transport.c"]]
|
||||
if settings:
|
||||
adapter = ROOT / 'src/web_httpd_adapter.c'
|
||||
sources.remove(adapter)
|
||||
subprocess.run(['cc', '-std=c11', '-D_GNU_SOURCE', '-Wall', '-Wextra', '-Werror',
|
||||
'-Dmalloc=settings_malloc', '-Dfree=settings_free',
|
||||
'-I' + str(tmp), '-I' + str(ROOT / 'src'), '-c', str(adapter),
|
||||
'-o', str(tmp / 'adapter.o')], check=True, timeout=30)
|
||||
sources.append(tmp / 'adapter.o')
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g", "-DHOST_OPENSSL",
|
||||
*(["-DHOST_ADMIN"] if admin else []),
|
||||
*(["-DHOST_SETTINGS"] if 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,154 @@
|
||||
/* Exact production snapshot/HTTP bodies, real auth/store/adapter, fake UART mutex. */
|
||||
#include <inttypes.h>
|
||||
#include <stdatomic.h>
|
||||
#include <sys/types.h>
|
||||
#include <stdlib.h>
|
||||
#include "serial_service.h"
|
||||
#include "web_server.h"
|
||||
#define pdTRUE 1
|
||||
#define ESP_ERR_TIMEOUT 0x107
|
||||
static bool s_initialized, serial_busy, serial_locked;
|
||||
static atomic_bool s_running;
|
||||
static serial_config_t s_config;
|
||||
static int s_state_mutex;
|
||||
static unsigned serial_takes;
|
||||
static web_server_counters_t s_counters;
|
||||
static unsigned allocations, allocation_fail_at, frees;
|
||||
static httpd_uri_t *routes[2];
|
||||
void *settings_malloc(size_t size) {
|
||||
assert(routes[1] == NULL); /* Neither allocation may publish partial state. */
|
||||
if (++allocations == allocation_fail_at) return NULL;
|
||||
void *value = malloc(size); assert(value); memset(value, 0xa5, size); return value;
|
||||
}
|
||||
void settings_free(void *value) { ++frees; free(value); }
|
||||
esp_err_t httpd_unregister_uri_handler(httpd_handle_t, const char *, httpd_method_t);
|
||||
static int xSemaphoreTake(int mutex, unsigned wait) {
|
||||
(void)mutex; assert(wait == 0 && !serial_locked && !host_lock_depth);
|
||||
++serial_takes;
|
||||
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 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));
|
||||
return httpd_resp_sendstr(r, body);
|
||||
}
|
||||
#include "settings_production.h"
|
||||
|
||||
static void settings_begin(const issued_t *identity) {
|
||||
begin("/api/settings/serial", HTTP_GET, NULL);
|
||||
add("Host", "device.example"); /* GET may omit Origin, but Host is validated. */
|
||||
if (identity) {
|
||||
char cookies[100];
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", identity->token);
|
||||
add("Cookie", cookies);
|
||||
}
|
||||
}
|
||||
static void settings_expect(const char *status) {
|
||||
unsigned before = serial_takes;
|
||||
(void)serial_settings_handler(&req);
|
||||
if (strcmp(response_status, status) || serial_takes != before)
|
||||
fprintf(stderr, "Settings expected %s, got %s; serial reads %u\n", status, response_status, serial_takes - before);
|
||||
assert(!strcmp(response_status, status) && serial_takes == before);
|
||||
assert(strlen(output) < 128 && !body_offset);
|
||||
zero(scratch, sizeof(scratch));
|
||||
}
|
||||
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};
|
||||
routes[0] = &existing; server.hd_calls = routes; server.config.max_uri_handlers = 2;
|
||||
for (unsigned failure = 1; failure <= 2; ++failure) {
|
||||
allocations = frees = 0; allocation_fail_at = failure;
|
||||
assert(web_httpd_register_optional_get(&server, &route) == ESP_ERR_NO_MEM);
|
||||
assert(allocations == failure && frees == failure - 1 && routes[0] == &existing && !routes[1]);
|
||||
}
|
||||
allocation_fail_at = 0; allocations = 0;
|
||||
assert(web_httpd_register_optional_get(&server, &route) == ESP_OK && allocations == 2);
|
||||
assert(routes[1] && routes[1] != &route && routes[1]->uri != route.uri);
|
||||
assert(!strcmp(routes[1]->uri, route.uri) && routes[1]->handler == route.handler);
|
||||
assert(web_httpd_register_optional_get(&server, &route) == ESP_ERR_INVALID_STATE);
|
||||
httpd_uri_t other = route; other.uri = "/other";
|
||||
assert(web_httpd_register_optional_get(&server, &other) == ESP_ERR_NO_MEM);
|
||||
assert(httpd_unregister_uri_handler(&server, route.uri, HTTP_GET) == ESP_OK && !routes[1]);
|
||||
assert(routes[0] == &existing); /* Actual installed IDF frees staged allocations. */
|
||||
for (unsigned mode = 0; mode < 5; ++mode) {
|
||||
other = route; char oversized[129]; memset(oversized, 'x', 128); oversized[128] = 0;
|
||||
if (mode == 0) other.method = HTTP_POST;
|
||||
if (mode == 1) other.is_websocket = true;
|
||||
if (mode == 2) other.supported_subprotocol = "test";
|
||||
if (mode == 3) other.uri = oversized;
|
||||
if (mode == 4) server.config.uri_match_fn = &existing;
|
||||
assert(web_httpd_register_optional_get(&server, &other) == ESP_ERR_INVALID_ARG && !routes[1]);
|
||||
server.config.uri_match_fn = NULL;
|
||||
}
|
||||
assert(web_httpd_register_optional_get(NULL, &route) == ESP_ERR_INVALID_ARG);
|
||||
assert(web_httpd_register_optional_get(&server, NULL) == ESP_ERR_INVALID_ARG);
|
||||
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;
|
||||
s_config = (serial_config_t){.version = SERIAL_CONFIG_VERSION, .baud_rate = 230400,
|
||||
.data_bits = SERIAL_CONFIG_DATA_BITS_8, .parity = SERIAL_CONFIG_PARITY_NONE,
|
||||
.stop_bits = SERIAL_CONFIG_STOP_BITS_1, .flow_control = SERIAL_CONFIG_FLOW_CONTROL_RTS_CTS,
|
||||
.dtr_behavior = SERIAL_CONFIG_DTR_ON_CONNECT, .rts_threshold = 96};
|
||||
serial_service_snapshot_t value;
|
||||
assert(serial_service_get_snapshot(NULL) == ESP_ERR_INVALID_ARG);
|
||||
s_initialized = false; memset(&value, 0xa5, sizeof(value));
|
||||
assert(serial_service_get_snapshot(&value) == ESP_ERR_INVALID_STATE); zero(&value, sizeof(value));
|
||||
s_initialized = true; serial_busy = true;
|
||||
assert(serial_service_get_snapshot(&value) == ESP_ERR_TIMEOUT); zero(&value, sizeof(value));
|
||||
serial_busy = false;
|
||||
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");
|
||||
|
||||
settings_begin(NULL); settings_expect("401 Unauthorized");
|
||||
settings_begin(&user); settings_expect("403 Forbidden");
|
||||
for (unsigned mode = 0; mode < 7; ++mode) {
|
||||
settings_begin(&admin);
|
||||
if (mode == 0) req.uri = "/api/settings/serial?unknown=1";
|
||||
if (mode == 1) req.content_len = aux.remaining_len = 1000000;
|
||||
if (mode == 2) req.method = HTTP_POST;
|
||||
if (mode == 3) add("Host", "device.example");
|
||||
if (mode == 4) add("Transfer-Encoding", "chunked");
|
||||
if (mode == 5) add("Origin", "https://elsewhere.example");
|
||||
if (mode == 6) add("Origin", "null");
|
||||
settings_expect(mode < 5 ? "400 Bad Request" : "403 Forbidden");
|
||||
}
|
||||
stale_user = alice.user_id; settings_begin(&admin); settings_expect("401 Unauthorized"); stale_user = 0;
|
||||
admin = mint(&alice);
|
||||
db_fail = true; settings_begin(&admin); settings_expect("503 Service Unavailable"); db_fail = false;
|
||||
admin = mint(&alice);
|
||||
puts("PASS Settings HTTP: unauthenticated/user/stale/DB failure and query/body/method/duplicate/framing/Origin denied before serial read");
|
||||
|
||||
settings_begin(&admin); assert(serial_settings_handler(&req) == ESP_OK);
|
||||
assert(!strcmp(output, "{\"running\":true,\"baud\":230400,\"data_bits\":\"8\",\"parity\":\"none\",\"stop_bits\":\"1\",\"flow\":\"rts-cts\",\"dtr\":\"on-connect\",\"rts_threshold\":96}"));
|
||||
assert(aux.resp_hdrs_count == 3 && !strcmp(response_headers[0].value, "no-store"));
|
||||
zero(scratch, sizeof(scratch));
|
||||
s_running = false; s_config.baud_rate = 1000000; s_config.data_bits = SERIAL_CONFIG_DATA_BITS_7;
|
||||
s_config.parity = SERIAL_CONFIG_PARITY_ODD; s_config.stop_bits = SERIAL_CONFIG_STOP_BITS_2;
|
||||
s_config.flow_control = SERIAL_CONFIG_FLOW_CONTROL_NONE;
|
||||
s_config.dtr_behavior = SERIAL_CONFIG_DTR_ACTIVE; s_config.rts_threshold = 127;
|
||||
settings_begin(&admin); assert(serial_settings_handler(&req) == ESP_OK);
|
||||
assert(!strcmp(output, "{\"running\":false,\"baud\":1000000,\"data_bits\":\"7\",\"parity\":\"odd\",\"stop_bits\":\"2\",\"flow\":\"none\",\"dtr\":\"active\",\"rts_threshold\":127}"));
|
||||
for (unsigned mode = 0; mode < 2; ++mode) {
|
||||
settings_begin(&admin); serial_busy = mode == 0; s_initialized = mode == 0;
|
||||
assert(serial_settings_handler(&req) == ESP_OK && !strcmp(response_status, "503 Service Unavailable"));
|
||||
assert(!strcmp(response_headers[0].field, "Retry-After") && !strcmp(response_headers[0].value, "1"));
|
||||
}
|
||||
serial_busy = false; s_initialized = true;
|
||||
settings_begin(&admin); send_fail = true; unsigned before = sends;
|
||||
assert(serial_settings_handler(&req) == ESP_FAIL && sends == before + 1); send_fail = false;
|
||||
for (unsigned limit = 0; limit < 3; ++limit) {
|
||||
settings_begin(&admin); server.config.max_resp_headers = limit; before = sends;
|
||||
assert(serial_settings_handler(&req) != ESP_OK && sends == before);
|
||||
}
|
||||
server.config.max_resp_headers = 8;
|
||||
puts("PASS Settings HTTP: exact typed working values, no-store, bounded busy/unavailable, header/send failure without second response");
|
||||
web_session_store_invalidate(admin.view.id);
|
||||
settings_begin(&admin); settings_expect("401 Unauthorized");
|
||||
settings_begin(&user); settings_expect("403 Forbidden");
|
||||
admin = mint(&alice); now += WEB_SESSION_STORE_LIFETIME_US;
|
||||
settings_begin(&admin); settings_expect("401 Unauthorized");
|
||||
puts("PASS Settings HTTP: originating-session invalidation, unrelated user isolation and absolute expiry");
|
||||
}
|
||||
@@ -119,6 +119,9 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_ADMIN
|
||||
#include "admin_test.c"
|
||||
#endif
|
||||
#ifdef HOST_SETTINGS
|
||||
#include "settings_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -266,6 +269,9 @@ int main(void) {
|
||||
puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login");
|
||||
#ifdef HOST_ADMIN
|
||||
admin_tests();
|
||||
#endif
|
||||
#ifdef HOST_SETTINGS
|
||||
settings_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -23,8 +23,12 @@ TaskHandle_t xTaskCreateStatic(void (*)(void *), const char *, uint32_t, void *,
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
typedef void *httpd_handle_t;
|
||||
typedef int httpd_method_t;
|
||||
typedef struct { httpd_handle_t handle; void *sess_ctx; void (*free_ctx)(void *);
|
||||
int method; size_t content_len; const char *uri; void *aux; } httpd_req_t;
|
||||
typedef struct { const char *uri; int method; esp_err_t (*handler)(httpd_req_t *);
|
||||
void *user_ctx; bool is_websocket, handle_ws_control_frames;
|
||||
const char *supported_subprotocol; } httpd_uri_t;
|
||||
typedef enum { HTTPD_WS_TYPE_CONTINUE, HTTPD_WS_TYPE_TEXT, HTTPD_WS_TYPE_BINARY } httpd_ws_type_t;
|
||||
typedef enum { HTTPD_WS_CLIENT_HTTP, HTTPD_WS_CLIENT_WEBSOCKET } httpd_ws_client_info_t;
|
||||
typedef struct { bool final, fragmented; httpd_ws_type_t type; unsigned char *payload;
|
||||
|
||||
@@ -44,11 +44,15 @@ Coverage:
|
||||
logout; same-session restore retains both scrollbacks behind validation gating.
|
||||
- Undefined initial dimensions recover at unchanged bounds; failed fits never
|
||||
populate the cache, readiness retries stop at three, and teardown fences stale
|
||||
callbacks even after restore. Sixteen Node groups total.
|
||||
callbacks even after restore.
|
||||
- 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.**
|
||||
|
||||
## Integration and known gaps
|
||||
|
||||
This covers the Phase 8D.3 browser session behavior and 8D.6 selector. The renderer
|
||||
This covers 8D.3 session behavior, the 8D.6 selector and 8D.8 Settings. 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.
|
||||
@@ -57,7 +61,7 @@ These tests model DOM, timers, fetch cancellation and WebSocket events. They do
|
||||
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
|
||||
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
|
||||
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout.
|
||||
The 8D.6 firmware build and pending target checklist are recorded separately in
|
||||
`docs/phase8d6_implementation.md`. No target resource reserve is claimed. Browser secret
|
||||
Prior 8D.6 signoff stands; the new Settings build and pending target checklist are in
|
||||
`docs/phase8d8_implementation.md`. No target resource reserve is claimed. Browser secret
|
||||
references are dropped and never persisted/logged, but JavaScript cannot securely
|
||||
wipe engine-managed strings.
|
||||
|
||||
@@ -6,12 +6,14 @@ const token = 'a'.repeat(64);
|
||||
const json = value => new Response(JSON.stringify(value));
|
||||
const session = (extra = {}) => json({username: '<img>', role: 'user', csrf: token, expires_in: 3600, ...extra});
|
||||
const ticket = () => json({ticket: 't'.repeat(32)});
|
||||
const serialSettings = (extra = {}) => ({running: true, baud: 230400, data_bits: '8', parity: 'none',
|
||||
stop_bits: '1', flow: 'rts-cts', dtr: 'on-connect', rts_threshold: 96, ...extra});
|
||||
const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}});
|
||||
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, 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': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': []};
|
||||
const fits = [];
|
||||
let serial = 0;
|
||||
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
|
||||
@@ -55,6 +57,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
if (next !== undefined) return typeof next === 'function' ? next(options) : next;
|
||||
if (url === '/api/session') return session({role});
|
||||
if (url === '/api/status') return json({});
|
||||
if (url === '/api/settings/serial') return json(serialSettings());
|
||||
if (url === '/api/ws-ticket') return ticket();
|
||||
if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30});
|
||||
throw new Error('network unavailable');
|
||||
@@ -366,5 +369,94 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
}
|
||||
}
|
||||
});
|
||||
await test('Settings is admin-only, read-only, bounded and preserves both sockets, lease and hidden output', async () => {
|
||||
const u = await connected(); u.click('select-settings'); u.click('refresh-settings'); await tick();
|
||||
assert.ok(!u.calls.some(c => c.url === '/api/settings/serial'));
|
||||
const b = await adminBrowser(), [serial, admin] = b.sockets;
|
||||
assert.ok(!b.calls.some(c => c.url === '/api/settings/serial'));
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
b.click('select-settings'); await tick();
|
||||
assert.equal(b.nodes['serial-settings'].hidden, false);
|
||||
assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true);
|
||||
assert.equal(b.nodes['settings-values'].hidden, false);
|
||||
assert.equal(b.nodes['setting-baud'].textContent, '230400');
|
||||
assert.equal(b.nodes['setting-dtr'].textContent, 'on-connect');
|
||||
assert.equal(b.nodes['setting-running'].textContent, 'Running');
|
||||
b.terminals.forEach(t => { assert.equal(t.options.disableStdin, true); t.input('WRONG'); });
|
||||
serial.emit('message', {data: Uint8Array.of(65).buffer}); admin.emit('message', {data: Uint8Array.of(66).buffer});
|
||||
b.click('select-serial'); b.click('select-admin');
|
||||
}
|
||||
assert.equal(b.sockets.length, 2); assert.ok(!serial.closed && !admin.closed);
|
||||
assert.equal(serial.sent.length, 0); assert.equal(admin.sent.length, 0);
|
||||
assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8');
|
||||
assert.equal(b.nodes['release-control'].disabled, false);
|
||||
assert.deepEqual(b.terminals.map(t => t.writes.length), [10, 10]);
|
||||
const reads = b.calls.filter(c => c.url === '/api/settings/serial');
|
||||
assert.equal(reads.length, 10); assert.ok(reads.every(c => c.method === 'GET' && c.body === undefined));
|
||||
b.queues['/api/settings/serial'].push(json(serialSettings({running: false, baud: 110, data_bits: '7', parity: 'odd', stop_bits: '2', flow: 'none', dtr: 'inactive', rts_threshold: 1})));
|
||||
b.click('select-settings'); await tick(); assert.equal(b.nodes['setting-running'].textContent, 'Stopped');
|
||||
b.click('refresh-settings'); await tick(); assert.equal(b.nodes['setting-running'].textContent, 'Running');
|
||||
});
|
||||
await test('Settings rejects malformed/oversized schemas, contains errors, and retries only explicitly', async () => {
|
||||
for (const response of [json(null), json(serialSettings({secret: 'bad'})), json(serialSettings({baud: 1000001})),
|
||||
json(serialSettings({running: 1})), json(serialSettings({parity: '<img>'})), json(serialSettings({rts_threshold: 0})),
|
||||
new Response(' '.repeat(257)), new Response(Uint8Array.of(255)), failure(400), failure(403), failure(404), failure(429), failure(503)]) {
|
||||
const b = await adminBrowser(); b.queues['/api/settings/serial'].push(response);
|
||||
b.click('select-settings'); await tick();
|
||||
assert.equal(b.nodes['settings-values'].hidden, true); assert.equal(b.nodes['setting-baud'].textContent, '');
|
||||
assert.match(b.nodes['settings-detail'].textContent, /Refresh to retry/);
|
||||
assert.ok(!b.nodes['settings-detail'].textContent.includes('SECRET'));
|
||||
assert.equal(b.nodes['refresh-settings'].disabled, false); assert.ok(b.sockets.every(s => !s.closed));
|
||||
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 1);
|
||||
b.click('refresh-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false);
|
||||
}
|
||||
const b = await adminBrowser();
|
||||
b.queues['/api/settings/serial'].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('SECRET timeout')))));
|
||||
b.click('select-settings'); await tick(); b.click('refresh-settings');
|
||||
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 1);
|
||||
b.fire(15000); await tick(); assert.match(b.nodes['settings-detail'].textContent, /Refresh to retry/);
|
||||
assert.ok(b.sockets.every(s => !s.closed));
|
||||
});
|
||||
await test('Settings cancellation fences late replies; session change, 401, logout and restore clear the view', async () => {
|
||||
for (const action of ['switch', 'pagehide', 'expiry', 'logout']) {
|
||||
const b = await adminBrowser(), d = deferred(); b.queues['/api/settings/serial'].push(d.promise);
|
||||
b.click('select-settings'); await tick(); const call = b.calls.find(c => c.url === '/api/settings/serial');
|
||||
if (action === 'switch') b.click('select-serial');
|
||||
if (action === 'pagehide') b.emit('pagehide');
|
||||
if (action === 'expiry') b.window.sakSessionExpired();
|
||||
if (action === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); await b.click('sign-out'); }
|
||||
assert.ok(call.signal.aborted); d.resolve(failure(401)); await tick();
|
||||
assert.equal(b.nodes['serial-settings'].hidden, true); assert.equal(b.nodes['setting-baud'].textContent, '');
|
||||
if (action === 'switch') { assert.deepEqual(b.redirects, []); assert.ok(b.sockets.every(s => !s.closed)); }
|
||||
if (action === 'pagehide') {
|
||||
b.emit('pageshow', {persisted: true}); await tick();
|
||||
assert.deepEqual(b.redirects, []); assert.equal(b.nodes['settings-values'].hidden, true);
|
||||
b.click('refresh-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false);
|
||||
}
|
||||
}
|
||||
for (const change of ['401', 'identity']) {
|
||||
const b = await adminBrowser();
|
||||
if (change === '401') b.queues['/api/settings/serial'].push(failure(401));
|
||||
else b.queues['/api/session'].push(session({role: 'admin', csrf: 'b'.repeat(64)}));
|
||||
b.click('select-settings'); await tick();
|
||||
assert.deepEqual(b.redirects, [change === '401' ? '/login' : '/']);
|
||||
assert.ok(b.sockets.every(s => s.closed)); assert.equal(b.nodes['serial-settings'].hidden, true);
|
||||
assert.equal(b.nodes['setting-baud'].textContent, '');
|
||||
}
|
||||
});
|
||||
await test('Settings session validation never strands concurrent serial reconnect; newer admission fences old settings checks', async () => {
|
||||
const b = await adminBrowser(), d = deferred();
|
||||
b.queues['/api/session'].push(d.promise); b.sockets[0].emit('close'); b.fire(1000); await tick();
|
||||
b.click('select-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false);
|
||||
d.resolve(session({role: 'admin'})); await tick();
|
||||
assert.equal(b.sockets.length, 3); assert.ok(!b.sockets[1].closed);
|
||||
const c = await adminBrowser(), old = deferred(); c.queues['/api/session'].push(old.promise);
|
||||
c.click('select-settings'); await tick(); c.sockets[0].emit('close'); c.fire(1000); await tick();
|
||||
old.resolve(session({role: 'admin'})); await tick();
|
||||
assert.equal(c.sockets.length, 3); assert.ok(!c.sockets[1].closed);
|
||||
assert.equal(c.nodes['refresh-settings'].disabled, false);
|
||||
assert.match(c.nodes['settings-detail'].textContent, /Refresh to retry/);
|
||||
c.click('refresh-settings'); await tick(); assert.equal(c.nodes['settings-values'].hidden, false);
|
||||
});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
Reference in New Issue
Block a user