|
| 1 | +using NpgsqlRestTests.Setup; |
| 2 | + |
| 3 | +namespace NpgsqlRestTests; |
| 4 | + |
| 5 | +public static partial class Database |
| 6 | +{ |
| 7 | + /// <summary> |
| 8 | + /// Setup for CacheKeyClaimMappedParamTest. |
| 9 | + /// |
| 10 | + /// Creates a function that takes `_user_id text` and returns "<user_id>:<random uuid>". |
| 11 | + /// The endpoint uses: |
| 12 | + /// - `user_parameters` → enables auto-population of `_user_id` from the `name_identifier` claim |
| 13 | + /// (per fixture's `ParameterNameClaimsMapping` mapping `_user_id` → `name_identifier`). |
| 14 | + /// - `cached _user_id` → caches the response, keyed by the resolved `_user_id` value. |
| 15 | + /// - `authorize` → requires authenticated user. |
| 16 | + /// |
| 17 | + /// `gen_random_uuid()` makes each fresh execution distinct. So: |
| 18 | + /// - cache hit → both calls return the same response (same UUID). |
| 19 | + /// - cache miss → second call returns a different UUID. |
| 20 | + /// We use this to detect both whether the cache works AND whether two different users get |
| 21 | + /// separate cache entries (which only happens if the resolved claim value is part of the key). |
| 22 | + /// </summary> |
| 23 | + public static void CacheKeyClaimMappedParamTest() |
| 24 | + { |
| 25 | + script.Append(@" |
| 26 | + create function cct_get_cached_per_user(_user_id text) |
| 27 | + returns text language plpgsql as $$ |
| 28 | + begin |
| 29 | + return _user_id || ':' || gen_random_uuid()::text; |
| 30 | + end; |
| 31 | + $$; |
| 32 | + comment on function cct_get_cached_per_user(text) is ' |
| 33 | + HTTP GET |
| 34 | + authorize |
| 35 | + user_parameters |
| 36 | + cached _user_id |
| 37 | + '; |
| 38 | + "); |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +[Collection("CacheClaimTestFixture")] |
| 43 | +public class CacheKeyClaimMappedParamTest(CacheClaimTestFixture test) |
| 44 | +{ |
| 45 | + /// <summary> |
| 46 | + /// Regression test: when a parameter is auto-populated from a claim (via |
| 47 | + /// `ParameterNameClaimsMapping` + `user_parameters` annotation), the cache key must use the |
| 48 | + /// resolved post-claim value — NOT the request-time null. Otherwise two different users |
| 49 | + /// would share a single cache entry, leaking responses across identities. |
| 50 | + /// |
| 51 | + /// Verifies four things in one test: |
| 52 | + /// 1. user_a's first call populates the cache and returns a fresh response. |
| 53 | + /// 2. user_a's second call hits the cache (identical response — same UUID). |
| 54 | + /// 3. user_b's first call does NOT hit user_a's entry (different starts: "user_b:" vs "user_a:") |
| 55 | + /// and is itself cached. |
| 56 | + /// 4. user_a's third call still hits user_a's original entry (separate from user_b's). |
| 57 | + /// |
| 58 | + /// If step 3 returns user_a's value, the cache key was built before claim resolution and the |
| 59 | + /// cache is leaking across identities — a security bug. Test catches this. |
| 60 | + /// </summary> |
| 61 | + [Fact] |
| 62 | + public async Task Cache_key_uses_claim_resolved_user_id_so_different_users_get_separate_entries() |
| 63 | + { |
| 64 | + using var clientA = test.CreateClient(); |
| 65 | + using var clientB = test.CreateClient(); |
| 66 | + |
| 67 | + // user_a logs in |
| 68 | + using var loginA = await clientA.GetAsync("/cct-login-a"); |
| 69 | + loginA.StatusCode.Should().Be(HttpStatusCode.OK); |
| 70 | + |
| 71 | + // user_b logs in (separate cookie container — independent identity) |
| 72 | + using var loginB = await clientB.GetAsync("/cct-login-b"); |
| 73 | + loginB.StatusCode.Should().Be(HttpStatusCode.OK); |
| 74 | + |
| 75 | + // user_a — first call, populates cache |
| 76 | + using var responseA1 = await clientA.GetAsync("/api/cct-get-cached-per-user"); |
| 77 | + responseA1.StatusCode.Should().Be(HttpStatusCode.OK); |
| 78 | + var bodyA1 = await responseA1.Content.ReadAsStringAsync(); |
| 79 | + bodyA1.Should().StartWith("user_a:"); |
| 80 | + |
| 81 | + // user_a — second call, must hit cache (same exact response, including UUID) |
| 82 | + using var responseA2 = await clientA.GetAsync("/api/cct-get-cached-per-user"); |
| 83 | + responseA2.StatusCode.Should().Be(HttpStatusCode.OK); |
| 84 | + var bodyA2 = await responseA2.Content.ReadAsStringAsync(); |
| 85 | + bodyA2.Should().Be(bodyA1); |
| 86 | + |
| 87 | + // user_b — first call. If the cache key was built without the claim value (i.e. with null), |
| 88 | + // user_b would hit user_a's cached entry and we'd see "user_a:..." here. We assert otherwise. |
| 89 | + using var responseB1 = await clientB.GetAsync("/api/cct-get-cached-per-user"); |
| 90 | + responseB1.StatusCode.Should().Be(HttpStatusCode.OK); |
| 91 | + var bodyB1 = await responseB1.Content.ReadAsStringAsync(); |
| 92 | + bodyB1.Should().StartWith("user_b:"); |
| 93 | + bodyB1.Should().NotBe(bodyA1); |
| 94 | + |
| 95 | + // user_b — second call hits user_b's own cache entry |
| 96 | + using var responseB2 = await clientB.GetAsync("/api/cct-get-cached-per-user"); |
| 97 | + responseB2.StatusCode.Should().Be(HttpStatusCode.OK); |
| 98 | + var bodyB2 = await responseB2.Content.ReadAsStringAsync(); |
| 99 | + bodyB2.Should().Be(bodyB1); |
| 100 | + |
| 101 | + // user_a — third call still returns the original cached value, separate from user_b's |
| 102 | + using var responseA3 = await clientA.GetAsync("/api/cct-get-cached-per-user"); |
| 103 | + responseA3.StatusCode.Should().Be(HttpStatusCode.OK); |
| 104 | + var bodyA3 = await responseA3.Content.ReadAsStringAsync(); |
| 105 | + bodyA3.Should().Be(bodyA1); |
| 106 | + } |
| 107 | +} |
0 commit comments