/* * test_jwks_cache.c — unit tests for H2: JWKS process-local cache. * * Tests cache hit returns same pkey ptr, miss triggers fetch path, * stale fallback logic, and eviction at JWK_CACHE_MAX entries. * See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.2.2 */ #include "test_helpers.h" /* Suppress OpenSSL 3.x deprecation warnings for RSA legacy API used in test stubs only */ #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" #include #include #include #pragma GCC diagnostic pop #include /* ── Inline cache implementation (same as pg_knoe_auth.c) ─────────────────── */ #define JWK_CACHE_MAX 32 #define JWK_CACHE_TTL_SECS 600 #define JWK_CACHE_STALE_SECS 86400 typedef struct { char *issuer; char *kid; EVP_PKEY *pkey; time_t fetched_at; } JwkCacheEntry; static JwkCacheEntry jwk_cache[JWK_CACHE_MAX]; static int jwk_cache_n = 0; static int jwk_cache_find(const char *issuer, const char *kid) { for (int i = 0; i < jwk_cache_n; i++) { if (strcmp(jwk_cache[i].issuer, issuer) != 0) continue; if (kid == NULL && jwk_cache[i].kid == NULL) return i; if (kid != NULL && jwk_cache[i].kid != NULL && strcmp(jwk_cache[i].kid, kid) == 0) return i; } return -1; } static void jwk_cache_store(const char *issuer, const char *kid, EVP_PKEY *pkey) { int idx = jwk_cache_find(issuer, kid); if (idx < 0) { if (jwk_cache_n < JWK_CACHE_MAX) { idx = jwk_cache_n++; } else { idx = 0; free(jwk_cache[0].issuer); free(jwk_cache[0].kid); EVP_PKEY_free(jwk_cache[0].pkey); memmove(&jwk_cache[0], &jwk_cache[1], (JWK_CACHE_MAX - 1) * sizeof(JwkCacheEntry)); jwk_cache_n = JWK_CACHE_MAX - 1; idx = jwk_cache_n++; } } else { free(jwk_cache[idx].issuer); free(jwk_cache[idx].kid); EVP_PKEY_free(jwk_cache[idx].pkey); } jwk_cache[idx].issuer = strdup(issuer); jwk_cache[idx].kid = kid ? strdup(kid) : NULL; jwk_cache[idx].pkey = pkey; jwk_cache[idx].fetched_at = time(NULL); } /* Reset cache between tests */ static void cache_reset(void) { for (int i = 0; i < jwk_cache_n; i++) { free(jwk_cache[i].issuer); free(jwk_cache[i].kid); EVP_PKEY_free(jwk_cache[i].pkey); } jwk_cache_n = 0; } /* Make a throwaway RSA EVP_PKEY for testing (uses legacy RSA API — suppress deprecation) */ #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" static EVP_PKEY *make_test_pkey(void) { RSA *rsa = RSA_new(); BIGNUM *bn_n = BN_new(); BIGNUM *bn_e = BN_new(); EVP_PKEY *pkey = EVP_PKEY_new(); BN_set_word(bn_n, 0xDEADBEEF); BN_set_word(bn_e, 65537); RSA_set0_key(rsa, bn_n, bn_e, NULL); EVP_PKEY_assign_RSA(pkey, rsa); return pkey; } #pragma GCC diagnostic pop int main(void) { const char *issuer = "https://api.0.knoe.dev/auth"; const char *kid1 = "key-001"; const char *kid2 = "key-002"; /* ── Test 1: cache miss returns -1 ─────────────────────────────────── */ cache_reset(); ASSERT(jwk_cache_find(issuer, kid1) == -1, "cache miss returns -1 on empty cache"); /* ── Test 2: store + hit returns same index ─────────────────────────── */ EVP_PKEY *pk1 = make_test_pkey(); jwk_cache_store(issuer, kid1, pk1); int idx = jwk_cache_find(issuer, kid1); ASSERT(idx >= 0, "cache hit after store returns valid index"); ASSERT(jwk_cache[idx].pkey == pk1, "cache hit returns the same pkey pointer"); /* ── Test 3: different kid is a miss ────────────────────────────────── */ ASSERT(jwk_cache_find(issuer, kid2) == -1, "different kid is a cache miss"); /* ── Test 4: overwrite same (issuer, kid) updates fetched_at ────────── */ time_t before = time(NULL); EVP_PKEY *pk1b = make_test_pkey(); jwk_cache_store(issuer, kid1, pk1b); idx = jwk_cache_find(issuer, kid1); ASSERT(idx >= 0 && jwk_cache[idx].pkey == pk1b, "overwrite same key updates pkey pointer"); ASSERT(jwk_cache[idx].fetched_at >= before, "overwrite updates fetched_at"); /* ── Test 5: stale detection — age >= TTL ───────────────────────────── */ jwk_cache[idx].fetched_at = time(NULL) - JWK_CACHE_TTL_SECS - 1; time_t age = time(NULL) - jwk_cache[idx].fetched_at; ASSERT(age >= JWK_CACHE_TTL_SECS, "entry with age >= TTL is detected as stale"); /* ── Test 6: stale but within STALE_SECS — fallback window open ─────── */ jwk_cache[idx].fetched_at = time(NULL) - JWK_CACHE_TTL_SECS - 1; age = time(NULL) - jwk_cache[idx].fetched_at; ASSERT(age < JWK_CACHE_STALE_SECS, "stale entry within 24h stale window is usable as fallback"); /* ── Test 7: eviction at JWK_CACHE_MAX ──────────────────────────────── */ cache_reset(); for (int i = 0; i < JWK_CACHE_MAX; i++) { char kid_buf[32]; snprintf(kid_buf, sizeof(kid_buf), "kid-%03d", i); jwk_cache_store(issuer, kid_buf, make_test_pkey()); } ASSERT(jwk_cache_n == JWK_CACHE_MAX, "cache fills to JWK_CACHE_MAX"); /* Adding one more should evict the oldest (kid-000) */ jwk_cache_store(issuer, "kid-new", make_test_pkey()); ASSERT(jwk_cache_n == JWK_CACHE_MAX, "cache stays at JWK_CACHE_MAX after eviction"); ASSERT(jwk_cache_find(issuer, "kid-000") == -1, "oldest entry (kid-000) was evicted"); ASSERT(jwk_cache_find(issuer, "kid-new") >= 0, "new entry is present after eviction"); cache_reset(); TEST_SUMMARY(); }