Route bounded admin mutations through the existing administration dispatcher, covering apply, lifecycle, persistence, authorization, and result tracking. Add the browser controls, automatic result refresh, regression coverage, and phase documentation.
285 lines
16 KiB
C
285 lines
16 KiB
C
/* Production store dependency doubles and its existing public API suite. */
|
|
#define main store_tests
|
|
#include "../web_session_store/test.c"
|
|
#undef main
|
|
#include "web_cookie_auth.h"
|
|
#include "web_httpd_adapter.h"
|
|
#include "esp_httpd_priv.h"
|
|
#ifdef HOST_ADMIN
|
|
#include "web_admin_transport.h"
|
|
#endif
|
|
|
|
static struct httpd_data server = {.config.max_resp_headers = 8};
|
|
static struct sock_db socket_state;
|
|
static struct resp_hdr response_headers[8];
|
|
static char scratch[1024], output[1024], cookie_values[2][200];
|
|
static const char *request_body;
|
|
static size_t body_offset;
|
|
static unsigned password_calls, cookie_count, sends, upgrades;
|
|
static unsigned fail_header, setter_calls;
|
|
static bool send_fail, recv_fail;
|
|
static size_t receive_fragment = 7;
|
|
static void (*password_hook)(void);
|
|
static char response_status[48];
|
|
static struct httpd_req_aux aux;
|
|
static httpd_req_t req;
|
|
size_t host_read_pending(httpd_req_t *r, char *out, size_t n);
|
|
|
|
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *status) {
|
|
(void)r; if (fail_header && ++setter_calls == fail_header) return ESP_FAIL;
|
|
snprintf(response_status, sizeof(response_status), "%s", status); return ESP_OK;
|
|
}
|
|
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *type) {
|
|
(void)r; (void)type; return ESP_OK;
|
|
}
|
|
esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
|
|
(void)r; ++sends; assert(strlen(body) < sizeof(output)); strcpy(output, body);
|
|
cookie_count = 0;
|
|
for (unsigned i = 0; i < aux.resp_hdrs_count; ++i) {
|
|
assert(response_headers[i].value);
|
|
assert(strcmp(response_headers[i].field, "WWW-Authenticate"));
|
|
if (!strcmp(response_headers[i].field, "Set-Cookie")) {
|
|
assert(cookie_count < 2);
|
|
snprintf(cookie_values[cookie_count++], 200, "%s", response_headers[i].value);
|
|
}
|
|
}
|
|
return send_fail ? ESP_FAIL : ESP_OK;
|
|
}
|
|
int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
|
|
(void)r; if (recv_fail) return -1;
|
|
if (size > receive_fragment) size = receive_fragment;
|
|
memcpy(out, request_body + body_offset, size); body_offset += size;
|
|
aux.remaining_len -= size; return (int)size;
|
|
}
|
|
esp_err_t web_login_ui_send_response(httpd_req_t *r) { return httpd_resp_sendstr(r, "login document"); }
|
|
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id) {
|
|
web_session_store_invalidate(id);
|
|
#ifdef HOST_ADMIN
|
|
web_admin_transport_revoke(id, NULL, 0);
|
|
#endif
|
|
return ESP_OK;
|
|
}
|
|
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *r, const char *protocol) {
|
|
(void)r; (void)protocol; ++upgrades; return ESP_OK;
|
|
}
|
|
esp_err_t user_database_authenticate_password(const uint8_t *u, size_t un,
|
|
const uint8_t *p, size_t pn, user_principal_t *principal, bool *authenticated) {
|
|
assert(!host_lock_depth); ++password_calls;
|
|
if (password_hook) { void (*hook)(void) = password_hook; password_hook = NULL; hook(); }
|
|
*authenticated = un == 5 && !memcmp(u, "alice", 5) && pn == 12 && !memcmp(p, "password1234", 12);
|
|
if (*authenticated) *principal = alice;
|
|
return db_fail ? ESP_FAIL : ESP_OK;
|
|
}
|
|
|
|
static void begin(const char *uri, int method, const char *body) {
|
|
memset(scratch, 0, sizeof(scratch)); memset(response_headers, 0, sizeof(response_headers));
|
|
memset(&socket_state, 0, sizeof(socket_state));
|
|
aux = (struct httpd_req_aux){.sd = &socket_state, .scratch = scratch,
|
|
.scratch_cur_size = sizeof(scratch), .resp_hdrs = response_headers};
|
|
req = (httpd_req_t){.handle = &server, .aux = &aux, .uri = uri, .method = method,
|
|
.content_len = body ? strlen(body) : 0};
|
|
aux.remaining_len = req.content_len;
|
|
request_body = body; body_offset = 0;
|
|
response_status[0] = output[0] = 0; cookie_count = 0;
|
|
}
|
|
static void add(const char *key, const char *value) {
|
|
char *at = scratch;
|
|
for (unsigned i = 0; i < aux.req_hdrs_count; ++i) at += strlen(at) + 1;
|
|
assert((size_t)(at - scratch) + strlen(key) + strlen(value) + 3 < sizeof(scratch));
|
|
sprintf(at, "%s: %s", key, value); ++aux.req_hdrs_count;
|
|
}
|
|
static void same_origin(void) { add("Host", "device.example"); add("Origin", origin); }
|
|
static void expect(const char *status) {
|
|
(void)web_cookie_auth_handler(&req);
|
|
assert(!strcmp(response_status, status));
|
|
}
|
|
static void token_from(const char *value, char token[65]) {
|
|
const char *start = strchr(value, '='); assert(start && strlen(start + 1) >= 64);
|
|
memcpy(token, start + 1, 64); token[64] = 0;
|
|
}
|
|
static void csrf_from(char csrf[65]) {
|
|
const char *start = strstr(output, "\"csrf\":\""); assert(start);
|
|
memcpy(csrf, start + 8, 64); csrf[64] = 0;
|
|
}
|
|
static void challenge(char token[65], char csrf[65]) {
|
|
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example");
|
|
add("X-Login-Bootstrap", "1"); expect("200 OK");
|
|
assert(cookie_count == 1); token_from(cookie_values[0], token); csrf_from(csrf);
|
|
}
|
|
static const char good_body[] = "{\"username\":\"alice\",\"password\":\"password1234\"}";
|
|
static void login_request(const char *token, const char *csrf, const char *body) {
|
|
begin("/api/login", HTTP_POST, body); same_origin(); add("Content-Type", "application/json");
|
|
add("X-CSRF-Token", csrf);
|
|
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
|
}
|
|
static void auth_reset(void) {
|
|
web_cookie_auth_stop(); reset(); assert(web_cookie_auth_start() == ESP_OK);
|
|
password_calls = 0; password_hook = NULL;
|
|
}
|
|
|
|
#ifdef HOST_ADMIN
|
|
#include "admin_test.c"
|
|
#endif
|
|
#ifdef HOST_SETTINGS
|
|
#include "settings_test.c"
|
|
#endif
|
|
#ifdef HOST_SERIAL_SETTINGS
|
|
#include "serial_settings_test.c"
|
|
#endif
|
|
|
|
int main(void) {
|
|
assert(store_tests() == 0); auth_reset();
|
|
char token[65], csrf[65], session[65], cookies[200];
|
|
challenge(token, csrf);
|
|
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example"); add("X-Login-Bootstrap", "1");
|
|
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
|
expect("200 OK"); assert(cookie_count == 0);
|
|
login_request(token, csrf, good_body); expect("200 OK");
|
|
assert(password_calls == 1 && cookie_count == 2 && snapshot().active == 1);
|
|
assert(strstr(cookie_values[0], "__Host-sak-prelogin="));
|
|
assert(strstr(cookie_values[0], "Max-Age=0"));
|
|
assert(strstr(cookie_values[1], "Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600"));
|
|
token_from(cookie_values[1], session);
|
|
begin("/api/session", HTTP_GET, NULL); same_origin();
|
|
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", session); add("Cookie", cookies);
|
|
expect("200 OK"); csrf_from(csrf);
|
|
issued_t other = mint(&alice);
|
|
begin("/api/logout", HTTP_POST, NULL); same_origin(); add("Cookie", cookies); add("X-CSRF-Token", csrf);
|
|
expect("204 No Content"); present(&other); assert(snapshot().active == 1);
|
|
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
|
puts("PASS: challenge reuse, fragmented login, two independent Set-Cookie fields, session and isolated logout");
|
|
|
|
auth_reset();
|
|
for (unsigned i = 0; i < 5; ++i) {
|
|
challenge(token, csrf); login_request(token, csrf, "{\"username\":\"alice\",\"password\":\"wrong\"}");
|
|
expect("401 Unauthorized");
|
|
login_request(token, csrf, good_body); expect("403 Forbidden");
|
|
}
|
|
challenge(token, csrf); login_request(token, csrf, good_body); expect("429 Too Many Requests");
|
|
assert(password_calls == 5); now += 60000000;
|
|
challenge(token, csrf); login_request(token, csrf, good_body); expect("200 OK");
|
|
auth_reset(); for (unsigned i = 0; i < 4; ++i) challenge(token, csrf);
|
|
begin("/api/login-challenge", HTTP_GET, NULL); same_origin(); add("X-Login-Bootstrap", "1"); expect("503 Service Unavailable");
|
|
now += 120000000; challenge(token, csrf);
|
|
auth_reset(); for (unsigned i = 0; i < 4; ++i) (void)mint(&alice);
|
|
challenge(token, csrf); login_request(token, csrf, good_body); expect("503 Service Unavailable"); assert(snapshot().active == 4);
|
|
puts("PASS: consumed challenges, global five/60s throttle, expiry and no live challenge/session eviction");
|
|
|
|
auth_reset(); challenge(token, csrf);
|
|
const char *keys[] = {"Host", "Origin", "Cookie", "Content-Type", "X-CSRF-Token"};
|
|
for (unsigned i = 0; i < sizeof(keys)/sizeof(keys[0]); ++i) {
|
|
login_request(token, csrf, good_body); add(keys[i], "ambiguous"); expect("400 Bad Request");
|
|
}
|
|
assert(password_calls == 0);
|
|
login_request(token, csrf, good_body); add("Transfer-Encoding", "chunked"); expect("400 Bad Request");
|
|
login_request(token, csrf, good_body); add("Sec-Fetch-Site", "cross-site"); expect("403 Forbidden");
|
|
login_request(token, "invalid", good_body); expect("403 Forbidden");
|
|
login_request(token, csrf, good_body); req.method = HTTP_GET; expect("400 Bad Request");
|
|
login_request(token, csrf, good_body); req.content_len = 513; expect("413 Payload Too Large");
|
|
login_request(token, csrf, "{\"username\":\"alice\",\"password\":\"x\",\"unknown\":1}"); expect("400 Bad Request");
|
|
assert(password_calls == 0);
|
|
begin("/", HTTP_GET, NULL); same_origin(); add("Authorization", "Basic ignored");
|
|
web_session_view_t view; bool allowed;
|
|
assert(web_cookie_auth_require(&req, false, false, &view, &allowed) == ESP_OK && !allowed);
|
|
assert(!strcmp(response_status, "303 See Other"));
|
|
begin("/assets/app.js", HTTP_GET, NULL); same_origin();
|
|
assert(web_cookie_auth_require(&req, false, false, &view, &allowed) == ESP_OK && !allowed);
|
|
assert(!strcmp(response_status, "401 Unauthorized"));
|
|
puts("PASS: duplicate security headers, framing, methods, metadata, CSRF, strict JSON and no Basic bypass");
|
|
|
|
auth_reset(); other = mint(&alice);
|
|
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
|
begin("/api/logout", HTTP_POST, NULL); same_origin(); add("Cookie", cookies);
|
|
expect("403 Forbidden"); present(&other);
|
|
begin("/api/logout", HTTP_POST, NULL); add("Host", "device.example");
|
|
add("Cookie", cookies); add("X-CSRF-Token", other.view.csrf);
|
|
expect("403 Forbidden"); present(&other);
|
|
begin("/api/logout", HTTP_POST, NULL); add("Host", "device.example"); add("Origin", "https://foreign.example");
|
|
add("Cookie", cookies); add("X-CSRF-Token", other.view.csrf);
|
|
expect("403 Forbidden"); present(&other);
|
|
begin("/api/session", HTTP_GET, NULL); add("Host", "device.example"); add("Cookie", cookies);
|
|
expect("200 OK");
|
|
begin("/api/session", HTTP_GET, NULL); add("Host", "alias.local"); add("Cookie", cookies);
|
|
expect("401 Unauthorized"); present(&other);
|
|
challenge(token, csrf);
|
|
begin("/api/login", HTTP_POST, good_body); same_origin(); add("Content-Type", "application/json");
|
|
add("X-CSRF-Token", csrf);
|
|
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s; __Host-sak-prelogin=%s", other.token, token);
|
|
add("Cookie", cookies); expect("409 Conflict"); assert(password_calls == 0); present(&other);
|
|
begin("/api/login", HTTP_POST, good_body); same_origin(); add("Content-Type", "application/json");
|
|
add("X-CSRF-Token", csrf);
|
|
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s; __Host-sak-session=%s", other.token, other.token);
|
|
add("Cookie", cookies); expect("400 Bad Request"); assert(password_calls == 0);
|
|
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
|
now = other.view.expires_at_us;
|
|
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
|
auth_reset(); other = mint(&alice); stale_user = alice.user_id;
|
|
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
|
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
|
puts("PASS: mandatory mutation Origin/CSRF, origin binding, explicit account switching, duplicate named cookies, expiry/currentness");
|
|
|
|
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
|
password_hook = web_cookie_auth_stop; expect("503 Service Unavailable"); assert(!snapshot().active);
|
|
auth_reset(); rng_fail = true;
|
|
begin("/api/login-challenge", HTTP_GET, NULL); same_origin(); add("X-Login-Bootstrap", "1"); expect("503 Service Unavailable");
|
|
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body); send_fail = true;
|
|
assert(web_cookie_auth_handler(&req) != ESP_OK); send_fail = false; assert(!snapshot().active);
|
|
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body); recv_fail = true;
|
|
expect("400 Bad Request"); recv_fail = false; assert(!password_calls);
|
|
begin("/api/session", HTTP_GET, NULL); add("Host", "first"); add("host", "second");
|
|
char value[32]; assert(httpd_req_get_hdr_value_str(&req, "Host", value, sizeof(value)) == ESP_OK);
|
|
assert(!strcmp(value, "first") && !web_httpd_headers_valid(&req));
|
|
begin("/ws/serial", HTTP_GET, NULL); same_origin();
|
|
add("Sec-WebSocket-Version", "13"); add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
|
|
assert(!web_httpd_upgrade_requested(&req)); aux.ws_handshake_detect = true;
|
|
assert(web_httpd_upgrade(&req, web_cookie_auth_handler) == ESP_OK && upgrades == 1);
|
|
assert(socket_state.ws_handshake_done && !web_httpd_upgrade_requested(&req));
|
|
begin("/ws/serial", HTTP_GET, NULL); same_origin(); aux.ws_handshake_detect = true;
|
|
add("Sec-WebSocket-Version", "130"); add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
|
|
assert(!web_httpd_upgrade_requested(&req));
|
|
for (size_t keep = 0; keep <= sizeof(socket_state.pending_data); ++keep) {
|
|
memset(socket_state.pending_data, 's', sizeof(socket_state.pending_data));
|
|
size_t offset = sizeof(socket_state.pending_data) - keep;
|
|
memset(socket_state.pending_data + offset, 'p', keep);
|
|
socket_state.pending_len = keep;
|
|
web_httpd_wipe_request(&req, false);
|
|
zero(socket_state.pending_data, offset);
|
|
char received[128];
|
|
size_t first = host_read_pending(&req, received, 1);
|
|
assert(first == (keep ? 1U : 0U));
|
|
if (first) assert(received[0] == 'p');
|
|
web_httpd_wipe_request(&req, false);
|
|
zero(socket_state.pending_data, offset + first);
|
|
size_t remaining = host_read_pending(&req, received, sizeof(received));
|
|
assert(remaining == keep - first);
|
|
for (size_t i = 0; i < remaining; ++i) assert(received[i] == 'p');
|
|
}
|
|
memset(socket_state.pending_data, 's', sizeof(socket_state.pending_data));
|
|
socket_state.pending_len = 3; web_httpd_wipe_request(&req, true);
|
|
zero(socket_state.pending_data, sizeof(socket_state.pending_data));
|
|
zero(scratch, sizeof(scratch));
|
|
puts("PASS: request wiping preserves right-aligned pending data through actual IDF reader (all lengths/partial reads)");
|
|
puts("PASS: stop/login race, RNG/send/receive failure, actual IDF first-header semantics and explicit upgrade adapter");
|
|
for (unsigned limit = 0; limit < 6; ++limit) {
|
|
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
|
server.config.max_resp_headers = limit;
|
|
assert(web_cookie_auth_handler(&req) != ESP_OK);
|
|
assert(snapshot().active == 0);
|
|
server.config.max_resp_headers = 8;
|
|
}
|
|
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
|
server.config.max_resp_headers = 6; expect("200 OK"); assert(cookie_count == 2);
|
|
server.config.max_resp_headers = 8;
|
|
puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login");
|
|
#ifdef HOST_ADMIN
|
|
admin_tests();
|
|
#endif
|
|
#ifdef HOST_SETTINGS
|
|
settings_tests();
|
|
#endif
|
|
#ifdef HOST_SERIAL_SETTINGS
|
|
serial_settings_tests();
|
|
#endif
|
|
return 0;
|
|
}
|