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);
+ }
+}