Files
VolcanicCore/tests/VolcanicCore.Tests/WebhookEndpointTests.cs
Claude 46372e8e38 feat(executor): {input.foo} template interpolation on node config strings
Every action/terminal node's config string fields are now run through
TemplateEngine.Render before the node sees them, so operators can pull
values from the incoming item into any config field.

- {input}, {input.a.b}, {input.tags[0]}, {input["weird.key"]}
- {{ / }} escape to literal braces
- Missing paths / null input → empty string (no 500s on absent query params)
- Unrecognised / malformed placeholders pass through so typos are visible
- Strings render raw; numbers/bools stringified; objects/arrays compact JSON

Interpolation runs executor-side (Executor.RunNodeAsync walks ConfigJson
and rewrites string leaves), so every node opts in for free. 20 new tests
(17 TemplateEngine + 2 Executor + 1 end-to-end webhook→WebResponse).
2026-08-29 18:44:03 +00:00

186 lines
7.5 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using VolcanicCore.Core;
namespace VolcanicCore.Tests;
public class WebhookEndpointTests : IClassFixture<WebFactory>
{
private readonly WebFactory _factory;
public WebhookEndpointTests(WebFactory f) => _factory = f;
[Fact]
public async Task Unknown_workflow_returns_404()
{
var c = _factory.CreateClient();
var res = await c.PostAsJsonAsync("/wh/does-not-exist/anything", new { });
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
}
[Fact]
public async Task Get_on_unknown_webhook_returns_404_like_post()
{
var c = _factory.CreateClient(new() { AllowAutoRedirect = false });
var res = await c.GetAsync("/wh/does-not-exist/hook");
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
}
[Fact]
public async Task Get_on_webhook_triggers_workflow_with_query_as_json_body()
{
var reg = _factory.Services.GetRequiredService<WebhookRegistry>();
var got = new TaskCompletionSource<JsonElement>();
using var _ = reg.Register("wf-get-1", "hi", (body, _id, _ct) =>
{
got.SetResult(body.Clone());
return Task.CompletedTask;
});
var c = _factory.CreateClient();
var res = await c.GetAsync("/wh/wf-get-1/hi?a=1&name=alice");
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
var body = await got.Task;
Assert.Equal("1", body.GetProperty("a").GetString());
Assert.Equal("alice", body.GetProperty("name").GetString());
}
[Fact]
public async Task Get_with_repeated_query_key_yields_string_array()
{
var reg = _factory.Services.GetRequiredService<WebhookRegistry>();
var got = new TaskCompletionSource<JsonElement>();
using var _ = reg.Register("wf-get-2", "tags", (body, _id, _ct) =>
{
got.SetResult(body.Clone());
return Task.CompletedTask;
});
var c = _factory.CreateClient();
var res = await c.GetAsync("/wh/wf-get-2/tags?tag=a&tag=b&only=x");
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
var body = await got.Task;
var tags = body.GetProperty("tag").EnumerateArray().Select(e => e.GetString()).ToList();
Assert.Equal(new[] { "a", "b" }, tags);
Assert.Equal("x", body.GetProperty("only").GetString());
}
[Fact]
public async Task Get_with_no_query_string_triggers_with_empty_object()
{
var reg = _factory.Services.GetRequiredService<WebhookRegistry>();
var got = new TaskCompletionSource<JsonElement>();
using var _ = reg.Register("wf-get-3", "noargs", (body, _id, _ct) =>
{
got.SetResult(body.Clone());
return Task.CompletedTask;
});
var c = _factory.CreateClient();
var res = await c.GetAsync("/wh/wf-get-3/noargs");
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
var body = await got.Task;
Assert.Equal(JsonValueKind.Object, body.ValueKind);
Assert.Empty(body.EnumerateObject());
}
[Fact]
public async Task Head_on_webhook_returns_200_empty_to_be_safe_for_link_previewers()
{
// Slack/Discord/etc issue HEAD on links to unfurl them. That MUST NOT
// fire the workflow, but also shouldn't look like a broken URL.
var c = _factory.CreateClient();
var res = await c.SendAsync(new HttpRequestMessage(HttpMethod.Head, "/wh/wf-head/hi"));
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
}
[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, GET", 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()
{
var reg = _factory.Services.GetRequiredService<WebhookRegistry>();
var got = new TaskCompletionSource<(JsonElement body, Guid ch)>();
using var _ = reg.Register("wf-1", "hi", (body, id, ct) =>
{
got.SetResult((body.Clone(), id));
return Task.CompletedTask; // no WebResponse → router timeout → 204
});
var c = _factory.CreateClient();
var res = await c.PostAsJsonAsync("/wh/wf-1/hi", new { greeting = "hello" });
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
var (body, ch) = await got.Task;
Assert.Equal("hello", body.GetProperty("greeting").GetString());
Assert.NotEqual(Guid.Empty, ch);
}
[Fact]
public async Task End_to_end_webhook_to_web_response_interpolates_template_from_query_string()
{
// Real workflow: GET /wh/{wf}/hook?test=hello → executor runs
// WebResponseNode with body "value: {input.test}" → the templating
// pass in Executor.RunNodeAsync should render "value: hello" before
// the node sees it.
var c = _factory.CreateClient(new() { HandleCookies = true, AllowAutoRedirect = false });
var login = await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
Assert.Equal(HttpStatusCode.NoContent, login.StatusCode);
var graph = new
{
nodes = new object[]
{
new { id = "t", type_id = "http.webhook", config = new { path = "tpl" } },
new { id = "r", type_id = "http.response",
config = new { status = 200, body = "value: {input.test}", contentType = "text/plain" } },
},
edges = new object[]
{
new { from_node = "t", from_port = 0, to_node = "r", to_port = 0 },
},
};
var create = await c.PostAsJsonAsync("/api/workflows", new { name = "tpl-e2e", graph });
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var wfId = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
var activate = await c.PostAsync($"/api/workflows/{wfId}/activate", null);
Assert.Equal(HttpStatusCode.NoContent, activate.StatusCode);
var res = await c.GetAsync($"/wh/{wfId}/tpl?test=hello");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
Assert.Equal("value: hello", await res.Content.ReadAsStringAsync());
}
[Fact]
public async Task Registered_callback_that_resolves_yields_the_supplied_status_body()
{
var reg = _factory.Services.GetRequiredService<WebhookRegistry>();
var router = _factory.Services.GetRequiredService<WebhookRouter>();
using var _ = reg.Register("wf-2", "reply", (body, id, ct) =>
{
router.Resolve(id, new WebResponsePayload(201, "created!", "text/plain"));
return Task.CompletedTask;
});
var c = _factory.CreateClient();
var res = await c.PostAsJsonAsync("/wh/wf-2/reply", new { });
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
Assert.Equal("created!", await res.Content.ReadAsStringAsync());
}
}