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; /// /// Executor-level egress tests: proves the guard runs before any send and on /// every redirect hop, that redirects are followed manually, and that /// credentials never cross to another origin. /// public class HttpRequestNodeExecutorEgressTests { private static readonly NodeBlueprintCatalog Catalog = NodeTestData.CoreCatalog(); private sealed record SentRequest(HttpMethod Method, Uri Uri, string? Authorization, string? Body); private sealed class RecordingHandler : HttpMessageHandler { private readonly Func _responder; public RecordingHandler(Func responder) => _responder = responder; public List Requests { get; } = new(); protected override async Task SendAsync( HttpRequestMessage request, CancellationToken ct) { var body = request.Content == null ? null : await request.Content.ReadAsStringAsync(ct); Requests.Add(new SentRequest( request.Method, request.RequestUri!, request.Headers.Authorization?.ToString(), body)); return _responder(request, Requests.Count - 1); } } private sealed class RecordingFactory : IHttpClientFactory { private readonly HttpMessageHandler _handler; public RecordingFactory(HttpMessageHandler handler) => _handler = handler; public string? LastClientName { get; private set; } public HttpClient CreateClient(string name) { LastClientName = name; return new HttpClient(_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 HttpResponseMessage Redirect( string location, HttpStatusCode status = HttpStatusCode.Found) { var response = new HttpResponseMessage(status) { Content = new StringContent("{}", Encoding.UTF8, "application/json"), }; response.Headers.TryAddWithoutValidation("Location", location); return response; } private static NodeExecutionContext Context( JsonObject parameters, IReadOnlyDictionary? credentials = null) => new() { Blueprint = Catalog.Get("core.httpRequest")!, Parameters = parameters, Inputs = new IReadOnlyList[] { Array.Empty() }, Credentials = credentials ?? new Dictionary(), }; private static HttpRequestNodeExecutor Executor( RecordingHandler handler, RecordingFactory factory, EgressGuard guard, NodeRequestBudget? quota = null) => new(factory, new CredentialTypeCatalog(), guard, quota ?? EgressTestData.Quota(), BinaryTestData.Store()); private static EgressGuard PublicEgress( StubHostAddressResolver? resolver = null, EgressPolicy? policy = null) => EgressTestData.Guard(policy, resolver ?? StubHostAddressResolver.Returning(EgressTestData.PublicAddress)); [Fact] public async Task Private_literal_url_is_blocked_before_sending() { var handler = new RecordingHandler((_, _) => Json("{}")); var factory = new RecordingFactory(handler); var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["url"] = "http://127.0.0.1/admin" }), default); Assert.False(outcome.Succeeded); Assert.Equal("egress_blocked", outcome.Failure!.Code); Assert.Empty(handler.Requests); } [Fact] public async Task Non_http_scheme_is_blocked_before_sending() { var handler = new RecordingHandler((_, _) => Json("{}")); var factory = new RecordingFactory(handler); var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["url"] = "ftp://example.com/x" }), default); Assert.False(outcome.Succeeded); Assert.Equal("egress_blocked", outcome.Failure!.Code); Assert.Empty(handler.Requests); } [Fact] public async Task Public_host_is_sent() { var handler = new RecordingHandler((_, _) => Json("""{"ok":true}""")); var factory = new RecordingFactory(handler); var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x" }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Single(handler.Requests); } [Fact] public async Task Redirect_to_a_private_host_is_blocked() { var handler = new RecordingHandler((_, index) => index == 0 ? Redirect("http://127.0.0.1/secret") : Json("{}")); var factory = new RecordingFactory(handler); var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x" }), default); Assert.False(outcome.Succeeded); Assert.Equal("egress_blocked", outcome.Failure!.Code); Assert.Single(handler.Requests); // the initial request only } [Fact] public async Task Public_redirect_is_followed_and_post_downgrades_to_get() { var handler = new RecordingHandler((_, index) => index == 0 ? Redirect("https://other.example.com/y") : Json("""{"done":true}""")); var factory = new RecordingFactory(handler); var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["method"] = "POST", ["url"] = "https://example.com/x", ["sendBody"] = true, ["bodyContentType"] = "json", ["body"] = new JsonObject { ["a"] = 1 }, }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal(2, handler.Requests.Count); Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); Assert.NotNull(handler.Requests[0].Body); Assert.Equal(HttpMethod.Get, handler.Requests[1].Method); Assert.Null(handler.Requests[1].Body); Assert.Equal("other.example.com", handler.Requests[1].Uri.Host); } [Fact] public async Task Follow_redirects_false_returns_the_redirect_response() { var handler = new RecordingHandler((_, _) => Redirect("https://other.example.com/y")); var factory = new RecordingFactory(handler); var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["options"] = new JsonObject { ["followRedirects"] = false }, }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Single(handler.Requests); } [Fact] public async Task Exceeding_max_redirects_fails() { var handler = new RecordingHandler((_, _) => Redirect("https://example.com/next")); var factory = new RecordingFactory(handler); var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["options"] = new JsonObject { ["maxRedirects"] = 1 }, }), default); Assert.False(outcome.Succeeded); Assert.Equal("too_many_redirects", outcome.Failure!.Code); Assert.Equal(2, handler.Requests.Count); } [Fact] public async Task Policy_caps_the_node_redirect_setting() { var policy = EgressTestData.Policy(o => o.MaxRedirects = 1); var handler = new RecordingHandler((_, _) => Redirect("https://example.com/next")); var factory = new RecordingFactory(handler); var outcome = await Executor(handler, factory, PublicEgress(policy: policy)).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["options"] = new JsonObject { ["maxRedirects"] = 50 }, }), default); Assert.False(outcome.Succeeded); Assert.Equal("too_many_redirects", outcome.Failure!.Code); Assert.Equal(2, handler.Requests.Count); } [Fact] public async Task Cross_origin_redirect_drops_the_credential() { var handler = new RecordingHandler((_, index) => index == 0 ? Redirect("https://two.example.com/y") : Json("{}")); var factory = new RecordingFactory(handler); var credentials = new Dictionary { [HttpRequestNodeExecutor.CredentialAlias] = new( "httpBasicAuth", new JsonObject { ["username"] = "ada", ["password"] = "secret" }), }; var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["url"] = "https://one.example.com/x", ["authentication"] = "credential", }, credentials), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.NotNull(handler.Requests[0].Authorization); Assert.Null(handler.Requests[1].Authorization); } [Fact] public async Task Same_origin_redirect_keeps_the_credential() { var handler = new RecordingHandler((_, index) => index == 0 ? Redirect("/y") : Json("{}")); var factory = new RecordingFactory(handler); var credentials = new Dictionary { [HttpRequestNodeExecutor.CredentialAlias] = new( "httpBasicAuth", new JsonObject { ["username"] = "ada", ["password"] = "secret" }), }; var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["url"] = "https://one.example.com/x", ["authentication"] = "credential", }, credentials), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.NotNull(handler.Requests[1].Authorization); } [Fact] public async Task Ignore_ssl_issues_selects_the_insecure_client() { var handler = new RecordingHandler((_, _) => Json("{}")); var factory = new RecordingFactory(handler); var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x", ["options"] = new JsonObject { ["ignoreSslIssues"] = true }, }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal(HttpRequestNodeExecutor.InsecureClientName, factory.LastClientName); } [Fact] public async Task Default_client_is_used_without_ignore_ssl_issues() { var handler = new RecordingHandler((_, _) => Json("{}")); var factory = new RecordingFactory(handler); var outcome = await Executor(handler, factory, PublicEgress()).RunAsync( Context(new JsonObject { ["url"] = "https://example.com/x" }), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal(HttpRequestNodeExecutor.TypeName, factory.LastClientName); } }