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;
+}