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:
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user