Add typed account and password settings
- Add admin account list, create, role, delete, and password workflows - Execute identity-checked mutations through the existing dispatcher - Bound queued credential lifetime and wipe transient secrets - Add explicit password generation with saved-value acknowledgement - Handle self-revocation and uncertain outcomes without automatic retries - Register optional account routes without disrupting terminal transports - Expand host regressions and document contracts and pending target checks Validated host suites and pio run; hardware validation remains pending.
This commit is contained in:
@@ -39,8 +39,109 @@ static void unchanged(const stored_database_t *before)
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
assert(!locks);
|
||||
}
|
||||
static void typed_account_tests(void)
|
||||
{
|
||||
reset(); user_database_accounts_t list;
|
||||
assert(user_database_get_accounts(&list)==ESP_OK && last_wait==0 && list.count==3);
|
||||
assert(!strcmp(list.users[1].username,"other"));
|
||||
user_database_account_t other=list.users[1], admin=list.users[0];
|
||||
snapshot_busy=true; memset(&list,0xff,sizeof(list));
|
||||
assert(user_database_get_accounts(&list)==ESP_ERR_TIMEOUT && all_zero(&list,sizeof(list)));
|
||||
snapshot_busy=false;
|
||||
assert(user_database_delete_current(&admin)==ESP_ERR_INVALID_STATE);
|
||||
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_ERR_INVALID_STATE);
|
||||
assert(!writes && !commits);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_OK);
|
||||
unsigned saved=commits;
|
||||
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND && commits==saved);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
|
||||
for (fail_stage=1;fail_stage<=3;++fail_stage) {
|
||||
stored_database_t before=s_database;
|
||||
assert(user_database_delete_current(&other)==ESP_FAIL); unchanged(&before);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_FAIL); unchanged(&before);
|
||||
}
|
||||
fail_stage=0; assert(user_database_delete_current(&other)==ESP_OK);
|
||||
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
|
||||
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
|
||||
assert(user_database_delete_current(&other)==ESP_OK);
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)) && !locks);
|
||||
s_initialized=false; memset(&list,0xff,sizeof(list));
|
||||
assert(user_database_get_accounts(&list)==ESP_ERR_INVALID_STATE && all_zero(&list,sizeof(list)));
|
||||
assert(user_database_delete_current(NULL)==ESP_ERR_INVALID_ARG);
|
||||
}
|
||||
static void typed_password_tests(void)
|
||||
{
|
||||
reset(); user_database_accounts_t list;
|
||||
assert(user_database_get_accounts(&list)==ESP_OK);
|
||||
user_database_account_t other=list.users[1], admin=list.users[0];
|
||||
const uint8_t password[]="quote\"slash\\ space";
|
||||
for (unsigned stage=1;stage<=5;++stage) {
|
||||
fail_stage=stage; stored_database_t before=s_database;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_FAIL);
|
||||
unchanged(&before);
|
||||
}
|
||||
fail_stage=0; writes=commits=0;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_OK);
|
||||
assert(writes==1 && commits==1 && s_database.users[1].auth_generation==other.auth_generation+1);
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
stored_database_t before=s_database; unsigned rng=random_calls;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
|
||||
unchanged(&before); assert(writes==1 && commits==1 && random_calls==rng);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
|
||||
assert(user_database_delete_current(&other)==ESP_OK);
|
||||
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
|
||||
before=s_database; rng=random_calls;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
|
||||
unchanged(&before); assert(random_calls==rng);
|
||||
assert(user_database_set_password_current(NULL,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
|
||||
other.user_id=0;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
|
||||
memset(other.username,'x',sizeof(other.username));
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
|
||||
assert(user_database_set_password_current(&admin,(const uint8_t *)"short",5)==ESP_ERR_INVALID_ARG);
|
||||
/* Own password is allowed even for the last administrator; old principal is stale. */
|
||||
assert(user_database_set_password_current(&admin,password,sizeof(password)-1)==ESP_OK);
|
||||
bool current=true; assert(user_database_principal_is_current(&actor,¤t)==ESP_OK && !current);
|
||||
assert(s_database.admin_count==1);
|
||||
/* With a second admin, canonical self role/delete invariants allow both. */
|
||||
assert(user_database_set_role((const uint8_t *)"other",5,USER_ROLE_ADMIN)==ESP_OK);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
|
||||
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_OK);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
|
||||
assert(user_database_delete_current(&admin)==ESP_OK);
|
||||
reset(); before=s_database; rng=random_calls;
|
||||
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_ADMIN,password,sizeof(password)-1)==ESP_ERR_INVALID_STATE);
|
||||
unchanged(&before); assert(!writes && !commits && rng==random_calls);
|
||||
for (unsigned i=3;i<USER_DATABASE_MAX_USERS;++i) {
|
||||
char name[17]; snprintf(name,sizeof(name),"account%u",i);
|
||||
assert(user_database_create((const uint8_t *)name,strlen(name),USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
|
||||
}
|
||||
before=s_database; rng=random_calls; unsigned saved=commits;
|
||||
assert(user_database_create((const uint8_t *)"extra",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_ERR_NO_MEM);
|
||||
unchanged(&before); assert(commits==saved && rng==random_calls);
|
||||
/* RNG-only helper is independent of initialized storage and leaves all DB state alone. */
|
||||
s_initialized=false; s_mutex=NULL;
|
||||
for (unsigned mode=0;mode<2;++mode) {
|
||||
user_database_generated_password_t generated; memset(&generated,0xa5,sizeof(generated));
|
||||
fail_stage=mode ? 4 : 0;
|
||||
assert(user_database_generate_password_value(&generated)==(mode ? ESP_FAIL : ESP_OK));
|
||||
if (mode) assert(all_zero(&generated,sizeof(generated)));
|
||||
else {
|
||||
assert(generated.password_length==24 && strlen((const char *)generated.password)==24);
|
||||
for (size_t i=0;i<24;++i) assert(strchr((const char *)s_generated_alphabet,generated.password[i]));
|
||||
}
|
||||
assert(!memcmp(&before,&s_database,sizeof(before)) && commits==saved && !locks);
|
||||
secure_wipe(&generated,sizeof(generated));
|
||||
}
|
||||
assert(user_database_generate_password_value(NULL)==ESP_ERR_INVALID_ARG);
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
typed_account_tests();
|
||||
typed_password_tests();
|
||||
const char *supported[]={
|
||||
"user add fresh user", "user add fresh admin", "user password other",
|
||||
"user delete other --force", "user role other admin --force",
|
||||
|
||||
@@ -24,6 +24,7 @@ db = (ROOT / "src/user_database.c").read_text()
|
||||
console = (ROOT / "src/user_console.c").read_text()
|
||||
admin = (ROOT / "src/admin_ssh_console.c").read_text()
|
||||
prelude = r'''
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
@@ -33,7 +34,10 @@ prelude = r'''
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
|
||||
ESP_ERR_NO_MEM, ESP_ERR_NOT_FOUND, ESP_ERR_NOT_ALLOWED,
|
||||
ESP_ERR_INVALID_RESPONSE, ESP_ERR_INVALID_VERSION };
|
||||
ESP_ERR_INVALID_RESPONSE, ESP_ERR_INVALID_VERSION, ESP_ERR_TIMEOUT };
|
||||
#define pdTRUE 1
|
||||
static bool snapshot_busy;
|
||||
static int last_wait;
|
||||
typedef void *SemaphoreHandle_t;
|
||||
#define portMAX_DELAY 0
|
||||
#define NVS_READWRITE 1
|
||||
@@ -45,7 +49,7 @@ static bool owner_current = true, remote = true, web = true, mismatch, cancel_pr
|
||||
static int notify_error = ESP_OK;
|
||||
static char revoked_name[17];
|
||||
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
|
||||
static void xSemaphoreTake(void *m, int t) { (void)m; (void)t; assert(!locks++); }
|
||||
static int xSemaphoreTake(void *m, int t) { (void)m; last_wait=t; if (snapshot_busy) return 0; assert(!locks++); return pdTRUE; }
|
||||
static void xSemaphoreGive(void *m) { (void)m; assert(locks-- == 1); }
|
||||
static const char *esp_err_to_name(int e) { (void)e; return "injected error"; }
|
||||
static int nvs_open(const char *ns, int mode, int *h) {
|
||||
@@ -143,8 +147,11 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
|
||||
"find_free_user", "stored_keys_equal", "validate_database", "recount",
|
||||
"next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user",
|
||||
"user_database_principal_is_current", "create_locked", "user_database_create",
|
||||
"mutate_user_begin", "user_database_delete", "user_database_set_role",
|
||||
"user_database_set_password"]
|
||||
"mutate_user_begin", "target_matches_locked", "delete_user", "set_role",
|
||||
"user_database_delete", "user_database_set_role", "user_database_get_accounts",
|
||||
"user_database_delete_current", "user_database_set_role_current",
|
||||
"set_password", "user_database_set_password", "user_database_set_password_current",
|
||||
"user_database_generate_password_value"]
|
||||
console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
|
||||
"show_generated_password", "mutation_currentness", "add_user", "change_password",
|
||||
"parse_key_index", "command_user_inner", "command_user"]
|
||||
|
||||
@@ -31,7 +31,8 @@ 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 uint32_t serial_settings_executed, account_settings_executed;
|
||||
static void web_account_settings_execute(uint32_t id) { assert(!lock_depth); account_settings_executed = id; }
|
||||
static unsigned serial_settings_preceding_runs, queue_send_wait;
|
||||
static void web_serial_settings_execute(uint32_t id) {
|
||||
assert(!lock_depth);
|
||||
|
||||
@@ -340,5 +340,16 @@ int main(void)
|
||||
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");
|
||||
assert(admin_ssh_console_submit_account_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_account_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_account_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_serial_settings(21) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_account_settings(22) == ESP_OK && queue_send_wait == 0);
|
||||
pump(worker_task);
|
||||
assert(serial_settings_executed == 21 && account_settings_executed == 22 && runs == before_serial + 5);
|
||||
puts("PASS: typed Accounts uses same bounded queue with nonblocking admission and isolated dispatcher routing");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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) != 16:
|
||||
raise RuntimeError('Review URI extraction: expected 14 descriptors and two tables')
|
||||
if len(uri_tables) != 20:
|
||||
raise RuntimeError('Review URI extraction: expected 18 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()
|
||||
@@ -108,7 +108,11 @@ 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)
|
||||
HANDLER(web_serial_settings_handler) HANDLER(web_account_settings_handler)
|
||||
HANDLER(web_account_generate_password_handler)
|
||||
static unsigned account_calls, account_fail_at;
|
||||
static unsigned generation_calls;
|
||||
static bool generation_fail;
|
||||
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; }
|
||||
@@ -120,7 +124,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 == 19 && config->port_secure == 443);
|
||||
assert(config->httpd.max_uri_handlers == 23 && 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);
|
||||
@@ -149,11 +153,26 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
|
||||
if (error == ESP_OK) { assert(registered_count < 32); registered[registered_count++] = uri; }
|
||||
return error;
|
||||
}
|
||||
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked && uri->handler == web_account_settings_handler);
|
||||
if (++account_calls == account_fail_at) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri; return ESP_OK;
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(uri->method == HTTP_GET);
|
||||
if (uri->handler == web_account_settings_handler) return account_register(s, uri);
|
||||
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) {
|
||||
if (uri->handler == web_account_generate_password_handler) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
assert(!strcmp(uri->uri, "/api/settings/accounts/generate-password") && uri->method == HTTP_POST);
|
||||
assert(!uri->is_websocket); ++generation_calls;
|
||||
if (generation_fail) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri; return ESP_OK;
|
||||
}
|
||||
if (uri->handler == web_account_settings_handler) return account_register(s, 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;
|
||||
@@ -163,7 +182,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
|
||||
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) ||
|
||||
(!strcmp(uri, "/api/settings/serial-operation") && method == HTTP_GET));
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation")) && method == HTTP_GET));
|
||||
++unregister_calls;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
||||
@@ -228,6 +247,7 @@ static void reset(void) {
|
||||
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;
|
||||
account_calls = account_fail_at = generation_calls = 0; generation_fail = false;
|
||||
}
|
||||
static void fresh_registration(void) { registration_calls = registered_count = 0; }
|
||||
static void start(void) {
|
||||
@@ -261,7 +281,8 @@ int main(void) {
|
||||
}
|
||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||
|
||||
reset(); start(); assert(registered_count == 19 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
reset(); start(); assert(registered_count == 23 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
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);
|
||||
@@ -313,7 +334,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 == 17 && unregister_calls == failure - 17);
|
||||
assert(registered_count == 21 && 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);
|
||||
@@ -322,13 +343,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 == 19 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(registered_count == 23 && 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 == 18);
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 22);
|
||||
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");
|
||||
@@ -340,7 +361,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 == 19 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
assert(registered_count == 23 && 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;
|
||||
@@ -362,7 +383,7 @@ 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 == 18);
|
||||
assert(settings_calls == 1 && registered_count == 22);
|
||||
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);
|
||||
@@ -370,13 +391,47 @@ int main(void) {
|
||||
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
||||
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(registered_count == 21 && 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)");
|
||||
for (unsigned failure = 1; failure <= 3; ++failure) {
|
||||
reset(); account_calls = 0; account_fail_at = failure; start();
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 20 : 21));
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
||||
assert(registered_count == 23 && account_calls == 3);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
||||
assert(registered_count == 22 && auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
||||
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
|
||||
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
|
||||
reset(); generation_fail = true; start();
|
||||
assert(generation_calls == 1 && registered_count == 22 && account_calls == 3);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
unsigned account_mutations = 0;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/accounts/generate-password"));
|
||||
if (!strcmp(registered[i]->uri, "/api/settings/account-operation") && registered[i]->method == HTTP_POST)
|
||||
++account_mutations;
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
generation_fail = false; fresh_registration(); start();
|
||||
assert(generation_calls == 2 && registered_count == 23);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
||||
puts("15 lifecycle groups passed (16 required fatal positions, 9 optional routes, plus failed unregister)");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -163,6 +163,32 @@ def main():
|
||||
b, out = span(body), Credentials()
|
||||
check("login", str(index), lambda: api.web_auth_parse_login(b, size(body), C.byref(out)),
|
||||
out, expected, decoded)
|
||||
api.web_auth_parse_json_string.argtypes = [C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t),
|
||||
C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
||||
api.web_auth_parse_json_string.restype = C.c_bool
|
||||
strings = [(b' "quote\\\"slash\\\\ space",', 65, b'quote"slash\\ space'),
|
||||
(b'"\\u0020\\u0022\\u005c"', 4, b' "\\'),
|
||||
(b'""', 1, b''), (b'"a"', 1, None),
|
||||
(b'"' + b'\\u0022' * 64 + b'"', 65, b'"' * 64),
|
||||
(b'"' + b'\\u0022' * 65 + b'"', 65, None),
|
||||
(b'"\\u0000"', 65, None), (b'"abc\\q"', 65, None),
|
||||
(b'"abc\\ud800"', 65, None), (b'"abc\xff"', 65, None),
|
||||
(None, 65, None)]
|
||||
complete = b'"secret\\\"value"'
|
||||
strings += [(complete[:i], 65, None) for i in range(len(complete))]
|
||||
for index, (body, capacity, expected) in enumerate(strings):
|
||||
prefix = b'prefix:'
|
||||
raw = None if body is None else prefix + body
|
||||
data, out = span(raw), C.create_string_buffer(capacity)
|
||||
position, length = C.c_size_t(len(prefix)), C.c_size_t(999)
|
||||
check("string", str(index),
|
||||
lambda: api.web_auth_parse_json_string(data, size(raw), C.byref(position), out, capacity, C.byref(length)),
|
||||
out, expected, lambda x: x.value)
|
||||
if expected is None:
|
||||
if position.value != len(prefix) or length.value != 0:
|
||||
failures.append(f"string: {index}: failure changed cursor or retained length")
|
||||
elif length.value != len(expected) or raw[position.value - 1:position.value] != b'"':
|
||||
failures.append(f"string: {index}: incorrect length/cursor")
|
||||
for failure in failures:
|
||||
print("FAIL:", failure)
|
||||
print(f"{count} cases; {len(failures)} failures")
|
||||
|
||||
@@ -16,6 +16,36 @@ This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, brows
|
||||
|
||||
See `docs/phase8d3_implementation.md` for source verification, other suite commands, build accounting and the target checklist.
|
||||
|
||||
## Accounts (8D.10)
|
||||
|
||||
```sh
|
||||
python3 tests/web_cookie_auth/run.py --accounts
|
||||
python3 tests/admin_console_boundary/accounts.py
|
||||
```
|
||||
|
||||
The first command adds nine account HTTP/operation groups using production
|
||||
cookie/store/handler/parser and the canonical generated-value helper. Queue,
|
||||
database mutations, timer scheduling and revocation are doubles; authorization
|
||||
is real. Covers max-width eight-account projection, strict 768-byte/four-receive
|
||||
credential schemas, decoded printable-ASCII passwords, bodyless generated-value
|
||||
authorization/currentness/no-mutation/cleanup, timer creation/start failure,
|
||||
queued expiry/replacement/executing fences, self success revocation and protected
|
||||
failure, pending/result isolation, stale IDs, submission/execution failure,
|
||||
target-only notifications, session invalidation and missed revocation/DB failure.
|
||||
Parent reports PASS for these nine groups plus shared regressions. Direct-handler
|
||||
tests do not prove route registration; the missing registration is now fixed as
|
||||
an independent optional endpoint (23 handlers), and the route agent reports 15
|
||||
lifecycle groups passing for registration, failure isolation and restart.
|
||||
Implementation is host-tested/build-verified (parent `pio run` PASS, 25.61 s,
|
||||
95,908 B RAM / 1,694,237 B flash), not target accepted. New timer runtime costs
|
||||
remain unmeasured. No sanitizer validation or device/asset/commit/8D.11 action.
|
||||
The second command separately exercises production conditional database mutation
|
||||
and zero-wait list bodies with NVS/RTOS doubles, including last-admin protection,
|
||||
target generation/recreation checks and commit-failure cleanup. It retains the
|
||||
canonical CLI account tests. These are not end-to-end RTOS/flash/TLS tests.
|
||||
See `docs/phase8d10_implementation.md` for current contracts, historical slice 1
|
||||
evidence and pending target checks. Timer doubles do not prove hard cleanup latency.
|
||||
|
||||
## Read-only Serial Settings
|
||||
|
||||
```sh
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/* Production auth/store/handler, deterministic dispatcher and DB/transport doubles.
|
||||
* Actual conditional database transactions are tested by accounts.py. */
|
||||
#define ESP_ERR_TIMEOUT 0x107
|
||||
#define ESP_ERR_NOT_FOUND 0x105
|
||||
static unsigned wiped_passwords, wiped_responses, wiped_generated, wiped_bodies;
|
||||
static const uint8_t *executing_password;
|
||||
static void account_wipe(void *p, size_t n) {
|
||||
if (n==65) ++wiped_passwords;
|
||||
if (n==96) ++wiped_responses;
|
||||
if (n==sizeof(user_database_generated_password_t)) ++wiped_generated;
|
||||
if (n==768) ++wiped_bodies;
|
||||
secure_wipe(p,n); zero(p,n);
|
||||
if (p==executing_password) executing_password=NULL;
|
||||
}
|
||||
#define secure_wipe account_wipe
|
||||
#include "account_parse_production.h"
|
||||
#include "../../src/web_account_settings.c"
|
||||
#undef secure_wipe
|
||||
|
||||
static unsigned timer_creates, timer_starts;
|
||||
static bool timer_create_fail, timer_start_fail;
|
||||
static void (*timer_callback)(void *);
|
||||
int esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *out) {
|
||||
assert(!host_lock_depth && !s_secret_timer); ++timer_creates;
|
||||
if (timer_create_fail) return ESP_FAIL;
|
||||
timer_callback=args->callback; *out=(void *)1; return ESP_OK;
|
||||
}
|
||||
int esp_timer_start_periodic(esp_timer_handle_t timer, uint64_t period) {
|
||||
assert(!host_lock_depth && timer==s_secret_timer && period==1000000); ++timer_starts;
|
||||
return timer_start_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
|
||||
static bool dispatcher, queue_fail, list_fail;
|
||||
static uint32_t queued;
|
||||
static unsigned mutations, web_revokes, ssh_revokes, lists;
|
||||
static esp_err_t mutation_error;
|
||||
static void (*mutation_hook)(void);
|
||||
static void (*queue_hook)(void);
|
||||
static bool self_target;
|
||||
static void check_slot_wiped(void) {
|
||||
zero(s_operation.password,sizeof(s_operation.password)); assert(!s_operation.password_length);
|
||||
zero(&s_operation.principal,sizeof(s_operation.principal));
|
||||
zero(&s_operation.target,sizeof(s_operation.target));
|
||||
}
|
||||
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id) {
|
||||
assert(!host_lock_depth && !dispatcher && id);
|
||||
if (queue_hook) { void (*hook)(void)=queue_hook; queue_hook=NULL; hook(); }
|
||||
if (queue_fail) return ESP_ERR_TIMEOUT;
|
||||
queued=id; return ESP_OK;
|
||||
}
|
||||
esp_err_t user_database_get_accounts(user_database_accounts_t *out) {
|
||||
assert(!host_lock_depth && !dispatcher); ++lists; memset(out,0,sizeof(*out));
|
||||
if (list_fail) return ESP_ERR_TIMEOUT;
|
||||
out->count=8;
|
||||
for (unsigned i=0;i<8;++i) {
|
||||
snprintf(out->users[i].username,sizeof(out->users[i].username),"account%09u",i);
|
||||
out->users[i].role=USER_ROLE_ADMIN; out->users[i].user_id=UINT32_MAX-i;
|
||||
out->users[i].auth_generation=UINT32_MAX;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t user_database_delete_current(const user_database_account_t *target) {
|
||||
assert(dispatcher && !host_lock_depth && !strcmp(target->username,self_target ? "alice" : "carol"));
|
||||
assert(s_operation.executing); check_slot_wiped();
|
||||
assert(target->user_id==7 && target->auth_generation==2); ++mutations;
|
||||
if (mutation_hook) { void (*hook)(void)=mutation_hook; mutation_hook=NULL; hook(); }
|
||||
return mutation_error;
|
||||
}
|
||||
esp_err_t user_database_set_role_current(const user_database_account_t *target,user_role_t role) {
|
||||
assert(role==USER_ROLE_ADMIN); return user_database_delete_current(target);
|
||||
}
|
||||
esp_err_t user_database_create(const uint8_t *u,size_t n,user_role_t role,const uint8_t *p,size_t pn) {
|
||||
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,"carol",5) && role==USER_ROLE_ADMIN);
|
||||
assert(user_database_password_valid(p,pn)); check_slot_wiped(); ++mutations;
|
||||
if (mutation_hook) { void (*hook)(void)=mutation_hook; mutation_hook=NULL; hook(); }
|
||||
return mutation_error;
|
||||
}
|
||||
esp_err_t user_database_set_password_current(const user_database_account_t *target,const uint8_t *p,size_t pn) {
|
||||
assert(user_database_password_valid(p,pn)); executing_password=p;
|
||||
return user_database_delete_current(target);
|
||||
}
|
||||
esp_err_t web_serial_transport_revoke_user(const uint8_t *u,size_t n) {
|
||||
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5));
|
||||
assert(!executing_password);
|
||||
if (self_target) web_session_store_invalidate_username(u,n);
|
||||
++web_revokes; return ESP_FAIL;
|
||||
}
|
||||
esp_err_t ssh_transport_revoke_user(const uint8_t *u,size_t n) {
|
||||
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5)); ++ssh_revokes; return ESP_FAIL;
|
||||
}
|
||||
static const char deletion[]="{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
|
||||
static const char role_body[]="{\"action\":\"role\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}";
|
||||
static void account_begin(const issued_t *identity,const char *body) {
|
||||
begin("/api/settings/account-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 account_expect(const char *status) {
|
||||
unsigned before=mutations;
|
||||
esp_err_t error=web_account_settings_handler(&req);
|
||||
assert(error==(send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
if (strcmp(status,response_status)) fprintf(stderr,"expected %s got %s\n",status,response_status);
|
||||
assert(!strcmp(status,response_status) && mutations==before);
|
||||
assert(strlen(output)<1024); zero(scratch,sizeof(scratch));
|
||||
}
|
||||
static void submit_account(const issued_t *identity,const char *body) {
|
||||
account_begin(identity,body); account_expect("202 Accepted"); assert(s_operation.id==queued && s_operation.state==PENDING);
|
||||
}
|
||||
static void execute_account(void) { dispatcher=true; web_account_settings_execute(queued); dispatcher=false; }
|
||||
static void invalidate_actor(void) { web_session_store_invalidate(s_operation.session); }
|
||||
static const char create_body[]="{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\"}";
|
||||
static const char password_body[]="{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\"}";
|
||||
static void generate_begin(const issued_t *identity) {
|
||||
account_begin(identity,""); req.uri="/api/settings/accounts/generate-password";
|
||||
}
|
||||
static void generate_expect(const char *status) {
|
||||
account_operation_t before=s_operation;
|
||||
unsigned ids=s_next_id, calls=mutations, response_wipes=wiped_responses, generated_wipes=wiped_generated;
|
||||
esp_err_t result=web_account_generate_password_handler(&req);
|
||||
assert(result==((send_fail || fail_header || aux.remaining_len) ? ESP_FAIL : ESP_OK));
|
||||
if (!fail_header) assert(!strcmp(status,response_status));
|
||||
assert(!memcmp(&before,&s_operation,sizeof(before)) && ids==s_next_id && mutations==calls);
|
||||
assert(wiped_responses==response_wipes+1 && wiped_generated>generated_wipes);
|
||||
zero(scratch,sizeof(scratch));
|
||||
}
|
||||
static void generated_tests(void) {
|
||||
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob);
|
||||
unsigned rng=rng_calls;
|
||||
generate_begin(NULL); generate_expect("401 Unauthorized");
|
||||
generate_begin(&user); generate_expect("403 Forbidden");
|
||||
for (unsigned mode=0;mode<9;++mode) {
|
||||
generate_begin(&admin);
|
||||
if (mode==0) { req.content_len=aux.remaining_len=1; }
|
||||
if (mode==1) req.method=HTTP_GET;
|
||||
if (mode==2) req.uri="/api/settings/accounts/generate-password?x=1";
|
||||
if (mode==3) add("Origin","https://evil.example");
|
||||
if (mode==4) add("X-CSRF-Token","duplicate");
|
||||
if (mode==5) add("Transfer-Encoding","chunked");
|
||||
if (mode==6) add("Sec-Fetch-Site","cross-site");
|
||||
if (mode==7) stale_user=alice.user_id;
|
||||
if (mode==8) db_fail=true;
|
||||
(void)web_account_generate_password_handler(&req);
|
||||
assert(response_status[0]=='4' && rng_calls==rng);
|
||||
stale_user=0; db_fail=false;
|
||||
}
|
||||
admin=mint(&alice); rng=rng_calls;
|
||||
generate_begin(&admin); generate_expect("200 OK");
|
||||
assert(rng_calls==rng+1 && strlen(output)==39 && !strncmp(output,"{\"password\":\"",13));
|
||||
for (unsigned i=13;i<37;++i) assert(strchr((const char *)s_generated_alphabet,output[i]));
|
||||
bool no_store=false;
|
||||
for (unsigned i=0;i<aux.resp_hdrs_count;++i)
|
||||
if (!strcmp(response_headers[i].field,"Cache-Control")) no_store=!strcmp(response_headers[i].value,"no-store");
|
||||
assert(no_store);
|
||||
rng_fail=true; generate_begin(&admin); generate_expect("503 Service Unavailable"); rng_fail=false;
|
||||
assert(!strstr(output,"password"));
|
||||
send_fail=true; generate_begin(&admin); generate_expect("200 OK"); send_fail=false;
|
||||
fail_header=1; setter_calls=0; generate_begin(&admin); generate_expect(""); fail_header=0;
|
||||
unsigned header_capacity=server.config.max_resp_headers;
|
||||
for (unsigned capacity=0;capacity<3;++capacity) {
|
||||
generate_begin(&admin); server.config.max_resp_headers=capacity;
|
||||
unsigned wipes=wiped_responses, sent=sends;
|
||||
assert(web_account_generate_password_handler(&req)==ESP_ERR_HTTPD_RESP_HDR);
|
||||
assert(sends==sent && wiped_responses==wipes+1);
|
||||
}
|
||||
server.config.max_resp_headers=header_capacity;
|
||||
hook_id=admin.view.id; rng_hook=invalidate_hook;
|
||||
generate_begin(&admin); generate_expect("401 Unauthorized"); assert(!strstr(output,"password"));
|
||||
admin=mint(&alice);
|
||||
/* Generate even with the operation slot occupied; it must not touch it. */
|
||||
submit_account(&admin,create_body); generate_begin(&admin); generate_expect("200 OK"); execute_account();
|
||||
web_session_store_invalidate(admin.view.id);
|
||||
rng=rng_calls; generate_begin(&admin); generate_expect("401 Unauthorized"); assert(rng_calls==rng);
|
||||
puts("PASS Accounts generated value: real RNG helper/auth/store, bodyless/admin/currentness/Origin/CSRF, base64url/no-store, no slot/DB write, success/RNG/header/send cleanup");
|
||||
}
|
||||
static void expire_queued(void) { now=s_operation.deadline; timer_callback(NULL); }
|
||||
static void executing_tick(void) {
|
||||
assert(s_operation.executing); check_slot_wiped();
|
||||
now=s_operation.deadline; timer_callback(NULL);
|
||||
assert(s_operation.executing && s_operation.state==PENDING);
|
||||
web_account_settings_execute(s_operation.id); /* Duplicate dispatcher delivery. */
|
||||
}
|
||||
static void validation_tick(void) {
|
||||
assert(s_operation.executing); check_slot_wiped();
|
||||
timer_callback(NULL); assert(s_operation.state==PENDING);
|
||||
}
|
||||
static issued_t busy_actor;
|
||||
static void executing_busy_request(void) {
|
||||
assert(dispatcher && s_operation.executing);
|
||||
dispatcher=false; account_begin(&busy_actor,create_body); account_expect("503 Service Unavailable"); dispatcher=true;
|
||||
assert(s_operation.executing); check_slot_wiped();
|
||||
}
|
||||
static void credential_tests(void) {
|
||||
auth_reset(); issued_t admin=mint(&alice); receive_fragment=768;
|
||||
unsigned before=mutations;
|
||||
timer_create_fail=true; account_begin(&admin,create_body); account_expect("503 Service Unavailable"); timer_create_fail=false;
|
||||
assert(!s_secret_timer && mutations==before);
|
||||
timer_start_fail=true; account_begin(&admin,password_body); account_expect("503 Service Unavailable"); timer_start_fail=false;
|
||||
assert(s_secret_timer && !s_secret_timer_started && mutations==before);
|
||||
submit_account(&admin,password_body); assert(timer_creates==2 && timer_starts==2);
|
||||
uint32_t old=queued; assert(s_operation.password_length==12);
|
||||
now=s_operation.deadline-1; timer_callback(NULL); assert(s_operation.state==PENDING);
|
||||
++now; timer_callback(NULL); assert(s_operation.state==CANCELLED); check_slot_wiped();
|
||||
submit_account(&admin,create_body); timer_callback(NULL); assert(s_operation.state==PENDING);
|
||||
dispatcher=true; web_account_settings_execute(old); dispatcher=false;
|
||||
assert(s_operation.state==PENDING && mutations==before);
|
||||
execute_account(); assert(mutations==before+1 && s_operation.state==OK); check_slot_wiped();
|
||||
assert(timer_creates==2 && timer_starts==2);
|
||||
/* Timeout between publication and queue submission leaves only a stale ID. */
|
||||
queue_hook=expire_queued; account_begin(&admin,password_body); account_expect("202 Accepted");
|
||||
assert(s_operation.state==CANCELLED); execute_account(); assert(mutations==before+1); check_slot_wiped();
|
||||
queue_fail=true; account_begin(&admin,password_body); account_expect("503 Service Unavailable"); queue_fail=false;
|
||||
zero(&s_operation,sizeof(s_operation));
|
||||
submit_account(&admin,password_body); db_hook=validation_tick; execute_account();
|
||||
assert(s_operation.state==OK && !executing_password); check_slot_wiped();
|
||||
submit_account(&admin,password_body); mutation_hook=executing_tick; execute_account();
|
||||
assert(s_operation.state==OK && !s_operation.executing && !executing_password);
|
||||
/* Tick during external validation, followed by deadline check: cancelled. */
|
||||
submit_account(&admin,password_body); db_hook=executing_tick; before=mutations; execute_account();
|
||||
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
|
||||
submit_account(&admin,password_body); web_session_store_invalidate(admin.view.id); execute_account();
|
||||
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
|
||||
admin=mint(&alice);
|
||||
submit_account(&admin,password_body); stale_user=alice.user_id; execute_account(); stale_user=0;
|
||||
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
|
||||
admin=mint(&alice);
|
||||
submit_account(&admin,password_body); db_fail=true; execute_account(); db_fail=false;
|
||||
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
|
||||
admin=mint(&alice);
|
||||
const esp_err_t errors[]={ESP_ERR_NOT_FOUND,ESP_FAIL,ESP_ERR_INVALID_STATE,ESP_ERR_NO_MEM};
|
||||
const unsigned states[]={STALE,FAILED,DUPLICATE,FULL};
|
||||
unsigned revokes=web_revokes;
|
||||
for (unsigned i=0;i<4;++i) {
|
||||
mutation_error=errors[i]; submit_account(&admin,i<2?password_body:create_body); execute_account();
|
||||
assert(s_operation.state==states[i] && web_revokes==revokes && !executing_password); check_slot_wiped();
|
||||
}
|
||||
mutation_error=ESP_OK;
|
||||
send_fail=true; account_begin(&admin,password_body); account_expect("202 Accepted"); send_fail=false;
|
||||
assert(s_operation.password_length==12); execute_account(); assert(s_operation.state==OK); check_slot_wiped();
|
||||
/* Keep executing ownership even if another request arrives during validation. */
|
||||
busy_actor=admin; submit_account(&admin,password_body); db_hook=executing_busy_request; execute_account();
|
||||
assert(s_operation.state==OK); check_slot_wiped();
|
||||
queue_fail=true; queue_hook=expire_queued;
|
||||
account_begin(&admin,create_body); account_expect("503 Service Unavailable"); queue_fail=false;
|
||||
zero(&s_operation,sizeof(s_operation));
|
||||
assert(timer_creates==2 && timer_starts==2);
|
||||
puts("PASS Accounts credential queue: timer create/start failure, explicit expiry, replacement/old IDs, queue failure, validation/execution ticks, stale/dead sessions, error and lost-ack cleanup");
|
||||
}
|
||||
static void password_parser_tests(void) {
|
||||
auth_reset(); issued_t admin=mint(&alice); receive_fragment=768;
|
||||
char body[800];
|
||||
const char *valid[]={"password1234", "space space ", "quote\\\"slash\\\\", "\\u0020\\u0022\\u005c123456789", "slash\\/1234567"};
|
||||
for (unsigned i=0;i<sizeof(valid)/sizeof(*valid);++i) {
|
||||
snprintf(body,sizeof(body),"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"%s\"}",valid[i]);
|
||||
account_operation_t parsed={0}; assert(parse(body,strlen(body),&parsed));
|
||||
assert(user_database_password_valid(parsed.password,parsed.password_length));
|
||||
if (i==2) assert(parsed.password_length==12 && !memcmp(parsed.password,"quote\"slash\\",12));
|
||||
secure_wipe(&parsed,sizeof(parsed));
|
||||
submit_account(&admin,body); execute_account(); assert(s_operation.state==OK);
|
||||
}
|
||||
const char *invalid[]={"", "12345678901", "12345678901\\n", "12345678901\\t", "12345678901\\u0000", "12345678901\\u007f", "12345678901\\u0080", "12345678901\\uD800", "12345678901\\x20"};
|
||||
for (unsigned i=0;i<sizeof(invalid)/sizeof(*invalid);++i) {
|
||||
snprintf(body,sizeof(body),"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"%s\"}",invalid[i]);
|
||||
account_begin(&admin,body); account_expect("400 Bad Request");
|
||||
}
|
||||
for (unsigned n=11;n<=65;++n) {
|
||||
char escaped[391];
|
||||
for (unsigned i=0;i<n;++i) memcpy(escaped+i*6,"\\u0022",6);
|
||||
escaped[n*6]=0;
|
||||
snprintf(body,sizeof(body),"{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"%s\"}",escaped);
|
||||
account_begin(&admin,body);
|
||||
account_expect(n>=12 && n<=64 ? "202 Accepted" : "400 Bad Request");
|
||||
if (n>=12 && n<=64) { assert(s_operation.password_length==n); execute_account(); }
|
||||
}
|
||||
const char *bad[]={
|
||||
"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\",\"user_id\":7}",
|
||||
"{\"action\":\"password\",\"username\":\"carol\",\"password\":\"password1234\"}",
|
||||
"{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\",\"role\":\"admin\"}",
|
||||
"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\",\"password\":\"password5678\"}"};
|
||||
for (unsigned i=0;i<sizeof(bad)/sizeof(*bad);++i) { account_begin(&admin,bad[i]); account_expect("400 Bad Request"); }
|
||||
for (size_t i=0;i<strlen(password_body);++i) {
|
||||
account_begin(&admin,password_body); req.content_len=aux.remaining_len=i;
|
||||
account_expect("400 Bad Request");
|
||||
}
|
||||
size_t n=strlen(create_body); memcpy(body,create_body,n); memset(body+n,' ',768-n); body[768]=0;
|
||||
receive_fragment=192; submit_account(&admin,body); execute_account();
|
||||
receive_fragment=191; account_begin(&admin,body); account_expect("400 Bad Request");
|
||||
receive_fragment=768; recv_fail=true; account_begin(&admin,password_body); account_expect("400 Bad Request"); recv_fail=false;
|
||||
puts("PASS Accounts passwords: decoded quote/backslash/space/slash/Unicode escapes, printable ASCII 12..64, exact schemas, truncations, 768-byte/four-receive bound");
|
||||
}
|
||||
static void self_tests(void) {
|
||||
const char *bodies[]={
|
||||
"{\"action\":\"role\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}",
|
||||
"{\"action\":\"delete\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2}",
|
||||
"{\"action\":\"password\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\"}"};
|
||||
self_target=true;
|
||||
for (unsigned i=0;i<3;++i) {
|
||||
auth_reset(); issued_t admin=mint(&alice), sibling=mint(&alice), user=mint(&bob);
|
||||
unsigned revokes=web_revokes;
|
||||
mutation_error=ESP_ERR_INVALID_STATE; submit_account(&admin,bodies[i]); execute_account();
|
||||
assert(s_operation.state==PROTECTED && web_revokes==revokes);
|
||||
account_begin(&admin,NULL); account_expect("200 OK"); assert(strstr(output,"protected"));
|
||||
mutation_error=ESP_OK; submit_account(&admin,bodies[i]); execute_account();
|
||||
assert(s_operation.state==OK && web_revokes==revokes+1 && !executing_password);
|
||||
account_begin(&admin,NULL); account_expect("401 Unauthorized"); assert(!strstr(output,"\"state\""));
|
||||
account_begin(&sibling,NULL); account_expect("401 Unauthorized");
|
||||
bool current=false; assert(web_session_store_check_principal(user.view.id,&user.view.principal,¤t)==ESP_OK && current);
|
||||
}
|
||||
self_target=false;
|
||||
puts("PASS Accounts self role/delete/password: protected failures keep login, success immediately target-revokes all logins, stale result reads denied, unrelated user survives");
|
||||
}
|
||||
static void account_settings_tests(void) {
|
||||
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob), other=mint(&alice); receive_fragment=64;
|
||||
account_begin(NULL,deletion); account_expect("401 Unauthorized");
|
||||
account_begin(&user,deletion); account_expect("403 Forbidden");
|
||||
for (unsigned mode=0;mode<8;++mode) {
|
||||
account_begin(&admin,deletion);
|
||||
if (mode==0) req.content_len=aux.remaining_len=769;
|
||||
if (mode==1) req.uri="/api/settings/account-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");
|
||||
unsigned before=s_next_id; (void)web_account_settings_handler(&req);
|
||||
assert(response_status[0]=='4' && s_next_id==before && !mutations);
|
||||
}
|
||||
puts("PASS Accounts security: current cookie/admin, body/query/framing/CSRF/Origin bounds");
|
||||
const char *bad[]={"{}","[]","{\"action\":\"password\"}","{\"action\":\"delete\",\"action\":\"role\"}",
|
||||
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":0,\"auth_generation\":2}",
|
||||
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":4294967296,\"auth_generation\":2}",
|
||||
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":07,\"auth_generation\":2}",
|
||||
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7e0,\"auth_generation\":2}",
|
||||
"{\"action\":\"delete\",\"username\":\"c\\u0061rol\",\"user_id\":7,\"auth_generation\":2}",
|
||||
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"role\":\"user\"}"};
|
||||
for (unsigned i=0;i<sizeof(bad)/sizeof(*bad);++i) { account_begin(&admin,bad[i]); account_expect("400 Bad Request"); }
|
||||
for (size_t n=0;n<strlen(role_body);++n) { account_operation_t out={0}; assert(!parse(role_body,n,&out)); }
|
||||
account_operation_t parsed={0}; assert(parse(role_body,strlen(role_body),&parsed));
|
||||
receive_fragment=1; account_begin(&admin,deletion); account_expect("400 Bad Request"); receive_fragment=64;
|
||||
puts("PASS Accounts strict bounded schema/parser and fragmented-body rejection");
|
||||
account_begin(&admin,NULL); req.uri="/api/settings/accounts"; account_expect("200 OK");
|
||||
assert(strstr(output,"account000000007") && !strstr(output,"password") && !strstr(output,"key"));
|
||||
list_fail=true; account_begin(&admin,NULL); req.uri="/api/settings/accounts"; account_expect("503 Service Unavailable"); list_fail=false;
|
||||
unsigned reads=lists; account_begin(&user,NULL); req.uri="/api/settings/accounts"; account_expect("403 Forbidden"); assert(lists==reads);
|
||||
puts("PASS Accounts list: eight bounded public projections, zero mutation and unavailable/role isolation");
|
||||
queue_fail=true; account_begin(&admin,deletion); account_expect("503 Service Unavailable"); queue_fail=false;
|
||||
submit_account(&admin,deletion); uint32_t old=queued;
|
||||
account_begin(&other,role_body); account_expect("503 Service Unavailable");
|
||||
account_begin(&other,NULL); account_expect("200 OK"); assert(strstr(output,"\"id\":0"));
|
||||
account_begin(&admin,NULL); account_expect("200 OK"); assert(strstr(output,"pending"));
|
||||
execute_account(); assert(s_operation.state==OK && mutations==1 && web_revokes==1 && ssh_revokes==1);
|
||||
execute_account(); assert(mutations==1);
|
||||
submit_account(&other,role_body); dispatcher=true; web_account_settings_execute(old); web_account_settings_execute(0); dispatcher=false;
|
||||
assert(mutations==1 && s_operation.state==PENDING); execute_account(); assert(mutations==2 && web_revokes==2);
|
||||
account_begin(&admin,NULL); account_expect("200 OK"); assert(strstr(output,"\"id\":0"));
|
||||
puts("PASS Accounts single pending slot, session isolation, stale queued IDs and target-only best-effort notifications");
|
||||
const esp_err_t errors[]={ESP_FAIL,ESP_ERR_NOT_FOUND,ESP_ERR_INVALID_STATE};
|
||||
const unsigned states[]={FAILED,STALE,PROTECTED};
|
||||
for (unsigned i=0;i<3;++i) { mutation_error=errors[i]; submit_account(&admin,deletion); execute_account(); assert(s_operation.state==states[i] && web_revokes==2 && ssh_revokes==2); }
|
||||
mutation_error=ESP_OK;
|
||||
unsigned before=mutations; submit_account(&admin,deletion); s_operation.deadline=0; execute_account(); assert(s_operation.state==CANCELLED && mutations==before);
|
||||
submit_account(&admin,deletion); web_session_store_invalidate(admin.view.id); execute_account(); assert(s_operation.state==CANCELLED && mutations==before);
|
||||
submit_account(&other,deletion); mutation_hook=invalidate_actor; execute_account(); assert(s_operation.state==OK && mutations==before+1 && web_revokes==3);
|
||||
zero(&s_operation.principal,sizeof(s_operation.principal)); zero(&s_operation.target,sizeof(s_operation.target));
|
||||
auth_reset(); admin=mint(&alice); before=mutations;
|
||||
submit_account(&admin,deletion); db_fail=true; execute_account(); db_fail=false;
|
||||
assert(s_operation.state==CANCELLED && mutations==before);
|
||||
admin=mint(&alice);
|
||||
submit_account(&admin,deletion); stale_user=alice.user_id; execute_account(); stale_user=0;
|
||||
assert(s_operation.state==CANCELLED && mutations==before);
|
||||
puts("PASS Accounts execution failure/stale/protected results, dequeue cancellation, missed account revocation/DB failure and admitted-work completion after expiry");
|
||||
credential_tests(); password_parser_tests(); generated_tests(); self_tests();
|
||||
assert(wiped_passwords && wiped_bodies && wiped_generated && wiped_responses);
|
||||
}
|
||||
@@ -45,6 +45,15 @@ 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
|
||||
accounts = "--accounts" in sys.argv
|
||||
if accounts:
|
||||
HEADERS["esp_timer.h"] += """
|
||||
#include <stdbool.h>
|
||||
typedef void *esp_timer_handle_t;
|
||||
typedef struct { void (*callback)(void *); const char *name; bool skip_unhandled_events; } esp_timer_create_args_t;
|
||||
int esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *);
|
||||
int esp_timer_start_periodic(esp_timer_handle_t, uint64_t);
|
||||
"""
|
||||
if admin:
|
||||
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
|
||||
HEADERS["esp_heap_caps.h"] = """#pragma once
|
||||
@@ -132,6 +141,11 @@ 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 accounts:
|
||||
db_source = (ROOT / 'src/user_database.c').read_text()
|
||||
alphabet_start = db_source.index('static const uint8_t s_generated_alphabet')
|
||||
alphabet = db_source[alphabet_start:db_source.index(';', alphabet_start) + 1]
|
||||
(tmp / 'account_parse_production.h').write_text(alphabet + '\n' + '\n'.join(function(db_source, name) for name in ('user_database_username_valid', 'user_database_password_valid', 'user_role_parse', 'user_role_to_string', 'user_database_generate_password_value')))
|
||||
if serial_settings:
|
||||
config_source = (ROOT / 'src/serial_config.c').read_text()
|
||||
names = ['serial_config_defaults', 'serial_config_validate']
|
||||
@@ -155,6 +169,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_ADMIN"] if admin else []),
|
||||
*(["-DHOST_SETTINGS"] if settings else []),
|
||||
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings else []),
|
||||
*(["-DHOST_ACCOUNTS"] if accounts 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)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/* Production store dependency doubles and its existing public API suite. */
|
||||
#define main store_tests
|
||||
#ifdef HOST_ACCOUNTS
|
||||
#define user_database_username_valid store_username_valid
|
||||
#endif
|
||||
#include "../web_session_store/test.c"
|
||||
#undef main
|
||||
#ifdef HOST_ACCOUNTS
|
||||
#undef user_database_username_valid
|
||||
#endif
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "esp_httpd_priv.h"
|
||||
@@ -126,6 +132,9 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_SERIAL_SETTINGS
|
||||
#include "serial_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_ACCOUNTS
|
||||
#include "account_settings_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -279,6 +288,9 @@ int main(void) {
|
||||
#endif
|
||||
#ifdef HOST_SERIAL_SETTINGS
|
||||
serial_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_ACCOUNTS
|
||||
account_settings_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,24 @@ Coverage:
|
||||
- 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.**
|
||||
- Accounts: admin-only strict eight-user/1,024-byte list, no secret/key fields,
|
||||
confirmed identity-bound role/delete, automatic completion/list refresh,
|
||||
ten-check limit/manual recovery, stale/protected/failure and lost-acknowledgement
|
||||
handling, cancellation, and 401/identity isolation. The original 41 groups remain;
|
||||
the former self-denial assertion now checks enabled self actions and confirmation cancellation.
|
||||
- Second slice: exact create/password JSON and CSRF, 768-byte request ceiling,
|
||||
untrimmed 12–64 printable ASCII passwords including spaces/quotes/backslashes,
|
||||
confirmation and username validation, separate bodyless generation without
|
||||
mutation/list changes, strict 24-character base64url/96-byte generation response.
|
||||
- Generated acknowledgement binds value and operation/target identity; edits,
|
||||
regeneration, target/purpose changes reset it. 60-second lifetime, including
|
||||
delayed timer admission checks; submission/cancel/failure and all lifecycle wipes.
|
||||
Late headers/streamed bodies, concurrent reconnect and newer snapshots are fenced.
|
||||
- Self password/role/delete warnings and POST/poll 401 close both routes without
|
||||
success claims or proactive logout. Safe duplicate/full messages and no routine
|
||||
secret outputs, storage, clipboard writes or history APIs.
|
||||
**57 Node groups total**, plus renderer/HTML/CSP checks, reported PASS by the UI
|
||||
continuation agent (four added beyond its earlier 53-group slice 2 run).
|
||||
|
||||
## Automatic result-check budget
|
||||
|
||||
@@ -91,7 +108,8 @@ 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.
|
||||
asks for confirmation among Serial actions, specifically because it overwrites saved
|
||||
configuration. Every Accounts mutation retains an explicit confirmation.
|
||||
|
||||
Tests use a deterministic clock and individually fired timer callbacks, including
|
||||
callbacks invoked after cancellation and fetch/body doubles that ignore abort.
|
||||
@@ -100,8 +118,10 @@ These deliberately exercise fences beyond normal browser cancellation behavior.
|
||||
## Integration and known gaps
|
||||
|
||||
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
|
||||
Serial UI and both 8D.10 Accounts slices. Operation/generation responses are fetch
|
||||
doubles, not end-to-end execution of `web_serial_settings.c`,
|
||||
`web_account_settings.c`, dispatcher work, credential generation/derivation,
|
||||
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.
|
||||
@@ -110,7 +130,13 @@ These tests model DOM, timers, fetch cancellation and WebSocket events. They do
|
||||
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
|
||||
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
|
||||
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout.
|
||||
Prior 8D.6 signoff stands; the new Settings build and pending target checklist are in
|
||||
`docs/phase8d8_implementation.md`. No target resource reserve is claimed. Browser secret
|
||||
Prior 8D.6 signoff stands; current slice 2 contracts and pending target checklist are in
|
||||
`docs/phase8d10_implementation.md`. Implementation is complete, host-tested/build-verified,
|
||||
not target accepted: parent build PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash.
|
||||
The generated endpoint is independently optionally registered (23 handlers), with
|
||||
route-agent lifecycle 15 PASS for failure isolation/restart. UI 57/CSP and lifecycle
|
||||
15 results are agent-attributed, not claims of the parent's additional reruns.
|
||||
Target/signoff and new timer runtime measurements remain open; no sanitizer,
|
||||
device/assets/commit/8D.11 action or target resource reserve is claimed. Browser secret
|
||||
references are dropped and never persisted/logged, but JavaScript cannot securely
|
||||
wipe engine-managed strings.
|
||||
|
||||
@@ -11,9 +11,9 @@ const serialSettings = (extra = {}) => ({running: true, baud: 230400, data_bits:
|
||||
const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}});
|
||||
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
|
||||
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
||||
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': [], '/api/settings/serial-operation': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': []};
|
||||
const fits = [];
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
@@ -39,7 +39,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
|
||||
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
|
||||
const context = vm.createContext({window, document: {getElementById(id) {
|
||||
return nodes[id] ||= {textContent: '', dataset: {}, classList: {toggle() {}},
|
||||
return nodes[id] ||= {textContent: '', value: '', checked: false, dataset: {}, classList: {toggle() {}},
|
||||
setAttribute(k, v) { this[k] = v; },
|
||||
getBoundingClientRect: () => ({width: 100, height: 100}),
|
||||
addEventListener(k, fn) { this[k] = fn; }};
|
||||
@@ -56,9 +56,10 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
calls.push({url, ...options});
|
||||
const next = queues[url].shift();
|
||||
if (next !== undefined) return typeof next === 'function' ? next(options) : next;
|
||||
if (url === '/api/session') return session({role});
|
||||
if (url === '/api/session') return session({role, username});
|
||||
if (url === '/api/status') return json({});
|
||||
if (url === '/api/settings/serial') return json(serialSettings());
|
||||
if (url === '/api/settings/accounts') return json({users: [{username: 'alice', user_id: 1, auth_generation: 2, role: 'admin'}, {username: 'carol', user_id: 7, auth_generation: 2, role: 'user'}]});
|
||||
if (url === '/api/ws-ticket') return ticket();
|
||||
if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30});
|
||||
throw new Error('network unavailable');
|
||||
@@ -782,6 +783,94 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
assert.equal(b.nodes['setting-baud'].textContent, '230400');
|
||||
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
|
||||
});
|
||||
async function accountsBrowser() {
|
||||
const b = browser({role: 'admin', username: 'alice'}); b.start(); await tick(); b.sockets[0].emit('open');
|
||||
b.click('select-admin'); b.click('admin-toggle'); await tick(); b.sockets[1].emit('open');
|
||||
b.click('select-settings'); await tick(); b.click('settings-accounts'); await tick();
|
||||
return b;
|
||||
}
|
||||
const accountPath = '/api/settings/account-operation';
|
||||
const accountReply = (id, state, action = 'role') => json({id, state, action});
|
||||
await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => {
|
||||
const u = await connected(); u.click('settings-accounts'); await tick();
|
||||
assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts'));
|
||||
const b = await accountsBrowser();
|
||||
assert.match(b.nodes['accounts-list'].textContent, /alice.*admin.*you/);
|
||||
assert.match(b.nodes['accounts-list'].textContent, /carol.*user/);
|
||||
assert.ok(!b.nodes['account-delete'].disabled);
|
||||
const calls=b.calls.length; b.window.confirm=()=>false; b.click('account-delete'); await tick(); assert.equal(b.calls.length,calls); b.window.confirm=()=>true;
|
||||
b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); assert.ok(!b.nodes['account-delete'].disabled);
|
||||
for(let i=0;i<3;++i) { b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); }
|
||||
assert.equal(b.sockets.length,2); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
|
||||
for (const bad of [{users:[{username:'<img>',role:'user',user_id:1,auth_generation:1}]}, {users:Array(9).fill({})}, {users:[],password:'SECRET'}, {users:[{username:'safe',role:'admin',user_id:0,auth_generation:1}]}]) {
|
||||
b.queues['/api/settings/accounts'].push(json(bad)); b.click('refresh-accounts'); await tick();
|
||||
assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.ok(!b.nodes['accounts-list'].textContent.includes('SECRET'));
|
||||
}
|
||||
});
|
||||
await test('Accounts role/delete confirmation, typed identity, automatic result and list refresh', async () => {
|
||||
for (const action of ['role','delete']) {
|
||||
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); b.nodes['account-role'].value='admin';
|
||||
const button=action==='role'?'account-change-role':'account-delete', before=b.calls.length;
|
||||
b.window.confirm=()=>false; b.click(button); await tick(); assert.equal(b.calls.length,before);
|
||||
b.window.confirm=()=>true; b.queues[accountPath].push(accountReply(10,'pending',action)); b.click(button); await tick();
|
||||
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1);
|
||||
assert.deepEqual(JSON.parse(posts[0].body),{action,username:'carol',user_id:7,auth_generation:2,...(action==='role'?{role:'admin'}:{})});
|
||||
assert.equal(posts[0].headers['X-CSRF-Token'],token); assert.ok(b.nodes['account-target'].disabled);
|
||||
for (const state of ['pending','ok']) { b.queues[accountPath].push(accountReply(10,state,action)); b.fire(1000); await tick(); }
|
||||
assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/);
|
||||
assert.match(b.nodes['accounts-detail'].textContent,/refreshed/);
|
||||
assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,2);
|
||||
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
|
||||
}
|
||||
});
|
||||
await test('Accounts bounded checks exhaust to manual recovery without POST retry', async () => {
|
||||
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
|
||||
b.queues[accountPath].push(accountReply(11,'pending')); b.click('account-change-role'); await tick();
|
||||
for(let i=0;i<10;++i) { b.queues[accountPath].push(accountReply(11,'pending')); b.elapse(1000); b.fire(1000); await tick(); }
|
||||
assert.match(b.nodes['account-operation-detail'].textContent,/stopped.*Check Result/); assert.ok(!b.nodes['account-result'].disabled);
|
||||
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='GET').length,10);
|
||||
b.queues[accountPath].push(accountReply(11,'ok')); b.click('account-result'); await tick();
|
||||
assert.match(b.nodes['account-operation-detail'].textContent,/completed/);
|
||||
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1);
|
||||
});
|
||||
await test('Accounts cancellation fences pending posts, checks and refreshes on domain/view/pagehide', async () => {
|
||||
for (const mode of ['domain','view','pagehide']) {
|
||||
const b=await accountsBrowser(), d=deferred(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
|
||||
b.queues[accountPath].push(d.promise); b.click('account-change-role'); await tick();
|
||||
const request=b.calls.filter(c=>c.url===accountPath).at(-1);
|
||||
if(mode==='domain') b.click('settings-serial'); else if(mode==='view') b.click('select-serial'); else b.emit('pagehide');
|
||||
assert.ok(request.signal.aborted); const detail=b.nodes['account-operation-detail'].textContent;
|
||||
d.resolve(accountReply(12,'pending')); await tick(); assert.equal(b.nodes['account-operation-detail'].textContent,detail);
|
||||
assert.equal(b.nodes['accounts-list'].textContent,'');
|
||||
assert.ok(!b.calls.some(c=>c.url===accountPath && c.method==='GET'));
|
||||
}
|
||||
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
|
||||
b.queues[accountPath].push(accountReply(13,'pending')); b.click('account-change-role'); await tick();
|
||||
b.click('settings-serial'); await tick(); assert.ok(![...b.timers.values()].some(t=>t.ms===1000 || t.ms===15000));
|
||||
});
|
||||
await test('Accounts timeout, stale/protected/failed outcomes, failed refresh and unknown acknowledgement', async () => {
|
||||
for(const state of ['stale','protected','failed','cancelled']) {
|
||||
const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(14,state)); b.queues['/api/settings/accounts'].push(failure(503));
|
||||
b.click('account-result'); await tick(); assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.match(b.nodes['accounts-list'].textContent,/carol/);
|
||||
}
|
||||
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
|
||||
b.queues[accountPath].push(()=>{throw new Error('lost');}); b.click('account-change-role'); await tick();
|
||||
assert.match(b.nodes['account-operation-detail'].textContent,/unknown/);
|
||||
for(let i=0;i<2;++i) { b.queues[accountPath].push(accountReply(15,'ok')); b.click('account-result'); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/Acknowledgement lost/); }
|
||||
b.queues[accountPath].push(accountReply(16,'pending')); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin'; b.click('account-change-role'); await tick();
|
||||
const d=deferred(); b.queues[accountPath].push(d.promise); b.fire(1000); await tick();
|
||||
b.elapse(15000); b.fire(15000); await tick(); d.resolve(accountReply(16,'ok')); await tick();
|
||||
assert.match(b.nodes['account-operation-detail'].textContent,/unknown/); assert.ok(!b.nodes['account-result'].disabled);
|
||||
});
|
||||
await test('Accounts 401 and identity changes close routes without adopting stale list', async () => {
|
||||
for(const identity of [false,true]) {
|
||||
const b=await accountsBrowser();
|
||||
if(identity) b.queues['/api/session'].push(session({role:'admin',username:'replacement'}));
|
||||
else b.queues['/api/settings/accounts'].push(failure(401));
|
||||
b.click('refresh-accounts'); await tick(); assert.deepEqual(b.redirects,[identity?'/':'/login']);
|
||||
assert.ok(b.sockets.every(s=>s.closed)); assert.equal(b.nodes['accounts-list'].textContent,'');
|
||||
}
|
||||
});
|
||||
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; };
|
||||
@@ -796,5 +885,209 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
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);
|
||||
});
|
||||
const generatePath = '/api/settings/accounts/generate-password', secret = 'Abcdefghijklmnopqrst_-12';
|
||||
function draft(b, purpose = 'create', password = ' typed "pass\\word ') {
|
||||
b.nodes['account-purpose'].value = purpose; b.nodes['account-purpose'].change();
|
||||
if (purpose === 'create') { b.nodes['account-username'].value = 'new_account'; b.nodes['account-username'].input(); }
|
||||
b.nodes['account-password'].value = b.nodes['account-password-confirm'].value = password;
|
||||
}
|
||||
function cleanSecret(b) {
|
||||
for (const id of ['account-password','account-password-confirm','account-generated']) assert.equal(b.nodes[id].value, '', id);
|
||||
assert.equal(b.nodes['account-password-saved'].checked, false); assert.ok(b.nodes['account-generated-panel'].hidden);
|
||||
}
|
||||
function noSecretOutput(b, value = secret) {
|
||||
for (const [id, node] of Object.entries(b.nodes)) assert.ok(!node.textContent.includes(value), id);
|
||||
}
|
||||
async function generated(b) {
|
||||
b.queues[generatePath].push(json({password: secret})); b.click('account-generate'); await tick();
|
||||
assert.equal(b.nodes['account-generated'].value, secret);
|
||||
b.nodes['account-password-confirm'].value = secret; b.nodes['account-password-confirm'].input();
|
||||
b.nodes['account-password-saved'].checked = true; b.nodes['account-password-saved'].change();
|
||||
}
|
||||
await test('Create/password exact JSON, escaped printable ASCII and bounds, CSRF and isolated sockets', async () => {
|
||||
for (const purpose of ['create','password']) for (const password of [' '.repeat(12), ' typed "pass\\word ', '\\'.repeat(64)]) {
|
||||
const b = await accountsBrowser(); draft(b, purpose, password);
|
||||
b.nodes['account-target'].value = '1'; b.nodes['account-create-role'].value = 'admin';
|
||||
b.queues[accountPath].push(accountReply(20, 'pending', purpose)); b.click('account-submit-password'); cleanSecret(b); await tick();
|
||||
const post = b.calls.find(c => c.url === accountPath && c.method === 'POST'); assert.ok(post);
|
||||
assert.deepEqual(JSON.parse(post.body), purpose === 'create' ? {action:purpose, username:'new_account', role:'admin', password} : {action:purpose, username:'carol', user_id:7, auth_generation:2, password});
|
||||
assert.ok(Buffer.byteLength(post.body) <= 768); assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
|
||||
b.queues[accountPath].push(accountReply(20, 'ok', purpose)); b.fire(1000); await tick();
|
||||
noSecretOutput(b, password); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
|
||||
}
|
||||
});
|
||||
await test('Credential validation rejects short/long/non-ASCII/mismatch and invalid usernames; every attempt wipes', async () => {
|
||||
for (const password of ['a'.repeat(11), 'a'.repeat(65), 'é'.repeat(12), 'abcde\nfghijklm', 'abcde\u007ffghijklm']) {
|
||||
const b=await accountsBrowser(); draft(b, 'create', password); b.click('account-submit-password'); await tick(); cleanSecret(b);
|
||||
assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
}
|
||||
for (const name of ['', 'Aname', 'a'.repeat(17), 'a b', '<img>']) {
|
||||
const b=await accountsBrowser(); draft(b); b.nodes['account-username'].value=name; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
}
|
||||
const b=await accountsBrowser(); draft(b); b.nodes['account-password-confirm'].value='different password'; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
});
|
||||
await test('Explicit generation is bodyless CSRF, bounded, separate from account list and mutation slot', async () => {
|
||||
const b=await accountsBrowser(); draft(b); const listReads=b.calls.filter(c=>c.url==='/api/settings/accounts').length;
|
||||
assert.ok(!b.calls.some(c=>c.url===generatePath)); await generated(b);
|
||||
const post=b.calls.find(c=>c.url===generatePath); assert.equal(post.body,''); assert.equal(post.headers['X-CSRF-Token'],token); assert.equal(post.headers['Content-Type'],undefined);
|
||||
assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,listReads); assert.ok(!b.calls.some(c=>c.url===accountPath)); noSecretOutput(b);
|
||||
assert.ok([...b.timers.values()].some(t=>t.ms===60000));
|
||||
b.queues[accountPath].push(accountReply(21,'pending','create')); b.click('account-submit-password'); cleanSecret(b); await tick();
|
||||
assert.equal(JSON.parse(b.calls.find(c=>c.url===accountPath).body).password,secret);
|
||||
});
|
||||
await test('Generated acknowledgement binds exact value and intent; edits and regeneration reset it', async () => {
|
||||
for (const edit of ['unchecked','password','confirm','username','role','target','purpose','regenerate','silent-target','silent-password']) {
|
||||
const b=await accountsBrowser(); draft(b); await generated(b);
|
||||
if(edit==='unchecked') b.nodes['account-password-saved'].checked=false;
|
||||
if(edit==='password') { b.nodes['account-password'].value='different password'; b.nodes['account-password'].input(); }
|
||||
if(edit==='confirm') b.nodes['account-password-confirm'].input();
|
||||
if(edit==='username') { b.nodes['account-username'].value='another'; b.nodes['account-username'].input(); }
|
||||
if(edit==='role') { b.nodes['account-create-role'].value='admin'; b.nodes['account-create-role'].change(); }
|
||||
if(edit==='target' || edit==='silent-target') { b.nodes['account-target'].value='1'; if(edit==='target') b.nodes['account-target'].change(); }
|
||||
if(edit==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); }
|
||||
if(edit==='regenerate') { b.queues[generatePath].push(json({password:secret})); b.click('account-generate'); await tick(); }
|
||||
if(edit==='silent-password') b.nodes['account-password'].value=b.nodes['account-password-confirm'].value='different password';
|
||||
if(!edit.startsWith('silent')) assert.equal(b.nodes['account-password-saved'].checked,false);
|
||||
b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
}
|
||||
});
|
||||
await test('Generated lifetime wipes at 60 seconds and expired delayed timer cannot authorize submission', async () => {
|
||||
for(const timer of [true,false]) {
|
||||
const b=await accountsBrowser(); draft(b); await generated(b); b.elapse(60000);
|
||||
if(timer) b.fire(60000); else b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
}
|
||||
});
|
||||
await test('All secret lifecycle cleanup: view/domain/target/purpose/refresh/pagehide/logout/identity/401', async () => {
|
||||
for(const mode of ['view','domain','target','purpose','refresh','pagehide','logout','identity','401']) {
|
||||
const b=await accountsBrowser(); draft(b); await generated(b);
|
||||
if(mode==='view') b.click('select-serial');
|
||||
if(mode==='domain') b.click('settings-serial');
|
||||
if(mode==='target') { b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); }
|
||||
if(mode==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); }
|
||||
if(mode==='refresh') b.click('refresh-accounts');
|
||||
if(mode==='pagehide') b.emit('pagehide');
|
||||
if(mode==='logout') b.click('sign-out');
|
||||
if(mode==='identity' || mode==='401') { b.queues['/api/session'].push(mode==='identity'?session({role:'admin',username:'newadmin'}):failure(401)); b.click('refresh-accounts'); }
|
||||
await tick(); cleanSecret(b); noSecretOutput(b); assert.ok(![...b.timers.values()].some(t=>t.ms===60000));
|
||||
}
|
||||
});
|
||||
await test('Late generation headers and streamed bodies cannot resurrect secrets after form cancellation', async () => {
|
||||
for(const body of [false,true]) for(const mode of ['target','edit','purpose','domain','pagehide','logout','refresh']) {
|
||||
const b=await accountsBrowser(); draft(b); const d=deferred(); let stream;
|
||||
b.queues[generatePath].push(body?new Response(new ReadableStream({start(c){stream=c;}})):d.promise);
|
||||
b.click('account-generate'); await tick(); const post=b.calls.find(c=>c.url===generatePath);
|
||||
if(mode==='target') b.nodes['account-target'].change();
|
||||
if(mode==='edit') b.nodes['account-password'].input();
|
||||
if(mode==='purpose') b.nodes['account-purpose'].change();
|
||||
if(mode==='domain') b.click('settings-serial');
|
||||
if(mode==='pagehide') b.emit('pagehide');
|
||||
if(mode==='logout') b.click('sign-out');
|
||||
if(mode==='refresh') b.click('refresh-accounts');
|
||||
assert.ok(post.signal.aborted);
|
||||
if(body) { stream.enqueue(new TextEncoder().encode(JSON.stringify({password:secret}))); stream.close(); } else d.resolve(json({password:secret}));
|
||||
await tick(); cleanSecret(b); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1); assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
}
|
||||
});
|
||||
await test('Generation errors/schema/96-byte overflow/timeouts never echo response or retry', async () => {
|
||||
for(const response of [failure(503), failure(403), json({password:secret,extra:1}),json({password:'!'.repeat(24)}),json({password:'x'.repeat(25)}),new Response(' '.repeat(97)),json({password:secret+' '.repeat(100)})]) {
|
||||
const b=await accountsBrowser(); draft(b); b.queues[generatePath].push(response); b.click('account-generate'); await tick(); cleanSecret(b); noSecretOutput(b); noSecretOutput(b,'SECRET ERROR BODY');
|
||||
assert.match(b.nodes['account-secret-detail'].textContent,/Nothing applied/); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1);
|
||||
}
|
||||
const b=await accountsBrowser(); draft(b); const d=deferred(); b.queues[generatePath].push(d.promise); b.click('account-generate'); await tick(); b.fire(15000); d.resolve(json({password:secret})); await tick(); cleanSecret(b);
|
||||
});
|
||||
await test('Generation timeout releases controls on fetch abort; explicit retry is isolated from serial/admin', async () => {
|
||||
const b = await accountsBrowser(); draft(b);
|
||||
b.queues[generatePath].push(options => new Promise((_, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(new Error('SECRET timeout')), {once:true});
|
||||
}));
|
||||
b.click('account-generate'); await tick(); assert.ok(b.nodes['account-generate'].disabled);
|
||||
b.fire(15000); await tick(); cleanSecret(b); noSecretOutput(b, 'SECRET timeout');
|
||||
assert.ok(!b.nodes['account-generate'].disabled && !b.nodes['account-submit-password'].disabled);
|
||||
assert.match(b.nodes['account-secret-detail'].textContent, /Nothing applied/);
|
||||
assert.equal(b.calls.filter(c => c.url === generatePath).length, 1);
|
||||
assert.ok(!b.calls.some(c => c.url === accountPath));
|
||||
await generated(b);
|
||||
assert.equal(b.calls.filter(c => c.url === generatePath).length, 2);
|
||||
assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
|
||||
});
|
||||
await test('Generation endpoint 401 wipes secrets and closes both routes once without a mutation', async () => {
|
||||
const b = await accountsBrowser(); draft(b); await generated(b);
|
||||
b.queues[generatePath].push(failure(401)); b.click('account-generate'); await tick();
|
||||
cleanSecret(b); noSecretOutput(b); noSecretOutput(b, 'SECRET ERROR BODY');
|
||||
assert.deepEqual(b.redirects, ['/login']); assert.ok(b.sockets.every(s => s.closed));
|
||||
assert.equal(b.timers.size, 0); assert.ok(!b.calls.some(c => c.url === accountPath));
|
||||
b.window.sakSessionExpired(); assert.deepEqual(b.redirects, ['/login']);
|
||||
});
|
||||
await test('Credential pre-submit session cancellation fences late responses and never sends the password', async () => {
|
||||
for (const purpose of ['create', 'password']) for (const mode of ['view', 'pagehide', 'identity', '401']) {
|
||||
const b = await accountsBrowser(); draft(b, purpose); await generated(b);
|
||||
const pending = deferred(); b.queues['/api/session'].push(pending.promise);
|
||||
b.click('account-submit-password'); cleanSecret(b); await tick();
|
||||
const check = b.calls.filter(c => c.url === '/api/session').at(-1);
|
||||
if (mode === 'view') b.click('select-serial');
|
||||
if (mode === 'pagehide') b.emit('pagehide');
|
||||
pending.resolve(mode === 'identity' ? session({role:'admin', username:'replacement'}) :
|
||||
mode === '401' ? failure(401) : session({role:'admin', username:'alice'}));
|
||||
await tick(); cleanSecret(b); noSecretOutput(b);
|
||||
assert.ok(check.signal.aborted); assert.ok(!b.calls.some(c => c.url === accountPath));
|
||||
assert.deepEqual(b.redirects, mode === 'identity' ? ['/'] : mode === '401' ? ['/login'] : []);
|
||||
assert.ok(b.sockets.every(s => mode === 'view' ? !s.closed : s.closed));
|
||||
}
|
||||
});
|
||||
await test('Self password/role/delete confirmation, no preemptive logout; 401 closes without success claim', async () => {
|
||||
for(const action of ['password','role','delete']) for(const stage of ['post','poll']) {
|
||||
const b=await accountsBrowser(); let warning=''; b.window.confirm=m=>{warning=m;return true;};
|
||||
if(action==='password') { draft(b,'password'); await generated(b); }
|
||||
const button=action==='password'?'account-submit-password':action==='role'?'account-change-role':'account-delete';
|
||||
b.queues[accountPath].push(stage==='post'?failure(401):accountReply(25,'pending',action)); b.click(button); cleanSecret(b);
|
||||
assert.ok(b.sockets.every(s=>!s.closed)); await tick();
|
||||
assert.match(warning,/ALL.*web\/SSH.*browser serial\/admin/); assert.match(warning,/401.*NOT proof/); assert.match(warning,/even a no-op role/); assert.ok(!warning.includes(secret));
|
||||
if(stage==='poll') { assert.ok(b.sockets.every(s=>!s.closed)); b.queues['/api/session'].push(failure(401)); b.fire(1000); await tick(); }
|
||||
assert.deepEqual(b.redirects,['/login']); assert.ok(b.sockets.every(s=>s.closed)); cleanSecret(b); noSecretOutput(b);
|
||||
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/completed and saved/); assert.ok(!b.calls.some(c=>c.url==='/api/logout'));
|
||||
}
|
||||
});
|
||||
await test('Cancelled confirmation and failed credential POST wipe; duplicate/full safe result messages', async () => {
|
||||
for(const cancel of [true,false]) {
|
||||
const b=await accountsBrowser(); draft(b); await generated(b); b.window.confirm=()=>!cancel;
|
||||
b.queues[accountPath].push(failure(503)); b.click('account-submit-password'); cleanSecret(b); await tick(); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===accountPath).length,cancel?0:1);
|
||||
}
|
||||
for(const state of ['duplicate','full']) { const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(27,state,'create')); b.click('account-result'); await tick(); assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/undefined|unknown/); }
|
||||
});
|
||||
await test('Rejected self mutations retain both routes and lease without logout or success claims', async () => {
|
||||
for (const action of ['password', 'role', 'delete']) for (const outcome of [403, 'protected', 'failed', 'stale']) {
|
||||
const b = await accountsBrowser();
|
||||
b.sockets[0].emit('message', {data:JSON.stringify({type:'hello', clientId:8, writerId:8, role:'writer'})});
|
||||
if (action === 'password') { draft(b, 'password'); await generated(b); }
|
||||
b.queues[accountPath].push(outcome === 403 ? failure(403) : accountReply(28, 'pending', action));
|
||||
b.click(action === 'password' ? 'account-submit-password' : action === 'role' ? 'account-change-role' : 'account-delete');
|
||||
await tick();
|
||||
if (outcome !== 403) { b.queues[accountPath].push(accountReply(28, outcome, action)); b.fire(1000); await tick(); }
|
||||
cleanSecret(b); noSecretOutput(b); assert.deepEqual(b.redirects, []);
|
||||
assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
|
||||
assert.equal(b.nodes['writer-id'].textContent, '8'); assert.equal(b.nodes['release-control'].disabled, false);
|
||||
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent, /completed and saved/);
|
||||
assert.ok(!b.calls.some(c => c.url === '/api/logout'));
|
||||
assert.equal(b.calls.filter(c => c.url === accountPath && c.method === 'POST').length, 1);
|
||||
}
|
||||
});
|
||||
await test('Generation session checks cannot strand reconnect; newer admission fences old generation', async () => {
|
||||
const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues['/api/session'].push(old.promise);
|
||||
b.click('account-generate'); await tick(); b.click('connection-toggle'); b.click('connection-toggle'); await tick();
|
||||
old.resolve(session({role:'admin',username:'alice'})); await tick();
|
||||
assert.equal(b.sockets.length,3); assert.ok(!b.sockets[1].closed); assert.ok(!b.calls.some(c=>c.url===generatePath)); cleanSecret(b);
|
||||
await generated(b); assert.equal(b.nodes['account-generated'].value,secret);
|
||||
const c=await accountsBrowser(); draft(c); const reconnect=deferred(); c.click('connection-toggle'); c.queues['/api/session'].push(reconnect.promise); c.click('connection-toggle'); await tick();
|
||||
await generated(c); reconnect.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(c.sockets.length,3); assert.equal(c.nodes['account-generated'].value,secret);
|
||||
});
|
||||
await test('Old generation/refresh responses cannot overwrite new target snapshot or generated value', async () => {
|
||||
const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues[generatePath].push(old.promise); b.click('account-generate'); await tick();
|
||||
b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); await generated(b);
|
||||
old.resolve(json({password:'x'.repeat(24)})); await tick(); assert.equal(b.nodes['account-generated'].value,secret); assert.equal(b.nodes['account-password-saved'].checked,true);
|
||||
const stale=deferred(); b.queues['/api/settings/accounts'].push(stale.promise); b.click('refresh-accounts'); await tick(); cleanSecret(b);
|
||||
b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); draft(b); await generated(b);
|
||||
stale.resolve(json({users:[{username:'replaced',role:'user',user_id:90,auth_generation:99}]})); await tick();
|
||||
assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret);
|
||||
});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
@@ -78,7 +78,13 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
|
||||
assert (rendered['html'].index('id="terminal-title"') <
|
||||
rendered['html'].index('id="admin-toggle"') <
|
||||
rendered['html'].index('id="terminal-selector"'))
|
||||
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization'):
|
||||
for field in ('account-password', 'account-password-confirm'):
|
||||
assert re.search(r'id="' + field + r'" type="password" maxlength="64" autocomplete="new-password"', rendered['html'])
|
||||
assert 'id="account-generated" readonly autocomplete="off"' in rendered['html']
|
||||
assert 'id="account-password-saved" type="checkbox"' in rendered['html']
|
||||
assert 'not applied yet' in rendered['html'] and 'no retrieval' in rendered['html']
|
||||
assert 'JavaScript cannot securely zero strings' in rendered['html']
|
||||
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization', 'clipboard', 'pushState', 'replaceState'):
|
||||
assert forbidden not in rendered['script'] + rendered['loader'], forbidden
|
||||
(tmp / 'rendered.json').write_text(json.dumps(rendered))
|
||||
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)
|
||||
|
||||
Reference in New Issue
Block a user