using System.Net; using System.Text; using System.Text.Json.Nodes; using w4c_workflows.Models.Credentials; using w4c_workflows.Models.Nodes; using w4c_workflows.Services.Credentials; using w4c_workflows.Services.Nodes; using w4c_workflows.Services.Nodes.Executors; using w4c_workflows.Services.Security; using Xunit; namespace w4c_workflows.Tests; /// /// Unit tests for the HTTP Request node, driven through a stub /// so no real network is touched. /// public class HttpRequestNodeExecutorTests { private static readonly NodeBlueprintCatalog Catalog = NodeTestData.CoreCatalog(); private sealed class StubHandler : HttpMessageHandler { private readonly Func _responder; public StubHandler(Func responder) => _responder = responder; public HttpRequestMessage? LastRequest { get; private set; } public string? LastBody { get; private set; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; if (request.Content != null) LastBody = await request.Content.ReadAsStringAsync(cancellationToken); return _responder(request); } } private sealed class StubFactory : IHttpClientFactory { private readonly HttpMessageHandler _handler; public StubFactory(HttpMessageHandler handler) => _handler = handler; public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false); } private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; private static FlowItem Item(string json) => FlowItem.FromJson(JsonNode.Parse(json)!.AsObject()); private static NodeExecutionContext Context( JsonObject parameters, IReadOnlyList? input = null, IReadOnlyDictionary? credentials = null) => new() { Blueprint = Catalog.Get("core.httpRequest")!, Parameters = parameters, Inputs = new IReadOnlyList[] { input ?? Array.Empty() }, Credentials = credentials ?? new Dictionary(), }; private static HttpRequestNodeExecutor Executor(StubHandler handler) => new( new StubFactory(handler), new CredentialTypeCatalog(), EgressTestData.Guard(), EgressTestData.Quota(), BinaryTestData.Store()); [Fact] public async Task Get_json_object_returns_one_item() { var handler = new StubHandler(_ => Json("""{"a":1}""")); var outcome = await Executor(handler).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x" }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal(1, Assert.Single(outcome.Outputs[0]).Json["a"]!.GetValue()); Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method); } [Fact] public async Task Get_json_array_returns_one_item_per_element() { var handler = new StubHandler(_ => Json("""[{"n":1},{"n":2}]""")); var outcome = await Executor(handler).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x" }), default); Assert.Equal(2, outcome.Outputs[0].Count); Assert.Equal(2, outcome.Outputs[0][1].Json["n"]!.GetValue()); } [Fact] public async Task Non_success_status_fails_with_the_http_status() { var handler = new StubHandler(_ => Json("""{"message":"nope"}""", HttpStatusCode.NotFound)); var outcome = await Executor(handler).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x" }), default); Assert.False(outcome.Succeeded); Assert.Equal("http_error", outcome.Failure!.Code); Assert.Equal(404, outcome.Failure.HttpStatus); } [Fact] public async Task Never_error_returns_the_body_instead_of_failing() { var handler = new StubHandler(_ => Json("""{"message":"nope"}""", HttpStatusCode.BadRequest)); var outcome = await Executor(handler).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["options"] = new JsonObject { ["neverError"] = true }, }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal("nope", Assert.Single(outcome.Outputs[0]).Json["message"]!.GetValue()); } [Fact] public async Task Post_sends_headers_and_a_json_body() { var handler = new StubHandler(_ => Json("""{"ok":true}""")); var outcome = await Executor(handler).RunAsync( Context(new JsonObject { ["method"] = "POST", ["url"] = "https://example.com/x", ["sendHeaders"] = true, ["headers"] = new JsonArray( new JsonObject { ["name"] = "X-Trace", ["value"] = "abc" }), ["sendBody"] = true, ["bodyContentType"] = "json", ["body"] = new JsonObject { ["name"] = "Ada" }, }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Equal("abc", handler.LastRequest.Headers.GetValues("X-Trace").Single()); Assert.Contains("\"name\":\"Ada\"", handler.LastBody); } [Fact] public async Task Invalid_url_fails_before_sending() { var handler = new StubHandler(_ => Json("{}")); var outcome = await Executor(handler).RunAsync( Context(new JsonObject { ["url"] = "not a url" }), default); Assert.False(outcome.Succeeded); Assert.Equal("invalid_url", outcome.Failure!.Code); Assert.Null(handler.LastRequest); } [Fact] public async Task Text_response_format_wraps_the_body_in_data() { var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("hello", Encoding.UTF8, "text/plain"), }); var outcome = await Executor(handler).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["options"] = new JsonObject { ["responseFormat"] = "text" }, }), default); Assert.Equal("hello", Assert.Single(outcome.Outputs[0]).Json["data"]!.GetValue()); } [Fact] public async Task Put_output_in_field_wraps_the_response() { var handler = new StubHandler(_ => Json("""{"a":1}""")); var outcome = await Executor(handler).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["options"] = new JsonObject { ["putOutputInField"] = "response" }, }), default); var item = Assert.Single(outcome.Outputs[0]); Assert.Equal(1, item.Json["response"]!["a"]!.GetValue()); } [Fact] public async Task Credential_auth_applies_basic_authorization() { var handler = new StubHandler(_ => Json("{}")); var credentials = new Dictionary { [HttpRequestNodeExecutor.CredentialAlias] = new( "httpBasicAuth", new JsonObject { ["username"] = "ada", ["password"] = "secret" }), }; var outcome = await Executor(handler).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["authentication"] = "credential", }, credentials: credentials), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("ada:secret")); Assert.Equal(expected, handler.LastRequest!.Headers.Authorization!.ToString()); } [Fact] public async Task Missing_required_credential_fails() { var handler = new StubHandler(_ => Json("{}")); var outcome = await Executor(handler).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["authentication"] = "credential", }), default); Assert.False(outcome.Succeeded); Assert.Equal("missing_credential", outcome.Failure!.Code); } }