using w4c_workflows.Models.Nodes; using w4c_workflows.Services.Nodes; using w4c_workflows.Services.Nodes.Executors; using w4c_workflows.Services.Nodes.Interpolation; using w4c_workflows.Services.Security; using Xunit; namespace w4c_workflows.Tests; /// /// End-to-end enforcement of node permissions: the same policy has to reject a /// barred node at compile time and again at run time, and a run-time denial must /// behave like any other node failure (error port / continue-on-fail). /// public class NodePermissionEnforcementTests { private sealed class StubExecutor : INodeExecutor { public StubExecutor(string type) => Type = type; public string Type { get; } public bool Invoked { get; private set; } public Task RunAsync(NodeExecutionContext context, CancellationToken ct) { Invoked = true; return Task.FromResult(NodeExecutionOutcome.Single(context.Input(0))); } } private static NodeGraphRunner Runner(NodePermissionPolicy permissions, params INodeExecutor[] extra) { var executors = new List { new NoOpNodeExecutor() }; executors.AddRange(extra); return new NodeGraphRunner( new NodeExecutorRegistry(executors), new NodeParameterInterpolator(), logger: null, permissions: permissions); } private static NodeGraph Compile(string yaml, string? tenantId = null, NodePermissionPolicy? permissions = null) { var result = new NodeGraphCompiler(NodeTestData.CoreCatalog(), permissions).Compile(NodeTestData.Parse(yaml), tenantId); Assert.True(result.Success, string.Join("\n", result.Errors)); return result.Graph!; } // ------------------------------------------------------------------ run time [Fact] public async Task A_denied_node_fails_the_run_with_the_policy_code() { var yaml = """ name: denied tasks: - id: work node: { type: core.noop } """; var policy = EgressTestData.Permissions(o => o.DenyTypes.Add("core.noop")); var result = await Runner(policy).RunAsync(Compile(yaml), Array.Empty()); Assert.False(result.Succeeded); Assert.Equal("node_type_blocked", result.Failure!.Code); } [Fact] public async Task A_denied_node_routes_to_its_error_output_without_invoking_the_executor() { var yaml = """ name: denied-error-branch tasks: - id: work node: { type: test.denied } onError: recover - id: recover node: { type: core.noop } """; var catalog = NodeTestData.Catalog( NodeTestData.NodeBlueprint("test.denied", outputs: new List { NodePort.Main, NodePort.Error })); var compile = new NodeGraphCompiler(catalog).Compile(NodeTestData.Parse(yaml)); Assert.True(compile.Success, string.Join("\n", compile.Errors)); var executor = new StubExecutor("test.denied"); var policy = EgressTestData.Permissions(o => o.DenyTypes.Add("test.denied")); var result = await Runner(policy, executor).RunAsync(compile.Graph!, new[] { FlowItem.FromJson(new System.Text.Json.Nodes.JsonObject()) }); Assert.True(result.Succeeded, result.Failure?.Message); Assert.False(executor.Invoked); var recovered = Assert.Single(result.OutputOf("recover")); Assert.Equal("node_type_blocked", recovered.Json["error"]!["code"]!.GetValue()); } [Fact] public async Task A_denied_node_with_continue_on_fail_does_not_fail_the_run() { var yaml = """ name: denied-soft tasks: - id: work node: { type: core.noop } continueOnFail: true """; var denying = EgressTestData.Permissions(o => o.DenyTypes.Add("core.noop")); var result = await Runner(denying).RunAsync(Compile(yaml), Array.Empty()); Assert.True(result.Succeeded, result.Failure?.Message); Assert.Empty(result.OutputOf("work")); } [Fact] public async Task A_permitted_run_is_unaffected_by_the_policy() { var yaml = """ name: allowed tasks: - id: work node: { type: core.noop } """; var policy = EgressTestData.Permissions(o => o.AllowTypes.Add("core.*")); var result = await Runner(policy).RunAsync(Compile(yaml), Array.Empty()); Assert.True(result.Succeeded, result.Failure?.Message); } // ------------------------------------------------------------------ compile time [Fact] public void Compiler_rejects_a_barred_node_for_the_tenant() { var yaml = """ name: compile-denied tasks: - id: work node: { type: core.noop } """; var policy = EgressTestData.Permissions(o => o.DenyTypes.Add("core.noop")); var result = new NodeGraphCompiler(NodeTestData.CoreCatalog(), policy) .Compile(NodeTestData.Parse(yaml), "tenant-a"); Assert.False(result.Success); Assert.Contains(result.Errors, e => e.Contains("core.noop") && e.Contains("blocked")); } [Fact] public void Compiler_without_a_policy_permits_every_blueprint() { var yaml = """ name: compile-open tasks: - id: work node: { type: core.noop } """; Assert.True(new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)).Success); } [Fact] public void Compiler_applies_the_tenant_rule_not_the_global_one() { var yaml = """ name: compile-tenant tasks: - id: work node: { type: core.noop } """; var policy = EgressTestData.Permissions(o => { o.DenyTypes.Add("core.noop"); o.Tenants["privileged"] = new NodePermissionRule(); }); var compiler = new NodeGraphCompiler(NodeTestData.CoreCatalog(), policy); Assert.True(compiler.Compile(NodeTestData.Parse(yaml), "privileged").Success); Assert.False(compiler.Compile(NodeTestData.Parse(yaml), "regular").Success); } }