using w4c_workflows.Models.Nodes; using w4c_workflows.Services.Security; using Xunit; namespace w4c_workflows.Tests; /// /// Pure policy tests: no catalog, no run. Every decision is driven by a /// fabricated blueprint so the allow/deny order and the wildcard matcher are /// exercised directly. /// public class NodePermissionPolicyTests { private static NodeBlueprint Blueprint( string type, string kind = NodeKind.Action, string origin = NodeOrigin.BuiltIn) => new() { Type = type, Version = 1, DisplayName = type, Kind = kind, Origin = origin, }; [Fact] public void Default_policy_permits_everything() { var policy = EgressTestData.Permissions(); Assert.True(policy.IsPermitted(Blueprint("core.noop"))); Assert.True(policy.IsPermitted(Blueprint("core.code"))); Assert.True(policy.IsPermitted(Blueprint("core.executeWorkflow", NodeKind.Control))); Assert.True(policy.IsPermitted(Blueprint("slack", NodeKind.Action, NodeOrigin.Connector))); } // ------------------------------------------------------------------ type lists [Theory] [InlineData("core.code")] [InlineData("core.httpRequest")] [InlineData("slack")] public void Deny_types_blocks_exact_and_wildcard_matches(string type) { var policy = EgressTestData.Permissions(o => o.DenyTypes.AddRange(new[] { "core.*", "slack" })); var decision = policy.Evaluate(Blueprint(type)); Assert.False(decision.Allowed); Assert.Equal("node_type_blocked", decision.Code); Assert.Contains(type, decision.Reason); } [Fact] public void Deny_types_leaves_other_types_untouched() { var policy = EgressTestData.Permissions(o => o.DenyTypes.Add("core.code")); Assert.True(policy.IsPermitted(Blueprint("core.noop"))); } [Fact] public void Allow_types_is_a_whitelist() { var policy = EgressTestData.Permissions(o => o.AllowTypes.Add("core.set")); Assert.True(policy.IsPermitted(Blueprint("core.set"))); var denied = policy.Evaluate(Blueprint("core.noop")); Assert.False(denied.Allowed); Assert.Equal("node_type_not_permitted", denied.Code); } [Fact] public void Deny_wins_over_allow() { var policy = EgressTestData.Permissions(o => { o.AllowTypes.Add("core.*"); o.DenyTypes.Add("core.code"); }); Assert.True(policy.IsPermitted(Blueprint("core.set"))); Assert.False(policy.IsPermitted(Blueprint("core.code"))); } // ------------------------------------------------------------------ kind / origin [Fact] public void Deny_kind_blocks_that_role() { var policy = EgressTestData.Permissions(o => o.DenyKinds.Add(NodeKind.Ai)); var denied = policy.Evaluate(Blueprint("ai.agent", NodeKind.Ai)); Assert.False(denied.Allowed); Assert.Equal("node_kind_blocked", denied.Code); Assert.True(policy.IsPermitted(Blueprint("core.set", NodeKind.Transform))); } [Fact] public void Allow_kind_is_a_whitelist() { var policy = EgressTestData.Permissions(o => o.AllowKinds.Add(NodeKind.Control)); Assert.True(policy.IsPermitted(Blueprint("core.if", NodeKind.Control))); var denied = policy.Evaluate(Blueprint("core.httpRequest", NodeKind.Action)); Assert.False(denied.Allowed); Assert.Equal("node_kind_not_permitted", denied.Code); } [Fact] public void Origin_deny_and_allow_are_enforced() { var deny = EgressTestData.Permissions(o => o.DenyOrigins.Add(NodeOrigin.Custom)); var blocked = deny.Evaluate(Blueprint("custom.node", NodeKind.Action, NodeOrigin.Custom)); Assert.False(blocked.Allowed); Assert.Equal("node_origin_blocked", blocked.Code); var allow = EgressTestData.Permissions(o => o.AllowOrigins.Add(NodeOrigin.BuiltIn)); var notPermitted = allow.Evaluate(Blueprint("slack", NodeKind.Action, NodeOrigin.Connector)); Assert.False(notPermitted.Allowed); Assert.Equal("node_origin_not_permitted", notPermitted.Code); } // ------------------------------------------------------------------ convenience switches [Fact] public void Code_nodes_can_be_disabled_without_touching_other_nodes() { var policy = EgressTestData.Permissions(o => o.AllowCodeNodes = false); var denied = policy.Evaluate(Blueprint("core.code", NodeKind.Transform)); Assert.False(denied.Allowed); Assert.Equal("code_node_disabled", denied.Code); Assert.True(policy.IsPermitted(Blueprint("core.noop"))); } [Fact] public void Sub_workflow_nodes_can_be_disabled() { var policy = EgressTestData.Permissions(o => o.AllowSubWorkflows = false); var denied = policy.Evaluate(Blueprint("core.executeWorkflow", NodeKind.Control)); Assert.False(denied.Allowed); Assert.Equal("sub_workflow_disabled", denied.Code); Assert.True(policy.IsPermitted(Blueprint("core.noop"))); } [Fact] public void Explicit_deny_beats_the_code_switch_allow() { // AllowCodeNodes true, but a deny entry still blocks it (deny wins). var policy = EgressTestData.Permissions(o => o.DenyTypes.Add("core.code")); Assert.False(policy.IsPermitted(Blueprint("core.code"))); } // ------------------------------------------------------------------ tenant overrides [Fact] public void Tenant_rule_replaces_the_global_rule() { var policy = EgressTestData.Permissions(o => { o.DenyTypes.Add("core.code"); o.Tenants["locked-down"] = new NodePermissionRule { AllowCodeNodes = false }; o.Tenants["open"] = new NodePermissionRule(); // permissive override }); // Global rule bars code. Assert.False(policy.IsPermitted(Blueprint("core.code"), "someone-else")); // The permissive tenant rule lifts the global bar. Assert.True(policy.IsPermitted(Blueprint("core.code"), "open")); // A tenant can be stricter than the global rule: code is barred there // even though the global rule would allow it. Assert.False(policy.IsPermitted(Blueprint("core.code"), "locked-down")); Assert.True(policy.IsPermitted(Blueprint("core.noop"), "locked-down")); } // ------------------------------------------------------------------ matcher [Theory] [InlineData("core.set", "core.*", true)] [InlineData("core", "core.*", false)] [InlineData("core.code", "*.code", true)] [InlineData("core.set", "*.code", false)] [InlineData("anything", "*", true)] [InlineData("core.set", "core.set", true)] [InlineData("CORE.SET", "core.set", true)] [InlineData("core.set", "core", false)] public void Glob_matcher_is_anchored_and_case_insensitive(string value, string pattern, bool expected) => Assert.Equal(expected, NodePermissionPolicy.MatchesPattern(value, pattern)); }