Mercurial
diff mrjunejune/test/admin_api_test.c @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/test/admin_api_test.c Fri Aug 07 07:34:12 2026 -0700 @@ -0,0 +1,1022 @@ +/* + * admin_api_test.c — unit tests for the admin API handlers. + * + * Tests run in-process against real store + crypto (no network). + * A fresh SQLite database is created per group. + */ + +#include "mrjunejune/admin_api.h" +#include "mrjunejune/auth_api.h" + +#include "auth/auth_crypto.h" +#include "auth/auth_store.h" +#include "deita/deita.h" +#include "dowa/dowa.h" +#include "seobeo/seobeo.h" + +#include <assert.h> +#include <stdarg.h> +#include <stdio.h> +#include <string.h> +#include <stdlib.h> +#include <unistd.h> + +#include <openssl/crypto.h> + +/* ------------------------------------------------------------------ */ +/* Utilities */ +/* ------------------------------------------------------------------ */ + +#define ASSERT(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \ + abort(); \ + } \ + } while (0) + +#define TEST(name) \ + do { fprintf(stdout, " %-60s", name); fflush(stdout); } while (0) + +#define PASS() \ + do { fprintf(stdout, "PASS\n"); } while (0) + +static const uint8 k_secret[64] = { + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, +}; + +static void make_temp_db(char *out, size_t capacity) +{ + snprintf(out, capacity, "/tmp/admin_api_test_XXXXXX"); + int fd = mkstemp(out); + ASSERT(fd >= 0); + close(fd); +} + +static void init_auth(const char *db) +{ + boolean ok = Auth_API_Init( + db, k_secret, sizeof(k_secret), + NULL, NULL, NULL, + AUTH_API_SESSION_IDLE_TTL_DEFAULT, + AUTH_API_SESSION_ABS_TTL_DEFAULT, + AUTH_API_GUEST_TTL_DEFAULT, + TRUE); /* dev_insecure_cookie */ + ASSERT(ok); +} + +static Seobeo_Request_Entry *make_request(Dowa_Arena *arena, ...) +{ + Seobeo_Request_Entry *req = NULL; + va_list ap; + va_start(ap, arena); + const char *key; + while ((key = va_arg(ap, const char *)) != NULL) + { + char *k = (char *)key; + const char *val = va_arg(ap, const char *); + char *stored = (char *)val; + if (strcmp(key, "Body") == 0) + stored = Dowa_Arena_Copy(arena, val, strlen(val) + 1); + Dowa_HashMap_Push_Arena(req, k, stored, arena); + } + va_end(ap); + return req; +} + +static const char *resp_status(Seobeo_Request_Entry *resp) +{ + void *p = Dowa_HashMap_Get_Ptr(resp, "status"); + return p ? ((Seobeo_Request_Entry *)p)->value : NULL; +} + +static const char *resp_body(Seobeo_Request_Entry *resp) +{ + void *p = Dowa_HashMap_Get_Ptr(resp, "body"); + return p ? ((Seobeo_Request_Entry *)p)->value : NULL; +} + +/* + * Create a user and return the new user_id. + * Hash a real password so the store accepts it. + */ +static boolean create_test_user( + Auth_Store *store, + const char *username, + const char *password, + const char *role, + boolean must_change, + char id_out[37]) +{ + char hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + if (Auth_Crypto_Password_Hash(password, hash, sizeof(hash)) != AUTH_CRYPTO_OK) + return FALSE; + Auth_Store_Result r = Auth_Store_Create_User( + store, username, hash, role, must_change, id_out); + OPENSSL_cleanse(hash, sizeof(hash)); + return r == AUTH_STORE_OK; +} + +/* + * Login as a user and return the session cookie value + CSRF token. + * Returns TRUE on success. + */ +static boolean login_user( + Dowa_Arena *arena, + const char *username, + const char *password, + char session_cookie_out[], + size_t cookie_cap, + char csrf_out[], + size_t csrf_cap) +{ + /* 1. Get a guest session to obtain a CSRF token + guest cookie. */ + Seobeo_Request_Entry *sess_req = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + NULL, NULL); + Seobeo_Request_Entry *sess_resp = + Auth_API_Test_Session_Handler(sess_req, arena); + const char *sess_body = resp_body(sess_resp); + ASSERT(sess_body); + + /* Extract csrfToken from session response. */ + const char *csrf_start = strstr(sess_body, "\"csrfToken\":\""); + ASSERT(csrf_start); + csrf_start += strlen("\"csrfToken\":\""); + const char *csrf_end = strchr(csrf_start, '"'); + ASSERT(csrf_end); + size_t csrf_len = (size_t)(csrf_end - csrf_start); + ASSERT(csrf_len < csrf_cap); + memcpy(csrf_out, csrf_start, csrf_len); + csrf_out[csrf_len] = '\0'; + + /* Extract the guest cookie from Set-Cookie to send with login. */ + char guest_cookie_val[512] = {0}; + void *sc_kv = Dowa_HashMap_Get_Ptr(sess_resp, "Set-Cookie"); + if (sc_kv) + { + const char *sc_hdr = ((Seobeo_Request_Entry *)sc_kv)->value; + const char *gc_start = strstr(sc_hdr, "mjj_guest="); + if (gc_start) + { + gc_start += strlen("mjj_guest="); + const char *gc_end = strchr(gc_start, ';'); + size_t gclen = gc_end + ? (size_t)(gc_end - gc_start) + : strlen(gc_start); + if (gclen < sizeof(guest_cookie_val)) + { + memcpy(guest_cookie_val, gc_start, gclen); + guest_cookie_val[gclen] = '\0'; + } + } + } + + /* 2. Call login with the guest cookie so Resolve_Principal finds the same guest. */ + char login_body[512]; + snprintf(login_body, sizeof(login_body), + "{\"username\":\"%s\",\"password\":\"%s\",\"csrfToken\":\"%s\"}", + username, password, csrf_out); + + char cookie_hdr[512] = {0}; + if (guest_cookie_val[0] != '\0') + snprintf(cookie_hdr, sizeof(cookie_hdr), + "mjj_guest=%s", guest_cookie_val); + + Seobeo_Request_Entry *login_req; + if (cookie_hdr[0] != '\0') + { + login_req = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + "Origin", "http://localhost", + "Cookie", cookie_hdr, + "Body", login_body, + NULL, NULL); + } + else + { + login_req = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + "Origin", "http://localhost", + "Body", login_body, + NULL, NULL); + } + + Seobeo_Request_Entry *login_resp = + Auth_API_Test_Login_Handler(login_req, arena); + const char *st = resp_status(login_resp); + if (!st || strcmp(st, "200") != 0) + return FALSE; + + /* 3. Extract session cookie value. */ + void *lsc_kv = Dowa_HashMap_Get_Ptr(login_resp, "Set-Cookie"); + if (!lsc_kv) return FALSE; + const char *lsc_hdr = ((Seobeo_Request_Entry *)lsc_kv)->value; + const char *cookie_start = strstr(lsc_hdr, "mjj_session="); + if (!cookie_start) return FALSE; + cookie_start += strlen("mjj_session="); + const char *cookie_end = strchr(cookie_start, ';'); + size_t clen = cookie_end + ? (size_t)(cookie_end - cookie_start) + : strlen(cookie_start); + ASSERT(clen < cookie_cap); + memcpy(session_cookie_out, cookie_start, clen); + session_cookie_out[clen] = '\0'; + + /* 4. Fetch a fresh CSRF from the authenticated session. */ + char auth_cookie_hdr[512]; + snprintf(auth_cookie_hdr, sizeof(auth_cookie_hdr), + "mjj_session=%s", session_cookie_out); + Seobeo_Request_Entry *csrfsess_req = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + "Cookie", auth_cookie_hdr, + NULL, NULL); + Seobeo_Request_Entry *csrfsess_resp = + Auth_API_Test_Session_Handler(csrfsess_req, arena); + const char *csrfsess_body = resp_body(csrfsess_resp); + ASSERT(csrfsess_body); + const char *cs2 = strstr(csrfsess_body, "\"csrfToken\":\""); + ASSERT(cs2); + cs2 += strlen("\"csrfToken\":\""); + const char *ce2 = strchr(cs2, '"'); + ASSERT(ce2); + size_t cl2 = (size_t)(ce2 - cs2); + ASSERT(cl2 < csrf_cap); + memcpy(csrf_out, cs2, cl2); + csrf_out[cl2] = '\0'; + + return TRUE; +} + +/* Build a request with session cookie + CSRF header + body. */ +static Seobeo_Request_Entry *make_admin_req( + Dowa_Arena *arena, + const char *method, + const char *session_cookie, + const char *csrf_token, + const char *body, + const char *id_param) +{ + /* Allocate cookie_hdr from arena so the pointer stays valid after return. */ + char *cookie_hdr = Dowa_Arena_Allocate(arena, 528); + ASSERT(cookie_hdr); + snprintf(cookie_hdr, 528, "mjj_session=%s", session_cookie); + + Seobeo_Request_Entry *req = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + "Origin", "http://localhost", + "Cookie", cookie_hdr, + "X-CSRF-Token", (char *)csrf_token, + "Body", body ? (char *)body : "", + NULL, NULL); + if (id_param) + Dowa_HashMap_Push_Arena(req, ":id", (char *)id_param, arena); + return req; + (void)method; +} + +/* ------------------------------------------------------------------ */ +/* Test group: non-admin denial */ +/* ------------------------------------------------------------------ */ + +static void test_non_admin_denial(void) +{ + printf("\n[non-admin denial]\n"); + + char db[256]; + make_temp_db(db, sizeof(db)); + init_auth(db); + Auth_Store *store = Auth_API_Get_Store(); + + char member_id[37]; + ASSERT(create_test_user(store, "member1", "password123456", "member", FALSE, member_id)); + + Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024); + + char scookie[512], csrf[512]; + ASSERT(login_user(arena, "member1", "password123456", + scookie, sizeof(scookie), csrf, sizeof(csrf))); + + TEST("member cannot list users (403)"); + { + Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "403") == 0); + } + PASS(); + + TEST("member cannot create users (403)"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "POST", scookie, csrf, + "{\"username\":\"hack\",\"temporaryPassword\":\"password123456\"}", NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "403") == 0); + } + PASS(); + + TEST("unauthenticated list → 401"); + { + Seobeo_Request_Entry *req = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + NULL, NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "401") == 0); + /* No page content must leak */ + const char *body = resp_body(resp); + ASSERT(!body || strstr(body, "password") == NULL); + } + PASS(); + + TEST("unauthenticated page → redirect (not content)"); + { + Seobeo_Request_Entry *req = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + NULL, NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Page_Handler(req, arena); + const char *st = resp_status(resp); + /* Must be 302 redirect; body must be empty/no admin content */ + ASSERT(st && (strcmp(st, "302") == 0 || strcmp(st, "401") == 0)); + const char *body = resp_body(resp); + ASSERT(!body || strstr(body, "<table") == NULL); + } + PASS(); + + Dowa_Arena_Free(arena); + Auth_API_Destroy(); + unlink(db); +} + +/* ------------------------------------------------------------------ */ +/* Test group: forced-password denial */ +/* ------------------------------------------------------------------ */ + +static void test_forced_password_denial(void) +{ + printf("\n[forced-password denial]\n"); + + char db[256]; + make_temp_db(db, sizeof(db)); + init_auth(db); + Auth_Store *store = Auth_API_Get_Store(); + + char admin_id[37]; + ASSERT(create_test_user(store, "fadmin", "password123456", "admin", TRUE, admin_id)); + + Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024); + + char scookie[512], csrf[512]; + ASSERT(login_user(arena, "fadmin", "password123456", + scookie, sizeof(scookie), csrf, sizeof(csrf))); + + TEST("admin with must_change_password blocked from list (403)"); + { + Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "403") == 0); + } + PASS(); + + TEST("admin with must_change_password blocked from create (403)"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "POST", scookie, csrf, + "{\"username\":\"newu\",\"temporaryPassword\":\"password123456\"}", NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "403") == 0); + } + PASS(); + + Dowa_Arena_Free(arena); + Auth_API_Destroy(); + unlink(db); +} + +/* ------------------------------------------------------------------ */ +/* Test group: create user */ +/* ------------------------------------------------------------------ */ + +static void test_create_user(void) +{ + printf("\n[create user]\n"); + + char db[256]; + make_temp_db(db, sizeof(db)); + init_auth(db); + Auth_Store *store = Auth_API_Get_Store(); + + char admin_id[37]; + ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id)); + + Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024); + char scookie[512], csrf[512]; + ASSERT(login_user(arena, "admin1", "password123456", + scookie, sizeof(scookie), csrf, sizeof(csrf))); + + TEST("create user succeeds → 201, mustChangePassword=true"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "POST", scookie, csrf, + "{\"username\":\"newuser\",\"temporaryPassword\":\"temppass123456\"}", NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "201") == 0); + const char *body = resp_body(resp); + ASSERT(body && strstr(body, "\"mustChangePassword\":true") != NULL); + /* No hash/digest in response */ + ASSERT(strstr(body, "hash") == NULL); + ASSERT(strstr(body, "password_hash") == NULL); + ASSERT(strstr(body, "digest") == NULL); + ASSERT(strstr(body, "session") == NULL); + } + PASS(); + + TEST("duplicate username → 409"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "POST", scookie, csrf, + "{\"username\":\"newuser\",\"temporaryPassword\":\"temppass123456\"}", NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "409") == 0); + } + PASS(); + + TEST("short password → 400 policy error"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "POST", scookie, csrf, + "{\"username\":\"shortpw\",\"temporaryPassword\":\"tooshort\"}", NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "400") == 0); + const char *body = resp_body(resp); + ASSERT(body && strstr(body, "password_policy") != NULL); + } + PASS(); + + TEST("invalid role → 400"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "POST", scookie, csrf, + "{\"username\":\"badrole\",\"temporaryPassword\":\"temppass123456\",\"role\":\"superuser\"}", NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "400") == 0); + } + PASS(); + + TEST("CSRF rejection → 403"); + { + char bad_cookie_hdr[512]; + snprintf(bad_cookie_hdr, sizeof(bad_cookie_hdr), "mjj_session=%s", scookie); + Seobeo_Request_Entry *req = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + "Origin", "http://localhost", + "Cookie", bad_cookie_hdr, + "X-CSRF-Token", "BADCSRF", + "Body", "{\"username\":\"x\",\"temporaryPassword\":\"temppass123456\"}", + NULL, NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "403") == 0); + } + PASS(); + + TEST("origin rejection → 403"); + { + char cookie_hdr[512]; + snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", scookie); + Seobeo_Request_Entry *req = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + "Origin", "http://evil.com", + "Cookie", cookie_hdr, + "X-CSRF-Token", csrf, + "Body", "{\"username\":\"y\",\"temporaryPassword\":\"temppass123456\"}", + NULL, NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "403") == 0); + } + PASS(); + + TEST("audit insertion failure rolls back user creation"); + { + Deita_Connection *connection = Deita_Connection_Create( + DEITA_DATABASE_TYPE_SQLITE3, db); + ASSERT(connection); + ASSERT(Deita_Query_Execute_Update( + connection, + "CREATE TRIGGER fail_admin_api_audit" + " BEFORE INSERT ON admin_audit_log" + " BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END") >= 0); + Deita_Connection_Close(connection); + + Seobeo_Request_Entry *req = make_admin_req( + arena, "POST", scookie, csrf, + "{\"username\":\"auditfail\"," + "\"temporaryPassword\":\"temppass123456\"}", + NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "500") == 0); + + Auth_User_Auth_Record record; + memset(&record, 0, sizeof(record)); + ASSERT(Auth_Store_Find_User_By_Username( + store, "auditfail", &record) == AUTH_STORE_NOT_FOUND); + + connection = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db); + ASSERT(connection); + ASSERT(Deita_Query_Execute_Update( + connection, "DROP TRIGGER fail_admin_api_audit") >= 0); + Deita_Connection_Close(connection); + } + PASS(); + + Dowa_Arena_Free(arena); + Auth_API_Destroy(); + unlink(db); +} + +/* ------------------------------------------------------------------ */ +/* Test group: enable/disable */ +/* ------------------------------------------------------------------ */ + +static void test_enable_disable(void) +{ + printf("\n[enable/disable]\n"); + + char db[256]; + make_temp_db(db, sizeof(db)); + init_auth(db); + Auth_Store *store = Auth_API_Get_Store(); + + char admin_id[37], user_id[37]; + ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id)); + ASSERT(create_test_user(store, "user1", "password123456", "member", FALSE, user_id)); + + Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024); + char scookie[512], csrf[512]; + ASSERT(login_user(arena, "admin1", "password123456", + scookie, sizeof(scookie), csrf, sizeof(csrf))); + + const char *user_token_digest = + "1111111111111111111111111111111111111111111111111111111111111111"; + const char *user_csrf_digest = + "2222222222222222222222222222222222222222222222222222222222222222"; + int64 session_now = (int64)time(NULL); + Auth_Session_Record user_session; + ASSERT(Auth_Store_Create_Session( + store, user_id, user_token_digest, user_csrf_digest, + 3600, 86400, session_now, &user_session) == AUTH_STORE_OK); + + TEST("disable user → 200, status=disabled"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "PATCH", scookie, csrf, "{\"op\":\"disable\"}", user_id); + Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "200") == 0); + const char *body = resp_body(resp); + ASSERT(body && strstr(body, "\"status\":\"disabled\"") != NULL); + Auth_Session_Record found_session; + Auth_User_Record found_user; + ASSERT(Auth_Store_Find_Session( + store, user_token_digest, session_now + 1, + &found_session, &found_user) == AUTH_STORE_REVOKED); + } + PASS(); + + TEST("enable user → 200, status=active"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "PATCH", scookie, csrf, "{\"op\":\"enable\"}", user_id); + Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "200") == 0); + const char *body = resp_body(resp); + ASSERT(body && strstr(body, "\"status\":\"active\"") != NULL); + Auth_Session_Record found_session; + Auth_User_Record found_user; + ASSERT(Auth_Store_Find_Session( + store, user_token_digest, session_now + 2, + &found_session, &found_user) == AUTH_STORE_REVOKED); + } + PASS(); + + TEST("disable last active admin → 409 last_admin"); + { + /* admin1 is the only admin */ + Seobeo_Request_Entry *req = make_admin_req( + arena, "PATCH", scookie, csrf, "{\"op\":\"disable\"}", admin_id); + Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "409") == 0); + const char *body = resp_body(resp); + ASSERT(body && strstr(body, "last_admin") != NULL); + } + PASS(); + + TEST("response body contains no secrets"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "PATCH", scookie, csrf, "{\"op\":\"enable\"}", user_id); + Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena); + const char *body = resp_body(resp); + ASSERT(body && strstr(body, "hash") == NULL); + ASSERT(body && strstr(body, "digest") == NULL); + ASSERT(body && strstr(body, "session") == NULL); + } + PASS(); + + Dowa_Arena_Free(arena); + Auth_API_Destroy(); + unlink(db); +} + +/* ------------------------------------------------------------------ */ +/* Test group: role update */ +/* ------------------------------------------------------------------ */ + +static void test_role_update(void) +{ + printf("\n[role update]\n"); + + char db[256]; + make_temp_db(db, sizeof(db)); + init_auth(db); + Auth_Store *store = Auth_API_Get_Store(); + + char admin_id[37], user_id[37]; + ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id)); + ASSERT(create_test_user(store, "user1", "password123456", "member", FALSE, user_id)); + + Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024); + char scookie[512], csrf[512]; + ASSERT(login_user(arena, "admin1", "password123456", + scookie, sizeof(scookie), csrf, sizeof(csrf))); + + TEST("promote member to admin → 200"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "PATCH", scookie, csrf, + "{\"op\":\"set_role\",\"role\":\"admin\"}", user_id); + Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "200") == 0); + ASSERT(strstr(resp_body(resp), "\"role\":\"admin\"") != NULL); + } + PASS(); + + TEST("demote admin to member → 200 (2 admins → safe)"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "PATCH", scookie, csrf, + "{\"op\":\"set_role\",\"role\":\"member\"}", user_id); + Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "200") == 0); + } + PASS(); + + TEST("demote last admin → 409"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "PATCH", scookie, csrf, + "{\"op\":\"set_role\",\"role\":\"member\"}", admin_id); + Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "409") == 0); + } + PASS(); + + TEST("invalid role value → 400"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "PATCH", scookie, csrf, + "{\"op\":\"set_role\",\"role\":\"superuser\"}", user_id); + Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "400") == 0); + } + PASS(); + + Dowa_Arena_Free(arena); + Auth_API_Destroy(); + unlink(db); +} + +/* ------------------------------------------------------------------ */ +/* Test group: temp password reset */ +/* ------------------------------------------------------------------ */ + +static void test_temp_reset(void) +{ + printf("\n[temp password reset]\n"); + + char db[256]; + make_temp_db(db, sizeof(db)); + init_auth(db); + Auth_Store *store = Auth_API_Get_Store(); + + char admin_id[37], user_id[37]; + ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id)); + ASSERT(create_test_user(store, "user1", "password123456", "member", FALSE, user_id)); + + Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024); + char scookie[512], csrf[512]; + ASSERT(login_user(arena, "admin1", "password123456", + scookie, sizeof(scookie), csrf, sizeof(csrf))); + + TEST("temp reset sets mustChangePassword=true in response"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "PATCH", scookie, csrf, + "{\"op\":\"temp_reset\",\"temporaryPassword\":\"newtemp123456\"}", user_id); + Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "200") == 0); + const char *body = resp_body(resp); + ASSERT(body && strstr(body, "\"mustChangePassword\":true") != NULL); + /* No raw password, hash, or digest in response. */ + ASSERT(strstr(body, "hash") == NULL); + ASSERT(strstr(body, "digest") == NULL); + ASSERT(strstr(body, "newtemp123456") == NULL); + } + PASS(); + + TEST("temp reset short password → 400"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "PATCH", scookie, csrf, + "{\"op\":\"temp_reset\",\"temporaryPassword\":\"short\"}", user_id); + Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "400") == 0); + } + PASS(); + + TEST("user can login after reset with new temp password"); + { + /* Login as user1 with the new temp password. */ + char u_cookie[512], u_csrf[512]; + boolean ok = login_user(arena, "user1", "newtemp123456", + u_cookie, sizeof(u_cookie), + u_csrf, sizeof(u_csrf)); + ASSERT(ok); + /* Confirm must_change_password is set. */ + char cookie_hdr[512]; + snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", u_cookie); + Seobeo_Request_Entry *sreq = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + "Cookie", cookie_hdr, + NULL, NULL); + Seobeo_Request_Entry *sresp = Auth_API_Test_Session_Handler(sreq, arena); + const char *sbody = resp_body(sresp); + ASSERT(sbody && strstr(sbody, "\"mustChangePassword\":true") != NULL); + } + PASS(); + + Dowa_Arena_Free(arena); + Auth_API_Destroy(); + unlink(db); +} + +/* ------------------------------------------------------------------ */ +/* Test group: session revocation */ +/* ------------------------------------------------------------------ */ + +static void test_session_revocation(void) +{ + printf("\n[session revocation]\n"); + + char db[256]; + make_temp_db(db, sizeof(db)); + init_auth(db); + Auth_Store *store = Auth_API_Get_Store(); + + char admin_id[37], user_id[37]; + ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id)); + ASSERT(create_test_user(store, "user1", "password123456", "member", FALSE, user_id)); + + Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024); + char admin_cookie[512], admin_csrf[512]; + ASSERT(login_user(arena, "admin1", "password123456", + admin_cookie, sizeof(admin_cookie), + admin_csrf, sizeof(admin_csrf))); + + /* Login user1 to create a session. */ + char u_cookie[512], u_csrf[512]; + ASSERT(login_user(arena, "user1", "password123456", + u_cookie, sizeof(u_cookie), u_csrf, sizeof(u_csrf))); + + TEST("DELETE /api/admin/users/:id/sessions → 200"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "DELETE", admin_cookie, admin_csrf, NULL, user_id); + Seobeo_Request_Entry *resp = + Admin_API_Test_Revoke_Sessions_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "200") == 0); + } + PASS(); + + TEST("user session is invalid after revocation"); + { + char cookie_hdr[512]; + snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", u_cookie); + Seobeo_Request_Entry *sreq = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + "Cookie", cookie_hdr, + NULL, NULL); + Seobeo_Request_Entry *sresp = Auth_API_Test_Session_Handler(sreq, arena); + const char *sbody = resp_body(sresp); + /* Should fall back to guest after session revoked */ + ASSERT(sbody && strstr(sbody, "\"kind\":\"user\"") == NULL); + } + PASS(); + + TEST("revoke sessions for non-existent user → 404"); + { + Seobeo_Request_Entry *req = make_admin_req( + arena, "DELETE", admin_cookie, admin_csrf, NULL, + "00000000-0000-0000-0000-000000000000"); + Seobeo_Request_Entry *resp = + Admin_API_Test_Revoke_Sessions_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "404") == 0); + } + PASS(); + + TEST("CSRF required for DELETE → 403 without CSRF"); + { + char cookie_hdr[512]; + snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", admin_cookie); + Seobeo_Request_Entry *req = make_request( + arena, + "Host", "localhost", + "Remote-Addr", "127.0.0.1", + "Origin", "http://localhost", + "Cookie", cookie_hdr, + ":id", user_id, + NULL, NULL); + Seobeo_Request_Entry *resp = + Admin_API_Test_Revoke_Sessions_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "403") == 0); + } + PASS(); + + Dowa_Arena_Free(arena); + Auth_API_Destroy(); + unlink(db); +} + +/* ------------------------------------------------------------------ */ +/* Test group: no secrets in responses */ +/* ------------------------------------------------------------------ */ + +static void test_no_secret_fields(void) +{ + printf("\n[no secret fields in JSON/page]\n"); + + char db[256]; + make_temp_db(db, sizeof(db)); + init_auth(db); + Auth_Store *store = Auth_API_Get_Store(); + + char admin_id[37]; + ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id)); + ASSERT(create_test_user(store, "user2", "password123456", "member", FALSE, admin_id)); + + Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024); + char scookie[512], csrf[512]; + ASSERT(login_user(arena, "admin1", "password123456", + scookie, sizeof(scookie), csrf, sizeof(csrf))); + + TEST("list response has no password_hash, session, digest, or guest fields"); + { + Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "200") == 0); + const char *body = resp_body(resp); + ASSERT(body); + ASSERT(strstr(body, "password_hash") == NULL); + ASSERT(strstr(body, "passwordHash") == NULL); + ASSERT(strstr(body, "token_digest") == NULL); + ASSERT(strstr(body, "csrf_digest") == NULL); + ASSERT(strstr(body, "guest_id") == NULL); + ASSERT(strstr(body, "ip_binding") == NULL); + ASSERT(strstr(body, "session") == NULL); + } + PASS(); + + TEST("audit log for create contains no credentials"); + { + /* Create a user, then verify audit log doesn't have hash/password. */ + Seobeo_Request_Entry *req = make_admin_req( + arena, "POST", scookie, csrf, + "{\"username\":\"audituser\",\"temporaryPassword\":\"auditpass123456\"}", NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "201") == 0); + const char *body = resp_body(resp); + ASSERT(body && strstr(body, "auditpass123456") == NULL); + ASSERT(body && strstr(body, "hash") == NULL); + } + PASS(); + + Dowa_Arena_Free(arena); + Auth_API_Destroy(); + unlink(db); +} + +/* ------------------------------------------------------------------ */ +/* Test group: list pagination */ +/* ------------------------------------------------------------------ */ + +static void test_list_pagination(void) +{ + printf("\n[list pagination]\n"); + + char db[256]; + make_temp_db(db, sizeof(db)); + init_auth(db); + Auth_Store *store = Auth_API_Get_Store(); + + char admin_id[37]; + ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id)); + /* Create 5 more users. */ + for (int i = 0; i < 5; i++) + { + char uname[32], uid[37]; + snprintf(uname, sizeof(uname), "user%d", i); + ASSERT(create_test_user(store, uname, "password123456", "member", FALSE, uid)); + } + + Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024); + char scookie[512], csrf[512]; + ASSERT(login_user(arena, "admin1", "password123456", + scookie, sizeof(scookie), csrf, sizeof(csrf))); + + TEST("list all users includes total count"); + { + Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL); + Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "200") == 0); + const char *body = resp_body(resp); + ASSERT(body && strstr(body, "\"total\":6") != NULL); + ASSERT(body && strstr(body, "\"users\":[") != NULL); + } + PASS(); + + TEST("huge page number returns an empty page without overflow"); + { + Seobeo_Request_Entry *req = + make_admin_req(arena, "GET", scookie, csrf, NULL, NULL); + Dowa_HashMap_Push_Arena( + req, "Query-page", "9223372036854775807", arena); + Dowa_HashMap_Push_Arena(req, "Query-limit", "100", arena); + Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena); + ASSERT(strcmp(resp_status(resp), "200") == 0); + const char *body = resp_body(resp); + ASSERT(body && strstr(body, "\"users\":[]") != NULL); + } + PASS(); + + Dowa_Arena_Free(arena); + Auth_API_Destroy(); + unlink(db); +} + +/* ------------------------------------------------------------------ */ +/* main */ +/* ------------------------------------------------------------------ */ + +int main(void) +{ + printf("=== admin_api_test ===\n"); + + test_non_admin_denial(); + test_forced_password_denial(); + test_create_user(); + test_enable_disable(); + test_role_update(); + test_temp_reset(); + test_session_revocation(); + test_no_secret_fields(); + test_list_pagination(); + + printf("\n=== ALL TESTS PASSED ===\n"); + return 0; +}