377 lines
14 KiB
C#
377 lines
14 KiB
C#
|
|
using System.Net;
|
||
|
|
using System.Net.Http.Headers;
|
||
|
|
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.Binary;
|
||
|
|
using w4c_workflows.Services.Nodes.Executors;
|
||
|
|
using Xunit;
|
||
|
|
|
||
|
|
namespace w4c_workflows.Tests;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Binary/file tests for the HTTP Request node: <c>responseFormat: file</c>
|
||
|
|
/// downloads into the binary store, and <c>multipart</c>/<c>binary</c> bodies
|
||
|
|
/// upload an input item's binary attachment. The stub handler keeps it
|
||
|
|
/// network-free.
|
||
|
|
/// </summary>
|
||
|
|
public class HttpRequestNodeExecutorBinaryTests
|
||
|
|
{
|
||
|
|
private static readonly NodeBlueprintCatalog Catalog = NodeTestData.CoreCatalog();
|
||
|
|
|
||
|
|
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 byte[]? LastBytes { get; private set; }
|
||
|
|
public string? LastBody { get; private set; }
|
||
|
|
|
||
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
LastRequest = request;
|
||
|
|
if (request.Content != null)
|
||
|
|
{
|
||
|
|
LastBytes = await request.Content.ReadAsByteArrayAsync(cancellationToken);
|
||
|
|
LastBody = Encoding.UTF8.GetString(LastBytes);
|
||
|
|
}
|
||
|
|
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 HttpResponseMessage Binary(
|
||
|
|
byte[] payload, string? contentType = null, string? fileName = null)
|
||
|
|
{
|
||
|
|
var content = new ByteArrayContent(payload);
|
||
|
|
if (contentType != null)
|
||
|
|
content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||
|
|
if (fileName != null)
|
||
|
|
content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
|
||
|
|
{
|
||
|
|
FileNameStar = fileName,
|
||
|
|
};
|
||
|
|
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
|
||
|
|
}
|
||
|
|
|
||
|
|
private static HttpRequestNodeExecutor Executor(StubHandler handler, IBinaryStore store)
|
||
|
|
=> new(
|
||
|
|
new StubFactory(handler),
|
||
|
|
new CredentialTypeCatalog(),
|
||
|
|
EgressTestData.Guard(),
|
||
|
|
EgressTestData.Quota(),
|
||
|
|
store);
|
||
|
|
|
||
|
|
private static NodeExecutionContext Context(JsonObject parameters, IReadOnlyList<FlowItem>? input = null)
|
||
|
|
=> new()
|
||
|
|
{
|
||
|
|
Blueprint = Catalog.Get("core.httpRequest")!,
|
||
|
|
Parameters = parameters,
|
||
|
|
Inputs = new IReadOnlyList<FlowItem>[] { input ?? Array.Empty<FlowItem>() },
|
||
|
|
Credentials = new Dictionary<string, CredentialData>(),
|
||
|
|
};
|
||
|
|
|
||
|
|
private static async Task<FlowItem> ItemWithBinaryAsync(
|
||
|
|
IBinaryStore store,
|
||
|
|
byte[] bytes,
|
||
|
|
string property = "data",
|
||
|
|
string? fileName = "upload.bin",
|
||
|
|
string? mime = "application/octet-stream")
|
||
|
|
{
|
||
|
|
var attachment = await store.SaveAsync(new MemoryStream(bytes), fileName, mime);
|
||
|
|
return new FlowItem
|
||
|
|
{
|
||
|
|
Json = new JsonObject(),
|
||
|
|
Binary = new Dictionary<string, BinaryAttachment>(StringComparer.Ordinal) { [property] = attachment },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async Task<byte[]> ReadAllAsync(Stream stream)
|
||
|
|
{
|
||
|
|
using var buffer = new MemoryStream();
|
||
|
|
await stream.CopyToAsync(buffer);
|
||
|
|
return buffer.ToArray();
|
||
|
|
}
|
||
|
|
|
||
|
|
private static JsonObject FileOptions(string? putOutputInField = null)
|
||
|
|
{
|
||
|
|
var options = new JsonObject { ["responseFormat"] = "file" };
|
||
|
|
if (putOutputInField != null)
|
||
|
|
options["putOutputInField"] = putOutputInField;
|
||
|
|
return options;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ download
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task File_response_format_stores_the_body_and_emits_a_binary_item()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var payload = Encoding.UTF8.GetBytes("%PDF-1.7 fake report");
|
||
|
|
var handler = new StubHandler(_ => Binary(payload, "application/pdf", "report.pdf"));
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["url"] = "https://example.com/files/ignored",
|
||
|
|
["options"] = FileOptions(),
|
||
|
|
}), default);
|
||
|
|
|
||
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
||
|
|
var item = Assert.Single(outcome.Outputs[0]);
|
||
|
|
Assert.Equal("report.pdf", item.Json["fileName"]!.GetValue<string>());
|
||
|
|
Assert.Equal("application/pdf", item.Json["mimeType"]!.GetValue<string>());
|
||
|
|
Assert.Equal(payload.Length, item.Json["sizeBytes"]!.GetValue<long>());
|
||
|
|
Assert.Equal("pdf", item.Json["fileExtension"]!.GetValue<string>());
|
||
|
|
|
||
|
|
var attachment = Assert.Single(item.Binary!).Value;
|
||
|
|
Assert.Equal("report.pdf", attachment.FileName);
|
||
|
|
await using var stored = await store.OpenAsync(attachment.AssetId);
|
||
|
|
Assert.Equal(payload, await ReadAllAsync(stored!));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task File_response_falls_back_to_the_url_segment_for_the_name()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var payload = Encoding.UTF8.GetBytes("data");
|
||
|
|
var handler = new StubHandler(_ => Binary(payload, "application/octet-stream"));
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["url"] = "https://example.com/files/report.dat",
|
||
|
|
["options"] = FileOptions(),
|
||
|
|
}), default);
|
||
|
|
|
||
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
||
|
|
Assert.Equal("report.dat", Assert.Single(outcome.Outputs[0]).Json["fileName"]!.GetValue<string>());
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task File_response_honours_put_output_in_field()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var handler = new StubHandler(_ => Binary(Encoding.UTF8.GetBytes("x"), "text/plain", "x.txt"));
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["url"] = "https://example.com/x",
|
||
|
|
["options"] = FileOptions("file"),
|
||
|
|
}), default);
|
||
|
|
|
||
|
|
var item = Assert.Single(outcome.Outputs[0]);
|
||
|
|
Assert.Equal("x.txt", item.Json["file"]!["fileName"]!.GetValue<string>());
|
||
|
|
Assert.NotNull(item.Binary!["data"]);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task File_response_is_not_paginated()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var calls = 0;
|
||
|
|
var handler = new StubHandler(_ =>
|
||
|
|
{
|
||
|
|
calls++;
|
||
|
|
return Binary(Encoding.UTF8.GetBytes("once"), "application/octet-stream", "once.bin");
|
||
|
|
});
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["url"] = "https://example.com/x",
|
||
|
|
["options"] = new JsonObject
|
||
|
|
{
|
||
|
|
["responseFormat"] = "file",
|
||
|
|
["pagination"] = new JsonObject { ["mode"] = "nextUrl", ["nextUrlPath"] = "next" },
|
||
|
|
},
|
||
|
|
}), default);
|
||
|
|
|
||
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
||
|
|
Assert.Equal(1, calls);
|
||
|
|
Assert.Single(outcome.Outputs[0]);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ upload
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Multipart_body_sends_fields_and_the_input_binary()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var fileBytes = Encoding.UTF8.GetBytes("FILE-CONTENT");
|
||
|
|
var input = await ItemWithBinaryAsync(store, fileBytes, "data", "note.txt", "text/plain");
|
||
|
|
var handler = new StubHandler(_ => Json("""{"ok":true}"""));
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["method"] = "POST",
|
||
|
|
["url"] = "https://example.com/upload",
|
||
|
|
["sendBody"] = true,
|
||
|
|
["bodyContentType"] = "multipart",
|
||
|
|
["binaryField"] = "data",
|
||
|
|
["body"] = new JsonObject { ["caption"] = "hi" },
|
||
|
|
}, new[] { input }), default);
|
||
|
|
|
||
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
||
|
|
Assert.StartsWith("multipart/form-data", handler.LastRequest!.Content!.Headers.ContentType!.ToString());
|
||
|
|
Assert.Contains("caption", handler.LastBody);
|
||
|
|
Assert.Contains("hi", handler.LastBody);
|
||
|
|
Assert.Contains("note.txt", handler.LastBody);
|
||
|
|
Assert.Contains("FILE-CONTENT", handler.LastBody);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Multipart_with_an_empty_binary_field_sends_fields_only()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var handler = new StubHandler(_ => Json("{}"));
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["method"] = "POST",
|
||
|
|
["url"] = "https://example.com/upload",
|
||
|
|
["sendBody"] = true,
|
||
|
|
["bodyContentType"] = "multipart",
|
||
|
|
["binaryField"] = "",
|
||
|
|
["body"] = new JsonObject { ["caption"] = "hi" },
|
||
|
|
}), default);
|
||
|
|
|
||
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
||
|
|
Assert.Contains("caption", handler.LastBody);
|
||
|
|
Assert.DoesNotContain("filename=", handler.LastBody);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Binary_body_sends_the_raw_attachment_with_its_mime_type()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var fileBytes = Encoding.UTF8.GetBytes("raw-bytes");
|
||
|
|
var input = await ItemWithBinaryAsync(store, fileBytes, "data", "raw.bin", "application/x-custom");
|
||
|
|
var handler = new StubHandler(_ => Json("{}"));
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["method"] = "PUT",
|
||
|
|
["url"] = "https://example.com/blob",
|
||
|
|
["sendBody"] = true,
|
||
|
|
["bodyContentType"] = "binary",
|
||
|
|
["binaryField"] = "data",
|
||
|
|
}, new[] { input }), default);
|
||
|
|
|
||
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
||
|
|
Assert.Equal("application/x-custom", handler.LastRequest!.Content!.Headers.ContentType!.MediaType);
|
||
|
|
Assert.Equal(fileBytes, handler.LastBytes);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Binary_body_defaults_to_octet_stream_when_the_attachment_has_no_mime()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var input = await ItemWithBinaryAsync(store, [1, 2, 3], "data", null, null);
|
||
|
|
var handler = new StubHandler(_ => Json("{}"));
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["method"] = "POST",
|
||
|
|
["url"] = "https://example.com/blob",
|
||
|
|
["sendBody"] = true,
|
||
|
|
["bodyContentType"] = "binary",
|
||
|
|
["binaryField"] = "data",
|
||
|
|
}, new[] { input }), default);
|
||
|
|
|
||
|
|
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
|
||
|
|
Assert.Equal(
|
||
|
|
"application/octet-stream", handler.LastRequest!.Content!.Headers.ContentType!.MediaType);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Binary_body_without_the_named_field_fails_before_sending()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var handler = new StubHandler(_ => Json("{}"));
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["method"] = "POST",
|
||
|
|
["url"] = "https://example.com/blob",
|
||
|
|
["sendBody"] = true,
|
||
|
|
["bodyContentType"] = "binary",
|
||
|
|
["binaryField"] = "data",
|
||
|
|
}), default);
|
||
|
|
|
||
|
|
Assert.False(outcome.Succeeded);
|
||
|
|
Assert.Equal("invalid_parameter", outcome.Failure!.Code);
|
||
|
|
Assert.Contains("no binary field 'data'", outcome.Failure.Message);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Multipart_without_the_named_field_fails()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var handler = new StubHandler(_ => Json("{}"));
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["method"] = "POST",
|
||
|
|
["url"] = "https://example.com/upload",
|
||
|
|
["sendBody"] = true,
|
||
|
|
["bodyContentType"] = "multipart",
|
||
|
|
["binaryField"] = "data",
|
||
|
|
["body"] = new JsonObject { ["caption"] = "hi" },
|
||
|
|
}), default);
|
||
|
|
|
||
|
|
Assert.False(outcome.Succeeded);
|
||
|
|
Assert.Equal("invalid_parameter", outcome.Failure!.Code);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Unknown_binary_asset_reference_fails()
|
||
|
|
{
|
||
|
|
var store = BinaryTestData.Store();
|
||
|
|
var input = new FlowItem
|
||
|
|
{
|
||
|
|
Json = new JsonObject(),
|
||
|
|
Binary = new Dictionary<string, BinaryAttachment>(StringComparer.Ordinal)
|
||
|
|
{
|
||
|
|
["data"] = new BinaryAttachment("sha256:" + new string('b', 64), "gone.bin", "application/octet-stream", 1),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
var handler = new StubHandler(_ => Json("{}"));
|
||
|
|
|
||
|
|
var outcome = await Executor(handler, store).RunAsync(
|
||
|
|
Context(new JsonObject
|
||
|
|
{
|
||
|
|
["method"] = "POST",
|
||
|
|
["url"] = "https://example.com/blob",
|
||
|
|
["sendBody"] = true,
|
||
|
|
["bodyContentType"] = "binary",
|
||
|
|
["binaryField"] = "data",
|
||
|
|
}, new[] { input }), default);
|
||
|
|
|
||
|
|
Assert.False(outcome.Succeeded);
|
||
|
|
Assert.Contains("was not found in the binary store", outcome.Failure!.Message);
|
||
|
|
}
|
||
|
|
}
|