Add Typed Display Settings Administration
Implements admin-only Display settings with generation-checked Apply, Save, Load, Defaults, and Reset operations across the web UI, CLI, SSH dispatcher, and local UI owner. Adds bounded HTTP handling, session-isolated operation results, browser lifecycle support, and comprehensive host tests and documentation.
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
/* Production HTTP/store/owner/config/CLI; deterministic storage and scheduling. */
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
#include "local_status_ui.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#define ESP_ERR_TIMEOUT 0x107
|
||||
#include "../../src/local_ui_config.c"
|
||||
static portMUX_TYPE s_timing_mux;
|
||||
static local_ui_config_t s_config;
|
||||
static bool s_config_available, s_config_busy, s_diagnostic_hold_active;
|
||||
static uint32_t s_config_generation, s_external_activity_sequence, s_diagnostic_hold_started;
|
||||
static void *s_diagnostic_gate;
|
||||
#define portENTER_CRITICAL taskENTER_CRITICAL
|
||||
#define portEXIT_CRITICAL taskEXIT_CRITICAL
|
||||
#define xTaskGetTickCount() 42U
|
||||
#define pdMS_TO_TICKS(v) (v)
|
||||
#define xSemaphoreTake(a,b) ((void)(a), (void)(b), 1)
|
||||
#define xSemaphoreGive(a) ((void)(a), 1)
|
||||
#define pdTRUE 1
|
||||
#include "display_owner_production.h"
|
||||
#include "../../src/web_display_settings.c"
|
||||
static int show_status(void) { return 0; }
|
||||
const char *esp_err_to_name(esp_err_t e) { (void)e; return "test error"; }
|
||||
#include "display_console_production.h"
|
||||
|
||||
static bool on_dispatcher, have_stored, queue_fail;
|
||||
static local_ui_config_t persisted, staged;
|
||||
static esp_err_t storage_error;
|
||||
static unsigned storage_calls, storage_fail_at, storage_step;
|
||||
static esp_err_t storage_result(void) { return ++storage_step == storage_fail_at ? ESP_FAIL : ESP_OK; }
|
||||
static uint32_t queued_id;
|
||||
static void (*storage_hook)(void);
|
||||
esp_err_t nvs_flash_init(void) {
|
||||
assert(on_dispatcher && !host_lock_depth); ++storage_calls;
|
||||
if (storage_hook) { void (*h)(void) = storage_hook; storage_hook = NULL; h(); }
|
||||
storage_step = 0;
|
||||
return storage_error == ESP_OK ? storage_result() : storage_error;
|
||||
}
|
||||
esp_err_t nvs_open(const char *ns, int mode, nvs_handle_t *h) {
|
||||
assert(!strcmp(ns, LOCAL_UI_CONFIG_NVS_NAMESPACE)); *h = 1;
|
||||
if (storage_result() != ESP_OK) return ESP_FAIL;
|
||||
return !have_stored && mode == NVS_READONLY ? ESP_ERR_NVS_NOT_FOUND : ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_get_blob(nvs_handle_t h, const char *key, void *out, size_t *size) {
|
||||
assert(h == 1 && !strcmp(key, LOCAL_UI_CONFIG_NVS_BLOB_KEY));
|
||||
if (storage_result() != ESP_OK) return ESP_FAIL;
|
||||
if (out) memcpy(out, &persisted, sizeof(persisted));
|
||||
*size = sizeof(persisted); return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_set_blob(nvs_handle_t h, const char *key, const void *in, size_t size) {
|
||||
assert(h == 1 && !strcmp(key, LOCAL_UI_CONFIG_NVS_BLOB_KEY) && size == sizeof(staged));
|
||||
if (storage_result() != ESP_OK) return ESP_FAIL;
|
||||
memcpy(&staged, in, size); return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_commit(nvs_handle_t h) { assert(h == 1); if (storage_result() != ESP_OK) return ESP_FAIL; persisted = staged; have_stored = true; return ESP_OK; }
|
||||
void nvs_close(nvs_handle_t h) { assert(h == 1); }
|
||||
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id) {
|
||||
assert(id && !on_dispatcher && !host_lock_depth);
|
||||
if (queue_fail) return ESP_ERR_TIMEOUT;
|
||||
queued_id = id; return ESP_OK;
|
||||
}
|
||||
static void operation_begin(const issued_t *identity, const char *body) {
|
||||
begin("/api/settings/display-operation", body ? HTTP_POST : HTTP_GET, body);
|
||||
same_origin(); if (body) add("Content-Type", "application/json");
|
||||
if (identity) {
|
||||
char cookie[100]; snprintf(cookie, sizeof(cookie), "__Host-sak-session=%s", identity->token);
|
||||
add("Cookie", cookie); if (body) add("X-CSRF-Token", identity->view.csrf);
|
||||
}
|
||||
}
|
||||
static void display_expect(const char *status, bool snapshot) {
|
||||
unsigned before = storage_calls;
|
||||
esp_err_t e = snapshot ? web_display_settings_handler(&req) : web_display_operation_handler(&req);
|
||||
assert(e == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
if (strcmp(response_status, status)) fprintf(stderr, "expected %s got %s: %s\n", status, response_status, output);
|
||||
assert(!strcmp(response_status, status) && storage_calls == before);
|
||||
assert(strlen(output) < 128); zero(scratch, sizeof(scratch));
|
||||
}
|
||||
static void execute(void) { on_dispatcher = true; web_display_settings_execute(queued_id); on_dispatcher = false; }
|
||||
static void submit(const issued_t *who, const char *body) {
|
||||
operation_begin(who, body); display_expect("202 Accepted", false); assert(s_operation.state == PENDING);
|
||||
}
|
||||
static void action(const issued_t *who, const char *name) {
|
||||
char body[96]; snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":%u}", name, s_config_generation);
|
||||
submit(who, body); execute();
|
||||
}
|
||||
static void concurrent(void) {
|
||||
local_ui_config_t config; uint32_t generation;
|
||||
assert(s_config_busy && local_status_ui_get_settings(&config, &generation) == ESP_ERR_TIMEOUT);
|
||||
assert(local_status_ui_apply_config(&s_config) == ESP_ERR_TIMEOUT);
|
||||
assert(local_status_ui_update_settings(LOCAL_UI_SETTINGS_RESET, 0, NULL, NULL) == ESP_ERR_TIMEOUT);
|
||||
uint32_t activity = s_external_activity_sequence, original = s_config_generation;
|
||||
local_status_ui_hold_for_diagnostics();
|
||||
assert(s_external_activity_sequence == activity + 1 && s_config_generation == original);
|
||||
}
|
||||
static void revoke(void) { web_session_store_invalidate(s_operation.session); }
|
||||
static void display_settings_tests(void) {
|
||||
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
|
||||
local_ui_config_defaults(&s_config); s_config_available = true; s_config_generation = 1; receive_fragment = 64;
|
||||
const char *apply = "{\"action\":\"apply\",\"generation\":1,\"dim_seconds\":0,\"off_seconds\":86400}";
|
||||
operation_begin(NULL, apply); display_expect("401 Unauthorized", false);
|
||||
operation_begin(&user, apply); display_expect("403 Forbidden", false);
|
||||
operation_begin(&user, NULL); display_expect("403 Forbidden", false);
|
||||
for (unsigned mode = 0; mode < 8; ++mode) {
|
||||
operation_begin(&admin, apply);
|
||||
if (mode == 0) req.content_len = aux.remaining_len = 257;
|
||||
if (mode == 1) req.uri = "/api/settings/display-operation?x=1";
|
||||
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) add("Sec-Fetch-Site", "cross-site");
|
||||
(void)web_display_operation_handler(&req);
|
||||
assert(response_status[0] == '4' && !s_next_id && !storage_calls);
|
||||
}
|
||||
puts("PASS Display HTTP security: admin/cookie/Origin/CSRF, duplicates, query/body/framing bounds");
|
||||
const char *invalid[] = {"{}", "[]", "{\"action\":\"save\"}", "{\"action\":\"save\",\"generation\":0}",
|
||||
"{\"action\":\"save\",\"generation\":1,\"dim_seconds\":2}", "{\"action\":\"save\",\"generation\":01}",
|
||||
"{\"action\":\"save\",\"generation\":1e2}", "{\"action\":\"save\",\"generation\":1.0}",
|
||||
"{\"action\":\"save\",\"generation\":-1}", "{\"action\":\"save\",\"generation\":4294967296}",
|
||||
"{\"action\":\"save\",\"generation\":1,\"generation\":1}", "{\"action\":\"sa\\u0076e\",\"generation\":1}",
|
||||
"{\"action\":\"apply\",\"generation\":1,\"dim_seconds\":10,\"off_seconds\":10}",
|
||||
"{\"action\":\"apply\",\"generation\":1,\"dim_seconds\":86401,\"off_seconds\":0}"};
|
||||
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
|
||||
operation_begin(&admin, invalid[i]); display_expect("400 Bad Request", false);
|
||||
}
|
||||
display_operation_t parsed;
|
||||
for (size_t n = 0; n < strlen(apply); ++n) assert(!parse(apply, n, &parsed));
|
||||
assert(parse(apply, strlen(apply), &parsed)); assert(!parse(apply, strlen(apply)+1, &parsed));
|
||||
for (unsigned dim = 0; dim < 3; ++dim) for (unsigned off = 0; off < 3; ++off) {
|
||||
unsigned values[] = {0, 1, 86400}; char body[160];
|
||||
snprintf(body, sizeof(body), "{\"off_seconds\":%u,\"dim_seconds\":%u,\"generation\":4294967295,\"action\":\"apply\"}", values[off], values[dim]);
|
||||
assert(parse(body, strlen(body), &parsed) == (!values[off] || !values[dim] || values[off] > values[dim]));
|
||||
}
|
||||
receive_fragment = 1; operation_begin(&admin, apply); display_expect("400 Bad Request", false); assert(body_offset == 4); receive_fragment = 64;
|
||||
char full[257]; memset(full, ' ', 256); memcpy(full, apply, strlen(apply)); full[256] = 0;
|
||||
queue_fail = true; operation_begin(&admin, full); display_expect("503 Service Unavailable", false); queue_fail = false;
|
||||
assert(body_offset == 256 && s_operation.state == IDLE);
|
||||
puts("PASS Display parser: exact schema, integer limits/order/zero, truncations, four receives and exact 256-byte admission");
|
||||
|
||||
for (unsigned mode = 0; mode < 5; ++mode) {
|
||||
operation_begin(mode == 0 ? NULL : mode == 1 ? &user : &admin, NULL); req.uri = "/api/settings/display";
|
||||
s_config_available = mode != 3; s_config_busy = mode == 4;
|
||||
display_expect(mode == 0 ? "401 Unauthorized" : mode == 1 ? "403 Forbidden" : mode >= 3 ? "503 Service Unavailable" : "200 OK", true);
|
||||
if (mode == 2) assert(!strcmp(output, "{\"generation\":1,\"dim_seconds\":300,\"off_seconds\":600}"));
|
||||
}
|
||||
s_config_available = true; s_config_busy = false;
|
||||
puts("PASS Display snapshot: bounded RAM-only, unavailable UI/contention; no panel or storage dependency");
|
||||
submit(&admin, apply); uint32_t first = queued_id;
|
||||
operation_begin(&other, apply); display_expect("503 Service Unavailable", false);
|
||||
operation_begin(&other, NULL); display_expect("200 OK", false); assert(strstr(output, "idle"));
|
||||
execute(); assert(s_operation.state == OK && s_config_generation == 2 && s_config.dim_timeout_seconds == 0 && !have_stored);
|
||||
zero(&s_operation.principal, sizeof(s_operation.principal)); zero(&s_operation.config, sizeof(s_operation.config));
|
||||
execute(); assert(s_config_generation == 2);
|
||||
submit(&admin, apply); execute(); assert(s_operation.state == CONFLICT && s_config_generation == 2);
|
||||
action(&admin, "save"); assert(have_stored && persisted.off_timeout_seconds == 86400 && s_config_generation == 2);
|
||||
action(&admin, "defaults"); assert(s_config.off_timeout_seconds == 600 && persisted.off_timeout_seconds == 86400);
|
||||
action(&admin, "load"); assert(s_config.off_timeout_seconds == 86400);
|
||||
have_stored = false; action(&admin, "load"); assert(s_operation.state == LOADED_DEFAULTS && s_config.off_timeout_seconds == 600 && !have_stored);
|
||||
have_stored = true; persisted.version = 99; action(&admin, "load"); assert(s_operation.state == LOADED_DEFAULTS && persisted.version == 99);
|
||||
action(&admin, "reset"); assert(s_operation.state == OK && persisted.version == 1);
|
||||
/* A simulated reboot invokes the real boot loader, not browser drafts. */
|
||||
local_ui_config_t boot; bool stored; on_dispatcher = true;
|
||||
assert(local_ui_config_load(&boot, &stored) == ESP_OK && stored && !memcmp(&boot, &persisted, sizeof(boot))); on_dispatcher = false;
|
||||
puts("PASS Display persistence: Apply/Save/Defaults/Load/fallback/Reset, real config loader reboot projection and stale generation/replay isolation");
|
||||
|
||||
on_dispatcher = true; char *set[] = {"display", "set", "dim-seconds", "10"}; assert(command_display(4, set) == 0); on_dispatcher = false;
|
||||
uint32_t selected = s_config_generation;
|
||||
char body[96]; snprintf(body, sizeof(body), "{\"action\":\"save\",\"generation\":%u}", selected); submit(&admin, body);
|
||||
on_dispatcher = true; set[3] = "20"; assert(command_display(4, set) == 0); on_dispatcher = false;
|
||||
execute(); assert(s_operation.state == CONFLICT && s_config.dim_timeout_seconds == 20 && persisted.dim_timeout_seconds == 300);
|
||||
storage_hook = concurrent; action(&admin, "save"); assert(s_operation.state == OK && persisted.dim_timeout_seconds == 20);
|
||||
storage_error = ESP_FAIL; selected = s_config_generation; action(&admin, "reset");
|
||||
assert(s_operation.state == FAILED && s_config_generation == selected && s_config.dim_timeout_seconds == 20);
|
||||
action(&admin, "load"); assert(s_operation.state == FAILED && s_config_generation == selected); storage_error = ESP_OK;
|
||||
for (unsigned failure = 1; failure <= 4; ++failure) {
|
||||
storage_fail_at = failure;
|
||||
local_ui_config_t old_working = s_config, old_saved = persisted;
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
action(&admin, i == 0 ? "save" : i == 1 ? "reset" : "load");
|
||||
assert(s_operation.state == FAILED && !s_config_busy && s_config_generation == selected);
|
||||
assert(!memcmp(&old_working, &s_config, sizeof(s_config)) && !memcmp(&old_saved, &persisted, sizeof(persisted)));
|
||||
}
|
||||
}
|
||||
storage_fail_at = 0;
|
||||
for (unsigned i = SAVE; i < ACTION_COUNT; ++i) {
|
||||
on_dispatcher = true; char *args[] = {"display", (char *)s_actions[i]}; assert(command_display(2, args) == 0); on_dispatcher = false;
|
||||
}
|
||||
s_config_generation = UINT32_MAX;
|
||||
assert(local_status_ui_apply_config(&s_config) == ESP_ERR_INVALID_STATE); s_config_generation = selected + 10;
|
||||
puts("PASS Display canonical concurrency: CLI generation conflicts, storage reservation, concurrent activity/diagnostic hold, failed persistence leaves RAM unchanged, no wrap");
|
||||
|
||||
snprintf(body, sizeof(body), "{\"action\":\"save\",\"generation\":%u}", s_config_generation);
|
||||
submit(&admin, body); unsigned before = storage_calls;
|
||||
web_display_settings_execute(0); web_display_settings_execute(first); assert(storage_calls == before && s_operation.state == PENDING);
|
||||
now += 30000000; execute(); assert(s_operation.state == CANCELLED && storage_calls == before);
|
||||
submit(&admin, body); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin, body); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED); admin = mint(&alice);
|
||||
submit(&admin, body); stale_user = alice.user_id; execute(); stale_user = 0; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); now = admin.view.expires_at_us - 1; submit(&admin, body); now = admin.view.expires_at_us; execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin, body); storage_hook = revoke; execute(); assert(s_operation.state == OK);
|
||||
operation_begin(&admin, NULL); display_expect("401 Unauthorized", false);
|
||||
admin = mint(&alice); submit(&admin, body); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); operation_begin(&admin, NULL); display_expect("200 OK", false); assert(strstr(output, "idle"));
|
||||
puts("PASS Display session lifecycle: deadline, expiry/revocation/missed notification/database failure, admitted completion, stop/restart fencing");
|
||||
send_fail = true; submit(&admin, body); send_fail = false; execute(); assert(s_operation.state == OK);
|
||||
operation_begin(&admin, NULL); display_expect("200 OK", false); assert(strstr(output, "ok"));
|
||||
s_next_id = UINT32_MAX; operation_begin(&admin, body); display_expect("503 Service Unavailable", false);
|
||||
puts("PASS Display operation results: lost acknowledgement retains result, no automatic replay, nonwrapping operation IDs");
|
||||
}
|
||||
@@ -59,6 +59,24 @@ admin = "--admin" in sys.argv
|
||||
settings = "--settings" in sys.argv
|
||||
serial_settings = "--serial-settings" in sys.argv
|
||||
accounts = "--accounts" in sys.argv
|
||||
display = "--display" in sys.argv
|
||||
if display:
|
||||
HEADERS["nvs_flash.h"] = '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n'
|
||||
HEADERS["nvs.h"] = '''#pragma once
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
typedef int nvs_handle_t;
|
||||
#define NVS_READONLY 0
|
||||
#define NVS_READWRITE 1
|
||||
#define ESP_ERR_NVS_NOT_FOUND 0x1102
|
||||
#define ESP_ERR_NVS_TYPE_MISMATCH 0x1103
|
||||
#define ESP_ERR_NVS_INVALID_LENGTH 0x110c
|
||||
esp_err_t nvs_open(const char *, int, nvs_handle_t *);
|
||||
esp_err_t nvs_get_blob(nvs_handle_t, const char *, void *, size_t *);
|
||||
esp_err_t nvs_set_blob(nvs_handle_t, const char *, const void *, size_t);
|
||||
esp_err_t nvs_commit(nvs_handle_t);
|
||||
void nvs_close(nvs_handle_t);
|
||||
'''
|
||||
network = "--network" in sys.argv
|
||||
if network:
|
||||
HEADERS["esp_wifi_types.h"] = "#pragma once\ntypedef int wifi_auth_mode_t;\n"
|
||||
@@ -186,6 +204,13 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
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 display:
|
||||
ui_source = (ROOT / 'src/local_status_ui.c').read_text()
|
||||
names = ('local_status_ui_get_config', 'local_status_ui_get_settings', 'local_status_ui_update_settings', 'local_status_ui_apply_config', 'local_status_ui_hold_for_diagnostics')
|
||||
(tmp / 'display_owner_production.h').write_text('\n'.join(function(ui_source, name) for name in names))
|
||||
console_source = (ROOT / 'src/local_ui_console.c').read_text()
|
||||
names = ('print_usage', 'print_config', 'parse_timeout', 'apply_parameter', 'command_display')
|
||||
(tmp / 'display_console_production.h').write_text('\n'.join(function(console_source, name) for name in names))
|
||||
if network:
|
||||
wifi_source = (ROOT / 'src/wifi_config.c').read_text()
|
||||
mdns_source = (ROOT / 'src/mdns_config.c').read_text()
|
||||
@@ -221,6 +246,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings else []),
|
||||
*(["-DHOST_ACCOUNTS"] if accounts else []),
|
||||
*(["-DHOST_NETWORK"] if network else []),
|
||||
*(["-DHOST_DISPLAY"] if display 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)
|
||||
|
||||
@@ -143,6 +143,9 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_NETWORK
|
||||
#include "network_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_DISPLAY
|
||||
#include "display_settings_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -302,6 +305,9 @@ int main(void) {
|
||||
#endif
|
||||
#ifdef HOST_NETWORK
|
||||
network_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_DISPLAY
|
||||
display_settings_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user