Mercurial
comparison auth/test/auth_store_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 #include "auth/auth_store.h" | |
| 2 #include "auth/auth_crypto.h" | |
| 3 #include "deita/deita.h" | |
| 4 | |
| 5 #include <assert.h> | |
| 6 #include <fcntl.h> | |
| 7 #include <stdio.h> | |
| 8 #include <string.h> | |
| 9 #include <time.h> | |
| 10 #include <unistd.h> | |
| 11 | |
| 12 /* ------------------------------------------------------------------ */ | |
| 13 /* Helpers */ | |
| 14 /* ------------------------------------------------------------------ */ | |
| 15 | |
| 16 static char g_hash_buf[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; | |
| 17 | |
| 18 static const char *get_test_hash(void) | |
| 19 { | |
| 20 if (g_hash_buf[0] == '\0') | |
| 21 { | |
| 22 Auth_Crypto_Result r = Auth_Crypto_Password_Hash( | |
| 23 "hunter2", g_hash_buf, sizeof(g_hash_buf)); | |
| 24 assert(r == AUTH_CRYPTO_OK); | |
| 25 } | |
| 26 return g_hash_buf; | |
| 27 } | |
| 28 | |
| 29 static void make_test_hash( | |
| 30 const char *password, | |
| 31 char output[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]) | |
| 32 { | |
| 33 assert(Auth_Crypto_Password_Hash( | |
| 34 password, output, AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE) == | |
| 35 AUTH_CRYPTO_OK); | |
| 36 } | |
| 37 | |
| 38 /* Simple UUID generator for the test, matching the pattern in | |
| 39 conversation_store.c (no dependency on auth_store internals). */ | |
| 40 static boolean test__make_uuid(char output[37]) | |
| 41 { | |
| 42 uint8 bytes[16]; | |
| 43 int fd = open("/dev/urandom", O_RDONLY); | |
| 44 size_t offset = 0; | |
| 45 if (fd < 0) | |
| 46 return FALSE; | |
| 47 while (offset < sizeof(bytes)) | |
| 48 { | |
| 49 ssize_t n = read(fd, bytes + offset, sizeof(bytes) - offset); | |
| 50 if (n <= 0) { close(fd); return FALSE; } | |
| 51 offset += (size_t)n; | |
| 52 } | |
| 53 close(fd); | |
| 54 bytes[6] = (uint8)((bytes[6] & 0x0f) | 0x40); | |
| 55 bytes[8] = (uint8)((bytes[8] & 0x3f) | 0x80); | |
| 56 snprintf(output, 37, | |
| 57 "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", | |
| 58 bytes[0],bytes[1],bytes[2],bytes[3],bytes[4],bytes[5], | |
| 59 bytes[6],bytes[7],bytes[8],bytes[9],bytes[10],bytes[11], | |
| 60 bytes[12],bytes[13],bytes[14],bytes[15]); | |
| 61 return TRUE; | |
| 62 } | |
| 63 | |
| 64 /* ------------------------------------------------------------------ */ | |
| 65 /* 1. Username normalization and rejection */ | |
| 66 /* ------------------------------------------------------------------ */ | |
| 67 | |
| 68 static void test_username_normalization(void) | |
| 69 { | |
| 70 char out[AUTH_STORE_USERNAME_MAX + 1]; | |
| 71 | |
| 72 assert(Auth_Store_Normalize_Username(" JohnDoe ", out, sizeof(out))); | |
| 73 assert(strcmp(out, "johndoe") == 0); | |
| 74 | |
| 75 assert(Auth_Store_Normalize_Username("Alice", out, sizeof(out))); | |
| 76 assert(strcmp(out, "alice") == 0); | |
| 77 | |
| 78 assert(Auth_Store_Normalize_Username("june_bot-2.0", out, sizeof(out))); | |
| 79 assert(strcmp(out, "june_bot-2.0") == 0); | |
| 80 | |
| 81 assert(Auth_Store_Normalize_Username("abc", out, sizeof(out))); | |
| 82 | |
| 83 /* Exactly 32 chars */ | |
| 84 assert(Auth_Store_Normalize_Username( | |
| 85 "abcdefghijklmnopqrstuvwxyz123456", out, sizeof(out))); | |
| 86 | |
| 87 /* Too short after trimming */ | |
| 88 assert(!Auth_Store_Normalize_Username("ab", out, sizeof(out))); | |
| 89 assert(!Auth_Store_Normalize_Username(" z ", out, sizeof(out))); | |
| 90 | |
| 91 /* Too long (33 chars) */ | |
| 92 assert(!Auth_Store_Normalize_Username( | |
| 93 "abcdefghijklmnopqrstuvwxyz1234567", out, sizeof(out))); | |
| 94 | |
| 95 /* Invalid characters */ | |
| 96 assert(!Auth_Store_Normalize_Username("hello world", out, sizeof(out))); | |
| 97 assert(!Auth_Store_Normalize_Username("invalid!", out, sizeof(out))); | |
| 98 assert(!Auth_Store_Normalize_Username("utf8\xc3\xa9", out, sizeof(out))); | |
| 99 | |
| 100 /* Buffer too small */ | |
| 101 char tiny[3]; | |
| 102 assert(!Auth_Store_Normalize_Username("abc", tiny, sizeof(tiny))); | |
| 103 | |
| 104 /* Validate pre-normalized */ | |
| 105 assert( Auth_Store_Validate_Username("johndoe")); | |
| 106 assert( Auth_Store_Validate_Username("abc")); | |
| 107 assert( Auth_Store_Validate_Username("june_bot-2.0")); | |
| 108 assert(!Auth_Store_Validate_Username("ab")); | |
| 109 assert(!Auth_Store_Validate_Username("Hello")); /* uppercase */ | |
| 110 assert(!Auth_Store_Validate_Username("bad char!")); | |
| 111 assert(!Auth_Store_Validate_Username(NULL)); | |
| 112 | |
| 113 puts("test_username_normalization: PASS"); | |
| 114 } | |
| 115 | |
| 116 /* ------------------------------------------------------------------ */ | |
| 117 /* 2. Migration idempotency / reopen */ | |
| 118 /* ------------------------------------------------------------------ */ | |
| 119 | |
| 120 static void test_migrations_idempotent(const char *db_path) | |
| 121 { | |
| 122 Auth_Store *p = Auth_Store_Create(db_path); | |
| 123 assert(p); | |
| 124 Auth_Store_Destroy(p); | |
| 125 | |
| 126 /* Reopen: migrations must be no-ops. */ | |
| 127 p = Auth_Store_Create(db_path); | |
| 128 assert(p); | |
| 129 Auth_Store_Destroy(p); | |
| 130 | |
| 131 /* Verify all expected tables exist via a second connection. */ | |
| 132 Dowa_Arena *p_arena = Dowa_Arena_Create(4096); | |
| 133 assert(p_arena); | |
| 134 Deita_Connection *p_conn = Deita_Connection_Create( | |
| 135 DEITA_DATABASE_TYPE_SQLITE3, db_path); | |
| 136 assert(p_conn); | |
| 137 | |
| 138 static const char *expected[] = { | |
| 139 "admin_audit_log", "auth_schema_migrations", "auth_sessions", | |
| 140 "guest_identities", "guest_usage", "guest_usage_reservations", "users", | |
| 141 }; | |
| 142 size_t found = 0; | |
| 143 | |
| 144 Deita_Result_Set *p_result = Deita_Query_Execute( | |
| 145 p_conn, | |
| 146 "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", | |
| 147 p_arena); | |
| 148 assert(p_result); | |
| 149 while (Deita_Result_Set_Next(p_result)) | |
| 150 { | |
| 151 const char *name = Deita_Result_Set_Get_Text(p_result, 0); | |
| 152 for (size_t i = 0; i < sizeof(expected)/sizeof(expected[0]); i++) | |
| 153 if (name && strcmp(name, expected[i]) == 0) { found++; break; } | |
| 154 } | |
| 155 Deita_Result_Set_Free(p_result); | |
| 156 Deita_Connection_Close(p_conn); | |
| 157 Dowa_Arena_Free(p_arena); | |
| 158 | |
| 159 assert(found == sizeof(expected)/sizeof(expected[0])); | |
| 160 puts("test_migrations_idempotent: PASS"); | |
| 161 } | |
| 162 | |
| 163 /* ------------------------------------------------------------------ */ | |
| 164 /* 3. Bootstrap admin idempotency */ | |
| 165 /* ------------------------------------------------------------------ */ | |
| 166 | |
| 167 static void test_bootstrap_admin(Auth_Store *p_store) | |
| 168 { | |
| 169 char id1[37], id2[37]; | |
| 170 Auth_Store_Bootstrap_Result br; | |
| 171 | |
| 172 assert(Auth_Store_Bootstrap_Admin( | |
| 173 p_store, "Admin", get_test_hash(), &br, id1) == AUTH_STORE_OK); | |
| 174 assert(br == AUTH_STORE_BOOTSTRAP_CREATED); | |
| 175 assert(strlen(id1) == 36); | |
| 176 | |
| 177 /* Second call with a different username: must not overwrite existing admin. */ | |
| 178 assert(Auth_Store_Bootstrap_Admin( | |
| 179 p_store, "OtherAdmin", get_test_hash(), &br, id2) == AUTH_STORE_OK); | |
| 180 assert(br == AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT); | |
| 181 assert(strcmp(id1, id2) == 0); | |
| 182 | |
| 183 /* Exactly one admin must exist. */ | |
| 184 Auth_User_Record *records = NULL; | |
| 185 Dowa_Arena *p_arena = Dowa_Arena_Create(64 * 1024); | |
| 186 assert(p_arena); | |
| 187 assert(Auth_Store_List_Users(p_store, &records, p_arena) == AUTH_STORE_OK); | |
| 188 size_t admin_count = 0; | |
| 189 for (size_t i = 0; i < Dowa_Array_Length(records); i++) | |
| 190 if (strcmp(records[i].role, "admin") == 0) | |
| 191 admin_count++; | |
| 192 assert(admin_count == 1); | |
| 193 Dowa_Arena_Free(p_arena); | |
| 194 | |
| 195 puts("test_bootstrap_admin: PASS"); | |
| 196 } | |
| 197 | |
| 198 /* ------------------------------------------------------------------ */ | |
| 199 /* 4. Last-admin protection (run while only one admin exists) */ | |
| 200 /* ------------------------------------------------------------------ */ | |
| 201 | |
| 202 static void test_last_admin_protection(Auth_Store *p_store) | |
| 203 { | |
| 204 /* At this point only the bootstrap admin ("Admin"/"admin") exists. */ | |
| 205 Auth_User_Auth_Record ar; | |
| 206 assert(Auth_Store_Find_User_By_Username( | |
| 207 p_store, "admin", &ar) == AUTH_STORE_OK); | |
| 208 const char *admin_id = ar.user.id; | |
| 209 | |
| 210 /* Cannot disable the last active admin. */ | |
| 211 assert(Auth_Store_Update_User_Status( | |
| 212 p_store, admin_id, "disabled", NULL) == AUTH_STORE_LAST_ADMIN); | |
| 213 | |
| 214 /* Cannot demote the last active admin. */ | |
| 215 assert(Auth_Store_Update_User_Role( | |
| 216 p_store, admin_id, "member", NULL) == AUTH_STORE_LAST_ADMIN); | |
| 217 assert(Auth_Store_Disable_User_And_Revoke_Sessions( | |
| 218 p_store, admin_id, NULL) == AUTH_STORE_LAST_ADMIN); | |
| 219 assert(Auth_Store_Update_Role_And_Revoke_Sessions( | |
| 220 p_store, admin_id, "member", NULL) == AUTH_STORE_LAST_ADMIN); | |
| 221 | |
| 222 /* Add a second active admin — operations on the first should now succeed. */ | |
| 223 char id2[37]; | |
| 224 assert(Auth_Store_Create_User( | |
| 225 p_store, "SecondAdmin", get_test_hash(), "admin", FALSE, id2) == | |
| 226 AUTH_STORE_OK); | |
| 227 | |
| 228 /* Now demotion of the first admin is allowed (two active admins). */ | |
| 229 assert(Auth_Store_Update_User_Role( | |
| 230 p_store, admin_id, "member", id2) == AUTH_STORE_OK); | |
| 231 | |
| 232 /* Re-promote so remaining tests can rely on admin being an admin. */ | |
| 233 assert(Auth_Store_Update_User_Role( | |
| 234 p_store, admin_id, "admin", id2) == AUTH_STORE_OK); | |
| 235 | |
| 236 puts("test_last_admin_protection: PASS"); | |
| 237 } | |
| 238 | |
| 239 /* ------------------------------------------------------------------ */ | |
| 240 /* 5. Username uniqueness */ | |
| 241 /* ------------------------------------------------------------------ */ | |
| 242 | |
| 243 static void test_username_uniqueness(Auth_Store *p_store) | |
| 244 { | |
| 245 char id[37]; | |
| 246 assert(Auth_Store_Create_User( | |
| 247 p_store, "UniqueUser", get_test_hash(), "member", FALSE, id) == | |
| 248 AUTH_STORE_OK); | |
| 249 | |
| 250 char id2[37]; | |
| 251 assert(Auth_Store_Create_User( | |
| 252 p_store, "uniqueuser", get_test_hash(), "member", FALSE, id2) == | |
| 253 AUTH_STORE_CONFLICT); | |
| 254 | |
| 255 puts("test_username_uniqueness: PASS"); | |
| 256 } | |
| 257 | |
| 258 /* ------------------------------------------------------------------ */ | |
| 259 /* 6. User lookup */ | |
| 260 /* ------------------------------------------------------------------ */ | |
| 261 | |
| 262 static void test_user_lookup(Auth_Store *p_store) | |
| 263 { | |
| 264 char id[37]; | |
| 265 assert(Auth_Store_Create_User( | |
| 266 p_store, "LookupUser", get_test_hash(), "member", FALSE, id) == | |
| 267 AUTH_STORE_OK); | |
| 268 | |
| 269 Auth_User_Record r; | |
| 270 assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK); | |
| 271 assert(strcmp(r.id, id) == 0); | |
| 272 assert(strcmp(r.username, "LookupUser") == 0); | |
| 273 assert(strcmp(r.normalized_username, "lookupuser") == 0); | |
| 274 assert(strcmp(r.role, "member") == 0); | |
| 275 assert(strcmp(r.status, "active") == 0); | |
| 276 assert(r.must_change_password == FALSE); | |
| 277 | |
| 278 /* Not found */ | |
| 279 assert(Auth_Store_Get_User( | |
| 280 p_store, "00000000-0000-0000-0000-000000000000", &r) == | |
| 281 AUTH_STORE_NOT_FOUND); | |
| 282 | |
| 283 /* Case-insensitive find by username */ | |
| 284 Auth_User_Auth_Record auth_r; | |
| 285 assert(Auth_Store_Find_User_By_Username( | |
| 286 p_store, " LOOKUPUSER ", &auth_r) == AUTH_STORE_OK); | |
| 287 assert(strcmp(auth_r.user.id, id) == 0); | |
| 288 assert(strncmp(auth_r.password_hash, "zenbu-scrypt$", 13) == 0); | |
| 289 | |
| 290 assert(Auth_Store_Find_User_By_Username( | |
| 291 p_store, "nobody", &auth_r) == AUTH_STORE_NOT_FOUND); | |
| 292 | |
| 293 puts("test_user_lookup: PASS"); | |
| 294 } | |
| 295 | |
| 296 /* ------------------------------------------------------------------ */ | |
| 297 /* 7. Forced password flag */ | |
| 298 /* ------------------------------------------------------------------ */ | |
| 299 | |
| 300 static void test_forced_password_flag(Auth_Store *p_store) | |
| 301 { | |
| 302 char id[37]; | |
| 303 assert(Auth_Store_Create_User( | |
| 304 p_store, "ForcedPwUser", get_test_hash(), "member", TRUE, id) == | |
| 305 AUTH_STORE_OK); | |
| 306 | |
| 307 Auth_User_Record r; | |
| 308 assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK); | |
| 309 assert(r.must_change_password == TRUE); | |
| 310 | |
| 311 assert(Auth_Store_Set_Must_Change_Password( | |
| 312 p_store, id, FALSE, NULL) == AUTH_STORE_OK); | |
| 313 assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK); | |
| 314 assert(r.must_change_password == FALSE); | |
| 315 | |
| 316 assert(Auth_Store_Set_Must_Change_Password( | |
| 317 p_store, id, TRUE, NULL) == AUTH_STORE_OK); | |
| 318 assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK); | |
| 319 assert(r.must_change_password == TRUE); | |
| 320 | |
| 321 /* Updating password clears the flag. */ | |
| 322 assert(Auth_Store_Update_Password( | |
| 323 p_store, id, get_test_hash(), FALSE, NULL) == AUTH_STORE_OK); | |
| 324 assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK); | |
| 325 assert(r.must_change_password == FALSE); | |
| 326 | |
| 327 puts("test_forced_password_flag: PASS"); | |
| 328 } | |
| 329 | |
| 330 /* ------------------------------------------------------------------ */ | |
| 331 /* 8. Session create / resolve / touch / revoke */ | |
| 332 /* ------------------------------------------------------------------ */ | |
| 333 | |
| 334 static void test_session_lifecycle(Auth_Store *p_store) | |
| 335 { | |
| 336 char user_id[37]; | |
| 337 assert(Auth_Store_Create_User( | |
| 338 p_store, "SessUser", get_test_hash(), "member", FALSE, user_id) == | |
| 339 AUTH_STORE_OK); | |
| 340 | |
| 341 const char *tok = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; | |
| 342 const char *csrf = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; | |
| 343 int64 now = 1700000000LL; | |
| 344 int64 idle_ttl = 3600; | |
| 345 int64 abs_ttl = 86400; | |
| 346 | |
| 347 Auth_Session_Record sess; | |
| 348 assert(Auth_Store_Create_Session( | |
| 349 p_store, user_id, tok, csrf, idle_ttl, abs_ttl, now, &sess) == | |
| 350 AUTH_STORE_OK); | |
| 351 assert(strcmp(sess.user_id, user_id) == 0); | |
| 352 assert(sess.created_at == now); | |
| 353 assert(sess.idle_expires_at == now + idle_ttl); | |
| 354 assert(sess.absolute_expires_at == now + abs_ttl); | |
| 355 assert(sess.password_changed_at_snapshot == 0); | |
| 356 | |
| 357 Auth_Session_Record fs; | |
| 358 Auth_User_Record fu; | |
| 359 int64 check = now + 60; | |
| 360 | |
| 361 assert(Auth_Store_Find_Session( | |
| 362 p_store, tok, check, &fs, &fu) == AUTH_STORE_OK); | |
| 363 assert(strcmp(fu.id, user_id) == 0); | |
| 364 | |
| 365 /* Touch extends idle expiry. */ | |
| 366 assert(Auth_Store_Touch_Session( | |
| 367 p_store, tok, check, idle_ttl) == AUTH_STORE_OK); | |
| 368 assert(Auth_Store_Find_Session( | |
| 369 p_store, tok, check, &fs, &fu) == AUTH_STORE_OK); | |
| 370 assert(fs.idle_expires_at == check + idle_ttl); | |
| 371 | |
| 372 /* Revoke. */ | |
| 373 assert(Auth_Store_Revoke_Session(p_store, tok) == AUTH_STORE_OK); | |
| 374 assert(Auth_Store_Find_Session( | |
| 375 p_store, tok, check, &fs, &fu) == AUTH_STORE_REVOKED); | |
| 376 | |
| 377 /* Unknown digest. */ | |
| 378 const char *unk = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; | |
| 379 assert(Auth_Store_Find_Session( | |
| 380 p_store, unk, check, &fs, &fu) == AUTH_STORE_NOT_FOUND); | |
| 381 | |
| 382 puts("test_session_lifecycle: PASS"); | |
| 383 } | |
| 384 | |
| 385 /* ------------------------------------------------------------------ */ | |
| 386 /* 9. Stale password snapshot */ | |
| 387 /* ------------------------------------------------------------------ */ | |
| 388 | |
| 389 static void test_stale_password_snapshot(Auth_Store *p_store) | |
| 390 { | |
| 391 char user_id[37]; | |
| 392 assert(Auth_Store_Create_User( | |
| 393 p_store, "StaleUser", get_test_hash(), "member", FALSE, user_id) == | |
| 394 AUTH_STORE_OK); | |
| 395 | |
| 396 const char *tok = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; | |
| 397 const char *csrf = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; | |
| 398 int64 now = 1700100000LL; | |
| 399 | |
| 400 Auth_Session_Record sess; | |
| 401 assert(Auth_Store_Create_Session( | |
| 402 p_store, user_id, tok, csrf, 3600, 86400, now, &sess) == AUTH_STORE_OK); | |
| 403 assert(sess.password_changed_at_snapshot == 0); | |
| 404 | |
| 405 Auth_Session_Record fs; | |
| 406 Auth_User_Record fu; | |
| 407 assert(Auth_Store_Find_Session( | |
| 408 p_store, tok, now + 10, &fs, &fu) == AUTH_STORE_OK); | |
| 409 | |
| 410 assert(Auth_Store_Update_Password( | |
| 411 p_store, user_id, get_test_hash(), FALSE, NULL) == AUTH_STORE_OK); | |
| 412 | |
| 413 assert(Auth_Store_Find_Session( | |
| 414 p_store, tok, now + 20, &fs, &fu) == AUTH_STORE_STALE_PASSWORD); | |
| 415 | |
| 416 puts("test_stale_password_snapshot: PASS"); | |
| 417 } | |
| 418 | |
| 419 /* ------------------------------------------------------------------ */ | |
| 420 /* 10. Disabled user blocks session resolution and creation */ | |
| 421 /* ------------------------------------------------------------------ */ | |
| 422 | |
| 423 static void test_disabled_user(Auth_Store *p_store) | |
| 424 { | |
| 425 char user_id[37]; | |
| 426 assert(Auth_Store_Create_User( | |
| 427 p_store, "DisabledUser", get_test_hash(), "member", FALSE, user_id) == | |
| 428 AUTH_STORE_OK); | |
| 429 | |
| 430 const char *tok = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; | |
| 431 const char *csrf = "1111111111111111111111111111111111111111111111111111111111111111"; | |
| 432 int64 now = 1700200000LL; | |
| 433 | |
| 434 Auth_Session_Record sess; | |
| 435 assert(Auth_Store_Create_Session( | |
| 436 p_store, user_id, tok, csrf, 3600, 86400, now, &sess) == AUTH_STORE_OK); | |
| 437 | |
| 438 Auth_Session_Record fs; | |
| 439 Auth_User_Record fu; | |
| 440 assert(Auth_Store_Find_Session( | |
| 441 p_store, tok, now + 10, &fs, &fu) == AUTH_STORE_OK); | |
| 442 | |
| 443 /* Disable the member user (no last-admin protection applies). */ | |
| 444 assert(Auth_Store_Update_User_Status( | |
| 445 p_store, user_id, "disabled", NULL) == AUTH_STORE_OK); | |
| 446 | |
| 447 assert(Auth_Store_Find_Session( | |
| 448 p_store, tok, now + 20, &fs, &fu) == AUTH_STORE_USER_DISABLED); | |
| 449 | |
| 450 /* New session for a disabled user must fail. */ | |
| 451 const char *tok2 = "2222222222222222222222222222222222222222222222222222222222222222"; | |
| 452 const char *csrf2 = "3333333333333333333333333333333333333333333333333333333333333333"; | |
| 453 assert(Auth_Store_Create_Session( | |
| 454 p_store, user_id, tok2, csrf2, 3600, 86400, now + 30, &sess) == | |
| 455 AUTH_STORE_USER_DISABLED); | |
| 456 | |
| 457 /* Re-enable. */ | |
| 458 assert(Auth_Store_Update_User_Status( | |
| 459 p_store, user_id, "active", NULL) == AUTH_STORE_OK); | |
| 460 assert(Auth_Store_Find_Session( | |
| 461 p_store, tok, now + 30, &fs, &fu) == AUTH_STORE_OK); | |
| 462 | |
| 463 puts("test_disabled_user: PASS"); | |
| 464 } | |
| 465 | |
| 466 /* ------------------------------------------------------------------ */ | |
| 467 /* 11. Password update revokes other sessions atomically */ | |
| 468 /* ------------------------------------------------------------------ */ | |
| 469 | |
| 470 static void test_password_update_revokes_others(Auth_Store *p_store) | |
| 471 { | |
| 472 char user_id[37]; | |
| 473 assert(Auth_Store_Create_User( | |
| 474 p_store, "RevokeOthers", get_test_hash(), "member", FALSE, user_id) == | |
| 475 AUTH_STORE_OK); | |
| 476 | |
| 477 int64 now = 1700300000LL; | |
| 478 const char *tok_keep = "4444444444444444444444444444444444444444444444444444444444444444"; | |
| 479 const char *tok_rev1 = "5555555555555555555555555555555555555555555555555555555555555555"; | |
| 480 const char *tok_rev2 = "6666666666666666666666666666666666666666666666666666666666666666"; | |
| 481 const char *csrf_k = "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111"; | |
| 482 const char *csrf_1 = "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222"; | |
| 483 const char *csrf_2 = "cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333"; | |
| 484 | |
| 485 Auth_Session_Record sess; | |
| 486 assert(Auth_Store_Create_Session( | |
| 487 p_store, user_id, tok_keep, csrf_k, 3600, 86400, now, &sess) == | |
| 488 AUTH_STORE_OK); | |
| 489 assert(Auth_Store_Create_Session( | |
| 490 p_store, user_id, tok_rev1, csrf_1, 3600, 86400, now, &sess) == | |
| 491 AUTH_STORE_OK); | |
| 492 assert(Auth_Store_Create_Session( | |
| 493 p_store, user_id, tok_rev2, csrf_2, 3600, 86400, now, &sess) == | |
| 494 AUTH_STORE_OK); | |
| 495 | |
| 496 /* Update password: keep tok_keep, revoke all others. */ | |
| 497 assert(Auth_Store_Update_Password( | |
| 498 p_store, user_id, get_test_hash(), TRUE, tok_keep) == AUTH_STORE_OK); | |
| 499 | |
| 500 int64 check = now + 60; | |
| 501 Auth_Session_Record fs; | |
| 502 Auth_User_Record fu; | |
| 503 | |
| 504 /* tok_keep is not revoked but snapshot is stale because password changed. */ | |
| 505 assert(Auth_Store_Find_Session( | |
| 506 p_store, tok_keep, check, &fs, &fu) == AUTH_STORE_STALE_PASSWORD); | |
| 507 | |
| 508 assert(Auth_Store_Find_Session( | |
| 509 p_store, tok_rev1, check, &fs, &fu) == AUTH_STORE_REVOKED); | |
| 510 assert(Auth_Store_Find_Session( | |
| 511 p_store, tok_rev2, check, &fs, &fu) == AUTH_STORE_REVOKED); | |
| 512 | |
| 513 puts("test_password_update_revokes_others: PASS"); | |
| 514 } | |
| 515 | |
| 516 /* ------------------------------------------------------------------ */ | |
| 517 /* 12. Revoke_All_Sessions */ | |
| 518 /* ------------------------------------------------------------------ */ | |
| 519 | |
| 520 static void test_revoke_all_sessions(Auth_Store *p_store) | |
| 521 { | |
| 522 char user_id[37]; | |
| 523 assert(Auth_Store_Create_User( | |
| 524 p_store, "RevokeAll", get_test_hash(), "member", FALSE, user_id) == | |
| 525 AUTH_STORE_OK); | |
| 526 | |
| 527 int64 now = 1700400000LL; | |
| 528 const char *t1 = "7777777777777777777777777777777777777777777777777777777777777777"; | |
| 529 const char *t2 = "8888888888888888888888888888888888888888888888888888888888888888"; | |
| 530 const char *t3 = "9999999999999999999999999999999999999999999999999999999999999999"; | |
| 531 const char *c1 = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; | |
| 532 const char *c2 = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"; | |
| 533 const char *c3 = "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"; | |
| 534 | |
| 535 Auth_Session_Record sess; | |
| 536 assert(Auth_Store_Create_Session( | |
| 537 p_store, user_id, t1, c1, 3600, 86400, now, &sess) == AUTH_STORE_OK); | |
| 538 assert(Auth_Store_Create_Session( | |
| 539 p_store, user_id, t2, c2, 3600, 86400, now, &sess) == AUTH_STORE_OK); | |
| 540 assert(Auth_Store_Create_Session( | |
| 541 p_store, user_id, t3, c3, 3600, 86400, now, &sess) == AUTH_STORE_OK); | |
| 542 | |
| 543 /* Revoke all except t2. */ | |
| 544 assert(Auth_Store_Revoke_All_Sessions(p_store, user_id, t2) == AUTH_STORE_OK); | |
| 545 | |
| 546 int64 check = now + 60; | |
| 547 Auth_Session_Record fs; | |
| 548 Auth_User_Record fu; | |
| 549 assert(Auth_Store_Find_Session(p_store, t1, check, &fs, &fu) == AUTH_STORE_REVOKED); | |
| 550 assert(Auth_Store_Find_Session(p_store, t2, check, &fs, &fu) == AUTH_STORE_OK); | |
| 551 assert(Auth_Store_Find_Session(p_store, t3, check, &fs, &fu) == AUTH_STORE_REVOKED); | |
| 552 | |
| 553 puts("test_revoke_all_sessions: PASS"); | |
| 554 } | |
| 555 | |
| 556 /* ------------------------------------------------------------------ */ | |
| 557 /* 13. Expired session */ | |
| 558 /* ------------------------------------------------------------------ */ | |
| 559 | |
| 560 static void test_expired_session(Auth_Store *p_store) | |
| 561 { | |
| 562 char user_id[37]; | |
| 563 assert(Auth_Store_Create_User( | |
| 564 p_store, "ExpiredUser", get_test_hash(), "member", FALSE, user_id) == | |
| 565 AUTH_STORE_OK); | |
| 566 | |
| 567 const char *tok = "abababababababababababababababababababababababababababababababab"; | |
| 568 const char *csrf = "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; | |
| 569 int64 now = 1700500000LL; | |
| 570 | |
| 571 Auth_Session_Record sess; | |
| 572 assert(Auth_Store_Create_Session( | |
| 573 p_store, user_id, tok, csrf, 100, 200, now, &sess) == AUTH_STORE_OK); | |
| 574 | |
| 575 Auth_Session_Record fs; | |
| 576 Auth_User_Record fu; | |
| 577 | |
| 578 assert(Auth_Store_Find_Session( | |
| 579 p_store, tok, now + 50, &fs, &fu) == AUTH_STORE_OK); | |
| 580 | |
| 581 assert(Auth_Store_Find_Session( | |
| 582 p_store, tok, now + 110, &fs, &fu) == AUTH_STORE_EXPIRED); | |
| 583 | |
| 584 assert(Auth_Store_Find_Session( | |
| 585 p_store, tok, now + 201, &fs, &fu) == AUTH_STORE_EXPIRED); | |
| 586 | |
| 587 puts("test_expired_session: PASS"); | |
| 588 } | |
| 589 | |
| 590 /* ------------------------------------------------------------------ */ | |
| 591 /* 14. Guest identity persistence and no raw IP storage */ | |
| 592 /* ------------------------------------------------------------------ */ | |
| 593 | |
| 594 static void test_guest_identity(Auth_Store *p_store, const char *db_path) | |
| 595 { | |
| 596 uint8 secret[AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES]; | |
| 597 memset(secret, 0xab, sizeof(secret)); | |
| 598 const char *raw_ip = "203.0.113.42"; /* TEST-NET-3, never stored */ | |
| 599 | |
| 600 char ip_digest[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE]; | |
| 601 assert(Auth_Crypto_IP_Binding_Digest( | |
| 602 secret, sizeof(secret), raw_ip, | |
| 603 ip_digest, sizeof(ip_digest)) == AUTH_CRYPTO_OK); | |
| 604 | |
| 605 char guest_id[37]; | |
| 606 assert(test__make_uuid(guest_id)); | |
| 607 int64 now = 1700600000LL; | |
| 608 int64 exp = now + 86400; | |
| 609 | |
| 610 Auth_Guest_Identity_Record g; | |
| 611 assert(Auth_Store_Upsert_Guest_Identity( | |
| 612 p_store, guest_id, ip_digest, exp, &g) == AUTH_STORE_OK); | |
| 613 assert(strcmp(g.id, guest_id) == 0); | |
| 614 assert(g.expires_at == exp); | |
| 615 | |
| 616 /* Find within expiry. */ | |
| 617 Auth_Guest_Identity_Record g2; | |
| 618 assert(Auth_Store_Find_Guest_Identity( | |
| 619 p_store, guest_id, now + 60, &g2) == AUTH_STORE_OK); | |
| 620 assert(strcmp(g2.id, guest_id) == 0); | |
| 621 | |
| 622 /* Find after expiry. */ | |
| 623 assert(Auth_Store_Find_Guest_Identity( | |
| 624 p_store, guest_id, exp + 1, &g2) == AUTH_STORE_EXPIRED); | |
| 625 | |
| 626 /* Upsert refreshes expiry without losing the identity row. */ | |
| 627 int64 new_exp = exp + 86400; | |
| 628 assert(Auth_Store_Upsert_Guest_Identity( | |
| 629 p_store, guest_id, ip_digest, new_exp, &g) == AUTH_STORE_OK); | |
| 630 assert(g.expires_at == new_exp); | |
| 631 assert(Auth_Store_Find_Guest_Identity( | |
| 632 p_store, guest_id, exp + 1, &g2) == AUTH_STORE_OK); | |
| 633 | |
| 634 /* Unknown guest. */ | |
| 635 assert(Auth_Store_Find_Guest_Identity( | |
| 636 p_store, "00000000-0000-4000-8000-000000000000", | |
| 637 now, &g2) == AUTH_STORE_NOT_FOUND); | |
| 638 | |
| 639 /* | |
| 640 * Verify raw IP is NOT stored in the database: open a second connection | |
| 641 * and inspect the ip_binding_digest column directly. | |
| 642 */ | |
| 643 Dowa_Arena *p_arena = Dowa_Arena_Create(4096); | |
| 644 assert(p_arena); | |
| 645 Deita_Connection *p_conn = Deita_Connection_Create( | |
| 646 DEITA_DATABASE_TYPE_SQLITE3, db_path); | |
| 647 assert(p_conn); | |
| 648 | |
| 649 const char *sel_params[] = {guest_id}; | |
| 650 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 651 p_conn, | |
| 652 "SELECT ip_binding_digest FROM guest_identities WHERE id = ?", | |
| 653 1, sel_params, p_arena); | |
| 654 assert(p_result); | |
| 655 assert(Deita_Result_Set_Next(p_result)); | |
| 656 const char *stored = Deita_Result_Set_Get_Text(p_result, 0); | |
| 657 assert(stored != NULL); | |
| 658 assert(strcmp(stored, ip_digest) == 0); /* HMAC digest is stored */ | |
| 659 assert(strstr(stored, raw_ip) == NULL); /* raw IP is NOT stored */ | |
| 660 Deita_Result_Set_Free(p_result); | |
| 661 Deita_Connection_Close(p_conn); | |
| 662 Dowa_Arena_Free(p_arena); | |
| 663 | |
| 664 puts("test_guest_identity: PASS"); | |
| 665 } | |
| 666 | |
| 667 /* ------------------------------------------------------------------ */ | |
| 668 /* 16. Rotate_Session */ | |
| 669 /* ------------------------------------------------------------------ */ | |
| 670 | |
| 671 static void test_rotate_session(Auth_Store *p_store) | |
| 672 { | |
| 673 char user_id[37]; | |
| 674 assert(Auth_Store_Create_User( | |
| 675 p_store, "RotateUser", get_test_hash(), "member", FALSE, user_id) == | |
| 676 AUTH_STORE_OK); | |
| 677 | |
| 678 int64 now = 1700400000LL; | |
| 679 const char *old_tok = "e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1"; | |
| 680 const char *old_csrf = "f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2"; | |
| 681 const char *new_tok = "a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3"; | |
| 682 const char *new_csrf = "b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4"; | |
| 683 | |
| 684 Auth_Session_Record sess; | |
| 685 assert(Auth_Store_Create_Session( | |
| 686 p_store, user_id, old_tok, old_csrf, 3600, 86400, now, &sess) == | |
| 687 AUTH_STORE_OK); | |
| 688 | |
| 689 /* Rotate: new session created, old revoked atomically */ | |
| 690 Auth_Session_Record new_sess; | |
| 691 assert(Auth_Store_Rotate_Session( | |
| 692 p_store, user_id, old_tok, new_tok, new_csrf, | |
| 693 3600, 86400, now + 10, &new_sess) == AUTH_STORE_OK); | |
| 694 | |
| 695 int64 check = now + 60; | |
| 696 Auth_Session_Record fs; | |
| 697 Auth_User_Record fu; | |
| 698 | |
| 699 /* Old session must be revoked */ | |
| 700 assert(Auth_Store_Find_Session(p_store, old_tok, check, &fs, &fu) == | |
| 701 AUTH_STORE_REVOKED); | |
| 702 | |
| 703 /* New session must be valid */ | |
| 704 assert(Auth_Store_Find_Session(p_store, new_tok, check, &fs, &fu) == | |
| 705 AUTH_STORE_OK); | |
| 706 assert(strcmp(fs.user_id, user_id) == 0); | |
| 707 | |
| 708 /* Rotate again with already-revoked old token must still succeed | |
| 709 * (idempotent revocation) */ | |
| 710 const char *new_tok2 = "c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5"; | |
| 711 const char *new_csrf2 = "d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6"; | |
| 712 Auth_Session_Record sess2; | |
| 713 assert(Auth_Store_Rotate_Session( | |
| 714 p_store, user_id, old_tok, new_tok2, new_csrf2, | |
| 715 3600, 86400, now + 20, &sess2) == AUTH_STORE_OK); | |
| 716 assert(Auth_Store_Find_Session(p_store, new_tok2, check, &fs, &fu) == | |
| 717 AUTH_STORE_OK); | |
| 718 | |
| 719 puts("test_rotate_session: PASS"); | |
| 720 } | |
| 721 | |
| 722 /* ------------------------------------------------------------------ */ | |
| 723 /* 17. Compare-and-swap session creation */ | |
| 724 /* ------------------------------------------------------------------ */ | |
| 725 | |
| 726 static void test_create_session_cas(Auth_Store *p_store) | |
| 727 { | |
| 728 char first_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; | |
| 729 char second_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; | |
| 730 char reset_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; | |
| 731 make_test_hash("cas-first-password", first_hash); | |
| 732 make_test_hash("cas-second-password", second_hash); | |
| 733 make_test_hash("cas-reset-password", reset_hash); | |
| 734 | |
| 735 char user_id[37]; | |
| 736 assert(Auth_Store_Create_User( | |
| 737 p_store, "CasSessionUser", first_hash, "member", FALSE, user_id) == | |
| 738 AUTH_STORE_OK); | |
| 739 | |
| 740 const char *token1 = | |
| 741 "1010101010101010101010101010101010101010101010101010101010101010"; | |
| 742 const char *csrf1 = | |
| 743 "2020202020202020202020202020202020202020202020202020202020202020"; | |
| 744 const char *token2 = | |
| 745 "3030303030303030303030303030303030303030303030303030303030303030"; | |
| 746 const char *csrf2 = | |
| 747 "4040404040404040404040404040404040404040404040404040404040404040"; | |
| 748 const char *token3 = | |
| 749 "5050505050505050505050505050505050505050505050505050505050505050"; | |
| 750 const char *csrf3 = | |
| 751 "6060606060606060606060606060606060606060606060606060606060606060"; | |
| 752 const char *token4 = | |
| 753 "7070707070707070707070707070707070707070707070707070707070707070"; | |
| 754 const char *csrf4 = | |
| 755 "8080808080808080808080808080808080808080808080808080808080808080"; | |
| 756 const char *disabled_token = | |
| 757 "9090909090909090909090909090909090909090909090909090909090909090"; | |
| 758 const char *disabled_csrf = | |
| 759 "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0"; | |
| 760 int64 now = 1800000000LL; | |
| 761 Auth_Session_Record session; | |
| 762 | |
| 763 assert(Auth_Store_Create_Session_CAS( | |
| 764 p_store, user_id, first_hash, token1, csrf1, | |
| 765 3600, 86400, now, &session) == AUTH_STORE_OK); | |
| 766 | |
| 767 assert(Auth_Store_Update_User_Status( | |
| 768 p_store, user_id, "disabled", NULL) == AUTH_STORE_OK); | |
| 769 assert(Auth_Store_Create_Session_CAS( | |
| 770 p_store, user_id, first_hash, disabled_token, disabled_csrf, | |
| 771 3600, 86400, now + 1, &session) == AUTH_STORE_USER_DISABLED); | |
| 772 assert(Auth_Store_Update_User_Status( | |
| 773 p_store, user_id, "active", NULL) == AUTH_STORE_OK); | |
| 774 | |
| 775 Auth_Session_Record found_session; | |
| 776 Auth_User_Record found_user; | |
| 777 assert(Auth_Store_Find_Session( | |
| 778 p_store, disabled_token, now + 2, &found_session, &found_user) == | |
| 779 AUTH_STORE_NOT_FOUND); | |
| 780 | |
| 781 assert(Auth_Store_Update_Password( | |
| 782 p_store, user_id, second_hash, FALSE, NULL) == AUTH_STORE_OK); | |
| 783 assert(Auth_Store_Create_Session_CAS( | |
| 784 p_store, user_id, first_hash, token2, csrf2, | |
| 785 3600, 86400, now + 10, &session) == AUTH_STORE_STALE_PASSWORD); | |
| 786 | |
| 787 assert(Auth_Store_Find_Session( | |
| 788 p_store, token2, now + 20, &found_session, &found_user) == | |
| 789 AUTH_STORE_NOT_FOUND); | |
| 790 | |
| 791 assert(Auth_Store_Admin_Reset_Password( | |
| 792 p_store, user_id, reset_hash, NULL) == AUTH_STORE_OK); | |
| 793 assert(Auth_Store_Create_Session_CAS( | |
| 794 p_store, user_id, second_hash, token3, csrf3, | |
| 795 3600, 86400, now + 20, &session) == AUTH_STORE_STALE_PASSWORD); | |
| 796 assert(Auth_Store_Find_Session( | |
| 797 p_store, token3, now + 30, &found_session, &found_user) == | |
| 798 AUTH_STORE_NOT_FOUND); | |
| 799 | |
| 800 assert(Auth_Store_Create_Session_CAS( | |
| 801 p_store, user_id, reset_hash, token4, csrf4, | |
| 802 3600, 86400, now + 30, &session) == AUTH_STORE_OK); | |
| 803 assert(Auth_Store_Find_Session( | |
| 804 p_store, token4, now + 40, &found_session, &found_user) == | |
| 805 AUTH_STORE_OK); | |
| 806 | |
| 807 memset(first_hash, 0, sizeof(first_hash)); | |
| 808 memset(second_hash, 0, sizeof(second_hash)); | |
| 809 memset(reset_hash, 0, sizeof(reset_hash)); | |
| 810 puts("test_create_session_cas: PASS"); | |
| 811 } | |
| 812 | |
| 813 /* ------------------------------------------------------------------ */ | |
| 814 /* 18. Atomic self-service password change */ | |
| 815 /* ------------------------------------------------------------------ */ | |
| 816 | |
| 817 static void test_self_change_password(Auth_Store *p_store) | |
| 818 { | |
| 819 char old_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; | |
| 820 char new_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; | |
| 821 char unused_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; | |
| 822 make_test_hash("self-change-old", old_hash); | |
| 823 make_test_hash("self-change-new", new_hash); | |
| 824 make_test_hash("self-change-unused", unused_hash); | |
| 825 | |
| 826 char user_id[37]; | |
| 827 assert(Auth_Store_Create_User( | |
| 828 p_store, "SelfChangeUser", old_hash, "member", TRUE, user_id) == | |
| 829 AUTH_STORE_OK); | |
| 830 | |
| 831 const char *old_token1 = | |
| 832 "1111222211112222111122221111222211112222111122221111222211112222"; | |
| 833 const char *old_csrf1 = | |
| 834 "2222333322223333222233332222333322223333222233332222333322223333"; | |
| 835 const char *old_token2 = | |
| 836 "3333444433334444333344443333444433334444333344443333444433334444"; | |
| 837 const char *old_csrf2 = | |
| 838 "4444555544445555444455554444555544445555444455554444555544445555"; | |
| 839 const char *new_token = | |
| 840 "5555666655556666555566665555666655556666555566665555666655556666"; | |
| 841 const char *new_csrf = | |
| 842 "6666777766667777666677776666777766667777666677776666777766667777"; | |
| 843 const char *failed_token = | |
| 844 "7777888877778888777788887777888877778888777788887777888877778888"; | |
| 845 const char *failed_csrf = | |
| 846 "8888999988889999888899998888999988889999888899998888999988889999"; | |
| 847 int64 now = 1810000000LL; | |
| 848 Auth_Session_Record session; | |
| 849 | |
| 850 assert(Auth_Store_Create_Session_CAS( | |
| 851 p_store, user_id, old_hash, old_token1, old_csrf1, | |
| 852 3600, 86400, now, &session) == AUTH_STORE_OK); | |
| 853 assert(Auth_Store_Create_Session_CAS( | |
| 854 p_store, user_id, old_hash, old_token2, old_csrf2, | |
| 855 3600, 86400, now, &session) == AUTH_STORE_OK); | |
| 856 | |
| 857 assert(Auth_Store_Self_Change_Password( | |
| 858 p_store, user_id, old_hash, new_hash, new_token, new_csrf, | |
| 859 3600, 86400, now + 100, &session) == AUTH_STORE_OK); | |
| 860 assert(session.password_changed_at_snapshot == now + 100); | |
| 861 | |
| 862 Auth_Session_Record found_session; | |
| 863 Auth_User_Record found_user; | |
| 864 assert(Auth_Store_Find_Session( | |
| 865 p_store, old_token1, now + 101, &found_session, &found_user) == | |
| 866 AUTH_STORE_REVOKED); | |
| 867 assert(Auth_Store_Find_Session( | |
| 868 p_store, old_token2, now + 101, &found_session, &found_user) == | |
| 869 AUTH_STORE_REVOKED); | |
| 870 assert(Auth_Store_Find_Session( | |
| 871 p_store, new_token, now + 101, &found_session, &found_user) == | |
| 872 AUTH_STORE_OK); | |
| 873 assert(found_user.must_change_password == FALSE); | |
| 874 assert(found_user.password_changed_at == now + 100); | |
| 875 | |
| 876 Auth_User_Auth_Record auth_record; | |
| 877 assert(Auth_Store_Find_User_By_Username( | |
| 878 p_store, "SelfChangeUser", &auth_record) == AUTH_STORE_OK); | |
| 879 assert(strcmp(auth_record.password_hash, new_hash) == 0); | |
| 880 memset(&auth_record, 0, sizeof(auth_record)); | |
| 881 | |
| 882 assert(Auth_Store_Self_Change_Password( | |
| 883 p_store, user_id, old_hash, unused_hash, failed_token, failed_csrf, | |
| 884 3600, 86400, now + 200, &session) == AUTH_STORE_STALE_PASSWORD); | |
| 885 assert(Auth_Store_Find_Session( | |
| 886 p_store, failed_token, now + 201, &found_session, &found_user) == | |
| 887 AUTH_STORE_NOT_FOUND); | |
| 888 assert(Auth_Store_Find_Session( | |
| 889 p_store, new_token, now + 201, &found_session, &found_user) == | |
| 890 AUTH_STORE_OK); | |
| 891 assert(Auth_Store_Find_User_By_Username( | |
| 892 p_store, "SelfChangeUser", &auth_record) == AUTH_STORE_OK); | |
| 893 assert(strcmp(auth_record.password_hash, new_hash) == 0); | |
| 894 | |
| 895 memset(&auth_record, 0, sizeof(auth_record)); | |
| 896 memset(old_hash, 0, sizeof(old_hash)); | |
| 897 memset(new_hash, 0, sizeof(new_hash)); | |
| 898 memset(unused_hash, 0, sizeof(unused_hash)); | |
| 899 puts("test_self_change_password: PASS"); | |
| 900 } | |
| 901 | |
| 902 /* ------------------------------------------------------------------ */ | |
| 903 /* 19. Audited and session-revoking admin operations */ | |
| 904 /* ------------------------------------------------------------------ */ | |
| 905 | |
| 906 static void test_audited_admin_operations( | |
| 907 Auth_Store *p_store, | |
| 908 const char *db_path) | |
| 909 { | |
| 910 char user_id[37]; | |
| 911 const char *actor_id = "00000000-0000-4000-8000-000000000019"; | |
| 912 assert(Auth_Store_Create_User_Audited( | |
| 913 p_store, "AuditedUser", get_test_hash(), "member", FALSE, | |
| 914 actor_id, user_id) == AUTH_STORE_OK); | |
| 915 | |
| 916 Dowa_Arena *p_arena = Dowa_Arena_Create(2048); | |
| 917 assert(p_arena); | |
| 918 Deita_Connection *p_conn = Deita_Connection_Create( | |
| 919 DEITA_DATABASE_TYPE_SQLITE3, db_path); | |
| 920 assert(p_conn); | |
| 921 const char *audit_params[] = {user_id}; | |
| 922 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 923 p_conn, | |
| 924 "SELECT actor_user_id, action, detail FROM admin_audit_log" | |
| 925 " WHERE target_user_id = ? ORDER BY id DESC LIMIT 1", | |
| 926 1, audit_params, p_arena); | |
| 927 assert(p_result); | |
| 928 assert(Deita_Result_Set_Next(p_result)); | |
| 929 assert(strcmp(Deita_Result_Set_Get_Text(p_result, 0), actor_id) == 0); | |
| 930 assert(strcmp(Deita_Result_Set_Get_Text(p_result, 1), | |
| 931 "admin_user_created") == 0); | |
| 932 assert(strcmp(Deita_Result_Set_Get_Text(p_result, 2), "member") == 0); | |
| 933 assert(strstr(Deita_Result_Set_Get_Text(p_result, 2), "zenbu-scrypt") == NULL); | |
| 934 Deita_Result_Set_Free(p_result); | |
| 935 Deita_Connection_Close(p_conn); | |
| 936 Dowa_Arena_Free(p_arena); | |
| 937 | |
| 938 const char *token1 = | |
| 939 "9191919191919191919191919191919191919191919191919191919191919191"; | |
| 940 const char *csrf1 = | |
| 941 "9292929292929292929292929292929292929292929292929292929292929292"; | |
| 942 const char *token2 = | |
| 943 "9393939393939393939393939393939393939393939393939393939393939393"; | |
| 944 const char *csrf2 = | |
| 945 "9494949494949494949494949494949494949494949494949494949494949494"; | |
| 946 const char *token3 = | |
| 947 "9595959595959595959595959595959595959595959595959595959595959595"; | |
| 948 const char *csrf3 = | |
| 949 "9696969696969696969696969696969696969696969696969696969696969696"; | |
| 950 int64 now = 1820000000LL; | |
| 951 Auth_Session_Record session; | |
| 952 Auth_Session_Record found_session; | |
| 953 Auth_User_Record found_user; | |
| 954 | |
| 955 assert(Auth_Store_Create_Session( | |
| 956 p_store, user_id, token1, csrf1, 3600, 86400, now, &session) == | |
| 957 AUTH_STORE_OK); | |
| 958 assert(Auth_Store_Enable_User(p_store, user_id, actor_id) == AUTH_STORE_OK); | |
| 959 assert(Auth_Store_Find_Session( | |
| 960 p_store, token1, now + 1, &found_session, &found_user) == AUTH_STORE_OK); | |
| 961 | |
| 962 assert(Auth_Store_Disable_User_And_Revoke_Sessions( | |
| 963 p_store, user_id, actor_id) == AUTH_STORE_OK); | |
| 964 assert(Auth_Store_Find_Session( | |
| 965 p_store, token1, now + 2, &found_session, &found_user) == | |
| 966 AUTH_STORE_REVOKED); | |
| 967 assert(Auth_Store_Enable_User(p_store, user_id, actor_id) == AUTH_STORE_OK); | |
| 968 assert(Auth_Store_Find_Session( | |
| 969 p_store, token1, now + 3, &found_session, &found_user) == | |
| 970 AUTH_STORE_REVOKED); | |
| 971 | |
| 972 assert(Auth_Store_Create_Session( | |
| 973 p_store, user_id, token2, csrf2, 3600, 86400, now + 4, &session) == | |
| 974 AUTH_STORE_OK); | |
| 975 assert(Auth_Store_Update_Role_And_Revoke_Sessions( | |
| 976 p_store, user_id, "admin", actor_id) == AUTH_STORE_OK); | |
| 977 assert(Auth_Store_Find_Session( | |
| 978 p_store, token2, now + 5, &found_session, &found_user) == | |
| 979 AUTH_STORE_REVOKED); | |
| 980 assert(Auth_Store_Get_User(p_store, user_id, &found_user) == AUTH_STORE_OK); | |
| 981 assert(strcmp(found_user.role, "admin") == 0); | |
| 982 | |
| 983 assert(Auth_Store_Create_Session( | |
| 984 p_store, user_id, token3, csrf3, 3600, 86400, now + 6, &session) == | |
| 985 AUTH_STORE_OK); | |
| 986 assert(Auth_Store_Revoke_All_Sessions_Audited( | |
| 987 p_store, user_id, NULL, actor_id) == AUTH_STORE_OK); | |
| 988 assert(Auth_Store_Find_Session( | |
| 989 p_store, token3, now + 7, &found_session, &found_user) == | |
| 990 AUTH_STORE_REVOKED); | |
| 991 assert(Auth_Store_Enable_User( | |
| 992 p_store, "00000000-0000-4000-8000-000000000000", actor_id) == | |
| 993 AUTH_STORE_NOT_FOUND); | |
| 994 | |
| 995 puts("test_audited_admin_operations: PASS"); | |
| 996 } | |
| 997 | |
| 998 /* ------------------------------------------------------------------ */ | |
| 999 /* 20. Audit failures roll back transactional mutations */ | |
| 1000 /* ------------------------------------------------------------------ */ | |
| 1001 | |
| 1002 static void test_audit_failure_rollback( | |
| 1003 Auth_Store *p_store, | |
| 1004 const char *db_path) | |
| 1005 { | |
| 1006 char reset_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; | |
| 1007 make_test_hash("audit-reset-password", reset_hash); | |
| 1008 | |
| 1009 char user_id[37]; | |
| 1010 assert(Auth_Store_Create_User( | |
| 1011 p_store, "AuditRollbackUser", get_test_hash(), "member", FALSE, | |
| 1012 user_id) == AUTH_STORE_OK); | |
| 1013 | |
| 1014 const char *token = | |
| 1015 "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"; | |
| 1016 const char *csrf = | |
| 1017 "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; | |
| 1018 int64 now = 1830000000LL; | |
| 1019 Auth_Session_Record session; | |
| 1020 assert(Auth_Store_Create_Session( | |
| 1021 p_store, user_id, token, csrf, 3600, 86400, now, &session) == | |
| 1022 AUTH_STORE_OK); | |
| 1023 | |
| 1024 Deita_Connection *p_conn = Deita_Connection_Create( | |
| 1025 DEITA_DATABASE_TYPE_SQLITE3, db_path); | |
| 1026 assert(p_conn); | |
| 1027 assert(Deita_Query_Execute_Update( | |
| 1028 p_conn, "UPDATE users SET role = 'member' WHERE role = 'admin'") >= 0); | |
| 1029 assert(Deita_Query_Execute_Update( | |
| 1030 p_conn, | |
| 1031 "CREATE TRIGGER fail_auth_audit BEFORE INSERT ON admin_audit_log" | |
| 1032 " BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END") >= 0); | |
| 1033 Deita_Connection_Close(p_conn); | |
| 1034 | |
| 1035 assert(Auth_Store_Set_Must_Change_Password( | |
| 1036 p_store, user_id, TRUE, NULL) == AUTH_STORE_ERROR); | |
| 1037 Auth_User_Record user; | |
| 1038 assert(Auth_Store_Get_User(p_store, user_id, &user) == AUTH_STORE_OK); | |
| 1039 assert(user.must_change_password == FALSE); | |
| 1040 | |
| 1041 Auth_Session_Record found_session; | |
| 1042 Auth_User_Record found_user; | |
| 1043 assert(Auth_Store_Update_User_Status( | |
| 1044 p_store, user_id, "disabled", NULL) == AUTH_STORE_ERROR); | |
| 1045 assert(Auth_Store_Get_User(p_store, user_id, &user) == AUTH_STORE_OK); | |
| 1046 assert(strcmp(user.status, "active") == 0); | |
| 1047 assert(Auth_Store_Find_Session( | |
| 1048 p_store, token, now + 1, &found_session, &found_user) == AUTH_STORE_OK); | |
| 1049 | |
| 1050 assert(Auth_Store_Update_User_Role( | |
| 1051 p_store, user_id, "admin", NULL) == AUTH_STORE_ERROR); | |
| 1052 assert(Auth_Store_Get_User(p_store, user_id, &user) == AUTH_STORE_OK); | |
| 1053 assert(strcmp(user.role, "member") == 0); | |
| 1054 | |
| 1055 assert(Auth_Store_Disable_User_And_Revoke_Sessions( | |
| 1056 p_store, user_id, NULL) == AUTH_STORE_ERROR); | |
| 1057 assert(Auth_Store_Update_Role_And_Revoke_Sessions( | |
| 1058 p_store, user_id, "admin", NULL) == AUTH_STORE_ERROR); | |
| 1059 assert(Auth_Store_Revoke_All_Sessions_Audited( | |
| 1060 p_store, user_id, NULL, NULL) == AUTH_STORE_ERROR); | |
| 1061 assert(Auth_Store_Find_Session( | |
| 1062 p_store, token, now + 2, &found_session, &found_user) == AUTH_STORE_OK); | |
| 1063 | |
| 1064 assert(Auth_Store_Admin_Reset_Password( | |
| 1065 p_store, user_id, reset_hash, NULL) == AUTH_STORE_ERROR); | |
| 1066 Auth_User_Auth_Record auth_record; | |
| 1067 assert(Auth_Store_Find_User_By_Username( | |
| 1068 p_store, "AuditRollbackUser", &auth_record) == AUTH_STORE_OK); | |
| 1069 assert(strcmp(auth_record.password_hash, get_test_hash()) == 0); | |
| 1070 assert(auth_record.user.must_change_password == FALSE); | |
| 1071 memset(&auth_record, 0, sizeof(auth_record)); | |
| 1072 assert(Auth_Store_Find_Session( | |
| 1073 p_store, token, now + 3, &found_session, &found_user) == AUTH_STORE_OK); | |
| 1074 | |
| 1075 Auth_Store_Bootstrap_Result bootstrap_result; | |
| 1076 char bootstrap_id[37]; | |
| 1077 assert(Auth_Store_Bootstrap_Admin( | |
| 1078 p_store, "AuditBootstrapRollback", get_test_hash(), | |
| 1079 &bootstrap_result, bootstrap_id) == AUTH_STORE_ERROR); | |
| 1080 assert(Auth_Store_Find_User_By_Username( | |
| 1081 p_store, "AuditBootstrapRollback", &auth_record) == AUTH_STORE_NOT_FOUND); | |
| 1082 | |
| 1083 assert(Auth_Store_Insert_Audit_Log( | |
| 1084 p_store, NULL, "forced_failure", user_id, NULL) == AUTH_STORE_ERROR); | |
| 1085 | |
| 1086 char failed_id[37]; | |
| 1087 assert(Auth_Store_Create_User_Audited( | |
| 1088 p_store, "AuditCreateRollback", get_test_hash(), "member", FALSE, | |
| 1089 NULL, failed_id) == AUTH_STORE_ERROR); | |
| 1090 assert(Auth_Store_Find_User_By_Username( | |
| 1091 p_store, "AuditCreateRollback", &auth_record) == AUTH_STORE_NOT_FOUND); | |
| 1092 | |
| 1093 p_conn = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db_path); | |
| 1094 assert(p_conn); | |
| 1095 assert(Deita_Query_Execute_Update( | |
| 1096 p_conn, "DROP TRIGGER fail_auth_audit") >= 0); | |
| 1097 Deita_Connection_Close(p_conn); | |
| 1098 | |
| 1099 memset(reset_hash, 0, sizeof(reset_hash)); | |
| 1100 puts("test_audit_failure_rollback: PASS"); | |
| 1101 } | |
| 1102 | |
| 1103 | |
| 1104 /* ------------------------------------------------------------------ */ | |
| 1105 /* Guest quota tests */ | |
| 1106 /* ------------------------------------------------------------------ */ | |
| 1107 | |
| 1108 /* Create a guest identity row (required as FK parent). */ | |
| 1109 static void make_guest(Auth_Store *p_store, const char *guest_id) | |
| 1110 { | |
| 1111 uint8 secret[AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES]; | |
| 1112 memset(secret, 0xab, sizeof(secret)); | |
| 1113 char ip_digest[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE]; | |
| 1114 assert(Auth_Crypto_IP_Binding_Digest( | |
| 1115 secret, sizeof(secret), "10.0.0.1", | |
| 1116 ip_digest, sizeof(ip_digest)) == AUTH_CRYPTO_OK); | |
| 1117 Auth_Guest_Identity_Record g; | |
| 1118 assert(Auth_Store_Upsert_Guest_Identity( | |
| 1119 p_store, guest_id, ip_digest, | |
| 1120 2000000000LL, &g) == AUTH_STORE_OK); | |
| 1121 } | |
| 1122 | |
| 1123 static void test_guest_quota(Auth_Store *p_store) | |
| 1124 { | |
| 1125 char guest_id[37]; | |
| 1126 assert(test__make_uuid(guest_id)); | |
| 1127 make_guest(p_store, guest_id); | |
| 1128 | |
| 1129 int64 window_start = 1700524800LL; /* UTC midnight 2023-11-21, used as window ID */ | |
| 1130 int64 expires = (int64)time(NULL) + 7200LL; /* 2 h from now — always future */ | |
| 1131 const int64 turns_limit = 3; | |
| 1132 const int64 tokens_limit = 1000; | |
| 1133 const int64 req_tokens = 200; | |
| 1134 | |
| 1135 /* 1. Initial usage is zero. */ | |
| 1136 { | |
| 1137 Auth_Store_Guest_Usage u; | |
| 1138 assert(Auth_Store_Guest_Get_Usage( | |
| 1139 p_store, guest_id, window_start, &u) == AUTH_STORE_OK); | |
| 1140 assert(u.turns_used == 0); | |
| 1141 assert(u.output_tokens_used == 0); | |
| 1142 assert(u.output_tokens_reserved == 0); | |
| 1143 } | |
| 1144 puts(" guest_quota/initial_usage: PASS"); | |
| 1145 | |
| 1146 /* 2. Reserve 3 turns; fourth must be TURNS_EXHAUSTED. */ | |
| 1147 { | |
| 1148 char req1[37], req2[37], req3[37]; | |
| 1149 assert(test__make_uuid(req1)); | |
| 1150 assert(test__make_uuid(req2)); | |
| 1151 assert(test__make_uuid(req3)); | |
| 1152 assert(Auth_Store_Guest_Reserve( | |
| 1153 p_store, guest_id, req1, window_start, | |
| 1154 req_tokens, turns_limit, tokens_limit, | |
| 1155 expires) == AUTH_STORE_GUEST_QUOTA_OK); | |
| 1156 assert(Auth_Store_Guest_Reserve( | |
| 1157 p_store, guest_id, req2, window_start, | |
| 1158 req_tokens, turns_limit, tokens_limit, | |
| 1159 expires) == AUTH_STORE_GUEST_QUOTA_OK); | |
| 1160 assert(Auth_Store_Guest_Reserve( | |
| 1161 p_store, guest_id, req3, window_start, | |
| 1162 req_tokens, turns_limit, tokens_limit, | |
| 1163 expires) == AUTH_STORE_GUEST_QUOTA_OK); | |
| 1164 | |
| 1165 /* Check state: 3 turns used, 600 tokens reserved. */ | |
| 1166 Auth_Store_Guest_Usage u; | |
| 1167 assert(Auth_Store_Guest_Get_Usage( | |
| 1168 p_store, guest_id, window_start, &u) == AUTH_STORE_OK); | |
| 1169 assert(u.turns_used == 3); | |
| 1170 assert(u.output_tokens_used == 0); | |
| 1171 assert(u.output_tokens_reserved == req_tokens * 3); | |
| 1172 | |
| 1173 /* Fourth reservation: turns exhausted. */ | |
| 1174 char req4[37]; | |
| 1175 assert(test__make_uuid(req4)); | |
| 1176 assert(Auth_Store_Guest_Reserve( | |
| 1177 p_store, guest_id, req4, window_start, | |
| 1178 req_tokens, turns_limit, tokens_limit, | |
| 1179 expires) == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED); | |
| 1180 puts(" guest_quota/turn_exhaustion: PASS"); | |
| 1181 | |
| 1182 /* Release all 3 reservations (keeps turn charge). */ | |
| 1183 assert(Auth_Store_Guest_Release(p_store, req1) == AUTH_STORE_OK); | |
| 1184 assert(Auth_Store_Guest_Release(p_store, req2) == AUTH_STORE_OK); | |
| 1185 assert(Auth_Store_Guest_Release(p_store, req3) == AUTH_STORE_OK); | |
| 1186 | |
| 1187 /* After release: turns still charged, tokens freed. */ | |
| 1188 assert(Auth_Store_Guest_Get_Usage( | |
| 1189 p_store, guest_id, window_start, &u) == AUTH_STORE_OK); | |
| 1190 assert(u.turns_used == 3); | |
| 1191 assert(u.output_tokens_reserved == 0); | |
| 1192 puts(" guest_quota/release_retains_turns: PASS"); | |
| 1193 } | |
| 1194 | |
| 1195 /* 3. Token exhaustion on a fresh window. */ | |
| 1196 { | |
| 1197 char guest2[37]; | |
| 1198 assert(test__make_uuid(guest2)); | |
| 1199 make_guest(p_store, guest2); | |
| 1200 int64 win2 = window_start + 86400LL; | |
| 1201 const int64 small_limit = 250; | |
| 1202 | |
| 1203 char r1[37], r2[37]; | |
| 1204 assert(test__make_uuid(r1)); | |
| 1205 assert(test__make_uuid(r2)); | |
| 1206 | |
| 1207 /* Reserve 200; only 250 total → second 200 would overflow. */ | |
| 1208 assert(Auth_Store_Guest_Reserve( | |
| 1209 p_store, guest2, r1, win2, | |
| 1210 200, 10, small_limit, expires) == AUTH_STORE_GUEST_QUOTA_OK); | |
| 1211 assert(Auth_Store_Guest_Reserve( | |
| 1212 p_store, guest2, r2, win2, | |
| 1213 200, 10, small_limit, expires) == | |
| 1214 AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED); | |
| 1215 puts(" guest_quota/token_exhaustion: PASS"); | |
| 1216 | |
| 1217 /* Reconcile r1 with 150 actual (< 200 reserved). */ | |
| 1218 assert(Auth_Store_Guest_Reconcile(p_store, r1, 150) == AUTH_STORE_OK); | |
| 1219 Auth_Store_Guest_Usage u; | |
| 1220 assert(Auth_Store_Guest_Get_Usage( | |
| 1221 p_store, guest2, win2, &u) == AUTH_STORE_OK); | |
| 1222 assert(u.output_tokens_used == 150); | |
| 1223 assert(u.output_tokens_reserved == 0); | |
| 1224 puts(" guest_quota/reconcile_actual_lt_reserved: PASS"); | |
| 1225 | |
| 1226 /* Reconcile with actual > reserved and charge the provider's actual use. */ | |
| 1227 char r3[37]; | |
| 1228 assert(test__make_uuid(r3)); | |
| 1229 assert(Auth_Store_Guest_Reserve( | |
| 1230 p_store, guest2, r3, win2, | |
| 1231 50, 10, small_limit, expires) == AUTH_STORE_GUEST_QUOTA_OK); | |
| 1232 assert(Auth_Store_Guest_Reconcile(p_store, r3, 9999) == AUTH_STORE_OK); | |
| 1233 assert(Auth_Store_Guest_Get_Usage( | |
| 1234 p_store, guest2, win2, &u) == AUTH_STORE_OK); | |
| 1235 assert(u.output_tokens_used == 150 + 9999); | |
| 1236 puts(" guest_quota/reconcile_oversize_actual: PASS"); | |
| 1237 } | |
| 1238 | |
| 1239 /* 4. Idempotent reconcile/release. */ | |
| 1240 { | |
| 1241 char guest3[37]; | |
| 1242 assert(test__make_uuid(guest3)); | |
| 1243 make_guest(p_store, guest3); | |
| 1244 int64 win3 = window_start + 2 * 86400LL; | |
| 1245 char rid[37]; | |
| 1246 assert(test__make_uuid(rid)); | |
| 1247 assert(Auth_Store_Guest_Reserve( | |
| 1248 p_store, guest3, rid, win3, | |
| 1249 100, 5, 500, expires) == AUTH_STORE_GUEST_QUOTA_OK); | |
| 1250 assert(Auth_Store_Guest_Reconcile(p_store, rid, 80) == AUTH_STORE_OK); | |
| 1251 /* Second reconcile on same request_id: idempotent OK. */ | |
| 1252 assert(Auth_Store_Guest_Reconcile(p_store, rid, 80) == AUTH_STORE_OK); | |
| 1253 /* Release after reconcile: idempotent OK. */ | |
| 1254 assert(Auth_Store_Guest_Release(p_store, rid) == AUTH_STORE_OK); | |
| 1255 puts(" guest_quota/idempotent_reconcile_release: PASS"); | |
| 1256 } | |
| 1257 | |
| 1258 /* 5. Concurrent reservation invariant: two reservations, check totals. */ | |
| 1259 { | |
| 1260 char guest4[37]; | |
| 1261 assert(test__make_uuid(guest4)); | |
| 1262 make_guest(p_store, guest4); | |
| 1263 int64 win4 = window_start + 3 * 86400LL; | |
| 1264 char ra[37], rb[37]; | |
| 1265 assert(test__make_uuid(ra)); | |
| 1266 assert(test__make_uuid(rb)); | |
| 1267 assert(Auth_Store_Guest_Reserve( | |
| 1268 p_store, guest4, ra, win4, 300, 5, 500, expires) == | |
| 1269 AUTH_STORE_GUEST_QUOTA_OK); | |
| 1270 assert(Auth_Store_Guest_Reserve( | |
| 1271 p_store, guest4, rb, win4, 300, 5, 500, expires) == | |
| 1272 AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED); | |
| 1273 /* 300 used, 300 reserved → 600 total, limit 500 → second fails. */ | |
| 1274 Auth_Store_Guest_Usage u; | |
| 1275 assert(Auth_Store_Guest_Get_Usage( | |
| 1276 p_store, guest4, win4, &u) == AUTH_STORE_OK); | |
| 1277 /* invariant: used + reserved <= limit */ | |
| 1278 assert(u.output_tokens_used + u.output_tokens_reserved <= 500); | |
| 1279 puts(" guest_quota/concurrent_invariant: PASS"); | |
| 1280 assert(Auth_Store_Guest_Release(p_store, ra) == AUTH_STORE_OK); | |
| 1281 } | |
| 1282 | |
| 1283 /* 6. Clear all reservations (login transfer). */ | |
| 1284 { | |
| 1285 char guest5[37]; | |
| 1286 assert(test__make_uuid(guest5)); | |
| 1287 make_guest(p_store, guest5); | |
| 1288 int64 win5 = window_start + 4 * 86400LL; | |
| 1289 char rc[37], rd[37]; | |
| 1290 assert(test__make_uuid(rc)); | |
| 1291 assert(test__make_uuid(rd)); | |
| 1292 assert(Auth_Store_Guest_Reserve( | |
| 1293 p_store, guest5, rc, win5, 100, 5, 500, expires) == | |
| 1294 AUTH_STORE_GUEST_QUOTA_OK); | |
| 1295 assert(Auth_Store_Guest_Reserve( | |
| 1296 p_store, guest5, rd, win5, 100, 5, 500, expires) == | |
| 1297 AUTH_STORE_GUEST_QUOTA_OK); | |
| 1298 assert(Auth_Store_Guest_Clear_Reservations( | |
| 1299 p_store, guest5) == AUTH_STORE_OK); | |
| 1300 Auth_Store_Guest_Usage u; | |
| 1301 assert(Auth_Store_Guest_Get_Usage( | |
| 1302 p_store, guest5, win5, &u) == AUTH_STORE_OK); | |
| 1303 assert(u.output_tokens_reserved == 0); | |
| 1304 assert(u.turns_used == 2); /* turns kept */ | |
| 1305 puts(" guest_quota/clear_reservations: PASS"); | |
| 1306 } | |
| 1307 | |
| 1308 /* 7. UTC rollover: windows are independent. */ | |
| 1309 { | |
| 1310 char guest6[37]; | |
| 1311 assert(test__make_uuid(guest6)); | |
| 1312 make_guest(p_store, guest6); | |
| 1313 int64 winA = window_start + 5 * 86400LL; | |
| 1314 int64 winB = winA + 86400LL; | |
| 1315 char re[37], rf[37]; | |
| 1316 assert(test__make_uuid(re)); | |
| 1317 assert(test__make_uuid(rf)); | |
| 1318 assert(Auth_Store_Guest_Reserve( | |
| 1319 p_store, guest6, re, winA, 100, 2, 200, expires) == | |
| 1320 AUTH_STORE_GUEST_QUOTA_OK); | |
| 1321 assert(Auth_Store_Guest_Reserve( | |
| 1322 p_store, guest6, rf, winA, 100, 2, 200, expires) == | |
| 1323 AUTH_STORE_GUEST_QUOTA_OK); | |
| 1324 /* winA full; winB is a fresh window. */ | |
| 1325 char rg[37]; | |
| 1326 assert(test__make_uuid(rg)); | |
| 1327 assert(Auth_Store_Guest_Reserve( | |
| 1328 p_store, guest6, rg, winB, 100, 2, 200, expires) == | |
| 1329 AUTH_STORE_GUEST_QUOTA_OK); | |
| 1330 Auth_Store_Guest_Usage uB; | |
| 1331 assert(Auth_Store_Guest_Get_Usage( | |
| 1332 p_store, guest6, winB, &uB) == AUTH_STORE_OK); | |
| 1333 assert(uB.turns_used == 1); | |
| 1334 puts(" guest_quota/utc_rollover: PASS"); | |
| 1335 assert(Auth_Store_Guest_Release(p_store, re) == AUTH_STORE_OK); | |
| 1336 assert(Auth_Store_Guest_Release(p_store, rf) == AUTH_STORE_OK); | |
| 1337 assert(Auth_Store_Guest_Release(p_store, rg) == AUTH_STORE_OK); | |
| 1338 } | |
| 1339 | |
| 1340 puts("test_guest_quota: PASS"); | |
| 1341 }/* ------------------------------------------------------------------ */ | |
| 1342 /* 19. Migration v2 preserves legacy guest_usage.count in turns_used */ | |
| 1343 /* ------------------------------------------------------------------ */ | |
| 1344 | |
| 1345 /* | |
| 1346 * Build a genuine v1-only database using a raw Deita connection (no | |
| 1347 * Auth_Store_Create) so that the v2 migration has not yet run. Insert | |
| 1348 * a legacy guest_usage row with count=7 and no turns_used column, then | |
| 1349 * open the database through Auth_Store_Create — which applies v2 — | |
| 1350 * and verify that turns_used is backfilled from count. | |
| 1351 */ | |
| 1352 static void test_migration_v2_legacy_backfill(const char *db_path) | |
| 1353 { | |
| 1354 (void)db_path; /* we use our own temp file */ | |
| 1355 | |
| 1356 char legacy_db[] = "/tmp/zenbu-auth-legacy-XXXXXX"; | |
| 1357 int fd = mkstemp(legacy_db); | |
| 1358 assert(fd >= 0); | |
| 1359 close(fd); | |
| 1360 | |
| 1361 /* Build the v1-only schema directly. */ | |
| 1362 Deita_Connection *p_conn = Deita_Connection_Create( | |
| 1363 DEITA_DATABASE_TYPE_SQLITE3, legacy_db); | |
| 1364 assert(p_conn); | |
| 1365 Deita_Query_Execute_Update(p_conn, | |
| 1366 "PRAGMA foreign_keys = OFF;" | |
| 1367 "PRAGMA journal_mode = WAL;"); | |
| 1368 | |
| 1369 /* auth_schema_migrations ledger */ | |
| 1370 Deita_Query_Execute_Update(p_conn, | |
| 1371 "CREATE TABLE IF NOT EXISTS auth_schema_migrations (" | |
| 1372 " version INTEGER PRIMARY KEY," | |
| 1373 " applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" | |
| 1374 ");"); | |
| 1375 | |
| 1376 /* v1 tables (condensed: only what the migration test needs) */ | |
| 1377 Deita_Query_Execute_Update(p_conn, | |
| 1378 "CREATE TABLE IF NOT EXISTS guest_identities (" | |
| 1379 " id TEXT PRIMARY KEY," | |
| 1380 " ip_binding_digest TEXT NOT NULL," | |
| 1381 " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," | |
| 1382 " last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," | |
| 1383 " expires_at INTEGER NOT NULL" | |
| 1384 ");"); | |
| 1385 Deita_Query_Execute_Update(p_conn, | |
| 1386 /* v1 guest_usage: count only, no turns_used/token columns */ | |
| 1387 "CREATE TABLE IF NOT EXISTS guest_usage (" | |
| 1388 " guest_id TEXT NOT NULL" | |
| 1389 " REFERENCES guest_identities(id) ON DELETE CASCADE," | |
| 1390 " window_start INTEGER NOT NULL," | |
| 1391 " count INTEGER NOT NULL DEFAULT 0," | |
| 1392 " PRIMARY KEY (guest_id, window_start)" | |
| 1393 ");"); | |
| 1394 Deita_Query_Execute_Update(p_conn, | |
| 1395 /* v1 reservations: no request_id, no token count */ | |
| 1396 "CREATE TABLE IF NOT EXISTS guest_usage_reservations (" | |
| 1397 " id INTEGER PRIMARY KEY AUTOINCREMENT," | |
| 1398 " guest_id TEXT NOT NULL" | |
| 1399 " REFERENCES guest_identities(id) ON DELETE CASCADE," | |
| 1400 " reserved_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," | |
| 1401 " expires_at INTEGER NOT NULL" | |
| 1402 ");"); | |
| 1403 /* Stub other tables so FK checks don't break */ | |
| 1404 Deita_Query_Execute_Update(p_conn, | |
| 1405 "CREATE TABLE IF NOT EXISTS users (" | |
| 1406 " id TEXT PRIMARY KEY," | |
| 1407 " username TEXT NOT NULL," | |
| 1408 " normalized_username TEXT NOT NULL UNIQUE," | |
| 1409 " password_hash TEXT NOT NULL," | |
| 1410 " role TEXT NOT NULL," | |
| 1411 " status TEXT NOT NULL DEFAULT 'active'," | |
| 1412 " must_change_password INTEGER NOT NULL DEFAULT 0," | |
| 1413 " password_changed_at INTEGER NOT NULL DEFAULT 0," | |
| 1414 " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," | |
| 1415 " updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" | |
| 1416 ");"); | |
| 1417 Deita_Query_Execute_Update(p_conn, | |
| 1418 "CREATE TABLE IF NOT EXISTS auth_sessions (" | |
| 1419 " token_digest TEXT PRIMARY KEY," | |
| 1420 " csrf_digest TEXT NOT NULL," | |
| 1421 " user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE," | |
| 1422 " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," | |
| 1423 " last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," | |
| 1424 " idle_expires_at INTEGER NOT NULL," | |
| 1425 " absolute_expires_at INTEGER NOT NULL," | |
| 1426 " password_changed_at_snapshot INTEGER NOT NULL DEFAULT 0," | |
| 1427 " revoked_at INTEGER" | |
| 1428 ");"); | |
| 1429 Deita_Query_Execute_Update(p_conn, | |
| 1430 "CREATE TABLE IF NOT EXISTS admin_audit_log (" | |
| 1431 " id INTEGER PRIMARY KEY AUTOINCREMENT," | |
| 1432 " actor_user_id TEXT," | |
| 1433 " action TEXT NOT NULL," | |
| 1434 " target_user_id TEXT," | |
| 1435 " detail TEXT," | |
| 1436 " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" | |
| 1437 ");"); | |
| 1438 | |
| 1439 /* Mark v1 as applied; v2 is intentionally absent. */ | |
| 1440 Deita_Query_Execute_Update(p_conn, | |
| 1441 "INSERT OR IGNORE INTO auth_schema_migrations (version) VALUES (1);"); | |
| 1442 | |
| 1443 /* Insert a guest identity and a legacy usage row with count=7. */ | |
| 1444 char g_id[37]; | |
| 1445 assert(test__make_uuid(g_id)); | |
| 1446 const char *gid_p[] = {g_id}; | |
| 1447 Deita_Query_Execute_Update_Prepared(p_conn, | |
| 1448 "INSERT INTO guest_identities (id, ip_binding_digest, expires_at)" | |
| 1449 " VALUES (?, 'test-digest', 9999999999)", | |
| 1450 1, gid_p); | |
| 1451 int64 win = 1700524800LL; | |
| 1452 char win_str[32]; | |
| 1453 snprintf(win_str, sizeof(win_str), "%lld", (long long)win); | |
| 1454 const char *gu_p[] = {g_id, win_str}; | |
| 1455 /* count = 7 in the legacy column */ | |
| 1456 Deita_Query_Execute_Update_Prepared(p_conn, | |
| 1457 "INSERT INTO guest_usage (guest_id, window_start, count) VALUES (?, ?, 7)", | |
| 1458 2, gu_p); | |
| 1459 Deita_Connection_Close(p_conn); | |
| 1460 | |
| 1461 /* Open through Auth_Store_Create: should apply v2 (backfill turns_used). */ | |
| 1462 Auth_Store *p_store = Auth_Store_Create(legacy_db); | |
| 1463 assert(p_store); | |
| 1464 | |
| 1465 Auth_Store_Guest_Usage u; | |
| 1466 assert(Auth_Store_Guest_Get_Usage( | |
| 1467 p_store, g_id, win, &u) == AUTH_STORE_OK); | |
| 1468 assert(u.turns_used == 7); /* backfilled from count */ | |
| 1469 puts("test_migration_v2_legacy_backfill: PASS"); | |
| 1470 | |
| 1471 Auth_Store_Destroy(p_store); | |
| 1472 unlink(legacy_db); | |
| 1473 } | |
| 1474 | |
| 1475 /* ------------------------------------------------------------------ */ | |
| 1476 /* 20. Expired reservation reaping */ | |
| 1477 /* ------------------------------------------------------------------ */ | |
| 1478 | |
| 1479 static void test_reap_expired_reservations(Auth_Store *p_store) | |
| 1480 { | |
| 1481 char gid[37]; | |
| 1482 assert(test__make_uuid(gid)); | |
| 1483 make_guest(p_store, gid); | |
| 1484 | |
| 1485 int64 now = (int64)time(NULL); | |
| 1486 /* Use a window far in the future so it can never collide with clock-based | |
| 1487 * reaping inside Auth_Store_Guest_Reserve. */ | |
| 1488 int64 win = now + 86400LL; /* tomorrow's window */ | |
| 1489 /* "past" and "future" are relative to now_sim (= now + 3600), but both | |
| 1490 * must be > now so the internal Reserve reap does not touch them. */ | |
| 1491 int64 past = now + 1800LL; /* expires in 30 min: past from now_sim */ | |
| 1492 int64 future = now + 7200LL; /* expires in 2 h: future from now_sim */ | |
| 1493 int64 now_sim = now + 3600LL; /* simulated "now": 1 h from now */ | |
| 1494 | |
| 1495 char ra[37], rb[37]; | |
| 1496 assert(test__make_uuid(ra)); | |
| 1497 assert(test__make_uuid(rb)); | |
| 1498 | |
| 1499 /* Reserve two turns: ra expires before now_sim, rb expires after. */ | |
| 1500 assert(Auth_Store_Guest_Reserve( | |
| 1501 p_store, gid, ra, win, 100, 10, 1000, past) == | |
| 1502 AUTH_STORE_GUEST_QUOTA_OK); | |
| 1503 assert(Auth_Store_Guest_Reserve( | |
| 1504 p_store, gid, rb, win, 200, 10, 1000, future) == | |
| 1505 AUTH_STORE_GUEST_QUOTA_OK); | |
| 1506 | |
| 1507 Auth_Store_Guest_Usage u; | |
| 1508 assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK); | |
| 1509 assert(u.turns_used == 2); | |
| 1510 assert(u.output_tokens_reserved == 300); /* 100 + 200 */ | |
| 1511 | |
| 1512 /* Reap with now_sim > past but < future: only ra should be reaped. */ | |
| 1513 assert(Auth_Store_Guest_Reap_Expired(p_store, now_sim) == AUTH_STORE_OK); | |
| 1514 | |
| 1515 assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK); | |
| 1516 assert(u.turns_used == 2); /* turns retained */ | |
| 1517 assert(u.output_tokens_reserved == 200); /* only ra's 100 removed */ | |
| 1518 puts(" reap/partial: PASS"); | |
| 1519 | |
| 1520 /* Idempotent: reaping again changes nothing. */ | |
| 1521 assert(Auth_Store_Guest_Reap_Expired(p_store, now_sim) == AUTH_STORE_OK); | |
| 1522 assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK); | |
| 1523 assert(u.output_tokens_reserved == 200); | |
| 1524 puts(" reap/idempotent: PASS"); | |
| 1525 | |
| 1526 /* Reap with future time: rb is now expired too. */ | |
| 1527 assert(Auth_Store_Guest_Reap_Expired(p_store, future + 1) == AUTH_STORE_OK); | |
| 1528 assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK); | |
| 1529 assert(u.output_tokens_reserved == 0); | |
| 1530 assert(u.turns_used == 2); | |
| 1531 puts(" reap/all_expired: PASS"); | |
| 1532 | |
| 1533 /* Release on an already-reaped reservation: idempotent OK. */ | |
| 1534 assert(Auth_Store_Guest_Release(p_store, ra) == AUTH_STORE_OK); | |
| 1535 assert(Auth_Store_Guest_Release(p_store, rb) == AUTH_STORE_OK); | |
| 1536 puts(" reap/release_after_reap: PASS"); | |
| 1537 | |
| 1538 puts("test_reap_expired_reservations: PASS"); | |
| 1539 } | |
| 1540 | |
| 1541 /* ------------------------------------------------------------------ */ | |
| 1542 /* Main */ | |
| 1543 /* ------------------------------------------------------------------ */ | |
| 1544 | |
| 1545 int main(void) | |
| 1546 { | |
| 1547 char db_path[] = "/tmp/zenbu-auth-XXXXXX"; | |
| 1548 int fd = mkstemp(db_path); | |
| 1549 assert(fd >= 0); | |
| 1550 close(fd); | |
| 1551 | |
| 1552 test_migrations_idempotent(db_path); | |
| 1553 test_migration_v2_legacy_backfill(db_path); | |
| 1554 | |
| 1555 Auth_Store *p_store = Auth_Store_Create(db_path); | |
| 1556 assert(p_store); | |
| 1557 | |
| 1558 test_username_normalization(); | |
| 1559 | |
| 1560 /* Bootstrap and last-admin tests must run first (only one admin). */ | |
| 1561 test_bootstrap_admin(p_store); | |
| 1562 test_last_admin_protection(p_store); | |
| 1563 | |
| 1564 test_username_uniqueness(p_store); | |
| 1565 test_user_lookup(p_store); | |
| 1566 test_forced_password_flag(p_store); | |
| 1567 test_session_lifecycle(p_store); | |
| 1568 test_stale_password_snapshot(p_store); | |
| 1569 test_disabled_user(p_store); | |
| 1570 test_password_update_revokes_others(p_store); | |
| 1571 test_revoke_all_sessions(p_store); | |
| 1572 test_expired_session(p_store); | |
| 1573 test_guest_identity(p_store, db_path); | |
| 1574 test_rotate_session(p_store); | |
| 1575 test_create_session_cas(p_store); | |
| 1576 test_self_change_password(p_store); | |
| 1577 test_audited_admin_operations(p_store, db_path); | |
| 1578 test_audit_failure_rollback(p_store, db_path); | |
| 1579 test_guest_quota(p_store); | |
| 1580 test_reap_expired_reservations(p_store); | |
| 1581 | |
| 1582 Auth_Store_Destroy(p_store); | |
| 1583 unlink(db_path); | |
| 1584 | |
| 1585 puts("auth_store_test: ALL PASS"); | |
| 1586 return 0; | |
| 1587 } |