diff --git a/CHANGELOG.md b/CHANGELOG.md
index 56c3919..bfb1262 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
## [Unreleased]
+### Changed
+- **`PATCH /api/v1/portal/endpoints/{id}` replaces `PUT`.** Portal endpoint update was tagged `[HttpPut]` but its body semantics were partial-replace (every field optional, only non-null fields applied) — that is the contract for `PATCH`, not `PUT`. Switching the method aligns the wire surface with the actual behaviour and avoids confusing REST consumers that expect `PUT` to be full-replace. The route, request shape, and response shape are unchanged. Minor breaking change for any direct HTTP caller hitting this route; the embeddable `` component already uses the new method.
+- **`POST /api/v1/dashboard/applications/{appId}/portal/disable` no longer wipes `AllowedPortalOriginsJson`.** Disable revokes the auth surface (`PortalSigningKey` + `PortalRotatedAt`) but preserves the operator-curated CORS allowlist so a re-enable doesn't lose origin configuration. To explicitly clear the allowlist, send `PUT /portal/origins` with `{"origins": []}`. The audit log entry's `before/after` snapshot still records the change accurately.
+- **Validator drift consolidation: new `EndpointValidationRules` extension methods** (`EndpointUrlSyntax`, `EndpointDescription`, `EndpointTransformExpression`, `EndpointCustomHeaders`, `EndpointAllowedIpsCidrs`, `EndpointSecretOverride`). The 4 admin endpoint validators (`CreateEndpoint`, `UpdateEndpoint`, `DashboardCreateEndpoint`, `DashboardUpdateEndpoint`) and the 2 portal endpoint validators (`PortalCreateEndpoint`, `PortalUpdateEndpoint`) now share a single source of truth for URL syntax / description max-length / transform-expression cap / custom-header policy / CIDR shape / `whsec_` prefix rules. Without this helper a tightening on one surface (e.g. a longer allowed transform expression on the portal side) would silently leave the admin side weaker. Behaviour unchanged in this commit; the consolidation is a precondition for the next round of rule tightening.
+
+### Security
+- **Portal CORS preflight gains a deny-cache.** `PortalCorsMiddleware.HandlePreflightAsync` now caches both allow and deny outcomes for the same TTL as the per-app signing-key lookup (default 60 s). Browsers don't cache rejected preflights, so before this change every `OPTIONS` against `/api/v1/portal/*` from a disallowed origin re-scanned the portal-enabled application set and the in-process JSON deserialize loop — a low-effort DB hammer vector. New regression test `Preflight_Deny_Decision_Is_Cached_Within_Ttl` pins the behaviour.
+
### Documentation
- **`docs/API.md` §3.8 — Portal API (Customer-Facing JWT).** Replaces the doc-drift gap surfaced by the v0.2.0 audit (where the portal API surface existed in code and CHANGELOG but was absent from `docs/API.md`). Covers HS256 JWT contract, capability scopes, per-app CORS, rate-limit partition reuse, cross-tenant `404` semantics, every route under `/api/v1/portal/*`, the dashboard portal-admin routes, the portal-specific error code table, and an end-to-end Node.js (`jose`) + cURL probe.
- **`docs/ARCHITECTURE.md` §4.3 — Portal Token Authentication.** Documents the load-bearing middleware ordering (`ApiKeyAuth` → `PortalTokenAuth` → `PortalCors` → `RateLimiter`), the three invariants it encodes, the `PortalLookupCache` TTL + atomic-CTS-swap behaviour, and the JWT validator's defense-in-depth choices (HS256 pin, 8 KiB token cap, `MapInboundClaims=false`, lifetime cap, opaque error bodies).
diff --git a/src/WebhookEngine.API/Controllers/DashboardPortalController.cs b/src/WebhookEngine.API/Controllers/DashboardPortalController.cs
index 0998904..e72021b 100644
--- a/src/WebhookEngine.API/Controllers/DashboardPortalController.cs
+++ b/src/WebhookEngine.API/Controllers/DashboardPortalController.cs
@@ -157,8 +157,11 @@ public async Task Disable(Guid appId, CancellationToken ct)
var beforeSnapshot = BuildAuditSnapshot(application);
+ // Disable revokes the auth surface (signing key + rotated-at) but
+ // preserves the operator-curated CORS allowlist so a re-enable
+ // doesn't lose the origin configuration. Operators wanting a full
+ // wipe can clear origins explicitly via PUT /portal/origins.
application.PortalSigningKey = null;
- application.AllowedPortalOriginsJson = null;
application.PortalRotatedAt = null;
await _applicationRepository.UpdateAsync(application, ct);
diff --git a/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs b/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs
index 10d753f..dc0d253 100644
--- a/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs
+++ b/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs
@@ -157,7 +157,7 @@ public async Task Create(
ApiEnvelope.Success(HttpContext, created.ToPortalDetail()));
}
- [HttpPut("endpoints/{endpointId:guid}")]
+ [HttpPatch("endpoints/{endpointId:guid}")]
public async Task Update(
Guid endpointId,
[FromBody] PortalUpdateEndpointRequest request,
diff --git a/src/WebhookEngine.API/Middleware/PortalCorsMiddleware.cs b/src/WebhookEngine.API/Middleware/PortalCorsMiddleware.cs
index 56f70e6..9d2bc3b 100644
--- a/src/WebhookEngine.API/Middleware/PortalCorsMiddleware.cs
+++ b/src/WebhookEngine.API/Middleware/PortalCorsMiddleware.cs
@@ -1,3 +1,6 @@
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+using WebhookEngine.Core.Options;
using WebhookEngine.Core.Utilities;
using WebhookEngine.Infrastructure.Repositories;
using WebhookEngine.Infrastructure.Services;
@@ -76,8 +79,27 @@ public async Task InvokeAsync(HttpContext context)
private static async Task HandlePreflightAsync(HttpContext context, string origin)
{
- var appRepo = context.RequestServices.GetRequiredService();
- var allowed = await appRepo.AnyAllowsPortalOriginAsync(origin, context.RequestAborted);
+ // Deny-cache the preflight decision so an attacker can't trigger an
+ // unbounded per-app candidate scan via OPTIONS spam (browsers don't
+ // cache rejected preflights, so the absence of a server-side cache
+ // turned every disallowed OPTIONS into a fresh DB sweep). Both
+ // allow and deny outcomes are cached for the same TTL as the
+ // signing-key lookup; eventual consistency on origin updates is
+ // bounded by that TTL, mirroring the existing per-app cache.
+ var cache = context.RequestServices.GetRequiredService();
+ var options = context.RequestServices.GetRequiredService>().Value;
+ var cacheKey = $"portal:cors:{origin.ToLowerInvariant()}";
+
+ if (!cache.TryGetValue(cacheKey, out var allowed))
+ {
+ var appRepo = context.RequestServices.GetRequiredService();
+ allowed = await appRepo.AnyAllowsPortalOriginAsync(origin, context.RequestAborted);
+ cache.Set(cacheKey, allowed, new MemoryCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(options.LookupCacheTtlSeconds),
+ Size = 1
+ });
+ }
var logger = context.RequestServices
.GetRequiredService()
diff --git a/src/WebhookEngine.API/Validators/EndpointValidationRules.cs b/src/WebhookEngine.API/Validators/EndpointValidationRules.cs
new file mode 100644
index 0000000..c81c2c2
--- /dev/null
+++ b/src/WebhookEngine.API/Validators/EndpointValidationRules.cs
@@ -0,0 +1,90 @@
+using FluentValidation;
+using WebhookEngine.Infrastructure.Services;
+
+namespace WebhookEngine.API.Validators;
+
+///
+/// Single source of truth for the field-shape rules shared between the admin
+/// (`/api/v1/endpoints` + `/api/v1/dashboard/endpoints`) and customer-facing
+/// (`/api/v1/portal/endpoints`) validators. Without this, the two surfaces
+/// drift over time — the v0.2.0 audit flagged that the 32-char `whsec_`
+/// prefix rule, the 4 KiB transform-expression cap, and the custom-header
+/// policy were each duplicated in 4-6 places, so a tightening on one side
+/// quietly leaves the other side weaker.
+///
+/// Async URL host-safety (`EndpointUrlPolicy.CheckHostSafeAsync`) is NOT
+/// covered here because the FluentValidation `DependentRules` + `CustomAsync`
+/// pattern needs to call back into the surrounding validator with the full
+/// property selector — keeping it in each validator avoids a fragile
+/// reflection-driven helper. The synchronous URL syntax check IS shared.
+///
+public static class EndpointValidationRules
+{
+ public const int MaxDescriptionLength = 500;
+ public const int MaxTransformExpressionLength = 4096;
+ public const int MinSecretOverrideLength = 32;
+ public const int MaxSecretOverrideLength = 128;
+ public const string SecretOverridePrefix = "whsec_";
+
+ ///
+ /// Synchronous URL syntax + SSRF-classification (no DNS lookup). Pair with
+ /// a `DependentRules { RuleFor(...).CustomAsync(... CheckHostSafeAsync ...) }`
+ /// in the host validator for the eager DNS-resolution check.
+ ///
+ public static IRuleBuilderOptions EndpointUrlSyntax(
+ this IRuleBuilder rule,
+ EndpointUrlPolicy urlPolicy)
+ {
+ return rule
+ .Must(urlPolicy.IsValid)
+ .WithMessage(urlPolicy.ValidationMessage);
+ }
+
+ public static IRuleBuilderOptions EndpointDescription(
+ this IRuleBuilder rule)
+ {
+ return rule.MaximumLength(MaxDescriptionLength);
+ }
+
+ public static IRuleBuilderOptions EndpointTransformExpression(
+ this IRuleBuilder rule)
+ {
+ return rule
+ .MaximumLength(MaxTransformExpressionLength)
+ .WithMessage($"TransformExpression must not exceed {MaxTransformExpressionLength} characters.");
+ }
+
+ public static IRuleBuilderOptions?> EndpointCustomHeaders(
+ this IRuleBuilder?> rule)
+ {
+ return rule
+ .Must(headers => CustomHeaderPolicy.Validate(headers) is null)
+ .WithMessage(_ => CustomHeaderPolicy.Validate(default(IDictionary?)) ?? "Invalid custom headers.");
+ }
+
+ public static IRuleBuilderOptions?> EndpointAllowedIpsCidrs(
+ this IRuleBuilder?> rule)
+ {
+ return rule
+ .Must(list => list!.All(cidr => IpAllowlistMatcher.TryParseCidr(cidr, out _)))
+ .WithMessage("AllowedIps must contain valid CIDR notations (e.g. \"203.0.113.0/24\").");
+ }
+
+ ///
+ /// Customer-typed override secret. Requires the `whsec_` prefix and at
+ /// least 32 chars so a hand-typed weak password ("password123") cannot
+ /// quietly undermine HMAC authenticity on every signed delivery.
+ ///
+ public static IRuleBuilderOptions EndpointSecretOverride(
+ this IRuleBuilder rule)
+ {
+ return rule
+ .MinimumLength(MinSecretOverrideLength)
+ .MaximumLength(MaxSecretOverrideLength)
+ .Must(s => s!.StartsWith(SecretOverridePrefix, StringComparison.Ordinal))
+ .WithMessage(
+ $"SecretOverride must start with the '{SecretOverridePrefix}' prefix and be at least " +
+ $"{MinSecretOverrideLength} characters. Use the portal's rotate-secret action to generate one " +
+ "rather than typing a password.");
+ }
+}
diff --git a/src/WebhookEngine.API/Validators/PortalRequestValidators.cs b/src/WebhookEngine.API/Validators/PortalRequestValidators.cs
index 25d2a51..77d54b1 100644
--- a/src/WebhookEngine.API/Validators/PortalRequestValidators.cs
+++ b/src/WebhookEngine.API/Validators/PortalRequestValidators.cs
@@ -10,8 +10,7 @@ public PortalCreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
{
RuleFor(x => x.Url)
.NotEmpty()
- .Must(urlPolicy.IsValid)
- .WithMessage(urlPolicy.ValidationMessage)
+ .EndpointUrlSyntax(urlPolicy)
.DependentRules(() =>
{
RuleFor(x => x.Url).CustomAsync(async (url, ctx, ct) =>
@@ -22,19 +21,15 @@ public PortalCreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
});
RuleFor(x => x.Description)
- .MaximumLength(500)
+ .EndpointDescription()
.When(x => x.Description is not null);
RuleFor(x => x.CustomHeaders)
- .Must(headers => CustomHeaderPolicy.Validate(headers) is null)
- .WithMessage(x => CustomHeaderPolicy.Validate(x.CustomHeaders) ?? "Invalid custom headers.")
+ .EndpointCustomHeaders()
.When(x => x.CustomHeaders is not null);
RuleFor(x => x.SecretOverride)
- .MinimumLength(32)
- .MaximumLength(128)
- .Must(s => s!.StartsWith("whsec_", StringComparison.Ordinal))
- .WithMessage("SecretOverride must start with the 'whsec_' prefix and be at least 32 characters. Use the portal's rotate-secret action to generate one rather than typing a password.")
+ .EndpointSecretOverride()
.When(x => x.SecretOverride is not null);
}
}
@@ -44,9 +39,8 @@ public class PortalUpdateEndpointRequestValidator : AbstractValidator x.Url)
- .Must(urlPolicy.IsValid)
+ .EndpointUrlSyntax(urlPolicy)
.When(x => x.Url is not null)
- .WithMessage(urlPolicy.ValidationMessage)
.DependentRules(() =>
{
RuleFor(x => x.Url!).CustomAsync(async (url, ctx, ct) =>
@@ -57,19 +51,15 @@ public PortalUpdateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
});
RuleFor(x => x.Description)
- .MaximumLength(500)
+ .EndpointDescription()
.When(x => x.Description is not null);
RuleFor(x => x.CustomHeaders)
- .Must(headers => CustomHeaderPolicy.Validate(headers) is null)
- .WithMessage(x => CustomHeaderPolicy.Validate(x.CustomHeaders) ?? "Invalid custom headers.")
+ .EndpointCustomHeaders()
.When(x => x.CustomHeaders is not null);
RuleFor(x => x.SecretOverride)
- .MinimumLength(32)
- .MaximumLength(128)
- .Must(s => s!.StartsWith("whsec_", StringComparison.Ordinal))
- .WithMessage("SecretOverride must start with the 'whsec_' prefix and be at least 32 characters. Use the portal's rotate-secret action to generate one rather than typing a password.")
+ .EndpointSecretOverride()
.When(x => x.SecretOverride is not null);
RuleFor(x => x)
diff --git a/src/WebhookEngine.API/Validators/RequestValidators.cs b/src/WebhookEngine.API/Validators/RequestValidators.cs
index 24fa396..5c8a4e8 100644
--- a/src/WebhookEngine.API/Validators/RequestValidators.cs
+++ b/src/WebhookEngine.API/Validators/RequestValidators.cs
@@ -107,8 +107,7 @@ public CreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
{
RuleFor(x => x.Url)
.NotEmpty()
- .Must(urlPolicy.IsValid)
- .WithMessage(urlPolicy.ValidationMessage)
+ .EndpointUrlSyntax(urlPolicy)
.DependentRules(() =>
{
// Resolve the host eagerly so an unreachable webhook target fails the
@@ -122,22 +121,19 @@ public CreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
});
RuleFor(x => x.Description)
- .MaximumLength(500)
+ .EndpointDescription()
.When(x => x.Description is not null);
RuleFor(x => x.TransformExpression)
- .MaximumLength(4096)
- .When(x => x.TransformExpression is not null)
- .WithMessage("TransformExpression must not exceed 4096 characters.");
+ .EndpointTransformExpression()
+ .When(x => x.TransformExpression is not null);
RuleFor(x => x.CustomHeaders)
- .Must(headers => CustomHeaderPolicy.Validate(headers) is null)
- .WithMessage(x => CustomHeaderPolicy.Validate(x.CustomHeaders) ?? "Invalid custom headers.")
+ .EndpointCustomHeaders()
.When(x => x.CustomHeaders is not null);
RuleFor(x => x.AllowedIps)
- .Must(list => list!.All(cidr => IpAllowlistMatcher.TryParseCidr(cidr, out _)))
- .WithMessage("AllowedIps must contain valid CIDR notations (e.g. \"203.0.113.0/24\").")
+ .EndpointAllowedIpsCidrs()
.When(x => x.AllowedIps is { Count: > 0 });
}
}
@@ -147,9 +143,8 @@ public class UpdateEndpointRequestValidator : AbstractValidator x.Url)
- .Must(urlPolicy.IsValid)
+ .EndpointUrlSyntax(urlPolicy)
.When(x => x.Url is not null)
- .WithMessage(urlPolicy.ValidationMessage)
.DependentRules(() =>
{
RuleFor(x => x.Url!).CustomAsync(async (url, ctx, ct) =>
@@ -160,22 +155,19 @@ public UpdateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
});
RuleFor(x => x.Description)
- .MaximumLength(500)
+ .EndpointDescription()
.When(x => x.Description is not null);
RuleFor(x => x.TransformExpression)
- .MaximumLength(4096)
- .When(x => x.TransformExpression is not null)
- .WithMessage("TransformExpression must not exceed 4096 characters.");
+ .EndpointTransformExpression()
+ .When(x => x.TransformExpression is not null);
RuleFor(x => x.CustomHeaders)
- .Must(headers => CustomHeaderPolicy.Validate(headers) is null)
- .WithMessage(x => CustomHeaderPolicy.Validate(x.CustomHeaders) ?? "Invalid custom headers.")
+ .EndpointCustomHeaders()
.When(x => x.CustomHeaders is not null);
RuleFor(x => x.AllowedIps)
- .Must(list => list!.All(cidr => IpAllowlistMatcher.TryParseCidr(cidr, out _)))
- .WithMessage("AllowedIps must contain valid CIDR notations (e.g. \"203.0.113.0/24\").")
+ .EndpointAllowedIpsCidrs()
.When(x => x.AllowedIps is { Count: > 0 });
RuleFor(x => x)
@@ -200,8 +192,7 @@ public DashboardCreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
RuleFor(x => x.Url)
.NotEmpty()
- .Must(urlPolicy.IsValid)
- .WithMessage(urlPolicy.ValidationMessage)
+ .EndpointUrlSyntax(urlPolicy)
.DependentRules(() =>
{
RuleFor(x => x.Url).CustomAsync(async (url, ctx, ct) =>
@@ -212,22 +203,19 @@ public DashboardCreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
});
RuleFor(x => x.Description)
- .MaximumLength(500)
+ .EndpointDescription()
.When(x => x.Description is not null);
RuleFor(x => x.TransformExpression)
- .MaximumLength(4096)
- .When(x => x.TransformExpression is not null)
- .WithMessage("TransformExpression must not exceed 4096 characters.");
+ .EndpointTransformExpression()
+ .When(x => x.TransformExpression is not null);
RuleFor(x => x.CustomHeaders)
- .Must(headers => CustomHeaderPolicy.Validate(headers) is null)
- .WithMessage(x => CustomHeaderPolicy.Validate(x.CustomHeaders) ?? "Invalid custom headers.")
+ .EndpointCustomHeaders()
.When(x => x.CustomHeaders is not null);
RuleFor(x => x.AllowedIps)
- .Must(list => list!.All(cidr => IpAllowlistMatcher.TryParseCidr(cidr, out _)))
- .WithMessage("AllowedIps must contain valid CIDR notations (e.g. \"203.0.113.0/24\").")
+ .EndpointAllowedIpsCidrs()
.When(x => x.AllowedIps is { Count: > 0 });
}
}
@@ -237,9 +225,8 @@ public class DashboardUpdateEndpointRequestValidator : AbstractValidator x.Url)
- .Must(urlPolicy.IsValid)
+ .EndpointUrlSyntax(urlPolicy)
.When(x => x.Url is not null)
- .WithMessage(urlPolicy.ValidationMessage)
.DependentRules(() =>
{
RuleFor(x => x.Url!).CustomAsync(async (url, ctx, ct) =>
@@ -250,22 +237,19 @@ public DashboardUpdateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
});
RuleFor(x => x.Description)
- .MaximumLength(500)
+ .EndpointDescription()
.When(x => x.Description is not null);
RuleFor(x => x.TransformExpression)
- .MaximumLength(4096)
- .When(x => x.TransformExpression is not null)
- .WithMessage("TransformExpression must not exceed 4096 characters.");
+ .EndpointTransformExpression()
+ .When(x => x.TransformExpression is not null);
RuleFor(x => x.CustomHeaders)
- .Must(headers => CustomHeaderPolicy.Validate(headers) is null)
- .WithMessage(x => CustomHeaderPolicy.Validate(x.CustomHeaders) ?? "Invalid custom headers.")
+ .EndpointCustomHeaders()
.When(x => x.CustomHeaders is not null);
RuleFor(x => x.AllowedIps)
- .Must(list => list!.All(cidr => IpAllowlistMatcher.TryParseCidr(cidr, out _)))
- .WithMessage("AllowedIps must contain valid CIDR notations (e.g. \"203.0.113.0/24\").")
+ .EndpointAllowedIpsCidrs()
.When(x => x.AllowedIps is { Count: > 0 });
RuleFor(x => x)
diff --git a/tests/WebhookEngine.API.Tests/Portal/DashboardPortalControllerTests.cs b/tests/WebhookEngine.API.Tests/Portal/DashboardPortalControllerTests.cs
index 3152ff7..c8695b0 100644
--- a/tests/WebhookEngine.API.Tests/Portal/DashboardPortalControllerTests.cs
+++ b/tests/WebhookEngine.API.Tests/Portal/DashboardPortalControllerTests.cs
@@ -117,8 +117,11 @@ public async Task Rotate_Returns_New_Signing_Key_And_Updates_RotatedAt()
}
[Fact]
- public async Task Disable_Clears_SigningKey_And_Origins()
+ public async Task Disable_Clears_SigningKey_But_Preserves_Origins()
{
+ // Operator behaviour: disable revokes auth (signing key + rotated-at)
+ // but keeps the CORS allowlist so re-enable doesn't force the
+ // operator to re-curate origins. Explicit clear is via /portal/origins.
await ResetDatabaseAsync();
var (appId, _) = await SeedAppAsync(
portalEnabled: true,
@@ -132,8 +135,8 @@ await ExecuteDbAsync(async db =>
{
var app = await db.Applications.AsNoTracking().FirstAsync(a => a.Id == appId);
app.PortalSigningKey.Should().BeNull();
- app.AllowedPortalOriginsJson.Should().BeNull();
app.PortalRotatedAt.Should().BeNull();
+ app.AllowedPortalOriginsJson.Should().Be("[\"https://app.acme.com\"]");
});
}
diff --git a/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs b/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs
index 7a73abd..99bd01d 100644
--- a/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs
+++ b/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs
@@ -137,6 +137,36 @@ public async Task Real_Request_With_Valid_Token_And_Disallowed_Origin_Has_No_Cor
response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse();
}
+ [Fact]
+ public async Task Preflight_Deny_Decision_Is_Cached_Within_Ttl()
+ {
+ // Regression for the deny-cache fix: previously every disallowed
+ // OPTIONS bypassed any cache and re-scanned the portal-enabled apps.
+ // Now both allow and deny outcomes are cached for LookupCacheTtlSeconds,
+ // so a first preflight that returns 403 must keep returning 403 even
+ // if the DB starts allowing the origin within the TTL window. Uses
+ // a unique origin so the assertion isn't polluted by other tests
+ // sharing the IClassFixture's MemoryCache.
+ const string uniqueOrigin = "https://deny-cache-fix.example";
+
+ await SeedAppAsync(allowedOriginsJson: "[\"https://other.example\"]");
+ var first = await SendAsync(HttpMethod.Options, PingPath, uniqueOrigin);
+ first.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+
+ // Mutate DB to now allow the origin — without invalidating the CORS
+ // cache the cached deny decision must still win until TTL elapses.
+ using (var scope = _factory.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var app = await db.Applications.FirstAsync();
+ app.AllowedPortalOriginsJson = $"[\"{uniqueOrigin}\"]";
+ await db.SaveChangesAsync();
+ }
+
+ var second = await SendAsync(HttpMethod.Options, PingPath, uniqueOrigin);
+ second.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ }
+
private async Task SendAsync(
HttpMethod method,
string path,
diff --git a/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs b/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs
index e8c5b0d..408d1d4 100644
--- a/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs
+++ b/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs
@@ -123,10 +123,9 @@ public async Task Portal_Update_Endpoint_Returns_200_And_Updates_Fields()
var token = MintFullToken(appId);
using var client = CreateClient(token);
- var response = await client.PutAsJsonAsync($"{PortalRoot}/endpoints/{endpointId}", new
- {
- description = "updated by portal"
- });
+ var response = await client.PatchAsync(
+ $"{PortalRoot}/endpoints/{endpointId}",
+ JsonContent.Create(new { description = "updated by portal" }));
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -309,7 +308,9 @@ public async Task Portal_Cannot_Update_Other_Apps_Endpoint()
using var client = CreateClient(tokenA);
var body = new { url = "https://example.com/new" };
- var response = await client.PutAsJsonAsync($"{PortalRoot}/endpoints/{otherEndpointId}", body);
+ var response = await client.PatchAsync(
+ $"{PortalRoot}/endpoints/{otherEndpointId}",
+ JsonContent.Create(body));
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
(await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND");