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:
2026-09-08 09:27:02 +02:00
parent 42548f6334
commit 94433ef975
30 changed files with 1864 additions and 48 deletions
+30
View File
@@ -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,&current)==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);
}
+15
View File
@@ -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)
+12
View File
@@ -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;
}