XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
UTF-8
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));
    }
}