443 lines
18 KiB
C#
443 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the declarative REST connector executor, driven through a stub
|
|
/// <see cref="HttpMessageHandler"/> so no real network is touched.
|
|
/// </summary>
|
|
public class RestConnectorExecutorTests
|
|
{
|
|
private sealed class StubHandler : HttpMessageHandler
|
|
{
|
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
|
|
|
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> 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<HttpResponseMessage> 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<NodeCredentialLink> { 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<string, CredentialData>? credentials = null,
|
|
IDictionary<string, object?>? state = null,
|
|
NodeBlueprint? blueprint = null)
|
|
=> new()
|
|
{
|
|
Blueprint = blueprint ?? TelegramBlueprint(),
|
|
Parameters = parameters,
|
|
Inputs = new IReadOnlyList<FlowItem>[] { Array.Empty<FlowItem>() },
|
|
Credentials = credentials ?? TelegramCredential(),
|
|
State = state ?? new Dictionary<string, object?>(),
|
|
};
|
|
|
|
private static IReadOnlyDictionary<string, CredentialData> TelegramCredential()
|
|
=> new Dictionary<string, CredentialData>
|
|
{
|
|
["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(),
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static NodeBlueprint SlackBlueprint() => new()
|
|
{
|
|
Type = "slack",
|
|
DisplayName = "Slack",
|
|
Origin = NodeOrigin.Connector,
|
|
Credentials = new List<NodeCredentialLink> { 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<string, string> { ["missing_scope"] = "slack_missing_scope" },
|
|
},
|
|
};
|
|
|
|
private static IReadOnlyDictionary<string, CredentialData> SlackCredential(string token = "xoxb-secret")
|
|
=> new Dictionary<string, CredentialData>
|
|
{
|
|
["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<int>());
|
|
}
|
|
|
|
[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<int>());
|
|
}
|
|
|
|
[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<string>());
|
|
}
|
|
|
|
[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<string, CredentialData>()), 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<string, object?>();
|
|
|
|
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<string>());
|
|
}
|
|
|
|
[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<bool>());
|
|
}
|
|
|
|
[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<string, CredentialData>(), 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<string, CredentialData>
|
|
{
|
|
["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<bool>());
|
|
Assert.Equal("missing_scope", item.Json["error"]!.GetValue<string>());
|
|
}
|
|
}
|