comparison 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
comparison
equal deleted inserted replaced
263:ee04e4e69fed 264:04fee26ecce0
1 /*
2 * admin_api_test.c — unit tests for the admin API handlers.
3 *
4 * Tests run in-process against real store + crypto (no network).
5 * A fresh SQLite database is created per group.
6 */
7
8 #include "mrjunejune/admin_api.h"
9 #include "mrjunejune/auth_api.h"
10
11 #include "auth/auth_crypto.h"
12 #include "auth/auth_store.h"
13 #include "deita/deita.h"
14 #include "dowa/dowa.h"
15 #include "seobeo/seobeo.h"
16
17 #include <assert.h>
18 #include <stdarg.h>
19 #include <stdio.h>
20 #include <string.h>
21 #include <stdlib.h>
22 #include <unistd.h>
23
24 #include <openssl/crypto.h>
25
26 /* ------------------------------------------------------------------ */
27 /* Utilities */
28 /* ------------------------------------------------------------------ */
29
30 #define ASSERT(cond) \
31 do { \
32 if (!(cond)) { \
33 fprintf(stderr, "FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
34 abort(); \
35 } \
36 } while (0)
37
38 #define TEST(name) \
39 do { fprintf(stdout, " %-60s", name); fflush(stdout); } while (0)
40
41 #define PASS() \
42 do { fprintf(stdout, "PASS\n"); } while (0)
43
44 static const uint8 k_secret[64] = {
45 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
46 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
47 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
48 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
49 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
50 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
51 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
52 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
53 };
54
55 static void make_temp_db(char *out, size_t capacity)
56 {
57 snprintf(out, capacity, "/tmp/admin_api_test_XXXXXX");
58 int fd = mkstemp(out);
59 ASSERT(fd >= 0);
60 close(fd);
61 }
62
63 static void init_auth(const char *db)
64 {
65 boolean ok = Auth_API_Init(
66 db, k_secret, sizeof(k_secret),
67 NULL, NULL, NULL,
68 AUTH_API_SESSION_IDLE_TTL_DEFAULT,
69 AUTH_API_SESSION_ABS_TTL_DEFAULT,
70 AUTH_API_GUEST_TTL_DEFAULT,
71 TRUE); /* dev_insecure_cookie */
72 ASSERT(ok);
73 }
74
75 static Seobeo_Request_Entry *make_request(Dowa_Arena *arena, ...)
76 {
77 Seobeo_Request_Entry *req = NULL;
78 va_list ap;
79 va_start(ap, arena);
80 const char *key;
81 while ((key = va_arg(ap, const char *)) != NULL)
82 {
83 char *k = (char *)key;
84 const char *val = va_arg(ap, const char *);
85 char *stored = (char *)val;
86 if (strcmp(key, "Body") == 0)
87 stored = Dowa_Arena_Copy(arena, val, strlen(val) + 1);
88 Dowa_HashMap_Push_Arena(req, k, stored, arena);
89 }
90 va_end(ap);
91 return req;
92 }
93
94 static const char *resp_status(Seobeo_Request_Entry *resp)
95 {
96 void *p = Dowa_HashMap_Get_Ptr(resp, "status");
97 return p ? ((Seobeo_Request_Entry *)p)->value : NULL;
98 }
99
100 static const char *resp_body(Seobeo_Request_Entry *resp)
101 {
102 void *p = Dowa_HashMap_Get_Ptr(resp, "body");
103 return p ? ((Seobeo_Request_Entry *)p)->value : NULL;
104 }
105
106 /*
107 * Create a user and return the new user_id.
108 * Hash a real password so the store accepts it.
109 */
110 static boolean create_test_user(
111 Auth_Store *store,
112 const char *username,
113 const char *password,
114 const char *role,
115 boolean must_change,
116 char id_out[37])
117 {
118 char hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
119 if (Auth_Crypto_Password_Hash(password, hash, sizeof(hash)) != AUTH_CRYPTO_OK)
120 return FALSE;
121 Auth_Store_Result r = Auth_Store_Create_User(
122 store, username, hash, role, must_change, id_out);
123 OPENSSL_cleanse(hash, sizeof(hash));
124 return r == AUTH_STORE_OK;
125 }
126
127 /*
128 * Login as a user and return the session cookie value + CSRF token.
129 * Returns TRUE on success.
130 */
131 static boolean login_user(
132 Dowa_Arena *arena,
133 const char *username,
134 const char *password,
135 char session_cookie_out[],
136 size_t cookie_cap,
137 char csrf_out[],
138 size_t csrf_cap)
139 {
140 /* 1. Get a guest session to obtain a CSRF token + guest cookie. */
141 Seobeo_Request_Entry *sess_req = make_request(
142 arena,
143 "Host", "localhost",
144 "Remote-Addr", "127.0.0.1",
145 NULL, NULL);
146 Seobeo_Request_Entry *sess_resp =
147 Auth_API_Test_Session_Handler(sess_req, arena);
148 const char *sess_body = resp_body(sess_resp);
149 ASSERT(sess_body);
150
151 /* Extract csrfToken from session response. */
152 const char *csrf_start = strstr(sess_body, "\"csrfToken\":\"");
153 ASSERT(csrf_start);
154 csrf_start += strlen("\"csrfToken\":\"");
155 const char *csrf_end = strchr(csrf_start, '"');
156 ASSERT(csrf_end);
157 size_t csrf_len = (size_t)(csrf_end - csrf_start);
158 ASSERT(csrf_len < csrf_cap);
159 memcpy(csrf_out, csrf_start, csrf_len);
160 csrf_out[csrf_len] = '\0';
161
162 /* Extract the guest cookie from Set-Cookie to send with login. */
163 char guest_cookie_val[512] = {0};
164 void *sc_kv = Dowa_HashMap_Get_Ptr(sess_resp, "Set-Cookie");
165 if (sc_kv)
166 {
167 const char *sc_hdr = ((Seobeo_Request_Entry *)sc_kv)->value;
168 const char *gc_start = strstr(sc_hdr, "mjj_guest=");
169 if (gc_start)
170 {
171 gc_start += strlen("mjj_guest=");
172 const char *gc_end = strchr(gc_start, ';');
173 size_t gclen = gc_end
174 ? (size_t)(gc_end - gc_start)
175 : strlen(gc_start);
176 if (gclen < sizeof(guest_cookie_val))
177 {
178 memcpy(guest_cookie_val, gc_start, gclen);
179 guest_cookie_val[gclen] = '\0';
180 }
181 }
182 }
183
184 /* 2. Call login with the guest cookie so Resolve_Principal finds the same guest. */
185 char login_body[512];
186 snprintf(login_body, sizeof(login_body),
187 "{\"username\":\"%s\",\"password\":\"%s\",\"csrfToken\":\"%s\"}",
188 username, password, csrf_out);
189
190 char cookie_hdr[512] = {0};
191 if (guest_cookie_val[0] != '\0')
192 snprintf(cookie_hdr, sizeof(cookie_hdr),
193 "mjj_guest=%s", guest_cookie_val);
194
195 Seobeo_Request_Entry *login_req;
196 if (cookie_hdr[0] != '\0')
197 {
198 login_req = make_request(
199 arena,
200 "Host", "localhost",
201 "Remote-Addr", "127.0.0.1",
202 "Origin", "http://localhost",
203 "Cookie", cookie_hdr,
204 "Body", login_body,
205 NULL, NULL);
206 }
207 else
208 {
209 login_req = make_request(
210 arena,
211 "Host", "localhost",
212 "Remote-Addr", "127.0.0.1",
213 "Origin", "http://localhost",
214 "Body", login_body,
215 NULL, NULL);
216 }
217
218 Seobeo_Request_Entry *login_resp =
219 Auth_API_Test_Login_Handler(login_req, arena);
220 const char *st = resp_status(login_resp);
221 if (!st || strcmp(st, "200") != 0)
222 return FALSE;
223
224 /* 3. Extract session cookie value. */
225 void *lsc_kv = Dowa_HashMap_Get_Ptr(login_resp, "Set-Cookie");
226 if (!lsc_kv) return FALSE;
227 const char *lsc_hdr = ((Seobeo_Request_Entry *)lsc_kv)->value;
228 const char *cookie_start = strstr(lsc_hdr, "mjj_session=");
229 if (!cookie_start) return FALSE;
230 cookie_start += strlen("mjj_session=");
231 const char *cookie_end = strchr(cookie_start, ';');
232 size_t clen = cookie_end
233 ? (size_t)(cookie_end - cookie_start)
234 : strlen(cookie_start);
235 ASSERT(clen < cookie_cap);
236 memcpy(session_cookie_out, cookie_start, clen);
237 session_cookie_out[clen] = '\0';
238
239 /* 4. Fetch a fresh CSRF from the authenticated session. */
240 char auth_cookie_hdr[512];
241 snprintf(auth_cookie_hdr, sizeof(auth_cookie_hdr),
242 "mjj_session=%s", session_cookie_out);
243 Seobeo_Request_Entry *csrfsess_req = make_request(
244 arena,
245 "Host", "localhost",
246 "Remote-Addr", "127.0.0.1",
247 "Cookie", auth_cookie_hdr,
248 NULL, NULL);
249 Seobeo_Request_Entry *csrfsess_resp =
250 Auth_API_Test_Session_Handler(csrfsess_req, arena);
251 const char *csrfsess_body = resp_body(csrfsess_resp);
252 ASSERT(csrfsess_body);
253 const char *cs2 = strstr(csrfsess_body, "\"csrfToken\":\"");
254 ASSERT(cs2);
255 cs2 += strlen("\"csrfToken\":\"");
256 const char *ce2 = strchr(cs2, '"');
257 ASSERT(ce2);
258 size_t cl2 = (size_t)(ce2 - cs2);
259 ASSERT(cl2 < csrf_cap);
260 memcpy(csrf_out, cs2, cl2);
261 csrf_out[cl2] = '\0';
262
263 return TRUE;
264 }
265
266 /* Build a request with session cookie + CSRF header + body. */
267 static Seobeo_Request_Entry *make_admin_req(
268 Dowa_Arena *arena,
269 const char *method,
270 const char *session_cookie,
271 const char *csrf_token,
272 const char *body,
273 const char *id_param)
274 {
275 /* Allocate cookie_hdr from arena so the pointer stays valid after return. */
276 char *cookie_hdr = Dowa_Arena_Allocate(arena, 528);
277 ASSERT(cookie_hdr);
278 snprintf(cookie_hdr, 528, "mjj_session=%s", session_cookie);
279
280 Seobeo_Request_Entry *req = make_request(
281 arena,
282 "Host", "localhost",
283 "Remote-Addr", "127.0.0.1",
284 "Origin", "http://localhost",
285 "Cookie", cookie_hdr,
286 "X-CSRF-Token", (char *)csrf_token,
287 "Body", body ? (char *)body : "",
288 NULL, NULL);
289 if (id_param)
290 Dowa_HashMap_Push_Arena(req, ":id", (char *)id_param, arena);
291 return req;
292 (void)method;
293 }
294
295 /* ------------------------------------------------------------------ */
296 /* Test group: non-admin denial */
297 /* ------------------------------------------------------------------ */
298
299 static void test_non_admin_denial(void)
300 {
301 printf("\n[non-admin denial]\n");
302
303 char db[256];
304 make_temp_db(db, sizeof(db));
305 init_auth(db);
306 Auth_Store *store = Auth_API_Get_Store();
307
308 char member_id[37];
309 ASSERT(create_test_user(store, "member1", "password123456", "member", FALSE, member_id));
310
311 Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
312
313 char scookie[512], csrf[512];
314 ASSERT(login_user(arena, "member1", "password123456",
315 scookie, sizeof(scookie), csrf, sizeof(csrf)));
316
317 TEST("member cannot list users (403)");
318 {
319 Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL);
320 Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
321 ASSERT(strcmp(resp_status(resp), "403") == 0);
322 }
323 PASS();
324
325 TEST("member cannot create users (403)");
326 {
327 Seobeo_Request_Entry *req = make_admin_req(
328 arena, "POST", scookie, csrf,
329 "{\"username\":\"hack\",\"temporaryPassword\":\"password123456\"}", NULL);
330 Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
331 ASSERT(strcmp(resp_status(resp), "403") == 0);
332 }
333 PASS();
334
335 TEST("unauthenticated list → 401");
336 {
337 Seobeo_Request_Entry *req = make_request(
338 arena,
339 "Host", "localhost",
340 "Remote-Addr", "127.0.0.1",
341 NULL, NULL);
342 Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
343 ASSERT(strcmp(resp_status(resp), "401") == 0);
344 /* No page content must leak */
345 const char *body = resp_body(resp);
346 ASSERT(!body || strstr(body, "password") == NULL);
347 }
348 PASS();
349
350 TEST("unauthenticated page → redirect (not content)");
351 {
352 Seobeo_Request_Entry *req = make_request(
353 arena,
354 "Host", "localhost",
355 "Remote-Addr", "127.0.0.1",
356 NULL, NULL);
357 Seobeo_Request_Entry *resp = Admin_API_Test_Page_Handler(req, arena);
358 const char *st = resp_status(resp);
359 /* Must be 302 redirect; body must be empty/no admin content */
360 ASSERT(st && (strcmp(st, "302") == 0 || strcmp(st, "401") == 0));
361 const char *body = resp_body(resp);
362 ASSERT(!body || strstr(body, "<table") == NULL);
363 }
364 PASS();
365
366 Dowa_Arena_Free(arena);
367 Auth_API_Destroy();
368 unlink(db);
369 }
370
371 /* ------------------------------------------------------------------ */
372 /* Test group: forced-password denial */
373 /* ------------------------------------------------------------------ */
374
375 static void test_forced_password_denial(void)
376 {
377 printf("\n[forced-password denial]\n");
378
379 char db[256];
380 make_temp_db(db, sizeof(db));
381 init_auth(db);
382 Auth_Store *store = Auth_API_Get_Store();
383
384 char admin_id[37];
385 ASSERT(create_test_user(store, "fadmin", "password123456", "admin", TRUE, admin_id));
386
387 Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
388
389 char scookie[512], csrf[512];
390 ASSERT(login_user(arena, "fadmin", "password123456",
391 scookie, sizeof(scookie), csrf, sizeof(csrf)));
392
393 TEST("admin with must_change_password blocked from list (403)");
394 {
395 Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL);
396 Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
397 ASSERT(strcmp(resp_status(resp), "403") == 0);
398 }
399 PASS();
400
401 TEST("admin with must_change_password blocked from create (403)");
402 {
403 Seobeo_Request_Entry *req = make_admin_req(
404 arena, "POST", scookie, csrf,
405 "{\"username\":\"newu\",\"temporaryPassword\":\"password123456\"}", NULL);
406 Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
407 ASSERT(strcmp(resp_status(resp), "403") == 0);
408 }
409 PASS();
410
411 Dowa_Arena_Free(arena);
412 Auth_API_Destroy();
413 unlink(db);
414 }
415
416 /* ------------------------------------------------------------------ */
417 /* Test group: create user */
418 /* ------------------------------------------------------------------ */
419
420 static void test_create_user(void)
421 {
422 printf("\n[create user]\n");
423
424 char db[256];
425 make_temp_db(db, sizeof(db));
426 init_auth(db);
427 Auth_Store *store = Auth_API_Get_Store();
428
429 char admin_id[37];
430 ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
431
432 Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
433 char scookie[512], csrf[512];
434 ASSERT(login_user(arena, "admin1", "password123456",
435 scookie, sizeof(scookie), csrf, sizeof(csrf)));
436
437 TEST("create user succeeds → 201, mustChangePassword=true");
438 {
439 Seobeo_Request_Entry *req = make_admin_req(
440 arena, "POST", scookie, csrf,
441 "{\"username\":\"newuser\",\"temporaryPassword\":\"temppass123456\"}", NULL);
442 Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
443 ASSERT(strcmp(resp_status(resp), "201") == 0);
444 const char *body = resp_body(resp);
445 ASSERT(body && strstr(body, "\"mustChangePassword\":true") != NULL);
446 /* No hash/digest in response */
447 ASSERT(strstr(body, "hash") == NULL);
448 ASSERT(strstr(body, "password_hash") == NULL);
449 ASSERT(strstr(body, "digest") == NULL);
450 ASSERT(strstr(body, "session") == NULL);
451 }
452 PASS();
453
454 TEST("duplicate username → 409");
455 {
456 Seobeo_Request_Entry *req = make_admin_req(
457 arena, "POST", scookie, csrf,
458 "{\"username\":\"newuser\",\"temporaryPassword\":\"temppass123456\"}", NULL);
459 Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
460 ASSERT(strcmp(resp_status(resp), "409") == 0);
461 }
462 PASS();
463
464 TEST("short password → 400 policy error");
465 {
466 Seobeo_Request_Entry *req = make_admin_req(
467 arena, "POST", scookie, csrf,
468 "{\"username\":\"shortpw\",\"temporaryPassword\":\"tooshort\"}", NULL);
469 Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
470 ASSERT(strcmp(resp_status(resp), "400") == 0);
471 const char *body = resp_body(resp);
472 ASSERT(body && strstr(body, "password_policy") != NULL);
473 }
474 PASS();
475
476 TEST("invalid role → 400");
477 {
478 Seobeo_Request_Entry *req = make_admin_req(
479 arena, "POST", scookie, csrf,
480 "{\"username\":\"badrole\",\"temporaryPassword\":\"temppass123456\",\"role\":\"superuser\"}", NULL);
481 Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
482 ASSERT(strcmp(resp_status(resp), "400") == 0);
483 }
484 PASS();
485
486 TEST("CSRF rejection → 403");
487 {
488 char bad_cookie_hdr[512];
489 snprintf(bad_cookie_hdr, sizeof(bad_cookie_hdr), "mjj_session=%s", scookie);
490 Seobeo_Request_Entry *req = make_request(
491 arena,
492 "Host", "localhost",
493 "Remote-Addr", "127.0.0.1",
494 "Origin", "http://localhost",
495 "Cookie", bad_cookie_hdr,
496 "X-CSRF-Token", "BADCSRF",
497 "Body", "{\"username\":\"x\",\"temporaryPassword\":\"temppass123456\"}",
498 NULL, NULL);
499 Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
500 ASSERT(strcmp(resp_status(resp), "403") == 0);
501 }
502 PASS();
503
504 TEST("origin rejection → 403");
505 {
506 char cookie_hdr[512];
507 snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", scookie);
508 Seobeo_Request_Entry *req = make_request(
509 arena,
510 "Host", "localhost",
511 "Remote-Addr", "127.0.0.1",
512 "Origin", "http://evil.com",
513 "Cookie", cookie_hdr,
514 "X-CSRF-Token", csrf,
515 "Body", "{\"username\":\"y\",\"temporaryPassword\":\"temppass123456\"}",
516 NULL, NULL);
517 Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
518 ASSERT(strcmp(resp_status(resp), "403") == 0);
519 }
520 PASS();
521
522 TEST("audit insertion failure rolls back user creation");
523 {
524 Deita_Connection *connection = Deita_Connection_Create(
525 DEITA_DATABASE_TYPE_SQLITE3, db);
526 ASSERT(connection);
527 ASSERT(Deita_Query_Execute_Update(
528 connection,
529 "CREATE TRIGGER fail_admin_api_audit"
530 " BEFORE INSERT ON admin_audit_log"
531 " BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END") >= 0);
532 Deita_Connection_Close(connection);
533
534 Seobeo_Request_Entry *req = make_admin_req(
535 arena, "POST", scookie, csrf,
536 "{\"username\":\"auditfail\","
537 "\"temporaryPassword\":\"temppass123456\"}",
538 NULL);
539 Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
540 ASSERT(strcmp(resp_status(resp), "500") == 0);
541
542 Auth_User_Auth_Record record;
543 memset(&record, 0, sizeof(record));
544 ASSERT(Auth_Store_Find_User_By_Username(
545 store, "auditfail", &record) == AUTH_STORE_NOT_FOUND);
546
547 connection = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db);
548 ASSERT(connection);
549 ASSERT(Deita_Query_Execute_Update(
550 connection, "DROP TRIGGER fail_admin_api_audit") >= 0);
551 Deita_Connection_Close(connection);
552 }
553 PASS();
554
555 Dowa_Arena_Free(arena);
556 Auth_API_Destroy();
557 unlink(db);
558 }
559
560 /* ------------------------------------------------------------------ */
561 /* Test group: enable/disable */
562 /* ------------------------------------------------------------------ */
563
564 static void test_enable_disable(void)
565 {
566 printf("\n[enable/disable]\n");
567
568 char db[256];
569 make_temp_db(db, sizeof(db));
570 init_auth(db);
571 Auth_Store *store = Auth_API_Get_Store();
572
573 char admin_id[37], user_id[37];
574 ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
575 ASSERT(create_test_user(store, "user1", "password123456", "member", FALSE, user_id));
576
577 Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
578 char scookie[512], csrf[512];
579 ASSERT(login_user(arena, "admin1", "password123456",
580 scookie, sizeof(scookie), csrf, sizeof(csrf)));
581
582 const char *user_token_digest =
583 "1111111111111111111111111111111111111111111111111111111111111111";
584 const char *user_csrf_digest =
585 "2222222222222222222222222222222222222222222222222222222222222222";
586 int64 session_now = (int64)time(NULL);
587 Auth_Session_Record user_session;
588 ASSERT(Auth_Store_Create_Session(
589 store, user_id, user_token_digest, user_csrf_digest,
590 3600, 86400, session_now, &user_session) == AUTH_STORE_OK);
591
592 TEST("disable user → 200, status=disabled");
593 {
594 Seobeo_Request_Entry *req = make_admin_req(
595 arena, "PATCH", scookie, csrf, "{\"op\":\"disable\"}", user_id);
596 Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
597 ASSERT(strcmp(resp_status(resp), "200") == 0);
598 const char *body = resp_body(resp);
599 ASSERT(body && strstr(body, "\"status\":\"disabled\"") != NULL);
600 Auth_Session_Record found_session;
601 Auth_User_Record found_user;
602 ASSERT(Auth_Store_Find_Session(
603 store, user_token_digest, session_now + 1,
604 &found_session, &found_user) == AUTH_STORE_REVOKED);
605 }
606 PASS();
607
608 TEST("enable user → 200, status=active");
609 {
610 Seobeo_Request_Entry *req = make_admin_req(
611 arena, "PATCH", scookie, csrf, "{\"op\":\"enable\"}", user_id);
612 Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
613 ASSERT(strcmp(resp_status(resp), "200") == 0);
614 const char *body = resp_body(resp);
615 ASSERT(body && strstr(body, "\"status\":\"active\"") != NULL);
616 Auth_Session_Record found_session;
617 Auth_User_Record found_user;
618 ASSERT(Auth_Store_Find_Session(
619 store, user_token_digest, session_now + 2,
620 &found_session, &found_user) == AUTH_STORE_REVOKED);
621 }
622 PASS();
623
624 TEST("disable last active admin → 409 last_admin");
625 {
626 /* admin1 is the only admin */
627 Seobeo_Request_Entry *req = make_admin_req(
628 arena, "PATCH", scookie, csrf, "{\"op\":\"disable\"}", admin_id);
629 Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
630 ASSERT(strcmp(resp_status(resp), "409") == 0);
631 const char *body = resp_body(resp);
632 ASSERT(body && strstr(body, "last_admin") != NULL);
633 }
634 PASS();
635
636 TEST("response body contains no secrets");
637 {
638 Seobeo_Request_Entry *req = make_admin_req(
639 arena, "PATCH", scookie, csrf, "{\"op\":\"enable\"}", user_id);
640 Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
641 const char *body = resp_body(resp);
642 ASSERT(body && strstr(body, "hash") == NULL);
643 ASSERT(body && strstr(body, "digest") == NULL);
644 ASSERT(body && strstr(body, "session") == NULL);
645 }
646 PASS();
647
648 Dowa_Arena_Free(arena);
649 Auth_API_Destroy();
650 unlink(db);
651 }
652
653 /* ------------------------------------------------------------------ */
654 /* Test group: role update */
655 /* ------------------------------------------------------------------ */
656
657 static void test_role_update(void)
658 {
659 printf("\n[role update]\n");
660
661 char db[256];
662 make_temp_db(db, sizeof(db));
663 init_auth(db);
664 Auth_Store *store = Auth_API_Get_Store();
665
666 char admin_id[37], user_id[37];
667 ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
668 ASSERT(create_test_user(store, "user1", "password123456", "member", FALSE, user_id));
669
670 Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
671 char scookie[512], csrf[512];
672 ASSERT(login_user(arena, "admin1", "password123456",
673 scookie, sizeof(scookie), csrf, sizeof(csrf)));
674
675 TEST("promote member to admin → 200");
676 {
677 Seobeo_Request_Entry *req = make_admin_req(
678 arena, "PATCH", scookie, csrf,
679 "{\"op\":\"set_role\",\"role\":\"admin\"}", user_id);
680 Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
681 ASSERT(strcmp(resp_status(resp), "200") == 0);
682 ASSERT(strstr(resp_body(resp), "\"role\":\"admin\"") != NULL);
683 }
684 PASS();
685
686 TEST("demote admin to member → 200 (2 admins → safe)");
687 {
688 Seobeo_Request_Entry *req = make_admin_req(
689 arena, "PATCH", scookie, csrf,
690 "{\"op\":\"set_role\",\"role\":\"member\"}", user_id);
691 Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
692 ASSERT(strcmp(resp_status(resp), "200") == 0);
693 }
694 PASS();
695
696 TEST("demote last admin → 409");
697 {
698 Seobeo_Request_Entry *req = make_admin_req(
699 arena, "PATCH", scookie, csrf,
700 "{\"op\":\"set_role\",\"role\":\"member\"}", admin_id);
701 Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
702 ASSERT(strcmp(resp_status(resp), "409") == 0);
703 }
704 PASS();
705
706 TEST("invalid role value → 400");
707 {
708 Seobeo_Request_Entry *req = make_admin_req(
709 arena, "PATCH", scookie, csrf,
710 "{\"op\":\"set_role\",\"role\":\"superuser\"}", user_id);
711 Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
712 ASSERT(strcmp(resp_status(resp), "400") == 0);
713 }
714 PASS();
715
716 Dowa_Arena_Free(arena);
717 Auth_API_Destroy();
718 unlink(db);
719 }
720
721 /* ------------------------------------------------------------------ */
722 /* Test group: temp password reset */
723 /* ------------------------------------------------------------------ */
724
725 static void test_temp_reset(void)
726 {
727 printf("\n[temp password reset]\n");
728
729 char db[256];
730 make_temp_db(db, sizeof(db));
731 init_auth(db);
732 Auth_Store *store = Auth_API_Get_Store();
733
734 char admin_id[37], user_id[37];
735 ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
736 ASSERT(create_test_user(store, "user1", "password123456", "member", FALSE, user_id));
737
738 Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
739 char scookie[512], csrf[512];
740 ASSERT(login_user(arena, "admin1", "password123456",
741 scookie, sizeof(scookie), csrf, sizeof(csrf)));
742
743 TEST("temp reset sets mustChangePassword=true in response");
744 {
745 Seobeo_Request_Entry *req = make_admin_req(
746 arena, "PATCH", scookie, csrf,
747 "{\"op\":\"temp_reset\",\"temporaryPassword\":\"newtemp123456\"}", user_id);
748 Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
749 ASSERT(strcmp(resp_status(resp), "200") == 0);
750 const char *body = resp_body(resp);
751 ASSERT(body && strstr(body, "\"mustChangePassword\":true") != NULL);
752 /* No raw password, hash, or digest in response. */
753 ASSERT(strstr(body, "hash") == NULL);
754 ASSERT(strstr(body, "digest") == NULL);
755 ASSERT(strstr(body, "newtemp123456") == NULL);
756 }
757 PASS();
758
759 TEST("temp reset short password → 400");
760 {
761 Seobeo_Request_Entry *req = make_admin_req(
762 arena, "PATCH", scookie, csrf,
763 "{\"op\":\"temp_reset\",\"temporaryPassword\":\"short\"}", user_id);
764 Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
765 ASSERT(strcmp(resp_status(resp), "400") == 0);
766 }
767 PASS();
768
769 TEST("user can login after reset with new temp password");
770 {
771 /* Login as user1 with the new temp password. */
772 char u_cookie[512], u_csrf[512];
773 boolean ok = login_user(arena, "user1", "newtemp123456",
774 u_cookie, sizeof(u_cookie),
775 u_csrf, sizeof(u_csrf));
776 ASSERT(ok);
777 /* Confirm must_change_password is set. */
778 char cookie_hdr[512];
779 snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", u_cookie);
780 Seobeo_Request_Entry *sreq = make_request(
781 arena,
782 "Host", "localhost",
783 "Remote-Addr", "127.0.0.1",
784 "Cookie", cookie_hdr,
785 NULL, NULL);
786 Seobeo_Request_Entry *sresp = Auth_API_Test_Session_Handler(sreq, arena);
787 const char *sbody = resp_body(sresp);
788 ASSERT(sbody && strstr(sbody, "\"mustChangePassword\":true") != NULL);
789 }
790 PASS();
791
792 Dowa_Arena_Free(arena);
793 Auth_API_Destroy();
794 unlink(db);
795 }
796
797 /* ------------------------------------------------------------------ */
798 /* Test group: session revocation */
799 /* ------------------------------------------------------------------ */
800
801 static void test_session_revocation(void)
802 {
803 printf("\n[session revocation]\n");
804
805 char db[256];
806 make_temp_db(db, sizeof(db));
807 init_auth(db);
808 Auth_Store *store = Auth_API_Get_Store();
809
810 char admin_id[37], user_id[37];
811 ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
812 ASSERT(create_test_user(store, "user1", "password123456", "member", FALSE, user_id));
813
814 Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
815 char admin_cookie[512], admin_csrf[512];
816 ASSERT(login_user(arena, "admin1", "password123456",
817 admin_cookie, sizeof(admin_cookie),
818 admin_csrf, sizeof(admin_csrf)));
819
820 /* Login user1 to create a session. */
821 char u_cookie[512], u_csrf[512];
822 ASSERT(login_user(arena, "user1", "password123456",
823 u_cookie, sizeof(u_cookie), u_csrf, sizeof(u_csrf)));
824
825 TEST("DELETE /api/admin/users/:id/sessions → 200");
826 {
827 Seobeo_Request_Entry *req = make_admin_req(
828 arena, "DELETE", admin_cookie, admin_csrf, NULL, user_id);
829 Seobeo_Request_Entry *resp =
830 Admin_API_Test_Revoke_Sessions_Handler(req, arena);
831 ASSERT(strcmp(resp_status(resp), "200") == 0);
832 }
833 PASS();
834
835 TEST("user session is invalid after revocation");
836 {
837 char cookie_hdr[512];
838 snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", u_cookie);
839 Seobeo_Request_Entry *sreq = make_request(
840 arena,
841 "Host", "localhost",
842 "Remote-Addr", "127.0.0.1",
843 "Cookie", cookie_hdr,
844 NULL, NULL);
845 Seobeo_Request_Entry *sresp = Auth_API_Test_Session_Handler(sreq, arena);
846 const char *sbody = resp_body(sresp);
847 /* Should fall back to guest after session revoked */
848 ASSERT(sbody && strstr(sbody, "\"kind\":\"user\"") == NULL);
849 }
850 PASS();
851
852 TEST("revoke sessions for non-existent user → 404");
853 {
854 Seobeo_Request_Entry *req = make_admin_req(
855 arena, "DELETE", admin_cookie, admin_csrf, NULL,
856 "00000000-0000-0000-0000-000000000000");
857 Seobeo_Request_Entry *resp =
858 Admin_API_Test_Revoke_Sessions_Handler(req, arena);
859 ASSERT(strcmp(resp_status(resp), "404") == 0);
860 }
861 PASS();
862
863 TEST("CSRF required for DELETE → 403 without CSRF");
864 {
865 char cookie_hdr[512];
866 snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", admin_cookie);
867 Seobeo_Request_Entry *req = make_request(
868 arena,
869 "Host", "localhost",
870 "Remote-Addr", "127.0.0.1",
871 "Origin", "http://localhost",
872 "Cookie", cookie_hdr,
873 ":id", user_id,
874 NULL, NULL);
875 Seobeo_Request_Entry *resp =
876 Admin_API_Test_Revoke_Sessions_Handler(req, arena);
877 ASSERT(strcmp(resp_status(resp), "403") == 0);
878 }
879 PASS();
880
881 Dowa_Arena_Free(arena);
882 Auth_API_Destroy();
883 unlink(db);
884 }
885
886 /* ------------------------------------------------------------------ */
887 /* Test group: no secrets in responses */
888 /* ------------------------------------------------------------------ */
889
890 static void test_no_secret_fields(void)
891 {
892 printf("\n[no secret fields in JSON/page]\n");
893
894 char db[256];
895 make_temp_db(db, sizeof(db));
896 init_auth(db);
897 Auth_Store *store = Auth_API_Get_Store();
898
899 char admin_id[37];
900 ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
901 ASSERT(create_test_user(store, "user2", "password123456", "member", FALSE, admin_id));
902
903 Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
904 char scookie[512], csrf[512];
905 ASSERT(login_user(arena, "admin1", "password123456",
906 scookie, sizeof(scookie), csrf, sizeof(csrf)));
907
908 TEST("list response has no password_hash, session, digest, or guest fields");
909 {
910 Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL);
911 Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
912 ASSERT(strcmp(resp_status(resp), "200") == 0);
913 const char *body = resp_body(resp);
914 ASSERT(body);
915 ASSERT(strstr(body, "password_hash") == NULL);
916 ASSERT(strstr(body, "passwordHash") == NULL);
917 ASSERT(strstr(body, "token_digest") == NULL);
918 ASSERT(strstr(body, "csrf_digest") == NULL);
919 ASSERT(strstr(body, "guest_id") == NULL);
920 ASSERT(strstr(body, "ip_binding") == NULL);
921 ASSERT(strstr(body, "session") == NULL);
922 }
923 PASS();
924
925 TEST("audit log for create contains no credentials");
926 {
927 /* Create a user, then verify audit log doesn't have hash/password. */
928 Seobeo_Request_Entry *req = make_admin_req(
929 arena, "POST", scookie, csrf,
930 "{\"username\":\"audituser\",\"temporaryPassword\":\"auditpass123456\"}", NULL);
931 Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
932 ASSERT(strcmp(resp_status(resp), "201") == 0);
933 const char *body = resp_body(resp);
934 ASSERT(body && strstr(body, "auditpass123456") == NULL);
935 ASSERT(body && strstr(body, "hash") == NULL);
936 }
937 PASS();
938
939 Dowa_Arena_Free(arena);
940 Auth_API_Destroy();
941 unlink(db);
942 }
943
944 /* ------------------------------------------------------------------ */
945 /* Test group: list pagination */
946 /* ------------------------------------------------------------------ */
947
948 static void test_list_pagination(void)
949 {
950 printf("\n[list pagination]\n");
951
952 char db[256];
953 make_temp_db(db, sizeof(db));
954 init_auth(db);
955 Auth_Store *store = Auth_API_Get_Store();
956
957 char admin_id[37];
958 ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
959 /* Create 5 more users. */
960 for (int i = 0; i < 5; i++)
961 {
962 char uname[32], uid[37];
963 snprintf(uname, sizeof(uname), "user%d", i);
964 ASSERT(create_test_user(store, uname, "password123456", "member", FALSE, uid));
965 }
966
967 Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
968 char scookie[512], csrf[512];
969 ASSERT(login_user(arena, "admin1", "password123456",
970 scookie, sizeof(scookie), csrf, sizeof(csrf)));
971
972 TEST("list all users includes total count");
973 {
974 Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL);
975 Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
976 ASSERT(strcmp(resp_status(resp), "200") == 0);
977 const char *body = resp_body(resp);
978 ASSERT(body && strstr(body, "\"total\":6") != NULL);
979 ASSERT(body && strstr(body, "\"users\":[") != NULL);
980 }
981 PASS();
982
983 TEST("huge page number returns an empty page without overflow");
984 {
985 Seobeo_Request_Entry *req =
986 make_admin_req(arena, "GET", scookie, csrf, NULL, NULL);
987 Dowa_HashMap_Push_Arena(
988 req, "Query-page", "9223372036854775807", arena);
989 Dowa_HashMap_Push_Arena(req, "Query-limit", "100", arena);
990 Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
991 ASSERT(strcmp(resp_status(resp), "200") == 0);
992 const char *body = resp_body(resp);
993 ASSERT(body && strstr(body, "\"users\":[]") != NULL);
994 }
995 PASS();
996
997 Dowa_Arena_Free(arena);
998 Auth_API_Destroy();
999 unlink(db);
1000 }
1001
1002 /* ------------------------------------------------------------------ */
1003 /* main */
1004 /* ------------------------------------------------------------------ */
1005
1006 int main(void)
1007 {
1008 printf("=== admin_api_test ===\n");
1009
1010 test_non_admin_denial();
1011 test_forced_password_denial();
1012 test_create_user();
1013 test_enable_disable();
1014 test_role_update();
1015 test_temp_reset();
1016 test_session_revocation();
1017 test_no_secret_fields();
1018 test_list_pagination();
1019
1020 printf("\n=== ALL TESTS PASSED ===\n");
1021 return 0;
1022 }