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.Connectors; using Xunit; namespace w4c_workflows.Tests; /// /// Unit tests for the declarative REST connector executor, driven through a stub /// so no real network is touched. /// public class RestConnectorExecutorTests { 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; } public int Calls { get; private set; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Calls++; 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 NodeBlueprint TelegramBlueprint() => new() { Type = "test.telegram", DisplayName = "Telegram", Origin = NodeOrigin.Connector, Credentials = new List { new() { Alias = "telegram", Required = true } }, Connector = new NodeConnector { BaseUrl = "https://api.telegram.org", Path = "/bot{credential.token}/{operation}", Method = "POST", ContentType = "json", CredentialAlias = "telegram", ResponsePath = "result", }, }; private static RestConnectorExecutor Executor( StubHandler handler, NodeBlueprint? blueprint = null, CredentialTypeCatalog? credentialTypes = null) => new( blueprint ?? TelegramBlueprint(), new StubFactory(handler), credentialTypes ?? new CredentialTypeCatalog(), EgressTestData.Guard(), EgressTestData.Quota()); private static NodeExecutionContext Context( JsonObject parameters, IReadOnlyDictionary? credentials = null, IDictionary? state = null, NodeBlueprint? blueprint = null) => new() { Blueprint = blueprint ?? TelegramBlueprint(), Parameters = parameters, Inputs = new IReadOnlyList[] { Array.Empty() }, Credentials = credentials ?? TelegramCredential(), State = state ?? new Dictionary(), }; private static IReadOnlyDictionary TelegramCredential() => new Dictionary { ["telegram"] = new("telegramApi", new JsonObject { ["token"] = "123:ABC" }), }; private static JsonObject TelegramParameters(JsonObject? body = null, JsonObject? options = null) => new() { ["operation"] = "sendMessage", ["body"] = body ?? new JsonObject { ["chat_id"] = 42, ["text"] = "hi" }, ["options"] = options ?? new JsonObject(), }; /// /// A connector that reports failure inside a HTTP 200 body (the Slack shape): /// the `ok` flag is the success marker and `error`/`needed` carry the reason. /// private static NodeBlueprint SlackBlueprint() => new() { Type = "slack", DisplayName = "Slack", Origin = NodeOrigin.Connector, Credentials = new List { new() { Alias = "slack", Required = true } }, Connector = new NodeConnector { BaseUrl = "https://slack.com", Path = "/api/{operation}", Method = "POST", ContentType = "json", BodyParameter = "body", CredentialAlias = "slack", SuccessPath = "ok", ErrorPath = "error", ErrorDetailsPath = "needed", ErrorCodeMap = new Dictionary { ["missing_scope"] = "slack_missing_scope" }, }, }; private static IReadOnlyDictionary SlackCredential(string token = "xoxb-secret") => new Dictionary { ["slack"] = new("slackApi", new JsonObject { ["token"] = token }), }; private static JsonObject SlackParameters(JsonObject? options = null) => new() { ["operation"] = "chat.postMessage", ["body"] = new JsonObject { ["channel"] = "C012345", ["text"] = "test message" }, ["options"] = options ?? new JsonObject(), }; [Fact] public async Task Builds_the_url_from_parameter_and_credential_placeholders() { var handler = new StubHandler(_ => Json("""{"ok":true,"result":{"message_id":5}}""")); var outcome = await Executor(handler).RunAsync(Context(TelegramParameters()), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal("/bot123:ABC/sendMessage", handler.LastRequest!.RequestUri!.AbsolutePath); Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); Assert.Equal("application/json", handler.LastRequest.Content!.Headers.ContentType!.MediaType); Assert.Contains("\"chat_id\":42", handler.LastBody); } [Fact] public async Task Response_path_extracts_the_payload() { var handler = new StubHandler(_ => Json("""{"ok":true,"result":{"message_id":5}}""")); var outcome = await Executor(handler).RunAsync(Context(TelegramParameters()), default); var item = Assert.Single(outcome.Outputs[0]); Assert.Equal(5, item.Json["message_id"]!.GetValue()); } [Fact] public async Task Response_path_array_becomes_one_item_per_element() { var handler = new StubHandler(_ => Json("""{"ok":true,"result":[{"n":1},{"n":2}]}""")); var outcome = await Executor(handler).RunAsync(Context(TelegramParameters()), default); Assert.Equal(2, outcome.Outputs[0].Count); Assert.Equal(2, outcome.Outputs[0][1].Json["n"]!.GetValue()); } [Fact] public async Task Non_json_response_becomes_a_text_item() { var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("pong", Encoding.UTF8, "text/plain"), }); var outcome = await Executor(handler).RunAsync(Context(TelegramParameters()), default); Assert.Equal("pong", Assert.Single(outcome.Outputs[0]).Json["data"]!.GetValue()); } [Fact] public async Task Unresolved_placeholder_fails_before_sending() { var handler = new StubHandler(_ => Json("{}")); var outcome = await Executor(handler).RunAsync( Context(TelegramParameters(), credentials: new Dictionary()), default); Assert.False(outcome.Succeeded); Assert.Equal("invalid_connector_url", outcome.Failure!.Code); Assert.Contains("{credential.token}", outcome.Failure.Message); Assert.Equal(0, handler.Calls); } [Fact] public async Task Blocked_host_is_rejected_before_sending() { var handler = new StubHandler(_ => Json("{}")); var guard = EgressTestData.Guard(EgressTestData.Policy(o => o.BlockedHosts = ["api.telegram.org"])); var executor = new RestConnectorExecutor( TelegramBlueprint(), new StubFactory(handler), new CredentialTypeCatalog(), guard, EgressTestData.Quota()); var outcome = await executor.RunAsync(Context(TelegramParameters()), default); Assert.False(outcome.Succeeded); Assert.Equal("egress_blocked", outcome.Failure!.Code); Assert.Equal(0, handler.Calls); } [Fact] public async Task Quota_is_shared_across_calls() { var handler = new StubHandler(_ => Json("""{"ok":true,"result":{}}""")); var quota = EgressTestData.Quota(o => o.MaxRequestsPerRun = 1); var executor = new RestConnectorExecutor( TelegramBlueprint(), new StubFactory(handler), new CredentialTypeCatalog(), EgressTestData.Guard(), quota); var state = new Dictionary(); var first = await executor.RunAsync(Context(TelegramParameters(), state: state), default); var second = await executor.RunAsync(Context(TelegramParameters(), state: state), default); Assert.True(first.Succeeded, first.Failure?.Message); Assert.False(second.Succeeded); Assert.Equal("quota_exceeded", second.Failure!.Code); } [Fact] public async Task Error_status_fails_unless_never_error() { var handler = new StubHandler(_ => Json("""{"ok":false,"description":"bad"}""", HttpStatusCode.BadRequest)); // No response path, so a tolerated error body maps straight to an item. var plain = new NodeBlueprint { Type = "test.plain", DisplayName = "Plain", Origin = NodeOrigin.Connector, Connector = new NodeConnector { BaseUrl = "https://api.telegram.org", Path = "/bot{credential.token}/{operation}", Method = "POST", ContentType = "json", CredentialAlias = "telegram", }, }; var failing = await Executor(handler).RunAsync(Context(TelegramParameters()), default); var tolerated = await Executor(handler, plain).RunAsync( Context( TelegramParameters(options: new JsonObject { ["neverError"] = true }), blueprint: plain), default); Assert.False(failing.Succeeded); Assert.Equal("http_error", failing.Failure!.Code); Assert.Equal(400, failing.Failure.HttpStatus); Assert.True(tolerated.Succeeded, tolerated.Failure?.Message); Assert.Equal("bad", Assert.Single(tolerated.Outputs[0]).Json["description"]!.GetValue()); } [Fact] public async Task Form_content_type_sends_form_encoded_fields() { var blueprint = new NodeBlueprint { Type = "test.form", DisplayName = "Form", Origin = NodeOrigin.Connector, Connector = new NodeConnector { BaseUrl = "https://api.example.com", Path = "/token", Method = "POST", ContentType = "form", BodyParameter = "body", }, }; var handler = new StubHandler(_ => Json("{}")); var outcome = await Executor(handler, blueprint).RunAsync( Context(new JsonObject { ["body"] = new JsonObject { ["grant_type"] = "client_credentials" } }, blueprint: blueprint), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal("application/x-www-form-urlencoded", handler.LastRequest!.Content!.Headers.ContentType!.MediaType); Assert.Equal("grant_type=client_credentials", handler.LastBody); } [Fact] public async Task Response_over_the_quota_cap_fails_with_response_too_large() { // P1-12: connectors must share the HTTP node's response-size cap instead of // buffering an unbounded body. 8 bytes is well under the 38-byte JSON body. var handler = new StubHandler(_ => Json("""{"ok":true,"result":{"message_id":5}}""")); var executor = new RestConnectorExecutor( TelegramBlueprint(), new StubFactory(handler), new CredentialTypeCatalog(), EgressTestData.Guard(), EgressTestData.Quota(o => o.MaxResponseBytes = 8)); var outcome = await executor.RunAsync(Context(TelegramParameters()), default); Assert.False(outcome.Succeeded); Assert.Equal("response_too_large", outcome.Failure!.Code); Assert.Equal(1, handler.Calls); } // ------------------------------------------------------------ credential injection [Fact] public async Task Bearer_credential_is_injected_and_a_successful_envelope_maps_to_an_item() { var handler = new StubHandler(_ => Json("""{"ok":true,"channel":"C012345","message":{"text":"test message"}}""")); var outcome = await Executor(handler, SlackBlueprint()).RunAsync( Context(SlackParameters(), SlackCredential(), blueprint: SlackBlueprint()), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); Assert.Equal("Bearer", handler.LastRequest!.Headers.Authorization!.Scheme); Assert.Equal("xoxb-secret", handler.LastRequest.Headers.Authorization.Parameter); Assert.Equal("/api/chat.postMessage", handler.LastRequest.RequestUri!.AbsolutePath); // No credential material in the URL or body — only the header. Assert.DoesNotContain("xoxb-secret", handler.LastBody ?? string.Empty); var item = Assert.Single(outcome.Outputs[0]); Assert.True(item.Json["ok"]!.GetValue()); } [Fact] public async Task Missing_credential_fails_before_sending() { var handler = new StubHandler(_ => Json("""{"ok":true}""")); var outcome = await Executor(handler, SlackBlueprint()).RunAsync( Context(SlackParameters(), credentials: new Dictionary(), blueprint: SlackBlueprint()), default); Assert.False(outcome.Succeeded); Assert.Equal("missing_credential", outcome.Failure!.Code); Assert.Equal(0, handler.Calls); } [Fact] public async Task Credential_without_the_secret_field_fails_before_sending() { var handler = new StubHandler(_ => Json("""{"ok":true}""")); var credentials = new Dictionary { ["slack"] = new("slackApi", new JsonObject()), }; var outcome = await Executor(handler, SlackBlueprint()).RunAsync( Context(SlackParameters(), credentials, blueprint: SlackBlueprint()), default); Assert.False(outcome.Succeeded); Assert.Equal("missing_credential", outcome.Failure!.Code); Assert.Equal(0, handler.Calls); } [Fact] public async Task Unknown_credential_type_fails_before_sending() { var handler = new StubHandler(_ => Json("""{"ok":true}""")); var catalogWithoutSlack = new CredentialTypeCatalog(new[] { new CredentialType { Type = "somethingElse", DisplayName = "Other", Injection = new CredentialInjection { Kind = CredentialInjectionKind.Bearer, TokenField = "token" }, }, }); var outcome = await Executor(handler, SlackBlueprint(), catalogWithoutSlack).RunAsync( Context(SlackParameters(), SlackCredential(), blueprint: SlackBlueprint()), default); Assert.False(outcome.Succeeded); Assert.Equal("missing_credential", outcome.Failure!.Code); Assert.Equal(0, handler.Calls); } // ------------------------------------------------------------ success/error envelope [Fact] public async Task Failed_envelope_at_http_200_fails_with_the_mapped_code() { var handler = new StubHandler(_ => Json("""{"ok":false,"error":"missing_scope","needed":"chat:write"}""")); var outcome = await Executor(handler, SlackBlueprint()).RunAsync( Context(SlackParameters(), SlackCredential(), blueprint: SlackBlueprint()), default); Assert.False(outcome.Succeeded); Assert.Equal("slack_missing_scope", outcome.Failure!.Code); Assert.Equal(200, outcome.Failure.HttpStatus); Assert.Equal("chat:write", outcome.Failure.Description); Assert.Contains("missing_scope", outcome.Failure.Message); } [Fact] public async Task Unmapped_envelope_error_uses_the_generic_code() { var handler = new StubHandler(_ => Json("""{"ok":false,"error":"some_other_error"}""")); var outcome = await Executor(handler, SlackBlueprint()).RunAsync( Context(SlackParameters(), SlackCredential(), blueprint: SlackBlueprint()), default); Assert.False(outcome.Succeeded); Assert.Equal("connector_error", outcome.Failure!.Code); Assert.Equal(200, outcome.Failure.HttpStatus); } [Fact] public async Task Never_error_keeps_a_failed_envelope_as_an_item() { var handler = new StubHandler(_ => Json("""{"ok":false,"error":"missing_scope","needed":"chat:write"}""")); var outcome = await Executor(handler, SlackBlueprint()).RunAsync( Context( SlackParameters(new JsonObject { ["neverError"] = true }), SlackCredential(), blueprint: SlackBlueprint()), default); Assert.True(outcome.Succeeded, outcome.Failure?.Message); var item = Assert.Single(outcome.Outputs[0]); Assert.False(item.Json["ok"]!.GetValue()); Assert.Equal("missing_scope", item.Json["error"]!.GetValue()); } }