w4c-workflows-api/w4c-workflows-api.Tests/NodePermissionEnforcementTests.cs
2026-09-12 01:02:46 +03:00

186 lines
6.4 KiB
C#

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;
/// <summary>
/// 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).
/// </summary>
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<NodeExecutionOutcome> 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<INodeExecutor> { 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<FlowItem>());
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> { 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<string>());
}
[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<FlowItem>());
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<FlowItem>());
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);
}
}