From 415c967edc9fe49252e6e210de00ad29acb59864 Mon Sep 17 00:00:00 2001 From: Voyvodka Date: Mon, 11 May 2026 10:56:23 +0300 Subject: [PATCH] test(portal): close v0.2.0 audit coverage gaps (23 new tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the test coverage that was deferred during the rapid B1 portal merge sequence. Brings the portal stack from 'core acceptance paths only' to 'every security-load-bearing branch covered'. PortalEndpointsControllerTests (+5): - Empty capabilities claim must yield 403 (silent regression guard against a future refactor that treats missing as wildcard). - Cross-tenant DELETE / enable / disable / test / attempts all return 404 PORTAL_NOT_FOUND. Previously only GET and PUT were guarded; every other route was its own untested CAS surface. - Test route additionally asserts the EndpointTester fake never gets invoked, so the 404 precedes any outbound dispatch. PortalCorsMiddlewareTests (new, +7): - Preflight with allowed / disallowed / subdomain-spoofed origin. - RFC 6454 case-insensitive host match. - Missing Origin falls through to next() with no CORS interference. - Real-request CORS-header echo (allowed) and non-echo (disallowed). - Stands up the production middleware ordering (PortalTokenAuth → PortalCors) in a minimal pipeline. PortalLookupCacheTests (new, +5): - Portal-not-enabled returns null. - Cache hit survives DB mutation (proves cache is in the loop). - InvalidateApplication forces DB reload on next GetAsync. - 64-way concurrent InvalidateApplication doesn't throw ObjectDisposedException (regression for the audit fix where Set used to GetOrAdd-reuse a CTS). PortalOriginsAllowlistE2ETests (new, +7, Testcontainers): - Exercises AnyAllowsPortalOriginAsync against real PostgreSQL JSONB. - Exact match, RFC 6454 case-insensitive, portal-disabled apps excluded, no-match, empty array, blank-origin Theory. - Documents in-line why the malformed-JSON catch isn't reachable through the EF write surface (PostgreSQL rejects invalid JSON at INSERT into JSONB). --- CHANGELOG.md | 3 + .../Portal/PortalCorsMiddlewareTests.cs | 226 ++++++++++++++++++ .../Portal/PortalEndpointsControllerTests.cs | 131 ++++++++++ .../PortalOriginsAllowlistE2ETests.cs | 144 +++++++++++ .../Services/PortalLookupCacheTests.cs | 161 +++++++++++++ 5 files changed, 665 insertions(+) create mode 100644 tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs create mode 100644 tests/WebhookEngine.Infrastructure.Tests/EndToEnd/PortalOriginsAllowlistE2ETests.cs create mode 100644 tests/WebhookEngine.Infrastructure.Tests/Services/PortalLookupCacheTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e452f..2bb42f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added +- **Portal stack test coverage — 23 new tests closing the v0.2.0 audit gaps.** New `PortalCorsMiddlewareTests` (7 facts) covers preflight allow / reject / case-insensitive match / subdomain-spoofing reject / missing-Origin pass-through and the real-request CORS-header echo path — previously zero coverage. New `PortalLookupCacheTests` (5 facts) pins TTL hit, invalidation forces DB reload, portal-disabled returns null, and concurrent-Invalidate doesn't double-dispose (regression for the audit fix). New `PortalOriginsAllowlistE2ETests` (7 facts, Testcontainers) runs `ApplicationRepository.AnyAllowsPortalOriginAsync` against real PostgreSQL JSONB. `PortalEndpointsControllerTests` gains four cross-tenant guards (`DELETE`, `/enable`, `/disable`, `/test`, `/attempts` against another tenant's endpoint id all return `404 PORTAL_NOT_FOUND`) and a defense-in-depth test for empty-capabilities tokens (the absence of any `capabilities` claim must surface as `403 PORTAL_INSUFFICIENT_CAPABILITY`, not silent full access). + ### Security - **Portal stack hardening — three post-merge fixes from the v0.2.0 audit.** (1) `PortalEndpointsController` now carries `[EnableRateLimiting("send-by-appid")]` at the controller level; previously a leaked or misbehaving portal token could spam the mutating routes (notably `/test`, which fires a real outbound HTTP POST) without sharing the per-tenant token-bucket budget that the public API has always enforced. (2) `PortalTokenAuthMiddleware` switches from a static `JwtSecurityTokenHandler` to a per-instance handler with `MapInboundClaims = false` and `MaximumTokenSizeInBytes = PortalAuthOptions.MaxTokenSizeBytes` (default 8 KiB, down from the .NET default ~250 KiB); a 16 KiB Bearer payload is now rejected before the JWT parser runs, killing a DoS amplification path. (3) `PortalLookupCache.Set` now atomically swaps the per-app `CancellationTokenSource` via `AddOrUpdate` and cancels-and-disposes the previous one, instead of `GetOrAdd`-reusing it; this closes a window where a `Set` racing an `InvalidateApplication` could bind a fresh cache entry to a disposed token. New `Portal_Request_With_Oversized_Token_Returns_401_Without_Parsing` regression test pins the size cap. diff --git a/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs b/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs new file mode 100644 index 0000000..7a73abd --- /dev/null +++ b/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs @@ -0,0 +1,226 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using WebhookEngine.API.Middleware; +using WebhookEngine.Core.Entities; +using WebhookEngine.Infrastructure.Data; + +namespace WebhookEngine.API.Tests.Portal; + +/// +/// Contract tests for PortalCorsMiddleware. Stand up the real production +/// middleware in a minimal pipeline (PortalTokenAuth → PortalCors → terminal +/// pong) so the ordering invariant the production code documents is exercised +/// here too. Allowed-origin matching, preflight rejection of subdomain spoofing, +/// and the "no Origin → no CORS interference" branch are the security-load-bearing +/// behaviours that previously had zero coverage. +/// +public class PortalCorsMiddlewareTests : IClassFixture +{ + private const string PingPath = "/api/v1/portal/_ping"; + private const string AllowedOrigin = "https://app.example.com"; + private const string DisallowedOrigin = "https://attacker.example"; + + private readonly CorsMiddlewareFactory _factory; + + public PortalCorsMiddlewareTests(CorsMiddlewareFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Preflight_With_Allowed_Origin_Returns_204_And_Cors_Headers() + { + await SeedAppAsync(allowedOriginsJson: $"[\"{AllowedOrigin}\"]"); + + var response = await SendAsync(HttpMethod.Options, PingPath, AllowedOrigin); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + response.Headers.GetValues("Access-Control-Allow-Origin").Should().ContainSingle().Which.Should().Be(AllowedOrigin); + response.Headers.GetValues("Access-Control-Allow-Methods").Should().ContainSingle().Which.Should().Contain("POST"); + response.Headers.GetValues("Access-Control-Allow-Headers").Should().ContainSingle().Which.Should().Contain("Authorization"); + response.Headers.GetValues("Access-Control-Max-Age").Should().ContainSingle().Which.Should().Be("600"); + response.Headers.Vary.Should().Contain("Origin"); + } + + [Fact] + public async Task Preflight_With_Disallowed_Origin_Returns_403_Without_Cors_Headers() + { + await SeedAppAsync(allowedOriginsJson: $"[\"{AllowedOrigin}\"]"); + + var response = await SendAsync(HttpMethod.Options, PingPath, DisallowedOrigin); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + // CORS headers MUST NOT leak on a rejected preflight — a browser that + // saw Allow-Origin: would treat the response as authorised. + response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse(); + response.Headers.Contains("Access-Control-Allow-Methods").Should().BeFalse(); + } + + [Fact] + public async Task Preflight_With_Subdomain_Spoofed_Origin_Is_Rejected() + { + // Classic CORS bypass: allowlist contains "https://acme.com", attacker + // sends Origin "https://acme.com.attacker.com". A naive + // origin.StartsWith(allowed) check would accept it. Strict equality + // (or, here, exact-string match) refuses it. + await SeedAppAsync(allowedOriginsJson: "[\"https://acme.com\"]"); + + var response = await SendAsync(HttpMethod.Options, PingPath, "https://acme.com.attacker.com"); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse(); + } + + [Fact] + public async Task Preflight_Origin_Match_Is_Case_Insensitive_Per_Rfc6454() + { + // RFC 6454 §4: scheme + host comparison is case-insensitive. Allowlist + // stored with uppercase host; browser sends lowercased Origin. + await SeedAppAsync(allowedOriginsJson: "[\"https://APP.EXAMPLE.com\"]"); + + var response = await SendAsync(HttpMethod.Options, PingPath, "https://app.example.com"); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + response.Headers.GetValues("Access-Control-Allow-Origin") + .Should().ContainSingle().Which.Should().Be("https://app.example.com"); + } + + [Fact] + public async Task Request_Without_Origin_Header_Falls_Through_To_Next() + { + // Same-origin or non-browser caller: no Origin header. The middleware + // must not interfere — the terminal handler should respond normally. + await SeedAppAsync(allowedOriginsJson: $"[\"{AllowedOrigin}\"]"); + + var response = await SendAsync(HttpMethod.Options, PingPath, origin: null); + + // Terminal pong handler returns 200 when reached. + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse(); + } + + [Fact] + public async Task Real_Request_With_Valid_Token_And_Allowed_Origin_Echoes_Cors_Headers() + { + var appId = await SeedAppAsync(allowedOriginsJson: $"[\"{AllowedOrigin}\"]"); + var token = PortalJwtFactory.Mint(appId, capabilities: ["endpoints:read"]); + + var response = await SendAsync(HttpMethod.Get, PingPath, AllowedOrigin, bearerToken: token); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.GetValues("Access-Control-Allow-Origin") + .Should().ContainSingle().Which.Should().Be(AllowedOrigin); + response.Headers.Vary.Should().Contain("Origin"); + } + + [Fact] + public async Task Real_Request_With_Valid_Token_And_Disallowed_Origin_Has_No_Cors_Headers() + { + // Token is valid (request reaches the handler) but Origin is not on + // the allowlist — browser will see the 200 without a matching + // Allow-Origin and surface a CORS error, which is the correct UX. + var appId = await SeedAppAsync(allowedOriginsJson: $"[\"{AllowedOrigin}\"]"); + var token = PortalJwtFactory.Mint(appId, capabilities: ["endpoints:read"]); + + var response = await SendAsync(HttpMethod.Get, PingPath, DisallowedOrigin, bearerToken: token); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse(); + } + + private async Task SendAsync( + HttpMethod method, + string path, + string? origin, + string? bearerToken = null) + { + var client = _factory.CreateClient(); + var request = new HttpRequestMessage(method, path); + if (origin is not null) + { + request.Headers.Add("Origin", origin); + } + if (bearerToken is not null) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + return await client.SendAsync(request); + } + + private async Task SeedAppAsync(string? allowedOriginsJson) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureDeletedAsync(); + await db.Database.EnsureCreatedAsync(); + + var appId = Guid.NewGuid(); + db.Applications.Add(new Application + { + Id = appId, + Name = $"cors-{appId:N}", + ApiKeyPrefix = $"whe_{appId:N}".Substring(0, 12) + "_", + ApiKeyHash = "deadbeef", + SigningSecret = "whsec_test", + PortalSigningKey = PortalJwtFactory.ValidSigningKey, + AllowedPortalOriginsJson = allowedOriginsJson, + IsActive = true + }); + await db.SaveChangesAsync(); + return appId; + } + + /// + /// Production DI from with the application pipeline + /// reduced to the two portal middlewares plus a terminal pong handler. + /// Mirrors the production ordering: PortalTokenAuth → PortalCors → next. + /// + public sealed class CorsMiddlewareFactory : WebApplicationFactory + { + private readonly string _dbName = $"PortalCorsTestDb_{Guid.NewGuid()}"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + services.RemoveAll>(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(typeof(IDbContextOptionsConfiguration)); + services.RemoveAll(); + + services.AddDbContext(options => + options.UseInMemoryDatabase(_dbName)); + }); + + builder.Configure(app => + { + app.UseMiddleware(); + app.UseMiddleware(); + app.Run(async context => + { + if (context.Request.Path == PingPath) + { + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"message\":\"pong\"}"); + return; + } + + context.Response.StatusCode = StatusCodes.Status404NotFound; + }); + }); + } + } +} diff --git a/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs b/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs index 1305c5a..e8c5b0d 100644 --- a/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs +++ b/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs @@ -338,6 +338,137 @@ public async Task Portal_Create_With_Weak_Secret_Override_Returns_422() response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); } + // ── Capability claim shape (defense-in-depth) ────── + + [Fact] + public async Task Portal_Token_Without_Any_Capabilities_Cannot_Access_Endpoints() + { + // Silent regression guard: if a future refactor accidentally treats + // a missing `capabilities` claim as full access (e.g. by collapsing + // the HashSet check to "is null OR contains"), every read path would + // open up. This test pins the contract that absence == 403. + await ResetDatabaseAsync(); + var (appId, _) = await SeedAppAsync(); + var token = MintToken(appId); // zero capabilities + using var client = CreateClient(token); + + var response = await client.GetAsync($"{PortalRoot}/endpoints"); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await ReadErrorCodeAsync(response)).Should().Be("PORTAL_INSUFFICIENT_CAPABILITY"); + } + + // ── Cross-tenant: every mutating route is its own CAS surface ── + + [Fact] + public async Task Portal_Cannot_Delete_Other_Apps_Endpoint() + { + // The 2-arg app-scoped GetByIdAsync inside Delete is the only check + // standing between this and a quiet cross-tenant wipe — without it + // EndpointRepository.DeleteAsync would happily 0-row the request and + // surface as a 204 No Content (silent leak). + await ResetDatabaseAsync(); + var (appA, _) = await SeedAppAsync(name: "tenant-A"); + var (_, otherEndpointId) = await SeedAppAndEndpointAsync(name: "tenant-B"); + + var tokenA = MintFullToken(appA); + using var client = CreateClient(tokenA); + + var response = await client.DeleteAsync($"{PortalRoot}/endpoints/{otherEndpointId}"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + (await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND"); + + // Belt-and-braces: the row must still exist in tenant B's slice. + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Endpoints.AsNoTracking().AnyAsync(e => e.Id == otherEndpointId)) + .Should().BeTrue(); + } + + [Fact] + public async Task Portal_Cannot_Enable_Other_Apps_Endpoint() + { + await ResetDatabaseAsync(); + var (appA, _) = await SeedAppAsync(name: "tenant-A"); + var (_, otherEndpointId) = await SeedAppAndEndpointAsync(name: "tenant-B"); + + var tokenA = MintFullToken(appA); + using var client = CreateClient(tokenA); + + var response = await client.PostAsync( + $"{PortalRoot}/endpoints/{otherEndpointId}/enable", + content: null); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + (await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND"); + } + + [Fact] + public async Task Portal_Cannot_Disable_Other_Apps_Endpoint() + { + await ResetDatabaseAsync(); + var (appA, _) = await SeedAppAsync(name: "tenant-A"); + var (_, otherEndpointId) = await SeedAppAndEndpointAsync(name: "tenant-B"); + + var tokenA = MintFullToken(appA); + using var client = CreateClient(tokenA); + + var response = await client.PostAsync( + $"{PortalRoot}/endpoints/{otherEndpointId}/disable", + content: null); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + (await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND"); + } + + [Fact] + public async Task Portal_Cannot_Test_Other_Apps_Endpoint() + { + // /test fires a real outbound HTTP POST (in production), so a + // cross-tenant probe here would let tenant A trigger arbitrary + // requests through tenant B's signing key. The 404 must precede any + // delivery dispatch — verified by asserting the EndpointTester fake + // never received a call. + await ResetDatabaseAsync(); + // Shared NSubstitute fake survives across tests in the class fixture; + // clear any prior call history so DidNotReceive() reflects this test. + _factory.EndpointTester.ClearReceivedCalls(); + var (appA, _) = await SeedAppAsync(name: "tenant-A"); + var (_, otherEndpointId) = await SeedAppAndEndpointAsync(name: "tenant-B"); + + var tokenA = MintFullToken(appA); + using var client = CreateClient(tokenA); + + var body = new { eventType = "test.event", payload = new { } }; + var response = await client.PostAsJsonAsync( + $"{PortalRoot}/endpoints/{otherEndpointId}/test", + body); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + (await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND"); + await _factory.EndpointTester.DidNotReceive() + .ExecuteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Portal_Cannot_Read_Attempts_Of_Other_Apps_Endpoint() + { + await ResetDatabaseAsync(); + var (appA, _) = await SeedAppAsync(name: "tenant-A"); + var (appB, otherEndpointId) = await SeedAppAndEndpointAsync(name: "tenant-B"); + await SeedAttemptsAsync(appB, otherEndpointId, count: 2); + + var tokenA = MintFullToken(appA); + using var client = CreateClient(tokenA); + + var response = await client.GetAsync( + $"{PortalRoot}/endpoints/{otherEndpointId}/attempts"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + (await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND"); + } + // ── Plumbing ────────────────────────────────────── private HttpClient CreateClient(string bearerToken) diff --git a/tests/WebhookEngine.Infrastructure.Tests/EndToEnd/PortalOriginsAllowlistE2ETests.cs b/tests/WebhookEngine.Infrastructure.Tests/EndToEnd/PortalOriginsAllowlistE2ETests.cs new file mode 100644 index 0000000..d014b09 --- /dev/null +++ b/tests/WebhookEngine.Infrastructure.Tests/EndToEnd/PortalOriginsAllowlistE2ETests.cs @@ -0,0 +1,144 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using WebhookEngine.Infrastructure.Data; +using WebhookEngine.Infrastructure.Repositories; +using ApplicationEntity = WebhookEngine.Core.Entities.Application; + +namespace WebhookEngine.Infrastructure.Tests.EndToEnd; + +/// +/// Real-PostgreSQL coverage for . +/// The method itself does the JSON containment in C# (so the InMemory and +/// Npgsql providers should agree), but the column is JSONB and the candidate +/// filter WHERE PortalSigningKey IS NOT NULL AND AllowedPortalOriginsJson IS NOT NULL +/// is provider-translated — Testcontainers exercises that the round-trip +/// through real PostgreSQL doesn't drop a portal-enabled row or surface a +/// disabled one. +/// +public class PortalOriginsAllowlistE2ETests : IClassFixture, IAsyncLifetime +{ + private readonly PostgresFixture _fixture; + + public PortalOriginsAllowlistE2ETests(PostgresFixture fixture) + { + _fixture = fixture; + } + + public Task InitializeAsync() => _fixture.ResetAsync(); + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task AnyAllowsPortalOriginAsync_Returns_True_For_Exact_Match() + { + await SeedAsync(portalEnabled: true, originsJson: "[\"https://app.example.com\"]"); + var (db, repo) = NewScope(); + + var allowed = await repo.AnyAllowsPortalOriginAsync("https://app.example.com"); + + allowed.Should().BeTrue(); + await db.DisposeAsync(); + } + + [Fact] + public async Task AnyAllowsPortalOriginAsync_Is_Case_Insensitive_On_Host() + { + // RFC 6454 §4: scheme + host case-insensitive. Allowlist stored with + // uppercase host; lookup hits with lowercase. + await SeedAsync(portalEnabled: true, originsJson: "[\"https://APP.EXAMPLE.COM\"]"); + var (db, repo) = NewScope(); + + var allowed = await repo.AnyAllowsPortalOriginAsync("https://app.example.com"); + + allowed.Should().BeTrue(); + await db.DisposeAsync(); + } + + [Fact] + public async Task AnyAllowsPortalOriginAsync_Skips_Portal_Disabled_Apps_Even_If_Allowlist_Matches() + { + // The candidate filter requires PortalSigningKey != null. A row with + // an origin that would otherwise match must not slip through if the + // app has the portal disabled. + await SeedAsync(portalEnabled: false, originsJson: "[\"https://app.example.com\"]"); + var (db, repo) = NewScope(); + + var allowed = await repo.AnyAllowsPortalOriginAsync("https://app.example.com"); + + allowed.Should().BeFalse(); + await db.DisposeAsync(); + } + + [Fact] + public async Task AnyAllowsPortalOriginAsync_Returns_False_When_Origin_Not_In_Allowlist() + { + await SeedAsync(portalEnabled: true, originsJson: "[\"https://app.example.com\"]"); + var (db, repo) = NewScope(); + + var allowed = await repo.AnyAllowsPortalOriginAsync("https://attacker.example"); + + allowed.Should().BeFalse(); + await db.DisposeAsync(); + } + + [Fact] + public async Task AnyAllowsPortalOriginAsync_Returns_False_When_Allowlist_Is_Empty_Array() + { + await SeedAsync(portalEnabled: true, originsJson: "[]"); + var (db, repo) = NewScope(); + + var allowed = await repo.AnyAllowsPortalOriginAsync("https://app.example.com"); + + allowed.Should().BeFalse(); + await db.DisposeAsync(); + } + + // Note: a "malformed JSON" case isn't tested here because PostgreSQL + // rejects invalid JSON at INSERT into a JSONB column — the corresponding + // catch (JsonException) inside the repository is defensive code that + // covers a hypothetical "different source schema" path and isn't + // reachable through the EF Core write surface. + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task AnyAllowsPortalOriginAsync_Returns_False_For_Blank_Origin(string origin) + { + await SeedAsync(portalEnabled: true, originsJson: "[\"https://app.example.com\"]"); + var (db, repo) = NewScope(); + + var allowed = await repo.AnyAllowsPortalOriginAsync(origin); + + allowed.Should().BeFalse(); + await db.DisposeAsync(); + } + + private async Task SeedAsync(bool portalEnabled, string? originsJson) + { + var (db, _) = NewScope(); + await using (db) + { + var appId = Guid.NewGuid(); + db.Applications.Add(new ApplicationEntity + { + Id = appId, + Name = $"portal-origins-{appId:N}", + ApiKeyPrefix = $"whe_{appId:N}".Substring(0, 12) + "_", + ApiKeyHash = "deadbeef", + SigningSecret = "whsec_test", + PortalSigningKey = portalEnabled ? "k" : null, + AllowedPortalOriginsJson = originsJson, + IsActive = true + }); + await db.SaveChangesAsync(); + } + } + + private (WebhookDbContext db, ApplicationRepository repo) NewScope() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_fixture.ConnectionString) + .Options; + var db = new WebhookDbContext(options); + return (db, new ApplicationRepository(db)); + } +} diff --git a/tests/WebhookEngine.Infrastructure.Tests/Services/PortalLookupCacheTests.cs b/tests/WebhookEngine.Infrastructure.Tests/Services/PortalLookupCacheTests.cs new file mode 100644 index 0000000..196403b --- /dev/null +++ b/tests/WebhookEngine.Infrastructure.Tests/Services/PortalLookupCacheTests.cs @@ -0,0 +1,161 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using WebhookEngine.Core.Options; +using WebhookEngine.Infrastructure.Data; +using WebhookEngine.Infrastructure.Repositories; +using WebhookEngine.Infrastructure.Services; +using ApplicationEntity = WebhookEngine.Core.Entities.Application; + +namespace WebhookEngine.Infrastructure.Tests.Services; + +/// +/// Behavioural tests for — TTL miss / cache hit / +/// invalidation / portal-not-enabled, plus a concurrent-Set+Invalidate race +/// that exercises the post-merge fix where Set now atomically swaps +/// the per-app instead +/// of GetOrAdd-reusing it. +/// +public class PortalLookupCacheTests +{ + [Fact] + public async Task GetAsync_Returns_Null_When_Portal_Not_Enabled() + { + await using var db = CreateDbContext(); + var app = SeedApp(db, portalSigningKey: null); + var cache = CreateCache(db); + + var lookup = await cache.GetAsync(app.Id, CancellationToken.None); + + lookup.Should().BeNull(); + } + + [Fact] + public async Task GetAsync_Returns_Lookup_For_Portal_Enabled_App() + { + await using var db = CreateDbContext(); + var app = SeedApp(db, portalSigningKey: "k", originsJson: "[\"https://app.example\"]"); + var cache = CreateCache(db); + + var lookup = await cache.GetAsync(app.Id, CancellationToken.None); + + lookup.Should().NotBeNull(); + lookup!.PortalSigningKey.Should().Be("k"); + lookup.AllowedOrigins.Should().BeEquivalentTo(["https://app.example"]); + } + + [Fact] + public async Task GetAsync_Caches_Across_Calls_Even_After_Db_Mutation() + { + // Once cached, the lookup must survive the next call without going + // back to the database — proving the cache is actually in the loop. + // We mutate the underlying row to a state that would deserialize + // differently if a fresh load happened (origins JSON cleared); the + // second call must still return the cached origins. + await using var db = CreateDbContext(); + var app = SeedApp(db, portalSigningKey: "k", originsJson: "[\"https://first.example\"]"); + var cache = CreateCache(db); + + var first = await cache.GetAsync(app.Id, CancellationToken.None); + + // Mutate row directly without going through the cache's invalidation path. + var tracked = await db.Applications.SingleAsync(a => a.Id == app.Id); + tracked.AllowedPortalOriginsJson = "[]"; + await db.SaveChangesAsync(); + + var second = await cache.GetAsync(app.Id, CancellationToken.None); + + first!.AllowedOrigins.Should().BeEquivalentTo(["https://first.example"]); + second!.AllowedOrigins.Should().BeEquivalentTo(["https://first.example"]); + } + + [Fact] + public async Task InvalidateApplication_Forces_Database_Reload_On_Next_GetAsync() + { + await using var db = CreateDbContext(); + var app = SeedApp(db, portalSigningKey: "k", originsJson: "[\"https://before\"]"); + var cache = CreateCache(db); + + await cache.GetAsync(app.Id, CancellationToken.None); + + var tracked = await db.Applications.SingleAsync(a => a.Id == app.Id); + tracked.AllowedPortalOriginsJson = "[\"https://after\"]"; + await db.SaveChangesAsync(); + + PortalLookupCache.InvalidateApplication(app.Id); + + var refreshed = await cache.GetAsync(app.Id, CancellationToken.None); + + refreshed!.AllowedOrigins.Should().BeEquivalentTo(["https://after"]); + } + + [Fact] + public async Task Concurrent_Invalidate_Calls_Do_Not_Throw_ObjectDisposed() + { + // Regression for the v0.2.0 audit fix: a single CTS per app, when + // racing N concurrent InvalidateApplication callers, must end up + // with exactly one Cancel + Dispose (the TryRemove winner) and N-1 + // no-ops — never a double-dispose throw. Set itself can't be raced + // from outside the class without a real DbContext (which isn't + // thread-safe), so the orthogonal Set-side race is exercised by + // the integration tests that drive the rotate endpoint. + await using var db = CreateDbContext(); + var app = SeedApp(db, portalSigningKey: "k", originsJson: "[\"https://race\"]"); + var cache = CreateCache(db); + + // Pre-warm so the per-app CTS exists in the static dictionary. + await cache.GetAsync(app.Id, CancellationToken.None); + + var act = async () => + { + var tasks = new List(); + for (var i = 0; i < 64; i++) + { + tasks.Add(Task.Run(() => PortalLookupCache.InvalidateApplication(app.Id))); + } + await Task.WhenAll(tasks); + }; + + await act.Should().NotThrowAsync(); + } + + private static WebhookDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"portal_lookup_cache_tests_{Guid.NewGuid()}") + .Options; + return new WebhookDbContext(options); + } + + private static ApplicationEntity SeedApp( + WebhookDbContext db, + string? portalSigningKey, + string? originsJson = null) + { + var appId = Guid.NewGuid(); + var app = new ApplicationEntity + { + Id = appId, + Name = $"portal-cache-{appId:N}", + ApiKeyPrefix = $"whe_{appId:N}".Substring(0, 12) + "_", + ApiKeyHash = "deadbeef", + SigningSecret = "whsec_test", + PortalSigningKey = portalSigningKey, + AllowedPortalOriginsJson = originsJson, + IsActive = true + }; + db.Applications.Add(app); + db.SaveChanges(); + return app; + } + + private static PortalLookupCache CreateCache(WebhookDbContext db) + { + // SizeLimit is required because PortalLookupCache sets entry Size = 1. + var memory = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1024 }); + var repo = new ApplicationRepository(db); + var options = Options.Create(new PortalAuthOptions { LookupCacheTtlSeconds = 60 }); + return new PortalLookupCache(memory, repo, options); + } +}