using System; using System.Linq; using System.Text.Json.Nodes; using StellaOps.AirGap.Policy; using StellaOps.TaskRunner.Core.Planning; namespace StellaOps.TaskRunner.Tests; public sealed class TaskPackPlannerTests { [Fact] public void Plan_WithSequentialSteps_ComputesDeterministicHash() { var manifest = TestManifests.Load(TestManifests.Sample); var planner = new TaskPackPlanner(); var inputs = new Dictionary { ["dryRun"] = JsonValue.Create(false) }; var resultA = planner.Plan(manifest, inputs); Assert.True(resultA.Success); var plan = resultA.Plan!; Assert.Equal(3, plan.Steps.Count); Assert.Equal("plan-step", plan.Steps[0].Id); Assert.Equal("plan-step", plan.Steps[0].TemplateId); Assert.Equal("run", plan.Steps[0].Type); Assert.Equal("gate.approval", plan.Steps[1].Type); Assert.Equal("security-review", plan.Steps[1].ApprovalId); Assert.Equal("run", plan.Steps[2].Type); Assert.True(plan.Steps[2].Enabled); Assert.Single(plan.Approvals); Assert.Equal("security-review", plan.Approvals[0].Id); Assert.False(string.IsNullOrWhiteSpace(plan.Hash)); var resultB = planner.Plan(manifest, inputs); Assert.True(resultB.Success); Assert.Equal(plan.Hash, resultB.Plan!.Hash); } [Fact] public void PlanHash_IsPrefixedSha256Digest() { var manifest = TestManifests.Load(TestManifests.Sample); var planner = new TaskPackPlanner(); var result = planner.Plan(manifest); Assert.True(result.Success); var hash = result.Plan!.Hash; Assert.StartsWith("sha256:", hash, StringComparison.Ordinal); Assert.Equal(71, hash.Length); // "sha256:" + 64 hex characters var hex = hash.Substring("sha256:".Length); Assert.True(hex.All(c => Uri.IsHexDigit(c)), "Hash contains non-hex characters."); } [Fact] public void Plan_WhenConditionEvaluatesFalse_DisablesStep() { var manifest = TestManifests.Load(TestManifests.Sample); var planner = new TaskPackPlanner(); var inputs = new Dictionary { ["dryRun"] = JsonValue.Create(true) }; var result = planner.Plan(manifest, inputs); Assert.True(result.Success); Assert.False(result.Plan!.Steps[2].Enabled); } [Fact] public void Plan_WithStepReferences_MarksParametersAsRuntime() { var manifest = TestManifests.Load(TestManifests.StepReference); var planner = new TaskPackPlanner(); var result = planner.Plan(manifest); Assert.True(result.Success); var plan = result.Plan!; Assert.Equal(2, plan.Steps.Count); var referenceParameters = plan.Steps[1].Parameters!; Assert.True(referenceParameters["sourceSummary"].RequiresRuntimeValue); Assert.Equal("steps.prepare.outputs.summary", referenceParameters["sourceSummary"].Expression); } [Fact] public void Plan_WithMapStep_ExpandsIterations() { var manifest = TestManifests.Load(TestManifests.Map); var planner = new TaskPackPlanner(); var inputs = new Dictionary { ["targets"] = new JsonArray("alpha", "beta", "gamma") }; var result = planner.Plan(manifest, inputs); Assert.True(result.Success); var plan = result.Plan!; var mapStep = plan.Steps.Single(s => s.Type == "map"); Assert.Equal(3, mapStep.Children!.Count); Assert.All(mapStep.Children!, child => Assert.Equal("echo-step", child.TemplateId)); Assert.Equal(3, mapStep.Parameters!["iterationCount"].Value!.GetValue()); Assert.Equal("alpha", mapStep.Children![0].Parameters!["item"].Value!.GetValue()); } [Fact] public void CollectApprovalRequirements_GroupsGates() { var manifest = TestManifests.Load(TestManifests.Sample); var planner = new TaskPackPlanner(); var plan = planner.Plan(manifest).Plan!; var requirements = TaskPackPlanInsights.CollectApprovalRequirements(plan); Assert.Single(requirements); var requirement = requirements[0]; Assert.Equal("security-review", requirement.ApprovalId); Assert.Contains("Packs.Approve", requirement.Grants); Assert.Equal(plan.Steps[1].Id, requirement.StepIds.Single()); var notifications = TaskPackPlanInsights.CollectNotificationHints(plan); Assert.Contains(notifications, hint => hint.Type == "approval-request" && hint.StepId == plan.Steps[1].Id); } [Fact] public void Plan_WithSecretReference_RecordsSecretMetadata() { var manifest = TestManifests.Load(TestManifests.Secret); var planner = new TaskPackPlanner(); var result = planner.Plan(manifest); Assert.True(result.Success); var plan = result.Plan!; Assert.Single(plan.Secrets); Assert.Equal("apiKey", plan.Secrets[0].Name); var param = plan.Steps[0].Parameters!["token"]; Assert.True(param.RequiresRuntimeValue); Assert.Equal("secrets.apiKey", param.Expression); } [Fact] public void Plan_WithOutputs_ProjectsResolvedValues() { var manifest = TestManifests.Load(TestManifests.Output); var planner = new TaskPackPlanner(); var result = planner.Plan(manifest); Assert.True(result.Success); var plan = result.Plan!; Assert.Equal(2, plan.Outputs.Count); var bundle = plan.Outputs.First(o => o.Name == "bundlePath"); Assert.NotNull(bundle.Path); Assert.False(bundle.Path!.RequiresRuntimeValue); Assert.Equal("artifacts/report.txt", bundle.Path.Value!.GetValue()); var evidence = plan.Outputs.First(o => o.Name == "evidenceModel"); Assert.NotNull(evidence.Expression); Assert.True(evidence.Expression!.RequiresRuntimeValue); Assert.Equal("steps.generate.outputs.evidence", evidence.Expression.Expression); } [Fact] public void Plan_WithFailurePolicy_PopulatesPlanFailure() { var manifest = TestManifests.Load(TestManifests.FailurePolicy); var planner = new TaskPackPlanner(); var result = planner.Plan(manifest); Assert.True(result.Success); var plan = result.Plan!; Assert.NotNull(plan.FailurePolicy); Assert.Equal(4, plan.FailurePolicy!.MaxAttempts); Assert.Equal(30, plan.FailurePolicy.BackoffSeconds); Assert.False(plan.FailurePolicy.ContinueOnError); } [Fact] public void PolicyGateHints_IncludeRuntimeMetadata() { var manifest = TestManifests.Load(TestManifests.PolicyGate); var planner = new TaskPackPlanner(); var plan = planner.Plan(manifest).Plan!; var hints = TaskPackPlanInsights.CollectPolicyGateHints(plan); Assert.Single(hints); var hint = hints[0]; Assert.Equal("policy-check", hint.StepId); var threshold = hint.Parameters.Single(p => p.Name == "threshold"); Assert.False(threshold.RequiresRuntimeValue); Assert.Null(threshold.Expression); var evidence = hint.Parameters.Single(p => p.Name == "evidenceRef"); Assert.True(evidence.RequiresRuntimeValue); Assert.Equal("steps.prepare.outputs.evidence", evidence.Expression); } [Fact] public void Plan_SealedMode_BlocksUndeclaredEgress() { var manifest = TestManifests.Load(TestManifests.EgressBlocked); var options = new EgressPolicyOptions { Mode = EgressPolicyMode.Sealed }; var planner = new TaskPackPlanner(new EgressPolicy(options)); var result = planner.Plan(manifest); Assert.False(result.Success); Assert.Contains(result.Errors, error => error.Message.Contains("example.com", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Plan_WhenRequiredInputMissing_ReturnsError() { var manifest = TestManifests.Load(TestManifests.RequiredInput); var planner = new TaskPackPlanner(); var result = planner.Plan(manifest); Assert.False(result.Success); Assert.NotEmpty(result.Errors); } [Fact] public void Plan_SealedMode_AllowsDeclaredEgress() { var manifest = TestManifests.Load(TestManifests.EgressAllowed); var options = new EgressPolicyOptions { Mode = EgressPolicyMode.Sealed }; options.AddAllowRule("mirror.internal", 443, EgressTransport.Https); var planner = new TaskPackPlanner(new EgressPolicy(options)); var result = planner.Plan(manifest); Assert.True(result.Success); } [Fact] public void Plan_SealedMode_RuntimeUrlWithoutDeclaration_ReturnsError() { var manifest = TestManifests.Load(TestManifests.EgressRuntime); var options = new EgressPolicyOptions { Mode = EgressPolicyMode.Sealed }; var planner = new TaskPackPlanner(new EgressPolicy(options)); var result = planner.Plan(manifest); Assert.False(result.Success); Assert.Contains(result.Errors, error => error.Path.StartsWith("spec.steps[0]", StringComparison.Ordinal)); } }