Unify Settings Layouts and Add Coverage

This commit is contained in:
2026-09-08 21:22:52 +02:00
parent 989821b7c4
commit 4a4d615c59
7 changed files with 295 additions and 43 deletions
+2
View File
@@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do
## Development state ## Development state
- **Settings presentation unified (2026-09-08), user-requested visual refinement:** Accounts/Network now use Serial-style 600px label/value definition lists, shared form styling, compact muted help, consistent Refresh/result labels and action grouping. Public-key textarea/generated-password fields styled; checkboxes intrinsic-width. Safe DOM text replaces preformatted summaries; readable ASCII SSID/hex fallback retained, pre-wrap preserves significant spaces (review finding fixed with rendered-width regression). IDs/events/auth/mutations/secret cleanup/terminal ownership unchanged; no backend/assets changes. Parent UI100 behavior groups plus HTML/renderer/CSP and headless Chromium geometry/whitespace checks at320/600/1200px PASS; pio run PASS23.69s, 99,548 B RAM / 1,744,325 B flash (+0/+1,744 vs preceding ASCII-summary build). Diff check PASS. Fixtures/browser layout checks are not target visual sign-off or live Wi-Fi validation. No upload/erase/commit.
- **8D.12/8D.13 implemented together by user authorization (2026-09-08); host-tested/build-verified, target pending:** Backend and admin Network UI deliver 8D.12 nonsecret STA/AP/profile/mDNS edits and explicit persistence, then 8D.13 secret replacement/disabled-STA clear and connection controls. Exact API, byte SSID/UTF-8+hex UI, owner/generation/persistence/uncertainty contracts: `docs/phase8d12_13_implementation.md`. One 768-byte request, 2,048-byte snapshot, 128-byte result; one login-bound slot and one-second ESP timer, 30-second non-executing expiry plus scheduling latency, not hard cancellation. Existing dispatcher IDs/manager owner; accepted != online. Wi-Fi Load stored-only, no reset/default-secret/export; mDNS separate generation/Set/Save/Load/Defaults/reannouncement. 27 handlers/six sockets, no stack/task/queue/schema growth; staged optional route failures preserve unrelated routes. Reported backend/cookie Network PASS; backend P3 queue-drop-counter fix complete; UI agent97+renderer/CSP/review PASS; lifecycle agent21 PASS. Parent final reruns PASS Network five production-path groups, cookie Network five+shared/accounts/serial-settings/admin, console boundary/canonical accounts, lifecycle21, UI97+CSP, idle18+guards, transport25/tickets12, store--serial, diagnostics12+guard and diff check. Independent reviews no remaining actionable findings. Parent pio run PASS24.99s, 99,548 B RAM / 1,742,437 B flash (+288/+36,656 vs accepted legacy cleanup). Earlier pre-final-UI integration build emitted nonfatal FATFS_PRINT_FLOAT config warning; no unrelated config edits. Host owner paths use radio/scheduler/storage doubles, not real network validation. Timer heap, memory floors, HTTPD/dispatcher margins, live Wi-Fi/mDNS/DNS/trust and target checklist remain pending. Profile editor is not explicit-index connection selection: only canonical Next profile. UART0/USB recovery, danger confirmations, no same-response delivery guarantee and unchanged browser-shell restrictions documented. No 8D.14, full M3, target acceptance or reserve approval. Documentation agent touched only authorized docs, no source/tests/assets/build/device/commands; also corrected stale SSH-to-HTTPS startup dependency against `main.c` from accepted legacy cleanup. Older wait-for-8D.12 statements below are superseded, not earlier scoped sign-offs. - **8D.12/8D.13 implemented together by user authorization (2026-09-08); host-tested/build-verified, target pending:** Backend and admin Network UI deliver 8D.12 nonsecret STA/AP/profile/mDNS edits and explicit persistence, then 8D.13 secret replacement/disabled-STA clear and connection controls. Exact API, byte SSID/UTF-8+hex UI, owner/generation/persistence/uncertainty contracts: `docs/phase8d12_13_implementation.md`. One 768-byte request, 2,048-byte snapshot, 128-byte result; one login-bound slot and one-second ESP timer, 30-second non-executing expiry plus scheduling latency, not hard cancellation. Existing dispatcher IDs/manager owner; accepted != online. Wi-Fi Load stored-only, no reset/default-secret/export; mDNS separate generation/Set/Save/Load/Defaults/reannouncement. 27 handlers/six sockets, no stack/task/queue/schema growth; staged optional route failures preserve unrelated routes. Reported backend/cookie Network PASS; backend P3 queue-drop-counter fix complete; UI agent97+renderer/CSP/review PASS; lifecycle agent21 PASS. Parent final reruns PASS Network five production-path groups, cookie Network five+shared/accounts/serial-settings/admin, console boundary/canonical accounts, lifecycle21, UI97+CSP, idle18+guards, transport25/tickets12, store--serial, diagnostics12+guard and diff check. Independent reviews no remaining actionable findings. Parent pio run PASS24.99s, 99,548 B RAM / 1,742,437 B flash (+288/+36,656 vs accepted legacy cleanup). Earlier pre-final-UI integration build emitted nonfatal FATFS_PRINT_FLOAT config warning; no unrelated config edits. Host owner paths use radio/scheduler/storage doubles, not real network validation. Timer heap, memory floors, HTTPD/dispatcher margins, live Wi-Fi/mDNS/DNS/trust and target checklist remain pending. Profile editor is not explicit-index connection selection: only canonical Next profile. UART0/USB recovery, danger confirmations, no same-response delivery guarantee and unchanged browser-shell restrictions documented. No 8D.14, full M3, target acceptance or reserve approval. Documentation agent touched only authorized docs, no source/tests/assets/build/device/commands; also corrected stale SSH-to-HTTPS startup dependency against `main.c` from accepted legacy cleanup. Older wait-for-8D.12 statements below are superseded, not earlier scoped sign-offs.
- **Legacy-credential cleanup signed off (2026-09-08):** User explicitly accepts cleanup, confirms unchanged HTTPS certificate fingerprint and continued use of preexisting test users, supplementing the clean full-mix telemetry below. Supersedes prior target-pending/fingerprint-unconfirmed status for this cleanup; idle-cleanup scoped acceptance stands. User suspects two boot auth failures were stale pre-flash sessions in two browser tabs; plausible, not traced/confirmed. Exact acceptance and limits: `docs/legacy_credential_removal.md`. No unreported blank provisioning/recovery/fault-injection/soak/all-key checks, numeric reserve approval or broader phase/M3 acceptance inferred. Documentation only; no source/config/build/test/device/assets/commit action. - **Legacy-credential cleanup signed off (2026-09-08):** User explicitly accepts cleanup, confirms unchanged HTTPS certificate fingerprint and continued use of preexisting test users, supplementing the clean full-mix telemetry below. Supersedes prior target-pending/fingerprint-unconfirmed status for this cleanup; idle-cleanup scoped acceptance stands. User suspects two boot auth failures were stale pre-flash sessions in two browser tabs; plausible, not traced/confirmed. Exact acceptance and limits: `docs/legacy_credential_removal.md`. No unreported blank provisioning/recovery/fault-injection/soak/all-key checks, numeric reserve approval or broader phase/M3 acceptance inferred. Documentation only; no source/config/build/test/device/assets/commit action.
+6
View File
@@ -6,6 +6,12 @@ The user authorized both phases together. Backend and admin-only Settings/Networ
Source authority: `src/web_network_settings.{c,h}`, `wifi_manager.{c,h}`, `wifi_config.{c,h}`, `mdns_service.{c,h}`, `mdns_config.{c,h}`, `admin_ssh_console.{c,h}`, integration in `web_server.c`/`src/CMakeLists.txt`, and authored `web_ui.c`. Contract/test details: `tests/web_network_settings/README.md`, `tests/web_ui_session/network.cjs`, cookie Network tests and server lifecycle tests. This documentation handoff changes no source, tests, generated assets or commands. Browser-shell Wi-Fi/mDNS restrictions are unchanged; typed routes do not grant general command execution. Source authority: `src/web_network_settings.{c,h}`, `wifi_manager.{c,h}`, `wifi_config.{c,h}`, `mdns_service.{c,h}`, `mdns_config.{c,h}`, `admin_ssh_console.{c,h}`, integration in `web_server.c`/`src/CMakeLists.txt`, and authored `web_ui.c`. Contract/test details: `tests/web_network_settings/README.md`, `tests/web_ui_session/network.cjs`, cookie Network tests and server lifecycle tests. This documentation handoff changes no source, tests, generated assets or commands. Browser-shell Wi-Fi/mDNS restrictions are unchanged; typed routes do not grant general command execution.
## Presentation refinement — 2026-09-08
On user request, Accounts and Network adopt Serial's compact label/value grids, form styles, help typography and action spacing. Account/key/Network summaries are semantic definition lists populated with DOM text; all information remains available. Printable ASCII SSIDs remain quoted, other bytes use hex, and value cells preserve significant spaces while wrapping. Key textarea, generated-password field and checkboxes share form styling. IDs, request/operation behavior, warnings, secret clearing and terminal/lease ownership are unchanged.
Parent validation PASS: UI **100 behavior groups**, HTML structure/renderer/header/CSP, and headless Chromium geometry/whitespace regressions at **320/600/1200px**. Review's SSID space-collapse issue was corrected and regression-tested. Final `pio run` **23.69s**, **99,548 B RAM / 1,744,325 B flash**, **+0/+1,744** versus the preceding readable-ASCII summary build (1,742,581 B flash); diff check PASS. These are fixture-based browser checks, not on-device visual acceptance. No backend/generated-asset/upload/erase/commit action.
## Routes, authorization and isolation ## Routes, authorization and isolation
| Method | Path | Purpose | | Method | Path | Purpose |
+53 -35
View File
@@ -82,10 +82,15 @@ static const char s_index_html[] =
".terminal-host .xterm-viewport{border-radius:7px}\n" ".terminal-host .xterm-viewport{border-radius:7px}\n"
".settings-page{overflow:auto;padding:8px;min-height:0}.settings-page h2{margin:0 0 8px;font-size:18px}" ".settings-page{overflow:auto;padding:8px;min-height:0}.settings-page h2{margin:0 0 8px;font-size:18px}"
".settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}" ".settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}"
".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;overflow-wrap:anywhere}\n" ".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}\n"
".serial-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}" ".serial-edit,.settings-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}"
".serial-edit label{display:grid;gap:4px;color:var(--muted)}.serial-edit input,.serial-edit select{font:inherit;width:100%;min-width:0;padding:8px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}" ".serial-edit label,.settings-edit label{display:grid;gap:4px;color:var(--muted);min-width:0}.serial-edit input,.serial-edit select,.settings-edit input,.settings-edit select,.settings-edit textarea{font:inherit;width:100%;min-width:0;padding:8px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}"
".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}#network-summary{white-space:pre-wrap;overflow-wrap:anywhere}\n" ".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0;max-width:600px}\n"
".settings-page h3{font-size:1.17em;margin:1em 0}.settings-page .connection-detail{max-width:600px;overflow-wrap:anywhere}"
".settings-edit .settings-wide{grid-column:1/-1}.settings-edit textarea{min-height:96px;resize:vertical}"
".settings-edit label.settings-check{display:flex;align-items:center;gap:8px}.settings-edit input[type=checkbox]{width:auto;flex:none}"
".settings-edit input[readonly]{font-family:monospace}.settings-values dt{overflow-wrap:anywhere}"
"@media(max-width:360px){.settings-values{grid-template-columns:minmax(0,1fr) minmax(0,2fr)}.settings-edit{grid-template-columns:minmax(0,1fr)}}\n"
".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n" ".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n"
"@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}" "@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}"
".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}" ".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
@@ -182,22 +187,22 @@ static const char s_index_html[] =
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>" "<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button></div>" "<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button></div>"
"<div id=\"network-settings\" hidden><h2>Network</h2>" "<div id=\"network-settings\" hidden><h2>Network</h2>"
"<p>Edits apply to RAM only. Save persists the device working configuration, NOT browser drafts. Refresh discards drafts. " "<p class=\"connection-detail\">Edits apply to RAM only. Save persists the device working configuration, NOT browser drafts. Refresh discards drafts. "
"Wi-Fi Load uses stored configuration only; missing or invalid storage leaves RAM unchanged. No Wi-Fi defaults/reset. " "Wi-Fi Load uses stored configuration only; missing or invalid storage leaves RAM unchanged. No Wi-Fi defaults/reset. "
"Start/Stop also change RAM enabled-at-boot; Save persists that policy. Reconnect/Next do nothing while stopped. " "Start/Stop also change RAM enabled-at-boot; Save persists that policy. Reconnect/Next do nothing while stopped. "
"Next selects the next enabled profile in priority order, wrapping.</p>" "Next selects the next enabled profile in priority order, wrapping.</p>"
"<p>Network changes may disconnect HTTPS, SSH and both browser terminals before acknowledgement. Accepted is NOT connected. " "<p class=\"connection-detail\">Network changes may disconnect HTTPS, SSH and both browser terminals before acknowledgement. Accepted is NOT connected. "
"Recover through STA/AP, UART0 or network-independent native USB serial. Navigation itself preserves terminals and writer lease.</p>" "Recover through STA/AP, UART0 or network-independent native USB serial. Navigation itself preserves terminals and writer lease.</p>"
"<button id=\"network-refresh\" class=\"button\" type=\"button\">Refresh network</button>" "<button id=\"network-refresh\" class=\"button\" type=\"button\">Refresh</button>"
"<p id=\"network-detail\" role=\"status\"></p><pre id=\"network-summary\"></pre>" "<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=\"serial-edit\">" "<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>" "<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=\"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>" "<option value=\"2\">STA profile 2</option><option value=\"3\">STA profile 3</option></select></label>"
"<label id=\"network-boot-label\">Enabled at boot<input id=\"network-boot\" type=\"checkbox\"></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-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>" "<label id=\"network-channel-label\">AP channel<input id=\"network-channel\" type=\"number\" min=\"1\" max=\"11\" step=\"1\"></label>"
"<label id=\"network-enabled-label\">Profile enabled<input id=\"network-enabled\" type=\"checkbox\"></label>" "<label id=\"network-enabled-label\" class=\"settings-check\">Profile enabled<input id=\"network-enabled\" type=\"checkbox\"></label>"
"<label id=\"network-priority-label\">Priority<input id=\"network-priority\" type=\"number\" min=\"0\" max=\"255\" step=\"1\"></label>" "<label id=\"network-priority-label\">Priority<input id=\"network-priority\" type=\"number\" min=\"0\" max=\"255\" step=\"1\"></label>"
"<label id=\"network-security-label\">Security<select id=\"network-security\"><option value=\"mixed\">WPA2 or stronger (mixed)</option><option value=\"wpa3\">WPA3</option></select></label>" "<label id=\"network-security-label\">Security<select id=\"network-security\"><option value=\"mixed\">WPA2 or stronger (mixed)</option><option value=\"wpa3\">WPA3</option></select></label>"
"<label>SSID editor<select id=\"network-ssid-mode\"><option value=\"text\">Ordinary text (UTF-8)</option><option value=\"hex\">Exact bytes (hex)</option></select></label>" "<label>SSID editor<select id=\"network-ssid-mode\"><option value=\"text\">Ordinary text (UTF-8)</option><option value=\"hex\">Exact bytes (hex)</option></select></label>"
@@ -205,27 +210,27 @@ static const char s_index_html[] =
"<label>Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>" "<label>Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>"
"<option value=\"replace\">Replace</option><option id=\"network-password-clear\" value=\"clear\">Clear (disabled STA only)</option></select></label>" "<option value=\"replace\">Replace</option><option id=\"network-password-clear\" value=\"clear\">Clear (disabled STA only)</option></select></label>"
"<label>New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>" "<label>New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>"
"<p id=\"network-password-status\"></p><p id=\"network-ssid-detail\" role=\"status\"></p>" "<p id=\"network-password-status\" class=\"connection-detail\"></p><p id=\"network-ssid-detail\" class=\"connection-detail\" role=\"status\"></p>"
"<p>SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. " "<p class=\"connection-detail\">SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. "
"Switching modes is lossless or refused. Password replacement requires 863 printable ASCII characters; blank never clears. " "Switching modes is lossless or refused. Password replacement requires 863 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. " "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>" "NVS is unencrypted; replacement/clear is not secure flash erasure. JavaScript cannot securely zero strings.</p>"
"<button id=\"network-apply\" class=\"button\" type=\"button\">Apply selected Wi-Fi target to RAM</button>" "<div class=\"serial-actions\"><button id=\"network-apply\" class=\"button\" type=\"button\">Apply selected Wi-Fi target to RAM</button>"
"<div class=\"serial-actions\"><button id=\"network-wifi-save\" class=\"button\" type=\"button\">Save working Wi-Fi to NVS</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>" "<button id=\"network-wifi-load\" class=\"button\" type=\"button\">Load stored Wi-Fi</button>"
"<button id=\"network-start\" class=\"button\" type=\"button\">Start</button><button id=\"network-stop\" class=\"button\" type=\"button\">Stop</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>"
"<button id=\"network-reconnect\" class=\"button\" type=\"button\">Reconnect</button><button id=\"network-next-profile\" class=\"button\" type=\"button\">Next profile</button></div>" "<button id=\"network-reconnect\" class=\"button\" type=\"button\">Reconnect</button><button id=\"network-next-profile\" class=\"button\" type=\"button\">Next profile</button></div>"
"<h3>mDNS</h3><p>STA-only responder. Expected announcement is not client-verified DNS. " "<h3>mDNS</h3><p class=\"connection-detail\">STA-only responder. Expected announcement is not client-verified DNS. "
"Suffix edits, Load and Defaults change RAM and request live reannouncement; queue failure can leave RAM changed. " "Suffix edits, Load and Defaults change RAM and request live reannouncement; queue failure can leave RAM changed. "
"Offline edits are used on the next STA IP. mDNS Load may select deterministic MAC defaults. Save explicitly persists.</p>" "Offline edits are used on the next STA IP. mDNS Load may select deterministic MAC defaults. Save explicitly persists.</p>"
"<div class=\"serial-edit\"><label>Hostname suffix (sak-… .local)<input id=\"network-suffix\" maxlength=\"55\" autocomplete=\"off\" spellcheck=\"false\"></label></div>" "<div class=\"settings-edit\"><label>Hostname suffix (sak-… .local)<input id=\"network-suffix\" maxlength=\"55\" autocomplete=\"off\" spellcheck=\"false\"></label></div>"
"<p>155 lowercase ASCII letters, digits or hyphens; no leading/trailing hyphen.</p><div class=\"serial-actions\">" "<p class=\"connection-detail\">155 lowercase ASCII letters, digits or hyphens; no leading/trailing hyphen.</p><div class=\"serial-actions\">"
"<button id=\"network-mdns-set\" class=\"button\" type=\"button\">Apply mDNS suffix to RAM</button>" "<button id=\"network-mdns-set\" class=\"button\" type=\"button\">Apply mDNS suffix to RAM</button>"
"<button id=\"network-mdns-save\" class=\"button\" type=\"button\">Save working mDNS to NVS</button>" "<button id=\"network-mdns-save\" class=\"button\" type=\"button\">Save working mDNS to NVS</button>"
"<button id=\"network-mdns-load\" class=\"button\" type=\"button\">Load mDNS</button>" "<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-mdns-defaults\" class=\"button\" type=\"button\">mDNS defaults in RAM</button></div></div>"
"<button id=\"network-result\" class=\"button\" type=\"button\">Check network operation result</button>" "<button id=\"network-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
"<p id=\"network-operation-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\">After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.</p></div>"
"<div id=\"serial-settings-content\"><h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. " "<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>" "Navigation leaves both terminals connected and preserves the serial writer lease.</p>"
"<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>" "<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>"
@@ -264,9 +269,9 @@ static const char s_index_html[] =
"<div id=\"account-settings\" hidden><h2>Accounts</h2>" "<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. " "<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>" "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>"
"<button id=\"refresh-accounts\" class=\"button\" type=\"button\">Refresh accounts</button>" "<button id=\"refresh-accounts\" class=\"button\" type=\"button\">Refresh</button>"
"<p id=\"accounts-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read accounts.</p>" "<p id=\"accounts-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read accounts.</p>"
"<pre id=\"accounts-list\"></pre><div class=\"serial-edit\">" "<dl id=\"accounts-list\" class=\"settings-values\"></dl><h3>Account selection / Edit account</h3><div class=\"settings-edit\">"
"<label>Account<select id=\"account-target\">" "<label>Account<select id=\"account-target\">"
"<option id=\"account-option-0\" value=\"0\"></option><option id=\"account-option-1\" value=\"1\"></option>" "<option id=\"account-option-0\" value=\"0\"></option><option id=\"account-option-1\" value=\"1\"></option>"
"<option id=\"account-option-2\" value=\"2\"></option><option id=\"account-option-3\" value=\"3\"></option>" "<option id=\"account-option-2\" value=\"2\"></option><option id=\"account-option-3\" value=\"3\"></option>"
@@ -275,24 +280,24 @@ static const char s_index_html[] =
"<label>New role<select id=\"account-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label></div>" "<label>New role<select id=\"account-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label></div>"
"<div class=\"serial-actions\"><button id=\"account-change-role\" class=\"button\" type=\"button\">Change role</button>" "<div class=\"serial-actions\"><button id=\"account-change-role\" class=\"button\" type=\"button\">Change role</button>"
"<button id=\"account-delete\" class=\"button\" type=\"button\">Delete account</button>" "<button id=\"account-delete\" class=\"button\" type=\"button\">Delete account</button>"
"<button id=\"account-result\" class=\"button\" type=\"button\">Check Result</button></div>" "<button id=\"account-result\" class=\"button\" type=\"button\">Check Operation Result</button></div>"
"<h3>Authorized SSH public keys</h3><p>Paste one OpenSSH Ed25519 or ECDSA P-256 public key (maximum 384 UTF-8 bytes). Public keys only; no private-key upload, export or SSH host management. Up to three keys per account.</p>" "<h3>Authorized SSH public keys</h3><p class=\"connection-detail\">Paste one OpenSSH Ed25519 or ECDSA P-256 public key (maximum 384 UTF-8 bytes). Public keys only; no private-key upload, export or SSH host management. Up to three keys per account.</p>"
"<button id=\"account-keys-refresh\" class=\"button\" type=\"button\">Read selected account keys</button>" "<button id=\"account-keys-refresh\" class=\"button\" type=\"button\">Read selected account keys</button>"
"<p id=\"account-keys-detail\" role=\"status\"></p><pre id=\"account-keys-list\"></pre>" "<p id=\"account-keys-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"account-keys-list\" class=\"settings-values\"></dl><div class=\"settings-edit\">"
"<label>Key to delete<select id=\"account-key-index\"><option id=\"key-option-0\" value=\"0\"></option><option id=\"key-option-1\" value=\"1\"></option><option id=\"key-option-2\" value=\"2\"></option></select></label>" "<label>Key to delete<select id=\"account-key-index\"><option id=\"key-option-0\" value=\"0\"></option><option id=\"key-option-1\" value=\"1\"></option><option id=\"key-option-2\" value=\"2\"></option></select></label>"
"<label>OpenSSH public key<textarea id=\"account-public-key\" maxlength=\"384\" autocomplete=\"off\" spellcheck=\"false\"></textarea></label>" "<label class=\"settings-wide\">OpenSSH public key<textarea id=\"account-public-key\" maxlength=\"384\" autocomplete=\"off\" spellcheck=\"false\"></textarea></label></div>"
"<div class=\"serial-actions\"><button id=\"account-key-add\" class=\"button\" type=\"button\">Import public key</button>" "<div class=\"serial-actions\"><button id=\"account-key-add\" class=\"button\" type=\"button\">Import public key</button>"
"<button id=\"account-key-delete\" class=\"button\" type=\"button\">Delete selected key</button><button id=\"account-key-clear\" class=\"button\" type=\"button\">Clear all authorized keys</button></div>" "<button id=\"account-key-delete\" class=\"button\" type=\"button\">Delete selected key</button><button id=\"account-key-clear\" class=\"button\" type=\"button\">Clear all authorized keys</button></div>"
"<div class=\"serial-edit\"><label>Purpose<select id=\"account-purpose\"><option value=\"create\">Create account</option><option value=\"password\">Change selected account password</option></select></label>" "<h3>Create account / Change password</h3><div class=\"settings-edit\"><label>Purpose<select id=\"account-purpose\"><option value=\"create\">Create account</option><option value=\"password\">Change selected account password</option></select></label>"
"<label id=\"account-username-label\">New username<input id=\"account-username\" maxlength=\"16\" autocomplete=\"off\"></label>" "<label id=\"account-username-label\">New username<input id=\"account-username\" maxlength=\"16\" autocomplete=\"off\"></label>"
"<label id=\"account-create-role-label\">Initial role<select id=\"account-create-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>" "<label id=\"account-create-role-label\">Initial role<select id=\"account-create-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
"<label>Password<input id=\"account-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label>" "<label>Password<input id=\"account-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label>"
"<label>Confirm password<input id=\"account-password-confirm\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label></div>" "<label>Confirm password<input id=\"account-password-confirm\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label></div>"
"<p class=\"connection-detail\">12-64 printable ASCII characters; spaces are significant. Confirm every password. Save it before submitting, especially for your own account.</p>" "<p class=\"connection-detail\">12-64 printable ASCII characters; spaces are significant. Confirm every password. Save it before submitting, especially for your own account.</p>"
"<button id=\"account-generate\" class=\"button\" type=\"button\">Generate password (not applied)</button>" "<button id=\"account-generate\" class=\"button\" type=\"button\">Generate password (not applied)</button>"
"<div id=\"account-generated-panel\" hidden><label>Generated password — not applied yet<input id=\"account-generated\" readonly autocomplete=\"off\"></label>" "<div id=\"account-generated-panel\" class=\"settings-edit\" hidden><label class=\"settings-wide\">Generated password — not applied yet<input id=\"account-generated\" readonly autocomplete=\"off\"></label>"
"<p>One-time display, no retrieval. Copy and save now; this display and password fields expire after 60 seconds. Enter it in Confirm password.</p>" "<p class=\"connection-detail settings-wide\">One-time display, no retrieval. Copy and save now; this display and password fields expire after 60 seconds. Enter it in Confirm password.</p>"
"<label><input id=\"account-password-saved\" type=\"checkbox\">I saved this exact password for this operation and account.</label></div>" "<label class=\"settings-check settings-wide\"><input id=\"account-password-saved\" type=\"checkbox\">I saved this exact password for this operation and account.</label></div>"
"<p class=\"connection-detail\">Secrets are cleared on submission and when leaving or changing this form. JavaScript cannot securely zero strings or browser-managed copies.</p>" "<p class=\"connection-detail\">Secrets are cleared on submission and when leaving or changing this form. JavaScript cannot securely zero strings or browser-managed copies.</p>"
"<p id=\"account-secret-detail\" class=\"connection-detail\" role=\"status\"></p>" "<p id=\"account-secret-detail\" class=\"connection-detail\" role=\"status\"></p>"
"<button id=\"account-submit-password\" class=\"button\" type=\"button\">Submit create / password change</button>" "<button id=\"account-submit-password\" class=\"button\" type=\"button\">Submit create / password change</button>"
@@ -307,6 +312,13 @@ static const char s_app_js[] =
"(() => {\n" "(() => {\n"
"'use strict';\n" "'use strict';\n"
"const element = (id) => document.getElementById(id);\n" "const element = (id) => document.getElementById(id);\n"
"function settingsRows(target, rows) {\n"
" target.textContent = '';\n"
" for (const [label, value] of rows) {\n"
" const dt = document.createElement('dt'), dd = document.createElement('dd');\n"
" dt.textContent = label; dd.textContent = value; target.appendChild(dt); target.appendChild(dd);\n"
" }\n"
"}\n"
"const connectionStatus = element('connection-status');\n" "const connectionStatus = element('connection-status');\n"
"const roleStatus = element('role-status');\n" "const roleStatus = element('role-status');\n"
"const clientIdField = element('client-id');\n" "const clientIdField = element('client-id');\n"
@@ -491,7 +503,7 @@ static const char s_app_js[] =
" if (!p || Object.keys(p).length !== 4 || p.username !== t.username || p.user_id !== t.user_id || p.auth_generation !== t.auth_generation) throw new Error('Stale key identity');\n" " if (!p || Object.keys(p).length !== 4 || p.username !== t.username || p.user_id !== t.user_id || p.auth_generation !== t.auth_generation) throw new Error('Stale key identity');\n"
" if (!Array.isArray(p.keys) || p.keys.length > 3 || new Set(p.keys.map(k => k?.index)).size !== p.keys.length || !p.keys.every(k => k && Object.keys(k).length === 3 && Number.isInteger(k.index) && k.index >= 0 && k.index <= 2 && ['ssh-ed25519','ecdsa-sha2-nistp256'].includes(k.type) && typeof k.fingerprint === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(k.fingerprint))) throw new Error('Invalid keys');\n" " if (!Array.isArray(p.keys) || p.keys.length > 3 || new Set(p.keys.map(k => k?.index)).size !== p.keys.length || !p.keys.every(k => k && Object.keys(k).length === 3 && Number.isInteger(k.index) && k.index >= 0 && k.index <= 2 && ['ssh-ed25519','ecdsa-sha2-nistp256'].includes(k.type) && typeof k.fingerprint === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(k.fingerprint))) throw new Error('Invalid keys');\n"
" accountKeys = p.keys; keysIdentity = identity;\n" " accountKeys = p.keys; keysIdentity = identity;\n"
" element('account-keys-list').textContent = accountKeys.map(k => k.index + ': ' + k.type + ' ' + k.fingerprint).join('\\n');\n" " settingsRows(element('account-keys-list'), accountKeys.map(k => [k.index + ': ', k.type + ' ' + k.fingerprint]));\n"
" for (let i = 0; i < 3; ++i) { const o = element('key-option-' + i), key = accountKeys.find(k => k.index === i); o.value = String(i); o.textContent = key ? key.index + ': ' + key.fingerprint : ''; o.hidden = o.disabled = !key; }\n" " for (let i = 0; i < 3; ++i) { const o = element('key-option-' + i), key = accountKeys.find(k => k.index === i); o.value = String(i); o.textContent = key ? key.index + ': ' + key.fingerprint : ''; o.hidden = o.disabled = !key; }\n"
" element('account-key-index').value = accountKeys.length ? String(accountKeys[0].index) : ''; element('account-keys-detail').textContent = t.username + ': ' + accountKeys.length + ' authorized keys. List refreshed.';\n" " element('account-key-index').value = accountKeys.length ? String(accountKeys[0].index) : ''; element('account-keys-detail').textContent = t.username + ': ' + accountKeys.length + ' authorized keys. List refreshed.';\n"
" } catch (error) { if (current()) { clearAccountKeys(); element('account-keys-detail').textContent = error.status === 409 || error.message === 'Stale key identity' ? 'Account identity stale. Refresh accounts and select the target again; no automatic retry.' : 'Keys unavailable or invalid. Refresh accounts or explicitly read keys again; no automatic retry.'; accountButtons(); } }\n" " } catch (error) { if (current()) { clearAccountKeys(); element('account-keys-detail').textContent = error.status === 409 || error.message === 'Stale key identity' ? 'Account identity stale. Refresh accounts and select the target again; no automatic retry.' : 'Keys unavailable or invalid. Refresh accounts or explicitly read keys again; no automatic retry.'; accountButtons(); } }\n"
@@ -568,7 +580,7 @@ static const char s_app_js[] =
" !payload.users.every(u => u && Object.keys(u).length === 4 && typeof u.username === 'string' && /^[a-z][a-z0-9_-]{0,15}$/.test(u.username) && validId(u.user_id) && validId(u.auth_generation) && ['user','admin'].includes(u.role)) ||\n" " !payload.users.every(u => u && Object.keys(u).length === 4 && typeof u.username === 'string' && /^[a-z][a-z0-9_-]{0,15}$/.test(u.username) && validId(u.user_id) && validId(u.auth_generation) && ['user','admin'].includes(u.role)) ||\n"
" new Set(payload.users.map(u => u.username)).size !== payload.users.length) throw new Error('Invalid account list');\n" " new Set(payload.users.map(u => u.username)).size !== payload.users.length) throw new Error('Invalid account list');\n"
" clearAccountSecret(); accounts = payload.users;\n" " clearAccountSecret(); accounts = payload.users;\n"
" element('accounts-list').textContent = accounts.map(u => u.username + ' — ' + u.role + (u.username === sessionIdentity.username ? ' (you)' : '')).join('\\n');\n" " settingsRows(element('accounts-list'), accounts.map(u => [u.username, u.role + (u.username === sessionIdentity.username ? ' (you)' : '')]));\n"
" for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = accounts[i]?.username || ''; option.hidden = option.disabled = !accounts[i]; }\n" " for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = accounts[i]?.username || ''; option.hidden = option.disabled = !accounts[i]; }\n"
" const index = Math.max(0, accounts.findIndex(t => t.username === previous?.username && t.user_id === previous?.user_id));\n" " const index = Math.max(0, accounts.findIndex(t => t.username === previous?.username && t.user_id === previous?.user_id));\n"
" element('account-target').value = String(index); element('account-role').value = accounts[index]?.role || 'user';\n" " element('account-target').value = String(index); element('account-role').value = accounts[index]?.role || 'user';\n"
@@ -779,8 +791,14 @@ static const char s_app_js[] =
" if (status !== 200 || !validateNetwork(payload)) throw new Error('Invalid network snapshot');\n" " if (status !== 200 || !validateNetwork(payload)) throw new Error('Invalid network snapshot');\n"
" networkSnapshot = payload; networkFresh = true;\n" " networkSnapshot = payload; networkFresh = true;\n"
" const w = payload.wifi, r = payload.runtime, m = payload.mdns;\n" " const w = payload.wifi, r = payload.runtime, m = payload.mdns;\n"
" net('summary').textContent = 'Wi-Fi generation ' + w.generation + '; boot enabled: ' + w.enabled_at_boot + '\\nAP ' + w.ap.policy + ', channel ' + w.ap.channel + ', ' + networkSSIDSummary(w.ap.ssid) + ', password configured: ' + w.ap.password_configured + '\\n' +\n" " settingsRows(net('summary'), [\n"
" w.profiles.map(p => 'STA ' + p.index + ': enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security + ', ' + networkSSIDSummary(p.ssid) + ', password configured: ' + p.password_configured).join('\\n') + '\\nRuntime: ' + r.state + ', started ' + r.started + ', active profile ' + r.active_profile + ', IP ' + (r.ip || 'none') + ', AP running ' + r.ap_running + ', AP clients ' + r.ap_clients + ', last error ' + r.last_error + '\\nmDNS generation ' + m.generation + ': ' + m.hostname + '.local; expected announcement ' + m.announced + ', last error ' + m.last_error + '. Not client-verified DNS.';\n" " ['Wi-Fi generation', w.generation], ['Enabled at boot', w.enabled_at_boot],\n"
" ['AP policy / channel', w.ap.policy + ' / ' + w.ap.channel], ['AP SSID', networkSSIDSummary(w.ap.ssid)], ['AP password configured', w.ap.password_configured],\n"
" ...w.profiles.flatMap(p => [['STA ' + p.index, 'enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security], ['STA ' + p.index + ' SSID', networkSSIDSummary(p.ssid)], ['STA ' + p.index + ' password configured', p.password_configured]]),\n"
" ['Runtime: ', r.state], ['Started', r.started], ['Active profile', r.active_profile], ['IP', r.ip || 'none'],\n"
" ['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)) net('target').value = 'ap';\n"
" renderNetworkTarget(); net('suffix').value = m.suffix; net('edit').hidden = false;\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. ' : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). ') + 'Browser drafts are not saved; Save persists device working state.';\n"
+32 -7
View File
@@ -38,12 +38,22 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id), setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id),
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id), requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}}; location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
const context = vm.createContext({window, document: {getElementById(id) { class Element {
return nodes[id] ||= {textContent: '', value: '', checked: false, dataset: {}, classList: {toggle() {}}, constructor(tagName = 'div') {
setAttribute(k, v) { this[k] = v; }, this.tagName = tagName.toUpperCase(); this.children = []; this._text = '';
getBoundingClientRect: () => ({width: 100, height: 100}), this.value = ''; this.checked = false; this.dataset = {}; this.classList = {toggle() {}};
addEventListener(k, fn) { this[k] = fn; }}; }
}}, Terminal, FitAddon: {FitAddon: class { get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(value) { this.children.forEach(child => { child.parentNode = null; }); this.children = []; this._text = String(value); }
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
setAttribute(k, v) { this[k] = v; }
getBoundingClientRect() { return {width: 100, height: 100}; }
addEventListener(k, fn) { this[k] = fn; }
}
const context = vm.createContext({window, document: {
createElement: tag => new Element(tag),
getElementById(id) { return nodes[id] ||= new Element(); }
}, Terminal, FitAddon: {FitAddon: class {
constructor() { this.measurements = []; this.calls = 0; fits.push(this); } constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; } proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
}}, }},
@@ -830,7 +840,8 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
}); });
const slotKey = index => ({index,type:'ssh-ed25519',fingerprint:'SHA256:' + String.fromCharCode(97 + index).repeat(43)}); const slotKey = index => ({index,type:'ssh-ed25519',fingerprint:'SHA256:' + String.fromCharCode(97 + index).repeat(43)});
function assertKeySlots(b, indices) { function assertKeySlots(b, indices) {
assert.equal(b.nodes['account-keys-list'].textContent, indices.map(index => `${index}: ssh-ed25519 ${slotKey(index).fingerprint}`).join('\n')); assert.deepEqual(b.nodes['account-keys-list'].children.map(node => [node.tagName, node.textContent]),
indices.flatMap(index => [['DT', `${index}: `], ['DD', `ssh-ed25519 ${slotKey(index).fingerprint}`]]));
assert.equal(b.nodes['account-key-index'].value, String(indices[0])); assert.equal(b.nodes['account-key-index'].value, String(indices[0]));
for(let index=0;index<3;++index) { for(let index=0;index<3;++index) {
const option=b.nodes['key-option-'+index], present=indices.includes(index); const option=b.nodes['key-option-'+index], present=indices.includes(index);
@@ -927,6 +938,20 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
const s=await accountsBrowser(); s.nodes['account-public-key'].value='ssh-ed25519 AAAA'; let warning; s.window.confirm=m=>{warning=m;return true;}; s.queues[accountPath].push(failure(401)); s.click('account-key-add'); await tick(); const s=await accountsBrowser(); s.nodes['account-public-key'].value='ssh-ed25519 AAAA'; let warning; s.window.confirm=m=>{warning=m;return true;}; s.queues[accountPath].push(failure(401)); s.click('account-key-add'); await tick();
assert.match(warning,/ALL.*web\/SSH.*401.*NOT proof/); assert.deepEqual(s.redirects,['/login']); assert.ok(s.sockets.every(s=>s.closed)); assert.doesNotMatch(s.nodes['account-operation-detail'].textContent,/completed/); assert.match(warning,/ALL.*web\/SSH.*401.*NOT proof/); assert.deepEqual(s.redirects,['/login']); assert.ok(s.sockets.every(s=>s.closed)); assert.doesNotMatch(s.nodes['account-operation-detail'].textContent,/completed/);
}); });
await test('Account definition rows and key rows clear as DOM children and fence late lists', async () => {
const b = await keyBrowser(), list = b.nodes['accounts-list'], keys = b.nodes['account-keys-list'];
assert.deepEqual(list.children.map(n => [n.tagName, n.textContent]),
[['DT', 'alice'], ['DD', 'admin (you)'], ['DT', 'carol'], ['DD', 'user']]);
assert.ok(keys.children.length > 0);
assert.ok([...list.children, ...keys.children].every(n => n.children.length === 0));
const old = [...list.children, ...keys.children], d = deferred();
b.queues['/api/settings/accounts'].push(d.promise); b.click('refresh-accounts'); await tick();
b.click('settings-serial'); await tick();
assert.equal(list.children.length, 0); assert.equal(keys.children.length, 0);
assert.ok(old.every(n => n.parentNode === null));
d.resolve(json({users: [{username: 'late', role: 'user', user_id: 9, auth_generation: 1}]})); await tick();
assert.equal(list.children.length, 0); assert.equal(keys.children.length, 0);
});
await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => { await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => {
const u = await connected(); u.click('settings-accounts'); await tick(); const u = await connected(); u.click('settings-accounts'); await tick();
assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts')); assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts'));
+171
View File
@@ -0,0 +1,171 @@
"""Structural checks of production HTML; not a browser layout/visual test."""
from html.parser import HTMLParser
class Document(HTMLParser):
def __init__(self, html):
super().__init__(convert_charrefs=True)
self.root = {'tag': 'root', 'attrs': {}, 'children': [], 'text': ''}
self.stack = [self.root]
self.ids = {}
self.feed(html)
assert self.stack == [self.root]
def handle_starttag(self, tag, attrs):
node = {'tag': tag, 'attrs': dict(attrs), 'children': [], 'text': '', 'parent': self.stack[-1]}
self.stack[-1]['children'].append(node)
if 'id' in node['attrs']:
ident = node['attrs']['id']
assert ident not in self.ids, ident
self.ids[ident] = node
if tag not in {'meta', 'link', 'img', 'input', 'br', 'hr'}:
self.stack.append(node)
def handle_endtag(self, tag):
assert self.stack[-1]['tag'] == tag, (tag, self.stack[-1]['tag'])
self.stack.pop()
def handle_data(self, data):
for node in self.stack:
node['text'] += data
def check_layout(html):
doc = Document(html)
ids = doc.ids
def classes(node):
return node['attrs'].get('class', '').split()
def descendants(node):
for child in node['children']:
yield child
yield from descendants(child)
def ancestor(node, cls):
while 'parent' in node:
node = node['parent']
if cls in classes(node):
return node
raise AssertionError(cls)
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary'):
assert ids[ident]['tag'] == 'dl'
assert 'settings-values' in classes(ids[ident])
for ident in ('serial-settings-content', 'account-settings', 'network-settings'):
nodes = list(descendants(ids[ident]))
assert not any(n['tag'] == 'pre' for n in nodes)
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
for n in nodes:
if n['tag'] in ('input', 'select', 'textarea'):
assert n['parent']['tag'] == 'label'
try:
ancestor(n, 'settings-edit')
except AssertionError:
ancestor(n, 'serial-edit')
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh'):
assert ids[ident]['text'] == 'Refresh'
for ident in ('serial-result', 'account-result', 'network-result'):
assert ids[ident]['text'] == 'Check Operation Result'
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
assert 'settings-check' in classes(ids[ident]['parent'])
for ident in ('account-public-key', 'account-generated'):
assert 'settings-wide' in classes(ids[ident]['parent'])
assert 'readonly' in ids['account-generated']['attrs']
assert 'hidden' in ids['account-generated-panel']['attrs']
assert ids['network-apply']['parent'] is ids['network-wifi-save']['parent']
assert ids['network-wifi-save']['parent'] is ids['network-wifi-load']['parent']
assert ids['network-start']['parent'] is not ids['network-wifi-save']['parent']
assert ids['network-start']['parent'] is ids['network-next-profile']['parent']
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 (
'.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}',
'.settings-edit .settings-wide{grid-column:1/-1}',
'.settings-edit input[type=checkbox]{width:auto;flex:none}',
'.settings-edit textarea{min-height:96px;resize:vertical}',
'.settings-edit input[readonly]{font-family:monospace}',
'.settings-page h3{font-size:1.17em;margin:1em 0}',
'.settings-page .connection-detail{max-width:600px;overflow-wrap:anywhere}',
'@media(max-width:360px)',
'.settings-edit{grid-template-columns:minmax(0,1fr)}',
):
assert rule in css, rule
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all three settings views')
def check_browser_layout(html, tmp, executable):
"""Optional real CSS layout check; fixture data, no application/network execution."""
import json
import re
import subprocess
# Keep production HTML/CSS, but do not run its authenticated loader or assets.
fixture = re.sub(r'<script\b[^>]*>.*?</script>', '', html, flags=re.S)
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']) {
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
cases.push(new Promise(resolve => {
frame.onload = () => {
const d = frame.contentDocument, win = frame.contentWindow;
d.getElementById('serial-settings').hidden = false;
for (const id of ['serial-settings-content', 'account-settings', 'network-settings']) d.getElementById(id).hidden = id !== view;
const section = d.getElementById(view);
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
section.querySelectorAll('dl').forEach(dl => {
dl.textContent = '';
for (let i = 0; i < 8; i++) {
const dt = d.createElement('dt'), dd = d.createElement('dd');
dt.textContent = 'Password configured'; dd.textContent = 'SHA256:' + 'x'.repeat(96);
dl.append(dt, dd);
}
});
section.querySelectorAll('input:not([type=checkbox]),textarea').forEach(n => n.value = 'x'.repeat(96));
const errors = [];
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
const rect = node.getBoundingClientRect();
if (!rect.width) continue;
if (rect.left < 0 || rect.right > width + 1) errors.push('overflow:' + (node.id || node.className));
if (node.matches('dl') && node.scrollWidth > node.clientWidth + 1) errors.push('summary overflow');
if (node.matches('input[type=checkbox]') && rect.width > 30) errors.push('checkbox width');
if (node.matches('dl,.settings-edit,.serial-edit') && rect.width > 601) errors.push('max width');
}
// Measure glyph ranges, not the full-width grid cell or textContent.
// The normal-whitespace control proves the pair detects collapsing.
for (const dd of section.querySelectorAll('dl dd:first-of-type')) {
const original = dd.textContent;
const measureSSID = spaces => {
dd.textContent = 'SSID: ' + JSON.stringify('office' + ' '.repeat(spaces) + 'wifi');
const range = d.createRange();
range.setStart(dd.firstChild, 7); range.setEnd(dd.firstChild, dd.textContent.length - 1);
if (range.getClientRects().length !== 1) errors.push('SSID probe unexpectedly wrapped');
return range.getBoundingClientRect().width;
};
const single = measureSSID(1), double = measureSSID(2);
if (!(single > 0 && double > single + 1)) errors.push('consecutive SSID spaces collapsed');
dd.style.whiteSpace = 'normal';
if (Math.abs(measureSSID(2) - measureSSID(1)) > 0.1) errors.push('invalid whitespace control');
dd.style.removeProperty('white-space'); dd.textContent = original;
}
resolve({width, view, errors});
};
}));
frame.srcdoc = FIXTURE; document.body.append(frame);
}
Promise.all(cases).then(results => {
const out = document.createElement('pre'); out.id = 'layout-results'; out.textContent = JSON.stringify(results); document.body.append(out);
});
'''.replace('FIXTURE', json.dumps(fixture))
page = tmp / 'layout-probe.html'
page.write_text('<!doctype html><html><body><script>' + probe + '</script></body></html>')
result = subprocess.run([executable, '--headless', '--no-sandbox', '--disable-gpu',
'--no-first-run', '--disable-background-networking',
'--user-data-dir=' + str(tmp / 'chromium-profile'),
'--virtual-time-budget=3000', '--dump-dom', page.as_uri()],
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
parsed = Document(result.stdout)
results = json.loads(parsed.ids['layout-results']['text'])
assert len(results) == 9
assert all(not case['errors'] for case in results), results
print('PASS Chromium layout: all three settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
+24 -1
View File
@@ -73,6 +73,26 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
} }
assert.equal(posts(b).length, 0); assert.equal(posts(b).length, 0);
}); });
await test('Structured network rows retain every field, escape markup and clear/fence navigation', async () => {
const v = fixture(); v.wifi.ap.ssid = '<img onerror="x">';
const b = await open(v), summary = n(b, 'summary');
const rows = summary.children;
assert.equal(rows.length, 58);
rows.forEach((node, i) => { assert.equal(node.tagName, i % 2 ? 'DD' : 'DT'); assert.equal(node.children.length, 0); });
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
assert.equal(values['AP SSID'], 'SSID: ' + JSON.stringify(v.wifi.ap.ssid));
assert.equal(values['AP password configured'], 'true');
assert.equal(values['STA 3 password configured'], 'false');
assert.equal(values['AP clients'], '1'); assert.equal(values['Wi-Fi last error'], '0');
assert.equal(values['mDNS last error'], '0'); assert.equal(values['Expected announcement'], 'false');
assert.equal(values['DNS verification'], 'Not client-verified DNS.');
const d = deferred(); b.queues[path].push(d.promise); b.click('network-refresh'); await tick();
b.click('settings-accounts'); await tick(); assert.equal(summary.children.length, 0);
assert.ok(rows.every(node => node.parentNode === null));
d.resolve(json(v)); await tick(); assert.equal(summary.children.length, 0);
b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
assert.equal(summary.children.length, 58); assert.ok(!summary.textContent.includes('<img'));
});
await test('Network strict nested snapshot shape rejects secret fields, types, ranges, duplicates and inconsistent canonical values', async () => { await test('Network strict nested snapshot shape rejects secret fields, types, ranges, duplicates and inconsistent canonical values', async () => {
const edits = [v => v.password = 'SECRET', v => v.wifi.password = 'SECRET', v => v.wifi.ap.password = 'SECRET', v => v.wifi.profiles[1].password = 'SECRET', const edits = [v => v.password = 'SECRET', v => v.wifi.password = 'SECRET', v => v.wifi.ap.password = 'SECRET', v => v.wifi.profiles[1].password = 'SECRET',
v => delete v.runtime.ip, v => v.wifi.generation = 0, v => v.mdns.generation = 4294967296, v => v.wifi.enabled_at_boot = 1, v => delete v.runtime.ip, v => v.wifi.generation = 0, v => v.mdns.generation = 4294967296, v => v.wifi.enabled_at_boot = 1,
@@ -272,7 +292,10 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
if (state === 'stale') assert.match(n(b, 'operation-detail').textContent, /Generation stale.*No automatic retry/); if (state === 'stale') assert.match(n(b, 'operation-detail').textContent, /Generation stale.*No automatic retry/);
if (state === 'applied_not_queued') assert.match(n(b, 'operation-detail').textContent, /RAM changed.*queue failed.*NOT rolled back/); if (state === 'applied_not_queued') assert.match(n(b, 'operation-detail').textContent, /RAM changed.*queue failed.*NOT rolled back/);
if (state === 'loaded_defaults') assert.match(n(b, 'operation-detail').textContent, /mDNS Load.*defaults in RAM.*NVS unchanged/); if (state === 'loaded_defaults') assert.match(n(b, 'operation-detail').textContent, /mDNS Load.*defaults in RAM.*NVS unchanged/);
assert.match(n(b, 'summary').textContent, /Runtime: error/); assert.match(n(b, 'summary').textContent, /last error 259/); assert.match(n(b, 'summary').textContent, /Runtime: error/); for (const label of ['Wi-Fi last error', 'mDNS last error']) {
const rows = n(b, 'summary').children, index = rows.findIndex(node => node.tagName === 'DT' && node.textContent === label);
assert.ok(index >= 0); assert.equal(rows[index + 1].tagName, 'DD'); assert.equal(rows[index + 1].textContent, '259');
}
} }
const b = await open(); b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(failure(503)); b.click('network-result'); await tick(); const b = await open(); b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(failure(503)); b.click('network-result'); await tick();
assert.match(n(b, 'operation-detail').textContent, /Accepted/); assert.match(n(b, 'detail').textContent, /stale/); assert.equal(n(b, 'edit').hidden, false); assert.ok(n(b, 'apply').disabled); assert.match(n(b, 'operation-detail').textContent, /Accepted/); assert.match(n(b, 'detail').textContent, /stale/); assert.equal(n(b, 'edit').hidden, false); assert.ok(n(b, 'apply').disabled);
+7
View File
@@ -10,6 +10,10 @@ import re
import shlex import shlex
import subprocess import subprocess
import tempfile import tempfile
import sys
sys.dont_write_bytecode = True
from layout import check_layout, check_browser_layout
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1] ROOT = HERE.parents[1]
@@ -90,6 +94,9 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
assert 'no private-key upload, export or SSH host management' in rendered['html'] assert 'no private-key upload, export or SSH host management' in rendered['html']
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization', 'clipboard', 'pushState', 'replaceState'): for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization', 'clipboard', 'pushState', 'replaceState'):
assert forbidden not in rendered['script'] + rendered['loader'], forbidden assert forbidden not in rendered['script'] + rendered['loader'], forbidden
check_layout(rendered['html'])
if os.environ.get('WEB_UI_CHROMIUM'):
check_browser_layout(rendered['html'], tmp, os.environ['WEB_UI_CHROMIUM'])
(tmp / 'rendered.json').write_text(json.dumps(rendered)) (tmp / 'rendered.json').write_text(json.dumps(rendered))
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30) subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)
print('PASS C/HTML: all resource headers/failures, no-store app/document, exact loader CSP, safe fallback') print('PASS C/HTML: all resource headers/failures, no-store app/document, exact loader CSP, safe fallback')