fix(webhook): GET on /wh/* returns 405 helper page instead of SPA fallback

Previously a browser click on the webhook URL fell through the POST-only
handler to MapFallbackToFile('index.html'), landing the user on the
workflows page and looking like a bug. Now non-POST verbs on the same
route return 405 with Allow: POST — GETs render a small HTML page showing
the correct curl invocation, other methods get JSON. Two new xUnit tests
cover the HTML and JSON branches and assert we don't hand back the SPA.
This commit is contained in:
Claude
2026-08-28 20:16:07 +00:00
parent 5d04c0a2d4
commit 1a1f519144
2 changed files with 74 additions and 0 deletions

View File

@@ -51,5 +51,51 @@ public static class WebhookEndpoint
if (payload is null) return Results.NoContent(); if (payload is null) return Results.NoContent();
return Results.Text(payload.Body, contentType: payload.ContentType, statusCode: payload.StatusCode); 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 = $$"""
<!doctype html>
<html><head><meta charset="utf-8"><title>VolcanicCore webhook</title>
<style>
body { font-family: system-ui, sans-serif; background: #020617; color: #e2e8f0;
padding: 2rem; max-width: 40rem; margin: 0 auto; }
code { background: #1e293b; padding: 0.1rem 0.4rem; border-radius: 0.25rem;
font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; }
pre { background: #1e293b; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
h1 { font-size: 1.1rem; color: #fbbf24; }
</style></head><body>
<h1>This is a POST-only webhook endpoint.</h1>
<p>You reached it with a browser (GET). To trigger the workflow, send a POST with a JSON body:</p>
<pre>curl -X POST -H 'content-type: application/json' \
-d '{"hello":"world"}' \
{{url}}</pre>
<p>The workflow must be <strong>active</strong> for this endpoint to respond;
an inactive workflow returns 404 on POST.</p>
</body></html>
""";
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);
});
} }
} }

View File

@@ -19,6 +19,34 @@ public class WebhookEndpointTests : IClassFixture<WebFactory>
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode); 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 <title>
// 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] [Fact]
public async Task Registered_callback_that_never_resolves_yields_204_after_execution_finishes() public async Task Registered_callback_that_never_resolves_yields_204_after_execution_finishes()
{ {