Mercurial
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/auth/test/auth_store_test.c Fri Aug 07 07:34:12 2026 -0700 @@ -0,0 +1,1587 @@ +#include "auth/auth_store.h" +#include "auth/auth_crypto.h" +#include "deita/deita.h" + +#include <assert.h> +#include <fcntl.h> +#include <stdio.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +static char g_hash_buf[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + +static const char *get_test_hash(void) +{ + if (g_hash_buf[0] == '\0') + { + Auth_Crypto_Result r = Auth_Crypto_Password_Hash( + "hunter2", g_hash_buf, sizeof(g_hash_buf)); + assert(r == AUTH_CRYPTO_OK); + } + return g_hash_buf; +} + +static void make_test_hash( + const char *password, + char output[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]) +{ + assert(Auth_Crypto_Password_Hash( + password, output, AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE) == + AUTH_CRYPTO_OK); +} + +/* Simple UUID generator for the test, matching the pattern in + conversation_store.c (no dependency on auth_store internals). */ +static boolean test__make_uuid(char output[37]) +{ + uint8 bytes[16]; + int fd = open("/dev/urandom", O_RDONLY); + size_t offset = 0; + if (fd < 0) + return FALSE; + while (offset < sizeof(bytes)) + { + ssize_t n = read(fd, bytes + offset, sizeof(bytes) - offset); + if (n <= 0) { close(fd); return FALSE; } + offset += (size_t)n; + } + close(fd); + bytes[6] = (uint8)((bytes[6] & 0x0f) | 0x40); + bytes[8] = (uint8)((bytes[8] & 0x3f) | 0x80); + snprintf(output, 37, + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + bytes[0],bytes[1],bytes[2],bytes[3],bytes[4],bytes[5], + bytes[6],bytes[7],bytes[8],bytes[9],bytes[10],bytes[11], + bytes[12],bytes[13],bytes[14],bytes[15]); + return TRUE; +} + +/* ------------------------------------------------------------------ */ +/* 1. Username normalization and rejection */ +/* ------------------------------------------------------------------ */ + +static void test_username_normalization(void) +{ + char out[AUTH_STORE_USERNAME_MAX + 1]; + + assert(Auth_Store_Normalize_Username(" JohnDoe ", out, sizeof(out))); + assert(strcmp(out, "johndoe") == 0); + + assert(Auth_Store_Normalize_Username("Alice", out, sizeof(out))); + assert(strcmp(out, "alice") == 0); + + assert(Auth_Store_Normalize_Username("june_bot-2.0", out, sizeof(out))); + assert(strcmp(out, "june_bot-2.0") == 0); + + assert(Auth_Store_Normalize_Username("abc", out, sizeof(out))); + + /* Exactly 32 chars */ + assert(Auth_Store_Normalize_Username( + "abcdefghijklmnopqrstuvwxyz123456", out, sizeof(out))); + + /* Too short after trimming */ + assert(!Auth_Store_Normalize_Username("ab", out, sizeof(out))); + assert(!Auth_Store_Normalize_Username(" z ", out, sizeof(out))); + + /* Too long (33 chars) */ + assert(!Auth_Store_Normalize_Username( + "abcdefghijklmnopqrstuvwxyz1234567", out, sizeof(out))); + + /* Invalid characters */ + assert(!Auth_Store_Normalize_Username("hello world", out, sizeof(out))); + assert(!Auth_Store_Normalize_Username("invalid!", out, sizeof(out))); + assert(!Auth_Store_Normalize_Username("utf8\xc3\xa9", out, sizeof(out))); + + /* Buffer too small */ + char tiny[3]; + assert(!Auth_Store_Normalize_Username("abc", tiny, sizeof(tiny))); + + /* Validate pre-normalized */ + assert( Auth_Store_Validate_Username("johndoe")); + assert( Auth_Store_Validate_Username("abc")); + assert( Auth_Store_Validate_Username("june_bot-2.0")); + assert(!Auth_Store_Validate_Username("ab")); + assert(!Auth_Store_Validate_Username("Hello")); /* uppercase */ + assert(!Auth_Store_Validate_Username("bad char!")); + assert(!Auth_Store_Validate_Username(NULL)); + + puts("test_username_normalization: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 2. Migration idempotency / reopen */ +/* ------------------------------------------------------------------ */ + +static void test_migrations_idempotent(const char *db_path) +{ + Auth_Store *p = Auth_Store_Create(db_path); + assert(p); + Auth_Store_Destroy(p); + + /* Reopen: migrations must be no-ops. */ + p = Auth_Store_Create(db_path); + assert(p); + Auth_Store_Destroy(p); + + /* Verify all expected tables exist via a second connection. */ + Dowa_Arena *p_arena = Dowa_Arena_Create(4096); + assert(p_arena); + Deita_Connection *p_conn = Deita_Connection_Create( + DEITA_DATABASE_TYPE_SQLITE3, db_path); + assert(p_conn); + + static const char *expected[] = { + "admin_audit_log", "auth_schema_migrations", "auth_sessions", + "guest_identities", "guest_usage", "guest_usage_reservations", "users", + }; + size_t found = 0; + + Deita_Result_Set *p_result = Deita_Query_Execute( + p_conn, + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", + p_arena); + assert(p_result); + while (Deita_Result_Set_Next(p_result)) + { + const char *name = Deita_Result_Set_Get_Text(p_result, 0); + for (size_t i = 0; i < sizeof(expected)/sizeof(expected[0]); i++) + if (name && strcmp(name, expected[i]) == 0) { found++; break; } + } + Deita_Result_Set_Free(p_result); + Deita_Connection_Close(p_conn); + Dowa_Arena_Free(p_arena); + + assert(found == sizeof(expected)/sizeof(expected[0])); + puts("test_migrations_idempotent: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 3. Bootstrap admin idempotency */ +/* ------------------------------------------------------------------ */ + +static void test_bootstrap_admin(Auth_Store *p_store) +{ + char id1[37], id2[37]; + Auth_Store_Bootstrap_Result br; + + assert(Auth_Store_Bootstrap_Admin( + p_store, "Admin", get_test_hash(), &br, id1) == AUTH_STORE_OK); + assert(br == AUTH_STORE_BOOTSTRAP_CREATED); + assert(strlen(id1) == 36); + + /* Second call with a different username: must not overwrite existing admin. */ + assert(Auth_Store_Bootstrap_Admin( + p_store, "OtherAdmin", get_test_hash(), &br, id2) == AUTH_STORE_OK); + assert(br == AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT); + assert(strcmp(id1, id2) == 0); + + /* Exactly one admin must exist. */ + Auth_User_Record *records = NULL; + Dowa_Arena *p_arena = Dowa_Arena_Create(64 * 1024); + assert(p_arena); + assert(Auth_Store_List_Users(p_store, &records, p_arena) == AUTH_STORE_OK); + size_t admin_count = 0; + for (size_t i = 0; i < Dowa_Array_Length(records); i++) + if (strcmp(records[i].role, "admin") == 0) + admin_count++; + assert(admin_count == 1); + Dowa_Arena_Free(p_arena); + + puts("test_bootstrap_admin: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 4. Last-admin protection (run while only one admin exists) */ +/* ------------------------------------------------------------------ */ + +static void test_last_admin_protection(Auth_Store *p_store) +{ + /* At this point only the bootstrap admin ("Admin"/"admin") exists. */ + Auth_User_Auth_Record ar; + assert(Auth_Store_Find_User_By_Username( + p_store, "admin", &ar) == AUTH_STORE_OK); + const char *admin_id = ar.user.id; + + /* Cannot disable the last active admin. */ + assert(Auth_Store_Update_User_Status( + p_store, admin_id, "disabled", NULL) == AUTH_STORE_LAST_ADMIN); + + /* Cannot demote the last active admin. */ + assert(Auth_Store_Update_User_Role( + p_store, admin_id, "member", NULL) == AUTH_STORE_LAST_ADMIN); + assert(Auth_Store_Disable_User_And_Revoke_Sessions( + p_store, admin_id, NULL) == AUTH_STORE_LAST_ADMIN); + assert(Auth_Store_Update_Role_And_Revoke_Sessions( + p_store, admin_id, "member", NULL) == AUTH_STORE_LAST_ADMIN); + + /* Add a second active admin — operations on the first should now succeed. */ + char id2[37]; + assert(Auth_Store_Create_User( + p_store, "SecondAdmin", get_test_hash(), "admin", FALSE, id2) == + AUTH_STORE_OK); + + /* Now demotion of the first admin is allowed (two active admins). */ + assert(Auth_Store_Update_User_Role( + p_store, admin_id, "member", id2) == AUTH_STORE_OK); + + /* Re-promote so remaining tests can rely on admin being an admin. */ + assert(Auth_Store_Update_User_Role( + p_store, admin_id, "admin", id2) == AUTH_STORE_OK); + + puts("test_last_admin_protection: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 5. Username uniqueness */ +/* ------------------------------------------------------------------ */ + +static void test_username_uniqueness(Auth_Store *p_store) +{ + char id[37]; + assert(Auth_Store_Create_User( + p_store, "UniqueUser", get_test_hash(), "member", FALSE, id) == + AUTH_STORE_OK); + + char id2[37]; + assert(Auth_Store_Create_User( + p_store, "uniqueuser", get_test_hash(), "member", FALSE, id2) == + AUTH_STORE_CONFLICT); + + puts("test_username_uniqueness: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 6. User lookup */ +/* ------------------------------------------------------------------ */ + +static void test_user_lookup(Auth_Store *p_store) +{ + char id[37]; + assert(Auth_Store_Create_User( + p_store, "LookupUser", get_test_hash(), "member", FALSE, id) == + AUTH_STORE_OK); + + Auth_User_Record r; + assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK); + assert(strcmp(r.id, id) == 0); + assert(strcmp(r.username, "LookupUser") == 0); + assert(strcmp(r.normalized_username, "lookupuser") == 0); + assert(strcmp(r.role, "member") == 0); + assert(strcmp(r.status, "active") == 0); + assert(r.must_change_password == FALSE); + + /* Not found */ + assert(Auth_Store_Get_User( + p_store, "00000000-0000-0000-0000-000000000000", &r) == + AUTH_STORE_NOT_FOUND); + + /* Case-insensitive find by username */ + Auth_User_Auth_Record auth_r; + assert(Auth_Store_Find_User_By_Username( + p_store, " LOOKUPUSER ", &auth_r) == AUTH_STORE_OK); + assert(strcmp(auth_r.user.id, id) == 0); + assert(strncmp(auth_r.password_hash, "zenbu-scrypt$", 13) == 0); + + assert(Auth_Store_Find_User_By_Username( + p_store, "nobody", &auth_r) == AUTH_STORE_NOT_FOUND); + + puts("test_user_lookup: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 7. Forced password flag */ +/* ------------------------------------------------------------------ */ + +static void test_forced_password_flag(Auth_Store *p_store) +{ + char id[37]; + assert(Auth_Store_Create_User( + p_store, "ForcedPwUser", get_test_hash(), "member", TRUE, id) == + AUTH_STORE_OK); + + Auth_User_Record r; + assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK); + assert(r.must_change_password == TRUE); + + assert(Auth_Store_Set_Must_Change_Password( + p_store, id, FALSE, NULL) == AUTH_STORE_OK); + assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK); + assert(r.must_change_password == FALSE); + + assert(Auth_Store_Set_Must_Change_Password( + p_store, id, TRUE, NULL) == AUTH_STORE_OK); + assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK); + assert(r.must_change_password == TRUE); + + /* Updating password clears the flag. */ + assert(Auth_Store_Update_Password( + p_store, id, get_test_hash(), FALSE, NULL) == AUTH_STORE_OK); + assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK); + assert(r.must_change_password == FALSE); + + puts("test_forced_password_flag: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 8. Session create / resolve / touch / revoke */ +/* ------------------------------------------------------------------ */ + +static void test_session_lifecycle(Auth_Store *p_store) +{ + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "SessUser", get_test_hash(), "member", FALSE, user_id) == + AUTH_STORE_OK); + + const char *tok = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const char *csrf = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + int64 now = 1700000000LL; + int64 idle_ttl = 3600; + int64 abs_ttl = 86400; + + Auth_Session_Record sess; + assert(Auth_Store_Create_Session( + p_store, user_id, tok, csrf, idle_ttl, abs_ttl, now, &sess) == + AUTH_STORE_OK); + assert(strcmp(sess.user_id, user_id) == 0); + assert(sess.created_at == now); + assert(sess.idle_expires_at == now + idle_ttl); + assert(sess.absolute_expires_at == now + abs_ttl); + assert(sess.password_changed_at_snapshot == 0); + + Auth_Session_Record fs; + Auth_User_Record fu; + int64 check = now + 60; + + assert(Auth_Store_Find_Session( + p_store, tok, check, &fs, &fu) == AUTH_STORE_OK); + assert(strcmp(fu.id, user_id) == 0); + + /* Touch extends idle expiry. */ + assert(Auth_Store_Touch_Session( + p_store, tok, check, idle_ttl) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session( + p_store, tok, check, &fs, &fu) == AUTH_STORE_OK); + assert(fs.idle_expires_at == check + idle_ttl); + + /* Revoke. */ + assert(Auth_Store_Revoke_Session(p_store, tok) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session( + p_store, tok, check, &fs, &fu) == AUTH_STORE_REVOKED); + + /* Unknown digest. */ + const char *unk = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + assert(Auth_Store_Find_Session( + p_store, unk, check, &fs, &fu) == AUTH_STORE_NOT_FOUND); + + puts("test_session_lifecycle: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 9. Stale password snapshot */ +/* ------------------------------------------------------------------ */ + +static void test_stale_password_snapshot(Auth_Store *p_store) +{ + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "StaleUser", get_test_hash(), "member", FALSE, user_id) == + AUTH_STORE_OK); + + const char *tok = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + const char *csrf = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + int64 now = 1700100000LL; + + Auth_Session_Record sess; + assert(Auth_Store_Create_Session( + p_store, user_id, tok, csrf, 3600, 86400, now, &sess) == AUTH_STORE_OK); + assert(sess.password_changed_at_snapshot == 0); + + Auth_Session_Record fs; + Auth_User_Record fu; + assert(Auth_Store_Find_Session( + p_store, tok, now + 10, &fs, &fu) == AUTH_STORE_OK); + + assert(Auth_Store_Update_Password( + p_store, user_id, get_test_hash(), FALSE, NULL) == AUTH_STORE_OK); + + assert(Auth_Store_Find_Session( + p_store, tok, now + 20, &fs, &fu) == AUTH_STORE_STALE_PASSWORD); + + puts("test_stale_password_snapshot: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 10. Disabled user blocks session resolution and creation */ +/* ------------------------------------------------------------------ */ + +static void test_disabled_user(Auth_Store *p_store) +{ + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "DisabledUser", get_test_hash(), "member", FALSE, user_id) == + AUTH_STORE_OK); + + const char *tok = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + const char *csrf = "1111111111111111111111111111111111111111111111111111111111111111"; + int64 now = 1700200000LL; + + Auth_Session_Record sess; + assert(Auth_Store_Create_Session( + p_store, user_id, tok, csrf, 3600, 86400, now, &sess) == AUTH_STORE_OK); + + Auth_Session_Record fs; + Auth_User_Record fu; + assert(Auth_Store_Find_Session( + p_store, tok, now + 10, &fs, &fu) == AUTH_STORE_OK); + + /* Disable the member user (no last-admin protection applies). */ + assert(Auth_Store_Update_User_Status( + p_store, user_id, "disabled", NULL) == AUTH_STORE_OK); + + assert(Auth_Store_Find_Session( + p_store, tok, now + 20, &fs, &fu) == AUTH_STORE_USER_DISABLED); + + /* New session for a disabled user must fail. */ + const char *tok2 = "2222222222222222222222222222222222222222222222222222222222222222"; + const char *csrf2 = "3333333333333333333333333333333333333333333333333333333333333333"; + assert(Auth_Store_Create_Session( + p_store, user_id, tok2, csrf2, 3600, 86400, now + 30, &sess) == + AUTH_STORE_USER_DISABLED); + + /* Re-enable. */ + assert(Auth_Store_Update_User_Status( + p_store, user_id, "active", NULL) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session( + p_store, tok, now + 30, &fs, &fu) == AUTH_STORE_OK); + + puts("test_disabled_user: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 11. Password update revokes other sessions atomically */ +/* ------------------------------------------------------------------ */ + +static void test_password_update_revokes_others(Auth_Store *p_store) +{ + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "RevokeOthers", get_test_hash(), "member", FALSE, user_id) == + AUTH_STORE_OK); + + int64 now = 1700300000LL; + const char *tok_keep = "4444444444444444444444444444444444444444444444444444444444444444"; + const char *tok_rev1 = "5555555555555555555555555555555555555555555555555555555555555555"; + const char *tok_rev2 = "6666666666666666666666666666666666666666666666666666666666666666"; + const char *csrf_k = "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111"; + const char *csrf_1 = "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222"; + const char *csrf_2 = "cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333"; + + Auth_Session_Record sess; + assert(Auth_Store_Create_Session( + p_store, user_id, tok_keep, csrf_k, 3600, 86400, now, &sess) == + AUTH_STORE_OK); + assert(Auth_Store_Create_Session( + p_store, user_id, tok_rev1, csrf_1, 3600, 86400, now, &sess) == + AUTH_STORE_OK); + assert(Auth_Store_Create_Session( + p_store, user_id, tok_rev2, csrf_2, 3600, 86400, now, &sess) == + AUTH_STORE_OK); + + /* Update password: keep tok_keep, revoke all others. */ + assert(Auth_Store_Update_Password( + p_store, user_id, get_test_hash(), TRUE, tok_keep) == AUTH_STORE_OK); + + int64 check = now + 60; + Auth_Session_Record fs; + Auth_User_Record fu; + + /* tok_keep is not revoked but snapshot is stale because password changed. */ + assert(Auth_Store_Find_Session( + p_store, tok_keep, check, &fs, &fu) == AUTH_STORE_STALE_PASSWORD); + + assert(Auth_Store_Find_Session( + p_store, tok_rev1, check, &fs, &fu) == AUTH_STORE_REVOKED); + assert(Auth_Store_Find_Session( + p_store, tok_rev2, check, &fs, &fu) == AUTH_STORE_REVOKED); + + puts("test_password_update_revokes_others: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 12. Revoke_All_Sessions */ +/* ------------------------------------------------------------------ */ + +static void test_revoke_all_sessions(Auth_Store *p_store) +{ + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "RevokeAll", get_test_hash(), "member", FALSE, user_id) == + AUTH_STORE_OK); + + int64 now = 1700400000LL; + const char *t1 = "7777777777777777777777777777777777777777777777777777777777777777"; + const char *t2 = "8888888888888888888888888888888888888888888888888888888888888888"; + const char *t3 = "9999999999999999999999999999999999999999999999999999999999999999"; + const char *c1 = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + const char *c2 = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"; + const char *c3 = "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"; + + Auth_Session_Record sess; + assert(Auth_Store_Create_Session( + p_store, user_id, t1, c1, 3600, 86400, now, &sess) == AUTH_STORE_OK); + assert(Auth_Store_Create_Session( + p_store, user_id, t2, c2, 3600, 86400, now, &sess) == AUTH_STORE_OK); + assert(Auth_Store_Create_Session( + p_store, user_id, t3, c3, 3600, 86400, now, &sess) == AUTH_STORE_OK); + + /* Revoke all except t2. */ + assert(Auth_Store_Revoke_All_Sessions(p_store, user_id, t2) == AUTH_STORE_OK); + + int64 check = now + 60; + Auth_Session_Record fs; + Auth_User_Record fu; + assert(Auth_Store_Find_Session(p_store, t1, check, &fs, &fu) == AUTH_STORE_REVOKED); + assert(Auth_Store_Find_Session(p_store, t2, check, &fs, &fu) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session(p_store, t3, check, &fs, &fu) == AUTH_STORE_REVOKED); + + puts("test_revoke_all_sessions: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 13. Expired session */ +/* ------------------------------------------------------------------ */ + +static void test_expired_session(Auth_Store *p_store) +{ + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "ExpiredUser", get_test_hash(), "member", FALSE, user_id) == + AUTH_STORE_OK); + + const char *tok = "abababababababababababababababababababababababababababababababab"; + const char *csrf = "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; + int64 now = 1700500000LL; + + Auth_Session_Record sess; + assert(Auth_Store_Create_Session( + p_store, user_id, tok, csrf, 100, 200, now, &sess) == AUTH_STORE_OK); + + Auth_Session_Record fs; + Auth_User_Record fu; + + assert(Auth_Store_Find_Session( + p_store, tok, now + 50, &fs, &fu) == AUTH_STORE_OK); + + assert(Auth_Store_Find_Session( + p_store, tok, now + 110, &fs, &fu) == AUTH_STORE_EXPIRED); + + assert(Auth_Store_Find_Session( + p_store, tok, now + 201, &fs, &fu) == AUTH_STORE_EXPIRED); + + puts("test_expired_session: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 14. Guest identity persistence and no raw IP storage */ +/* ------------------------------------------------------------------ */ + +static void test_guest_identity(Auth_Store *p_store, const char *db_path) +{ + uint8 secret[AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES]; + memset(secret, 0xab, sizeof(secret)); + const char *raw_ip = "203.0.113.42"; /* TEST-NET-3, never stored */ + + char ip_digest[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE]; + assert(Auth_Crypto_IP_Binding_Digest( + secret, sizeof(secret), raw_ip, + ip_digest, sizeof(ip_digest)) == AUTH_CRYPTO_OK); + + char guest_id[37]; + assert(test__make_uuid(guest_id)); + int64 now = 1700600000LL; + int64 exp = now + 86400; + + Auth_Guest_Identity_Record g; + assert(Auth_Store_Upsert_Guest_Identity( + p_store, guest_id, ip_digest, exp, &g) == AUTH_STORE_OK); + assert(strcmp(g.id, guest_id) == 0); + assert(g.expires_at == exp); + + /* Find within expiry. */ + Auth_Guest_Identity_Record g2; + assert(Auth_Store_Find_Guest_Identity( + p_store, guest_id, now + 60, &g2) == AUTH_STORE_OK); + assert(strcmp(g2.id, guest_id) == 0); + + /* Find after expiry. */ + assert(Auth_Store_Find_Guest_Identity( + p_store, guest_id, exp + 1, &g2) == AUTH_STORE_EXPIRED); + + /* Upsert refreshes expiry without losing the identity row. */ + int64 new_exp = exp + 86400; + assert(Auth_Store_Upsert_Guest_Identity( + p_store, guest_id, ip_digest, new_exp, &g) == AUTH_STORE_OK); + assert(g.expires_at == new_exp); + assert(Auth_Store_Find_Guest_Identity( + p_store, guest_id, exp + 1, &g2) == AUTH_STORE_OK); + + /* Unknown guest. */ + assert(Auth_Store_Find_Guest_Identity( + p_store, "00000000-0000-4000-8000-000000000000", + now, &g2) == AUTH_STORE_NOT_FOUND); + + /* + * Verify raw IP is NOT stored in the database: open a second connection + * and inspect the ip_binding_digest column directly. + */ + Dowa_Arena *p_arena = Dowa_Arena_Create(4096); + assert(p_arena); + Deita_Connection *p_conn = Deita_Connection_Create( + DEITA_DATABASE_TYPE_SQLITE3, db_path); + assert(p_conn); + + const char *sel_params[] = {guest_id}; + Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( + p_conn, + "SELECT ip_binding_digest FROM guest_identities WHERE id = ?", + 1, sel_params, p_arena); + assert(p_result); + assert(Deita_Result_Set_Next(p_result)); + const char *stored = Deita_Result_Set_Get_Text(p_result, 0); + assert(stored != NULL); + assert(strcmp(stored, ip_digest) == 0); /* HMAC digest is stored */ + assert(strstr(stored, raw_ip) == NULL); /* raw IP is NOT stored */ + Deita_Result_Set_Free(p_result); + Deita_Connection_Close(p_conn); + Dowa_Arena_Free(p_arena); + + puts("test_guest_identity: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 16. Rotate_Session */ +/* ------------------------------------------------------------------ */ + +static void test_rotate_session(Auth_Store *p_store) +{ + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "RotateUser", get_test_hash(), "member", FALSE, user_id) == + AUTH_STORE_OK); + + int64 now = 1700400000LL; + const char *old_tok = "e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1"; + const char *old_csrf = "f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2"; + const char *new_tok = "a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3"; + const char *new_csrf = "b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4"; + + Auth_Session_Record sess; + assert(Auth_Store_Create_Session( + p_store, user_id, old_tok, old_csrf, 3600, 86400, now, &sess) == + AUTH_STORE_OK); + + /* Rotate: new session created, old revoked atomically */ + Auth_Session_Record new_sess; + assert(Auth_Store_Rotate_Session( + p_store, user_id, old_tok, new_tok, new_csrf, + 3600, 86400, now + 10, &new_sess) == AUTH_STORE_OK); + + int64 check = now + 60; + Auth_Session_Record fs; + Auth_User_Record fu; + + /* Old session must be revoked */ + assert(Auth_Store_Find_Session(p_store, old_tok, check, &fs, &fu) == + AUTH_STORE_REVOKED); + + /* New session must be valid */ + assert(Auth_Store_Find_Session(p_store, new_tok, check, &fs, &fu) == + AUTH_STORE_OK); + assert(strcmp(fs.user_id, user_id) == 0); + + /* Rotate again with already-revoked old token must still succeed + * (idempotent revocation) */ + const char *new_tok2 = "c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5"; + const char *new_csrf2 = "d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6"; + Auth_Session_Record sess2; + assert(Auth_Store_Rotate_Session( + p_store, user_id, old_tok, new_tok2, new_csrf2, + 3600, 86400, now + 20, &sess2) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session(p_store, new_tok2, check, &fs, &fu) == + AUTH_STORE_OK); + + puts("test_rotate_session: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 17. Compare-and-swap session creation */ +/* ------------------------------------------------------------------ */ + +static void test_create_session_cas(Auth_Store *p_store) +{ + char first_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + char second_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + char reset_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + make_test_hash("cas-first-password", first_hash); + make_test_hash("cas-second-password", second_hash); + make_test_hash("cas-reset-password", reset_hash); + + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "CasSessionUser", first_hash, "member", FALSE, user_id) == + AUTH_STORE_OK); + + const char *token1 = + "1010101010101010101010101010101010101010101010101010101010101010"; + const char *csrf1 = + "2020202020202020202020202020202020202020202020202020202020202020"; + const char *token2 = + "3030303030303030303030303030303030303030303030303030303030303030"; + const char *csrf2 = + "4040404040404040404040404040404040404040404040404040404040404040"; + const char *token3 = + "5050505050505050505050505050505050505050505050505050505050505050"; + const char *csrf3 = + "6060606060606060606060606060606060606060606060606060606060606060"; + const char *token4 = + "7070707070707070707070707070707070707070707070707070707070707070"; + const char *csrf4 = + "8080808080808080808080808080808080808080808080808080808080808080"; + const char *disabled_token = + "9090909090909090909090909090909090909090909090909090909090909090"; + const char *disabled_csrf = + "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0"; + int64 now = 1800000000LL; + Auth_Session_Record session; + + assert(Auth_Store_Create_Session_CAS( + p_store, user_id, first_hash, token1, csrf1, + 3600, 86400, now, &session) == AUTH_STORE_OK); + + assert(Auth_Store_Update_User_Status( + p_store, user_id, "disabled", NULL) == AUTH_STORE_OK); + assert(Auth_Store_Create_Session_CAS( + p_store, user_id, first_hash, disabled_token, disabled_csrf, + 3600, 86400, now + 1, &session) == AUTH_STORE_USER_DISABLED); + assert(Auth_Store_Update_User_Status( + p_store, user_id, "active", NULL) == AUTH_STORE_OK); + + Auth_Session_Record found_session; + Auth_User_Record found_user; + assert(Auth_Store_Find_Session( + p_store, disabled_token, now + 2, &found_session, &found_user) == + AUTH_STORE_NOT_FOUND); + + assert(Auth_Store_Update_Password( + p_store, user_id, second_hash, FALSE, NULL) == AUTH_STORE_OK); + assert(Auth_Store_Create_Session_CAS( + p_store, user_id, first_hash, token2, csrf2, + 3600, 86400, now + 10, &session) == AUTH_STORE_STALE_PASSWORD); + + assert(Auth_Store_Find_Session( + p_store, token2, now + 20, &found_session, &found_user) == + AUTH_STORE_NOT_FOUND); + + assert(Auth_Store_Admin_Reset_Password( + p_store, user_id, reset_hash, NULL) == AUTH_STORE_OK); + assert(Auth_Store_Create_Session_CAS( + p_store, user_id, second_hash, token3, csrf3, + 3600, 86400, now + 20, &session) == AUTH_STORE_STALE_PASSWORD); + assert(Auth_Store_Find_Session( + p_store, token3, now + 30, &found_session, &found_user) == + AUTH_STORE_NOT_FOUND); + + assert(Auth_Store_Create_Session_CAS( + p_store, user_id, reset_hash, token4, csrf4, + 3600, 86400, now + 30, &session) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session( + p_store, token4, now + 40, &found_session, &found_user) == + AUTH_STORE_OK); + + memset(first_hash, 0, sizeof(first_hash)); + memset(second_hash, 0, sizeof(second_hash)); + memset(reset_hash, 0, sizeof(reset_hash)); + puts("test_create_session_cas: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 18. Atomic self-service password change */ +/* ------------------------------------------------------------------ */ + +static void test_self_change_password(Auth_Store *p_store) +{ + char old_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + char new_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + char unused_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + make_test_hash("self-change-old", old_hash); + make_test_hash("self-change-new", new_hash); + make_test_hash("self-change-unused", unused_hash); + + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "SelfChangeUser", old_hash, "member", TRUE, user_id) == + AUTH_STORE_OK); + + const char *old_token1 = + "1111222211112222111122221111222211112222111122221111222211112222"; + const char *old_csrf1 = + "2222333322223333222233332222333322223333222233332222333322223333"; + const char *old_token2 = + "3333444433334444333344443333444433334444333344443333444433334444"; + const char *old_csrf2 = + "4444555544445555444455554444555544445555444455554444555544445555"; + const char *new_token = + "5555666655556666555566665555666655556666555566665555666655556666"; + const char *new_csrf = + "6666777766667777666677776666777766667777666677776666777766667777"; + const char *failed_token = + "7777888877778888777788887777888877778888777788887777888877778888"; + const char *failed_csrf = + "8888999988889999888899998888999988889999888899998888999988889999"; + int64 now = 1810000000LL; + Auth_Session_Record session; + + assert(Auth_Store_Create_Session_CAS( + p_store, user_id, old_hash, old_token1, old_csrf1, + 3600, 86400, now, &session) == AUTH_STORE_OK); + assert(Auth_Store_Create_Session_CAS( + p_store, user_id, old_hash, old_token2, old_csrf2, + 3600, 86400, now, &session) == AUTH_STORE_OK); + + assert(Auth_Store_Self_Change_Password( + p_store, user_id, old_hash, new_hash, new_token, new_csrf, + 3600, 86400, now + 100, &session) == AUTH_STORE_OK); + assert(session.password_changed_at_snapshot == now + 100); + + Auth_Session_Record found_session; + Auth_User_Record found_user; + assert(Auth_Store_Find_Session( + p_store, old_token1, now + 101, &found_session, &found_user) == + AUTH_STORE_REVOKED); + assert(Auth_Store_Find_Session( + p_store, old_token2, now + 101, &found_session, &found_user) == + AUTH_STORE_REVOKED); + assert(Auth_Store_Find_Session( + p_store, new_token, now + 101, &found_session, &found_user) == + AUTH_STORE_OK); + assert(found_user.must_change_password == FALSE); + assert(found_user.password_changed_at == now + 100); + + Auth_User_Auth_Record auth_record; + assert(Auth_Store_Find_User_By_Username( + p_store, "SelfChangeUser", &auth_record) == AUTH_STORE_OK); + assert(strcmp(auth_record.password_hash, new_hash) == 0); + memset(&auth_record, 0, sizeof(auth_record)); + + assert(Auth_Store_Self_Change_Password( + p_store, user_id, old_hash, unused_hash, failed_token, failed_csrf, + 3600, 86400, now + 200, &session) == AUTH_STORE_STALE_PASSWORD); + assert(Auth_Store_Find_Session( + p_store, failed_token, now + 201, &found_session, &found_user) == + AUTH_STORE_NOT_FOUND); + assert(Auth_Store_Find_Session( + p_store, new_token, now + 201, &found_session, &found_user) == + AUTH_STORE_OK); + assert(Auth_Store_Find_User_By_Username( + p_store, "SelfChangeUser", &auth_record) == AUTH_STORE_OK); + assert(strcmp(auth_record.password_hash, new_hash) == 0); + + memset(&auth_record, 0, sizeof(auth_record)); + memset(old_hash, 0, sizeof(old_hash)); + memset(new_hash, 0, sizeof(new_hash)); + memset(unused_hash, 0, sizeof(unused_hash)); + puts("test_self_change_password: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 19. Audited and session-revoking admin operations */ +/* ------------------------------------------------------------------ */ + +static void test_audited_admin_operations( + Auth_Store *p_store, + const char *db_path) +{ + char user_id[37]; + const char *actor_id = "00000000-0000-4000-8000-000000000019"; + assert(Auth_Store_Create_User_Audited( + p_store, "AuditedUser", get_test_hash(), "member", FALSE, + actor_id, user_id) == AUTH_STORE_OK); + + Dowa_Arena *p_arena = Dowa_Arena_Create(2048); + assert(p_arena); + Deita_Connection *p_conn = Deita_Connection_Create( + DEITA_DATABASE_TYPE_SQLITE3, db_path); + assert(p_conn); + const char *audit_params[] = {user_id}; + Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( + p_conn, + "SELECT actor_user_id, action, detail FROM admin_audit_log" + " WHERE target_user_id = ? ORDER BY id DESC LIMIT 1", + 1, audit_params, p_arena); + assert(p_result); + assert(Deita_Result_Set_Next(p_result)); + assert(strcmp(Deita_Result_Set_Get_Text(p_result, 0), actor_id) == 0); + assert(strcmp(Deita_Result_Set_Get_Text(p_result, 1), + "admin_user_created") == 0); + assert(strcmp(Deita_Result_Set_Get_Text(p_result, 2), "member") == 0); + assert(strstr(Deita_Result_Set_Get_Text(p_result, 2), "zenbu-scrypt") == NULL); + Deita_Result_Set_Free(p_result); + Deita_Connection_Close(p_conn); + Dowa_Arena_Free(p_arena); + + const char *token1 = + "9191919191919191919191919191919191919191919191919191919191919191"; + const char *csrf1 = + "9292929292929292929292929292929292929292929292929292929292929292"; + const char *token2 = + "9393939393939393939393939393939393939393939393939393939393939393"; + const char *csrf2 = + "9494949494949494949494949494949494949494949494949494949494949494"; + const char *token3 = + "9595959595959595959595959595959595959595959595959595959595959595"; + const char *csrf3 = + "9696969696969696969696969696969696969696969696969696969696969696"; + int64 now = 1820000000LL; + Auth_Session_Record session; + Auth_Session_Record found_session; + Auth_User_Record found_user; + + assert(Auth_Store_Create_Session( + p_store, user_id, token1, csrf1, 3600, 86400, now, &session) == + AUTH_STORE_OK); + assert(Auth_Store_Enable_User(p_store, user_id, actor_id) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session( + p_store, token1, now + 1, &found_session, &found_user) == AUTH_STORE_OK); + + assert(Auth_Store_Disable_User_And_Revoke_Sessions( + p_store, user_id, actor_id) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session( + p_store, token1, now + 2, &found_session, &found_user) == + AUTH_STORE_REVOKED); + assert(Auth_Store_Enable_User(p_store, user_id, actor_id) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session( + p_store, token1, now + 3, &found_session, &found_user) == + AUTH_STORE_REVOKED); + + assert(Auth_Store_Create_Session( + p_store, user_id, token2, csrf2, 3600, 86400, now + 4, &session) == + AUTH_STORE_OK); + assert(Auth_Store_Update_Role_And_Revoke_Sessions( + p_store, user_id, "admin", actor_id) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session( + p_store, token2, now + 5, &found_session, &found_user) == + AUTH_STORE_REVOKED); + assert(Auth_Store_Get_User(p_store, user_id, &found_user) == AUTH_STORE_OK); + assert(strcmp(found_user.role, "admin") == 0); + + assert(Auth_Store_Create_Session( + p_store, user_id, token3, csrf3, 3600, 86400, now + 6, &session) == + AUTH_STORE_OK); + assert(Auth_Store_Revoke_All_Sessions_Audited( + p_store, user_id, NULL, actor_id) == AUTH_STORE_OK); + assert(Auth_Store_Find_Session( + p_store, token3, now + 7, &found_session, &found_user) == + AUTH_STORE_REVOKED); + assert(Auth_Store_Enable_User( + p_store, "00000000-0000-4000-8000-000000000000", actor_id) == + AUTH_STORE_NOT_FOUND); + + puts("test_audited_admin_operations: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* 20. Audit failures roll back transactional mutations */ +/* ------------------------------------------------------------------ */ + +static void test_audit_failure_rollback( + Auth_Store *p_store, + const char *db_path) +{ + char reset_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + make_test_hash("audit-reset-password", reset_hash); + + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "AuditRollbackUser", get_test_hash(), "member", FALSE, + user_id) == AUTH_STORE_OK); + + const char *token = + "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"; + const char *csrf = + "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; + int64 now = 1830000000LL; + Auth_Session_Record session; + assert(Auth_Store_Create_Session( + p_store, user_id, token, csrf, 3600, 86400, now, &session) == + AUTH_STORE_OK); + + Deita_Connection *p_conn = Deita_Connection_Create( + DEITA_DATABASE_TYPE_SQLITE3, db_path); + assert(p_conn); + assert(Deita_Query_Execute_Update( + p_conn, "UPDATE users SET role = 'member' WHERE role = 'admin'") >= 0); + assert(Deita_Query_Execute_Update( + p_conn, + "CREATE TRIGGER fail_auth_audit BEFORE INSERT ON admin_audit_log" + " BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END") >= 0); + Deita_Connection_Close(p_conn); + + assert(Auth_Store_Set_Must_Change_Password( + p_store, user_id, TRUE, NULL) == AUTH_STORE_ERROR); + Auth_User_Record user; + assert(Auth_Store_Get_User(p_store, user_id, &user) == AUTH_STORE_OK); + assert(user.must_change_password == FALSE); + + Auth_Session_Record found_session; + Auth_User_Record found_user; + assert(Auth_Store_Update_User_Status( + p_store, user_id, "disabled", NULL) == AUTH_STORE_ERROR); + assert(Auth_Store_Get_User(p_store, user_id, &user) == AUTH_STORE_OK); + assert(strcmp(user.status, "active") == 0); + assert(Auth_Store_Find_Session( + p_store, token, now + 1, &found_session, &found_user) == AUTH_STORE_OK); + + assert(Auth_Store_Update_User_Role( + p_store, user_id, "admin", NULL) == AUTH_STORE_ERROR); + assert(Auth_Store_Get_User(p_store, user_id, &user) == AUTH_STORE_OK); + assert(strcmp(user.role, "member") == 0); + + assert(Auth_Store_Disable_User_And_Revoke_Sessions( + p_store, user_id, NULL) == AUTH_STORE_ERROR); + assert(Auth_Store_Update_Role_And_Revoke_Sessions( + p_store, user_id, "admin", NULL) == AUTH_STORE_ERROR); + assert(Auth_Store_Revoke_All_Sessions_Audited( + p_store, user_id, NULL, NULL) == AUTH_STORE_ERROR); + assert(Auth_Store_Find_Session( + p_store, token, now + 2, &found_session, &found_user) == AUTH_STORE_OK); + + assert(Auth_Store_Admin_Reset_Password( + p_store, user_id, reset_hash, NULL) == AUTH_STORE_ERROR); + Auth_User_Auth_Record auth_record; + assert(Auth_Store_Find_User_By_Username( + p_store, "AuditRollbackUser", &auth_record) == AUTH_STORE_OK); + assert(strcmp(auth_record.password_hash, get_test_hash()) == 0); + assert(auth_record.user.must_change_password == FALSE); + memset(&auth_record, 0, sizeof(auth_record)); + assert(Auth_Store_Find_Session( + p_store, token, now + 3, &found_session, &found_user) == AUTH_STORE_OK); + + Auth_Store_Bootstrap_Result bootstrap_result; + char bootstrap_id[37]; + assert(Auth_Store_Bootstrap_Admin( + p_store, "AuditBootstrapRollback", get_test_hash(), + &bootstrap_result, bootstrap_id) == AUTH_STORE_ERROR); + assert(Auth_Store_Find_User_By_Username( + p_store, "AuditBootstrapRollback", &auth_record) == AUTH_STORE_NOT_FOUND); + + assert(Auth_Store_Insert_Audit_Log( + p_store, NULL, "forced_failure", user_id, NULL) == AUTH_STORE_ERROR); + + char failed_id[37]; + assert(Auth_Store_Create_User_Audited( + p_store, "AuditCreateRollback", get_test_hash(), "member", FALSE, + NULL, failed_id) == AUTH_STORE_ERROR); + assert(Auth_Store_Find_User_By_Username( + p_store, "AuditCreateRollback", &auth_record) == AUTH_STORE_NOT_FOUND); + + p_conn = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db_path); + assert(p_conn); + assert(Deita_Query_Execute_Update( + p_conn, "DROP TRIGGER fail_auth_audit") >= 0); + Deita_Connection_Close(p_conn); + + memset(reset_hash, 0, sizeof(reset_hash)); + puts("test_audit_failure_rollback: PASS"); +} + + +/* ------------------------------------------------------------------ */ +/* Guest quota tests */ +/* ------------------------------------------------------------------ */ + +/* Create a guest identity row (required as FK parent). */ +static void make_guest(Auth_Store *p_store, const char *guest_id) +{ + uint8 secret[AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES]; + memset(secret, 0xab, sizeof(secret)); + char ip_digest[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE]; + assert(Auth_Crypto_IP_Binding_Digest( + secret, sizeof(secret), "10.0.0.1", + ip_digest, sizeof(ip_digest)) == AUTH_CRYPTO_OK); + Auth_Guest_Identity_Record g; + assert(Auth_Store_Upsert_Guest_Identity( + p_store, guest_id, ip_digest, + 2000000000LL, &g) == AUTH_STORE_OK); +} + +static void test_guest_quota(Auth_Store *p_store) +{ + char guest_id[37]; + assert(test__make_uuid(guest_id)); + make_guest(p_store, guest_id); + + int64 window_start = 1700524800LL; /* UTC midnight 2023-11-21, used as window ID */ + int64 expires = (int64)time(NULL) + 7200LL; /* 2 h from now — always future */ + const int64 turns_limit = 3; + const int64 tokens_limit = 1000; + const int64 req_tokens = 200; + + /* 1. Initial usage is zero. */ + { + Auth_Store_Guest_Usage u; + assert(Auth_Store_Guest_Get_Usage( + p_store, guest_id, window_start, &u) == AUTH_STORE_OK); + assert(u.turns_used == 0); + assert(u.output_tokens_used == 0); + assert(u.output_tokens_reserved == 0); + } + puts(" guest_quota/initial_usage: PASS"); + + /* 2. Reserve 3 turns; fourth must be TURNS_EXHAUSTED. */ + { + char req1[37], req2[37], req3[37]; + assert(test__make_uuid(req1)); + assert(test__make_uuid(req2)); + assert(test__make_uuid(req3)); + assert(Auth_Store_Guest_Reserve( + p_store, guest_id, req1, window_start, + req_tokens, turns_limit, tokens_limit, + expires) == AUTH_STORE_GUEST_QUOTA_OK); + assert(Auth_Store_Guest_Reserve( + p_store, guest_id, req2, window_start, + req_tokens, turns_limit, tokens_limit, + expires) == AUTH_STORE_GUEST_QUOTA_OK); + assert(Auth_Store_Guest_Reserve( + p_store, guest_id, req3, window_start, + req_tokens, turns_limit, tokens_limit, + expires) == AUTH_STORE_GUEST_QUOTA_OK); + + /* Check state: 3 turns used, 600 tokens reserved. */ + Auth_Store_Guest_Usage u; + assert(Auth_Store_Guest_Get_Usage( + p_store, guest_id, window_start, &u) == AUTH_STORE_OK); + assert(u.turns_used == 3); + assert(u.output_tokens_used == 0); + assert(u.output_tokens_reserved == req_tokens * 3); + + /* Fourth reservation: turns exhausted. */ + char req4[37]; + assert(test__make_uuid(req4)); + assert(Auth_Store_Guest_Reserve( + p_store, guest_id, req4, window_start, + req_tokens, turns_limit, tokens_limit, + expires) == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED); + puts(" guest_quota/turn_exhaustion: PASS"); + + /* Release all 3 reservations (keeps turn charge). */ + assert(Auth_Store_Guest_Release(p_store, req1) == AUTH_STORE_OK); + assert(Auth_Store_Guest_Release(p_store, req2) == AUTH_STORE_OK); + assert(Auth_Store_Guest_Release(p_store, req3) == AUTH_STORE_OK); + + /* After release: turns still charged, tokens freed. */ + assert(Auth_Store_Guest_Get_Usage( + p_store, guest_id, window_start, &u) == AUTH_STORE_OK); + assert(u.turns_used == 3); + assert(u.output_tokens_reserved == 0); + puts(" guest_quota/release_retains_turns: PASS"); + } + + /* 3. Token exhaustion on a fresh window. */ + { + char guest2[37]; + assert(test__make_uuid(guest2)); + make_guest(p_store, guest2); + int64 win2 = window_start + 86400LL; + const int64 small_limit = 250; + + char r1[37], r2[37]; + assert(test__make_uuid(r1)); + assert(test__make_uuid(r2)); + + /* Reserve 200; only 250 total → second 200 would overflow. */ + assert(Auth_Store_Guest_Reserve( + p_store, guest2, r1, win2, + 200, 10, small_limit, expires) == AUTH_STORE_GUEST_QUOTA_OK); + assert(Auth_Store_Guest_Reserve( + p_store, guest2, r2, win2, + 200, 10, small_limit, expires) == + AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED); + puts(" guest_quota/token_exhaustion: PASS"); + + /* Reconcile r1 with 150 actual (< 200 reserved). */ + assert(Auth_Store_Guest_Reconcile(p_store, r1, 150) == AUTH_STORE_OK); + Auth_Store_Guest_Usage u; + assert(Auth_Store_Guest_Get_Usage( + p_store, guest2, win2, &u) == AUTH_STORE_OK); + assert(u.output_tokens_used == 150); + assert(u.output_tokens_reserved == 0); + puts(" guest_quota/reconcile_actual_lt_reserved: PASS"); + + /* Reconcile with actual > reserved and charge the provider's actual use. */ + char r3[37]; + assert(test__make_uuid(r3)); + assert(Auth_Store_Guest_Reserve( + p_store, guest2, r3, win2, + 50, 10, small_limit, expires) == AUTH_STORE_GUEST_QUOTA_OK); + assert(Auth_Store_Guest_Reconcile(p_store, r3, 9999) == AUTH_STORE_OK); + assert(Auth_Store_Guest_Get_Usage( + p_store, guest2, win2, &u) == AUTH_STORE_OK); + assert(u.output_tokens_used == 150 + 9999); + puts(" guest_quota/reconcile_oversize_actual: PASS"); + } + + /* 4. Idempotent reconcile/release. */ + { + char guest3[37]; + assert(test__make_uuid(guest3)); + make_guest(p_store, guest3); + int64 win3 = window_start + 2 * 86400LL; + char rid[37]; + assert(test__make_uuid(rid)); + assert(Auth_Store_Guest_Reserve( + p_store, guest3, rid, win3, + 100, 5, 500, expires) == AUTH_STORE_GUEST_QUOTA_OK); + assert(Auth_Store_Guest_Reconcile(p_store, rid, 80) == AUTH_STORE_OK); + /* Second reconcile on same request_id: idempotent OK. */ + assert(Auth_Store_Guest_Reconcile(p_store, rid, 80) == AUTH_STORE_OK); + /* Release after reconcile: idempotent OK. */ + assert(Auth_Store_Guest_Release(p_store, rid) == AUTH_STORE_OK); + puts(" guest_quota/idempotent_reconcile_release: PASS"); + } + + /* 5. Concurrent reservation invariant: two reservations, check totals. */ + { + char guest4[37]; + assert(test__make_uuid(guest4)); + make_guest(p_store, guest4); + int64 win4 = window_start + 3 * 86400LL; + char ra[37], rb[37]; + assert(test__make_uuid(ra)); + assert(test__make_uuid(rb)); + assert(Auth_Store_Guest_Reserve( + p_store, guest4, ra, win4, 300, 5, 500, expires) == + AUTH_STORE_GUEST_QUOTA_OK); + assert(Auth_Store_Guest_Reserve( + p_store, guest4, rb, win4, 300, 5, 500, expires) == + AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED); + /* 300 used, 300 reserved → 600 total, limit 500 → second fails. */ + Auth_Store_Guest_Usage u; + assert(Auth_Store_Guest_Get_Usage( + p_store, guest4, win4, &u) == AUTH_STORE_OK); + /* invariant: used + reserved <= limit */ + assert(u.output_tokens_used + u.output_tokens_reserved <= 500); + puts(" guest_quota/concurrent_invariant: PASS"); + assert(Auth_Store_Guest_Release(p_store, ra) == AUTH_STORE_OK); + } + + /* 6. Clear all reservations (login transfer). */ + { + char guest5[37]; + assert(test__make_uuid(guest5)); + make_guest(p_store, guest5); + int64 win5 = window_start + 4 * 86400LL; + char rc[37], rd[37]; + assert(test__make_uuid(rc)); + assert(test__make_uuid(rd)); + assert(Auth_Store_Guest_Reserve( + p_store, guest5, rc, win5, 100, 5, 500, expires) == + AUTH_STORE_GUEST_QUOTA_OK); + assert(Auth_Store_Guest_Reserve( + p_store, guest5, rd, win5, 100, 5, 500, expires) == + AUTH_STORE_GUEST_QUOTA_OK); + assert(Auth_Store_Guest_Clear_Reservations( + p_store, guest5) == AUTH_STORE_OK); + Auth_Store_Guest_Usage u; + assert(Auth_Store_Guest_Get_Usage( + p_store, guest5, win5, &u) == AUTH_STORE_OK); + assert(u.output_tokens_reserved == 0); + assert(u.turns_used == 2); /* turns kept */ + puts(" guest_quota/clear_reservations: PASS"); + } + + /* 7. UTC rollover: windows are independent. */ + { + char guest6[37]; + assert(test__make_uuid(guest6)); + make_guest(p_store, guest6); + int64 winA = window_start + 5 * 86400LL; + int64 winB = winA + 86400LL; + char re[37], rf[37]; + assert(test__make_uuid(re)); + assert(test__make_uuid(rf)); + assert(Auth_Store_Guest_Reserve( + p_store, guest6, re, winA, 100, 2, 200, expires) == + AUTH_STORE_GUEST_QUOTA_OK); + assert(Auth_Store_Guest_Reserve( + p_store, guest6, rf, winA, 100, 2, 200, expires) == + AUTH_STORE_GUEST_QUOTA_OK); + /* winA full; winB is a fresh window. */ + char rg[37]; + assert(test__make_uuid(rg)); + assert(Auth_Store_Guest_Reserve( + p_store, guest6, rg, winB, 100, 2, 200, expires) == + AUTH_STORE_GUEST_QUOTA_OK); + Auth_Store_Guest_Usage uB; + assert(Auth_Store_Guest_Get_Usage( + p_store, guest6, winB, &uB) == AUTH_STORE_OK); + assert(uB.turns_used == 1); + puts(" guest_quota/utc_rollover: PASS"); + assert(Auth_Store_Guest_Release(p_store, re) == AUTH_STORE_OK); + assert(Auth_Store_Guest_Release(p_store, rf) == AUTH_STORE_OK); + assert(Auth_Store_Guest_Release(p_store, rg) == AUTH_STORE_OK); + } + + puts("test_guest_quota: PASS"); +}/* ------------------------------------------------------------------ */ +/* 19. Migration v2 preserves legacy guest_usage.count in turns_used */ +/* ------------------------------------------------------------------ */ + +/* + * Build a genuine v1-only database using a raw Deita connection (no + * Auth_Store_Create) so that the v2 migration has not yet run. Insert + * a legacy guest_usage row with count=7 and no turns_used column, then + * open the database through Auth_Store_Create — which applies v2 — + * and verify that turns_used is backfilled from count. + */ +static void test_migration_v2_legacy_backfill(const char *db_path) +{ + (void)db_path; /* we use our own temp file */ + + char legacy_db[] = "/tmp/zenbu-auth-legacy-XXXXXX"; + int fd = mkstemp(legacy_db); + assert(fd >= 0); + close(fd); + + /* Build the v1-only schema directly. */ + Deita_Connection *p_conn = Deita_Connection_Create( + DEITA_DATABASE_TYPE_SQLITE3, legacy_db); + assert(p_conn); + Deita_Query_Execute_Update(p_conn, + "PRAGMA foreign_keys = OFF;" + "PRAGMA journal_mode = WAL;"); + + /* auth_schema_migrations ledger */ + Deita_Query_Execute_Update(p_conn, + "CREATE TABLE IF NOT EXISTS auth_schema_migrations (" + " version INTEGER PRIMARY KEY," + " applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" + ");"); + + /* v1 tables (condensed: only what the migration test needs) */ + Deita_Query_Execute_Update(p_conn, + "CREATE TABLE IF NOT EXISTS guest_identities (" + " id TEXT PRIMARY KEY," + " ip_binding_digest TEXT NOT NULL," + " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," + " last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," + " expires_at INTEGER NOT NULL" + ");"); + Deita_Query_Execute_Update(p_conn, + /* v1 guest_usage: count only, no turns_used/token columns */ + "CREATE TABLE IF NOT EXISTS guest_usage (" + " guest_id TEXT NOT NULL" + " REFERENCES guest_identities(id) ON DELETE CASCADE," + " window_start INTEGER NOT NULL," + " count INTEGER NOT NULL DEFAULT 0," + " PRIMARY KEY (guest_id, window_start)" + ");"); + Deita_Query_Execute_Update(p_conn, + /* v1 reservations: no request_id, no token count */ + "CREATE TABLE IF NOT EXISTS guest_usage_reservations (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " guest_id TEXT NOT NULL" + " REFERENCES guest_identities(id) ON DELETE CASCADE," + " reserved_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," + " expires_at INTEGER NOT NULL" + ");"); + /* Stub other tables so FK checks don't break */ + Deita_Query_Execute_Update(p_conn, + "CREATE TABLE IF NOT EXISTS users (" + " id TEXT PRIMARY KEY," + " username TEXT NOT NULL," + " normalized_username TEXT NOT NULL UNIQUE," + " password_hash TEXT NOT NULL," + " role TEXT NOT NULL," + " status TEXT NOT NULL DEFAULT 'active'," + " must_change_password INTEGER NOT NULL DEFAULT 0," + " password_changed_at INTEGER NOT NULL DEFAULT 0," + " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," + " updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" + ");"); + Deita_Query_Execute_Update(p_conn, + "CREATE TABLE IF NOT EXISTS auth_sessions (" + " token_digest TEXT PRIMARY KEY," + " csrf_digest TEXT NOT NULL," + " user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE," + " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," + " last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," + " idle_expires_at INTEGER NOT NULL," + " absolute_expires_at INTEGER NOT NULL," + " password_changed_at_snapshot INTEGER NOT NULL DEFAULT 0," + " revoked_at INTEGER" + ");"); + Deita_Query_Execute_Update(p_conn, + "CREATE TABLE IF NOT EXISTS admin_audit_log (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " actor_user_id TEXT," + " action TEXT NOT NULL," + " target_user_id TEXT," + " detail TEXT," + " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" + ");"); + + /* Mark v1 as applied; v2 is intentionally absent. */ + Deita_Query_Execute_Update(p_conn, + "INSERT OR IGNORE INTO auth_schema_migrations (version) VALUES (1);"); + + /* Insert a guest identity and a legacy usage row with count=7. */ + char g_id[37]; + assert(test__make_uuid(g_id)); + const char *gid_p[] = {g_id}; + Deita_Query_Execute_Update_Prepared(p_conn, + "INSERT INTO guest_identities (id, ip_binding_digest, expires_at)" + " VALUES (?, 'test-digest', 9999999999)", + 1, gid_p); + int64 win = 1700524800LL; + char win_str[32]; + snprintf(win_str, sizeof(win_str), "%lld", (long long)win); + const char *gu_p[] = {g_id, win_str}; + /* count = 7 in the legacy column */ + Deita_Query_Execute_Update_Prepared(p_conn, + "INSERT INTO guest_usage (guest_id, window_start, count) VALUES (?, ?, 7)", + 2, gu_p); + Deita_Connection_Close(p_conn); + + /* Open through Auth_Store_Create: should apply v2 (backfill turns_used). */ + Auth_Store *p_store = Auth_Store_Create(legacy_db); + assert(p_store); + + Auth_Store_Guest_Usage u; + assert(Auth_Store_Guest_Get_Usage( + p_store, g_id, win, &u) == AUTH_STORE_OK); + assert(u.turns_used == 7); /* backfilled from count */ + puts("test_migration_v2_legacy_backfill: PASS"); + + Auth_Store_Destroy(p_store); + unlink(legacy_db); +} + +/* ------------------------------------------------------------------ */ +/* 20. Expired reservation reaping */ +/* ------------------------------------------------------------------ */ + +static void test_reap_expired_reservations(Auth_Store *p_store) +{ + char gid[37]; + assert(test__make_uuid(gid)); + make_guest(p_store, gid); + + int64 now = (int64)time(NULL); + /* Use a window far in the future so it can never collide with clock-based + * reaping inside Auth_Store_Guest_Reserve. */ + int64 win = now + 86400LL; /* tomorrow's window */ + /* "past" and "future" are relative to now_sim (= now + 3600), but both + * must be > now so the internal Reserve reap does not touch them. */ + int64 past = now + 1800LL; /* expires in 30 min: past from now_sim */ + int64 future = now + 7200LL; /* expires in 2 h: future from now_sim */ + int64 now_sim = now + 3600LL; /* simulated "now": 1 h from now */ + + char ra[37], rb[37]; + assert(test__make_uuid(ra)); + assert(test__make_uuid(rb)); + + /* Reserve two turns: ra expires before now_sim, rb expires after. */ + assert(Auth_Store_Guest_Reserve( + p_store, gid, ra, win, 100, 10, 1000, past) == + AUTH_STORE_GUEST_QUOTA_OK); + assert(Auth_Store_Guest_Reserve( + p_store, gid, rb, win, 200, 10, 1000, future) == + AUTH_STORE_GUEST_QUOTA_OK); + + Auth_Store_Guest_Usage u; + assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK); + assert(u.turns_used == 2); + assert(u.output_tokens_reserved == 300); /* 100 + 200 */ + + /* Reap with now_sim > past but < future: only ra should be reaped. */ + assert(Auth_Store_Guest_Reap_Expired(p_store, now_sim) == AUTH_STORE_OK); + + assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK); + assert(u.turns_used == 2); /* turns retained */ + assert(u.output_tokens_reserved == 200); /* only ra's 100 removed */ + puts(" reap/partial: PASS"); + + /* Idempotent: reaping again changes nothing. */ + assert(Auth_Store_Guest_Reap_Expired(p_store, now_sim) == AUTH_STORE_OK); + assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK); + assert(u.output_tokens_reserved == 200); + puts(" reap/idempotent: PASS"); + + /* Reap with future time: rb is now expired too. */ + assert(Auth_Store_Guest_Reap_Expired(p_store, future + 1) == AUTH_STORE_OK); + assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK); + assert(u.output_tokens_reserved == 0); + assert(u.turns_used == 2); + puts(" reap/all_expired: PASS"); + + /* Release on an already-reaped reservation: idempotent OK. */ + assert(Auth_Store_Guest_Release(p_store, ra) == AUTH_STORE_OK); + assert(Auth_Store_Guest_Release(p_store, rb) == AUTH_STORE_OK); + puts(" reap/release_after_reap: PASS"); + + puts("test_reap_expired_reservations: PASS"); +} + +/* ------------------------------------------------------------------ */ +/* Main */ +/* ------------------------------------------------------------------ */ + +int main(void) +{ + char db_path[] = "/tmp/zenbu-auth-XXXXXX"; + int fd = mkstemp(db_path); + assert(fd >= 0); + close(fd); + + test_migrations_idempotent(db_path); + test_migration_v2_legacy_backfill(db_path); + + Auth_Store *p_store = Auth_Store_Create(db_path); + assert(p_store); + + test_username_normalization(); + + /* Bootstrap and last-admin tests must run first (only one admin). */ + test_bootstrap_admin(p_store); + test_last_admin_protection(p_store); + + test_username_uniqueness(p_store); + test_user_lookup(p_store); + test_forced_password_flag(p_store); + test_session_lifecycle(p_store); + test_stale_password_snapshot(p_store); + test_disabled_user(p_store); + test_password_update_revokes_others(p_store); + test_revoke_all_sessions(p_store); + test_expired_session(p_store); + test_guest_identity(p_store, db_path); + test_rotate_session(p_store); + test_create_session_cas(p_store); + test_self_change_password(p_store); + test_audited_admin_operations(p_store, db_path); + test_audit_failure_rollback(p_store, db_path); + test_guest_quota(p_store); + test_reap_expired_reservations(p_store); + + Auth_Store_Destroy(p_store); + unlink(db_path); + + puts("auth_store_test: ALL PASS"); + return 0; +}