diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 1c1c2f4..765bfc5 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -152,6 +152,10 @@ The final administrator cannot be deleted or demoted. UART0 is trusted for initi NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials, legacy recovery credentials, and TLS/SSH private keys remain recoverable under physical flash extraction. +## Typed Serial settings (8D.9) + +`web_serial_settings` admits strict bounded admin cookie/Origin/CSRF JSON into one static session-bound operation/result slot, queuing only a non-reused ID on the existing administration dispatcher. HTTPD never runs serial/NVS mutations. The dispatcher checks session/principal currentness and a 30-second dequeue deadline before canonical Apply/Start/Stop/Save/Load/Defaults/Reset APIs; already admitted work may complete after logout. A blocked dispatcher retains the slot, not a timed job cancellation. Results are login-isolated and replaceable after completion; no durable history/idempotent retry guarantee. After acknowledgement the UI checks at one-second intervals, bounded to 10 GET attempts and a 15-second overall deadline including session checks, then automatically refreshes working values for known terminal outcomes. Errors/exhaustion use manual recovery; no automatic mutation retries or navigation resumption. Settings remain visible with stale/pending labels; only Reset confirms saved-NVS overwrite. Selecting the current view is a no-op. Settings UI retains uncertain-result warnings, explicit RAM/NVS/discard explanations and both terminal sockets/lease. Two optional exact GET/POST registrations bring the URI budget to 19, with six sockets and unchanged tasks/stacks/queue depth. `/api/status` also uses the zero-wait serial snapshot and emits `running:null` when unavailable. See `docs/phase8d9_implementation.md` for resource and target-pending evidence. + ## Console architecture UART0 and admin SSH share canonical command implementations: diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 32ac227..83205ec 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -76,6 +76,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the - 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. +- 8D.9: `web_serial_settings.{c,h}` owns strict 256-byte typed mutation admission and one session-bound pending/result slot. Existing `admin_ssh_console` dispatcher consumes only an ID, revalidates currentness/dequeue deadline and calls canonical serial APIs. `web_server.c` adds optional GET/POST `/api/settings/serial-operation` (19 handlers total); `web_cookie_auth_require_json()` retains Origin/CSRF/admin policy, private optional registration supports exact GET/POST. UI adds explicit framing/lifecycle/persistence with automatic completion checks (1 s, at most 10 GETs/15 s overall), refresh on known terminal results and manual uncertainty recovery without socket/lease changes. Settings stay visible/stale while pending; only Reset confirms NVS overwrite; selecting the current view is a no-op. `/api/status` uses a consistent zero-wait serial snapshot (`running:null` when unavailable). Tests: cookie `--serial-settings` (10 groups), `--settings` (6), UI (35 after UX refinement), console boundary and lifecycle (13). Build verified, target/signoff pending; bounds and failure contracts: `docs/phase8d9_implementation.md`. Supersedes 8D.8's no-8D.9 status above. + ### Browser admin backend (8D.5) - **8D.7 current status (2026-09-07): implemented scope validated; M2 explicitly signed off by the user ("Jupp, sign M2 off").** Supersedes M2-open/target-pending/continuation statements in the historical slices below; accepted M2 does not require revalidation. User verified certificate rotation and web start/stop via UART0/SSH admin/web admin, restarting after browser stop via another route; full mix without broker drops up to 230400 baud after external adapter correction is user-reported. Intermittent supported two serial + one admin admission failures, recently not recurring, are accepted nonblocking, not fixed. Browser self/generated/key/legacy-credential and other owner command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Numeric memory reserves/stack margins remain unapproved; no full parity or individual unreported checklist passes. Next: separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation. Evidence: `docs/phase8d7_implementation.md`. diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 9e5083a..24a67a1 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,12 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.9 UX refinement (2026-09-08), host-tested/build-verified; target pending:** User reported successful Apply required awkward manual Check Result then Refresh and approved automatic flow/removing routine popups. `web_ui.c` now keeps settings visible/stale while pending, checks acknowledged operations every 1 s up to 10 GET attempts/15 s overall, then refreshes working values while preserving outcome. Errors/exhaustion/lost ack use manual recovery; no POST retry, no resume after navigation. Only Reset retains a saved-NVS overwrite confirmation; inline discard semantics remain. Current-view selection is a no-op so repeated Settings clicks cannot cancel work. Parent UI **35 groups + renderer/CSP**, `pio run` **10.91 s / 95,708 B RAM / 1,668,837 B flash**, diff check PASS; **0 / +2,112 B** vs original 8D.9. No backend/assets/device/commit action or new signoff. Details/evidence limits in `docs/phase8d9_implementation.md`; target automatic-completion, failure/manual-recovery and full-mix latency checks pending. + +- **8D.9 continuation complete (2026-09-07), implemented / reviewed / host-tested / build-verified; target/signoff pending:** Preserved inherited implementation; typed Apply/Start/Stop/Save/Load/Defaults/Reset uses one session-bound slot and the existing dispatcher queue, 256-byte JSON POST and 96-byte explicit GET results. No new tasks/stacks/queue depth or broker lease semantics; 19 URI handlers, six sockets/no LRU. Fixed persistent uncertain-result warnings and consistent zero-wait `/api/status` serial state (`running:null` when unavailable). Final parent Serial 10, Settings/status 6, UI 27/CSP, console boundary and lifecycle 13 groups pass; additional transport/tickets/store/admin/account/lifecycle regressions passed during review. Final `pio run` 23.73 s, **95,708 B RAM / 1,666,725 B flash**, **+128 / +12,196 B** vs 8D.8. Deadline is dequeue admission only; blocked dispatcher retains pending slot, admitted NVS work may finish after logout, completed results can be replaced. Exact contracts/tests/limits and pending target checklist: `docs/phase8d9_implementation.md`. No target/device/asset/commit action, reserve approval or 8D.8/8D.9 signoff. Stop before separately requested 8D.10; M2 acceptance and existing deferred restrictions/admission followups stand. + +- **8D.9 original handoff (2026-09-07; superseded by completion above):** User explicitly requests end-to-end Serial typed edits/persistence despite pending 8D.8 target validation. This is continuation authorization, not 8D.8 target signoff. Plan: verify canonical serial and concurrency contracts; implement a single bounded session-bound operation slot on the existing dispatcher with nonblocking HTTP admission/result reads; add explicit Serial controls and focused regressions; finite build, diff review and accurate resource/target handoff. No later domains/popovers/generated assets/device/commit actions. Existing modification to `docs/phase8d8_implementation.md` belongs to parent/user and is preserved. M2 acceptance, deferred restrictions, unresolved admission and unapproved memory/stack followups stand. + - **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. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index f47b3c9..1dc1321 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -102,6 +102,10 @@ Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutab **Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c` +## Typed serial mutations share the administration dispatcher + +**8D.9:** HTTPD performs bounded typed admission/result reads only; serial reconfiguration and NVS execute on the existing dispatcher so CLI commands cannot interleave. One global pending slot rejects concurrent work; copied session/principal plus non-reused ID fence stale queued work. A 30-second deadline is checked on dequeue, not a cancellation timer or execution limit. Admitted mutations may finish after revocation; completed results can be replaced. Keep explicit uncertain-outcome recovery and never automatically retry mutations. Apply/Defaults are RAM-only, Save persists working device state rather than browser drafts, and Reset follows canonical apply/persist/best-effort-rollback ordering. Navigation preserves broker clients/writer lease, while explicit serial reconfiguration can discard serial-service pending data. No generic command runner/job history is exposed. See `src/web_serial_settings.{c,h}` and `docs/phase8d9_implementation.md`. + ## 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. diff --git a/docs/phase8d8_implementation.md b/docs/phase8d8_implementation.md index 1174ec7..e6b6416 100644 --- a/docs/phase8d8_implementation.md +++ b/docs/phase8d8_implementation.md @@ -77,6 +77,10 @@ The initial Settings test incorrectly reused a session after testing authoritati `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. +### Independent Review Completion + +Parent obtained a separate agent review after implementation. No actionable findings were identified in authorization/secrecy/bounds, nonblocking snapshot locking, navigation/session/expiry races, or the optional registration adapter's ownership against installed IDF 5.5.0. Reviewer reran Settings/auth (5 Settings groups), UI (21 groups plus renderer/CSP), and server lifecycle (12 groups), all passing. This supersedes the implementer-session independent-review limitation above. No production edits or additional build were needed for review. Real browser/HTTPD scheduling, hardware and runtime reserve limits remain unverified. + ## 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. diff --git a/docs/phase8d9_implementation.md b/docs/phase8d9_implementation.md new file mode 100644 index 0000000..4b58719 --- /dev/null +++ b/docs/phase8d9_implementation.md @@ -0,0 +1,89 @@ +# Phase 8D.9 — Serial edits and persistence + +## State and scope (2026-09-07) + +Implemented, reviewed, host-tested and firmware-build verified; **target/browser validation and phase signoff remain pending**. Resumed the previous agent's uncommitted implementation at the user's request. Continuation was explicitly authorized despite pending 8D.8 target validation; it does not sign off 8D.8 or 8D.9. M2 acceptance stands. No later domains, quick popover, generated assets, upload, erase, or commit actions. + +## UX refinement (2026-09-08) + +User reported Apply succeeds but manual Check Result hides the editor without refreshing the displayed configuration. User approved automatic completion/refresh and removal of routine disruption popups. Implemented in `src/web_ui.c`, with no backend/protocol change: + +- Actions submit once, show applying/pending and keep settings visible with conflicting controls disabled and an explicit stale-snapshot message. +- After acknowledged submission, result checks wait one second initially and between pending replies, capped at **10 GET attempts and a separate 15-second overall monotonic deadline** including session revalidation. The deadline aborts an in-flight check; late callbacks are fenced. Browser timer scheduling is not a hard real-time guarantee. +- Known terminal results, including failures, automatically refresh working values while retaining outcome/uncertainty messages. Refresh failure leaves existing values visible and explicitly stale; Refresh remains available. The completion refresh has its own existing per-request bounds, outside the auto-check deadline. +- Errors/exhaustion/lost acknowledgements stop automatic checking and retain Check Result for manual recovery. Navigation/pagehide/logout/identity changes cancel checks without auto-resumption; they do not cancel backend work. No automatic POST retry. +- Apply/Save/Load/Defaults/Start/Stop no longer show confirmation dialogs. Reset alone confirms overwriting saved NVS configuration. Inline RAM/NVS/discard semantics remain. +- Review found repeated selection of the current Settings tab cancelled requests/polling; selecting the current view is now a no-op, with a regression covering submission, inter-check delay, in-flight checks and completion refresh. + +Final parent validation: `python3 tests/web_ui_session/run.py` **35 behavior groups plus renderer/header/failure/exact CSP checks**, `pio run` **PASS 10.91 s**, `git --no-pager diff --check` PASS. Build **95,708 B RAM / 1,668,837 B flash**, **0 / +2,112 B** versus the original 8D.9 build below (**+128 / +14,308 B** versus 8D.8). Static build accounting is not runtime/stack telemetry. Tests model DOM/fetch/timers/WebSockets; new flow not target-validated. No upload, erase, asset regeneration or commit. Original validation below remains historical evidence, not a claim that backend suites were rerun for this UI-only refinement. + +## Contract + +- Existing admin-only Settings/Serial now has typed baud, data bits, parity, stop bits, flow control, DTR and RTS-threshold drafts, plus explicit Apply, Start, Stop, Save, Load, Defaults and Reset controls. Server authorization, not UI visibility, enforces admin access. +- `POST /api/settings/serial-operation` requires current cookie/principal, admin role, canonical Origin and CSRF. JSON is at most 256 bytes and four receive calls, with exact action-only or complete Apply schema; unknown/duplicate fields, escapes, nesting, invalid framing and out-of-range values reject. Failed unread bodies close rather than contaminate the next request. +- HTTPD admits only an operation ID to the existing four-entry administration queue with zero wait. `web_serial_settings` owns one static session-bound pending/result slot; the existing 12 KiB dispatcher executes typed serial APIs, never CLI text or HTTPD-owned socket operations. Queue/slot exhaustion returns 503 with Retry-After; no new task or generic job framework. +- Execution checks originating cookie/principal/admin currentness and the 30-second queue-admission deadline before side effects. This deadline is checked on dequeue: a blocked dispatcher retains the slot, and the deadline is neither an execution timeout nor a slot-release timer. Work already admitted to serial/NVS APIs may finish after logout/revocation. IDs do not wrap/reuse; stale queued IDs cannot execute replacement work. +- Bodyless `GET /api/settings/serial-operation` exposes only the current login's retained `{id, action, state}` (96-byte response buffer). States are idle, pending, ok, failed, cancelled, loaded_defaults and rollback_failed. A subsequent admitted operation, including another session's, can replace a completed result. This is neither durable history nor an idempotent retry API. +- Result checking is single-flight and automatically bounded after acknowledgement, with manual recovery as detailed above; mutations are never automatically retried. Lost acknowledgements and replaced results retain uncertainty warnings across further reads, Refresh and navigation. Cancellation/navigation is not a promise that admitted work stopped. Inspect current working settings and CLI storage before deciding to retry. +- Settings navigation preserves both terminal sockets, hidden output, broker client and writer lease. The operation itself may stop/reconfigure UART1 and discard serial-service RX/TX/task-pending bytes; already-fanned broker output and writer ownership remain unchanged. Open USB can restart a stopped service. + +### Working versus persisted state + +| Action | Canonical behavior | +|---|---| +| Apply | Validate and replace all working fields; running service stop/restart and best-effort rollback use existing service API. Overwrites intervening CLI edits. No NVS write. | +| Start / Stop | Existing service lifecycle APIs; no persistence or broker lease change. | +| Save | Persist device working configuration at execution, **not the browser draft**. | +| Load | Load and apply stored config; absent/incompatible storage applies defaults and reports `loaded_defaults`, without writing NVS. | +| Defaults | Apply defaults to RAM only. | +| Reset | Capture previous working config, apply defaults, persist defaults through canonical reset API; on persistence failure attempt runtime rollback, reporting rollback failure distinctly. | + +Refresh replaces the browser draft. Snapshots do not expose a saved-config copy or claim a dirty/persisted comparison. There is no compare-and-swap protection against intervening CLI edits. + +## Integration and continuation fixes + +- `src/web_serial_settings.{c,h}`: parser, bounded operation slot, typed execution, result protocol. +- `src/admin_ssh_console.{c,h}`: typed ID in existing queue union and dispatcher; no console slot consumed. +- `src/web_cookie_auth.{c,h}`: bounded-JSON mutation authorization while preserving bodyless policies for existing endpoints. +- `src/web_httpd_adapter.{c,h}`: existing failure-safe optional registration generalized to exact GET/POST. `src/web_server.c` registers result GET first and mutation POST second; partial failure cannot leave a mutation-only endpoint. Optional failure preserves both transports. SDK-private boundary remains pinned to IDF 5.5.0; inherited non-Settings registration audit remains open. +- `src/web_ui.c`: explicit typed controls, Reset-only confirmation, bounded automatic completion/refresh and manual result recovery and session/navigation fencing. Review fixed uncertainty warnings disappearing after later result checks/Refresh/navigation. +- `/api/status` now uses the zero-wait serial snapshot rather than blocking config acquisition. Review fixed a separate live running read mixing moments with the snapshot. When unavailable, `running` is JSON `null` and `config_available` is false; consumers must not interpret that as an authoritative stopped state. +- Backend review found no further confirmed runtime defects; added parser/security/queue/currentness/race regressions and documented deadline/result limitations. Existing parent/user edits in `docs/phase8d8_implementation.md` were preserved. + +## Validation performed + +Final parent sequential run, all passed: + +```sh +python3 tests/web_cookie_auth/run.py --serial-settings +python3 tests/web_cookie_auth/run.py --settings +python3 tests/web_ui_session/run.py +python3 tests/admin_console_boundary/run.py +python3 tests/web_admin_transport/server_lifecycle.py +pio run +git --no-pager diff --check +``` + +- Serial: 10 groups plus shared auth/store regressions, including 288 valid framing/range combinations, 256-byte/four-read boundaries, strict rejection, no HTTPD serial/NVS execution, session isolation, stale IDs, simulated concurrent reads/submissions, canonical CLI/API ordering, failures/reset rollback, account revocation and cookie expiry before the queue deadline. +- Settings/status: 6 groups plus shared regressions. Status serial projection compiles production acquisition/format/arguments, verifies consistent snapshot across changed live state and null on unavailability; it is not the full multi-subsystem handler. +- UI: 27 behavior groups plus production C renderer/header/failure and exact loader CSP hash checks. DOM/fetch/WebSockets are modeled, not on-wire integration. +- Console boundary: FIFO/zero-wait typed admission, full queue preserves all four UART requests; existing console/certificate/SSH-adapter regressions. +- Server: 13 lifecycle groups, including optional GET/POST failure and recovery, six sockets/no LRU. + +Additional passing runs during continuation: parent transport `run.py --tickets` (25 transport/12 ticket groups), session store `run.py --serial`; backend review agent cookie `--admin`, console `accounts.py` and `lifecycle.py`. These use deterministic RTOS/UART/NVS/transport doubles, not real hardware scheduling, flash failures or rollback. No sanitizer run or target pass claimed. + +### Resource accounting + +Final `pio run`: **23.73 s; 95,708 B RAM / 1,666,725 B flash**. Delta against recorded 8D.8: **+128 B RAM / +12,196 B flash**; against 8D.0: **+1,176 / +66,752 B**. Build totals are static/linker accounting, not runtime free heap or stack margins. + +Two additional optional handlers (GET and POST at one path) raise the configured URI budget from 17 to **19**. Six HTTPS sockets/no LRU, two serial sockets/one admin socket, existing queue depth, tasks and stack allocations are unchanged. Additional route descriptors/names/table pointers consume runtime heap beyond static RAM; exact allocator overhead and handler/dispatcher high-water marks have not been measured. The bounded request/result buffers are 256/96 bytes. No generated asset changes; loader hash remains unchanged and verified. Numeric memory reserves/stack margins remain unapproved. + +## Pending target checklist / next step + +1. Flash only by explicit user action/authorization; compare browser Refresh with UART0 serial config/status and confirm ordinary users cannot access settings/mutations. +2. Compare every browser action with CLI semantics, valid framing boundaries and invalid requests. Verify Apply/Defaults versus Save/Load/Reset across reboot; confirm explicit Refresh draft replacement and concurrent CLI overwrite warning. +3. Exercise start/stop/reconfigure under USB + SSH + both browser serial observers + browser/SSH admin. Confirm expected serial-service discards, unchanged broker writer/client identity, isolated output and USB restart behavior; do not mistake expected discards for a regression. +4. Hold dispatcher in a prompt, submit once, observe bounded busy behavior and dequeue deadline cancellation. Test logout/account revocation while queued, expiry, navigation, lost acknowledgement, replacement by another tab/login, and manual uncertain-result recovery without blind resubmission. +5. Where safely injectable, verify NVS/load/apply/rollback failures and physical port fault behavior. Host doubles are not evidence of hardware rollback success. +6. Capture settled boot/full-mix/post-cleanup internal/DMA/PSRAM free/min/largest and dispatcher/HTTPD stack high-water data; test slow/fragmented body requests alongside serial traffic. Existing intermittent supported-mix admission issue remains accepted nonblocking and unresolved. +7. Obtain explicit 8D.8/8D.9 target acceptance as appropriate. Stop before separately requested 8D.10. M2 is not reopened, deferred browser owner/credential restrictions remain, and no memory reserve approval is inferred. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index 75a4cd3..ad2165a 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -1,6 +1,8 @@ # 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. +**Latest continuation (2026-09-07):** Separately authorized **8D.9 is implemented / reviewed / host-tested / build-verified**, with 8D.8/8D.9 target acceptance still pending. [8D.9 record](phase8d9_implementation.md): typed Serial framing/lifecycle/persistence, one session-bound operation slot on the existing dispatcher, manual bounded result recovery, 19 handlers/six sockets; final **95,708 B RAM / 1,666,725 B flash**. No new task/queue depth/stack or broker writer semantics. This supersedes older stop-before-8D.9 instructions, not M2 signoff or deferred restrictions, admission followups or unapproved reserves. Stop before separately requested 8D.10; no target signoff inferred. + +**Previous 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). diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2e53c5b..b868c7b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -31,6 +31,7 @@ idf_component_register( "user_console.c" "web_security.c" "web_serial_transport.c" + "web_serial_settings.c" "web_admin_tickets.c" "web_admin_transport.c" "web_assets_data.c" diff --git a/src/admin_ssh_console.c b/src/admin_ssh_console.c index 678a24c..2b8b4a1 100644 --- a/src/admin_ssh_console.c +++ b/src/admin_ssh_console.c @@ -16,6 +16,7 @@ #include "linenoise/linenoise.h" #include "secure_random.h" #include "user_database.h" +#include "web_serial_settings.h" #define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U #define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U @@ -81,6 +82,7 @@ typedef enum { ADMIN_REQUEST_SSH = 0, ADMIN_REQUEST_UART0, ADMIN_REQUEST_DEFERRED, + ADMIN_REQUEST_SERIAL_SETTINGS, } admin_request_origin_t; typedef struct { @@ -91,6 +93,7 @@ typedef struct { union { uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; admin_control_request_t deferred; + uint32_t serial_settings_id; }; } admin_request_t; @@ -646,6 +649,16 @@ static void dispatch_registered_command(admin_request_t *request) static void dispatch_deferred_request(admin_request_t *request); +esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id) +{ + taskENTER_CRITICAL(&s_lock); + bool ready = s_dispatch_ready; + taskEXIT_CRITICAL(&s_lock); + if (!ready || !id) return ESP_ERR_INVALID_STATE; + admin_request_t request = {.origin = ADMIN_REQUEST_SERIAL_SETTINGS, .serial_settings_id = id}; + return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; +} + static void worker_task(void *context) { (void)context; @@ -654,6 +667,11 @@ static void worker_task(void *context) if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) { continue; } + if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) { + web_serial_settings_execute(request.serial_settings_id); + secure_wipe(&request, sizeof(request)); + continue; + } if (request.origin == ADMIN_REQUEST_DEFERRED) { dispatch_deferred_request(&request); secure_wipe(&request, sizeof(request)); diff --git a/src/admin_ssh_console.h b/src/admin_ssh_console.h index 0ee5dfa..9715f20 100644 --- a/src/admin_ssh_console.h +++ b/src/admin_ssh_console.h @@ -14,6 +14,9 @@ extern "C" { #endif +/* Nonblocking typed Serial-settings admission to the canonical dispatcher. */ +esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id); + /* Fits the longest supported ECDSA P-256 OpenSSH key import command. */ #define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U diff --git a/src/web_cookie_auth.c b/src/web_cookie_auth.c index 45e4711..42e4986 100644 --- a/src/web_cookie_auth.c +++ b/src/web_cookie_auth.c @@ -173,14 +173,14 @@ void web_cookie_auth_clear_counters(void) taskEXIT_CRITICAL(&s_lock); } -esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade, - web_session_view_t *view, bool *allowed) +static esp_err_t require(httpd_req_t *r, bool mutation, bool upgrade, size_t body_limit, + web_session_view_t *view, bool *allowed) { char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0}; *allowed = false; memset(view, 0, sizeof(*view)); if (!web_httpd_headers_valid(r) || !cookies_valid(r) || (!upgrade && strchr(r->uri, '?')) || - r->content_len || r->method != (mutation ? HTTP_POST : HTTP_GET)) + r->content_len > body_limit || r->method != (mutation ? HTTP_POST : HTTP_GET)) return failure(r, "400 Bad Request", "invalid_request"); if (!origin(r, mutation || upgrade, canonical)) return failure(r, "403 Forbidden", "origin"); @@ -212,6 +212,18 @@ esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade, return ESP_OK; } +esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade, + web_session_view_t *view, bool *allowed) +{ + return require(r, mutation, upgrade, 0, view, allowed); +} + +esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit, + web_session_view_t *view, bool *allowed) +{ + return require(r, true, false, body_limit, view, allowed); +} + static bool secret(char out[65]) { uint8_t bytes[32]; diff --git a/src/web_cookie_auth.h b/src/web_cookie_auth.h index e6e0ef2..fce8551 100644 --- a/src/web_cookie_auth.h +++ b/src/web_cookie_auth.h @@ -17,3 +17,6 @@ esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation, bool upgrade, web_session_view_t *view, bool *allowed); esp_err_t web_cookie_auth_handler(httpd_req_t *request); +/* Same mutation policy, allowing a bounded body; caller validates JSON/content type. */ +esp_err_t web_cookie_auth_require_json(httpd_req_t *request, size_t body_limit, + web_session_view_t *view, bool *allowed); diff --git a/src/web_httpd_adapter.c b/src/web_httpd_adapter.c index 54f8872..43ef388 100644 --- a/src/web_httpd_adapter.c +++ b/src/web_httpd_adapter.c @@ -106,10 +106,11 @@ bool web_httpd_unread_body(httpd_req_t *request) return aux && aux->remaining_len != 0; } -esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri) +esp_err_t web_httpd_register_optional(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 || + if (!hd || !uri || !uri->uri || !uri->handler || + (uri->method != HTTP_GET && uri->method != HTTP_POST) || uri->is_websocket || uri->supported_subprotocol || hd->config.uri_match_fn) return ESP_ERR_INVALID_ARG; size_t length = 0; @@ -118,7 +119,7 @@ esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri 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) + else if (!strcmp(hd->hd_calls[i]->uri, uri->uri) && hd->hd_calls[i]->method == uri->method) return ESP_ERR_INVALID_STATE; } if (slot < 0) return ESP_ERR_NO_MEM; @@ -134,3 +135,9 @@ esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri hd->hd_calls[slot] = copy; return ESP_OK; } + +esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri) +{ + if (!uri || uri->method != HTTP_GET) return ESP_ERR_INVALID_ARG; + return web_httpd_register_optional(server, uri); +} diff --git a/src/web_httpd_adapter.h b/src/web_httpd_adapter.h index d8afa8e..04b88dd 100644 --- a/src/web_httpd_adapter.h +++ b/src/web_httpd_adapter.h @@ -16,3 +16,5 @@ esp_err_t web_httpd_upgrade(httpd_req_t *request, /* 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); +/* Same staged startup ownership for ordinary exact GET or POST. */ +esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri); diff --git a/src/web_serial_settings.c b/src/web_serial_settings.c new file mode 100644 index 0000000..98b6700 --- /dev/null +++ b/src/web_serial_settings.c @@ -0,0 +1,240 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "web_serial_settings.h" + +#include +#include +#include +#include "admin_ssh_console.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "secure_random.h" +#include "serial_service.h" +#include "web_cookie_auth.h" +#include "web_httpd_adapter.h" + +enum { APPLY, START, STOP, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT }; +static const char *const s_actions[] = {"apply", "start", "stop", "save", "load", "defaults", "reset"}; +enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, ROLLBACK_FAILED }; +static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "rollback_failed"}; +typedef struct { + uint32_t id; + web_session_id_t session; + user_principal_t principal; + int64_t deadline; + serial_config_t config; + unsigned action, state; +} serial_operation_t; +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static serial_operation_t s_operation; +static uint32_t s_next_id; + +/* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers, + * no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */ +static bool parse(const char *body, size_t length, serial_operation_t *operation) +{ + const char *keys[] = {"action", "baud", "data_bits", "parity", "stop_bits", "flow", "dtr", "rts_threshold"}; + unsigned seen = 0; + size_t pos = 0; + serial_config_defaults(&operation->config); + operation->action = ACTION_COUNT; +#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos +#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0) + TAKE('{'); + for (unsigned field = 0; field < 8; ++field) { + if (field) { TAKE(','); } + TAKE('"'); + size_t start = pos; + while (pos < length && body[pos] != '"') ++pos; + if (pos == length) return false; + unsigned key = 0; + for (; key < 8; ++key) + if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break; + if (key == 8 || (seen & (1U << key))) return false; + ++pos; TAKE(':'); SPACE(); + uint32_t number = 0; + char value[16] = {0}; + if (key == 1 || key == 7) { + start = pos; + while (pos < length && body[pos] >= '0' && body[pos] <= '9') { + if (number > 1000000U) return false; + number = number * 10 + (unsigned)(body[pos++] - '0'); + } + if (pos == start || (pos - start > 1 && body[start] == '0')) return false; + } else { + TAKE('"'); start = pos; + while (pos < length && body[pos] != '"') { + if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false; + ++pos; + } + if (pos == length) return false; + memcpy(value, body + start, pos - start); ++pos; + } + switch (key) { + case 0: + for (unsigned i = 0; i < ACTION_COUNT; ++i) + if (!strcmp(value, s_actions[i])) operation->action = i; + if (operation->action == ACTION_COUNT) return false; + break; + case 1: operation->config.baud_rate = number; break; + case 2: if (!serial_config_parse_data_bits(value, &operation->config.data_bits)) return false; break; + case 3: if (!serial_config_parse_parity(value, &operation->config.parity)) return false; break; + case 4: if (!serial_config_parse_stop_bits(value, &operation->config.stop_bits)) return false; break; + case 5: if (!serial_config_parse_flow_control(value, &operation->config.flow_control)) return false; break; + case 6: if (!serial_config_parse_dtr_behavior(value, &operation->config.dtr_behavior)) return false; break; + case 7: operation->config.rts_threshold = number; break; + } + seen |= 1U << key; + SPACE(); + if (pos < length && body[pos] == '}') break; + } + TAKE('}'); SPACE(); +#undef TAKE +#undef SPACE + return pos == length && seen == (operation->action == APPLY ? 255U : 1U) && + serial_config_validate(&operation->config) == ESP_OK; +} + +void web_serial_settings_execute(uint32_t id) +{ + serial_operation_t operation; + taskENTER_CRITICAL(&s_lock); + operation = s_operation; + taskEXIT_CRITICAL(&s_lock); + if (!id || operation.id != id || operation.state != PENDING) { + secure_wipe(&operation, sizeof(operation)); + return; + } + bool current = false; + esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t); + unsigned state = CANCELLED; + if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN && + esp_timer_get_time() < operation.deadline) { + /* Operation-admission currentness, not cancellation of an admitted NVS + * commit. CLI commands cannot interleave on this single dispatcher. */ + serial_config_t config, previous; + bool stored = true; + state = OK; + switch (operation.action) { + case APPLY: error = serial_service_apply_config(&operation.config); break; + case START: error = serial_service_start(); break; + case STOP: error = serial_service_stop(); break; + case SAVE: + error = serial_service_get_config(&config); + if (error == ESP_OK) error = serial_config_save(&config); + break; + case LOAD: + error = serial_config_load(&config, &stored); + if (error == ESP_OK) error = serial_service_apply_config(&config); + if (!stored) state = LOADED_DEFAULTS; + break; + case DEFAULTS: + serial_config_defaults(&config); + error = serial_service_apply_config(&config); + break; + case RESET: + serial_config_defaults(&config); + error = serial_service_get_config(&previous); + if (error == ESP_OK) error = serial_service_apply_config(&config); + if (error == ESP_OK) { + error = serial_config_reset_storage(); + if (error != ESP_OK && serial_service_apply_config(&previous) != ESP_OK) + state = ROLLBACK_FAILED; + } + break; + default: error = ESP_ERR_INVALID_ARG; break; + } + if (error != ESP_OK && state != ROLLBACK_FAILED) state = FAILED; + } + taskENTER_CRITICAL(&s_lock); + if (s_operation.id == id && s_operation.state == PENDING) { + s_operation.state = state; + secure_wipe(&s_operation.principal, sizeof(s_operation.principal)); + secure_wipe(&s_operation.config, sizeof(s_operation.config)); + } + taskEXIT_CRITICAL(&s_lock); + secure_wipe(&operation, sizeof(operation)); +} + +static esp_err_t respond(httpd_req_t *request, const char *status, const char *body) +{ + esp_err_t error = httpd_resp_set_status(request, status); + if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); + if (error == ESP_OK) error = httpd_resp_sendstr(request, body); + return web_httpd_unread_body(request) ? ESP_FAIL : error; +} + +esp_err_t web_serial_settings_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; + bool allowed = false; + bool mutation = request->method == HTTP_POST; + esp_err_t error = mutation + ? web_cookie_auth_require_json(request, 256, &view, &allowed) + : web_cookie_auth_require(request, false, false, &view, &allowed); + if (error != ESP_OK || !allowed) goto done; + if (view.principal.role != USER_ROLE_ADMIN) { + error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); + goto done; + } + serial_operation_t operation = {0}; + if (mutation) { + char type[40] = {0}, body[256]; + size_t received = 0; + bool valid = request->content_len && + httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK && + (!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8")); + /* Finite bytes and receive calls; timeout/error closes, never retry/drain. */ + for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) { + int count = httpd_req_recv(request, body + received, request->content_len - received); + if (count <= 0 || (size_t)count > request->content_len - received) valid = false; + else received += (size_t)count; + } + valid = valid && received == request->content_len && parse(body, received, &operation); + secure_wipe(body, sizeof(body)); + if (!valid) { + error = respond(request, "400 Bad Request", "{\"error\":\"invalid_serial_request\"}"); + goto done; + } + operation.session = view.id; + operation.principal = view.principal; + operation.deadline = esp_timer_get_time() + 30000000LL; + operation.state = PENDING; + taskENTER_CRITICAL(&s_lock); + bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX; + if (!busy) { + operation.id = ++s_next_id; + s_operation = operation; + } + taskEXIT_CRITICAL(&s_lock); + if (busy || admin_ssh_console_submit_serial_settings(operation.id) != ESP_OK) { + taskENTER_CRITICAL(&s_lock); + if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation)); + taskEXIT_CRITICAL(&s_lock); + error = httpd_resp_set_hdr(request, "Retry-After", "1"); + if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}"); + secure_wipe(&operation, sizeof(operation)); + goto done; + } + } else { + taskENTER_CRITICAL(&s_lock); + if (s_operation.session == view.id) { + operation.id = s_operation.id; + operation.action = s_operation.action; + operation.state = s_operation.state; + } + taskEXIT_CRITICAL(&s_lock); + } + char response[96]; + int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}", + operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]); + error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : + respond(request, mutation ? "202 Accepted" : "200 OK", response); + secure_wipe(&operation, sizeof(operation)); +done: + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} diff --git a/src/web_serial_settings.h b/src/web_serial_settings.h new file mode 100644 index 0000000..8ac12c6 --- /dev/null +++ b/src/web_serial_settings.h @@ -0,0 +1,14 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once +#include +#include "esp_http_server.h" + +/* HTTPD owns requests/responses; the existing admin dispatcher alone executes. + * One global slot rejects mutations while pending (including execution). GET + * exposes only the caller's session result; a later admitted operation replaces + * that result, so this is not a durable history or an idempotent retry API. + * The 30-second deadline is checked when dequeued, not a completion deadline or + * a timer that frees the slot. Revocation/expiry cancels before operation + * admission; admitted serial/NVS work may finish after the session is gone. */ +esp_err_t web_serial_settings_handler(httpd_req_t *request); +void web_serial_settings_execute(uint32_t id); diff --git a/src/web_server.c b/src/web_server.c index 4f854b7..325bdf2 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -23,6 +23,7 @@ #include "user_database.h" #include "web_security.h" #include "web_serial_transport.h" +#include "web_serial_settings.h" #include "web_admin_transport.h" #include "web_session_store.h" #include "web_cookie_auth.h" @@ -223,7 +224,7 @@ static esp_err_t status_handler(httpd_req_t *request) increment_counter(&s_counters.status_requests); wifi_manager_snapshot_t wifi = {0}; - serial_config_t serial_config = {0}; + serial_service_snapshot_t serial_snapshot = {0}; serial_service_counters_t serial_counters = {0}; session_broker_global_snapshot_t broker = {0}; usb_cdc_transport_snapshot_t usb = {0}; @@ -235,7 +236,8 @@ static esp_err_t status_handler(httpd_req_t *request) char response[WEB_SERVER_STATUS_JSON_CAPACITY]; bool wifi_available = wifi_manager_get_snapshot(&wifi) == ESP_OK; - bool serial_config_available = serial_service_get_config(&serial_config) == ESP_OK; + bool serial_config_available = serial_service_get_snapshot(&serial_snapshot) == ESP_OK; + serial_config_t serial_config = serial_snapshot.config; serial_service_get_counters(&serial_counters); bool broker_available = session_broker_get_global_snapshot(&broker) == ESP_OK; bool usb_available = usb_cdc_transport_get_snapshot(&usb) == ESP_OK; @@ -279,7 +281,7 @@ static esp_err_t status_handler(httpd_req_t *request) wifi_available ? (unsigned int)wifi.sta_channel : 0U, wifi_available && wifi.ap_running ? "true" : "false", wifi_available ? (unsigned int)wifi.ap_client_count : 0U, - serial_service_is_running() ? "true" : "false", + serial_config_available ? (serial_snapshot.running ? "true" : "false") : "null", serial_config_available ? "true" : "false", serial_config_available ? serial_config.baud_rate : 0U, serial_config_available ? safe_string(serial_config_data_bits_to_string(serial_config.data_bits)) : "unknown", @@ -375,6 +377,17 @@ static const httpd_uri_t s_serial_settings_uri = { .handler = serial_settings_handler, }; +static const httpd_uri_t s_serial_operation_get_uri = { + .uri = "/api/settings/serial-operation", + .method = HTTP_GET, + .handler = web_serial_settings_handler, +}; +static const httpd_uri_t s_serial_operation_post_uri = { + .uri = "/api/settings/serial-operation", + .method = HTTP_POST, + .handler = web_serial_settings_handler, +}; + static const httpd_uri_t s_root_uri = { .uri = "/", .method = HTTP_GET, @@ -563,7 +576,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]) + 3U; + sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 5U; /* Exhaustion rejects new sockets, never evicts an existing serial writer. */ config.httpd.lru_purge_enable = false; config.httpd.recv_wait_timeout = 1; @@ -611,6 +624,9 @@ esp_err_t web_server_start(void) 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 (web_httpd_register_optional(server, &s_serial_operation_get_uri) == ESP_OK && + web_httpd_register_optional(server, &s_serial_operation_post_uri) != ESP_OK) + (void)httpd_unregister_uri_handler(server, s_serial_operation_get_uri.uri, HTTP_GET); } if (error != ESP_OK) { web_cookie_auth_stop(); diff --git a/src/web_ui.c b/src/web_ui.c index 5502882..53ffed3 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -83,6 +83,9 @@ static const char s_index_html[] = ".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" + ".serial-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-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}\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}" @@ -175,7 +178,7 @@ static const char s_index_html[] = "
\n" "\n" "\n" + "" + "" + "" + "

Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.

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