Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f40c09c11a | ||
|
|
4a53a21f31 |
@@ -224,6 +224,7 @@ Hardware diagnostics are synchronous console commands. RS-232 tests own the phys
|
||||
- Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Some password mutation paths currently derive verifiers while holding the mutation lock; do not generalize the authentication locking pattern without checking the exact path.
|
||||
- Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it. Preserve the existing broker-before-serial lock order.
|
||||
- Serial RX/TX stream payloads, broker per-client payloads, the transactional user-database candidate, and selected cryptographic allocations prefer PSRAM with internal fallback. The live user database, FreeRTOS control structures, UART driver buffers, and task stacks remain internal where deterministic/cache-disable access matters.
|
||||
- Ping's 21-event payload (4,200 bytes) and the public user-console snapshot (2,156 bytes) are lazy PSRAM-only allocations retained for firmware lifetime, with no internal fallback. Queue control and synchronization stay internal; only ping or user status/list/show fails on allocation failure, never registration, mutations or UART0 recovery. Commands retain dispatcher/gate serialization; ping callbacks are task-context producers. The entire user snapshot is wiped after each display attempt. Never introduce ISR/cache-off access or free payloads while callbacks can retain them. Regression coverage: `tests/admin_console_boundary/psram_ping.py` and `accounts.py`.
|
||||
- The build disables wolfSSL ESP32 AES/SHA acceleration, and the HTTPS path uses software AES for PSRAM-backed records. This preserves the validated workaround for uncoordinated mbedTLS/wolfSSL hardware-crypto locks and a prior mbedTLS external-RAM DMA watchdog stall.
|
||||
|
||||
## Legacy credential removal storage boundary
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), then [architecture](architecture.md) and [decisions](design-decisions.md).
|
||||
|
||||
## Web popup cosmetics — 2026-09-14
|
||||
|
||||
- `src/web_ui.c`: contextual buttons now occupy entire status cards; bounded grid columns and clipped/ellipsized values prevent narrow-window overflow. Quick panels preserve the visible terminal title, admin toolbar and selected-view indication; full-page promotion retains drafts and restores Settings presentation.
|
||||
- Quick panels hide full-page explanations and duplicate detail readouts while preserving live status, errors and operation results. Wi-Fi exposes configured profile selection/enabled state and AP policy, plus Apply/Save/Refresh/Result; empty profile slots and advanced editors remain full-page-only. Profile selection is an editing target, not a direct-connect command; existing backend operations are unchanged.
|
||||
- Validation: all 161 UI behavior groups and C/HTML checks PASS; 36 Chromium geometry fixtures PASS at 320/600/900/1200px. `pio run` PASS (94,212 B linked RAM / 1,830,329 B flash). No generated-asset regeneration, upload, erase or hardware validation. Updated regression coverage in `tests/web_ui_session/`.
|
||||
|
||||
## Focused cleanup / PSRAM review — 2026-09-13
|
||||
|
||||
- User-authorized post-acceptance code review: removed superseded `admin_ssh_console_open()` SSH-only wrapper; production already uses available-slot admission. Updated adapter regression to actual production entry. Wrapper was already linker-discarded: no binary saving attributed to removal.
|
||||
- Moved ping queue payload4,200 B and public user snapshot2,156 B to lazy PSRAM-only lifetime allocations, no fallback. Queue control/internal locks unchanged; allocation failure affects only ping or status/list/show, not UART0 registration/mutations/recovery. Snapshot fully wiped on success/error. No serial hot-path, stacks, CPU, queue bounds or external-BSS config changes.
|
||||
- Baseline pio PASS23.79s100,556 RAM/1,828,573 flash. Final parent pio PASS22.11s **94,212 RAM/1,828,809 flash: −6,344 B linked internal RAM/+236 B flash**. Requested lazy PSRAM6,356 B plus allocator overhead; target pointer sizes4 B, controls84 B verified. This is not measured runtime-minimum improvement. CPU160 retained.
|
||||
- Focused ping allocation/callback/end-capacity and accounts allocation/full-wipe/failure/retry tests PASS; console boundary/lifecycle/policy and SSH management/runtime suites PASS. Independent review found no actionable bugs and reran ping/accounts/boundary/diff PASS. Sanitizer linking unavailable (missing host runtimes), no sanitizer or hardware pass. No upload/erase/commit. Next target check: ping/user list/show repeatedly alongside NVS writes and full mix230400, collect serial/broker counters and memory before/after first allocations.
|
||||
- Further audited opportunities, not implemented: OLED framebuffer1,024 B (internal I2C staging retained), local/remote completion scratch1,024 B each, optional web diagnostic ring2,816 B, remote console output payload8,192 B. Console rings need explicit secret-output wiping/lifecycle and admission-failure isolation; do not relocate mixed state wholesale. Leave authoritative user database, driver/DMA buffers, locks and task stacks internal. Global external-BSS enable is not surgical: it also changes SDK library placement.
|
||||
|
||||
## Accepted state — 2026-09-13
|
||||
|
||||
- **8D.22 explicitly signed off by the user:** “Yep, I tested the firmware thats a 8d.22 signoff.” The retained Phase 8D scope is complete; earlier per-slice pending target/review/integration gates are superseded. Roadmap already records 8A/B/C as complete and target-hardware validated, so **Phase 8 is complete**. Acceptance does not manufacture individual unreported test passes.
|
||||
@@ -23,7 +37,7 @@ Working memory, not an implementation timeline. Source is authoritative; begin w
|
||||
- Typed operations carry original-login IDs to the existing dispatcher. Owner-reserved generations fence stale/ABA changes; later revocation/timeout does not cancel admitted work. HTTPS commits before stop/restart; SSH stops before commit/restart; committed identity never rolls back on lifecycle failure. Lost ACK/result means uncertainty, never automatic replay.
|
||||
- Preserve private IDF HTTPD version guards, at-most-one owner-work reservations through failed destruction, retained SSH context until all slots retire, bounded queues/buffers and secret-free metadata. Canonical recovery survives conditional-token exhaustion.
|
||||
|
||||
## Documentation handoff
|
||||
## Previous documentation consolidation handoff
|
||||
|
||||
Initial Git status was clean. This task changes root `README.md`, `docs/` and five test-directory READMEs; executable source/tests/config/generated assets remain untouched. Independent documentation review checked acceptance scope, owner contracts and local links. It restored explicit pointer-backed HTTPD response-header lifetime and same-version SDK-patch audit warnings, updated test README links, and removed obsolete forwarding notes without reopening sign-off.
|
||||
|
||||
|
||||
@@ -142,12 +142,6 @@ esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
esp_err_t admin_ssh_console_dispatch_defer(
|
||||
admin_ssh_deferred_action_type_t action, uint32_t argument);
|
||||
|
||||
/* SSH compatibility entry point, implemented by the owner in ssh_transport.c.
|
||||
* Token/principal are copied; no SSH or socket objects cross this boundary.
|
||||
* Existing feed/close/read/snapshot APIs below also accept open_owned tokens.
|
||||
*/
|
||||
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal);
|
||||
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
|
||||
|
||||
/* Called by the session owner. Returns false when input must be backpressured. */
|
||||
|
||||
+13
-1
@@ -12,6 +12,7 @@
|
||||
|
||||
#include "esp_console.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
@@ -186,7 +187,9 @@ typedef struct {
|
||||
|
||||
#define PING_EVENT_QUEUE_LENGTH (PING_MAX_COUNT + 1U)
|
||||
static StaticQueue_t s_ping_queue_storage;
|
||||
static uint8_t s_ping_queue_bytes[PING_EVENT_QUEUE_LENGTH * sizeof(ping_event_t)];
|
||||
/* Dispatcher-owned lazy payload; retain for firmware lifetime so callback queue
|
||||
* storage cannot dangle. Queue control stays internal. No internal-RAM fallback. */
|
||||
static uint8_t *s_ping_queue_bytes;
|
||||
static QueueHandle_t s_ping_queue;
|
||||
|
||||
static void ping_on_success(esp_ping_handle_t handle, void *arguments)
|
||||
@@ -289,6 +292,15 @@ static int execute_ping(int argc, char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (s_ping_queue_bytes == NULL) {
|
||||
s_ping_queue_bytes = heap_caps_malloc(
|
||||
PING_EVENT_QUEUE_LENGTH * sizeof(ping_event_t),
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (s_ping_queue_bytes == NULL) {
|
||||
printf("ping: PSRAM event storage unavailable\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (s_ping_queue == NULL) {
|
||||
s_ping_queue = xQueueCreateStatic(PING_EVENT_QUEUE_LENGTH, sizeof(ping_event_t),
|
||||
s_ping_queue_bytes, &s_ping_queue_storage);
|
||||
|
||||
@@ -292,15 +292,6 @@ static const admin_console_owner_t s_admin_console_owner = {
|
||||
.perform = admin_console_perform,
|
||||
};
|
||||
|
||||
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
return admin_ssh_console_open_owned(token, principal, &s_admin_console_owner);
|
||||
}
|
||||
|
||||
static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
|
||||
+21
-8
@@ -11,6 +11,7 @@
|
||||
#include "admin_ssh_console.h"
|
||||
#include "console_input.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
@@ -19,8 +20,9 @@
|
||||
|
||||
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U
|
||||
|
||||
/* `user` commands are serialized by the administration gate. */
|
||||
static user_database_snapshot_t s_user_snapshot;
|
||||
/* Gate-owned public projection (no verifiers). Lazily retained for firmware
|
||||
* lifetime; allocation failure must not disable mutations or UART0 recovery. */
|
||||
static user_database_snapshot_t *s_user_snapshot;
|
||||
|
||||
static void print_usage(void)
|
||||
{
|
||||
@@ -92,21 +94,30 @@ static void print_user(const user_database_user_snapshot_t *user)
|
||||
|
||||
static int show_users(const char *selected)
|
||||
{
|
||||
esp_err_t error = user_database_get_snapshot(&s_user_snapshot);
|
||||
if (s_user_snapshot == NULL) {
|
||||
s_user_snapshot = heap_caps_malloc(sizeof(*s_user_snapshot),
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (s_user_snapshot == NULL) {
|
||||
printf("User status unavailable: PSRAM snapshot storage unavailable\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
esp_err_t error = user_database_get_snapshot(s_user_snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("User database unavailable: %s\n", esp_err_to_name(error));
|
||||
secure_wipe(s_user_snapshot, sizeof(*s_user_snapshot));
|
||||
return 1;
|
||||
}
|
||||
if (selected == NULL) {
|
||||
printf("User database: generation=%lu users=%u/%u admins=%u\n",
|
||||
(unsigned long)s_user_snapshot.generation,
|
||||
(unsigned int)s_user_snapshot.user_count,
|
||||
(unsigned long)s_user_snapshot->generation,
|
||||
(unsigned int)s_user_snapshot->user_count,
|
||||
USER_DATABASE_MAX_USERS,
|
||||
(unsigned int)s_user_snapshot.admin_count);
|
||||
(unsigned int)s_user_snapshot->admin_count);
|
||||
}
|
||||
bool found = false;
|
||||
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
|
||||
const user_database_user_snapshot_t *user = &s_user_snapshot.users[index];
|
||||
const user_database_user_snapshot_t *user = &s_user_snapshot->users[index];
|
||||
if (!user->active ||
|
||||
(selected != NULL &&
|
||||
(strlen(selected) != user->username_length ||
|
||||
@@ -118,11 +129,13 @@ static int show_users(const char *selected)
|
||||
}
|
||||
if (selected != NULL && !found) {
|
||||
printf("User '%s' not found.\n", selected);
|
||||
secure_wipe(s_user_snapshot, sizeof(*s_user_snapshot));
|
||||
return 1;
|
||||
}
|
||||
if (s_user_snapshot.admin_count == 0U) {
|
||||
if (s_user_snapshot->admin_count == 0U) {
|
||||
printf("No administrators; use 'user add <username> admin' on UART0.\n");
|
||||
}
|
||||
secure_wipe(s_user_snapshot, sizeof(*s_user_snapshot));
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+35
-27
@@ -26,9 +26,13 @@ static const char s_index_html[] =
|
||||
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
|
||||
"*{box-sizing:border-box}\n"
|
||||
"[hidden]{display:none!important}\n"
|
||||
".quick-trigger{background:none;border:0;padding:0;color:inherit;text-align:left;cursor:pointer}.quick-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:3px}\n"
|
||||
".quick-trigger{display:block;width:100%;color:inherit;text-align:left;font:inherit;cursor:pointer;overflow:hidden}.quick-trigger:disabled{cursor:default}.quick-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:3px}\n"
|
||||
".settings-page[data-quick='true']{position:fixed;z-index:10;top:8px;right:8px;width:min(600px,calc(100vw - 16px));max-height:calc(100vh - 16px);max-height:calc(100dvh - 16px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;box-shadow:0 8px 32px #0008}\n"
|
||||
"[data-quick='true']>#settings-navigation,[data-quick='true'] #network-password-mode-label,[data-quick='true'] #network-password-label,[data-quick='true'] #network-password-status{display:none}\n"
|
||||
"[data-quick='true'] .full-settings-only,[data-quick='true'] #settings-navigation,[data-quick='true'] #quick-help,[data-quick='true'] h3,[data-quick='true']>div>h2,[data-quick='true'] p:not([role=status]),[data-quick='true'] #network-summary,[data-quick='true'] #broker-values,[data-quick='true'] #network-ssid-detail{display:none}\n"
|
||||
"[data-quick='true'] #quick-header>h2{display:block}[data-quick='true'] #settings-values>:nth-child(n+3){display:none}\n"
|
||||
"[data-quick='true'] #network-edit>*{display:none}[data-quick='true'] #network-edit>.settings-edit:first-of-type{display:grid}[data-quick='true'] #network-edit>.network-quick-actions{display:flex}\n"
|
||||
"[data-quick='true'] #network-edit>.settings-edit>label{display:none}[data-quick='true'] #network-edit>.settings-edit>#network-target-label,[data-quick='true'] #network-edit>.settings-edit>#network-policy-label{display:grid}[data-quick='true'] #network-edit>.settings-edit>#network-enabled-label{display:flex}\n"
|
||||
"[data-quick='true'] #network-wifi-load{display:none}\n"
|
||||
"html,body{height:100%;margin:0;overflow:hidden}\n"
|
||||
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
|
||||
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
|
||||
@@ -55,7 +59,7 @@ static const char s_index_html[] =
|
||||
".dashboard{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(280px,.65fr);gap:16px}\n"
|
||||
".panel{background:linear-gradient(145deg,#151e2cdd,#0f1621ee);border:1px solid var(--line);"
|
||||
"border-radius:var(--radius);box-shadow:0 16px 45px #0005}\n"
|
||||
".status-grid{padding:16px;display:grid;grid-template-columns:repeat(4,minmax(105px,1fr));gap:12px}\n"
|
||||
".status-grid{min-width:0;padding:16px;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}\n"
|
||||
".status-item{min-width:0;padding:10px 12px;background:#0b111b99;border:1px solid #202c3e;"
|
||||
"border-radius:10px}\n"
|
||||
".status-item.wide{grid-column:span 2}\n"
|
||||
@@ -152,14 +156,10 @@ static const char s_index_html[] =
|
||||
"<span id=\"role-status\" class=\"badge\" data-tone=\"warn\">Observer</span></div>\n"
|
||||
"<div class=\"status-item\"><span class=\"label\">Broker client</span>"
|
||||
"<span id=\"client-id\" class=\"value\">—</span></div>\n"
|
||||
"<div class=\"status-item\"><span class=\"label\">Active writer</span>"
|
||||
"<button type=\"button\" id=\"quick-writer\" class=\"quick-trigger\" aria-label=\"Active writer management\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"writer-id\" class=\"value\">None</span></button></div>\n"
|
||||
"<div class=\"status-item\"><span class=\"label\">Broker clients</span>"
|
||||
"<button type=\"button\" id=\"quick-broker\" class=\"quick-trigger\" aria-label=\"Live broker clients\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"broker-clients\" class=\"value\">—</span></button></div>\n"
|
||||
"<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>"
|
||||
"<button type=\"button\" id=\"quick-network\" class=\"quick-trigger\" aria-label=\"Wi-Fi quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"wifi-summary\" class=\"value\">Loading…</span></button></div>\n"
|
||||
"<div class=\"status-item wide\"><span class=\"label\">Serial</span>"
|
||||
"<button type=\"button\" id=\"quick-serial\" class=\"quick-trigger\" aria-label=\"Serial quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"serial-summary\" class=\"value\">Loading…</span></button></div>\n"
|
||||
"<button type=\"button\" id=\"quick-writer\" class=\"status-item quick-trigger\" aria-label=\"Active writer management\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Active writer</span><span id=\"writer-id\" class=\"value\">None</span></button>\n"
|
||||
"<button type=\"button\" id=\"quick-broker\" class=\"status-item quick-trigger\" aria-label=\"Live broker clients\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Broker clients</span><span id=\"broker-clients\" class=\"value\">—</span></button>\n"
|
||||
"<button type=\"button\" id=\"quick-network\" class=\"status-item wide quick-trigger\" aria-label=\"Wi-Fi quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Wi-Fi</span><span id=\"wifi-summary\" class=\"value\">Loading…</span></button>\n"
|
||||
"<button type=\"button\" id=\"quick-serial\" class=\"status-item wide quick-trigger\" aria-label=\"Serial quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Serial</span><span id=\"serial-summary\" class=\"value\">Loading…</span></button>\n"
|
||||
"</div>\n"
|
||||
"<div class=\"panel controls\">\n"
|
||||
"<div class=\"button-row\">\n"
|
||||
@@ -201,7 +201,7 @@ static const char s_index_html[] =
|
||||
"<div class=\"serial-actions\"><button id=\"lifecycle-stop\" class=\"button\" type=\"button\">Stop HTTPS…</button><button id=\"lifecycle-restart\" class=\"button\" type=\"button\">Restart HTTPS…</button><button id=\"lifecycle-reboot\" class=\"button\" type=\"button\">Reboot device…</button><button id=\"lifecycle-rotate\" class=\"button\" type=\"button\">Rotate HTTPS identity…</button><button id=\"lifecycle-result\" class=\"button\" type=\"button\">Check Operation Result</button></div>"
|
||||
"<p id=\"lifecycle-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Acknowledgement is not peer receipt or completion. Connection loss, expiry, revocation or timeout does not prove cancellation after admission. No automatic mutation retry or restore. Check Result, inspect state, then act explicitly.</p><a href=\"/\">Reload / sign in after recovery</a></div>\n"
|
||||
"<div id=\"ssh-settings\" hidden><h2>SSH service and sessions</h2><p class=\"connection-detail\">SSH only, TCP port 22. Start/Stop do not change saved settings or host identity. Stop closes all SSH sessions, including any admitted before execution; an SSH administrator's already executing command may finish. HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped. Targeted disconnect affects only the selected SSH connection, not all logins for its account. Viewing or selecting never changes services or writer ownership.</p><button id=\"ssh-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"ssh-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"ssh-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Disconnect SSH session<select id=\"ssh-target\"><option value=\"\">Select a session</option><option id=\"ssh-option-0\" hidden disabled></option><option id=\"ssh-option-1\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"ssh-start\" class=\"button\" type=\"button\">Start SSH…</button><button id=\"ssh-stop\" class=\"button\" type=\"button\">Stop SSH…</button><button id=\"ssh-rotate\" class=\"button\" type=\"button\">Rotate SSH host identity…</button><button id=\"ssh-disconnect\" class=\"button\" type=\"button\">Disconnect selected…</button><button id=\"ssh-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"ssh-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. After submission use Check Operation Result, then Refresh. Navigation or timeout does not cancel admitted work. No automatic mutation retry.</p></div>\n"
|
||||
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</p></div>\n"
|
||||
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\"><span class=\"full-settings-only\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</span></p></div>\n"
|
||||
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
|
||||
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 0–86400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
|
||||
"<p class=\"connection-detail\">Apply and Defaults change RAM only. Save persists the working snapshot, not browser drafts. Load discards drafts and uses stored settings, or defaults if storage is absent/incompatible; it does not change NVS. Reset saves defaults and applies them. Refresh discards drafts. Intervening configuration edits reject stale operations: Refresh and review before retrying.</p>\n"
|
||||
@@ -227,9 +227,9 @@ static const char s_index_html[] =
|
||||
"<button id=\"network-refresh\" class=\"button\" type=\"button\">Refresh</button>"
|
||||
"<p id=\"network-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"network-summary\" class=\"settings-values\"></dl>"
|
||||
"<div id=\"network-edit\" hidden><h3>Wi-Fi working configuration</h3><div class=\"settings-edit\">"
|
||||
"<label>Target<select id=\"network-target\"><option value=\"ap\">Access point / boot policy</option>"
|
||||
"<option value=\"0\">STA profile 0</option><option value=\"1\">STA profile 1</option>"
|
||||
"<option value=\"2\">STA profile 2</option><option value=\"3\">STA profile 3</option></select></label>"
|
||||
"<label id=\"network-target-label\">Profile / AP mode<select id=\"network-target\"><option value=\"ap\">Access point</option>"
|
||||
"<option id=\"network-profile-0\" value=\"0\">STA profile 0</option><option id=\"network-profile-1\" value=\"1\">STA profile 1</option>"
|
||||
"<option id=\"network-profile-2\" value=\"2\">STA profile 2</option><option id=\"network-profile-3\" value=\"3\">STA profile 3</option></select></label>"
|
||||
"<label id=\"network-boot-label\" class=\"settings-check\">Enabled at boot<input id=\"network-boot\" type=\"checkbox\"></label>"
|
||||
"<label id=\"network-policy-label\">AP policy<select id=\"network-policy\"><option>off</option><option>fallback</option><option>always</option></select></label>"
|
||||
"<label id=\"network-channel-label\">AP channel<input id=\"network-channel\" type=\"number\" min=\"1\" max=\"11\" step=\"1\"></label>"
|
||||
@@ -246,7 +246,7 @@ static const char s_index_html[] =
|
||||
"Switching modes is lossless or refused. Password replacement requires 8–63 printable ASCII characters; blank never clears. "
|
||||
"AP always requires a PSK. STA clear requires disabled state. Transient passwords expire after 60 seconds and clear on submission or context change. "
|
||||
"NVS is unencrypted; replacement/clear is not secure flash erasure. JavaScript cannot securely zero strings.</p>"
|
||||
"<div class=\"serial-actions\"><button id=\"network-apply\" class=\"button\" type=\"button\">Apply selected Wi-Fi target to RAM</button>"
|
||||
"<div class=\"serial-actions network-quick-actions\"><button id=\"network-apply\" class=\"button\" type=\"button\">Apply selected Wi-Fi target to RAM</button>"
|
||||
"<button id=\"network-wifi-save\" class=\"button\" type=\"button\">Save working Wi-Fi to NVS</button>"
|
||||
"<button id=\"network-wifi-load\" class=\"button\" type=\"button\">Load stored Wi-Fi</button>"
|
||||
"</div><h3>Wi-Fi connection</h3><div class=\"serial-actions\"><button id=\"network-start\" class=\"button\" type=\"button\">Start</button><button id=\"network-stop\" class=\"button\" type=\"button\">Stop</button>"
|
||||
@@ -261,7 +261,7 @@ static const char s_index_html[] =
|
||||
"<button id=\"network-mdns-load\" class=\"button\" type=\"button\">Load mDNS</button>"
|
||||
"<button id=\"network-mdns-defaults\" class=\"button\" type=\"button\">mDNS defaults in RAM</button></div></div>"
|
||||
"<button id=\"network-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
|
||||
"<p id=\"network-operation-detail\" class=\"connection-detail\" role=\"status\">After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.</p></div>"
|
||||
"<p id=\"network-operation-detail\" class=\"connection-detail\" role=\"status\"><span class=\"full-settings-only\">After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.</span></p></div>"
|
||||
"<div id=\"serial-settings-content\"><h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. "
|
||||
"Navigation leaves both terminals connected and preserves the serial writer lease.</p>"
|
||||
"<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>"
|
||||
@@ -296,7 +296,7 @@ static const char s_index_html[] =
|
||||
"<button id=\"serial-start\" class=\"button\" type=\"button\">Start</button>"
|
||||
"<button id=\"serial-stop\" class=\"button\" type=\"button\">Stop</button></div></div>"
|
||||
"<button id=\"serial-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
|
||||
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</p></div>"
|
||||
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\"><span class=\"full-settings-only\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</span></p></div>"
|
||||
"<div id=\"account-settings\" hidden><h2>Accounts</h2>"
|
||||
"<p class=\"connection-detail\">Role and deletion changes are saved immediately and revoke only the affected account's network sessions. The final administrator is protected. "
|
||||
"Self changes can immediately close all this account's web/SSH sessions, including browser serial/admin. A disconnect or 401 is NOT proof of success; re-login and inspect before retrying.</p>"
|
||||
@@ -439,7 +439,7 @@ static const char s_app_js[] =
|
||||
" element('quick-title').textContent = quickTitle(domain);\n"
|
||||
" element('quick-help').textContent = brokerContext ? 'Live clients share one writer and isolated observation. Opening, refreshing and selecting never transfers the lease. Assign writer requires separate confirmation. Closing does not cancel admitted work; after uncertainty use Check Result, never automatic retry.' : 'Opening and selecting never applies changes. Apply changes RAM; Save persists working device values, not drafts. Closing discards drafts; admitted work is not cancelled. Password changes are available only on the full Network page.';\n"
|
||||
" element('quick-header').hidden = false; trigger.setAttribute('aria-expanded', 'true');\n"
|
||||
" terminalHost.hidden = previous !== 'serial'; adminHost.hidden = previous !== 'admin';\n"
|
||||
" renderTerminalView(previous);\n"
|
||||
" refreshSettings(); if (activate) element('quick-close').focus();\n"
|
||||
"}\n"
|
||||
"function quickContains(target) { return !!quick && !!target && (settingsHost.contains(target) || quick.trigger.contains(target)); }\n"
|
||||
@@ -458,7 +458,7 @@ static const char s_app_js[] =
|
||||
"settingsHost.addEventListener('pointerenter', () => window.clearTimeout(quickLeaveTimer));\n"
|
||||
"settingsHost.addEventListener('pointerleave', quickLeave); settingsHost.addEventListener('focusout', quickLeave);\n"
|
||||
"element('quick-close').addEventListener('click', () => closeQuick(true));\n"
|
||||
"element('quick-full').addEventListener('click', event => { event.preventDefault(); if (!quick) return; resetQuick(); clearNetworkSecret(); networkButtons(); terminalHost.hidden = adminHost.hidden = true; element('settings-' + settingsDomain).focus(); });\n"
|
||||
"element('quick-full').addEventListener('click', event => { event.preventDefault(); if (!quick) return; resetQuick(); clearNetworkSecret(); networkButtons(); renderTerminalView('settings'); element('settings-' + settingsDomain).focus(); });\n"
|
||||
"window.addEventListener('pointerdown', event => { if (quick && !quickContains(event.target)) closeQuick(); });\n"
|
||||
"window.addEventListener('keydown', event => { if (quick && event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeQuick(true); } });\n"
|
||||
"function clearSettings() {\n"
|
||||
@@ -498,7 +498,7 @@ static const char s_app_js[] =
|
||||
" !['inactive', 'active', 'on-connect'].includes(value.dtr) || !Number.isInteger(value.rts_threshold) || value.rts_threshold < 1 || value.rts_threshold > 127) throw new Error('Invalid snapshot');\n"
|
||||
" for (const key of settingsFields) element('setting-' + key).textContent = key === 'running' ? (value[key] ? 'Running' : 'Stopped') : String(value[key]);\n"
|
||||
" for (const key of settingsFields.slice(1)) element('edit-' + key).value = String(value[key]);\n"
|
||||
" element('serial-edit').hidden = false; element('settings-values').hidden = false; settingsDetail.textContent = (serialOperationPending ? 'Snapshot may be stale: operation outcome pending or unknown. ' : 'Working snapshot loaded. ') + 'Apply changes RAM; Save explicitly persists working settings. Refresh replaces your draft.';\n"
|
||||
" element('serial-edit').hidden = false; element('settings-values').hidden = false; settingsDetail.textContent = (serialOperationPending ? 'Snapshot may be stale: operation outcome pending or unknown. ' : 'Working snapshot loaded. ') + (quick ? '' : 'Apply changes RAM; Save explicitly persists working settings. Refresh replaces your draft.');\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(generation) && current()) settingsDetail.textContent = (error.status ? error.message : 'Serial snapshot could not be read or was invalid.') + ' Snapshot stale or unavailable. Select Refresh to retry.';\n"
|
||||
" } finally {\n"
|
||||
@@ -817,7 +817,7 @@ static const char s_app_js[] =
|
||||
" }\n"
|
||||
" }\n"
|
||||
" element('broker-target').value = brokerSelection && !brokerSelection.missing && brokerClients.some(c => c.id === brokerSelection.target) ? String(brokerSelection.target) : '';\n"
|
||||
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. ' + (brokerSelection?.stale ? 'Selection stale: lease or client changed. Choose the blank option then explicitly select again before confirmation. ' : 'Refresh preserves selection, never renews its confirmation token. ') + (quick ? 'Live refresh every 5 seconds after completion. ' : '') + (brokerGeneration === 4294967295 ? 'Confirmation generation exhausted; use the admin shell.' : '');\n"
|
||||
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. ' + (brokerSelection?.stale ? 'Selection stale: choose the blank option, then reselect a client. ' : quick ? '' : 'Refresh preserves selection, never renews its confirmation token. ') + (brokerGeneration === 4294967295 ? 'Confirmation generation exhausted; use the admin shell.' : '');\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(generation) && current()) { brokerGeneration = 0; if (brokerSelection) brokerSelection.stale = true; brokerDetail.textContent = (error.status ? error.message : 'Broker snapshot unavailable or invalid.') + ' Selection stale. Select Refresh and reselect to retry.'; }\n"
|
||||
" } finally { window.clearTimeout(deadline); if (current()) { brokerAbort = null; brokerButtons(); if (brokerGeneration) scheduleBrokerLive(); } }\n"
|
||||
@@ -1283,6 +1283,11 @@ static const char s_app_js[] =
|
||||
" net('refresh').disabled = net('result').disabled = busy;\n"
|
||||
" net('password-clear').hidden = net('password-clear').disabled = net('target').value === 'ap';\n"
|
||||
" if (quick) { clearNetworkSecret(); net('password-mode').disabled = true; }\n"
|
||||
" for (let i = 0; i < 4; ++i) {\n"
|
||||
" const option = net('profile-' + i), profile = networkSnapshot?.wifi.profiles[i];\n"
|
||||
" option.textContent = 'STA ' + i + (profile?.ssid ? ' — ' + networkSSIDSummary(profile.ssid) : ' (empty)');\n"
|
||||
" option.hidden = option.disabled = !!quick && !profile?.ssid;\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"function clearNetwork() {\n"
|
||||
" clearNetworkSecret();\n"
|
||||
@@ -1357,9 +1362,9 @@ static const char s_app_js[] =
|
||||
" ['AP running', r.ap_running], ['AP clients', r.ap_clients], ['Wi-Fi last error', r.last_error],\n"
|
||||
" ['mDNS generation', m.generation], ['Hostname', m.hostname + '.local'], ['Expected announcement', m.announced],\n"
|
||||
" ['mDNS last error', m.last_error], ['DNS verification', 'Not client-verified DNS.']]);\n"
|
||||
" if (!['ap','0','1','2','3'].includes(net('target').value)) net('target').value = 'ap';\n"
|
||||
" if (!['ap','0','1','2','3'].includes(net('target').value) || quick && net('target').value !== 'ap' && !w.profiles[Number(net('target').value)]?.ssid) net('target').value = 'ap';\n"
|
||||
" renderNetworkTarget(); net('suffix').value = m.suffix; net('edit').hidden = false;\n"
|
||||
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). ') + 'Browser drafts are not saved; Save persists device working state.';\n"
|
||||
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : '') + (quick ? r.state + ' · IP: ' + (r.ip || 'none') + ' · AP: ' + (r.ap_running ? 'running' : 'off') + ' · Profile: ' + (r.active_profile < 0 ? 'none' : r.active_profile) : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). Browser drafts are not saved; Save persists device working state.');\n"
|
||||
" } catch (error) { if (live(generation) && current()) net('detail').textContent = 'Network snapshot stale or unavailable/invalid. Refresh explicitly to retry. No values inferred.'; }\n"
|
||||
" finally { if (current()) { networkAbort = null; networkButtons(); } }\n"
|
||||
"}\n"
|
||||
@@ -1555,15 +1560,18 @@ static const char s_app_js[] =
|
||||
" for (let offset = 0; offset < bytes.length; offset += 512) adminSocket.send(bytes.subarray(offset, offset + 512));\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
" renderTerminalView(mode);\n"
|
||||
" settingsHost.hidden = mode !== 'settings';\n"
|
||||
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
|
||||
" if (mode === 'settings') { if (read) refreshSettings(); } else if (focus) (mode === 'serial' ? terminal : adminTerminal).focus();\n"
|
||||
"}\n"
|
||||
"function renderTerminalView(mode) {\n"
|
||||
" terminalHost.hidden = mode !== 'serial'; adminHost.hidden = mode !== 'admin';\n"
|
||||
" adminToggle.hidden = adminDetail.hidden = mode !== 'admin';\n"
|
||||
" element('select-serial').setAttribute('aria-pressed', String(mode === 'serial'));\n"
|
||||
" element('select-admin').setAttribute('aria-pressed', String(mode === 'admin'));\n"
|
||||
" element('select-settings').setAttribute('aria-pressed', String(mode === 'settings'));\n"
|
||||
" settingsHost.hidden = mode !== 'settings';\n"
|
||||
" element('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : mode === 'admin' ? 'Administration shell' : 'Settings';\n"
|
||||
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
|
||||
" if (mode === 'settings') { if (read) refreshSettings(); } else if (focus) (mode === 'serial' ? terminal : adminTerminal).focus();\n"
|
||||
"}\n"
|
||||
"async function openAdmin() {\n"
|
||||
" if (accountRole !== 'admin' || selected !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf || adminSocket || adminAbort) return;\n"
|
||||
|
||||
@@ -138,8 +138,9 @@ static bool admin_ssh_console_dispatch_is_current(void) {
|
||||
if (checks==revoke_check) owner_current=false;
|
||||
return owner_current && user_database_principal_is_current(&actor, ¤t)==ESP_OK && current;
|
||||
}
|
||||
static int admin_command_gate_take(void) { return ESP_OK; }
|
||||
static void admin_command_gate_give(void) {}
|
||||
static bool gate_held;
|
||||
static int admin_command_gate_take(void) { assert(!gate_held); gate_held=true; return ESP_OK; }
|
||||
static void admin_command_gate_give(void) { assert(gate_held); gate_held=false; }
|
||||
static int console_input_read_hidden(const char *prompt, uint8_t *out, size_t cap,
|
||||
size_t min, size_t max, size_t *n) {
|
||||
(void)prompt; (void)min; (void)max; assert(cap>=13); ++prompts;
|
||||
@@ -158,7 +159,28 @@ static int ssh_transport_revoke_user(const uint8_t *u, size_t n) {
|
||||
++ssh_revokes; assert(strlen(revoked_name)==n && !memcmp(u,revoked_name,n)); return notify_error;
|
||||
}
|
||||
/* Forbidden paths are traps rather than alternative implementations. */
|
||||
static int show_users(const char *n) { (void)n; return 0; }
|
||||
#define MALLOC_CAP_SPIRAM 1U
|
||||
#define MALLOC_CAP_8BIT 2U
|
||||
static bool fail_snapshot_alloc;
|
||||
static unsigned snapshot_allocations;
|
||||
static struct { uint64_t before; user_database_snapshot_t value; uint64_t after; } snapshot_memory;
|
||||
static void *heap_caps_malloc(size_t n, unsigned caps) {
|
||||
assert(gate_held && n==sizeof(snapshot_memory.value));
|
||||
assert(caps==(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
|
||||
++snapshot_allocations;
|
||||
if (fail_snapshot_alloc) return NULL;
|
||||
snapshot_memory.before=snapshot_memory.after=UINT64_C(0xaabbccdd11223344);
|
||||
memset(&snapshot_memory.value,0xa5,n);
|
||||
return &snapshot_memory.value;
|
||||
}
|
||||
static unsigned user_registrations;
|
||||
typedef struct { const char *command,*help,*hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
|
||||
static int esp_console_cmd_register(const esp_console_cmd_t *c) {
|
||||
assert(!strcmp(c->command,"user") && c->func); ++user_registrations; return ESP_OK;
|
||||
}
|
||||
static int mbedtls_base64_encode(uint8_t *out,size_t cap,size_t *n,const uint8_t *in,size_t len) {
|
||||
(void)in; assert(cap>=4 && len==32); memcpy(out,"AAAA",4); *n=4; return 0;
|
||||
}
|
||||
|
||||
static int add_key(const char *n) { (void)n; assert(!"key mutation"); return 1; }
|
||||
static int add_key_parts(const char *n,const uint8_t *t,size_t tl,const uint8_t *b,size_t bl) {
|
||||
@@ -190,17 +212,21 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
|
||||
"user_database_clear_ssh_keys_current", "fill_principal", "user_database_authorize_ssh_public_key",
|
||||
"initialize_dummy_verifier", "user_database_init", "user_database_recover_empty",
|
||||
"user_database_get_snapshot"]
|
||||
console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
|
||||
console_names = ["print_fingerprint", "print_user", "show_users", "print_usage", "revoke_user_network_sessions", "read_password",
|
||||
"show_generated_password", "mutation_currentness", "add_user", "change_password",
|
||||
"parse_key_index", "recover_database", "command_user_inner", "command_user"]
|
||||
"parse_key_index", "recover_database", "command_user_inner", "command_user",
|
||||
"user_console_register_commands"]
|
||||
unit = prelude + header + "\n" + state + fakes
|
||||
unit += "\n".join(function(db, n) for n in db_names)
|
||||
unit += function(admin, "admin_ssh_console_web_user_command_allowed")
|
||||
unit += console[console.index("static user_database_snapshot_t"):console.index("static void print_usage")]
|
||||
unit += "\n".join(function(console, n) for n in console_names)
|
||||
account_tests = (ROOT / "tests/admin_console_boundary/accounts.c").read_text()
|
||||
key_tests = (ROOT / "tests/admin_console_boundary/account_keys.c").read_text()
|
||||
account_tests = account_tests.replace('int main(void)', key_tests + '\nint main(void)')
|
||||
account_tests = account_tests.replace(' typed_account_tests();', ' typed_key_tests();\n typed_account_tests();')
|
||||
snapshot_tests = (ROOT / "tests/admin_console_boundary/psram_snapshot.c").read_text()
|
||||
account_tests = account_tests.replace('int main(void)', snapshot_tests + '\nint main(void)')
|
||||
account_tests = account_tests.replace(' typed_account_tests();', ' psram_snapshot_tests();\n typed_key_tests();\n typed_account_tests();')
|
||||
assert ' typed_key_tests();' in account_tests
|
||||
unit += account_tests
|
||||
with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
|
||||
@@ -209,9 +235,13 @@ with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable",
|
||||
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
|
||||
"-lcrypto", "-o", str(path / "test")], check=True, timeout=30)
|
||||
result = subprocess.run([str(path / "test")], check=True, timeout=10, capture_output=True, text=True)
|
||||
result = subprocess.run([str(path / "test")], timeout=10, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
print(result.stderr)
|
||||
result.check_returncode()
|
||||
assert "test-password" not in result.stdout
|
||||
assert "Generated password for" not in result.stdout
|
||||
print("PASS: PSRAM snapshot allocation failure/retry/retention, status/list/show/unknown/unavailable, full wipe and bounds, gate ownership, mutation/recovery isolation")
|
||||
print("PASS: empty initialization/recovery, unchanged v1 records, corrupt/unsupported fail-closed loads, first UART0 administrator and removed bootstrap commands")
|
||||
print("PASS: canonical SSH keys: Ed25519/P256 parser and authorization, malformed/off-curve/truncated inputs, zero-wait fingerprints, stale ID/generation/recreation, duplicates/capacity, sparse indices, failed persistence and CLI parity (OpenSSL-backed curve/SHA adapters)")
|
||||
print("PASS: operation-admission semantics: browser invalidated in derivation double before NVS; admitted add/password transactions still commit, only target is revoked, next command rejects; persistence failure still preserves live state (not precommit cancellation or real concurrency)")
|
||||
|
||||
@@ -33,6 +33,7 @@ static bool admin_console_drained(const admin_ssh_console_token_t *);
|
||||
static bool admin_console_is_current(const admin_ssh_console_token_t *, const user_principal_t *);
|
||||
static bool consume_external_close(const ssh_slot_t *, size_t);
|
||||
static esp_err_t admin_console_perform(const admin_ssh_console_token_t *, admin_ssh_deferred_action_type_t, uint32_t);
|
||||
static const admin_console_owner_t s_admin_console_owner;
|
||||
static void test_adapter(void)
|
||||
{
|
||||
admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 };
|
||||
@@ -40,7 +41,7 @@ static void test_adapter(void)
|
||||
.username_length=5, .username="admin" };
|
||||
assert(admin_ssh_console_init()==ESP_OK);
|
||||
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
|
||||
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
|
||||
assert(admin_ssh_console_open_available(&token,&admin,&s_admin_console_owner)==ESP_OK);
|
||||
assert(!admin_console_drained(&token));
|
||||
s_session_snapshots[0]=(ssh_transport_session_snapshot_t){ .active=true, .session_id=7, .generation=3 };
|
||||
assert(!admin_console_drained(&token)); /* No published console binding. */
|
||||
@@ -82,16 +83,19 @@ static void test_adapter(void)
|
||||
admin.username_length=1; assert(!admin_console_is_current(&token,&admin)); admin.username_length=5;
|
||||
token.transport=1; assert(!admin_console_drained(&token));
|
||||
assert(!admin_console_is_current(&token,&admin));
|
||||
assert(admin_ssh_console_open(&token,&admin)==ESP_ERR_INVALID_ARG);
|
||||
|
||||
token.transport=0; token.slot_generation=4; assert(!admin_console_drained(&token));
|
||||
assert(!admin_console_is_current(&token,&admin));
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND && stopped==0);
|
||||
token.slot_generation=3;
|
||||
/* Same physical session can be assigned the other console slot. */
|
||||
admin_ssh_console_close(&token);
|
||||
token.slot_index=1;
|
||||
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
|
||||
active.console_slot_index=1; publish_slot(&active,0);
|
||||
admin_ssh_console_token_t other={ .session_id=8, .slot_generation=1 };
|
||||
assert(admin_ssh_console_open_available(&other,&admin,&s_admin_console_owner)==ESP_OK);
|
||||
assert(other.slot_index==0);
|
||||
assert(admin_ssh_console_open_available(&token,&admin,&s_admin_console_owner)==ESP_OK);
|
||||
assert(token.slot_index==1);
|
||||
active.console_slot_index=token.slot_index; publish_slot(&active,0);
|
||||
assert(admin_console_is_current(&token,&admin));
|
||||
assert(s_console_slot_indices[0]==1);
|
||||
admin_ssh_console_token_t wrong=token; wrong.slot_index=0;
|
||||
@@ -111,5 +115,5 @@ static void test_adapter(void)
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_ROTATE,0)==ESP_OK && rotated==1);
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_RESET,0)==ESP_OK && reset==1);
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_REBOOT,0)==ESP_OK && restarted==1);
|
||||
puts("PASS: actual SSH snapshot/principal publication and wiping, adapter identity/drain checks, legacy admission and lifecycle action routing");
|
||||
puts("PASS: actual SSH snapshot/principal publication and wiping, adapter identity/drain checks, shared-slot admission and lifecycle action routing");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
static int run_ping(const char *count)
|
||||
{
|
||||
char *argv[]={"ping","localhost",(char *)count};
|
||||
int result=network_console_execute(count?3:2,argv);
|
||||
if(worker_started) { assert(!pthread_join(worker,NULL)); worker_started=false; }
|
||||
return result;
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
_Static_assert(sizeof(ping_event_t)==200,"audited event payload changed");
|
||||
assert(network_console_register_root_commands()==ESP_OK && registrations==3 && !allocations);
|
||||
assert(run_ping("0")==1 && run_ping("21")==1 && run_ping("-1")==1);
|
||||
assert(run_ping("999999999999999999999")==1 && !allocations);
|
||||
resolve_fail=true; assert(run_ping(NULL)==1 && !allocations); resolve_fail=false;
|
||||
fail_alloc=true; assert(run_ping(NULL)==1 && allocations==1 && !creations && !starts);
|
||||
char *lookup[]={"nslookup","localhost"};
|
||||
assert(network_console_execute(2,lookup)==0);
|
||||
assert(network_console_register_root_commands()==ESP_OK && allocations==1);
|
||||
fail_alloc=false; fail_queue=true;
|
||||
assert(run_ping(NULL)==1 && allocations==2 && creations==1 && !starts);
|
||||
void *retained=s_ping_queue_bytes;
|
||||
fail_alloc=true; fail_queue=false; fail_new=true;
|
||||
assert(run_ping(NULL)==1 && allocations==2 && creations==2 && !starts);
|
||||
fail_new=false; fail_start=true;
|
||||
assert(run_ping(NULL)==1 && deletes==1);
|
||||
fail_start=false;
|
||||
for(unsigned i=0;i<4;++i) {
|
||||
burst=i%2==0;
|
||||
assert(run_ping("20")==0);
|
||||
assert(s_ping_queue_bytes==retained && allocations==2 && creations==2);
|
||||
assert(queue.count==0);
|
||||
assert(memory.before==UINT64_C(0x1122334455667788));
|
||||
assert(memory.after==UINT64_C(0x1122334455667788));
|
||||
}
|
||||
/* Reset stale payload before a new session, preserving the end-event slot. */
|
||||
ping_event_t stale={.kind=PING_EVENT_END,.profile_error=ESP_FAIL};
|
||||
xQueueSend(s_ping_queue,&stale,0);
|
||||
assert(run_ping("1")==0);
|
||||
all_timeouts=true; assert(run_ping(NULL)==1); all_timeouts=false;
|
||||
fail_profile=true; assert(run_ping(NULL)==1); fail_profile=false;
|
||||
fail_delete=true; assert(run_ping(NULL)==1); fail_delete=false;
|
||||
assert(run_ping(NULL)==0 && resets>=10 && allocations==2);
|
||||
assert(s_ping_queue==&queue && s_ping_queue_bytes==retained);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Production ping command/callbacks with bounded pthread queue and SDK doubles."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
s = (ROOT / 'src/network_console.c').read_text()
|
||||
def function(name):
|
||||
start = s.rfind('\n', 0, s.index(name + '(')) + 1
|
||||
return s[start:s.index('\n}', start) + 2] + '\n'
|
||||
prelude = (ROOT / 'tests/admin_console_boundary/psram_ping_fakes.c').read_text()
|
||||
unit = prelude + '\n'
|
||||
unit += s[s.index('#define PING_DEFAULT_COUNT'):s.index('/* This covers')]
|
||||
unit += function('print_command_usage') + function('parse_bounded_u32')
|
||||
unit += s[s.index('typedef enum {\n PING_EVENT_LINE'):s.index('static bool socket_addresses_equal')]
|
||||
unit += function('network_console_is_command') + function('network_console_execute')
|
||||
unit += function('network_console_register_root_commands')
|
||||
unit += (ROOT / 'tests/admin_console_boundary/psram_ping.c').read_text()
|
||||
with tempfile.TemporaryDirectory(prefix='psram-ping-') as d:
|
||||
p = Path(d)
|
||||
(p / 'test.c').write_text(unit)
|
||||
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', '-pthread',
|
||||
'-g', str(p / 'test.c'), '-o', str(p / 'test')],
|
||||
check=True, timeout=30)
|
||||
subprocess.run([str(p / 'test')], check=True, timeout=20)
|
||||
print('PASS: production ping allocation/create-queue/session failures, retry/retention/reset, bounds, asynchronous success/timeout/end callbacks and registration isolation (host doubles)')
|
||||
@@ -0,0 +1,119 @@
|
||||
/* Deterministic SDK boundaries; queue data movement uses the supplied payload. */
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL };
|
||||
#define MALLOC_CAP_SPIRAM 1U
|
||||
#define MALLOC_CAP_8BIT 2U
|
||||
#define portMAX_DELAY UINT32_MAX
|
||||
#define pdTRUE 1
|
||||
static bool fail_alloc, fail_queue, fail_new, fail_start, fail_delete, fail_profile, resolve_fail;
|
||||
static unsigned allocations, creations, resets, registrations, starts, deletes;
|
||||
static struct { uint64_t before; uint8_t bytes[4200]; uint64_t after; } memory;
|
||||
static void *heap_caps_malloc(size_t n, unsigned caps) {
|
||||
++allocations; assert(n==sizeof(memory.bytes));
|
||||
assert(caps==(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
|
||||
if (fail_alloc) return NULL;
|
||||
memory.before=memory.after=UINT64_C(0x1122334455667788);
|
||||
return memory.bytes;
|
||||
}
|
||||
typedef struct { unsigned marker; } StaticQueue_t;
|
||||
typedef struct { uint8_t *bytes; size_t length, size, read, count; } queue_t;
|
||||
typedef queue_t *QueueHandle_t;
|
||||
static queue_t queue;
|
||||
static pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_cond_t ready=PTHREAD_COND_INITIALIZER;
|
||||
static QueueHandle_t xQueueCreateStatic(size_t length,size_t size,uint8_t *bytes,StaticQueue_t *control) {
|
||||
++creations; assert(length==21 && size==200 && bytes==memory.bytes && control);
|
||||
if (fail_queue) return NULL;
|
||||
queue=(queue_t){.bytes=bytes,.length=length,.size=size}; return &queue;
|
||||
}
|
||||
static int xQueueReset(QueueHandle_t q) {
|
||||
pthread_mutex_lock(&mutex); ++resets; q->read=q->count=0;
|
||||
pthread_mutex_unlock(&mutex); return pdTRUE;
|
||||
}
|
||||
static int xQueueSend(QueueHandle_t q,const void *event,unsigned wait) {
|
||||
assert(wait==0); pthread_mutex_lock(&mutex); assert(q->count<q->length);
|
||||
memcpy(q->bytes+((q->read+q->count)%q->length)*q->size,event,q->size);
|
||||
++q->count; pthread_cond_signal(&ready); pthread_mutex_unlock(&mutex); return pdTRUE;
|
||||
}
|
||||
static int xQueueReceive(QueueHandle_t q,void *event,unsigned wait) {
|
||||
assert(wait==portMAX_DELAY); pthread_mutex_lock(&mutex);
|
||||
while (!q->count) pthread_cond_wait(&ready,&mutex);
|
||||
memcpy(event,q->bytes+q->read*q->size,q->size);
|
||||
q->read=(q->read+1)%q->length; --q->count;
|
||||
pthread_mutex_unlock(&mutex); return pdTRUE;
|
||||
}
|
||||
typedef uint32_t ip_addr_t;
|
||||
#define IP_IS_V4(p) ((void)(p), 1)
|
||||
static char *ipaddr_ntoa_r(const ip_addr_t *ip,char *out,int n) {
|
||||
(void)ip; assert(n>=10); strcpy(out,"127.0.0.1"); return out;
|
||||
}
|
||||
static size_t strlcpy(char *out,const char *in,size_t n) {
|
||||
size_t len=strlen(in); if(n) { size_t copy=len<n-1?len:n-1; memcpy(out,in,copy); out[copy]=0; } return len;
|
||||
}
|
||||
static int resolve_ping_target(const char *host,ip_addr_t *ip,char *out,size_t n) {
|
||||
(void)host; *ip=0; ipaddr_ntoa_r(ip,out,(int)n); return resolve_fail;
|
||||
}
|
||||
static const char *esp_err_to_name(int e) { (void)e; return "injected"; }
|
||||
typedef void *esp_ping_handle_t;
|
||||
typedef struct { uint32_t count; ip_addr_t target_addr; } esp_ping_config_t;
|
||||
#define ESP_PING_DEFAULT_CONFIG() ((esp_ping_config_t){0})
|
||||
typedef struct {
|
||||
void *cb_args;
|
||||
void (*on_ping_success)(esp_ping_handle_t,void *);
|
||||
void (*on_ping_timeout)(esp_ping_handle_t,void *);
|
||||
void (*on_ping_end)(esp_ping_handle_t,void *);
|
||||
} esp_ping_callbacks_t;
|
||||
enum { ESP_PING_PROF_SEQNO, ESP_PING_PROF_SIZE, ESP_PING_PROF_TIMEGAP,
|
||||
ESP_PING_PROF_IPADDR, ESP_PING_PROF_TTL, ESP_PING_PROF_REQUEST,
|
||||
ESP_PING_PROF_REPLY, ESP_PING_PROF_DURATION };
|
||||
static esp_ping_callbacks_t callbacks;
|
||||
static uint32_t probes, replies;
|
||||
static uint16_t sequence;
|
||||
static pthread_t worker;
|
||||
static bool worker_started, all_timeouts, burst;
|
||||
static int esp_ping_get_profile(esp_ping_handle_t h,int profile,void *out,size_t n) {
|
||||
assert(h==(void *)1);
|
||||
if (fail_profile) return ESP_FAIL;
|
||||
if (profile==ESP_PING_PROF_SEQNO) { assert(n==2); memcpy(out,&sequence,n); }
|
||||
else if(profile==ESP_PING_PROF_TTL) { assert(n==1); *(uint8_t *)out=64; }
|
||||
else { assert(n==4); uint32_t value=profile==ESP_PING_PROF_REQUEST?probes:
|
||||
profile==ESP_PING_PROF_REPLY?replies:1; memcpy(out,&value,n); }
|
||||
return ESP_OK;
|
||||
}
|
||||
static int esp_ping_delete_session(esp_ping_handle_t h) {
|
||||
assert(h==(void *)1); ++deletes; return fail_delete?ESP_FAIL:ESP_OK;
|
||||
}
|
||||
static void *produce(void *unused) {
|
||||
(void)unused;
|
||||
for(sequence=1;sequence<=probes;++sequence) {
|
||||
if(all_timeouts || sequence%2==0) callbacks.on_ping_timeout((void *)1,callbacks.cb_args);
|
||||
else callbacks.on_ping_success((void *)1,callbacks.cb_args);
|
||||
}
|
||||
callbacks.on_ping_end((void *)1,callbacks.cb_args); return NULL;
|
||||
}
|
||||
static int esp_ping_new_session(const esp_ping_config_t *c,const esp_ping_callbacks_t *cb,esp_ping_handle_t *h) {
|
||||
assert(queue.bytes==memory.bytes && c->count>=1 && c->count<=20);
|
||||
if(fail_new) return ESP_FAIL;
|
||||
callbacks=*cb; probes=c->count; replies=all_timeouts?0:(probes+1)/2; *h=(void *)1; return ESP_OK;
|
||||
}
|
||||
static int esp_ping_start(esp_ping_handle_t h) {
|
||||
assert(h==(void *)1); ++starts; if(fail_start) return ESP_FAIL;
|
||||
assert(!pthread_create(&worker,NULL,produce,NULL)); worker_started=true;
|
||||
if(burst) { assert(!pthread_join(worker,NULL)); worker_started=false; assert(queue.count==probes+1); }
|
||||
return ESP_OK;
|
||||
}
|
||||
static int execute_nslookup(int argc,char **argv) { (void)argc; (void)argv; return 0; }
|
||||
static int execute_traceroute(int argc,char **argv) { (void)argc; (void)argv; return 0; }
|
||||
typedef struct { const char *command,*help,*hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
|
||||
static int esp_console_cmd_register(const esp_console_cmd_t *c) {
|
||||
assert(c->func); ++registrations; return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* Actual canonical handlers and database projection; included by accounts.py. */
|
||||
static void psram_snapshot_tests(void)
|
||||
{
|
||||
reset(); remote=web=false;
|
||||
fail_snapshot_alloc=true;
|
||||
assert(user_console_register_commands()==ESP_OK && user_registrations==1);
|
||||
assert(!snapshot_allocations);
|
||||
assert(run("user status")==1);
|
||||
assert(run("user list")==1);
|
||||
assert(run("user show other")==1);
|
||||
assert(snapshot_allocations==3 && s_user_snapshot==NULL && !gate_held);
|
||||
assert(run("user add newcomer user")==0);
|
||||
assert(snapshot_allocations==3);
|
||||
s_initialized=false; s_mutex=NULL; storage_test=true;
|
||||
assert(run("user recover --force")==0);
|
||||
storage_test=false;
|
||||
assert(snapshot_allocations==3);
|
||||
reset(); remote=web=false;
|
||||
fail_snapshot_alloc=false;
|
||||
assert(run("user status")==0 && snapshot_allocations==4);
|
||||
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
|
||||
void *retained=s_user_snapshot;
|
||||
fail_snapshot_alloc=true;
|
||||
for (unsigned i=0;i<3;++i) {
|
||||
assert(run("user")==0);
|
||||
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
|
||||
assert(run("user list")==0);
|
||||
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
|
||||
assert(run("user show other")==0);
|
||||
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
|
||||
assert(run("user show missing")==1);
|
||||
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
|
||||
s_initialized=false;
|
||||
memset(s_user_snapshot,0xa5,sizeof(*s_user_snapshot));
|
||||
assert(run("user status")==1);
|
||||
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
|
||||
s_initialized=true;
|
||||
assert(s_user_snapshot==retained && snapshot_allocations==4 && !gate_held);
|
||||
assert(snapshot_memory.before==UINT64_C(0xaabbccdd11223344));
|
||||
assert(snapshot_memory.after==UINT64_C(0xaabbccdd11223344));
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ and inline asset-failure script, not a separately maintained implementation.
|
||||
|
||||
Historical 8D.17 run: **126 behavior groups**, plus production renderer/HTML/CSP checks. Seven new quick groups cover mouse/focus/touch activation, single DOM ownership, focused-editor hover stability, Escape/outside dismissal, late reads/expiry, terminal input/drain isolation, full-page draft/promotion preservation, explicit Apply/Save and pending non-replay, Network secret exclusion/strict schema/401. `Element` focus/containment/hover and events are doubles, not browser event-dispatch proof.
|
||||
|
||||
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 18 cases (four full views plus Serial/Wi-Fi quick panels at320/600/1200px, quick height360), including viewport bounds/scrolling and secret-control exclusion. The 8D.17 attempt was **blocked before measurement** by sandbox socket/Crash Reports restrictions; no geometry pass claimed. See [contextual-control contracts](../../docs/web_administration.md#broker-and-contextual-controls), [acceptance evidence and limits](../../docs/web_administration_acceptance.md), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
|
||||
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 36 cases at 320/600/900/1200px: full settings and quick panels plus dashboard card hit-testing, overflow/ellipsis, compact controls, status visibility, and full-page restoration. Quick panels use height 360px. The 2026-09-14 cosmetic update passed these Chromium fixtures and all 161 browser behavior groups; fixtures are not live-device validation. See [contextual-control contracts](../../docs/web_administration.md#broker-and-contextual-controls), [acceptance evidence and limits](../../docs/web_administration_acceptance.md), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
|
||||
|
||||
Coverage:
|
||||
|
||||
|
||||
@@ -115,6 +115,32 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
trigger.pointerenter({pointerType:'mouse'}); await tick(); assert.equal(host['data-quick'],'false');
|
||||
b.terminals[0].input('x'); assert.equal(ws.sent.length, 1); assert.ok(!ws.closed);
|
||||
});
|
||||
await test('Every quick card retains Serial/Admin title, toolbar and selected-view semantics until full promotion', async () => {
|
||||
for (const mode of ['serial', 'admin']) for (const domain of ['serial', 'network', 'broker', 'writer']) {
|
||||
const b = browser({role:'admin'}); b.start(); await tick();
|
||||
if (mode === 'admin') { b.click('select-admin'); await tick(); }
|
||||
const assertView = view => {
|
||||
assert.equal(b.nodes['terminal-title'].textContent, {serial:'Live serial stream', admin:'Administration shell', settings:'Settings'}[view], domain);
|
||||
for (const id of ['admin-toggle', 'admin-detail']) assert.equal(b.nodes[id].hidden, view !== 'admin', id);
|
||||
for (const choice of ['serial', 'admin', 'settings']) assert.equal(b.nodes['select-' + choice]['aria-pressed'], String(choice === view), choice);
|
||||
assert.equal(b.nodes.terminal.hidden, view !== 'serial');
|
||||
assert.equal(b.nodes['admin-terminal'].hidden, view !== 'admin');
|
||||
};
|
||||
b.click('quick-' + domain); await tick(); assertView(mode);
|
||||
assert.equal(b.nodes['serial-settings'].hidden, false);
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'true');
|
||||
b.click('quick-close'); await tick(); assertView(mode);
|
||||
b.click('quick-' + domain); await tick();
|
||||
const calls = b.calls.length;
|
||||
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
|
||||
assertView('settings');
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'false');
|
||||
assert.equal(b.nodes['quick-header'].hidden, true);
|
||||
assert.equal(b.nodes['quick-' + domain]['aria-expanded'], 'false');
|
||||
assert.equal(b.document.activeElement, b.nodes['settings-' + (domain === 'writer' ? 'broker' : domain)]);
|
||||
assert.equal(b.calls.length, calls, 'promotion must not reload the shared controller');
|
||||
}
|
||||
});
|
||||
await test('Quick adjacent trigger keyboard focus survives switching without focusing either terminal', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick();
|
||||
b.nodes['quick-network'].focus(); await tick();
|
||||
|
||||
@@ -52,6 +52,19 @@ def check_layout(html):
|
||||
assert trigger['attrs']['aria-expanded'] == 'false'
|
||||
assert trigger['attrs']['aria-haspopup'] == 'dialog'
|
||||
assert trigger['attrs']['aria-label'] and 'disabled' in trigger['attrs']
|
||||
assert {'status-item', 'quick-trigger'} <= set(classes(trigger)), domain
|
||||
assert 'status-grid' in classes(trigger['parent']), 'the button itself must be the grid card'
|
||||
assert [classes(n) for n in trigger['children']] == [['label'], ['value']], domain
|
||||
assert all(n['tag'] == 'span' for n in trigger['children'])
|
||||
value_id = {'serial': 'serial-summary', 'network': 'wifi-summary', 'broker': 'broker-clients', 'writer': 'writer-id'}[domain]
|
||||
assert ids[value_id]['parent'] is trigger
|
||||
assert ('wide' in classes(trigger)) == (domain in ('serial', 'network'))
|
||||
assert not any(n['tag'] in ('button', 'a', 'input', 'select') for n in descendants(trigger))
|
||||
options = ids['network-target']['children']
|
||||
assert [n['attrs']['value'] for n in options] == ['ap', '0', '1', '2', '3']
|
||||
for i, option in enumerate(options[1:]):
|
||||
assert ids['network-profile-' + str(i)] is option
|
||||
assert 'hidden' not in option['attrs'] and 'disabled' not in option['attrs']
|
||||
assert ids['quick-full']['tag'] == 'a' and ids['quick-full']['attrs']['href'] == '#serial-settings'
|
||||
assert 'hidden' in ids['quick-header']['attrs']
|
||||
assert ids['network-password']['parent'] is ids['network-password-label']
|
||||
@@ -87,6 +100,19 @@ def check_layout(html):
|
||||
assert html.index('id="account-delete"') < html.index('id="account-result"') < html.index('id="account-key-add"') < html.index('id="account-submit-password"')
|
||||
css = next(n['text'] for n in descendants(doc.root) if n['tag'] == 'style')
|
||||
for rule in (
|
||||
'.quick-trigger{display:block;width:100%;color:inherit;text-align:left;font:inherit;cursor:pointer;overflow:hidden}',
|
||||
'.status-grid{min-width:0;padding:16px;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}',
|
||||
'.status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}',
|
||||
'.status-item{min-width:0;',
|
||||
'.value{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
|
||||
"[data-quick='true'] #quick-header>h2{display:block}",
|
||||
"[data-quick='true'] #settings-values>:nth-child(n+3){display:none}",
|
||||
"[data-quick='true'] #network-edit>*{display:none}",
|
||||
|
||||
"[data-quick='true'] #network-edit>.settings-edit>label{display:none}",
|
||||
"[data-quick='true'] #network-edit>.settings-edit>#network-target-label,[data-quick='true'] #network-edit>.settings-edit>#network-policy-label{display:grid}",
|
||||
"[data-quick='true'] #network-edit>.settings-edit>#network-enabled-label{display:flex}",
|
||||
"[data-quick='true'] #network-wifi-load{display:none}",
|
||||
'.settings-values dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}',
|
||||
'.settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}',
|
||||
'.serial-edit,.settings-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}',
|
||||
@@ -100,8 +126,18 @@ def check_layout(html):
|
||||
'.settings-edit{grid-template-columns:minmax(0,1fr)}',
|
||||
):
|
||||
assert rule in css, rule
|
||||
# These selectors must share a hide rule, not merely occur in explanatory text.
|
||||
import re
|
||||
hide_selectors = {selector.strip() for selectors in re.findall(r'([^{}]+)\{display:none\}', css) for selector in selectors.split(',')}
|
||||
for selector in ('#settings-navigation', '#quick-help', 'h3', '>div>h2', 'p:not([role=status])', '#network-summary', '#broker-values'):
|
||||
scoped = "[data-quick='true']" + ('' if selector.startswith('>') else ' ') + selector
|
||||
assert scoped in hide_selectors, scoped
|
||||
for ident in ('settings-detail', 'serial-operation-detail', 'network-detail', 'network-operation-detail', 'broker-detail', 'broker-operation-detail'):
|
||||
assert ids[ident]['attrs']['role'] == 'status', ident
|
||||
assert [n['tag'] for n in ids['settings-values']['children'][:2]] == ['dt', 'dd']
|
||||
assert ids['settings-values']['children'][0]['text'] == 'Service'
|
||||
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all seven settings views')
|
||||
print('PASS HTML layout: full-card triggers, compact quick CSS, profile slots, status roles and shared settings structure/styles')
|
||||
|
||||
|
||||
def check_browser_layout(html, tmp, executable):
|
||||
@@ -114,15 +150,45 @@ def check_browser_layout(html, tmp, executable):
|
||||
fixture = re.sub(r'<link\b[^>]*>|<img\b[^>]*>', '', fixture)
|
||||
probe = r'''
|
||||
const cases = [];
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) for (const quick of [false, true]) {
|
||||
if (quick && !['serial-settings-content', 'network-settings'].includes(view)) continue;
|
||||
const widths = [320, 600, 900, 1200];
|
||||
const views = ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings'];
|
||||
for (const width of widths) {
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
|
||||
cases.push(new Promise(resolve => {
|
||||
frame.onload = () => {
|
||||
const d = frame.contentDocument, win = frame.contentWindow, errors = [];
|
||||
for (const id of ['quick-serial', 'quick-network', 'quick-broker', 'quick-writer']) {
|
||||
const card = d.getElementById(id), value = card.querySelector('.value');
|
||||
card.disabled = false; value.textContent = 'long-unbroken-status-'.repeat(20);
|
||||
}
|
||||
const grid = d.querySelector('.status-grid'), gridRect = grid.getBoundingClientRect();
|
||||
if (grid.scrollWidth > grid.clientWidth + 1 || gridRect.left < 0 || gridRect.right > width + 1) errors.push('status grid overflow');
|
||||
for (const id of ['quick-serial', 'quick-network', 'quick-broker', 'quick-writer']) {
|
||||
const card = d.getElementById(id), value = card.querySelector('.value');
|
||||
const r = card.getBoundingClientRect(), v = value.getBoundingClientRect(), style = win.getComputedStyle(value);
|
||||
if (r.width <= 0 || r.left < gridRect.left || r.right > gridRect.right + 1 || card.scrollWidth > card.clientWidth + 1) errors.push('card overflow:' + id);
|
||||
if (v.left < r.left || v.right > r.right || value.scrollWidth <= value.clientWidth) errors.push('missing constrained long value:' + id);
|
||||
if (style.textOverflow !== 'ellipsis' || style.whiteSpace !== 'nowrap' || style.overflowX !== 'hidden') errors.push('missing ellipsis:' + id);
|
||||
// Probe card padding/corners as well as label/value: a nested text-only button must fail.
|
||||
for (const [x, y] of [[r.left + 6, r.top + 6], [r.right - 6, r.bottom - 6], [r.left + r.width / 2, r.top + r.height / 2], [v.left + 1, v.top + 1]]) {
|
||||
const hit = d.elementFromPoint(x, y);
|
||||
if (hit?.closest('button') !== card) errors.push('card hit target:' + id + ' at ' + x + ',' + y + ' hit ' + (hit?.id || hit?.tagName || 'nothing'));
|
||||
}
|
||||
}
|
||||
resolve({width, view:'status-cards', errors});
|
||||
};
|
||||
}));
|
||||
frame.srcdoc = FIXTURE; document.body.append(frame);
|
||||
}
|
||||
for (const width of widths) for (const view of views) for (const quick of [false, true]) {
|
||||
if (quick && !['serial-settings-content', 'network-settings', 'broker-settings'].includes(view)) continue;
|
||||
const height = quick ? 360 : 900;
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = height + 'px';
|
||||
cases.push(new Promise(resolve => {
|
||||
frame.onload = () => {
|
||||
const d = frame.contentDocument, win = frame.contentWindow;
|
||||
d.getElementById('serial-settings').hidden = false;
|
||||
for (const id of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) d.getElementById(id).hidden = id !== view;
|
||||
for (const id of views) d.getElementById(id).hidden = id !== view;
|
||||
const section = d.getElementById(view);
|
||||
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
|
||||
section.querySelectorAll('dl').forEach(dl => {
|
||||
@@ -142,9 +208,27 @@ def check_browser_layout(html, tmp, executable):
|
||||
if (rect.left < 0 || rect.top < 0 || rect.right > width || rect.bottom > height) errors.push('popover viewport overflow');
|
||||
if (host.scrollWidth > host.clientWidth + 1) errors.push('popover horizontal overflow');
|
||||
if (win.getComputedStyle(host).overflowY !== 'auto') errors.push('popover not scrollable');
|
||||
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status']) {
|
||||
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status', 'network-summary', 'broker-values']) {
|
||||
if (d.getElementById(id).getBoundingClientRect().width) errors.push('quick hidden content:' + id);
|
||||
}
|
||||
const visible = node => !!node.getBoundingClientRect().width;
|
||||
for (const node of section.querySelectorAll('p:not([role=status]),h2,h3')) if (visible(node)) errors.push('quick explanatory content:' + node.tagName);
|
||||
if (!visible(d.getElementById('quick-title'))) errors.push('missing quick title');
|
||||
const statuses = { 'serial-settings-content':['settings-detail','serial-operation-detail'], 'network-settings':['network-detail','network-operation-detail'], 'broker-settings':['broker-detail','broker-operation-detail'] };
|
||||
for (const id of statuses[view]) if (!visible(d.getElementById(id))) errors.push('hidden result/status:' + id);
|
||||
if (view === 'serial-settings-content') {
|
||||
const rows = [...d.getElementById('settings-values').children];
|
||||
if (!rows.every((node, i) => visible(node) === (i < 2))) errors.push('quick serial must show only service pair');
|
||||
}
|
||||
if (view === 'network-settings') {
|
||||
const allowed = ['network-target','network-policy','network-enabled','network-apply','network-wifi-save','network-result','network-refresh'];
|
||||
const controls = [...section.querySelectorAll('input,select,textarea,button')];
|
||||
for (const node of controls) if (visible(node) !== allowed.includes(node.id)) errors.push('quick control visibility:' + node.id);
|
||||
// CSS-only promotion restores the full editor; application draft/option state is checked in Node.
|
||||
host.dataset.quick = 'false';
|
||||
for (const node of controls) if (!visible(node)) errors.push('full control not restored:' + node.id);
|
||||
host.dataset.quick = 'true';
|
||||
}
|
||||
}
|
||||
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
@@ -190,6 +274,7 @@ def check_browser_layout(html, tmp, executable):
|
||||
assert result.returncode == 0, result.stderr
|
||||
parsed = Document(result.stdout)
|
||||
results = json.loads(parsed.ids['layout-results']['text'])
|
||||
assert len(results) == 18
|
||||
assert all(not case['errors'] for case in results), results
|
||||
print('PASS Chromium layout: four full views plus Serial/Wi-Fi popovers at 320/600/1200px (quick height360); viewport/overflow/secret exclusion and full-page whitespace checks (fixtures, not live app)')
|
||||
assert len(results) == 36
|
||||
failures = [case for case in results if case['errors']]
|
||||
assert not failures, failures
|
||||
print('PASS Chromium layout: full-card hit targets/ellipsis, five full views and Serial/Wi-Fi/Broker popovers at 320/600/900/1200px (quick height360); compact controls/status, promotion restoration and whitespace checks (fixtures, not live app)')
|
||||
@@ -35,16 +35,59 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
assert.equal(reads(b).length,1); assert.equal(posts(b).length,0); clean(b);
|
||||
assert.ok(n(b,'password-mode').disabled);
|
||||
target(b,'0'); assert.equal(posts(b).length,0); clean(b);
|
||||
input(b,'ssid','new network');
|
||||
n(b,'enabled').checked = false; n(b,'enabled').change();
|
||||
b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
|
||||
assert.equal(posts(b).length,1);
|
||||
const body = JSON.parse(posts(b)[0].body); assert.equal(body.action,'profile-patch');
|
||||
assert.ok(!Object.hasOwn(body,'password')); clean(b);
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'profile-patch', generation:7, profile:0, enabled:false});
|
||||
clean(b);
|
||||
b.click('quick-close'); await tick(); assert.equal(n(b,'ssid').value,''); clean(b);
|
||||
b.queues[path].push(json(fixture())); b.click('quick-network'); await tick();
|
||||
assert.equal(posts(b).length,1); assert.ok(n(b,'apply').disabled);
|
||||
assert.match(n(b,'operation-detail').textContent,/pending|unknown|uncertain/);
|
||||
});
|
||||
await test('Quick Wi-Fi names configured profile slots, hides only empty slots and restores all slots on promotion', async () => {
|
||||
const value = fixture();
|
||||
value.wifi.profiles[2].ssid = '<b>office backup</b>'; // Configured but disabled remains selectable; label is safe text.
|
||||
value.wifi.profiles[2].password_configured = true;
|
||||
const b = await adminBrowser(); b.queues[path].push(json(value)); b.click('quick-network'); await tick();
|
||||
const slots = quick => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const option = n(b, 'profile-' + i), empty = !value.wifi.profiles[i].ssid;
|
||||
assert.ok(option, 'missing profile option ' + i);
|
||||
assert.equal(option.hidden, quick && empty, 'profile visibility ' + i);
|
||||
assert.equal(option.disabled, quick && empty, 'profile selectability ' + i);
|
||||
if (!empty) assert.ok(option.textContent.includes(value.wifi.profiles[i].ssid), 'SSID label ' + i);
|
||||
assert.equal(option.children.length, 0, 'SSID must not become markup');
|
||||
}
|
||||
};
|
||||
slots(true); assert.equal(n(b,'target').value, 'ap');
|
||||
target(b,'2'); n(b,'enabled').checked = true; n(b,'enabled').change();
|
||||
const selectedOption = n(b,'profile-2'), count = b.calls.length;
|
||||
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
|
||||
slots(false); assert.equal(n(b,'profile-2'), selectedOption);
|
||||
assert.equal(n(b,'target').value, '2'); assert.equal(n(b,'enabled').checked, true);
|
||||
assert.equal(n(b,'ssid').value, value.wifi.profiles[2].ssid);
|
||||
assert.equal(b.nodes['terminal-title'].textContent, 'Settings');
|
||||
assert.equal(b.calls.length, count, 'promotion must preserve the draft without fetching');
|
||||
target(b,'3'); assert.equal(n(b,'ssid').value, '', 'full page can configure an empty slot');
|
||||
b.click('select-admin'); b.queues[path].push(json(value)); b.click('quick-network'); await tick(); slots(true);
|
||||
value.wifi.profiles[2].ssid = ''; value.wifi.profiles[2].password_configured = false;
|
||||
value.wifi.profiles[3].ssid = 'replacement'; value.wifi.profiles[3].password_configured = true;
|
||||
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); slots(true);
|
||||
assert.equal(posts(b).length, 0, 'opening, selecting, promotion and refresh never apply'); clean(b);
|
||||
});
|
||||
await test('Quick Wi-Fi AP policy applies only its typed patch; Save persists working values, not the draft', async () => {
|
||||
for (const action of ['wifi-patch', 'wifi-save']) {
|
||||
const b = await adminBrowser(); b.queues[path].push(json(fixture())); b.click('quick-network'); await tick();
|
||||
assert.equal(n(b,'target').value, 'ap');
|
||||
input(b,'policy','always','change');
|
||||
assert.equal(posts(b).length, 0);
|
||||
b.queues[operation].push(ack(action)); b.click(action === 'wifi-patch' ? 'network-apply' : 'network-wifi-save'); await tick();
|
||||
assert.equal(posts(b).length, 1);
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), action === 'wifi-patch' ? {action, generation:7, ap_policy:'always'} : {action, generation:7});
|
||||
clean(b);
|
||||
}
|
||||
});
|
||||
await test('Quick Network rejects extra secret fields, fences401 and keeps full-page secret draft out of hover', async () => {
|
||||
const b = await open(); secret(b); const count = b.calls.length;
|
||||
b.nodes['quick-serial'].pointerenter({pointerType:'mouse'}); await tick();
|
||||
|
||||
Reference in New Issue
Block a user