/* * test_aud_pipeline.c — unit tests for the full aud claim pipeline. * * Tests the complete payload_json → json_get_aud → aud_contains path. * This test MUST FAIL against rev1 code (before §2.2.4-FIX) because * json_get_string bails on '[' and the array branch of aud_contains * is never reached. It MUST PASS after §2.2.4-FIX (json_get_aud). * * 8 cases per §2.2.4-FIX table. * See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.2.4-FIX */ #include "test_helpers.h" /* ── Inline copies of the functions under test ───────────────────────────── */ /* * json_get_aud — copied verbatim from pg_knoe_auth.c so we can test it * without linking against PostgreSQL. */ static char * json_get_aud(const char *json) { char search[16]; const char *p, *start; int depth; snprintf(search, sizeof(search), "\"aud\""); p = strstr(json, search); if (!p) return NULL; p += strlen(search); while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; if (*p != ':') return NULL; p++; while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; if (*p == '"') { /* String form — return content between quotes */ p++; start = p; while (*p && *p != '"') { if (*p == '\\') p++; if (*p) p++; } return pnstrdup(start, (size_t)(p - start)); } if (*p == '[') { /* Array form — return [...] verbatim including brackets */ start = p; depth = 1; p++; while (*p && depth > 0) { if (*p == '"') { p++; while (*p && *p != '"') { if (*p == '\\') p++; if (*p) p++; } if (*p == '"') p++; } else if (*p == '[') { depth++; p++; } else if (*p == ']') { depth--; p++; } else { p++; } } return pnstrdup(start, (size_t)(p - start)); } /* Unknown shape (number, null, etc.) — caller treats as missing. */ return NULL; } /* * aud_contains — copied verbatim from pg_knoe_auth.c. */ static bool aud_contains(const char *aud_value, const char *expected) { if (!aud_value || !expected) return false; /* Array form: ["val1","val2",...] */ if (aud_value[0] == '[') { const char *p = aud_value + 1; while (*p) { const char *elem_start, *elem_end; size_t elem_len; while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++; if (*p == ']' || *p == '\0') break; if (*p != '"') break; p++; elem_start = p; while (*p && *p != '"') { if (*p == '\\') p++; if (*p) p++; } elem_end = p; if (*p == '"') p++; elem_len = (size_t)(elem_end - elem_start); if (elem_len == strlen(expected) && strncmp(elem_start, expected, elem_len) == 0) return true; } return false; } /* String form: exact equality */ return strcmp(aud_value, expected) == 0; } /* ── Helper: run the full pipeline ──────────────────────────────────────── */ static bool pipeline(const char *payload_json, const char *expected_aud) { char *claim_aud = json_get_aud(payload_json); bool result = aud_contains(claim_aud, expected_aud); if (claim_aud) pfree(claim_aud); return result; } int main(void) { /* Case 1: string form — exact match → accept */ ASSERT( pipeline("{\"iss\":\"https://auth.0.knoe.dev\",\"aud\":\"pg.0.knoe.dev\",\"sub\":\"u1\"}", "pg.0.knoe.dev"), "string aud exact match passes"); /* Case 2: array form, single element matching → accept (THE KEY BUG CASE) */ ASSERT( pipeline("{\"iss\":\"https://auth.0.knoe.dev\",\"aud\":[\"pg.0.knoe.dev\"],\"sub\":\"u1\"}", "pg.0.knoe.dev"), "array aud single element match passes"); /* Case 3: array form, multiple elements, one matches → accept */ ASSERT( pipeline("{\"aud\":[\"other\",\"pg.0.knoe.dev\",\"more\"]}", "pg.0.knoe.dev"), "array aud multi-element match passes"); /* Case 4: array form, no element matches → reject */ ASSERT(!pipeline("{\"aud\":[\"other\",\"also-other\"]}", "pg.0.knoe.dev"), "array aud no match is rejected"); /* Case 5: string form — substring bypass attempt → reject */ ASSERT(!pipeline("{\"aud\":\"evil.pg.0.knoe.dev\"}", "pg.0.knoe.dev"), "string aud substring bypass is rejected"); /* Case 6: array form — substring bypass attempt → reject */ ASSERT(!pipeline("{\"aud\":[\"evil.pg.0.knoe.dev\"]}", "pg.0.knoe.dev"), "array aud substring bypass is rejected"); /* Case 7: numeric aud (e.g. aud:42) — json_get_aud returns NULL → reject */ ASSERT(!pipeline("{\"aud\":42,\"sub\":\"u1\"}", "pg.0.knoe.dev"), "numeric aud is rejected"); /* Case 8: missing aud claim entirely → reject */ ASSERT(!pipeline("{\"iss\":\"https://auth.0.knoe.dev\",\"sub\":\"u1\",\"exp\":9999999999}", "pg.0.knoe.dev"), "missing aud claim is rejected"); TEST_SUMMARY(); }