using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEExtension.NetCore.XUnit.Assertions;
using XFEExtension.NetCore.XUnit.Attributes;
using XFEWorkflow.Abstractions;
using XFEWorkflow.Core.Runtime;
using XFEWorkflow.Space.Contracts;
using XFEWorkflow.Space.Server;
namespace XFEWorkflow.Test;
[TestFixture]
public class SpaceWorkflowTests
{
private static readonly SpaceUser Author = new("author", "author", "工作流作者", SpaceRole.Author);
private static readonly SpaceUser Other = new("other", "other", "另一位作者", SpaceRole.Author);
private static readonly SpaceUser Admin = new("admin", "admin", "管理员", SpaceRole.Administrator);
private static JsonObject Resource(string id, string? decision = null) => new() { ["kind"] = "workflow", ["id"] = id, ["decision"] = decision, ["reason"] = "审核说明 中文😀" };
private static async Task<UploadSession> StageAsync(SpaceStore store, byte[] bytes, SpaceUser? author = null, string version = "1.0.0", string? hash = null)
{
author ??= Author;
var session = (await store.AuthorAsync("uploads/begin", new() { ["kind"] = "workflow", ["version"] = version, ["length"] = bytes.Length, ["sha256"] = hash ?? Convert.ToHexString(SHA256.HashData(bytes)) }, author)).Deserialize<UploadSession>(WorkflowJson.Options)!;
for (var i = 0; i < session.ChunkCount; i++)
await store.AuthorAsync("uploads/chunk", new() { ["id"] = session.Id, ["index"] = i, ["data"] = Convert.ToBase64String(bytes.AsSpan(i * PluginPackage.ChunkBytes, (int)Math.Min(PluginPackage.ChunkBytes, bytes.Length - (long)i * PluginPackage.ChunkBytes))) }, author);
return session;
}
private static async Task<WorkflowPublication> UploadAsync(SpaceStore store, WorkflowDefinition flow, string directory, string version = "1.0.0", SpaceUser? author = null)
{
var path = Path.Combine(directory, Guid.NewGuid().ToString("N") + ".xfeworkflow"); WorkflowPackage.Create(flow, path);
var session = await StageAsync(store, await File.ReadAllBytesAsync(path), author, version);
try { return (await store.AuthorAsync("uploads/complete", new() { ["id"] = session.Id }, author ?? Author)).Deserialize<WorkflowPublication>(WorkflowJson.Options)!; }
catch { await store.AuthorAsync("uploads/cancel", new() { ["id"] = session.Id }, author ?? Author); throw; }
}
[Test]
public async Task PreviewChecksPublicationOwnershipAndPackageIntegrityWithoutExecutingCode()
{
await using var workspace = new TestWorkspace(); var store = new SpaceStore(Path.Combine(workspace.Paths.Root, "Space"));
var flow = WorkflowTemplates.CreateCode(); flow.Code = "// 中文😀 preview only\nthrow new Exception(\"预览不会运行\");"; flow.PluginVersions["missing.plugin"] = "1.0.0";
var publication = await UploadAsync(store, flow, workspace.Paths.Temp);
await Assert.ThrowsAsync<FileNotFoundException>(() => store.PublicAsync("workflows/preview", Resource(publication.Id)));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => store.AuthorAsync("workflows/preview/private", Resource(publication.Id), Other));
foreach (var user in new[] { Author, Admin })
{
var privatePreview = (await store.AuthorAsync("workflows/preview/private", Resource(publication.Id), user)).Deserialize<WorkflowPreview>(WorkflowJson.Options)!;
Assert.Equal(flow.Code, privatePreview.Definition.Code); Assert.Equal(0, privatePreview.Plugins.Count);
}
await store.AuthorAsync("reviews/decide", Resource(publication.Id, "publish"), Admin);
var preview = (await store.PublicAsync("workflows/preview", Resource(publication.Id))).Deserialize<WorkflowPreview>(WorkflowJson.Options)!;
Assert.Equal(publication.Id, preview.PublicationId); Assert.Equal(publication.Sha256, preview.Sha256); Assert.Equal(flow.Id, preview.Definition.Id); Assert.Equal(flow.Code, preview.Definition.Code);
Assert.Equal(0, (await new Infrastructure.Storage.WorkflowRepository(workspace.Paths).ListAsync()).Count);
var path = Path.Combine(store.Root, "packages", publication.Id + ".xfeworkflow"); var bytes = await File.ReadAllBytesAsync(path); bytes[0] ^= 1; await File.WriteAllBytesAsync(path, bytes);
await Assert.ThrowsAsync<InvalidDataException>(() => store.PublicAsync("workflows/preview", Resource(publication.Id)));
}
[Test]
public async Task PortablePackagesPreserveGraphCodeFieldsUnicodeAndNeverEnableWorkflows()
{
await using var workspace = new TestWorkspace();
foreach (var kind in new[] { WorkflowKind.Graph, WorkflowKind.CSharp })
{
var flow = TestSupport.Graph(new NodeDefinition { CapabilityId = "core.log", Parameters = new() { ["message"] = "中文😀👨👩👧👦é $var 不替换" } });
flow.Kind = kind; flow.Code = "// 中文与表情😀\nreturn null;"; flow.Enabled = true;
flow.PluginVersions = new() { ["xfe.wecom"] = "2.0.0" }; flow.Inputs = [new() { Name = "message", Label = "消息", Type = WorkflowValueType.String }];
var path = Path.Combine(workspace.Paths.Temp, kind + ".xfeworkflow"); WorkflowPackage.Create(flow, path);
var actual = WorkflowPackage.Inspect(path); Assert.Equal(flow.Id, actual.Id); Assert.Equal(flow.Code, actual.Code); Assert.False(actual.Enabled == true); Assert.True(flow.Enabled == true);
Assert.Equal(flow.Graph.Nodes[1].Parameters.String("message"), actual.Graph.Nodes[1].Parameters.String("message"));
using var zip = ZipFile.OpenRead(path); using var reader = new StreamReader(zip.Entries.Single().Open()); var json = reader.ReadToEnd();
Assert.Contains("中文😀", json); Assert.False(json.Contains("\\u4E2D", StringComparison.OrdinalIgnoreCase));
}
}
[Test]
public async Task WorkflowReviewDownloadAndRestartRemainSeparateFromPluginCatalog()
{
await using var workspace = new TestWorkspace(); var store = new SpaceStore(Path.Combine(workspace.Paths.Root, "Space"));
var flow = WorkflowTemplates.CreateGraph(); flow.Name = "联系人遍历😀"; flow.Description = "通过连线输出名称";
var publication = await UploadAsync(store, flow, workspace.Paths.Temp); Assert.Equal(PublicationStatus.Pending, publication.Status);
Assert.Equal(0, (await store.PublicAsync("workflows/search", [])).Deserialize<WorkflowCatalogPage>(WorkflowJson.Options)!.Total);
await Assert.ThrowsAsync<FileNotFoundException>(() => store.PublicAsync("packages/chunk", new() { ["kind"] = "workflow", ["id"] = publication.Id, ["index"] = 0 }));
var mine = (await store.AuthorAsync("workflows/mine", [], Author)).Deserialize<List<WorkflowPublication>>(WorkflowJson.Options)!; Assert.Equal(1, mine.Count);
Assert.Equal(0, (await store.AuthorAsync("plugins/mine", [], Author)).Deserialize<List<PluginPublication>>(WorkflowJson.Options)!.Count);
await store.AuthorAsync("reviews/decide", Resource(publication.Id, "publish"), Admin);
var restarted = new SpaceStore(store.Root);
var catalog = (await restarted.PublicAsync("workflows/search", new() { ["query"] = "联系人 作者", ["limit"] = 1 })).Deserialize<WorkflowCatalogPage>(WorkflowJson.Options)!;
Assert.Equal(1, catalog.Total); Assert.Equal(flow.Name, catalog.Items.Single().Workflow.Name);
Assert.Equal(0, (await restarted.PublicAsync("plugins/search", [])).Deserialize<CatalogPage>(WorkflowJson.Options)!.Total);
var chunk = await restarted.PublicAsync("packages/chunk", new() { ["kind"] = "workflow", ["id"] = publication.Id, ["index"] = 0 });
Assert.Equal(publication.Sha256, Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(chunk["data"]!.GetValue<string>()))).ToLowerInvariant());
await Assert.ThrowsAsync<InvalidDataException>(() => restarted.PublicAsync("packages/chunk", new() { ["kind"] = "workflow", ["id"] = publication.Id, ["index"] = 1 }));
await restarted.AuthorAsync("workflows/withdraw", Resource(publication.Id), Author);
Assert.Equal(0, (await restarted.PublicAsync("workflows/search", [])).Deserialize<WorkflowCatalogPage>(WorkflowJson.Options)!.Total);
await Assert.ThrowsAsync<FileNotFoundException>(() => restarted.PublicAsync("packages/chunk", new() { ["kind"] = "workflow", ["id"] = publication.Id, ["index"] = 0 }));
}
[Test]
public async Task WorkflowOwnershipPermissionsImmutableVersionsAndReviewTransitionsAreEnforced()
{
await using var workspace = new TestWorkspace(); var store = new SpaceStore(Path.Combine(workspace.Paths.Root, "Space")); var flow = WorkflowTemplates.CreateGraph();
var publication = await UploadAsync(store, flow, workspace.Paths.Temp);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => store.AuthorAsync("reviews/list", new() { ["kind"] = "workflow" }, Author));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => store.AuthorAsync("reviews/decide", Resource(publication.Id, "publish"), Author));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => store.AuthorAsync("workflows/withdraw", Resource(publication.Id), Other));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => UploadAsync(store, flow, workspace.Paths.Temp, "2.0.0", Other));
await Assert.ThrowsAsync<InvalidOperationException>(() => UploadAsync(store, flow, workspace.Paths.Temp));
await Assert.ThrowsAsync<InvalidOperationException>(() => store.AuthorAsync("workflows/withdraw", Resource(publication.Id), Author));
await store.AuthorAsync("reviews/decide", Resource(publication.Id, "reject"), Admin);
await Assert.ThrowsAsync<InvalidOperationException>(() => store.AuthorAsync("reviews/decide", Resource(publication.Id, "publish"), Admin));
var rejected = (await store.AuthorAsync("workflows/mine", [], Author)).Deserialize<List<WorkflowPublication>>(WorkflowJson.Options)!.Single();
Assert.Equal(PublicationStatus.Rejected, rejected.Status); Assert.Equal("审核说明 中文😀", rejected.ReviewReason);
await Assert.ThrowsAsync<InvalidOperationException>(() => UploadAsync(store, flow, workspace.Paths.Temp));
}
[Test]
public async Task WorkflowCatalogUsesSemanticLatestVersionAndPagination()
{
await using var workspace = new TestWorkspace(); var store = new SpaceStore(Path.Combine(workspace.Paths.Root, "Space")); var flow = WorkflowTemplates.CreateGraph();
foreach (var version in new[] { "2.0.0", "10.0.0" }) { var result = await UploadAsync(store, flow, workspace.Paths.Temp, version); await store.AuthorAsync("reviews/decide", Resource(result.Id, "publish"), Admin); }
var second = WorkflowTemplates.CreateGraph(); second.Name = "另一个工作流";
var result2 = await UploadAsync(store, second, workspace.Paths.Temp); await store.AuthorAsync("reviews/decide", Resource(result2.Id, "publish"), Admin);
var all = (await store.PublicAsync("workflows/search", new() { ["limit"] = 1, ["offset"] = 1 })).Deserialize<WorkflowCatalogPage>(WorkflowJson.Options)!;
Assert.Equal(2, all.Total); Assert.Equal("10.0.0", all.Items.Single().Workflow.Version);
var versions = (await store.PublicAsync("workflows/detail", new() { ["workflowId"] = flow.Id })).Deserialize<List<WorkflowPublication>>(WorkflowJson.Options)!;
Assert.Equal(2, versions.Count); Assert.Equal("10.0.0", versions[0].Workflow.Version);
}
[Test]
public async Task WorkflowUploadsRejectCorruptionAndCancellationChecksTheOwner()
{
await using var workspace = new TestWorkspace(); var store = new SpaceStore(Path.Combine(workspace.Paths.Root, "Space"));
var staged = await StageAsync(store, [1, 2, 3], hash: new string('0', 64));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => store.AuthorAsync("uploads/cancel", new() { ["id"] = staged.Id }, Other));
await Assert.ThrowsAsync<InvalidDataException>(() => store.AuthorAsync("uploads/complete", new() { ["id"] = staged.Id }, Author));
await store.AuthorAsync("uploads/cancel", new() { ["id"] = staged.Id }, Author);
Assert.Equal(0, Directory.GetFiles(Path.Combine(store.Root, "uploads")).Length);
await Assert.ThrowsAsync<FileNotFoundException>(() => store.AuthorAsync("uploads/complete", new() { ["id"] = staged.Id }, Author));
await Assert.ThrowsAsync<InvalidDataException>(() => store.AuthorAsync("uploads/begin", new() { ["kind"] = "workflow", ["length"] = WorkflowPackage.MaximumBytes + 1, ["version"] = "1.0.0", ["sha256"] = new string('0', 64) }, Author));
await Assert.ThrowsAsync<InvalidDataException>(() => store.AuthorAsync("uploads/begin", new() { ["kind"] = "workflow", ["length"] = 1, ["version"] = "latest", ["sha256"] = new string('0', 64) }, Author));
await Assert.ThrowsAsync<InvalidDataException>(() => store.AuthorAsync("uploads/begin", new() { ["kind"] = "unknown", ["length"] = 1, ["sha256"] = new string('0', 64) }, Author));
}
[Test]
public async Task WorkflowInspectionRejectsUnsafeArchivesBombsMalformedGraphsAndLocks()
{
await using var workspace = new TestWorkspace();
foreach (var entryName in new[] { "../workflow.json", "folder/workflow.json", "/workflow.json" })
{
var path = Path.Combine(workspace.Paths.Temp, Guid.NewGuid().ToString("N") + ".xfeworkflow");
using (var zip = ZipFile.Open(path, ZipArchiveMode.Create)) using (var writer = new StreamWriter(zip.CreateEntry(entryName).Open())) writer.Write(WorkflowJson.SerializeDisplay(WorkflowTemplates.CreateGraph()));
Assert.Throws<InvalidDataException>(() => WorkflowPackage.Inspect(path));
var store = new SpaceStore(Path.Combine(workspace.Paths.Root, Guid.NewGuid().ToString("N")));
var staged = await StageAsync(store, await File.ReadAllBytesAsync(path)); Assert.Equal(SpaceResourceKind.Workflow, staged.Kind);
await Assert.ThrowsAsync<InvalidDataException>(() => store.AuthorAsync("uploads/complete", new() { ["id"] = staged.Id }, Author));
await store.AuthorAsync("uploads/cancel", new() { ["id"] = staged.Id }, Author);
}
var bomb = Path.Combine(workspace.Paths.Temp, "bomb.xfeworkflow");
using (var zip = ZipFile.Open(bomb, ZipArchiveMode.Create)) using (var stream = zip.CreateEntry("workflow.json").Open()) stream.Write(new byte[WorkflowPackage.MaximumBytes + 1]);
Assert.Throws<InvalidDataException>(() => WorkflowPackage.Inspect(bomb));
var flow = WorkflowTemplates.CreateGraph(); flow.PluginVersions["xfe.test"] = "latest"; Assert.Throws<InvalidDataException>(() => WorkflowPackage.Validate(flow));
flow.PluginVersions.Clear(); flow.Graph.Edges[0].ToNode = "missing"; Assert.Throws<InvalidDataException>(() => WorkflowPackage.Validate(flow));
flow = WorkflowTemplates.CreateGraph(); flow.Graph.Nodes[1].Id = flow.Graph.Nodes[0].Id; Assert.Throws<InvalidDataException>(() => WorkflowPackage.Validate(flow));
var malformed = Path.Combine(workspace.Paths.Temp, "malformed.json"); await File.WriteAllTextAsync(malformed, "{\"inputs\":null}", Encoding.UTF8); Assert.Throws<InvalidDataException>(() => WorkflowPackage.Inspect(malformed));
flow = WorkflowTemplates.CreateGraph(); flow.Id = "legacy-readable-id"; WorkflowPackage.Validate(flow);
flow.Outputs = [null!]; Assert.Throws<InvalidDataException>(() => WorkflowPackage.Validate(flow));
flow.Outputs = [new() { Name = "result", Type = (WorkflowValueType)999 }]; Assert.Throws<InvalidDataException>(() => WorkflowPackage.Validate(flow));
}
}
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEExtension.NetCore.XUnit.Assertions;
using XFEExtension.NetCore.XUnit.Attributes;
using XFEWorkflow.Abstractions;
using XFEWorkflow.Core.Runtime;
using XFEWorkflow.Space.Contracts;
using XFEWorkflow.Space.Server;
namespace XFEWorkflow.Test;
[TestFixture]
public class SpaceWorkflowTests
{
private static readonly SpaceUser Author = new("author", "author", "工作流作者", SpaceRole.Author);
private static readonly SpaceUser Other = new("other", "other", "另一位作者", SpaceRole.Author);
private static readonly SpaceUser Admin = new("admin", "admin", "管理员", SpaceRole.Administrator);
private static JsonObject Resource(string id, string? decision = null) => new() { ["kind"] = "workflow", ["id"] = id, ["decision"] = decision, ["reason"] = "审核说明 中文😀" };
private static async Task<UploadSession> StageAsync(SpaceStore store, byte[] bytes, SpaceUser? author = null, string version = "1.0.0", string? hash = null)
{
author ??= Author;
var session = (await store.AuthorAsync("uploads/begin", new() { ["kind"] = "workflow", ["version"] = version, ["length"] = bytes.Length, ["sha256"] = hash ?? Convert.ToHexString(SHA256.HashData(bytes)) }, author)).Deserialize<UploadSession>(WorkflowJson.Options)!;
for (var i = 0; i < session.ChunkCount; i++)
await store.AuthorAsync("uploads/chunk", new() { ["id"] = session.Id, ["index"] = i, ["data"] = Convert.ToBase64String(bytes.AsSpan(i * PluginPackage.ChunkBytes, (int)Math.Min(PluginPackage.ChunkBytes, bytes.Length - (long)i * PluginPackage.ChunkBytes))) }, author);
return session;
}
private static async Task<WorkflowPublication> UploadAsync(SpaceStore store, WorkflowDefinition flow, string directory, string version = "1.0.0", SpaceUser? author = null)
{
var path = Path.Combine(directory, Guid.NewGuid().ToString("N") + ".xfeworkflow"); WorkflowPackage.Create(flow, path);
var session = await StageAsync(store, await File.ReadAllBytesAsync(path), author, version);
try { return (await store.AuthorAsync("uploads/complete", new() { ["id"] = session.Id }, author ?? Author)).Deserialize<WorkflowPublication>(WorkflowJson.Options)!; }
catch { await store.AuthorAsync("uploads/cancel", new() { ["id"] = session.Id }, author ?? Author); throw; }
}
[Test]
public async Task PreviewChecksPublicationOwnershipAndPackageIntegrityWithoutExecutingCode()
{
await using var workspace = new TestWorkspace(); var store = new SpaceStore(Path.Combine(workspace.Paths.Root, "Space"));
var flow = WorkflowTemplates.CreateCode(); flow.Code = "// 中文😀 preview only\nthrow new Exception(\"预览不会运行\");"; flow.PluginVersions["missing.plugin"] = "1.0.0";
var publication = await UploadAsync(store, flow, workspace.Paths.Temp);
await Assert.ThrowsAsync<FileNotFoundException>(() => store.PublicAsync("workflows/preview", Resource(publication.Id)));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => store.AuthorAsync("workflows/preview/private", Resource(publication.Id), Other));
foreach (var user in new[] { Author, Admin })
{
var privatePreview = (await store.AuthorAsync("workflows/preview/private", Resource(publication.Id), user)).Deserialize<WorkflowPreview>(WorkflowJson.Options)!;
Assert.Equal(flow.Code, privatePreview.Definition.Code); Assert.Equal(0, privatePreview.Plugins.Count);
}
await store.AuthorAsync("reviews/decide", Resource(publication.Id, "publish"), Admin);
var preview = (await store.PublicAsync("workflows/preview", Resource(publication.Id))).Deserialize<WorkflowPreview>(WorkflowJson.Options)!;
Assert.Equal(publication.Id, preview.PublicationId); Assert.Equal(publication.Sha256, preview.Sha256); Assert.Equal(flow.Id, preview.Definition.Id); Assert.Equal(flow.Code, preview.Definition.Code);
Assert.Equal(0, (await new Infrastructure.Storage.WorkflowRepository(workspace.Paths).ListAsync()).Count);
var path = Path.Combine(store.Root, "packages", publication.Id + ".xfeworkflow"); var bytes = await File.ReadAllBytesAsync(path); bytes[0] ^= 1; await File.WriteAllBytesAsync(path, bytes);
await Assert.ThrowsAsync<InvalidDataException>(() => store.PublicAsync("workflows/preview", Resource(publication.Id)));
}
[Test]
public async Task PortablePackagesPreserveGraphCodeFieldsUnicodeAndNeverEnableWorkflows()
{
await using var workspace = new TestWorkspace();
foreach (var kind in new[] { WorkflowKind.Graph, WorkflowKind.CSharp })
{
var flow = TestSupport.Graph(new NodeDefinition { CapabilityId = "core.log", Parameters = new() { ["message"] = "中文😀👨👩👧👦é $var 不替换" } });
flow.Kind = kind; flow.Code = "// 中文与表情😀\nreturn null;"; flow.Enabled = true;
flow.PluginVersions = new() { ["xfe.wecom"] = "2.0.0" }; flow.Inputs = [new() { Name = "message", Label = "消息", Type = WorkflowValueType.String }];
var path = Path.Combine(workspace.Paths.Temp, kind + ".xfeworkflow"); WorkflowPackage.Create(flow, path);
var actual = WorkflowPackage.Inspect(path); Assert.Equal(flow.Id, actual.Id); Assert.Equal(flow.Code, actual.Code); Assert.False(actual.Enabled == true); Assert.True(flow.Enabled == true);
Assert.Equal(flow.Graph.Nodes[1].Parameters.String("message"), actual.Graph.Nodes[1].Parameters.String("message"));
using var zip = ZipFile.OpenRead(path); using var reader = new StreamReader(zip.Entries.Single().Open()); var json = reader.ReadToEnd();
Assert.Contains("中文😀", json); Assert.False(json.Contains("\\u4E2D", StringComparison.OrdinalIgnoreCase));
}
}
[Test]
public async Task WorkflowReviewDownloadAndRestartRemainSeparateFromPluginCatalog()
{
await using var workspace = new TestWorkspace(); var store = new SpaceStore(Path.Combine(workspace.Paths.Root, "Space"));
var flow = WorkflowTemplates.CreateGraph(); flow.Name = "联系人遍历😀"; flow.Description = "通过连线输出名称";
var publication = await UploadAsync(store, flow, workspace.Paths.Temp); Assert.Equal(PublicationStatus.Pending, publication.Status);
Assert.Equal(0, (await store.PublicAsync("workflows/search", [])).Deserialize<WorkflowCatalogPage>(WorkflowJson.Options)!.Total);
await Assert.ThrowsAsync<FileNotFoundException>(() => store.PublicAsync("packages/chunk", new() { ["kind"] = "workflow", ["id"] = publication.Id, ["index"] = 0 }));
var mine = (await store.AuthorAsync("workflows/mine", [], Author)).Deserialize<List<WorkflowPublication>>(WorkflowJson.Options)!; Assert.Equal(1, mine.Count);
Assert.Equal(0, (await store.AuthorAsync("plugins/mine", [], Author)).Deserialize<List<PluginPublication>>(WorkflowJson.Options)!.Count);
await store.AuthorAsync("reviews/decide", Resource(publication.Id, "publish"), Admin);
var restarted = new SpaceStore(store.Root);
var catalog = (await restarted.PublicAsync("workflows/search", new() { ["query"] = "联系人 作者", ["limit"] = 1 })).Deserialize<WorkflowCatalogPage>(WorkflowJson.Options)!;
Assert.Equal(1, catalog.Total); Assert.Equal(flow.Name, catalog.Items.Single().Workflow.Name);
Assert.Equal(0, (await restarted.PublicAsync("plugins/search", [])).Deserialize<CatalogPage>(WorkflowJson.Options)!.Total);
var chunk = await restarted.PublicAsync("packages/chunk", new() { ["kind"] = "workflow", ["id"] = publication.Id, ["index"] = 0 });
Assert.Equal(publication.Sha256, Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(chunk["data"]!.GetValue<string>()))).ToLowerInvariant());
await Assert.ThrowsAsync<InvalidDataException>(() => restarted.PublicAsync("packages/chunk", new() { ["kind"] = "workflow", ["id"] = publication.Id, ["index"] = 1 }));
await restarted.AuthorAsync("workflows/withdraw", Resource(publication.Id), Author);
Assert.Equal(0, (await restarted.PublicAsync("workflows/search", [])).Deserialize<WorkflowCatalogPage>(WorkflowJson.Options)!.Total);
await Assert.ThrowsAsync<FileNotFoundException>(() => restarted.PublicAsync("packages/chunk", new() { ["kind"] = "workflow", ["id"] = publication.Id, ["index"] = 0 }));
}
[Test]
public async Task WorkflowOwnershipPermissionsImmutableVersionsAndReviewTransitionsAreEnforced()
{
await using var workspace = new TestWorkspace(); var store = new SpaceStore(Path.Combine(workspace.Paths.Root, "Space")); var flow = WorkflowTemplates.CreateGraph();
var publication = await UploadAsync(store, flow, workspace.Paths.Temp);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => store.AuthorAsync("reviews/list", new() { ["kind"] = "workflow" }, Author));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => store.AuthorAsync("reviews/decide", Resource(publication.Id, "publish"), Author));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => store.AuthorAsync("workflows/withdraw", Resource(publication.Id), Other));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => UploadAsync(store, flow, workspace.Paths.Temp, "2.0.0", Other));
await Assert.ThrowsAsync<InvalidOperationException>(() => UploadAsync(store, flow, workspace.Paths.Temp));
await Assert.ThrowsAsync<InvalidOperationException>(() => store.AuthorAsync("workflows/withdraw", Resource(publication.Id), Author));
await store.AuthorAsync("reviews/decide", Resource(publication.Id, "reject"), Admin);
await Assert.ThrowsAsync<InvalidOperationException>(() => store.AuthorAsync("reviews/decide", Resource(publication.Id, "publish"), Admin));
var rejected = (await store.AuthorAsync("workflows/mine", [], Author)).Deserialize<List<WorkflowPublication>>(WorkflowJson.Options)!.Single();
Assert.Equal(PublicationStatus.Rejected, rejected.Status); Assert.Equal("审核说明 中文😀", rejected.ReviewReason);
await Assert.ThrowsAsync<InvalidOperationException>(() => UploadAsync(store, flow, workspace.Paths.Temp));
}
[Test]
public async Task WorkflowCatalogUsesSemanticLatestVersionAndPagination()
{
await using var workspace = new TestWorkspace(); var store = new SpaceStore(Path.Combine(workspace.Paths.Root, "Space")); var flow = WorkflowTemplates.CreateGraph();
foreach (var version in new[] { "2.0.0", "10.0.0" }) { var result = await UploadAsync(store, flow, workspace.Paths.Temp, version); await store.AuthorAsync("reviews/decide", Resource(result.Id, "publish"), Admin); }
var second = WorkflowTemplates.CreateGraph(); second.Name = "另一个工作流";
var result2 = await UploadAsync(store, second, workspace.Paths.Temp); await store.AuthorAsync("reviews/decide", Resource(result2.Id, "publish"), Admin);
var all = (await store.PublicAsync("workflows/search", new() { ["limit"] = 1, ["offset"] = 1 })).Deserialize<WorkflowCatalogPage>(WorkflowJson.Options)!;
Assert.Equal(2, all.Total); Assert.Equal("10.0.0", all.Items.Single().Workflow.Version);
var versions = (await store.PublicAsync("workflows/detail", new() { ["workflowId"] = flow.Id })).Deserialize<List<WorkflowPublication>>(WorkflowJson.Options)!;
Assert.Equal(2, versions.Count); Assert.Equal("10.0.0", versions[0].Workflow.Version);
}
[Test]
public async Task WorkflowUploadsRejectCorruptionAndCancellationChecksTheOwner()
{
await using var workspace = new TestWorkspace(); var store = new SpaceStore(Path.Combine(workspace.Paths.Root, "Space"));
var staged = await StageAsync(store, [1, 2, 3], hash: new string('0', 64));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => store.AuthorAsync("uploads/cancel", new() { ["id"] = staged.Id }, Other));
await Assert.ThrowsAsync<InvalidDataException>(() => store.AuthorAsync("uploads/complete", new() { ["id"] = staged.Id }, Author));
await store.AuthorAsync("uploads/cancel", new() { ["id"] = staged.Id }, Author);
Assert.Equal(0, Directory.GetFiles(Path.Combine(store.Root, "uploads")).Length);
await Assert.ThrowsAsync<FileNotFoundException>(() => store.AuthorAsync("uploads/complete", new() { ["id"] = staged.Id }, Author));
await Assert.ThrowsAsync<InvalidDataException>(() => store.AuthorAsync("uploads/begin", new() { ["kind"] = "workflow", ["length"] = WorkflowPackage.MaximumBytes + 1, ["version"] = "1.0.0", ["sha256"] = new string('0', 64) }, Author));
await Assert.ThrowsAsync<InvalidDataException>(() => store.AuthorAsync("uploads/begin", new() { ["kind"] = "workflow", ["length"] = 1, ["version"] = "latest", ["sha256"] = new string('0', 64) }, Author));
await Assert.ThrowsAsync<InvalidDataException>(() => store.AuthorAsync("uploads/begin", new() { ["kind"] = "unknown", ["length"] = 1, ["sha256"] = new string('0', 64) }, Author));
}
[Test]
public async Task WorkflowInspectionRejectsUnsafeArchivesBombsMalformedGraphsAndLocks()
{
await using var workspace = new TestWorkspace();
foreach (var entryName in new[] { "../workflow.json", "folder/workflow.json", "/workflow.json" })
{
var path = Path.Combine(workspace.Paths.Temp, Guid.NewGuid().ToString("N") + ".xfeworkflow");
using (var zip = ZipFile.Open(path, ZipArchiveMode.Create)) using (var writer = new StreamWriter(zip.CreateEntry(entryName).Open())) writer.Write(WorkflowJson.SerializeDisplay(WorkflowTemplates.CreateGraph()));
Assert.Throws<InvalidDataException>(() => WorkflowPackage.Inspect(path));
var store = new SpaceStore(Path.Combine(workspace.Paths.Root, Guid.NewGuid().ToString("N")));
var staged = await StageAsync(store, await File.ReadAllBytesAsync(path)); Assert.Equal(SpaceResourceKind.Workflow, staged.Kind);
await Assert.ThrowsAsync<InvalidDataException>(() => store.AuthorAsync("uploads/complete", new() { ["id"] = staged.Id }, Author));
await store.AuthorAsync("uploads/cancel", new() { ["id"] = staged.Id }, Author);
}
var bomb = Path.Combine(workspace.Paths.Temp, "bomb.xfeworkflow");
using (var zip = ZipFile.Open(bomb, ZipArchiveMode.Create)) using (var stream = zip.CreateEntry("workflow.json").Open()) stream.Write(new byte[WorkflowPackage.MaximumBytes + 1]);
Assert.Throws<InvalidDataException>(() => WorkflowPackage.Inspect(bomb));
var flow = WorkflowTemplates.CreateGraph(); flow.PluginVersions["xfe.test"] = "latest"; Assert.Throws<InvalidDataException>(() => WorkflowPackage.Validate(flow));
flow.PluginVersions.Clear(); flow.Graph.Edges[0].ToNode = "missing"; Assert.Throws<InvalidDataException>(() => WorkflowPackage.Validate(flow));
flow = WorkflowTemplates.CreateGraph(); flow.Graph.Nodes[1].Id = flow.Graph.Nodes[0].Id; Assert.Throws<InvalidDataException>(() => WorkflowPackage.Validate(flow));
var malformed = Path.Combine(workspace.Paths.Temp, "malformed.json"); await File.WriteAllTextAsync(malformed, "{\"inputs\":null}", Encoding.UTF8); Assert.Throws<InvalidDataException>(() => WorkflowPackage.Inspect(malformed));
flow = WorkflowTemplates.CreateGraph(); flow.Id = "legacy-readable-id"; WorkflowPackage.Validate(flow);
flow.Outputs = [null!]; Assert.Throws<InvalidDataException>(() => WorkflowPackage.Validate(flow));
flow.Outputs = [new() { Name = "result", Type = (WorkflowValueType)999 }]; Assert.Throws<InvalidDataException>(() => WorkflowPackage.Validate(flow));
}
}