From 5a2aa0d4d8a47a28ab22ea3656f75525fab18d33 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Mon, 7 Sep 2026 20:12:33 +0200 Subject: [PATCH] Add admin serial settings view --- docs/agent/architecture.md | 2 + docs/agent/code-map.md | 1 + docs/agent/current-state.md | 2 + docs/agent/design-decisions.md | 2 + docs/phase8d8_implementation.md | 89 ++++++++++ docs/phase8d_plan.md | 2 + src/serial_service.c | 12 ++ src/serial_service.h | 8 + src/web_httpd_adapter.c | 30 ++++ src/web_httpd_adapter.h | 4 + src/web_server.c | 50 +++++- src/web_ui.c | 72 +++++++- tests/web_admin_transport/server_lifecycle.py | 43 +++-- tests/web_cookie_auth/README.md | 21 +++ tests/web_cookie_auth/run.py | 29 +++- tests/web_cookie_auth/settings_test.c | 154 ++++++++++++++++++ tests/web_cookie_auth/test.c | 6 + tests/web_session_store/serial_headers.py | 4 + tests/web_ui_session/README.md | 12 +- tests/web_ui_session/browser.cjs | 94 ++++++++++- 20 files changed, 612 insertions(+), 25 deletions(-) create mode 100644 docs/phase8d8_implementation.md create mode 100644 tests/web_cookie_auth/settings_test.c diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 97e23f8..1c1c2f4 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -116,6 +116,8 @@ Web serial initialization is failure-isolated from the base HTTPS service: if th ### Browser admin backend +8D.8 adds an in-document admin-only Settings/Serial view and optional `GET /api/settings/serial`. It requires current cookie/principal/admin authorization, rejects bodies/queries and inherits ordinary-GET Origin/no-store policy. Eight working config/running fields fit a 256-byte response; `serial_service_get_snapshot()` takes the existing state mutex with zero wait, releasing it before encoding/send and returning unavailable on contention. No mutation, NVS, broker or socket-lifecycle operation occurs. Both hidden terminals continue draining; Settings input is disabled, refresh is explicit/single-flight with session identity checks that cannot supersede serial admission, and cancellation clears/fences the settings view. URI budget is now 17, sockets remain six/no LRU. The private adapter's startup-only exact-GET registration stages descriptor/name allocation before table publication, unlike installed IDF's public failure path; HTTPD retains normal free ownership. Only Settings uses that helper; existing registration callers remain unchanged. See `docs/phase8d8_implementation.md` for bounds, tests and target-pending evidence. + The 8D.6 document binds retained terminal state to its first validated username/role/session-stable CSRF tuple. Every later session adoption must match, otherwise both terminal hosts are hidden, both sockets/work are closed/fenced and a clean `/` document is required. Pagehide hides scrollback until same-session revalidation; no clearing is needed for unchanged-session restore or mode changes. Terminal-fit readiness uses successful-bounds caching and at most three generation-fenced animation-frame retries per external request, never unbounded polling. 8D.6 `web_ui.c` now supplies the admin-only Serial/Admin selector using this backend. Selection leaves serial and any open admin socket connected and draining; only focus, displayed terminal and keyboard destination change. Broker identity/lease and writer controls remain serial-owned in both views. Admin opens/reopens explicitly and closes independently. Two page-lifetime terminals have separate 5,000-line scrollbacks and 64 KiB callback-accounted pending output each; browser overflow is dropped with visible counts. Admin input is bounded to 4 KiB admission and 512-byte frames. Logout/expiry/page exit closes both with generation fencing and socket-listener cleanup; bfcache revalidates serial/session but never automatically reopens admin. No server policy/capacity changes or 8D.7 lifecycle parity. See `docs/phase8d6_implementation.md`; the following paragraph describes the original backend boundary, before its UI entry was added. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 65d2899..32ac227 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -74,6 +74,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the - 8D.3 UI: `src/web_login_ui.{c,h}` serves standalone `/login`; `web_ui.c` validates session before serial connect/restore and handles logout/401 safely. Both scripts hash-bound, auth documents/app no-store. Tests: `python3 tests/web_login_ui/run.py` and `python3 tests/web_ui_session/run.py`. Live cutover host-tested/build-verified, M1 validated by user sign-off (numeric reserves open): `docs/phase8d3_implementation.md`. - Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually. - 8D.6 UI: `web_ui.c` adds admin-only Serial/Admin selection and explicit admin open/close through existing endpoints. Serial socket/client/lease survives mode switches; hidden output drains into independent 5,000-line/64 KiB-pending terminals with visible browser-drop counts. Selected keyboard only; logout/expiry/pagehide closes both with handler cleanup. Session identity changes require a clean document before adopting the view; same-session restore retains hidden-until-validated buffers. Fit readiness retries are bounded to three and cache only success. Focused `tests/web_ui_session/run.py` has 17 groups plus toolbar-order/CSP checks. 8D.6 is user-validated; telemetry, evidence limits and 8D.7 handoff are in `docs/phase8d6_implementation.md`. Numeric reserves remain open; no 8D.7 restriction change. +- 8D.8: `web_ui.c` adds admin-only Settings/Serial without socket/lease changes. `web_server.c` exposes optional admin-only bodyless `GET /api/settings/serial`, eight working serial values, 256-byte response, no writes/NVS. `serial_service_get_snapshot()` is a zero-wait consistent config/running copy. `web_httpd_register_optional_get()` stages both new-route allocations before table publication (installed IDF public registration leaves a dangling descriptor on name-allocation failure); only Settings uses this startup/exact-GET adapter. 17 URI slots, six sockets/no LRU, no new task. Tests: cookie auth `--settings` (5 groups), UI (21 groups), lifecycle (12 groups). Implemented/build-verified, target/signoff pending; exact accounting and inherited registration-audit followup: `docs/phase8d8_implementation.md`. M2 remains signed off; no 8D.9. ### Browser admin backend (8D.5) diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 6f52b07..9e5083a 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.8 separately authorized, implemented / host-tested / build-verified (2026-09-07), target/signoff pending:** Admin-only Settings/Serial in the existing document, bodyless server-authorized `GET /api/settings/serial`, eight typed working values with 256-byte response bound. Nonblocking serial-state snapshot avoids blocking HTTPD on console reconfiguration; no mutations/NVS/broker/terminal lifecycle side effects. Selection preserves both sockets/lease and hidden output; explicit refresh, safe errors, cancellation/identity/expiry and concurrent-reconnect fencing. One optional URI (17 total), six sockets/no LRU unchanged. Private IDF adapter stages new-route descriptor/name before publication to avoid installed public registration's dangling entry on strdup failure; only the new Settings route uses it, broader inherited registration audit remains a followup. Final `pio run` **24.31 s, 95,580 B RAM / 1,654,529 B flash**, **0 / +5,952 B** vs recorded 8D.7; **+1,048 / +54,556 B** vs 8D.0. Target snapshot/descriptor 36/24 B; requested route/table heap +49 B before overhead, handler local frame 416 B excluding callees, no stack/task/queue/capacity increase. UI **21**, Settings **5**, lifecycle **12**, transport **25**/tickets **12** and auth/store/login/parser/console/policy regressions pass; diff check passes. No device, sanitizer, generated-asset or commit action; inline loader/CSP hashes unchanged and verified. Task/todowrite tools unavailable; implementer review fixes rerun, no independent-agent review claim. See `docs/phase8d8_implementation.md` for exact tests/resources/limits/pending target checklist. **M2 signoff, deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups stand. Stop before separately requested 8D.9; no new-phase signoff inferred.** This supersedes the older next-8D.8/wait-for-request instructions below, not their signoff/evidence. + - **M2 explicitly signed off by the user (2026-09-07):** After 8D.7 implemented-scope validation and discussion of read-only settings next, the user says "Jupp, sign M2 off". This supersedes all earlier M2-open statements and continuation instructions below; accepted M2 does not require revalidation or imply full browser command parity. Browser self-target/generated-password/key/legacy-credential and other owner-specific command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Intermittent supported two serial + one admin web admission failures are accepted nonblocking, not fixed or diagnosed. Numeric memory reserves and stack margins remain unapproved follow-ups, not blockers reopening M2. **Next: 8D.8 read-only settings entry and Serial page, only when separately requested; this sign-off alone authorizes no implementation.** Evidence/history: `docs/phase8d7_implementation.md`. Documentation only; no source/tests/build/device/commit action. Earlier development entries below are historical; the latest M2 sign-off supersedes their pending status and next-work instructions, not their evidence. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index 3457d12..f47b3c9 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -104,6 +104,8 @@ Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutab ## Browser authentication has a narrow version-pinned HTTPD boundary +**8D.8 read-only settings:** Reuse bodyless GET cookie/current-admin policy and the existing bounded browser API/errors; no CSRF mutation semantics on a read. Obtain working serial config/running atomically with a zero-wait existing serial mutex, never block HTTPD on stop/reconfiguration or inspect NVS. Navigation changes view/input only, preserving both terminal sockets/lease/output; Settings session validation must not supersede serial-admission checks. One optional exact-GET URI raises only handler capacity to 17. The private adapter stages both descriptor/name allocations before publishing, avoiding the installed 5.5.0 public registration's freed table pointer on strdup failure. Serialized startup/exact matcher only, normal HTTPD allocation/free ownership; re-audit this boundary on SDK changes. Existing public registration callers are not refactored by this phase. + **8D.6 terminal separation:** Browser selection never reconnects serial or requests/releases a writer lease. Hidden connected terminals continue draining with separate bounded scrollback/pending writes and visible browser-drop accounting; only selected keyboard input is sent. Admin admission/reopen is explicit, close is isolated, and logout/expiry/pagehide tears down both routes. Keep the two page-lifetime input subscriptions stable across switches and remove socket callbacks on close. UI role hiding complements, never replaces, backend authorization. Existing unsupported WEB lifecycle/account-command restrictions remain for 8D.7. **Decision:** 8D.3 uses `web_cookie_auth` plus digest-only session/challenge stores, mandatory Origin/CSRF mutations and no live session/challenge/ticket eviction. Four one-hour absolute sessions deliberately interrupt long serial connections at expiry. No Basic compatibility path remains. diff --git a/docs/phase8d8_implementation.md b/docs/phase8d8_implementation.md new file mode 100644 index 0000000..1174ec7 --- /dev/null +++ b/docs/phase8d8_implementation.md @@ -0,0 +1,89 @@ +# Phase 8D.8 Implementation Record + +## Status and Scope + +2026-09-07: separately user-authorized 8D.8 is **implemented / host-tested / build-verified**. Target/browser validation and new phase signoff remain pending. No 8D.9 work, mutation/persistence controls, placeholder domains, all-subsystem settings snapshot, CLI-over-HTTP, generated-asset change, upload, erase or commit. Worktree was clean at task entry; no unrelated edits were reverted. + +**M2 remains explicitly signed off** by the user ("Jupp, sign M2 off"). Accepted 8D.7 scope does not need revalidation. Deferred browser self-target/generated-password/key/legacy-credential and other owner-specific command restrictions remain unchanged; bootstrap/recovery remain permanently UART0-only. The intermittent supported two serial + one admin admission issue remains accepted nonblocking, unresolved and undiagnosed. Numeric memory reserves, low historical internal/DMA minima and stack margins remain unapproved followups. Nothing here establishes full browser command parity or fixes that admission issue. + +## Implemented Contract + +- `src/web_ui.c`: Settings joins the existing admin-only Serial/Admin selector and opens the only implemented settings page, Serial, within the same document. It displays working UART1 service state, baud, data bits, parity, stop bits, flow control, DTR behavior and RTS threshold. It explicitly does not represent saved NVS state. +- Selection never creates/closes a serial or admin socket, joins/disconnects the broker, or requests/releases the writer lease. Both hidden terminals keep draining through their existing independent 5,000-line scrollbacks and 64 KiB pending-output bounds/drop notices. Only the selected terminal accepts keyboard input; neither does in Settings. Writer status and explicit writer controls remain available. +- Entry/Refresh first revalidates the existing username/role/session-stable CSRF identity, then requests `GET /api/settings/serial`. Settings validation must not supersede an in-flight serial-admission session check; newer admission may supersede an older Settings check, which returns to explicit retry. Identity change still requires a clean document before adoption. Absolute expiry is never extended. +- One active Settings refresh, no new periodic poll, no automatic retries, and the existing fixed-path/same-origin/no-store/redirect-denial fetch helper. Each fetch has a 15-second deadline; the two sequential session/snapshot requests can take up to approximately 30 seconds plus scheduling. Snapshot input is capped at 256 bytes, fatal UTF-8/JSON decoded, checked for exactly eight fields with bounded types/ranges/enums, and rendered only with `textContent`. +- Leaving Settings aborts and clears it. Logout/401/expiry/pagehide use existing two-route teardown and also clear/hide Settings. Late replies cannot repopulate it. Same-session bfcache restore revalidates before showing the empty page; Refresh is explicit and admin is not reopened automatically. Ordinary Settings failure does not close either terminal. Shared bounded error messages never display arbitrary response/error bodies; 429/503 retry hints are capped, and retries are manual. +- The external authored app script changed; the inline loader did **not**. Its two existing CSP hashes remain exact and unchanged, verified against the actual rendered loader. CSP was not relaxed. No vendored/generated assets were regenerated. + +## Backend and Ownership + +`serial_service_get_snapshot()` copies `serial_config_t` and running state under one **zero-wait** acquisition of the existing serial state mutex. NULL is invalid; a non-NULL output is cleared on failure; uninitialized returns invalid-state and contention returns timeout. No UART/GPIO/NVS/broker IO, additional allocation or new lock. This avoids the blocking config getter on the new HTTPD route and cannot stall behind a console stop/reconfiguration. It is a point-in-time working snapshot, not a promise that later console changes cannot occur. + +`web_server.c` authorizes with the existing real cookie/session/current-principal policy and then requires admin before any serial read. The route accepts only bodyless GET without query parameters. Existing Host/origin binding and Fetch Metadata policy apply: same-origin GET can omit Origin; supplied cross/null Origin rejects. GET is nonmutating and requires no CSRF token. Duplicate headers, malformed/oversized credentials, transfer framing, unknown query fields and bodies reject through existing bounded policy. Unread rejected bodies cause connection closure rather than unbounded draining. Auth/principal temporaries and consumed header scratch are wiped. + +Success is bounded JSON (256-byte stack buffer including terminator), containing only fixed firmware enum names, boolean and integers. No secrets, configuration version/storage blobs, counters or unrelated subsystem data are serialized. Missing/expired/revoked login gives 401; normal role gives 403; invalid request gives 400; busy/uninitialized serial gives 503 with `Retry-After: 1`. Common error routes give fixed 404/405 for unknown routes/methods. Responses are no-store/nosniff/no-referrer. Header/send failures stop without a second response; existing aggregate request/auth/response counters are reused. + +Registration is optional and independent of admin-console attachment: failure leaves base auth/serial/admin usable, with Settings returning the existing safe 404 and manual retry message. URI capacity increases from 16 to 17; sockets remain six with LRU disabled. No admission eviction or capacity change for sessions, challenges, tickets, console slots or WebSockets. + +### IDF Allocation Boundary + +Review of installed IDF 5.5.0 `httpd_uri.c:147-163` found that public URI registration publishes an allocated descriptor before duplicating its name; name-allocation failure frees it without clearing `hd_calls`. Ignoring that failure for the new optional route could leave a dangling table entry. `web_httpd_register_optional_get()` in the existing private adapter stages both ordinary heap allocations before publishing the fully initialized entry. It is restricted to serialized startup, exact-match GET, no WebSocket/subprotocol and a 127-byte URI limit; duplicate/full/invalid shape rejects without publication. HTTPD retains normal ownership and frees both allocations. No SDK patch or runtime registry framework was added. + +The new route alone uses this helper. Existing public registration callers were not refactored; broader inherited SDK allocation-failure handling remains a followup, not a diagnosed cause of the accepted admission issue. The version-pinned adapter now also requires registration/table/free-ownership re-audit on SDK updates. Host tests inject failure at both actual adapter allocations and compile the installed unregister implementation to exercise successful cleanup. They do not prove multicore registry timing or real low-heap behavior. + +## Resources + +Final release `pio run`, tool timeout **120 seconds**, passed in **24.31 seconds**, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0 / N16R8: + +| Resource | Final / Delta | +|---|---| +| Linked RAM | **95,580 B**, **0 B** vs recorded 8D.7; **+1,048 B** vs 8D.0 | +| Flash | **1,654,529 B**, **+5,952 B** vs recorded 8D.7 (1,648,577 B); **+54,556 B** vs 8D.0 (1,599,973 B) | +| HTTPD handlers | 17, +1; table +4 B on target | +| New route allocations | Target descriptor 24 B + URI/name terminator 21 B = 45 B; with table growth **49 B requested heap**, excluding allocator rounding/metadata | +| Snapshot / response scratch | Target snapshot 36 B; response array 256 B; caller principal 40 B | +| Compiled local frames | Settings handler 416 B, snapshot getter 32 B, registration adapter 48 B, **excluding callees** | +| Authored HTML/app arrays | 10,432 / 32,061 B including terminators in target ELF; flash-resident, not module heap | +| Tasks/stacks/queues | No new task, stack-size change, queue, depth, mutex or timer | +| Sockets/capacity | Six HTTPS sockets, two serial + one admin WebSocket; all session/ticket/shared-console capacities unchanged | +| Browser resources | Eight fixed value nodes, one active refresh; snapshot body <=256 B, session body <=512 B; existing terminal bounds unchanged | + +Sizes/entry frames above were inspected with the target GDB against the final ELF. The URI table allocation was verified in installed `httpd_main.c` and descriptor/free ownership in `httpd_uri.c`. Ordinary `malloc`/`calloc` follow existing IDF allocator policy, with no new PSRAM-only requirement or fallback policy; conservatively budget all 49 requested bytes as possible internal heap. No module persistent heap or PSRAM payload was added beyond HTTPD's route ownership. TLS/request allocations, allocator overhead, actual internal versus PSRAM placement, browser engine storage and full call-chain stack usage remain unmeasured. Static RAM and local frames are **not** runtime reserve evidence. + +Earlier pre-review build passed in 25.14 seconds at 95,580 B RAM / 1,654,021 B flash. The final build above supersedes it and includes the reconnect and allocation-publication fixes. + +## Verification + +All commands below passed in this task; compiler/test subprocesses and firmware builds had finite tool timeouts. No sanitizer run or hardware operation was performed. + +| Command | Result / Scope | +|---|---| +| `python3 tests/web_cookie_auth/run.py --settings` | Existing store/auth suite + **5 Settings groups**: real auth/store/parser/adapter, exact extracted production handler/helper/snapshot/enum bodies, allocation failure and installed unregister; fake UART mutex/database/HTTP IO | +| `python3 tests/web_ui_session/run.py` | **21 Node groups**, production-C renderer/headers/failures, exact loader CSP; includes 10 Settings cycles with retained sockets/IDs, hidden output/input, malformed/oversized/errors/timeout/manual retry, cancellation/restore/identity and concurrent serial reconnect | +| `python3 tests/web_admin_transport/server_lifecycle.py` | **12 groups**, required/optional registration failure and stop/restart ownership; Settings allocation failure does not disable either transport | +| `python3 tests/web_cookie_auth/run.py --admin` | Store/auth plus real admin ticket-to-upgrade integration, isolation, currentness and expiry | +| `python3 tests/web_admin_transport/run.py --tickets` | **25 transport / 12 ticket groups** | +| `python3 tests/web_session_store/run.py --serial` | Store and serial/session integration | +| `python3 tests/web_login_ui/run.py` | Production renderer/CSP plus **8 Node groups** | +| `python3 tests/web_auth_parse/run.py` | **268 cases, 0 failures** | +| `python3 tests/admin_console_boundary/run.py` | Shared console/currentness, deferred certificate handoff and production SSH adapter regressions | +| `python3 tests/admin_console_boundary/lifecycle.py` | Canonical lifecycle and unchanged SSH/UART0 paths | +| `python3 tests/admin_console_boundary/accounts.py` | Existing account operation-admission/persistence/isolation regressions | +| `python3 tests/admin_ssh_policy/run.py` | Existing parsed restrictions and role/transport policy | +| `pio run` | Final build above, PASS | +| `git diff --check` | PASS | + +The initial Settings test incorrectly reused a session after testing authoritative stale-principal invalidation; the real store correctly retired it. Fixtures now mint independent sessions for stale, database-failure and success cases. This was a test error, not an authentication-policy change. + +`todowrite` and a task/subagent tool were unavailable in this session. Progress was tracked in commentary and this record. A separate implementer review pass found/fixed the reconnect-supersession and IDF publication issues, followed by affected suite reruns and the final build. **Independent agent review was not available and is not claimed.** No remaining actionable finding was identified in the implementer's final review; that is not an independent review result. + +## Pending Target Checklist + +- Record flashed revision/browser(s)/direct-IP or mDNS origin. On desktop and mobile, verify layout, scrolling, keyboard/touch navigation, focus/pressed states, visibility of writer controls and CSP console cleanliness. +- Compare all eight Serial fields against UART0 `serial status` at a stable configuration, while stopped and running; make working-only changes via existing UART0/SSH admin controls, then Refresh. Confirm Settings never starts/stops/applies/saves/loads/resets serial or changes persisted values. +- With live serial writer and open admin, switch Serial/Admin/Settings at least 10 times and Refresh. Confirm same broker client/writer IDs, continuous serial/admin output, no input while Settings is selected, retained admin prompt/history and bounded overflow notices. Confirm explicit Disconnect and Close admin still affect only their intended route. +- Exercise normal-user direct endpoint denial; unknown query/body/method rejection; expired/logout/revoked session and account switch; delayed response/cancel, bfcache restore and explicit Refresh; unavailable/busy serial and optional route allocation failure where injectable. Confirm no old settings/terminal identity is exposed to a replacement login. +- Run UART0 recovery, native USB UART1, user/admin SSH and both browser routes concurrently, at the supported workload used for M2 (user reported up to 230400 baud). Include Settings reads while console reconfiguration is active. Record any existing admission failure by stage without asserting it is fixed or caused by this phase. +- Record settled boot, connected/full-mix and post-cycle/cleanup `memory` free/minimum/largest internal/DMA/PSRAM values, HTTPD and relevant task stack margins where instrumented, duration/byte/drop counts and any allocation failure. Runtime floors/budgets remain pending user approval; do not infer safety from linked RAM. + +Stop at 8D.8 for target validation/user decision. **8D.9 is not started and requires a separate request** after that decision. Prior M2 signoff remains accepted regardless of these new-phase pending checks. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index fc2acea..75a4cd3 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -1,5 +1,7 @@ # Phase 8D — Incremental web administration plan +**Latest implementation (2026-09-07):** Separately user-authorized **8D.8 is implemented / host-tested / build-verified**, with target/browser validation and new phase signoff pending. [8D.8 record](phase8d8_implementation.md): read-only admin Settings/Serial, nonblocking typed snapshot, 17 handlers/six sockets, final 95,580 B RAM / 1,654,529 B flash; exact tests/resources/limits and target checklist recorded. This supersedes older next-8D.8/wait-for-request instructions below, not M2 signoff or evidence. Deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups remain. Stop before separately requested 8D.9; no new signoff is inferred. + Status: **8D.0–8D.6 and M1 validated by explicit user sign-off. 8D.7 implemented scope validated and M2 explicitly signed off by the user on 2026-09-07 ("Jupp, sign M2 off"), superseding historical M2-open, target-pending and continuation instructions below without requiring revalidation. Full browser parity is not claimed: self/generated/key/legacy-credential and other owner-specific command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Intermittent supported two serial + one admin web admission failures are accepted nonblocking, not fixed. Numeric memory reserves/stack margins remain unapproved. Next is separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation.** See the [8D.7/M2 sign-off and evidence](phase8d7_implementation.md), [8D.6 implementation record](phase8d6_implementation.md), [8D.5 implementation record](phase8d5_implementation.md), [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md). This is the execution plan for [roadmap Phase 8D](roadmap.md#phase-8--role-based-users-and-administrative-access). The roadmap retains the feature/security requirements; this document defines small work units, dependencies, and release gates. The [administration test matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration) remains the final acceptance checklist. diff --git a/src/serial_service.c b/src/serial_service.c index 4e12578..c8d730a 100644 --- a/src/serial_service.c +++ b/src/serial_service.c @@ -646,6 +646,18 @@ esp_err_t serial_service_get_config(serial_config_t *config) return ESP_OK; } +esp_err_t serial_service_get_snapshot(serial_service_snapshot_t *snapshot) +{ + if (snapshot == NULL) return ESP_ERR_INVALID_ARG; + memset(snapshot, 0, sizeof(*snapshot)); + if (!s_initialized) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(s_state_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT; + snapshot->config = s_config; + snapshot->running = atomic_load(&s_running); + xSemaphoreGive(s_state_mutex); + return ESP_OK; +} + size_t serial_service_read(uint8_t *data, size_t size) { if (!s_initialized || data == NULL || size == 0) { diff --git a/src/serial_service.h b/src/serial_service.h index 7057315..e00f630 100644 --- a/src/serial_service.h +++ b/src/serial_service.h @@ -48,6 +48,14 @@ bool serial_service_is_running(void); esp_err_t serial_service_apply_config(const serial_config_t *config); esp_err_t serial_service_get_config(serial_config_t *config); +typedef struct { + serial_config_t config; + bool running; +} serial_service_snapshot_t; + +/* Nonblocking, consistent working configuration/state; no hardware or NVS IO. */ +esp_err_t serial_service_get_snapshot(serial_service_snapshot_t *snapshot); + /* * Access is intentionally nonblocking. The session broker is the sole * logical RX consumer and TX producer; calls are serialized internally to diff --git a/src/web_httpd_adapter.c b/src/web_httpd_adapter.c index 555df1d..54f8872 100644 --- a/src/web_httpd_adapter.c +++ b/src/web_httpd_adapter.c @@ -1,6 +1,7 @@ /* SPDX-License-Identifier: GPL-3.0-only */ /* Deliberately isolated dependency on the installed IDF HTTPD layout. */ #include "web_httpd_adapter.h" +#include #include #include #include "esp_idf_version.h" @@ -104,3 +105,32 @@ bool web_httpd_unread_body(httpd_req_t *request) const struct httpd_req_aux *aux = request->aux; return aux && aux->remaining_len != 0; } + +esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri) +{ + struct httpd_data *hd = server; + if (!hd || !uri || !uri->uri || !uri->handler || uri->method != HTTP_GET || + uri->is_websocket || uri->supported_subprotocol || hd->config.uri_match_fn) + return ESP_ERR_INVALID_ARG; + size_t length = 0; + while (length < 128 && uri->uri[length]) ++length; + if (!length || length == 128) return ESP_ERR_INVALID_ARG; + int slot = -1; + for (unsigned i = 0; i < hd->config.max_uri_handlers; ++i) { + if (!hd->hd_calls[i]) { if (slot < 0) slot = (int)i; } + else if (!strcmp(hd->hd_calls[i]->uri, uri->uri) && hd->hd_calls[i]->method == HTTP_GET) + return ESP_ERR_INVALID_STATE; + } + if (slot < 0) return ESP_ERR_NO_MEM; + /* IDF 5.5.0 publishes its descriptor before strdup; strdup failure leaves a + * freed hd_calls entry. Optional registration must leave the table intact. */ + httpd_uri_t *copy = malloc(sizeof(*copy)); + if (!copy) return ESP_ERR_NO_MEM; + char *name = malloc(length + 1); + if (!name) { free(copy); return ESP_ERR_NO_MEM; } + memcpy(name, uri->uri, length + 1); + *copy = *uri; + copy->uri = name; + hd->hd_calls[slot] = copy; + return ESP_OK; +} diff --git a/src/web_httpd_adapter.h b/src/web_httpd_adapter.h index b45e606..d8afa8e 100644 --- a/src/web_httpd_adapter.h +++ b/src/web_httpd_adapter.h @@ -12,3 +12,7 @@ bool web_httpd_unread_body(httpd_req_t *request); void web_httpd_wipe_request(httpd_req_t *request, bool closing); esp_err_t web_httpd_upgrade(httpd_req_t *request, esp_err_t (*handler)(httpd_req_t *)); + +/* Serialized server startup only, exact-match ordinary GET, URI <= 127 bytes. + * Stage both allocations before publication; HTTPD owns/frees them on success. */ +esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri); diff --git a/src/web_server.c b/src/web_server.c index a9f8d46..4f854b7 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -329,6 +329,52 @@ static esp_err_t status_handler(httpd_req_t *request) return error; } +static esp_err_t serial_settings_handler(httpd_req_t *request) +{ + user_principal_t principal = {0}; + bool authorized = false; + esp_err_t error = authorize_or_respond(request, &principal, &authorized); + bool admin = authorized && principal.role == USER_ROLE_ADMIN; + secure_wipe(&principal, sizeof(principal)); + if (error != ESP_OK || !authorized) return error; + if (!admin) + return send_plain_error(request, "403 Forbidden", "Administrator access required.\n"); + + serial_service_snapshot_t snapshot = {0}; + if (serial_service_get_snapshot(&snapshot) != ESP_OK) { + error = httpd_resp_set_hdr(request, "Retry-After", "1"); + if (error != ESP_OK) return error; + return send_plain_error(request, "503 Service Unavailable", "Serial snapshot unavailable.\n"); + } + const serial_config_t *config = &snapshot.config; + char response[256]; + /* Only firmware-owned enum names and numeric values, never CLI output. */ + int written = snprintf(response, sizeof(response), + "{\"running\":%s,\"baud\":%" PRIu32 ",\"data_bits\":\"%s\"," + "\"parity\":\"%s\",\"stop_bits\":\"%s\",\"flow\":\"%s\"," + "\"dtr\":\"%s\",\"rts_threshold\":%" PRIu32 "}", + snapshot.running ? "true" : "false", config->baud_rate, + safe_string(serial_config_data_bits_to_string(config->data_bits)), + safe_string(serial_config_parity_to_string(config->parity)), + safe_string(serial_config_stop_bits_to_string(config->stop_bits)), + safe_string(serial_config_flow_control_to_string(config->flow_control)), + safe_string(serial_config_dtr_behavior_to_string(config->dtr_behavior)), + config->rts_threshold); + if (written < 0 || (size_t)written >= sizeof(response)) + return send_plain_error(request, "500 Internal Server Error", "Serial response overflow.\n"); + error = httpd_resp_set_type(request, "application/json; charset=utf-8"); + if (error == ESP_OK) error = set_common_headers(request); + if (error == ESP_OK) error = httpd_resp_send(request, response, (ssize_t)written); + if (error != ESP_OK) increment_counter(&s_counters.response_errors); + return error; +} + +static const httpd_uri_t s_serial_settings_uri = { + .uri = "/api/settings/serial", + .method = HTTP_GET, + .handler = serial_settings_handler, +}; + static const httpd_uri_t s_root_uri = { .uri = "/", .method = HTTP_GET, @@ -517,7 +563,7 @@ esp_err_t web_server_start(void) config.httpd.max_open_sockets = 6; config.httpd.max_uri_handlers = sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) + - sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 2U; + sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 3U; /* Exhaustion rejects new sockets, never evicts an existing serial writer. */ config.httpd.lru_purge_enable = false; config.httpd.recv_wait_timeout = 1; @@ -563,6 +609,8 @@ esp_err_t web_server_start(void) (void)httpd_unregister_uri_handler(server, WEB_ADMIN_TICKET_URI, HTTP_POST); if (admin_error == ESP_OK && web_admin_transport_init() == ESP_OK) admin_transport_owned = web_admin_transport_attach(server) == ESP_OK; + /* Optional settings allocation failure must not disable either terminal. */ + (void)web_httpd_register_optional_get(server, &s_serial_settings_uri); } if (error != ESP_OK) { web_cookie_auth_stop(); diff --git a/src/web_ui.c b/src/web_ui.c index 8d7b71c..5502882 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -80,6 +80,9 @@ static const char s_index_html[] = "background:#080c12;padding:8px}\n" ".terminal-host .xterm{width:100%;height:100%}\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-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" ".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)}" ".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}" @@ -163,13 +166,25 @@ static const char s_index_html[] = "
\n" "
Live serial stream" "" - "\n" + " " + "
\n" "\n" "

Scrollback: 5000 lines per terminal; oldest lines expire.

\n" "
\n" "\n" + "\n" "
\n" "\n" "\n" @@ -197,6 +212,38 @@ static const char s_app_js[] = "const adminHost = element('admin-terminal');\n" "const adminToggle = element('admin-toggle');\n" "const adminDetail = element('admin-detail');\n" + "const settingsHost = element('serial-settings'), settingsDetail = element('settings-detail');\n" + "const settingsFields = ['running', 'baud', 'data_bits', 'parity', 'stop_bits', 'flow', 'dtr', 'rts_threshold'];\n" + "let settingsAbort = null;\n" + "function clearSettings() {\n" + " if (settingsAbort) settingsAbort.abort();\n" + " settingsAbort = null; settingsHost.hidden = true; element('settings-values').hidden = true;\n" + " for (const key of settingsFields) element('setting-' + key).textContent = '';\n" + " element('refresh-settings').disabled = false; settingsDetail.textContent = 'Select Refresh to read current values.';\n" + "}\n" + "async function refreshSettings() {\n" + " if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort) return;\n" + " clearSettings(); settingsHost.hidden = false;\n" + " const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n" + " const current = () => settingsAbort === controller && selected === 'settings';\n" + " element('refresh-settings').disabled = true; settingsDetail.textContent = 'Reading serial configuration...';\n" + " try {\n" + " if (!await loadSession(generation, controller.signal, false)) throw new Error('Session check cancelled');\n" + " if (!current()) return;\n" + " const {payload: value} = await api('/api/settings/serial', generation, {signal: controller.signal, limit: 256, current});\n" + " if (!value || Object.keys(value).length !== settingsFields.length || !settingsFields.every(key => Object.hasOwn(value, key)) ||\n" + " typeof value.running !== 'boolean' || !Number.isInteger(value.baud) || value.baud < 110 || value.baud > 1000000 ||\n" + " !['7', '8'].includes(value.data_bits) || !['none', 'even', 'odd'].includes(value.parity) ||\n" + " !['1', '2'].includes(value.stop_bits) || !['none', 'rts-cts'].includes(value.flow) ||\n" + " !['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" + " element('settings-values').hidden = false; settingsDetail.textContent = 'Snapshot loaded. Refresh to see later changes; nothing is applied or saved here.';\n" + " } catch (error) {\n" + " if (live(generation) && current()) settingsDetail.textContent = (error.status ? error.message : 'Serial snapshot could not be read or was invalid.') + ' Select Refresh to retry.';\n" + " } finally {\n" + " if (current()) { settingsAbort = null; element('refresh-settings').disabled = false; }\n" + " }\n" + "}\n" "let accountRole = 'user', selected = 'serial';\n" "let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n" "let adminGeneration = 0, adminTimer = null;\n" @@ -240,7 +287,8 @@ static const char s_app_js[] = " updateControls();\n" "}\n" "function selectTerminal(mode) {\n" - " if (unloading || navigating || loggingOut || !sessionVerified || (mode === 'admin' && accountRole !== 'admin')) return;\n" + " if (unloading || navigating || loggingOut || !sessionVerified || (mode !== 'serial' && accountRole !== 'admin')) return;\n" + " clearSettings();\n" " selected = mode;\n" " if (mode === 'admin' && !adminTerminal) {\n" " adminTerminal = new Terminal({...terminal.options, disableStdin: true, scrollback: 5000});\n" @@ -258,9 +306,11 @@ static const char s_app_js[] = " 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('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : 'Administration shell';\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" - " (mode === 'serial' ? terminal : adminTerminal).focus();\n" + " if (mode === 'settings') refreshSettings(); else (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" @@ -328,6 +378,7 @@ static const char s_app_js[] = "const requests = new Set();\n" "const live = (generation) => generation === workGeneration && !unloading && !navigating;\n" "const cancelWork = () => {\n" + " clearSettings();\n" " ++workGeneration;\n" " ++connectionGeneration;\n" " if (fitFrame) window.cancelAnimationFrame(fitFrame);\n" @@ -407,8 +458,9 @@ static const char s_app_js[] = " if (signal) signal.removeEventListener('abort', abort);\n" " }\n" "}\n" - "async function loadSession(generation, signal) {\n" - " const sequence = ++sessionGeneration;\n" + "async function loadSession(generation, signal, supersede = true) {\n" + " // A Settings read must not supersede an in-flight serial admission check.\n" + " const sequence = supersede ? ++sessionGeneration : sessionGeneration;\n" " const current = () => sequence === sessionGeneration;\n" " const {payload} = await api('/api/session', generation, {signal, current});\n" " if (!live(generation) || !current() || (signal && signal.aborted)) return false;\n" @@ -431,6 +483,7 @@ static const char s_app_js[] = " element('terminal-selector').hidden = accountRole !== 'admin';\n" " if (accountRole !== 'admin') { closeAdmin(); selectTerminal('serial'); }\n" " terminalHost.hidden = selected !== 'serial'; adminHost.hidden = selected !== 'admin';\n" + " settingsHost.hidden = selected !== 'settings' || loggingOut || suspended;\n" " scheduleFit();\n" " const deadline = Date.now() + payload.expires_in * 1000;\n" " sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n" @@ -494,6 +547,7 @@ static const char s_app_js[] = " ? 'Writer mode — terminal input is enabled.'\n" " : 'Observer mode — terminal input is disabled.';\n" " if (selected === 'admin') inputState.textContent = `Serial ${writer ? 'writer lease retained' : 'observer'}; serial input disabled while hidden. Admin input ${adminOpen ? 'enabled' : 'disabled'}.`;\n" + " if (selected === 'settings') inputState.textContent = `Serial ${writer ? 'writer lease retained' : 'observer'}; both terminals keep receiving. Terminal input disabled in Settings.`;\n" " setBadge(roleStatus, writer ? 'Writer' : 'Observer', writer ? 'good' : 'warn');\n" "};\n" "const setRole = (nextRole) => {\n" @@ -651,10 +705,12 @@ static const char s_app_js[] = "});\n" "element('select-serial').addEventListener('click', () => selectTerminal('serial'));\n" "element('select-admin').addEventListener('click', () => selectTerminal('admin'));\n" + "element('select-settings').addEventListener('click', () => selectTerminal('settings'));\n" + "element('refresh-settings').addEventListener('click', refreshSettings);\n" "adminToggle.addEventListener('click', () => { if (adminSocket || adminAbort) closeAdmin(); else openAdmin(); });\n" "const fitTerminal = () => {\n" " fitFrame = 0;\n" - " if (unloading || navigating || loggingOut || !sessionVerified) return;\n" + " if (unloading || navigating || loggingOut || !sessionVerified || selected === 'settings') return;\n" " const target = selected === 'admin' ? adminTerminal : terminal;\n" " const addon = selected === 'admin' ? adminFit : fitAddon;\n" " const bounds = (selected === 'admin' ? adminHost : terminalHost).getBoundingClientRect();\n" diff --git a/tests/web_admin_transport/server_lifecycle.py b/tests/web_admin_transport/server_lifecycle.py index f06c1cd..4a4c696 100644 --- a/tests/web_admin_transport/server_lifecycle.py +++ b/tests/web_admin_transport/server_lifecycle.py @@ -37,8 +37,8 @@ def define(path, name): uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};', source, re.M | re.S) # Non-array declarations have no brackets; explicit shape avoids silent omission. -if len(uri_tables) != 13: - raise RuntimeError('Review URI extraction: expected 11 descriptors and two tables') +if len(uri_tables) != 14: + raise RuntimeError('Review URI extraction: expected 12 descriptors and two tables') state = source[source.index('static SemaphoreHandle_t s_server_mutex;'): source.index('static esp_err_t ensure_mutex(void)')] header = (ROOT / 'src/web_server.h').read_text() @@ -93,6 +93,8 @@ static unsigned ssl_starts, ssl_stops, serial_attaches, serial_detaches; static unsigned admin_attaches, admin_detaches, admin_stoppeds; static unsigned registration_calls, registration_fail_at, registered_count, unregister_calls; static bool unregister_fail; +static bool settings_fail; +static unsigned settings_calls; static const httpd_uri_t *registered[32]; static char events[128]; static size_t event_length; static void event(char value) { assert(!locked && event_length + 1 < sizeof(events)); events[event_length++] = value; events[event_length] = 0; } @@ -104,6 +106,7 @@ static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); } HANDLER(root_handler) HANDLER(status_handler) HANDLER(ticket_handler) HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler) HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler) +HANDLER(serial_settings_handler) static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; } static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; } static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; } @@ -115,7 +118,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) { assert(!locked && auth_live && !ssl_live); ++ssl_starts; assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable); - assert(config->httpd.max_uri_handlers == 16 && config->port_secure == 443); + assert(config->httpd.max_uri_handlers == 17 && config->port_secure == 443); assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1); assert(config->tls_handshake_timeout_ms == 5000); assert(config->servercert_len == 1 && config->servercert[0] == 1); @@ -128,6 +131,14 @@ static esp_err_t register_one(httpd_handle_t server) { return registration_calls == registration_fail_at ? ESP_FAIL : ESP_OK; } static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t *uri) { + if (!strcmp(uri->uri, "/api/settings/serial")) { + assert(s == SERVER && auth_live && ssl_live && registration_calls >= 17); + assert(uri->method == HTTP_GET && uri->handler == serial_settings_handler); + ++settings_calls; + if (settings_fail) return ESP_ERR_NO_MEM; + registered[registered_count++] = uri; + return ESP_OK; + } if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) { assert(registration_calls >= 16); assert(serial_init_error != ESP_OK || serial_live); @@ -136,6 +147,10 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t if (error == ESP_OK) { assert(registered_count < 32); registered[registered_count++] = uri; } return error; } +static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) { + assert(!strcmp(uri->uri, "/api/settings/serial")); + return httpd_register_uri_handler(s, uri); +} static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) { assert(!locked && s == SERVER && ssl_live && auth_live && serial_live); assert(registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST); @@ -201,7 +216,7 @@ static void reset(void) { serial_inits = admin_inits = auth_starts = auth_stops = ssl_starts = ssl_stops = 0; serial_attaches = serial_detaches = admin_attaches = admin_detaches = admin_stoppeds = 0; registration_calls = registration_fail_at = registered_count = unregister_calls = 0; - unregister_fail = false; clear_events(); + unregister_fail = settings_fail = false; settings_calls = 0; clear_events(); } static void fresh_registration(void) { registration_calls = registered_count = 0; } static void start(void) { @@ -235,7 +250,8 @@ int main(void) { } puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment"); - reset(); start(); assert(registered_count == 16 && registration_calls == 18); + reset(); start(); assert(registered_count == 17 && registration_calls == 18 && settings_calls == 1); + assert(route("/api/settings/serial")->handler == serial_settings_handler); const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin"); assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket); assert(ws->method == HTTP_GET && ws->handler == web_admin_transport_upgrade_handler && !ws->is_websocket); @@ -286,7 +302,7 @@ int main(void) { assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned); assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops); assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures); - assert(registered_count == 14 && unregister_calls == failure - 17); + assert(registered_count == 15 && unregister_calls == failure - 17); for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin")); assert(route("/ws/serial")->handler == websocket_handler); @@ -295,13 +311,13 @@ int main(void) { clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH")); assert(!admin_detaches && !admin_stoppeds); registration_fail_at = 0; fresh_registration(); start(); - assert(registered_count == 16 && admin_attaches == 1 && s_counters.starts == 2); + assert(registered_count == 17 && admin_attaches == 1 && s_counters.starts == 2); assert(web_server_stop() == ESP_OK && admin_stoppeds == 1); } puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart"); reset(); registration_fail_at = 18; unregister_fail = true; - assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 15); + assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 16); assert(auth_live && ssl_live && serial_live && s_serial_transport_attached); assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned); ticket = route("/api/admin/ws-ticket"); @@ -313,7 +329,7 @@ int main(void) { clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH")); assert(!admin_detaches && !admin_stoppeds); unregister_fail = false; registration_fail_at = 0; fresh_registration(); start(); - assert(registered_count == 16 && admin_attaches == 1 && web_server_stop() == ESP_OK); + assert(registered_count == 17 && admin_attaches == 1 && web_server_stop() == ESP_OK); puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart"); reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL; @@ -334,7 +350,14 @@ int main(void) { assert(web_server_start() == ESP_ERR_INVALID_STATE && !auth_starts); assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops); puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection"); - puts("11 lifecycle groups passed (16 required fatal positions, 2 optional positions, plus failed unregister)"); + reset(); settings_fail = true; start(); + assert(settings_calls == 1 && registered_count == 16); + assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK); + settings_fail = false; fresh_registration(); start(); + assert(route("/api/settings/serial")->handler == serial_settings_handler); + assert(web_server_stop() == ESP_OK); + puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers"); + puts("12 lifecycle groups passed (16 required fatal positions, 3 optional routes, plus failed unregister)"); return 0; } ''' diff --git a/tests/web_cookie_auth/README.md b/tests/web_cookie_auth/README.md index 05b5350..0164c24 100644 --- a/tests/web_cookie_auth/README.md +++ b/tests/web_cookie_auth/README.md @@ -15,3 +15,24 @@ Coverage includes challenge reuse/consumption/expiry, capacities without evictio This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, browser, multicore task or hardware test. The private struct doubles do not prove binary layout; firmware compilation uses the actual pinned headers, and the version guard requires a new audit on SDK changes. Handshake sending and transport revocation are doubled. Actual on-wire pre-101 rejection, frame routing, pipelining/early bytes, cookie/CSP/browser recovery and loaded expiry latency remain M1 target gates. No sanitizer or runtime memory-reserve result is implied. See `docs/phase8d3_implementation.md` for source verification, other suite commands, build accounting and the target checklist. + +## Read-only Serial Settings + +```sh +python3 tests/web_cookie_auth/run.py --settings +``` + +Runs the existing auth/store suite plus five 8D.8 groups. Compiles exact extracted +production server handler/helpers, serial snapshot getter and enum formatters, +with the real cookie/store/parser/private adapter. Serial locking/state and HTTP +IO are doubled; authorization is not. Exercises normal-role/stale/expired/revoked +denial, DB failure, body/query/method/header/framing/Origin rejection before any +serial read, working values, zero-wait busy/uninitialized failure, no-store and +header/send errors. Adapter-only allocator substitution injects both staged +registration failures; the installed IDF unregister function frees successful +registration. No SDK files are modified. Lifecycle registration/optional failure +orchestration is separately tested by `tests/web_admin_transport/server_lifecycle.py`. + +This does not run the full serial task/UART driver, TLS/network dispatcher or a real +browser. Target comparison with UART0 and runtime memory/stack validation remain +pending in `docs/phase8d8_implementation.md`; prior M2 signoff remains accepted. diff --git a/tests/web_cookie_auth/run.py b/tests/web_cookie_auth/run.py index 35a1fb0..1f5d780 100644 --- a/tests/web_cookie_auth/run.py +++ b/tests/web_cookie_auth/run.py @@ -37,11 +37,13 @@ struct sock_db { bool ws_handshake_done; esp_err_t (*ws_handler)(httpd_req_t *); struct httpd_req_aux { struct sock_db *sd; char *scratch; size_t scratch_cur_size, remaining_len; unsigned req_hdrs_count, resp_hdrs_count; bool ws_handshake_detect; struct resp_hdr { const char *field, *value; } *resp_hdrs; }; -struct httpd_data { struct { unsigned max_resp_headers; } config; }; +struct httpd_data { struct { unsigned max_resp_headers, max_uri_handlers; void *uri_match_fn; } config; + httpd_uri_t **hd_calls; }; esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *); """ admin = "--admin" in sys.argv +settings = "--settings" in sys.argv if admin: HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n" HEADERS["esp_heap_caps.h"] = """#pragma once @@ -73,8 +75,10 @@ extracted = """ #pragma GCC diagnostic ignored "-Wsign-compare" #include #include +#include #include "esp_httpd_priv.h" #define ESP_LOGD(...) ((void)0) +#define ESP_LOGW(...) ((void)0) #define MIN(a,b) ((a) < (b) ? (a) : (b)) static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; } static size_t strlcpy(char *d, const char *s, size_t n) { @@ -88,6 +92,9 @@ for name in ["httpd_req_get_hdr_value_len", "httpd_req_get_hdr_value_str"]: extracted += function(txrx[txrx.index("esp_err_t httpd_resp_set_hdr"):], "httpd_resp_set_hdr") extracted += "\n" + function(txrx[txrx.index("static size_t httpd_recv_pending"):], "httpd_recv_pending") extracted += "\nsize_t host_read_pending(httpd_req_t *r, char *out, size_t n) { return httpd_recv_pending(r, out, n); }\n" +if settings: + uri_source = (idf / 'components/esp_http_server/src/httpd_uri.c').read_text() + extracted += '\n' + function(uri_source[uri_source.index('esp_err_t httpd_unregister_uri_handler'):], 'httpd_unregister_uri_handler') with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory: tmp = pathlib.Path(directory) @@ -96,12 +103,32 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text) (tmp / "installed_httpd.c").write_text(extracted) + if settings: + # Compile exact production handler/helpers and nonblocking snapshot body. + server_source = (ROOT / 'src/web_server.c').read_text() + service_source = (ROOT / 'src/serial_service.c').read_text() + config_source = (ROOT / 'src/serial_config.c').read_text() + settings_source = function(service_source, 'serial_service_get_snapshot') + '\n' + for name in ('data_bits', 'parity', 'stop_bits', 'flow_control', 'dtr_behavior'): + settings_source += function(config_source[config_source.index('const char *serial_config_' + name + '_to_string'):], 'serial_config_' + name + '_to_string') + '\n' + for name in ('set_common_headers', 'send_plain_error', 'authorize_or_respond', 'safe_string', 'serial_settings_handler'): + settings_source += function(server_source, name) + '\n' + (tmp / 'settings_production.h').write_text(settings_source) sources = [HERE / "test.c", tmp / "installed_httpd.c"] sources += [ROOT / "src" / name for name in ["web_session_store.c", "web_auth_parse.c", "web_cookie_auth.c", "web_httpd_adapter.c"]] if admin: sources += [ROOT / "src" / name for name in ["web_admin_tickets.c", "web_admin_transport.c"]] + if settings: + adapter = ROOT / 'src/web_httpd_adapter.c' + sources.remove(adapter) + subprocess.run(['cc', '-std=c11', '-D_GNU_SOURCE', '-Wall', '-Wextra', '-Werror', + '-Dmalloc=settings_malloc', '-Dfree=settings_free', + '-I' + str(tmp), '-I' + str(ROOT / 'src'), '-c', str(adapter), + '-o', str(tmp / 'adapter.o')], check=True, timeout=30) + sources.append(tmp / 'adapter.o') subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g", "-DHOST_OPENSSL", *(["-DHOST_ADMIN"] if admin else []), + *(["-DHOST_SETTINGS"] if settings else []), "-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto", "-o", str(tmp / "test")], check=True, timeout=30) subprocess.run([str(tmp / "test")], check=True, timeout=20) diff --git a/tests/web_cookie_auth/settings_test.c b/tests/web_cookie_auth/settings_test.c new file mode 100644 index 0000000..4f95ab5 --- /dev/null +++ b/tests/web_cookie_auth/settings_test.c @@ -0,0 +1,154 @@ +/* Exact production snapshot/HTTP bodies, real auth/store/adapter, fake UART mutex. */ +#include +#include +#include +#include +#include "serial_service.h" +#include "web_server.h" +#define pdTRUE 1 +#define ESP_ERR_TIMEOUT 0x107 +static bool s_initialized, serial_busy, serial_locked; +static atomic_bool s_running; +static serial_config_t s_config; +static int s_state_mutex; +static unsigned serial_takes; +static web_server_counters_t s_counters; +static unsigned allocations, allocation_fail_at, frees; +static httpd_uri_t *routes[2]; +void *settings_malloc(size_t size) { + assert(routes[1] == NULL); /* Neither allocation may publish partial state. */ + if (++allocations == allocation_fail_at) return NULL; + void *value = malloc(size); assert(value); memset(value, 0xa5, size); return value; +} +void settings_free(void *value) { ++frees; free(value); } +esp_err_t httpd_unregister_uri_handler(httpd_handle_t, const char *, httpd_method_t); +static int xSemaphoreTake(int mutex, unsigned wait) { + (void)mutex; assert(wait == 0 && !serial_locked && !host_lock_depth); + ++serial_takes; + if (serial_busy) return 0; + serial_locked = true; return pdTRUE; +} +static void xSemaphoreGive(int mutex) { (void)mutex; assert(serial_locked); serial_locked = false; } +static void increment_counter(uint64_t *counter) { ++*counter; } +esp_err_t httpd_resp_send(httpd_req_t *r, const char *body, int length) { + assert(!serial_locked && length >= 0 && length < 256 && (size_t)length == strlen(body)); + return httpd_resp_sendstr(r, body); +} +#include "settings_production.h" + +static void settings_begin(const issued_t *identity) { + begin("/api/settings/serial", HTTP_GET, NULL); + add("Host", "device.example"); /* GET may omit Origin, but Host is validated. */ + if (identity) { + char cookies[100]; + snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", identity->token); + add("Cookie", cookies); + } +} +static void settings_expect(const char *status) { + unsigned before = serial_takes; + (void)serial_settings_handler(&req); + if (strcmp(response_status, status) || serial_takes != before) + fprintf(stderr, "Settings expected %s, got %s; serial reads %u\n", status, response_status, serial_takes - before); + assert(!strcmp(response_status, status) && serial_takes == before); + assert(strlen(output) < 128 && !body_offset); + zero(scratch, sizeof(scratch)); +} +static void settings_tests(void) { + httpd_uri_t existing = {.uri = "/", .method = HTTP_GET, .handler = serial_settings_handler}; + httpd_uri_t route = {.uri = "/api/settings/serial", .method = HTTP_GET, .handler = serial_settings_handler}; + routes[0] = &existing; server.hd_calls = routes; server.config.max_uri_handlers = 2; + for (unsigned failure = 1; failure <= 2; ++failure) { + allocations = frees = 0; allocation_fail_at = failure; + assert(web_httpd_register_optional_get(&server, &route) == ESP_ERR_NO_MEM); + assert(allocations == failure && frees == failure - 1 && routes[0] == &existing && !routes[1]); + } + allocation_fail_at = 0; allocations = 0; + assert(web_httpd_register_optional_get(&server, &route) == ESP_OK && allocations == 2); + assert(routes[1] && routes[1] != &route && routes[1]->uri != route.uri); + assert(!strcmp(routes[1]->uri, route.uri) && routes[1]->handler == route.handler); + assert(web_httpd_register_optional_get(&server, &route) == ESP_ERR_INVALID_STATE); + httpd_uri_t other = route; other.uri = "/other"; + assert(web_httpd_register_optional_get(&server, &other) == ESP_ERR_NO_MEM); + assert(httpd_unregister_uri_handler(&server, route.uri, HTTP_GET) == ESP_OK && !routes[1]); + assert(routes[0] == &existing); /* Actual installed IDF frees staged allocations. */ + for (unsigned mode = 0; mode < 5; ++mode) { + other = route; char oversized[129]; memset(oversized, 'x', 128); oversized[128] = 0; + if (mode == 0) other.method = HTTP_POST; + if (mode == 1) other.is_websocket = true; + if (mode == 2) other.supported_subprotocol = "test"; + if (mode == 3) other.uri = oversized; + if (mode == 4) server.config.uri_match_fn = &existing; + assert(web_httpd_register_optional_get(&server, &other) == ESP_ERR_INVALID_ARG && !routes[1]); + server.config.uri_match_fn = NULL; + } + assert(web_httpd_register_optional_get(NULL, &route) == ESP_ERR_INVALID_ARG); + assert(web_httpd_register_optional_get(&server, NULL) == ESP_ERR_INVALID_ARG); + puts("PASS Settings registration: both allocation failures leave table intact, duplicate/full/shape bounds, installed IDF unregister frees successful ownership"); + auth_reset(); issued_t admin = mint(&alice), user = mint(&bob); + s_initialized = true; s_running = true; + s_config = (serial_config_t){.version = SERIAL_CONFIG_VERSION, .baud_rate = 230400, + .data_bits = SERIAL_CONFIG_DATA_BITS_8, .parity = SERIAL_CONFIG_PARITY_NONE, + .stop_bits = SERIAL_CONFIG_STOP_BITS_1, .flow_control = SERIAL_CONFIG_FLOW_CONTROL_RTS_CTS, + .dtr_behavior = SERIAL_CONFIG_DTR_ON_CONNECT, .rts_threshold = 96}; + serial_service_snapshot_t value; + assert(serial_service_get_snapshot(NULL) == ESP_ERR_INVALID_ARG); + s_initialized = false; memset(&value, 0xa5, sizeof(value)); + assert(serial_service_get_snapshot(&value) == ESP_ERR_INVALID_STATE); zero(&value, sizeof(value)); + s_initialized = true; serial_busy = true; + assert(serial_service_get_snapshot(&value) == ESP_ERR_TIMEOUT); zero(&value, sizeof(value)); + serial_busy = false; + assert(serial_service_get_snapshot(&value) == ESP_OK && value.running); + assert(!memcmp(&value.config, &s_config, sizeof(s_config)) && !serial_locked); + puts("PASS Settings snapshot: exact nonblocking production body, failure clearing and lock-consistent copy"); + + settings_begin(NULL); settings_expect("401 Unauthorized"); + settings_begin(&user); settings_expect("403 Forbidden"); + for (unsigned mode = 0; mode < 7; ++mode) { + settings_begin(&admin); + if (mode == 0) req.uri = "/api/settings/serial?unknown=1"; + if (mode == 1) req.content_len = aux.remaining_len = 1000000; + if (mode == 2) req.method = HTTP_POST; + if (mode == 3) add("Host", "device.example"); + if (mode == 4) add("Transfer-Encoding", "chunked"); + if (mode == 5) add("Origin", "https://elsewhere.example"); + if (mode == 6) add("Origin", "null"); + settings_expect(mode < 5 ? "400 Bad Request" : "403 Forbidden"); + } + stale_user = alice.user_id; settings_begin(&admin); settings_expect("401 Unauthorized"); stale_user = 0; + admin = mint(&alice); + db_fail = true; settings_begin(&admin); settings_expect("503 Service Unavailable"); db_fail = false; + admin = mint(&alice); + puts("PASS Settings HTTP: unauthenticated/user/stale/DB failure and query/body/method/duplicate/framing/Origin denied before serial read"); + + settings_begin(&admin); assert(serial_settings_handler(&req) == ESP_OK); + assert(!strcmp(output, "{\"running\":true,\"baud\":230400,\"data_bits\":\"8\",\"parity\":\"none\",\"stop_bits\":\"1\",\"flow\":\"rts-cts\",\"dtr\":\"on-connect\",\"rts_threshold\":96}")); + assert(aux.resp_hdrs_count == 3 && !strcmp(response_headers[0].value, "no-store")); + zero(scratch, sizeof(scratch)); + s_running = false; s_config.baud_rate = 1000000; s_config.data_bits = SERIAL_CONFIG_DATA_BITS_7; + s_config.parity = SERIAL_CONFIG_PARITY_ODD; s_config.stop_bits = SERIAL_CONFIG_STOP_BITS_2; + s_config.flow_control = SERIAL_CONFIG_FLOW_CONTROL_NONE; + s_config.dtr_behavior = SERIAL_CONFIG_DTR_ACTIVE; s_config.rts_threshold = 127; + settings_begin(&admin); assert(serial_settings_handler(&req) == ESP_OK); + assert(!strcmp(output, "{\"running\":false,\"baud\":1000000,\"data_bits\":\"7\",\"parity\":\"odd\",\"stop_bits\":\"2\",\"flow\":\"none\",\"dtr\":\"active\",\"rts_threshold\":127}")); + for (unsigned mode = 0; mode < 2; ++mode) { + settings_begin(&admin); serial_busy = mode == 0; s_initialized = mode == 0; + assert(serial_settings_handler(&req) == ESP_OK && !strcmp(response_status, "503 Service Unavailable")); + assert(!strcmp(response_headers[0].field, "Retry-After") && !strcmp(response_headers[0].value, "1")); + } + serial_busy = false; s_initialized = true; + settings_begin(&admin); send_fail = true; unsigned before = sends; + assert(serial_settings_handler(&req) == ESP_FAIL && sends == before + 1); send_fail = false; + for (unsigned limit = 0; limit < 3; ++limit) { + settings_begin(&admin); server.config.max_resp_headers = limit; before = sends; + assert(serial_settings_handler(&req) != ESP_OK && sends == before); + } + server.config.max_resp_headers = 8; + puts("PASS Settings HTTP: exact typed working values, no-store, bounded busy/unavailable, header/send failure without second response"); + web_session_store_invalidate(admin.view.id); + settings_begin(&admin); settings_expect("401 Unauthorized"); + settings_begin(&user); settings_expect("403 Forbidden"); + admin = mint(&alice); now += WEB_SESSION_STORE_LIFETIME_US; + settings_begin(&admin); settings_expect("401 Unauthorized"); + puts("PASS Settings HTTP: originating-session invalidation, unrelated user isolation and absolute expiry"); +} diff --git a/tests/web_cookie_auth/test.c b/tests/web_cookie_auth/test.c index 064a304..5980cf1 100644 --- a/tests/web_cookie_auth/test.c +++ b/tests/web_cookie_auth/test.c @@ -119,6 +119,9 @@ static void auth_reset(void) { #ifdef HOST_ADMIN #include "admin_test.c" #endif +#ifdef HOST_SETTINGS +#include "settings_test.c" +#endif int main(void) { assert(store_tests() == 0); auth_reset(); @@ -266,6 +269,9 @@ int main(void) { puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login"); #ifdef HOST_ADMIN admin_tests(); +#endif +#ifdef HOST_SETTINGS + settings_tests(); #endif return 0; } diff --git a/tests/web_session_store/serial_headers.py b/tests/web_session_store/serial_headers.py index c1b0aab..caa74df 100644 --- a/tests/web_session_store/serial_headers.py +++ b/tests/web_session_store/serial_headers.py @@ -23,8 +23,12 @@ TaskHandle_t xTaskCreateStatic(void (*)(void *), const char *, uint32_t, void *, #include #include "esp_err.h" typedef void *httpd_handle_t; +typedef int httpd_method_t; typedef struct { httpd_handle_t handle; void *sess_ctx; void (*free_ctx)(void *); int method; size_t content_len; const char *uri; void *aux; } httpd_req_t; +typedef struct { const char *uri; int method; esp_err_t (*handler)(httpd_req_t *); + void *user_ctx; bool is_websocket, handle_ws_control_frames; + const char *supported_subprotocol; } httpd_uri_t; typedef enum { HTTPD_WS_TYPE_CONTINUE, HTTPD_WS_TYPE_TEXT, HTTPD_WS_TYPE_BINARY } httpd_ws_type_t; typedef enum { HTTPD_WS_CLIENT_HTTP, HTTPD_WS_CLIENT_WEBSOCKET } httpd_ws_client_info_t; typedef struct { bool final, fragmented; httpd_ws_type_t type; unsigned char *payload; diff --git a/tests/web_ui_session/README.md b/tests/web_ui_session/README.md index 6968251..e15fa27 100644 --- a/tests/web_ui_session/README.md +++ b/tests/web_ui_session/README.md @@ -44,11 +44,15 @@ Coverage: logout; same-session restore retains both scrollbacks behind validation gating. - Undefined initial dimensions recover at unchanged bounds; failed fits never populate the cache, readiness retries stop at three, and teardown fences stale - callbacks even after restore. Sixteen Node groups total. + callbacks even after restore. +- Read-only admin Settings/Serial: 10 selection cycles preserve both sockets/IDs, + drain hidden output and block terminal input; exact eight-field/256-byte schema, + explicit refresh, error/timeout containment, late cancellation/restore/identity, + and concurrent serial reconnect without superseding admission. **21 Node groups total.** ## Integration and known gaps -This covers the Phase 8D.3 browser session behavior and 8D.6 selector. The renderer +This covers 8D.3 session behavior, the 8D.6 selector and 8D.8 Settings. The renderer still relies on its caller to authenticate resources; protected asset failures must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is implemented here. Existing 8D.5 server authorization/protocols are unchanged. @@ -57,7 +61,7 @@ These tests model DOM, timers, fetch cancellation and WebSocket events. They do not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior, actual bfcache policy, cookie expiry, server revocation, or hardware serial byte integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout. -The 8D.6 firmware build and pending target checklist are recorded separately in -`docs/phase8d6_implementation.md`. No target resource reserve is claimed. Browser secret +Prior 8D.6 signoff stands; the new Settings build and pending target checklist are in +`docs/phase8d8_implementation.md`. No target resource reserve is claimed. Browser secret references are dropped and never persisted/logged, but JavaScript cannot securely wipe engine-managed strings. diff --git a/tests/web_ui_session/browser.cjs b/tests/web_ui_session/browser.cjs index 217c436..24fc9dd 100644 --- a/tests/web_ui_session/browser.cjs +++ b/tests/web_ui_session/browser.cjs @@ -6,12 +6,14 @@ const token = 'a'.repeat(64); const json = value => new Response(JSON.stringify(value)); const session = (extra = {}) => json({username: '', role: 'user', csrf: token, expires_in: 3600, ...extra}); const ticket = () => json({ticket: 't'.repeat(32)}); +const serialSettings = (extra = {}) => ({running: true, baud: 230400, data_bits: '8', parity: 'none', + stop_bits: '1', flow: 'rts-cts', dtr: 'on-connect', rts_threshold: 96, ...extra}); const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}}); const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; }; const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); }; function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) { const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = []; - const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': []}; + const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': []}; const fits = []; let serial = 0; const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); }; @@ -55,6 +57,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) { if (next !== undefined) return typeof next === 'function' ? next(options) : next; if (url === '/api/session') return session({role}); if (url === '/api/status') return json({}); + if (url === '/api/settings/serial') return json(serialSettings()); if (url === '/api/ws-ticket') return ticket(); if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30}); throw new Error('network unavailable'); @@ -366,5 +369,94 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na } } }); + await test('Settings is admin-only, read-only, bounded and preserves both sockets, lease and hidden output', async () => { + const u = await connected(); u.click('select-settings'); u.click('refresh-settings'); await tick(); + assert.ok(!u.calls.some(c => c.url === '/api/settings/serial')); + const b = await adminBrowser(), [serial, admin] = b.sockets; + assert.ok(!b.calls.some(c => c.url === '/api/settings/serial')); + for (let i = 0; i < 10; ++i) { + b.click('select-settings'); await tick(); + assert.equal(b.nodes['serial-settings'].hidden, false); + assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true); + assert.equal(b.nodes['settings-values'].hidden, false); + assert.equal(b.nodes['setting-baud'].textContent, '230400'); + assert.equal(b.nodes['setting-dtr'].textContent, 'on-connect'); + assert.equal(b.nodes['setting-running'].textContent, 'Running'); + b.terminals.forEach(t => { assert.equal(t.options.disableStdin, true); t.input('WRONG'); }); + serial.emit('message', {data: Uint8Array.of(65).buffer}); admin.emit('message', {data: Uint8Array.of(66).buffer}); + b.click('select-serial'); b.click('select-admin'); + } + assert.equal(b.sockets.length, 2); assert.ok(!serial.closed && !admin.closed); + assert.equal(serial.sent.length, 0); assert.equal(admin.sent.length, 0); + assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8'); + assert.equal(b.nodes['release-control'].disabled, false); + assert.deepEqual(b.terminals.map(t => t.writes.length), [10, 10]); + const reads = b.calls.filter(c => c.url === '/api/settings/serial'); + assert.equal(reads.length, 10); assert.ok(reads.every(c => c.method === 'GET' && c.body === undefined)); + b.queues['/api/settings/serial'].push(json(serialSettings({running: false, baud: 110, data_bits: '7', parity: 'odd', stop_bits: '2', flow: 'none', dtr: 'inactive', rts_threshold: 1}))); + b.click('select-settings'); await tick(); assert.equal(b.nodes['setting-running'].textContent, 'Stopped'); + b.click('refresh-settings'); await tick(); assert.equal(b.nodes['setting-running'].textContent, 'Running'); + }); + await test('Settings rejects malformed/oversized schemas, contains errors, and retries only explicitly', async () => { + for (const response of [json(null), json(serialSettings({secret: 'bad'})), json(serialSettings({baud: 1000001})), + json(serialSettings({running: 1})), json(serialSettings({parity: ''})), json(serialSettings({rts_threshold: 0})), + new Response(' '.repeat(257)), new Response(Uint8Array.of(255)), failure(400), failure(403), failure(404), failure(429), failure(503)]) { + const b = await adminBrowser(); b.queues['/api/settings/serial'].push(response); + b.click('select-settings'); await tick(); + assert.equal(b.nodes['settings-values'].hidden, true); assert.equal(b.nodes['setting-baud'].textContent, ''); + assert.match(b.nodes['settings-detail'].textContent, /Refresh to retry/); + assert.ok(!b.nodes['settings-detail'].textContent.includes('SECRET')); + assert.equal(b.nodes['refresh-settings'].disabled, false); assert.ok(b.sockets.every(s => !s.closed)); + assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 1); + b.click('refresh-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false); + } + const b = await adminBrowser(); + b.queues['/api/settings/serial'].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('SECRET timeout'))))); + b.click('select-settings'); await tick(); b.click('refresh-settings'); + assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 1); + b.fire(15000); await tick(); assert.match(b.nodes['settings-detail'].textContent, /Refresh to retry/); + assert.ok(b.sockets.every(s => !s.closed)); + }); + await test('Settings cancellation fences late replies; session change, 401, logout and restore clear the view', async () => { + for (const action of ['switch', 'pagehide', 'expiry', 'logout']) { + const b = await adminBrowser(), d = deferred(); b.queues['/api/settings/serial'].push(d.promise); + b.click('select-settings'); await tick(); const call = b.calls.find(c => c.url === '/api/settings/serial'); + if (action === 'switch') b.click('select-serial'); + if (action === 'pagehide') b.emit('pagehide'); + if (action === 'expiry') b.window.sakSessionExpired(); + if (action === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); await b.click('sign-out'); } + assert.ok(call.signal.aborted); d.resolve(failure(401)); await tick(); + assert.equal(b.nodes['serial-settings'].hidden, true); assert.equal(b.nodes['setting-baud'].textContent, ''); + if (action === 'switch') { assert.deepEqual(b.redirects, []); assert.ok(b.sockets.every(s => !s.closed)); } + if (action === 'pagehide') { + b.emit('pageshow', {persisted: true}); await tick(); + assert.deepEqual(b.redirects, []); assert.equal(b.nodes['settings-values'].hidden, true); + b.click('refresh-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false); + } + } + for (const change of ['401', 'identity']) { + const b = await adminBrowser(); + if (change === '401') b.queues['/api/settings/serial'].push(failure(401)); + else b.queues['/api/session'].push(session({role: 'admin', csrf: 'b'.repeat(64)})); + b.click('select-settings'); await tick(); + assert.deepEqual(b.redirects, [change === '401' ? '/login' : '/']); + assert.ok(b.sockets.every(s => s.closed)); assert.equal(b.nodes['serial-settings'].hidden, true); + assert.equal(b.nodes['setting-baud'].textContent, ''); + } + }); + await test('Settings session validation never strands concurrent serial reconnect; newer admission fences old settings checks', async () => { + const b = await adminBrowser(), d = deferred(); + b.queues['/api/session'].push(d.promise); b.sockets[0].emit('close'); b.fire(1000); await tick(); + b.click('select-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false); + d.resolve(session({role: 'admin'})); await tick(); + assert.equal(b.sockets.length, 3); assert.ok(!b.sockets[1].closed); + const c = await adminBrowser(), old = deferred(); c.queues['/api/session'].push(old.promise); + c.click('select-settings'); await tick(); c.sockets[0].emit('close'); c.fire(1000); await tick(); + old.resolve(session({role: 'admin'})); await tick(); + assert.equal(c.sockets.length, 3); assert.ok(!c.sockets[1].closed); + assert.equal(c.nodes['refresh-settings'].disabled, false); + assert.match(c.nodes['settings-detail'].textContent, /Refresh to retry/); + c.click('refresh-settings'); await tick(); assert.equal(c.nodes['settings-values'].hidden, false); + }); console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`); })().catch(error => { console.error(error); process.exitCode = 1; });