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");