diff --git a/src/VolcanicCore.Api/Endpoints/WebhookEndpoint.cs b/src/VolcanicCore.Api/Endpoints/WebhookEndpoint.cs index 954c83c..6d291f1 100644 --- a/src/VolcanicCore.Api/Endpoints/WebhookEndpoint.cs +++ b/src/VolcanicCore.Api/Endpoints/WebhookEndpoint.cs @@ -51,5 +51,51 @@ public static class WebhookEndpoint if (payload is null) return Results.NoContent(); return Results.Text(payload.Body, contentType: payload.ContentType, statusCode: payload.StatusCode); }); + + // Non-POST requests to the same path — without this, browsers clicking + // the webhook URL (GET) fall through to MapFallbackToFile("index.html") + // and land on the SPA instead of getting a useful hint. + app.MapMethods( + "/wh/{workflowId}/{**path}", + new[] { "GET", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS" }, + (string workflowId, string? path, HttpRequest req, HttpResponse res) => + { + res.Headers.Allow = "POST"; + var origin = $"{req.Scheme}://{req.Host}"; + var url = $"{origin}/wh/{workflowId}/{path ?? ""}"; + if (HttpMethods.IsHead(req.Method)) + return Results.StatusCode(405); + if (HttpMethods.IsGet(req.Method)) + { + // $$""" so single {/} are literal (CSS + curl JSON) and {{url}} interpolates. + var html = $$""" + + VolcanicCore webhook + +

This is a POST-only webhook endpoint.

+

You reached it with a browser (GET). To trigger the workflow, send a POST with a JSON body:

+
curl -X POST -H 'content-type: application/json' \
+                          -d '{"hello":"world"}' \
+                          {{url}}
+

The workflow must be active for this endpoint to respond; + an inactive workflow returns 404 on POST.

+ + """; + return Results.Content(html, contentType: "text/html; charset=utf-8", statusCode: 405); + } + return Results.Json(new + { + error = "method_not_allowed", + message = "This endpoint accepts POST only.", + endpoint = url, + }, statusCode: 405); + }); } } diff --git a/tests/VolcanicCore.Tests/WebhookEndpointTests.cs b/tests/VolcanicCore.Tests/WebhookEndpointTests.cs index e5d460b..ff29291 100644 --- a/tests/VolcanicCore.Tests/WebhookEndpointTests.cs +++ b/tests/VolcanicCore.Tests/WebhookEndpointTests.cs @@ -19,6 +19,34 @@ public class WebhookEndpointTests : IClassFixture Assert.Equal(HttpStatusCode.NotFound, res.StatusCode); } + [Fact] + public async Task Get_on_webhook_url_returns_405_html_helper_not_spa_fallback() + { + var c = _factory.CreateClient(new() { AllowAutoRedirect = false }); + var res = await c.GetAsync("/wh/some-workflow/hook"); + Assert.Equal(HttpStatusCode.MethodNotAllowed, res.StatusCode); + Assert.Equal("POST", res.Content.Headers.Allow.ToString()); + Assert.Contains("text/html", res.Content.Headers.ContentType?.ToString() ?? ""); + var body = await res.Content.ReadAsStringAsync(); + Assert.Contains("POST-only webhook endpoint", body); + Assert.Contains("/wh/some-workflow/hook", body); + // Sanity: it must NOT be the SPA index.html (that would say "VolcanicCore" in + // without any mention of "POST-only" or "webhook"). + Assert.DoesNotContain("id=\"root\"", body); + } + + [Fact] + public async Task Put_on_webhook_url_returns_405_json_not_spa_fallback() + { + var c = _factory.CreateClient(new() { AllowAutoRedirect = false }); + var res = await c.PutAsync("/wh/some-workflow/hook", + new StringContent("{}", System.Text.Encoding.UTF8, "application/json")); + Assert.Equal(HttpStatusCode.MethodNotAllowed, res.StatusCode); + Assert.Equal("POST", res.Content.Headers.Allow.ToString()); + var body = await res.Content.ReadFromJsonAsync<JsonElement>(); + Assert.Equal("method_not_allowed", body.GetProperty("error").GetString()); + } + [Fact] public async Task Registered_callback_that_never_resolves_yields_204_after_execution_finishes() {