consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,183 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.WebService.Deprecation;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class ApiDeprecationTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DeprecatedEndpoint_PathPattern_MatchesExpected()
{
var endpoint = new DeprecatedEndpoint
{
PathPattern = "/v1/legacy/*",
DeprecatedAt = DateTimeOffset.UtcNow.AddDays(-30),
SunsetAt = DateTimeOffset.UtcNow.AddDays(60),
ReplacementPath = "/v2/new",
Message = "Use the v2 API"
};
Assert.Equal("/v1/legacy/*", endpoint.PathPattern);
Assert.NotNull(endpoint.DeprecatedAt);
Assert.NotNull(endpoint.SunsetAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ApiDeprecationOptions_DefaultValues_AreCorrect()
{
var options = new ApiDeprecationOptions();
Assert.True(options.EmitDeprecationHeaders);
Assert.True(options.EmitSunsetHeaders);
Assert.NotNull(options.DeprecationPolicyUrl);
Assert.Empty(options.DeprecatedEndpoints);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task LoggingDeprecationNotificationService_GetUpcoming_FiltersCorrectly()
{
var now = DateTimeOffset.UtcNow;
var options = new ApiDeprecationOptions
{
DeprecatedEndpoints =
[
new DeprecatedEndpoint
{
PathPattern = "/v1/soon/*",
SunsetAt = now.AddDays(30) // Within 90 days
},
new DeprecatedEndpoint
{
PathPattern = "/v1/later/*",
SunsetAt = now.AddDays(180) // Beyond 90 days
},
new DeprecatedEndpoint
{
PathPattern = "/v1/past/*",
SunsetAt = now.AddDays(-10) // Already passed
}
]
};
var optionsMonitor = new OptionsMonitor(options);
var service = new LoggingDeprecationNotificationService(
NullLogger<LoggingDeprecationNotificationService>.Instance,
optionsMonitor);
var upcoming = await service.GetUpcomingDeprecationsAsync(90, CancellationToken.None);
Assert.Single(upcoming);
Assert.Equal("/v1/soon/*", upcoming[0].EndpointPath);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task LoggingDeprecationNotificationService_GetUpcoming_OrdersBySunsetDate()
{
var now = DateTimeOffset.UtcNow;
var options = new ApiDeprecationOptions
{
DeprecatedEndpoints =
[
new DeprecatedEndpoint { PathPattern = "/v1/third/*", SunsetAt = now.AddDays(60) },
new DeprecatedEndpoint { PathPattern = "/v1/first/*", SunsetAt = now.AddDays(10) },
new DeprecatedEndpoint { PathPattern = "/v1/second/*", SunsetAt = now.AddDays(30) }
]
};
var optionsMonitor = new OptionsMonitor(options);
var service = new LoggingDeprecationNotificationService(
NullLogger<LoggingDeprecationNotificationService>.Instance,
optionsMonitor);
var upcoming = await service.GetUpcomingDeprecationsAsync(90, CancellationToken.None);
Assert.Equal(3, upcoming.Count);
Assert.Equal("/v1/first/*", upcoming[0].EndpointPath);
Assert.Equal("/v1/second/*", upcoming[1].EndpointPath);
Assert.Equal("/v1/third/*", upcoming[2].EndpointPath);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DeprecationInfo_DaysUntilSunset_CalculatesCorrectly()
{
var now = DateTimeOffset.UtcNow;
var sunsetDate = now.AddDays(45);
var info = new DeprecationInfo(
"/v1/test/*",
now.AddDays(-30),
sunsetDate,
"/v2/test/*",
"https://docs.example.com/migration",
45);
Assert.Equal(45, info.DaysUntilSunset);
Assert.Equal("/v2/test/*", info.ReplacementPath);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DeprecationNotification_RecordProperties_AreAccessible()
{
var notification = new DeprecationNotification(
"/v1/legacy/endpoint",
"/v2/new/endpoint",
DateTimeOffset.UtcNow.AddDays(90),
"This endpoint is deprecated",
"https://docs.example.com/deprecation",
["consumer-1", "consumer-2"]);
Assert.Equal("/v1/legacy/endpoint", notification.EndpointPath);
Assert.Equal("/v2/new/endpoint", notification.ReplacementPath);
Assert.NotNull(notification.SunsetDate);
Assert.Equal(2, notification.AffectedConsumerIds?.Count);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PathPattern_WildcardToRegex_MatchesSingleSegment()
{
var pattern = "^" + Regex.Escape("/v1/packs/*")
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*") + "$";
Assert.Matches(pattern, "/v1/packs/foo");
Assert.Matches(pattern, "/v1/packs/bar");
Assert.DoesNotMatch(pattern, "/v1/packs/foo/bar"); // Single * shouldn't match /
Assert.DoesNotMatch(pattern, "/v2/packs/foo");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PathPattern_DoubleWildcard_MatchesMultipleSegments()
{
var pattern = "^" + Regex.Escape("/v1/legacy/**")
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*") + "$";
Assert.Matches(pattern, "/v1/legacy/foo");
Assert.Matches(pattern, "/v1/legacy/foo/bar");
Assert.Matches(pattern, "/v1/legacy/foo/bar/baz");
Assert.DoesNotMatch(pattern, "/v2/legacy/foo");
}
private sealed class OptionsMonitor : IOptionsMonitor<ApiDeprecationOptions>
{
public OptionsMonitor(ApiDeprecationOptions value) => CurrentValue = value;
public ApiDeprecationOptions CurrentValue { get; }
public ApiDeprecationOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<ApiDeprecationOptions, string?> listener) => null;
}
}

View File

@@ -0,0 +1,358 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Evidence;
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class BundleImportEvidenceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BundleImportHashChain_Compute_CreatesDeterministicHash()
{
var input = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "test-bundle",
BundleVersion: "2025.10.0",
CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"),
CreatedBy: "test@example.com",
TotalSizeBytes: 1024,
ItemCount: 5,
ManifestSha256: "sha256:abc123",
Signature: null,
SignatureValid: null);
var outputs = new List<BundleImportOutputFile>
{
new("file1.json", "sha256:aaa", 100, "application/json", DateTimeOffset.UtcNow, "item1"),
new("file2.json", "sha256:bbb", 200, "application/json", DateTimeOffset.UtcNow, "item2")
};
var transcript = new List<BundleImportTranscriptEntry>
{
new(DateTimeOffset.UtcNow, "info", "import.started", "Import started", null)
};
var chain1 = BundleImportHashChain.Compute(input, outputs, transcript);
var chain2 = BundleImportHashChain.Compute(input, outputs, transcript);
Assert.Equal(chain1.RootHash, chain2.RootHash);
Assert.Equal(chain1.InputsHash, chain2.InputsHash);
Assert.Equal(chain1.OutputsHash, chain2.OutputsHash);
Assert.StartsWith("sha256:", chain1.RootHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BundleImportHashChain_Compute_DifferentInputsProduceDifferentHashes()
{
var input1 = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "bundle-1",
BundleVersion: "2025.10.0",
CreatedAt: DateTimeOffset.UtcNow,
CreatedBy: null,
TotalSizeBytes: 1024,
ItemCount: 5,
ManifestSha256: "sha256:abc123",
Signature: null,
SignatureValid: null);
var input2 = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "bundle-2",
BundleVersion: "2025.10.0",
CreatedAt: DateTimeOffset.UtcNow,
CreatedBy: null,
TotalSizeBytes: 1024,
ItemCount: 5,
ManifestSha256: "sha256:def456",
Signature: null,
SignatureValid: null);
var outputs = new List<BundleImportOutputFile>();
var transcript = new List<BundleImportTranscriptEntry>();
var chain1 = BundleImportHashChain.Compute(input1, outputs, transcript);
var chain2 = BundleImportHashChain.Compute(input2, outputs, transcript);
Assert.NotEqual(chain1.RootHash, chain2.RootHash);
Assert.NotEqual(chain1.InputsHash, chain2.InputsHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_StoresEvidence()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, CancellationToken.None);
Assert.True(result.Success);
Assert.NotNull(result.Snapshot);
Assert.NotNull(result.EvidencePointer);
Assert.Single(store.GetAll());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_CreatesCorrectMaterials()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, CancellationToken.None);
Assert.True(result.Success);
var snapshot = result.Snapshot!;
// Should have: input manifest, 2 outputs, transcript, validation, hashchain = 6 materials
Assert.Equal(6, snapshot.Materials.Count);
Assert.Contains(snapshot.Materials, m => m.Section == "input");
Assert.Contains(snapshot.Materials, m => m.Section == "output");
Assert.Contains(snapshot.Materials, m => m.Section == "transcript");
Assert.Contains(snapshot.Materials, m => m.Section == "validation");
Assert.Contains(snapshot.Materials, m => m.Section == "hashchain");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_SetsCorrectMetadata()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, CancellationToken.None);
Assert.True(result.Success);
var snapshot = result.Snapshot!;
Assert.Equal(evidence.JobId, snapshot.Metadata!["jobId"]);
Assert.Equal(evidence.Status.ToString(), snapshot.Metadata["status"]);
Assert.Equal(evidence.SourcePath, snapshot.Metadata["sourcePath"]);
Assert.Equal("2", snapshot.Metadata["outputCount"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_EmitsTimelineEvent()
{
var store = new InMemoryPackRunEvidenceStore();
var timelineSink = new InMemoryPackRunTimelineEventSink();
var emitter = new PackRunTimelineEventEmitter(
timelineSink,
TimeProvider.System,
NullLogger<PackRunTimelineEventEmitter>.Instance);
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance,
emitter);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, CancellationToken.None);
Assert.True(result.Success);
Assert.Single(timelineSink.GetEvents());
var evt = timelineSink.GetEvents()[0];
Assert.Equal("bundle.import.evidence_captured", evt.EventType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_GetAsync_ReturnsEvidence()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
await service.CaptureAsync(evidence, CancellationToken.None);
var retrieved = await service.GetAsync(evidence.JobId, CancellationToken.None);
Assert.NotNull(retrieved);
Assert.Equal(evidence.JobId, retrieved.JobId);
Assert.Equal(evidence.TenantId, retrieved.TenantId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_GetAsync_ReturnsNullForMissingJob()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var retrieved = await service.GetAsync("non-existent-job", CancellationToken.None);
Assert.Null(retrieved);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_CreatesFile()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
await service.CaptureAsync(evidence, CancellationToken.None);
var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json");
try
{
var result = await service.ExportToPortableBundleAsync(
evidence.JobId,
outputPath,
CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(outputPath, result.OutputPath);
Assert.True(File.Exists(outputPath));
Assert.True(result.SizeBytes > 0);
Assert.StartsWith("sha256:", result.BundleSha256);
}
finally
{
if (File.Exists(outputPath))
{
File.Delete(outputPath);
}
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_FailsForMissingJob()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json");
var result = await service.ExportToPortableBundleAsync(
"non-existent-job",
outputPath,
CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("No evidence found", result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BundleImportEvidence_RecordProperties_AreAccessible()
{
var evidence = CreateTestEvidence();
Assert.Equal("test-job-123", evidence.JobId);
Assert.Equal("tenant-1", evidence.TenantId);
Assert.Equal("/path/to/bundle.tar.gz", evidence.SourcePath);
Assert.Equal(BundleImportStatus.Completed, evidence.Status);
Assert.NotNull(evidence.InputManifest);
Assert.Equal(2, evidence.OutputFiles.Count);
Assert.Equal(2, evidence.Transcript.Count);
Assert.NotNull(evidence.ValidationResult);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BundleImportValidationResult_RecordProperties_AreAccessible()
{
var result = new BundleImportValidationResult(
Valid: true,
ChecksumValid: true,
SignatureValid: true,
FormatValid: true,
Errors: null,
Warnings: ["Advisory data may be stale"]);
Assert.True(result.Valid);
Assert.True(result.ChecksumValid);
Assert.True(result.SignatureValid);
Assert.True(result.FormatValid);
Assert.Null(result.Errors);
Assert.Single(result.Warnings!);
}
private static BundleImportEvidence CreateTestEvidence()
{
var now = DateTimeOffset.UtcNow;
var input = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "test-bundle-001",
BundleVersion: "2025.10.0",
CreatedAt: now.AddHours(-1),
CreatedBy: "bundle-builder@example.com",
TotalSizeBytes: 10240,
ItemCount: 5,
ManifestSha256: "sha256:abcdef1234567890",
Signature: "base64sig...",
SignatureValid: true);
var outputs = new List<BundleImportOutputFile>
{
new("advisories/CVE-2025-0001.json", "sha256:output1hash", 512, "application/json", now, "item1"),
new("advisories/CVE-2025-0002.json", "sha256:output2hash", 1024, "application/json", now, "item2")
};
var transcript = new List<BundleImportTranscriptEntry>
{
new(now.AddMinutes(-5), "info", "import.started", "Bundle import started", new Dictionary<string, string>
{
["sourcePath"] = "/path/to/bundle.tar.gz"
}),
new(now, "info", "import.completed", "Bundle import completed successfully", new Dictionary<string, string>
{
["itemsImported"] = "5"
})
};
var validation = new BundleImportValidationResult(
Valid: true,
ChecksumValid: true,
SignatureValid: true,
FormatValid: true,
Errors: null,
Warnings: null);
var hashChain = BundleImportHashChain.Compute(input, outputs, transcript);
return new BundleImportEvidence(
JobId: "test-job-123",
TenantId: "tenant-1",
SourcePath: "/path/to/bundle.tar.gz",
StartedAt: now.AddMinutes(-5),
CompletedAt: now,
Status: BundleImportStatus.Completed,
ErrorMessage: null,
InitiatedBy: "admin@example.com",
InputManifest: input,
OutputFiles: outputs,
Transcript: transcript,
ValidationResult: validation,
HashChain: hashChain);
}
}

View File

@@ -0,0 +1,144 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class BundleIngestionStepExecutorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_ValidBundle_CopiesAndSucceeds()
{
using var temp = new TempDirectory();
var source = Path.Combine(temp.Path, "bundle.tgz");
var ct = CancellationToken.None;
await File.WriteAllTextAsync(source, "bundle-data", ct);
var checksum = "b9c72134b48cdc15e7a47f2476a41612d2084b763bea0575f5600b22041db7dc"; // sha256 of "bundle-data"
var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path });
var executor = new BundleIngestionStepExecutor(options, NullLogger<BundleIngestionStepExecutor>.Instance);
var step = CreateStep("builtin:bundle.ingest", new Dictionary<string, TaskPackPlanParameterValue>
{
["path"] = Value(source),
["checksum"] = Value(checksum)
});
var result = await executor.ExecuteAsync(step, step.Parameters, ct);
Assert.True(result.Succeeded);
var staged = Path.Combine(temp.Path, "bundles", checksum, "bundle.tgz");
Assert.True(File.Exists(staged));
Assert.Equal(await File.ReadAllBytesAsync(source, ct), await File.ReadAllBytesAsync(staged, ct));
var metadataPath = Path.Combine(temp.Path, "bundles", checksum, "metadata.json");
Assert.True(File.Exists(metadataPath));
var metadata = await File.ReadAllTextAsync(metadataPath, ct);
Assert.Contains(checksum, metadata, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_ChecksumMismatch_Fails()
{
using var temp = new TempDirectory();
var source = Path.Combine(temp.Path, "bundle.tgz");
var ct = CancellationToken.None;
await File.WriteAllTextAsync(source, "bundle-data", ct);
var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path });
var executor = new BundleIngestionStepExecutor(options, NullLogger<BundleIngestionStepExecutor>.Instance);
var step = CreateStep("builtin:bundle.ingest", new Dictionary<string, TaskPackPlanParameterValue>
{
["path"] = Value(source),
["checksum"] = Value("deadbeef")
});
var result = await executor.ExecuteAsync(step, step.Parameters, ct);
Assert.False(result.Succeeded);
Assert.Contains("Checksum mismatch", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_MissingChecksum_Fails()
{
using var temp = new TempDirectory();
var source = Path.Combine(temp.Path, "bundle.tgz");
var ct = CancellationToken.None;
await File.WriteAllTextAsync(source, "bundle-data", ct);
var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path });
var executor = new BundleIngestionStepExecutor(options, NullLogger<BundleIngestionStepExecutor>.Instance);
var step = CreateStep("builtin:bundle.ingest", new Dictionary<string, TaskPackPlanParameterValue>
{
["path"] = Value(source)
});
var result = await executor.ExecuteAsync(step, step.Parameters, ct);
Assert.False(result.Succeeded);
Assert.Contains("Checksum is required", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_UnknownUses_NoOpSuccess()
{
var ct = CancellationToken.None;
var executor = new BundleIngestionStepExecutor(
Options.Create(new PackRunWorkerOptions { ArtifactsPath = Path.GetTempPath() }),
NullLogger<BundleIngestionStepExecutor>.Instance);
var step = CreateStep("builtin:noop", new Dictionary<string, TaskPackPlanParameterValue>());
var result = await executor.ExecuteAsync(step, step.Parameters, ct);
Assert.True(result.Succeeded);
}
private static TaskPackPlanParameterValue Value(string literal)
=> new(JsonValue.Create(literal), null, null, false);
private static PackRunExecutionStep CreateStep(string uses, IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters)
=> new(
id: "ingest",
templateId: "ingest",
kind: PackRunStepKind.Run,
enabled: true,
uses: uses,
parameters: parameters,
approvalId: null,
gateMessage: null,
maxParallel: null,
continueOnError: false,
children: PackRunExecutionStep.EmptyChildren);
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,91 @@
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class FilePackRunLogStoreTests : IDisposable
{
private readonly string rootPath;
public FilePackRunLogStoreTests()
{
rootPath = Path.Combine(Path.GetTempPath(), "StellaOps_TaskRunnerTests", Guid.NewGuid().ToString("n"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AppendAndReadAsync_RoundTripsEntriesInOrder()
{
var store = new FilePackRunLogStore(rootPath);
var runId = "run-append-test";
var first = new PackRunLogEntry(
DateTimeOffset.UtcNow,
"info",
"run.created",
"Run created.",
StepId: null,
Metadata: new Dictionary<string, string>(StringComparer.Ordinal)
{
["planHash"] = "hash-1"
});
var second = new PackRunLogEntry(
DateTimeOffset.UtcNow.AddSeconds(1),
"info",
"step.started",
"Step started.",
StepId: "build",
Metadata: null);
await store.AppendAsync(runId, first, CancellationToken.None);
await store.AppendAsync(runId, second, CancellationToken.None);
var reloaded = new List<PackRunLogEntry>();
await foreach (var entry in store.ReadAsync(runId, CancellationToken.None))
{
reloaded.Add(entry);
}
Assert.Collection(
reloaded,
entry =>
{
Assert.Equal("run.created", entry.EventType);
Assert.NotNull(entry.Metadata);
Assert.Equal("hash-1", entry.Metadata!["planHash"]);
},
entry =>
{
Assert.Equal("step.started", entry.EventType);
Assert.Equal("build", entry.StepId);
});
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExistsAsync_ReturnsFalseWhenNoLogPresent()
{
var store = new FilePackRunLogStore(rootPath);
var exists = await store.ExistsAsync("missing-run", CancellationToken.None);
Assert.False(exists);
}
public void Dispose()
{
try
{
if (Directory.Exists(rootPath))
{
Directory.Delete(rootPath, recursive: true);
}
}
catch
{
// Ignore cleanup failures to keep tests deterministic.
}
}
}

View File

@@ -0,0 +1,132 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class FilePackRunStateStoreTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SaveAndGetAsync_RoundTripsState()
{
var directory = CreateTempDirectory();
try
{
var store = new FilePackRunStateStore(directory);
var original = CreateState("run:primary");
await store.SaveAsync(original, CancellationToken.None);
var reloaded = await store.GetAsync("run:primary", CancellationToken.None);
Assert.NotNull(reloaded);
Assert.Equal(original.RunId, reloaded!.RunId);
Assert.Equal(original.PlanHash, reloaded.PlanHash);
Assert.Equal(original.FailurePolicy, reloaded.FailurePolicy);
Assert.Equal(original.Steps.Count, reloaded.Steps.Count);
var step = Assert.Single(reloaded.Steps);
Assert.Equal("step-a", step.Key);
Assert.Equal(original.Steps["step-a"], step.Value);
}
finally
{
TryDelete(directory);
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListAsync_ReturnsStatesInDeterministicOrder()
{
var directory = CreateTempDirectory();
try
{
var store = new FilePackRunStateStore(directory);
var stateB = CreateState("run-b");
var stateA = CreateState("run-a");
await store.SaveAsync(stateB, CancellationToken.None);
await store.SaveAsync(stateA, CancellationToken.None);
var states = await store.ListAsync(CancellationToken.None);
Assert.Collection(states,
first => Assert.Equal("run-a", first.RunId),
second => Assert.Equal("run-b", second.RunId));
}
finally
{
TryDelete(directory);
}
}
private static PackRunState CreateState(string runId)
{
var failurePolicy = new TaskPackPlanFailurePolicy(MaxAttempts: 3, BackoffSeconds: 30, ContinueOnError: false);
var metadata = new TaskPackPlanMetadata("sample", "1.0.0", null, Array.Empty<string>());
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
var stepPlan = new TaskPackPlanStep(
Id: "step-a",
TemplateId: "run/image",
Name: "Run step",
Type: "run",
Enabled: true,
Uses: "builtin/run",
Parameters: parameters,
ApprovalId: null,
GateMessage: null,
Children: Array.Empty<TaskPackPlanStep>());
var plan = new TaskPackPlan(
metadata,
new Dictionary<string, JsonNode?>(StringComparer.Ordinal),
new[] { stepPlan },
"hash-123",
Array.Empty<TaskPackPlanApproval>(),
Array.Empty<TaskPackPlanSecret>(),
Array.Empty<TaskPackPlanOutput>(),
failurePolicy);
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal)
{
["step-a"] = new PackRunStepStateRecord(
StepId: "step-a",
Kind: PackRunStepKind.Run,
Enabled: true,
ContinueOnError: false,
MaxParallel: null,
ApprovalId: null,
GateMessage: null,
Status: PackRunStepExecutionStatus.Pending,
Attempts: 1,
LastTransitionAt: DateTimeOffset.UtcNow,
NextAttemptAt: null,
StatusReason: null)
};
var timestamp = DateTimeOffset.UtcNow;
return PackRunState.Create(runId, "hash-123", plan, failurePolicy, timestamp, steps, timestamp);
}
private static string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "stellaops-taskrunner-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static void TryDelete(string directory)
{
try
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
catch
{
// Swallow cleanup errors to avoid masking test assertions.
}
}
}

View File

@@ -0,0 +1,106 @@
using System.Text.Json;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class FilesystemPackRunArtifactReaderTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListAsync_ReturnsEmpty_WhenManifestMissing()
{
using var temp = new TempDir();
var ct = CancellationToken.None;
var reader = new FilesystemPackRunArtifactReader(temp.Path);
var results = await reader.ListAsync("run-absent", ct);
Assert.Empty(results);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListAsync_ParsesManifestAndSortsByName()
{
using var temp = new TempDir();
var runId = "run-1";
var manifestPath = Path.Combine(temp.Path, "run-1", "artifact-manifest.json");
Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
var ct = CancellationToken.None;
var manifest = new
{
RunId = runId,
UploadedAt = DateTimeOffset.UtcNow,
Outputs = new[]
{
new
{
Name = "b",
Type = "file",
SourcePath = (string?)"/tmp/source-b",
StoredPath = "files/b.txt",
Status = "copied",
Notes = (string?)"ok",
ExpressionJson = (string?)null
},
new
{
Name = "a",
Type = "object",
SourcePath = (string?)null,
StoredPath = "expressions/a.json",
Status = "materialized",
Notes = (string?)null,
ExpressionJson = (string?)"{\"key\":\"value\"}"
}
}
};
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web));
await File.WriteAllTextAsync(manifestPath, json, ct);
var reader = new FilesystemPackRunArtifactReader(temp.Path);
var results = await reader.ListAsync(runId, ct);
Assert.Equal(2, results.Count);
Assert.Collection(results,
first =>
{
Assert.Equal("a", first.Name);
Assert.Equal("object", first.Type);
Assert.Equal("expressions/a.json", first.StoredPath);
Assert.Equal("materialized", first.Status);
Assert.Equal("{\"key\":\"value\"}", first.ExpressionJson);
},
second =>
{
Assert.Equal("b", second.Name);
Assert.Equal("file", second.Type);
Assert.Equal("files/b.txt", second.StoredPath);
Assert.Equal("copied", second.Status);
Assert.Null(second.ExpressionJson);
});
}
}
internal sealed class TempDir : IDisposable
{
public TempDir()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}

View File

@@ -0,0 +1,142 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Infrastructure.Execution;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class FilesystemPackRunArtifactUploaderTests : IDisposable
{
private readonly string artifactsRoot;
public FilesystemPackRunArtifactUploaderTests()
{
artifactsRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CopiesFileOutputs()
{
var sourceFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():n}.txt");
await File.WriteAllTextAsync(sourceFile, "artifact-content", CancellationToken.None);
var uploader = CreateUploader();
var output = CreateFileOutput("bundle", sourceFile);
var context = CreateContext();
var state = CreateState(context);
await uploader.UploadAsync(context, state, new[] { output }, CancellationToken.None);
var runPath = Path.Combine(artifactsRoot, context.RunId);
var filesDirectory = Path.Combine(runPath, "files");
var copiedFiles = Directory.GetFiles(filesDirectory);
Assert.Single(copiedFiles);
Assert.Equal("bundle.txt", Path.GetFileName(copiedFiles[0]));
Assert.Equal("artifact-content", await File.ReadAllTextAsync(copiedFiles[0], CancellationToken.None));
var manifest = await ReadManifestAsync(runPath);
Assert.Single(manifest.Outputs);
Assert.Equal("copied", manifest.Outputs[0].Status);
Assert.Equal("files/bundle.txt", manifest.Outputs[0].StoredPath);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RecordsMissingFilesWithoutThrowing()
{
var uploader = CreateUploader();
var output = CreateFileOutput("missing", Path.Combine(Path.GetTempPath(), "does-not-exist.txt"));
var context = CreateContext();
var state = CreateState(context);
await uploader.UploadAsync(context, state, new[] { output }, CancellationToken.None);
var manifest = await ReadManifestAsync(Path.Combine(artifactsRoot, context.RunId));
Assert.Equal("missing", manifest.Outputs[0].Status);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WritesExpressionOutputsAsJson()
{
var uploader = CreateUploader();
var output = CreateExpressionOutput("metadata", JsonNode.Parse("""{"foo":"bar"}""")!);
var context = CreateContext();
var state = CreateState(context);
await uploader.UploadAsync(context, state, new[] { output }, CancellationToken.None);
var expressionPath = Path.Combine(artifactsRoot, context.RunId, "expressions", "metadata.json");
Assert.True(File.Exists(expressionPath));
var manifest = await ReadManifestAsync(Path.Combine(artifactsRoot, context.RunId));
Assert.Equal("materialized", manifest.Outputs[0].Status);
Assert.Equal("expressions/metadata.json", manifest.Outputs[0].StoredPath);
}
private FilesystemPackRunArtifactUploader CreateUploader()
=> new(artifactsRoot, TimeProvider.System, NullLogger<FilesystemPackRunArtifactUploader>.Instance);
private static TaskPackPlanOutput CreateFileOutput(string name, string path)
=> new(
name,
Type: "file",
Path: new TaskPackPlanParameterValue(JsonValue.Create(path), null, null, false),
Expression: null);
private static TaskPackPlanOutput CreateExpressionOutput(string name, JsonNode expression)
=> new(
name,
Type: "object",
Path: null,
Expression: new TaskPackPlanParameterValue(expression, null, null, false));
private static PackRunExecutionContext CreateContext()
=> new("run-" + Guid.NewGuid().ToString("n"), CreatePlan(), DateTimeOffset.UtcNow);
private static PackRunState CreateState(PackRunExecutionContext context)
=> PackRunState.Create(
runId: context.RunId,
planHash: context.Plan.Hash,
context.Plan,
failurePolicy: new TaskPackPlanFailurePolicy(1, 1, false),
requestedAt: DateTimeOffset.UtcNow,
steps: new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal),
timestamp: DateTimeOffset.UtcNow);
private static TaskPackPlan CreatePlan()
{
return new TaskPackPlan(
new TaskPackPlanMetadata("sample-pack", "1.0.0", null, Array.Empty<string>()),
new Dictionary<string, JsonNode?>(StringComparer.Ordinal),
Array.Empty<TaskPackPlanStep>(),
hash: "hash",
approvals: Array.Empty<TaskPackPlanApproval>(),
secrets: Array.Empty<TaskPackPlanSecret>(),
outputs: Array.Empty<TaskPackPlanOutput>(),
failurePolicy: new TaskPackPlanFailurePolicy(1, 1, false));
}
private static async Task<ArtifactManifestModel> ReadManifestAsync(string runPath)
{
var json = await File.ReadAllTextAsync(Path.Combine(runPath, "artifact-manifest.json"), CancellationToken.None);
return JsonSerializer.Deserialize<ArtifactManifestModel>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
}
public void Dispose()
{
if (Directory.Exists(artifactsRoot))
{
Directory.Delete(artifactsRoot, recursive: true);
}
}
private sealed record ArtifactManifestModel(string RunId, DateTimeOffset UploadedAt, List<ArtifactRecordModel> Outputs);
private sealed record ArtifactRecordModel(string Name, string Type, string? SourcePath, string? StoredPath, string Status, string? Notes);
}

View File

@@ -0,0 +1,67 @@
using System.Text.Json;
using StellaOps.AirGap.Policy;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class FilesystemPackRunDispatcherTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TryDequeueAsync_BlocksJob_WhenEgressPolicyDeniesDestination()
{
var root = Path.Combine(Path.GetTempPath(), "StellaOps_TaskRunnerTests", Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(root);
var cancellationToken = CancellationToken.None;
var queuePath = Path.Combine(root, "queue");
var archivePath = Path.Combine(root, "archive");
Directory.CreateDirectory(queuePath);
Directory.CreateDirectory(archivePath);
var manifestPath = Path.Combine(queuePath, "manifest.yaml");
await File.WriteAllTextAsync(manifestPath, TestManifests.EgressBlocked, cancellationToken);
var jobEnvelope = new
{
RunId = "run-egress-blocked",
ManifestPath = Path.GetFileName(manifestPath),
InputsPath = (string?)null,
RequestedAt = (DateTimeOffset?)null
};
var jobPath = Path.Combine(queuePath, "job.json");
await File.WriteAllTextAsync(jobPath, JsonSerializer.Serialize(jobEnvelope), cancellationToken);
var policy = new EgressPolicy(new EgressPolicyOptions
{
Mode = EgressPolicyMode.Sealed,
AllowLoopback = false,
AllowPrivateNetworks = false
});
try
{
var dispatcher = new FilesystemPackRunDispatcher(queuePath, archivePath, policy);
var result = await dispatcher.TryDequeueAsync(cancellationToken);
Assert.Null(result);
Assert.False(File.Exists(jobPath));
Assert.True(File.Exists(jobPath + ".failed"));
Assert.Empty(Directory.GetFiles(archivePath));
}
finally
{
try
{
Directory.Delete(root, recursive: true);
}
catch
{
// Best-effort cleanup; ignore failures to avoid masking test results.
}
}
}
}

View File

@@ -0,0 +1,55 @@
using StellaOps.TaskRunner.WebService;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class OpenApiMetadataFactoryTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_ProducesExpectedDefaults()
{
var metadata = OpenApiMetadataFactory.Create();
Assert.Equal("/openapi", metadata.SpecUrl);
Assert.Equal(OpenApiMetadataFactory.ApiVersion, metadata.Version);
Assert.False(string.IsNullOrWhiteSpace(metadata.BuildVersion));
Assert.StartsWith("W/\"", metadata.ETag);
Assert.EndsWith("\"", metadata.ETag);
Assert.StartsWith("sha256:", metadata.Signature);
var hashPart = metadata.Signature["sha256:".Length..];
Assert.Equal(64, hashPart.Length);
Assert.True(hashPart.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f')));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_AllowsOverrideUrl()
{
var metadata = OpenApiMetadataFactory.Create("/docs/openapi.json");
Assert.Equal("/docs/openapi.json", metadata.SpecUrl);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_SignatureIncludesAllComponents()
{
var metadata1 = OpenApiMetadataFactory.Create("/path1");
var metadata2 = OpenApiMetadataFactory.Create("/path2");
// Different URLs should produce different signatures
Assert.NotEqual(metadata1.Signature, metadata2.Signature);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_ETagIsDeterministic()
{
var metadata1 = OpenApiMetadataFactory.Create();
var metadata2 = OpenApiMetadataFactory.Create();
// Same inputs should produce same ETag
Assert.Equal(metadata1.ETag, metadata2.ETag);
}
}

View File

@@ -0,0 +1,101 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunApprovalCoordinatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_FromPlan_PopulatesApprovals()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var approvals = coordinator.GetApprovals();
Assert.Single(approvals);
Assert.Equal("security-review", approvals[0].ApprovalId);
Assert.Equal(PackRunApprovalStatus.Pending, approvals[0].Status);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Approve_AllowsResumeWhenLastApprovalCompletes()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var result = coordinator.Approve("security-review", "approver-1", DateTimeOffset.UtcNow);
Assert.True(result.ShouldResumeRun);
Assert.Equal(PackRunApprovalStatus.Approved, result.State.Status);
Assert.Equal("approver-1", result.State.ActorId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Reject_DoesNotResumeAndMarksState()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var result = coordinator.Reject("security-review", "approver-1", DateTimeOffset.UtcNow, "Not safe");
Assert.False(result.ShouldResumeRun);
Assert.Equal(PackRunApprovalStatus.Rejected, result.State.Status);
Assert.Equal("Not safe", result.State.Summary);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildNotifications_UsesRequirements()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var notifications = coordinator.BuildNotifications(plan);
Assert.Single(notifications);
var notification = notifications[0];
Assert.Equal("security-review", notification.ApprovalId);
Assert.Contains("Packs.Approve", notification.RequiredGrants);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildPolicyNotifications_ProducesGateMetadata()
{
var plan = BuildPolicyPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var notifications = coordinator.BuildPolicyNotifications(plan);
Assert.Single(notifications);
var hint = notifications[0];
Assert.Equal("policy-check", hint.StepId);
var parameter = hint.Parameters.Single(p => p.Name == "threshold");
Assert.False(parameter.RequiresRuntimeValue);
var runtimeParam = hint.Parameters.Single(p => p.Name == "evidenceRef");
Assert.True(runtimeParam.RequiresRuntimeValue);
Assert.Equal("steps.prepare.outputs.evidence", runtimeParam.Expression);
}
private static TaskPackPlan BuildPlan()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(false)
};
return planner.Plan(manifest, inputs).Plan!;
}
private static TaskPackPlan BuildPolicyPlan()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
return planner.Plan(manifest).Plan!;
}
}

View File

@@ -0,0 +1,292 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunApprovalDecisionServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ApplyAsync_ApprovingLastGateSchedulesResume()
{
var plan = TestPlanFactory.CreatePlan();
var state = TestPlanFactory.CreateState("run-1", plan);
var approval = new PackRunApprovalState(
"security-review",
new[] { "Packs.Approve" },
new[] { "step-a" },
Array.Empty<string>(),
null,
DateTimeOffset.UtcNow.AddMinutes(-5),
PackRunApprovalStatus.Pending);
var approvalStore = new InMemoryApprovalStore(new Dictionary<string, IReadOnlyList<PackRunApprovalState>>
{
["run-1"] = new List<PackRunApprovalState> { approval }
});
var stateStore = new InMemoryStateStore(new Dictionary<string, PackRunState>
{
["run-1"] = state
});
var scheduler = new RecordingScheduler();
var service = new PackRunApprovalDecisionService(
approvalStore,
stateStore,
scheduler,
NullLogger<PackRunApprovalDecisionService>.Instance);
var result = await service.ApplyAsync(
new PackRunApprovalDecisionRequest("run-1", "security-review", plan.Hash, PackRunApprovalDecisionType.Approved, "approver@example.com", "LGTM"),
CancellationToken.None);
Assert.Equal("resumed", result.Status);
Assert.True(scheduler.ScheduledContexts.TryGetValue("run-1", out var context));
Assert.Equal(plan.Hash, context!.Plan.Hash);
Assert.Equal(PackRunApprovalStatus.Approved, approvalStore.LastUpdated?.Status);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ApplyAsync_ReturnsNotFoundWhenStateMissing()
{
var approvalStore = new InMemoryApprovalStore(new Dictionary<string, IReadOnlyList<PackRunApprovalState>>());
var stateStore = new InMemoryStateStore(new Dictionary<string, PackRunState>());
var scheduler = new RecordingScheduler();
var service = new PackRunApprovalDecisionService(
approvalStore,
stateStore,
scheduler,
NullLogger<PackRunApprovalDecisionService>.Instance);
var result = await service.ApplyAsync(
new PackRunApprovalDecisionRequest("missing", "approval", "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", PackRunApprovalDecisionType.Approved, "actor", null),
CancellationToken.None);
Assert.Equal("not_found", result.Status);
Assert.False(scheduler.ScheduledContexts.Any());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ApplyAsync_ReturnsPlanHashMismatchWhenIncorrect()
{
var plan = TestPlanFactory.CreatePlan();
var state = TestPlanFactory.CreateState("run-1", plan);
var approval = new PackRunApprovalState(
"security-review",
new[] { "Packs.Approve" },
new[] { "step-a" },
Array.Empty<string>(),
null,
DateTimeOffset.UtcNow.AddMinutes(-5),
PackRunApprovalStatus.Pending);
var approvalStore = new InMemoryApprovalStore(new Dictionary<string, IReadOnlyList<PackRunApprovalState>>
{
["run-1"] = new List<PackRunApprovalState> { approval }
});
var stateStore = new InMemoryStateStore(new Dictionary<string, PackRunState>
{
["run-1"] = state
});
var scheduler = new RecordingScheduler();
var service = new PackRunApprovalDecisionService(
approvalStore,
stateStore,
scheduler,
NullLogger<PackRunApprovalDecisionService>.Instance);
var result = await service.ApplyAsync(
new PackRunApprovalDecisionRequest("run-1", "security-review", "wrong-hash", PackRunApprovalDecisionType.Approved, "actor", null),
CancellationToken.None);
Assert.Equal("plan_hash_mismatch", result.Status);
Assert.False(scheduler.ScheduledContexts.Any());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ApplyAsync_ReturnsPlanHashMismatchWhenFormatInvalid()
{
var plan = TestPlanFactory.CreatePlan();
var state = TestPlanFactory.CreateState("run-1", plan);
var approval = new PackRunApprovalState(
"security-review",
new[] { "Packs.Approve" },
new[] { "step-a" },
Array.Empty<string>(),
null,
DateTimeOffset.UtcNow.AddMinutes(-5),
PackRunApprovalStatus.Pending);
var approvalStore = new InMemoryApprovalStore(new Dictionary<string, IReadOnlyList<PackRunApprovalState>>
{
["run-1"] = new List<PackRunApprovalState> { approval }
});
var stateStore = new InMemoryStateStore(new Dictionary<string, PackRunState>
{
["run-1"] = state
});
var scheduler = new RecordingScheduler();
var service = new PackRunApprovalDecisionService(
approvalStore,
stateStore,
scheduler,
NullLogger<PackRunApprovalDecisionService>.Instance);
var result = await service.ApplyAsync(
new PackRunApprovalDecisionRequest("run-1", "security-review", "not-a-digest", PackRunApprovalDecisionType.Approved, "actor", null),
CancellationToken.None);
Assert.Equal("plan_hash_mismatch", result.Status);
Assert.False(scheduler.ScheduledContexts.Any());
}
private sealed class InMemoryApprovalStore : IPackRunApprovalStore
{
private readonly Dictionary<string, List<PackRunApprovalState>> _approvals;
public PackRunApprovalState? LastUpdated { get; private set; }
public InMemoryApprovalStore(IDictionary<string, IReadOnlyList<PackRunApprovalState>> seed)
{
_approvals = seed.ToDictionary(
pair => pair.Key,
pair => pair.Value.ToList(),
StringComparer.Ordinal);
}
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
_approvals[runId] = approvals.ToList();
return Task.CompletedTask;
}
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
{
if (_approvals.TryGetValue(runId, out var existing))
{
return Task.FromResult<IReadOnlyList<PackRunApprovalState>>(existing);
}
return Task.FromResult<IReadOnlyList<PackRunApprovalState>>(Array.Empty<PackRunApprovalState>());
}
public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
{
if (_approvals.TryGetValue(runId, out var list))
{
for (var i = 0; i < list.Count; i++)
{
if (string.Equals(list[i].ApprovalId, approval.ApprovalId, StringComparison.Ordinal))
{
list[i] = approval;
LastUpdated = approval;
break;
}
}
}
return Task.CompletedTask;
}
}
private sealed class InMemoryStateStore : IPackRunStateStore
{
private readonly Dictionary<string, PackRunState> _states;
public InMemoryStateStore(IDictionary<string, PackRunState> states)
{
_states = new Dictionary<string, PackRunState>(states, StringComparer.Ordinal);
}
public Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
_states.TryGetValue(runId, out var state);
return Task.FromResult(state);
}
public Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
_states[state.RunId] = state;
return Task.CompletedTask;
}
public Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<PackRunState>>(_states.Values.ToList());
}
private sealed class RecordingScheduler : IPackRunJobScheduler
{
public Dictionary<string, PackRunExecutionContext> ScheduledContexts { get; } = new(StringComparer.Ordinal);
public Task ScheduleAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
ScheduledContexts[context.RunId] = context;
return Task.CompletedTask;
}
}
}
internal static class TestPlanFactory
{
public static TaskPackPlan CreatePlan()
{
var metadata = new TaskPackPlanMetadata("sample", "1.0.0", null, Array.Empty<string>());
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
var step = new TaskPackPlanStep(
Id: "step-a",
TemplateId: "run/image",
Name: "Run step",
Type: "run",
Enabled: true,
Uses: "builtin/run",
Parameters: parameters,
ApprovalId: "security-review",
GateMessage: null,
Children: Array.Empty<TaskPackPlanStep>());
return new TaskPackPlan(
metadata,
new Dictionary<string, JsonNode?>(StringComparer.Ordinal),
new[] { step },
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
new[]
{
new TaskPackPlanApproval("security-review", new[] { "Packs.Approve" }, null, null)
},
Array.Empty<TaskPackPlanSecret>(),
Array.Empty<TaskPackPlanOutput>(),
new TaskPackPlanFailurePolicy(3, 30, false));
}
public static PackRunState CreateState(string runId, TaskPackPlan plan)
{
var timestamp = DateTimeOffset.UtcNow;
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal)
{
["step-a"] = new PackRunStepStateRecord(
"step-a",
PackRunStepKind.GateApproval,
true,
false,
null,
"security-review",
null,
PackRunStepExecutionStatus.Pending,
0,
null,
null,
null)
};
return PackRunState.Create(runId, plan.Hash, plan, plan.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy, timestamp, steps, timestamp);
}
}

View File

@@ -0,0 +1,506 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Attestation;
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TaskRunner.Core.Evidence;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunAttestationTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GenerateAsync_CreatesAttestationWithSubjects()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" }),
new("artifact/sbom.json", new Dictionary<string, string> { ["sha256"] = "def456" })
};
var request = new PackRunAttestationRequest(
RunId: "run-001",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: subjects,
EvidenceSnapshotId: Guid.NewGuid(),
StartedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var result = await service.GenerateAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.NotNull(result.Attestation);
Assert.Equal(PackRunAttestationStatus.Signed, result.Attestation.Status);
Assert.Equal(2, result.Attestation.Subjects.Count);
Assert.NotNull(result.Attestation.Envelope);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GenerateAsync_WithoutSigner_CreatesPendingAttestation()
{
var store = new InMemoryPackRunAttestationStore();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance);
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
};
var request = new PackRunAttestationRequest(
RunId: "run-002",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: subjects,
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: null,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var result = await service.GenerateAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.NotNull(result.Attestation);
Assert.Equal(PackRunAttestationStatus.Pending, result.Attestation.Status);
Assert.Null(result.Attestation.Envelope);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GenerateAsync_EmitsTimelineEvent()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var timelineSink = new InMemoryPackRunTimelineEventSink();
var emitter = new PackRunTimelineEventEmitter(
timelineSink,
TimeProvider.System,
NullLogger<PackRunTimelineEventEmitter>.Instance);
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer,
emitter);
var request = new PackRunAttestationRequest(
RunId: "run-003",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: [new("artifact/test.json", new Dictionary<string, string> { ["sha256"] = "abc" })],
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
await service.GenerateAsync(request, CancellationToken.None);
Assert.Single(timelineSink.GetEvents());
var evt = timelineSink.GetEvents()[0];
Assert.Equal(PackRunAttestationEventTypes.AttestationCreated, evt.EventType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_ValidatesSubjectsMatch()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
};
var request = new PackRunAttestationRequest(
RunId: "run-004",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: subjects,
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var genResult = await service.GenerateAsync(request, CancellationToken.None);
Assert.NotNull(genResult.Attestation);
var verifyResult = await service.VerifyAsync(
new PackRunAttestationVerificationRequest(
AttestationId: genResult.Attestation.AttestationId,
ExpectedSubjects: subjects,
VerifySignature: true,
VerifySubjects: true,
CheckRevocation: true),
CancellationToken.None);
Assert.True(verifyResult.Valid);
Assert.Equal(PackRunSignatureVerificationStatus.Valid, verifyResult.SignatureStatus);
Assert.Equal(PackRunSubjectVerificationStatus.Match, verifyResult.SubjectStatus);
Assert.Equal(PackRunRevocationStatus.NotRevoked, verifyResult.RevocationStatus);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_DetectsMismatchedSubjects()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
};
var request = new PackRunAttestationRequest(
RunId: "run-005",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: subjects,
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var genResult = await service.GenerateAsync(request, CancellationToken.None);
Assert.NotNull(genResult.Attestation);
// Verify with different expected subjects
var differentSubjects = new List<PackRunAttestationSubject>
{
new("artifact/different.tar.gz", new Dictionary<string, string> { ["sha256"] = "xyz789" })
};
var verifyResult = await service.VerifyAsync(
new PackRunAttestationVerificationRequest(
AttestationId: genResult.Attestation.AttestationId,
ExpectedSubjects: differentSubjects,
VerifySignature: false,
VerifySubjects: true,
CheckRevocation: false),
CancellationToken.None);
Assert.False(verifyResult.Valid);
Assert.Equal(PackRunSubjectVerificationStatus.Missing, verifyResult.SubjectStatus);
Assert.NotNull(verifyResult.Errors);
Assert.Contains(verifyResult.Errors, e => e.Contains("Missing subjects"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_DetectsRevokedAttestation()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
};
var request = new PackRunAttestationRequest(
RunId: "run-006",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: subjects,
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var genResult = await service.GenerateAsync(request, CancellationToken.None);
Assert.NotNull(genResult.Attestation);
// Revoke the attestation
await store.UpdateStatusAsync(
genResult.Attestation.AttestationId,
PackRunAttestationStatus.Revoked,
"Compromised key",
CancellationToken.None);
var verifyResult = await service.VerifyAsync(
new PackRunAttestationVerificationRequest(
AttestationId: genResult.Attestation.AttestationId,
ExpectedSubjects: null,
VerifySignature: false,
VerifySubjects: false,
CheckRevocation: true),
CancellationToken.None);
Assert.False(verifyResult.Valid);
Assert.Equal(PackRunRevocationStatus.Revoked, verifyResult.RevocationStatus);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_ReturnsErrorForNonExistentAttestation()
{
var store = new InMemoryPackRunAttestationStore();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance);
var verifyResult = await service.VerifyAsync(
new PackRunAttestationVerificationRequest(
AttestationId: Guid.NewGuid(),
ExpectedSubjects: null,
VerifySignature: false,
VerifySubjects: false,
CheckRevocation: false),
CancellationToken.None);
Assert.False(verifyResult.Valid);
Assert.NotNull(verifyResult.Errors);
Assert.Contains(verifyResult.Errors, e => e.Contains("not found"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListByRunAsync_ReturnsAttestationsForRun()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
// Create two attestations for the same run
for (var i = 0; i < 2; i++)
{
var request = new PackRunAttestationRequest(
RunId: "run-007",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: [new($"artifact/output{i}.tar.gz", new Dictionary<string, string> { ["sha256"] = $"hash{i}" })],
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
await service.GenerateAsync(request, CancellationToken.None);
}
var attestations = await service.ListByRunAsync("tenant-1", "run-007", CancellationToken.None);
Assert.Equal(2, attestations.Count);
Assert.All(attestations, a => Assert.Equal("run-007", a.RunId));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetEnvelopeAsync_ReturnsEnvelopeForSignedAttestation()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var request = new PackRunAttestationRequest(
RunId: "run-008",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })],
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var genResult = await service.GenerateAsync(request, CancellationToken.None);
Assert.NotNull(genResult.Attestation);
var envelope = await service.GetEnvelopeAsync(genResult.Attestation.AttestationId, CancellationToken.None);
Assert.NotNull(envelope);
Assert.Equal(PackRunDsseEnvelope.InTotoPayloadType, envelope.PayloadType);
Assert.Single(envelope.Signatures);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PackRunAttestationSubject_FromArtifact_ParsesSha256Prefix()
{
var artifact = new PackRunArtifactReference(
Name: "output.tar.gz",
Sha256: "sha256:abcdef123456",
SizeBytes: 1024,
MediaType: "application/gzip");
var subject = PackRunAttestationSubject.FromArtifact(artifact);
Assert.Equal("output.tar.gz", subject.Name);
Assert.Equal("abcdef123456", subject.Digest["sha256"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PackRunAttestation_ComputeStatementDigest_IsDeterministic()
{
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
};
var attestation = new PackRunAttestation(
AttestationId: Guid.NewGuid(),
TenantId: "tenant-1",
RunId: "run-001",
PlanHash: "sha256:plan123",
CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"),
Subjects: subjects,
PredicateType: PredicateTypes.PackRunProvenance,
PredicateJson: "{\"test\":true}",
Envelope: null,
Status: PackRunAttestationStatus.Pending,
Error: null,
EvidenceSnapshotId: null,
Metadata: null);
var digest1 = attestation.ComputeStatementDigest();
var digest2 = attestation.ComputeStatementDigest();
Assert.Equal(digest1, digest2);
Assert.StartsWith("sha256:", digest1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PackRunDsseEnvelope_ComputeDigest_IsDeterministic()
{
var envelope = new PackRunDsseEnvelope(
PayloadType: PackRunDsseEnvelope.InTotoPayloadType,
Payload: Convert.ToBase64String([1, 2, 3]),
Signatures: [new PackRunDsseSignature("key-001", "sig123")]);
var digest1 = envelope.ComputeDigest();
var digest2 = envelope.ComputeDigest();
Assert.Equal(digest1, digest2);
Assert.StartsWith("sha256:", digest1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GenerateAsync_WithExternalParameters_IncludesInPredicate()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var externalParams = new Dictionary<string, object>
{
["manifestUrl"] = "https://registry.example.com/pack/v1",
["version"] = "1.0.0"
};
var request = new PackRunAttestationRequest(
RunId: "run-009",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc" })],
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: "https://stellaops.io/task-runner/custom",
ExternalParameters: externalParams,
ResolvedDependencies: null,
Metadata: null);
var result = await service.GenerateAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.NotNull(result.Attestation);
Assert.Contains("manifestUrl", result.Attestation.PredicateJson);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GenerateAsync_WithResolvedDependencies_IncludesInPredicate()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var dependencies = new List<PackRunDependency>
{
new("https://registry.example.com/tool/scanner:v1",
new Dictionary<string, string> { ["sha256"] = "scanner123" },
"scanner",
"application/vnd.oci.image.index.v1+json")
};
var request = new PackRunAttestationRequest(
RunId: "run-010",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc" })],
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: dependencies,
Metadata: null);
var result = await service.GenerateAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.NotNull(result.Attestation);
Assert.Contains("resolvedDependencies", result.Attestation.PredicateJson);
Assert.Contains("scanner", result.Attestation.PredicateJson);
}
}

View File

@@ -0,0 +1,740 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TaskRunner.Core.Evidence;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
/// <summary>
/// Tests for pack run evidence snapshot domain model, store, redaction guard, and service.
/// Per TASKRUN-OBS-53-001.
/// </summary>
public sealed class PackRunEvidenceSnapshotTests
{
private const string TestTenantId = "test-tenant";
private const string TestRunId = "run-12345";
private const string TestPlanHash = "sha256:abc123def456789012345678901234567890123456789012345678901234";
private const string TestStepId = "plan-step";
#region PackRunEvidenceSnapshot Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_WithMaterials_ComputesMerkleRoot()
{
// Arrange
var materials = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromString("transcript", "step-001.json", "{\"stepId\":\"step-001\"}"),
PackRunEvidenceMaterial.FromString("transcript", "step-002.json", "{\"stepId\":\"step-002\"}")
};
// Act
var snapshot = PackRunEvidenceSnapshot.Create(
TestTenantId,
TestRunId,
TestPlanHash,
PackRunEvidenceSnapshotKind.RunCompletion,
materials);
// Assert
Assert.NotEqual(Guid.Empty, snapshot.SnapshotId);
Assert.Equal(TestTenantId, snapshot.TenantId);
Assert.Equal(TestRunId, snapshot.RunId);
Assert.Equal(TestPlanHash, snapshot.PlanHash);
Assert.Equal(PackRunEvidenceSnapshotKind.RunCompletion, snapshot.Kind);
Assert.Equal(2, snapshot.Materials.Count);
Assert.StartsWith("sha256:", snapshot.RootHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_WithEmptyMaterials_ReturnsZeroHash()
{
// Act
var snapshot = PackRunEvidenceSnapshot.Create(
TestTenantId,
TestRunId,
TestPlanHash,
PackRunEvidenceSnapshotKind.RunCompletion,
new List<PackRunEvidenceMaterial>());
// Assert
Assert.Equal("sha256:" + new string('0', 64), snapshot.RootHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_WithMetadata_StoresMetadata()
{
// Arrange
var metadata = new Dictionary<string, string>
{
["key1"] = "value1",
["key2"] = "value2"
};
// Act
var snapshot = PackRunEvidenceSnapshot.Create(
TestTenantId,
TestRunId,
TestPlanHash,
PackRunEvidenceSnapshotKind.StepExecution,
new List<PackRunEvidenceMaterial>(),
metadata);
// Assert
Assert.NotNull(snapshot.Metadata);
Assert.Equal("value1", snapshot.Metadata["key1"]);
Assert.Equal("value2", snapshot.Metadata["key2"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_SameMaterials_ProducesDeterministicHash()
{
// Arrange
var materials = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromString("transcript", "step-001.json", "{\"data\":\"test\"}")
};
// Act
var snapshot1 = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.StepExecution, materials);
var snapshot2 = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.StepExecution, materials);
// Assert
Assert.Equal(snapshot1.RootHash, snapshot2.RootHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_MaterialOrderDoesNotAffectHash()
{
// Arrange - materials in different order
var materials1 = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromString("transcript", "a.json", "{}"),
PackRunEvidenceMaterial.FromString("transcript", "b.json", "{}")
};
var materials2 = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromString("transcript", "b.json", "{}"),
PackRunEvidenceMaterial.FromString("transcript", "a.json", "{}")
};
// Act
var snapshot1 = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.RunCompletion, materials1);
var snapshot2 = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.RunCompletion, materials2);
// Assert - hash should be same due to canonical ordering
Assert.Equal(snapshot1.RootHash, snapshot2.RootHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ToJson_AndFromJson_RoundTrips()
{
// Arrange
var materials = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromString("test", "file.txt", "content")
};
var snapshot = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.RunCompletion, materials);
// Act
var json = snapshot.ToJson();
var restored = PackRunEvidenceSnapshot.FromJson(json);
// Assert
Assert.NotNull(restored);
Assert.Equal(snapshot.SnapshotId, restored.SnapshotId);
Assert.Equal(snapshot.RootHash, restored.RootHash);
Assert.Equal(snapshot.TenantId, restored.TenantId);
}
#endregion
#region PackRunEvidenceMaterial Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FromString_ComputesSha256Hash()
{
// Act
var material = PackRunEvidenceMaterial.FromString(
"transcript", "output.txt", "Hello, World!");
// Assert
Assert.Equal("transcript", material.Section);
Assert.Equal("output.txt", material.Path);
Assert.StartsWith("sha256:", material.Sha256);
Assert.Equal("text/plain", material.MediaType);
Assert.Equal(13, material.SizeBytes); // "Hello, World!" is 13 bytes
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FromJson_ComputesSha256Hash()
{
// Arrange
var obj = new { stepId = "step-001", status = "completed" };
// Act
var material = PackRunEvidenceMaterial.FromJson("transcript", "step.json", obj);
// Assert
Assert.Equal("transcript", material.Section);
Assert.Equal("step.json", material.Path);
Assert.StartsWith("sha256:", material.Sha256);
Assert.Equal("application/json", material.MediaType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FromContent_WithAttributes_StoresAttributes()
{
// Arrange
var attributes = new Dictionary<string, string> { ["stepId"] = "step-001" };
// Act
var material = PackRunEvidenceMaterial.FromContent(
"artifact", "output.bin", new byte[] { 1, 2, 3 },
"application/octet-stream", attributes);
// Assert
Assert.NotNull(material.Attributes);
Assert.Equal("step-001", material.Attributes["stepId"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalPath_CombinesSectionAndPath()
{
// Act
var material = PackRunEvidenceMaterial.FromString("transcript", "step-001.json", "{}");
// Assert
Assert.Equal("transcript/step-001.json", material.CanonicalPath);
}
#endregion
#region InMemoryPackRunEvidenceStore Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Store_AndGet_ReturnsSnapshot()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
var snapshot = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.RunCompletion,
new List<PackRunEvidenceMaterial>());
// Act
await store.StoreAsync(snapshot, CancellationToken.None);
var retrieved = await store.GetAsync(snapshot.SnapshotId, CancellationToken.None);
// Assert
Assert.NotNull(retrieved);
Assert.Equal(snapshot.SnapshotId, retrieved.SnapshotId);
Assert.Equal(snapshot.RootHash, retrieved.RootHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Get_NonExistent_ReturnsNull()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
// Act
var result = await store.GetAsync(Guid.NewGuid(), CancellationToken.None);
// Assert
Assert.Null(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListByRun_ReturnsMatchingSnapshots()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
var snapshot1 = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.StepExecution,
new List<PackRunEvidenceMaterial>());
var snapshot2 = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.ApprovalDecision,
new List<PackRunEvidenceMaterial>());
var otherRunSnapshot = PackRunEvidenceSnapshot.Create(
TestTenantId, "other-run", TestPlanHash,
PackRunEvidenceSnapshotKind.StepExecution,
new List<PackRunEvidenceMaterial>());
await store.StoreAsync(snapshot1, CancellationToken.None);
await store.StoreAsync(snapshot2, CancellationToken.None);
await store.StoreAsync(otherRunSnapshot, CancellationToken.None);
// Act
var results = await store.ListByRunAsync(TestTenantId, TestRunId, CancellationToken.None);
// Assert
Assert.Equal(2, results.Count);
Assert.All(results, s => Assert.Equal(TestRunId, s.RunId));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListByKind_ReturnsMatchingSnapshots()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
var stepSnapshot1 = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.StepExecution,
new List<PackRunEvidenceMaterial>());
var stepSnapshot2 = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.StepExecution,
new List<PackRunEvidenceMaterial>());
var approvalSnapshot = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.ApprovalDecision,
new List<PackRunEvidenceMaterial>());
await store.StoreAsync(stepSnapshot1, CancellationToken.None);
await store.StoreAsync(stepSnapshot2, CancellationToken.None);
await store.StoreAsync(approvalSnapshot, CancellationToken.None);
// Act
var results = await store.ListByKindAsync(
TestTenantId, TestRunId,
PackRunEvidenceSnapshotKind.StepExecution,
CancellationToken.None);
// Assert
Assert.Equal(2, results.Count);
Assert.All(results, s => Assert.Equal(PackRunEvidenceSnapshotKind.StepExecution, s.Kind));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_ValidSnapshot_ReturnsValid()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
var materials = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromString("test", "file.txt", "content")
};
var snapshot = PackRunEvidenceSnapshot.Create(
TestTenantId, TestRunId, TestPlanHash,
PackRunEvidenceSnapshotKind.RunCompletion, materials);
await store.StoreAsync(snapshot, CancellationToken.None);
// Act
var result = await store.VerifyAsync(snapshot.SnapshotId, CancellationToken.None);
// Assert
Assert.True(result.Valid);
Assert.Equal(snapshot.RootHash, result.ExpectedHash);
Assert.Equal(snapshot.RootHash, result.ComputedHash);
Assert.Null(result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_NonExistent_ReturnsInvalid()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
// Act
var result = await store.VerifyAsync(Guid.NewGuid(), CancellationToken.None);
// Assert
Assert.False(result.Valid);
Assert.Equal("Snapshot not found", result.Error);
}
#endregion
#region PackRunRedactionGuard Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RedactTranscript_RedactsSensitiveOutput()
{
// Arrange
var guard = new PackRunRedactionGuard();
var transcript = new PackRunStepTranscript(
StepId: TestStepId,
Kind: "shell",
StartedAt: DateTimeOffset.UtcNow,
EndedAt: DateTimeOffset.UtcNow,
Status: "completed",
Attempt: 1,
DurationMs: 100,
Output: "Connecting with Bearer eyJhbGciOiJIUzI1NiJ9.token",
Error: null,
EnvironmentDigest: null,
Artifacts: null);
// Act
var redacted = guard.RedactTranscript(transcript);
// Assert
Assert.DoesNotContain("eyJhbGciOiJIUzI1NiJ9", redacted.Output);
Assert.Contains("[REDACTED", redacted.Output);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RedactTranscript_PreservesNonSensitiveOutput()
{
// Arrange
var guard = new PackRunRedactionGuard();
var transcript = new PackRunStepTranscript(
StepId: TestStepId,
Kind: "shell",
StartedAt: DateTimeOffset.UtcNow,
EndedAt: DateTimeOffset.UtcNow,
Status: "completed",
Attempt: 1,
DurationMs: 100,
Output: "Build completed successfully",
Error: null,
EnvironmentDigest: null,
Artifacts: null);
// Act
var redacted = guard.RedactTranscript(transcript);
// Assert
Assert.Equal("Build completed successfully", redacted.Output);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RedactIdentity_RedactsEmail()
{
// Arrange
var guard = new PackRunRedactionGuard();
// Act
var redacted = guard.RedactIdentity("john.doe@example.com");
// Assert
Assert.DoesNotContain("john.doe", redacted);
Assert.DoesNotContain("example.com", redacted);
Assert.Contains("[", redacted); // Contains redaction markers
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RedactIdentity_HashesNonEmailIdentity()
{
// Arrange
var guard = new PackRunRedactionGuard();
// Act
var redacted = guard.RedactIdentity("admin-user-12345");
// Assert
Assert.StartsWith("[USER:", redacted);
Assert.EndsWith("]", redacted);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RedactApproval_RedactsApproverAndComments()
{
// Arrange
var guard = new PackRunRedactionGuard();
var approval = new PackRunApprovalEvidence(
ApprovalId: "approval-001",
Approver: "jane.doe@example.com",
ApprovedAt: DateTimeOffset.UtcNow,
Decision: "approved",
RequiredGrants: new[] { "deploy:production" },
GrantedBy: new[] { "team-lead@example.com" },
Comments: "Approved. Use token=abc123xyz for deployment.");
// Act
var redacted = guard.RedactApproval(approval);
// Assert
Assert.DoesNotContain("jane.doe", redacted.Approver);
Assert.DoesNotContain("team-lead", redacted.GrantedBy![0]);
Assert.Contains("[REDACTED", redacted.Comments);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RedactValue_ReturnsHashedValue()
{
// Arrange
var guard = new PackRunRedactionGuard();
// Act
var redacted = guard.RedactValue("super-secret-value");
// Assert
Assert.StartsWith("[HASH:", redacted);
Assert.EndsWith("]", redacted);
Assert.DoesNotContain("super-secret-value", redacted);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void NoOpRedactionGuard_PreservesAllData()
{
// Arrange
var guard = NoOpPackRunRedactionGuard.Instance;
var transcript = new PackRunStepTranscript(
StepId: TestStepId,
Kind: "shell",
StartedAt: DateTimeOffset.UtcNow,
EndedAt: DateTimeOffset.UtcNow,
Status: "completed",
Attempt: 1,
DurationMs: 100,
Output: "Bearer secret-token-12345",
Error: null,
EnvironmentDigest: null,
Artifacts: null);
// Act
var result = guard.RedactTranscript(transcript);
// Assert
Assert.Same(transcript, result);
Assert.Equal("Bearer secret-token-12345", result.Output);
}
#endregion
#region PackRunEvidenceSnapshotService Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CaptureRunCompletion_StoresSnapshot()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
var sink = new InMemoryPackRunTimelineEventSink();
var emitter = new PackRunTimelineEventEmitter(
sink, TimeProvider.System, NullLogger<PackRunTimelineEventEmitter>.Instance);
var service = new PackRunEvidenceSnapshotService(
store,
new PackRunRedactionGuard(),
NullLogger<PackRunEvidenceSnapshotService>.Instance,
emitter);
var state = CreateTestPackRunState();
// Act
var result = await service.CaptureRunCompletionAsync(
TestTenantId, TestRunId, TestPlanHash, state,
cancellationToken: CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.NotNull(result.Snapshot);
Assert.NotNull(result.EvidencePointer);
Assert.Equal(PackRunEvidenceSnapshotKind.RunCompletion, result.Snapshot.Kind);
Assert.Single(store.GetAll());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CaptureRunCompletion_WithTranscripts_IncludesRedactedTranscripts()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
var service = new PackRunEvidenceSnapshotService(
store,
new PackRunRedactionGuard(),
NullLogger<PackRunEvidenceSnapshotService>.Instance);
var state = CreateTestPackRunState();
var transcripts = new List<PackRunStepTranscript>
{
new(TestStepId, "shell", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
"completed", 1, 100, "Bearer token123", null, null, null)
};
// Act
var result = await service.CaptureRunCompletionAsync(
TestTenantId, TestRunId, TestPlanHash, state,
transcripts: transcripts,
cancellationToken: CancellationToken.None);
// Assert
Assert.True(result.Success);
var transcriptMaterial = result.Snapshot!.Materials
.FirstOrDefault(m => m.Section == "transcript");
Assert.NotNull(transcriptMaterial);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CaptureStepExecution_CapturesTranscript()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
var service = new PackRunEvidenceSnapshotService(
store,
new PackRunRedactionGuard(),
NullLogger<PackRunEvidenceSnapshotService>.Instance);
var transcript = new PackRunStepTranscript(
TestStepId, "shell", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
"completed", 1, 150, "Build output", null, null, null);
// Act
var result = await service.CaptureStepExecutionAsync(
TestTenantId, TestRunId, TestPlanHash, transcript,
CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.Equal(PackRunEvidenceSnapshotKind.StepExecution, result.Snapshot!.Kind);
Assert.Contains(result.Snapshot.Materials, m => m.Section == "transcript");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CaptureApprovalDecision_CapturesApproval()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
var service = new PackRunEvidenceSnapshotService(
store,
new PackRunRedactionGuard(),
NullLogger<PackRunEvidenceSnapshotService>.Instance);
var approval = new PackRunApprovalEvidence(
"approval-001",
"approver@example.com",
DateTimeOffset.UtcNow,
"approved",
new[] { "deploy:prod" },
null,
"LGTM");
// Act
var result = await service.CaptureApprovalDecisionAsync(
TestTenantId, TestRunId, TestPlanHash, approval,
CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.Equal(PackRunEvidenceSnapshotKind.ApprovalDecision, result.Snapshot!.Kind);
Assert.Contains(result.Snapshot.Materials, m => m.Section == "approval");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CapturePolicyEvaluation_CapturesEvaluation()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
var service = new PackRunEvidenceSnapshotService(
store,
new PackRunRedactionGuard(),
NullLogger<PackRunEvidenceSnapshotService>.Instance);
var evaluation = new PackRunPolicyEvidence(
"require-approval",
"1.0.0",
"pass",
DateTimeOffset.UtcNow,
5.5,
new[] { "rule-1", "rule-2" },
"sha256:policy123");
// Act
var result = await service.CapturePolicyEvaluationAsync(
TestTenantId, TestRunId, TestPlanHash, evaluation,
CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.Equal(PackRunEvidenceSnapshotKind.PolicyEvaluation, result.Snapshot!.Kind);
Assert.Contains(result.Snapshot.Materials, m => m.Section == "policy");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CaptureRunCompletion_EmitsTimelineEvent()
{
// Arrange
var store = new InMemoryPackRunEvidenceStore();
var sink = new InMemoryPackRunTimelineEventSink();
var emitter = new PackRunTimelineEventEmitter(
sink, TimeProvider.System, NullLogger<PackRunTimelineEventEmitter>.Instance);
var service = new PackRunEvidenceSnapshotService(
store,
new PackRunRedactionGuard(),
NullLogger<PackRunEvidenceSnapshotService>.Instance,
emitter);
var state = CreateTestPackRunState();
// Act
await service.CaptureRunCompletionAsync(
TestTenantId, TestRunId, TestPlanHash, state,
cancellationToken: CancellationToken.None);
// Assert
var events = sink.GetEvents();
Assert.Single(events);
Assert.Equal("pack.evidence.captured", events[0].EventType);
}
#endregion
#region Helper Methods
private static PackRunState CreateTestPackRunState()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var planResult = planner.Plan(manifest);
var plan = planResult.Plan!;
var context = new PackRunExecutionContext(TestRunId, plan, DateTimeOffset.UtcNow);
var graphBuilder = new PackRunExecutionGraphBuilder();
var graph = graphBuilder.Build(plan);
var simulationEngine = new PackRunSimulationEngine();
var timestamp = DateTimeOffset.UtcNow;
return PackRunStateFactory.CreateInitialState(context, graph, simulationEngine, timestamp);
}
#endregion
}

View File

@@ -0,0 +1,71 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunExecutionGraphBuilderTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_GeneratesParallelMetadata()
{
var manifest = TestManifests.Load(TestManifests.Parallel);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
Assert.True(result.Success);
var plan = result.Plan!;
var builder = new PackRunExecutionGraphBuilder();
var graph = builder.Build(plan);
Assert.Equal(2, graph.FailurePolicy.MaxAttempts);
Assert.Equal(10, graph.FailurePolicy.BackoffSeconds);
var parallel = Assert.Single(graph.Steps);
Assert.Equal(PackRunStepKind.Parallel, parallel.Kind);
Assert.True(parallel.Enabled);
Assert.Equal(2, parallel.MaxParallel);
Assert.True(parallel.ContinueOnError);
Assert.Equal(2, parallel.Children.Count);
Assert.All(parallel.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_PreservesMapIterationsAndDisabledSteps()
{
var planner = new TaskPackPlanner();
var builder = new PackRunExecutionGraphBuilder();
// Map iterations
var mapManifest = TestManifests.Load(TestManifests.Map);
var inputs = new Dictionary<string, JsonNode?>
{
["targets"] = new JsonArray("alpha", "beta", "gamma")
};
var mapPlan = planner.Plan(mapManifest, inputs).Plan!;
var mapGraph = builder.Build(mapPlan);
var mapStep = Assert.Single(mapGraph.Steps);
Assert.Equal(PackRunStepKind.Map, mapStep.Kind);
Assert.Equal(3, mapStep.Children.Count);
Assert.All(mapStep.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind));
// Disabled conditional step
var conditionalManifest = TestManifests.Load(TestManifests.Sample);
var conditionalInputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(true)
};
var conditionalPlan = planner.Plan(conditionalManifest, conditionalInputs).Plan!;
var conditionalGraph = builder.Build(conditionalPlan);
var applyStep = conditionalGraph.Steps.Single(step => step.Id == "apply-step");
Assert.False(applyStep.Enabled);
}
}

View File

@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunGateStateUpdaterTests
{
private static readonly DateTimeOffset RequestedAt = DateTimeOffset.UnixEpoch;
private static readonly DateTimeOffset UpdateTimestamp = DateTimeOffset.UnixEpoch.AddMinutes(5);
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Apply_ApprovedGate_ClearsReasonAndSucceeds()
{
var plan = BuildApprovalPlan();
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var state = CreateInitialState(plan, graph);
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
coordinator.Approve("security-review", "approver-1", UpdateTimestamp);
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
Assert.False(result.HasBlockingFailure);
Assert.Equal(UpdateTimestamp, result.State.UpdatedAt);
var gate = result.State.Steps["approval"];
Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status);
Assert.Null(gate.StatusReason);
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Apply_RejectedGate_FlagsFailure()
{
var plan = BuildApprovalPlan();
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var state = CreateInitialState(plan, graph);
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
coordinator.Reject("security-review", "approver-1", UpdateTimestamp, "not-safe");
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
Assert.True(result.HasBlockingFailure);
Assert.Equal(UpdateTimestamp, result.State.UpdatedAt);
var gate = result.State.Steps["approval"];
Assert.Equal(PackRunStepExecutionStatus.Failed, gate.Status);
Assert.StartsWith("approval-rejected", gate.StatusReason, StringComparison.Ordinal);
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Apply_PolicyGate_ClearsPendingReason()
{
var plan = BuildPolicyPlan();
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var state = CreateInitialState(plan, graph);
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
Assert.False(result.HasBlockingFailure);
var gate = result.State.Steps["policy-check"];
Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status);
Assert.Null(gate.StatusReason);
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
var prepare = result.State.Steps["prepare"];
Assert.Equal(PackRunStepExecutionStatus.Pending, prepare.Status);
Assert.Null(prepare.StatusReason);
}
private static TaskPackPlan BuildApprovalPlan()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, System.Text.Json.Nodes.JsonNode?>
{
["dryRun"] = System.Text.Json.Nodes.JsonValue.Create(false)
};
return planner.Plan(manifest, inputs).Plan!;
}
private static TaskPackPlan BuildPolicyPlan()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
return planner.Plan(manifest).Plan!;
}
private static PackRunState CreateInitialState(TaskPackPlan plan, PackRunExecutionGraph graph)
{
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal);
foreach (var step in EnumerateSteps(graph.Steps))
{
var status = PackRunStepExecutionStatus.Pending;
string? reason = null;
if (!step.Enabled)
{
status = PackRunStepExecutionStatus.Skipped;
reason = "disabled";
}
else if (step.Kind == PackRunStepKind.GateApproval)
{
reason = "requires-approval";
}
else if (step.Kind == PackRunStepKind.GatePolicy)
{
reason = "requires-policy";
}
steps[step.Id] = new PackRunStepStateRecord(
step.Id,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
status,
Attempts: 0,
LastTransitionAt: null,
NextAttemptAt: null,
StatusReason: reason);
}
return PackRunState.Create("run-1", plan.Hash, plan, graph.FailurePolicy, RequestedAt, steps, RequestedAt);
}
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
{
foreach (var step in steps)
{
yield return step;
if (step.Children.Count > 0)
{
foreach (var child in EnumerateSteps(step.Children))
{
yield return child;
}
}
}
}
}

View File

@@ -0,0 +1,413 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TaskRunner.Core.IncidentMode;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunIncidentModeTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ActivateAsync_ActivatesIncidentModeSuccessfully()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
var request = new IncidentModeActivationRequest(
RunId: "run-001",
TenantId: "tenant-1",
Level: IncidentEscalationLevel.Medium,
Source: IncidentModeSource.Manual,
Reason: "Debugging production issue",
DurationMinutes: 60,
RequestedBy: "admin@example.com");
var result = await service.ActivateAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.True(result.Status.Active);
Assert.Equal(IncidentEscalationLevel.Medium, result.Status.Level);
Assert.Equal(IncidentModeSource.Manual, result.Status.Source);
Assert.NotNull(result.Status.ActivatedAt);
Assert.NotNull(result.Status.ExpiresAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ActivateAsync_WithoutDuration_CreatesIndefiniteIncidentMode()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
var request = new IncidentModeActivationRequest(
RunId: "run-002",
TenantId: "tenant-1",
Level: IncidentEscalationLevel.High,
Source: IncidentModeSource.Manual,
Reason: "Critical investigation",
DurationMinutes: null,
RequestedBy: null);
var result = await service.ActivateAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.Null(result.Status.ExpiresAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ActivateAsync_EmitsTimelineEvent()
{
var store = new InMemoryPackRunIncidentModeStore();
var timelineSink = new InMemoryPackRunTimelineEventSink();
var emitter = new PackRunTimelineEventEmitter(
timelineSink,
TimeProvider.System,
NullLogger<PackRunTimelineEventEmitter>.Instance);
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance,
null,
emitter);
var request = new IncidentModeActivationRequest(
RunId: "run-003",
TenantId: "tenant-1",
Level: IncidentEscalationLevel.Low,
Source: IncidentModeSource.Manual,
Reason: "Test",
DurationMinutes: 30,
RequestedBy: null);
await service.ActivateAsync(request, CancellationToken.None);
Assert.Single(timelineSink.GetEvents());
var evt = timelineSink.GetEvents()[0];
Assert.Equal(PackRunIncidentEventTypes.IncidentModeActivated, evt.EventType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeactivateAsync_DeactivatesIncidentMode()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
// First activate
var activateRequest = new IncidentModeActivationRequest(
RunId: "run-004",
TenantId: "tenant-1",
Level: IncidentEscalationLevel.Medium,
Source: IncidentModeSource.Manual,
Reason: "Test",
DurationMinutes: null,
RequestedBy: null);
await service.ActivateAsync(activateRequest, CancellationToken.None);
// Then deactivate
var result = await service.DeactivateAsync("run-004", "Issue resolved", CancellationToken.None);
Assert.True(result.Success);
Assert.False(result.Status.Active);
var status = await service.GetStatusAsync("run-004", CancellationToken.None);
Assert.False(status.Active);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetStatusAsync_ReturnsInactiveForUnknownRun()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
var status = await service.GetStatusAsync("unknown-run", CancellationToken.None);
Assert.False(status.Active);
Assert.Equal(IncidentEscalationLevel.None, status.Level);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetStatusAsync_AutoDeactivatesExpiredIncidentMode()
{
var store = new InMemoryPackRunIncidentModeStore();
var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance,
fakeTime);
var request = new IncidentModeActivationRequest(
RunId: "run-005",
TenantId: "tenant-1",
Level: IncidentEscalationLevel.Medium,
Source: IncidentModeSource.Manual,
Reason: "Test",
DurationMinutes: 30,
RequestedBy: null);
await service.ActivateAsync(request, CancellationToken.None);
// Advance time past expiration
fakeTime.Advance(TimeSpan.FromMinutes(31));
var status = await service.GetStatusAsync("run-005", CancellationToken.None);
Assert.False(status.Active);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleSloBreachAsync_ActivatesIncidentModeFromBreach()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
var breach = new SloBreachNotification(
BreachId: "breach-001",
SloName: "error_rate_5m",
Severity: "HIGH",
OccurredAt: DateTimeOffset.UtcNow,
CurrentValue: 15.5,
Threshold: 5.0,
Target: 1.0,
ResourceId: "run-006",
TenantId: "tenant-1",
Context: new Dictionary<string, string> { ["step"] = "scan" });
var result = await service.HandleSloBreachAsync(breach, CancellationToken.None);
Assert.True(result.Success);
Assert.True(result.Status.Active);
Assert.Equal(IncidentEscalationLevel.High, result.Status.Level);
Assert.Equal(IncidentModeSource.SloBreach, result.Status.Source);
Assert.Contains("error_rate_5m", result.Status.ActivationReason!);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleSloBreachAsync_MapsSeverityToLevel()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
var severityToLevel = new Dictionary<string, IncidentEscalationLevel>
{
["CRITICAL"] = IncidentEscalationLevel.Critical,
["HIGH"] = IncidentEscalationLevel.High,
["MEDIUM"] = IncidentEscalationLevel.Medium,
["LOW"] = IncidentEscalationLevel.Low
};
var runIndex = 0;
foreach (var (severity, expectedLevel) in severityToLevel)
{
var breach = new SloBreachNotification(
BreachId: $"breach-{runIndex}",
SloName: "test_slo",
Severity: severity,
OccurredAt: DateTimeOffset.UtcNow,
CurrentValue: 10.0,
Threshold: 5.0,
Target: 1.0,
ResourceId: $"run-severity-{runIndex++}",
TenantId: "tenant-1",
Context: null);
var result = await service.HandleSloBreachAsync(breach, CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(expectedLevel, result.Status.Level);
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleSloBreachAsync_ReturnsErrorForMissingResourceId()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
var breach = new SloBreachNotification(
BreachId: "breach-no-resource",
SloName: "test_slo",
Severity: "HIGH",
OccurredAt: DateTimeOffset.UtcNow,
CurrentValue: 10.0,
Threshold: 5.0,
Target: 1.0,
ResourceId: null,
TenantId: "tenant-1",
Context: null);
var result = await service.HandleSloBreachAsync(breach, CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("No resource ID", result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EscalateAsync_IncreasesEscalationLevel()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
// First activate at Low level
var activateRequest = new IncidentModeActivationRequest(
RunId: "run-escalate",
TenantId: "tenant-1",
Level: IncidentEscalationLevel.Low,
Source: IncidentModeSource.Manual,
Reason: "Initial activation",
DurationMinutes: null,
RequestedBy: null);
await service.ActivateAsync(activateRequest, CancellationToken.None);
// Escalate to High
var result = await service.EscalateAsync(
"run-escalate",
IncidentEscalationLevel.High,
"Issue is more severe than expected",
CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(IncidentEscalationLevel.High, result.Status.Level);
Assert.Contains("Escalated", result.Status.ActivationReason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EscalateAsync_FailsWhenNotInIncidentMode()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
var result = await service.EscalateAsync(
"unknown-run",
IncidentEscalationLevel.High,
null,
CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("not active", result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EscalateAsync_FailsWhenNewLevelIsLowerOrEqual()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
var activateRequest = new IncidentModeActivationRequest(
RunId: "run-no-deescalate",
TenantId: "tenant-1",
Level: IncidentEscalationLevel.High,
Source: IncidentModeSource.Manual,
Reason: "Test",
DurationMinutes: null,
RequestedBy: null);
await service.ActivateAsync(activateRequest, CancellationToken.None);
var result = await service.EscalateAsync(
"run-no-deescalate",
IncidentEscalationLevel.Medium, // Lower than High
null,
CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("Cannot escalate", result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GetSettingsForLevel_ReturnsCorrectSettings()
{
var store = new InMemoryPackRunIncidentModeStore();
var service = new PackRunIncidentModeService(
store,
NullLogger<PackRunIncidentModeService>.Instance);
// Test None level
var noneSettings = service.GetSettingsForLevel(IncidentEscalationLevel.None);
Assert.False(noneSettings.TelemetrySettings.EnhancedTelemetryActive);
Assert.False(noneSettings.DebugCaptureSettings.CaptureActive);
// Test Critical level
var criticalSettings = service.GetSettingsForLevel(IncidentEscalationLevel.Critical);
Assert.True(criticalSettings.TelemetrySettings.EnhancedTelemetryActive);
Assert.Equal(IncidentLogVerbosity.Debug, criticalSettings.TelemetrySettings.LogVerbosity);
Assert.Equal(1.0, criticalSettings.TelemetrySettings.TraceSamplingRate);
Assert.True(criticalSettings.DebugCaptureSettings.CaptureActive);
Assert.True(criticalSettings.DebugCaptureSettings.CaptureHeapDumps);
Assert.Equal(365, criticalSettings.RetentionPolicy.LogRetentionDays);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PackRunIncidentModeStatus_Inactive_ReturnsDefaultValues()
{
var inactive = PackRunIncidentModeStatus.Inactive();
Assert.False(inactive.Active);
Assert.Equal(IncidentEscalationLevel.None, inactive.Level);
Assert.Null(inactive.ActivatedAt);
Assert.Null(inactive.ActivationReason);
Assert.Equal(IncidentModeSource.None, inactive.Source);
Assert.False(inactive.RetentionPolicy.ExtendedRetentionActive);
Assert.False(inactive.TelemetrySettings.EnhancedTelemetryActive);
Assert.False(inactive.DebugCaptureSettings.CaptureActive);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IncidentRetentionPolicy_Extended_HasLongerRetention()
{
var defaultPolicy = IncidentRetentionPolicy.Default();
var extendedPolicy = IncidentRetentionPolicy.Extended();
Assert.True(extendedPolicy.ExtendedRetentionActive);
Assert.True(extendedPolicy.LogRetentionDays > defaultPolicy.LogRetentionDays);
Assert.True(extendedPolicy.ArtifactRetentionDays > defaultPolicy.ArtifactRetentionDays);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IncidentTelemetrySettings_Enhanced_HasHigherSampling()
{
var defaultSettings = IncidentTelemetrySettings.Default();
var enhancedSettings = IncidentTelemetrySettings.Enhanced();
Assert.True(enhancedSettings.EnhancedTelemetryActive);
Assert.True(enhancedSettings.TraceSamplingRate > defaultSettings.TraceSamplingRate);
Assert.True(enhancedSettings.CaptureEnvironment);
Assert.True(enhancedSettings.CaptureStepIo);
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Text.Json.Nodes;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunProcessorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessNewRunAsync_PersistsApprovalsAndPublishesNotifications()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest, new Dictionary<string, JsonNode?> { ["dryRun"] = JsonValue.Create(false) }).Plan!;
var context = new PackRunExecutionContext("run-123", plan, DateTimeOffset.UtcNow);
var store = new TestApprovalStore();
var publisher = new TestNotificationPublisher();
var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance);
var result = await processor.ProcessNewRunAsync(context, CancellationToken.None);
Assert.False(result.ShouldResumeImmediately);
var saved = Assert.Single(store.Saved);
Assert.Equal("security-review", saved.ApprovalId);
Assert.Single(publisher.Approvals);
Assert.Empty(publisher.Policies);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessNewRunAsync_NoApprovals_ResumesImmediately()
{
var manifest = TestManifests.Load(TestManifests.Output);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var context = new PackRunExecutionContext("run-456", plan, DateTimeOffset.UtcNow);
var store = new TestApprovalStore();
var publisher = new TestNotificationPublisher();
var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance);
var result = await processor.ProcessNewRunAsync(context, CancellationToken.None);
Assert.True(result.ShouldResumeImmediately);
Assert.Empty(store.Saved);
Assert.Empty(publisher.Approvals);
}
private sealed class TestApprovalStore : IPackRunApprovalStore
{
public List<PackRunApprovalState> Saved { get; } = new();
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
=> Task.FromResult((IReadOnlyList<PackRunApprovalState>)Saved);
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
Saved.Clear();
Saved.AddRange(approvals);
return Task.CompletedTask;
}
public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
private sealed class TestNotificationPublisher : IPackRunNotificationPublisher
{
public List<ApprovalNotification> Approvals { get; } = new();
public List<PolicyGateNotification> Policies { get; } = new();
public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
Approvals.Add(notification);
return Task.CompletedTask;
}
public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
Policies.Add(notification);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,73 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.TaskPacks;
using StellaOps.TaskRunner.Infrastructure.Execution;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunProvenanceWriterTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Filesystem_writer_emits_manifest()
{
var (context, state) = CreateRunState();
var completedAt = new DateTimeOffset(2025, 11, 30, 12, 30, 0, TimeSpan.Zero);
var temp = Directory.CreateTempSubdirectory();
try
{
var ct = CancellationToken.None;
var writer = new FilesystemPackRunProvenanceWriter(temp.FullName, new FixedTimeProvider(completedAt));
await writer.WriteAsync(context, state, ct);
var path = Path.Combine(temp.FullName, "provenance", "run-test.json");
Assert.True(File.Exists(path));
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(path, ct));
var root = document.RootElement;
Assert.Equal("run-test", root.GetProperty("runId").GetString());
Assert.Equal("tenant-alpha", root.GetProperty("tenantId").GetString());
Assert.Equal(context.Plan.Hash, root.GetProperty("planHash").GetString());
Assert.Equal(completedAt, root.GetProperty("completedAt").GetDateTimeOffset());
}
finally
{
temp.Delete(recursive: true);
}
}
private static (PackRunExecutionContext Context, PackRunState State) CreateRunState()
{
var loader = new TaskPackManifestLoader();
var planner = new TaskPackPlanner();
var manifest = loader.Deserialize(TestManifests.Sample);
var plan = planner.Plan(manifest, new Dictionary<string, JsonNode?>()).Plan ?? throw new InvalidOperationException("Plan generation failed.");
var graphBuilder = new PackRunExecutionGraphBuilder();
var simulationEngine = new PackRunSimulationEngine();
var graph = graphBuilder.Build(plan);
var requestedAt = new DateTimeOffset(2025, 11, 30, 10, 0, 0, TimeSpan.Zero);
var context = new PackRunExecutionContext("run-test", plan, requestedAt, "tenant-alpha");
var state = PackRunStateFactory.CreateInitialState(context, graph, simulationEngine, requestedAt);
return (context, state);
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset now;
public FixedTimeProvider(DateTimeOffset now)
{
this.now = now;
}
public override DateTimeOffset GetUtcNow() => now;
}
}

View File

@@ -0,0 +1,149 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunSimulationEngineTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Simulate_IdentifiesGateStatuses()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var gate = result.Steps.Single(step => step.Kind == PackRunStepKind.GatePolicy);
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, gate.Status);
var run = result.Steps.Single(step => step.Kind == PackRunStepKind.Run);
Assert.Equal(PackRunSimulationStatus.Pending, run.Status);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Simulate_MarksDisabledStepsAndOutputs()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(true)
};
var plan = planner.Plan(manifest, inputs).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var applyStep = result.Steps.Single(step => step.Id == "apply-step");
Assert.Equal(PackRunSimulationStatus.Skipped, applyStep.Status);
Assert.Empty(result.Outputs);
Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.MaxAttempts, result.FailurePolicy.MaxAttempts);
Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.BackoffSeconds, result.FailurePolicy.BackoffSeconds);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Simulate_ProjectsOutputsAndRuntimeFlags()
{
var manifest = TestManifests.Load(TestManifests.Output);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var step = Assert.Single(result.Steps);
Assert.Equal(PackRunStepKind.Run, step.Kind);
Assert.Collection(result.Outputs,
bundle =>
{
Assert.Equal("bundlePath", bundle.Name);
Assert.False(bundle.RequiresRuntimeValue);
},
evidence =>
{
Assert.Equal("evidenceModel", evidence.Name);
Assert.True(evidence.RequiresRuntimeValue);
});
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Simulate_LoopStep_SetsWillIterateStatus()
{
var manifest = TestManifests.Load(TestManifests.Loop);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["targets"] = new JsonArray { "a", "b", "c" }
};
var result = planner.Plan(manifest, inputs);
Assert.Empty(result.Errors);
Assert.NotNull(result.Plan);
var engine = new PackRunSimulationEngine();
var simResult = engine.Simulate(result.Plan);
var loopStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Loop);
Assert.Equal(PackRunSimulationStatus.WillIterate, loopStep.Status);
Assert.Equal("process-loop", loopStep.Id);
Assert.NotNull(loopStep.LoopInfo);
Assert.Equal("target", loopStep.LoopInfo.Iterator);
Assert.Equal("idx", loopStep.LoopInfo.Index);
Assert.Equal(100, loopStep.LoopInfo.MaxIterations);
Assert.Equal("collect", loopStep.LoopInfo.AggregationMode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Simulate_ConditionalStep_SetsWillBranchStatus()
{
var manifest = TestManifests.Load(TestManifests.Conditional);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["environment"] = JsonValue.Create("production")
};
var result = planner.Plan(manifest, inputs);
Assert.Empty(result.Errors);
Assert.NotNull(result.Plan);
var engine = new PackRunSimulationEngine();
var simResult = engine.Simulate(result.Plan);
var conditionalStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Conditional);
Assert.Equal(PackRunSimulationStatus.WillBranch, conditionalStep.Status);
Assert.Equal("env-branch", conditionalStep.Id);
Assert.NotNull(conditionalStep.ConditionalInfo);
Assert.Equal(2, conditionalStep.ConditionalInfo.Branches.Count);
Assert.True(conditionalStep.ConditionalInfo.OutputUnion);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Simulate_PolicyGateStep_HasPolicyInfo()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var policyStep = result.Steps.Single(s => s.Kind == PackRunStepKind.GatePolicy);
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, policyStep.Status);
Assert.NotNull(policyStep.PolicyInfo);
Assert.Equal("security-hold", policyStep.PolicyInfo.PolicyId);
Assert.Equal("abort", policyStep.PolicyInfo.FailureAction);
}
}

View File

@@ -0,0 +1,42 @@
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.TaskPacks;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunStateFactoryTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateInitialState_AssignsGateReasons()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var planResult = planner.Plan(manifest);
Assert.True(planResult.Success);
Assert.NotNull(planResult.Plan);
var plan = planResult.Plan!;
var context = new PackRunExecutionContext("run-state-factory", plan, DateTimeOffset.UtcNow);
var graphBuilder = new PackRunExecutionGraphBuilder();
var graph = graphBuilder.Build(plan);
var simulationEngine = new PackRunSimulationEngine();
var timestamp = DateTimeOffset.UtcNow;
var state = PackRunStateFactory.CreateInitialState(context, graph, simulationEngine, timestamp);
Assert.Equal("run-state-factory", state.RunId);
Assert.Equal(plan.Hash, state.PlanHash);
Assert.True(state.Steps.TryGetValue("approval", out var approvalStep));
Assert.Equal(PackRunStepExecutionStatus.Pending, approvalStep.Status);
Assert.Equal("requires-approval", approvalStep.StatusReason);
Assert.True(state.Steps.TryGetValue("plan-step", out var planStep));
Assert.Equal(PackRunStepExecutionStatus.Pending, planStep.Status);
Assert.Null(planStep.StatusReason);
}
}

View File

@@ -0,0 +1,71 @@
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunStepStateMachineTests
{
private static readonly TaskPackPlanFailurePolicy RetryTwicePolicy = new(MaxAttempts: 3, BackoffSeconds: 5, ContinueOnError: false);
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Start_FromPending_SetsRunning()
{
var state = PackRunStepStateMachine.Create();
var started = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
Assert.Equal(PackRunStepExecutionStatus.Running, started.Status);
Assert.Equal(0, started.Attempts);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CompleteSuccess_IncrementsAttempts()
{
var state = PackRunStepStateMachine.Create();
var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
var completed = PackRunStepStateMachine.CompleteSuccess(running, DateTimeOffset.UnixEpoch.AddSeconds(1));
Assert.Equal(PackRunStepExecutionStatus.Succeeded, completed.Status);
Assert.Equal(1, completed.Attempts);
Assert.Null(completed.NextAttemptAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RegisterFailure_SchedulesRetryUntilMaxAttempts()
{
var state = PackRunStepStateMachine.Create();
var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
var firstFailure = PackRunStepStateMachine.RegisterFailure(running, DateTimeOffset.UnixEpoch.AddSeconds(2), RetryTwicePolicy);
Assert.Equal(PackRunStepFailureOutcome.Retry, firstFailure.Outcome);
Assert.Equal(PackRunStepExecutionStatus.Pending, firstFailure.State.Status);
Assert.Equal(1, firstFailure.State.Attempts);
Assert.Equal(DateTimeOffset.UnixEpoch.AddSeconds(7), firstFailure.State.NextAttemptAt);
var restarted = PackRunStepStateMachine.Start(firstFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(7));
var secondFailure = PackRunStepStateMachine.RegisterFailure(restarted, DateTimeOffset.UnixEpoch.AddSeconds(9), RetryTwicePolicy);
Assert.Equal(PackRunStepFailureOutcome.Retry, secondFailure.Outcome);
Assert.Equal(2, secondFailure.State.Attempts);
var finalStart = PackRunStepStateMachine.Start(secondFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(9 + RetryTwicePolicy.BackoffSeconds));
var terminalFailure = PackRunStepStateMachine.RegisterFailure(finalStart, DateTimeOffset.UnixEpoch.AddSeconds(20), RetryTwicePolicy);
Assert.Equal(PackRunStepFailureOutcome.Abort, terminalFailure.Outcome);
Assert.Equal(PackRunStepExecutionStatus.Failed, terminalFailure.State.Status);
Assert.Equal(3, terminalFailure.State.Attempts);
Assert.Null(terminalFailure.State.NextAttemptAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Skip_FromPending_SetsSkipped()
{
var state = PackRunStepStateMachine.Create();
var skipped = PackRunStepStateMachine.Skip(state, DateTimeOffset.UnixEpoch.AddHours(1));
Assert.Equal(PackRunStepExecutionStatus.Skipped, skipped.Status);
Assert.Equal(0, skipped.Attempts);
}
}

View File

@@ -0,0 +1,746 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Events;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
/// <summary>
/// Tests for pack run timeline event domain model, emitter, and sink.
/// Per TASKRUN-OBS-52-001.
/// </summary>
public sealed class PackRunTimelineEventTests
{
private const string TestTenantId = "test-tenant";
private const string TestRunId = "run-12345";
private const string TestPlanHash = "sha256:abc123";
private const string TestStepId = "step-001";
private const string TestProjectId = "project-xyz";
#region Domain Model Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_WithRequiredFields_GeneratesValidEvent()
{
// Arrange
var occurredAt = DateTimeOffset.UtcNow;
// Act
var evt = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackStarted,
source: "taskrunner-worker",
occurredAt: occurredAt,
runId: TestRunId,
planHash: TestPlanHash);
// Assert
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.Equal(TestTenantId, evt.TenantId);
Assert.Equal(PackRunEventTypes.PackStarted, evt.EventType);
Assert.Equal("taskrunner-worker", evt.Source);
Assert.Equal(occurredAt, evt.OccurredAt);
Assert.Equal(TestRunId, evt.RunId);
Assert.Equal(TestPlanHash, evt.PlanHash);
Assert.Null(evt.ReceivedAt);
Assert.Null(evt.EventSeq);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_WithPayload_ComputesHashAndNormalizes()
{
// Arrange
var payload = new { stepId = "step-001", attempt = 1 };
// Act
var evt = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.StepStarted,
source: "taskrunner-worker",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash,
payload: payload);
// Assert
Assert.NotNull(evt.RawPayloadJson);
Assert.NotNull(evt.NormalizedPayloadJson);
Assert.NotNull(evt.PayloadHash);
Assert.StartsWith("sha256:", evt.PayloadHash);
Assert.Equal(64 + 7, evt.PayloadHash.Length); // sha256: prefix + 64 hex chars
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_WithStepId_SetsStepId()
{
// Act
var evt = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.StepCompleted,
source: "taskrunner-worker",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash,
stepId: TestStepId);
// Assert
Assert.Equal(TestStepId, evt.StepId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Create_WithEvidencePointer_SetsPointer()
{
// Arrange
var evidence = PackRunEvidencePointer.Bundle(Guid.NewGuid(), "sha256:def456");
// Act
var evt = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackCompleted,
source: "taskrunner-worker",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash,
evidencePointer: evidence);
// Assert
Assert.NotNull(evt.EvidencePointer);
Assert.Equal(PackRunEvidencePointerType.Bundle, evt.EvidencePointer.Type);
Assert.Equal("sha256:def456", evt.EvidencePointer.BundleDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void WithReceivedAt_CreatesCopyWithTimestamp()
{
// Arrange
var evt = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackStarted,
source: "taskrunner-worker",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash);
var receivedAt = DateTimeOffset.UtcNow.AddSeconds(1);
// Act
var updated = evt.WithReceivedAt(receivedAt);
// Assert
Assert.Null(evt.ReceivedAt);
Assert.Equal(receivedAt, updated.ReceivedAt);
Assert.Equal(evt.EventId, updated.EventId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void WithSequence_CreatesCopyWithSequence()
{
// Arrange
var evt = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackStarted,
source: "taskrunner-worker",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash);
// Act
var updated = evt.WithSequence(42);
// Assert
Assert.Null(evt.EventSeq);
Assert.Equal(42, updated.EventSeq);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ToJson_SerializesEvent()
{
// Arrange
var evt = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.StepCompleted,
source: "taskrunner-worker",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash,
stepId: TestStepId);
// Act
var json = evt.ToJson();
// Assert
Assert.Contains("\"tenantId\"", json);
Assert.Contains("\"eventType\"", json);
Assert.Contains("pack.step.completed", json);
Assert.Contains(TestStepId, json);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FromJson_DeserializesEvent()
{
// Arrange
var original = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.StepCompleted,
source: "taskrunner-worker",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash,
stepId: TestStepId);
var json = original.ToJson();
// Act
var deserialized = PackRunTimelineEvent.FromJson(json);
// Assert
Assert.NotNull(deserialized);
Assert.Equal(original.EventId, deserialized.EventId);
Assert.Equal(original.TenantId, deserialized.TenantId);
Assert.Equal(original.EventType, deserialized.EventType);
Assert.Equal(original.RunId, deserialized.RunId);
Assert.Equal(original.StepId, deserialized.StepId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GenerateIdempotencyKey_ReturnsConsistentKey()
{
// Arrange
var evt = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackStarted,
source: "taskrunner-worker",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash);
// Act
var key1 = evt.GenerateIdempotencyKey();
var key2 = evt.GenerateIdempotencyKey();
// Assert
Assert.Equal(key1, key2);
Assert.Contains(TestTenantId, key1);
Assert.Contains(PackRunEventTypes.PackStarted, key1);
}
#endregion
#region Event Types Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PackRunEventTypes_HasExpectedValues()
{
Assert.Equal("pack.started", PackRunEventTypes.PackStarted);
Assert.Equal("pack.completed", PackRunEventTypes.PackCompleted);
Assert.Equal("pack.failed", PackRunEventTypes.PackFailed);
Assert.Equal("pack.step.started", PackRunEventTypes.StepStarted);
Assert.Equal("pack.step.completed", PackRunEventTypes.StepCompleted);
Assert.Equal("pack.step.failed", PackRunEventTypes.StepFailed);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("pack.started", true)]
[InlineData("pack.step.completed", true)]
[InlineData("scan.completed", false)]
[InlineData("job.started", false)]
public void IsPackRunEvent_ReturnsCorrectly(string eventType, bool expected)
{
Assert.Equal(expected, PackRunEventTypes.IsPackRunEvent(eventType));
}
#endregion
#region Evidence Pointer Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EvidencePointer_Bundle_CreatesCorrectType()
{
var bundleId = Guid.NewGuid();
var pointer = PackRunEvidencePointer.Bundle(bundleId, "sha256:abc");
Assert.Equal(PackRunEvidencePointerType.Bundle, pointer.Type);
Assert.Equal(bundleId, pointer.BundleId);
Assert.Equal("sha256:abc", pointer.BundleDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EvidencePointer_Attestation_CreatesCorrectType()
{
var pointer = PackRunEvidencePointer.Attestation("subject:uri", "sha256:abc");
Assert.Equal(PackRunEvidencePointerType.Attestation, pointer.Type);
Assert.Equal("subject:uri", pointer.AttestationSubject);
Assert.Equal("sha256:abc", pointer.AttestationDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EvidencePointer_Manifest_CreatesCorrectType()
{
var pointer = PackRunEvidencePointer.Manifest("https://example.com/manifest", "/locker/path");
Assert.Equal(PackRunEvidencePointerType.Manifest, pointer.Type);
Assert.Equal("https://example.com/manifest", pointer.ManifestUri);
Assert.Equal("/locker/path", pointer.LockerPath);
}
#endregion
#region In-Memory Sink Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InMemorySink_WriteAsync_StoresEvent()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var evt = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackStarted,
source: "taskrunner-worker",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash);
// Act
var result = await sink.WriteAsync(evt, CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.NotNull(result.Sequence);
Assert.False(result.Deduplicated);
Assert.Single(sink.GetEvents());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InMemorySink_WriteAsync_Deduplicates()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var evt = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackStarted,
source: "taskrunner-worker",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash);
var ct = CancellationToken.None;
// Act
await sink.WriteAsync(evt, ct);
var result = await sink.WriteAsync(evt, ct);
// Assert
Assert.True(result.Success);
Assert.True(result.Deduplicated);
Assert.Single(sink.GetEvents());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InMemorySink_AssignsMonotonicSequence()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var ct = CancellationToken.None;
// Act
var evt1 = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackStarted,
source: "test",
occurredAt: DateTimeOffset.UtcNow,
runId: "run-1",
planHash: TestPlanHash);
var evt2 = PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.StepStarted,
source: "test",
occurredAt: DateTimeOffset.UtcNow,
runId: "run-1",
planHash: TestPlanHash);
var result1 = await sink.WriteAsync(evt1, ct);
var result2 = await sink.WriteAsync(evt2, ct);
// Assert
Assert.Equal(1, result1.Sequence);
Assert.Equal(2, result2.Sequence);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InMemorySink_WriteBatchAsync_StoresMultiple()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var events = Enumerable.Range(0, 3).Select(i =>
PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.StepStarted,
source: "test",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash,
stepId: $"step-{i}")).ToList();
// Act
var result = await sink.WriteBatchAsync(events, CancellationToken.None);
// Assert
Assert.Equal(3, result.Written);
Assert.Equal(0, result.Deduplicated);
Assert.Equal(3, sink.Count);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InMemorySink_GetEventsForRun_FiltersCorrectly()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var ct = CancellationToken.None;
await sink.WriteAsync(PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackStarted,
source: "test",
occurredAt: DateTimeOffset.UtcNow,
runId: "run-1",
planHash: TestPlanHash), ct);
await sink.WriteAsync(PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackStarted,
source: "test",
occurredAt: DateTimeOffset.UtcNow,
runId: "run-2",
planHash: TestPlanHash), ct);
// Act
var run1Events = sink.GetEventsForRun("run-1");
var run2Events = sink.GetEventsForRun("run-2");
// Assert
Assert.Single(run1Events);
Assert.Single(run2Events);
Assert.Equal("run-1", run1Events[0].RunId);
Assert.Equal("run-2", run2Events[0].RunId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InMemorySink_Clear_RemovesAll()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
await sink.WriteAsync(PackRunTimelineEvent.Create(
tenantId: TestTenantId,
eventType: PackRunEventTypes.PackStarted,
source: "test",
occurredAt: DateTimeOffset.UtcNow,
runId: TestRunId,
planHash: TestPlanHash), CancellationToken.None);
// Act
sink.Clear();
// Assert
Assert.Empty(sink.GetEvents());
}
#endregion
#region Emitter Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Emitter_EmitPackStartedAsync_CreatesEvent()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var emitter = new PackRunTimelineEventEmitter(
sink,
timeProvider,
NullLogger<PackRunTimelineEventEmitter>.Instance);
// Act
var result = await emitter.EmitPackStartedAsync(
TestTenantId,
TestRunId,
TestPlanHash,
projectId: TestProjectId,
cancellationToken: CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.False(result.Deduplicated);
Assert.Equal(PackRunEventTypes.PackStarted, result.Event.EventType);
Assert.Equal(TestRunId, result.Event.RunId);
Assert.Single(sink.GetEvents());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Emitter_EmitPackCompletedAsync_CreatesEvent()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var emitter = new PackRunTimelineEventEmitter(
sink,
timeProvider,
NullLogger<PackRunTimelineEventEmitter>.Instance);
// Act
var result = await emitter.EmitPackCompletedAsync(
TestTenantId,
TestRunId,
TestPlanHash,
cancellationToken: CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.Equal(PackRunEventTypes.PackCompleted, result.Event.EventType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Emitter_EmitPackFailedAsync_CreatesEventWithError()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var emitter = new PackRunTimelineEventEmitter(
sink,
timeProvider,
NullLogger<PackRunTimelineEventEmitter>.Instance);
// Act
var result = await emitter.EmitPackFailedAsync(
TestTenantId,
TestRunId,
TestPlanHash,
failureReason: "Step step-001 failed",
cancellationToken: CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.Equal(PackRunEventTypes.PackFailed, result.Event.EventType);
Assert.Equal(PackRunEventSeverity.Error, result.Event.Severity);
Assert.Contains("failureReason", result.Event.Attributes!.Keys);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Emitter_EmitStepStartedAsync_IncludesAttempt()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var emitter = new PackRunTimelineEventEmitter(
sink,
timeProvider,
NullLogger<PackRunTimelineEventEmitter>.Instance);
// Act
var result = await emitter.EmitStepStartedAsync(
TestTenantId,
TestRunId,
TestPlanHash,
TestStepId,
attempt: 2,
cancellationToken: CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.Equal(PackRunEventTypes.StepStarted, result.Event.EventType);
Assert.Equal(TestStepId, result.Event.StepId);
Assert.Equal("2", result.Event.Attributes!["attempt"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Emitter_EmitStepCompletedAsync_IncludesDuration()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var emitter = new PackRunTimelineEventEmitter(
sink,
timeProvider,
NullLogger<PackRunTimelineEventEmitter>.Instance);
// Act
var result = await emitter.EmitStepCompletedAsync(
TestTenantId,
TestRunId,
TestPlanHash,
TestStepId,
attempt: 1,
durationMs: 123.45,
cancellationToken: CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.Equal(PackRunEventTypes.StepCompleted, result.Event.EventType);
Assert.Contains("durationMs", result.Event.Attributes!.Keys);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Emitter_EmitStepFailedAsync_IncludesError()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var emitter = new PackRunTimelineEventEmitter(
sink,
timeProvider,
NullLogger<PackRunTimelineEventEmitter>.Instance);
// Act
var result = await emitter.EmitStepFailedAsync(
TestTenantId,
TestRunId,
TestPlanHash,
TestStepId,
attempt: 3,
error: "Connection timeout",
cancellationToken: CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.Equal(PackRunEventTypes.StepFailed, result.Event.EventType);
Assert.Equal(PackRunEventSeverity.Error, result.Event.Severity);
Assert.Equal("Connection timeout", result.Event.Attributes!["error"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Emitter_EmitBatchAsync_OrdersEventsDeterministically()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var emitter = new PackRunTimelineEventEmitter(
sink,
timeProvider,
NullLogger<PackRunTimelineEventEmitter>.Instance);
var now = DateTimeOffset.UtcNow;
var events = new[]
{
PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.StepStarted, "test", now.AddSeconds(2), TestRunId, TestPlanHash),
PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.PackStarted, "test", now, TestRunId, TestPlanHash),
PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.StepCompleted, "test", now.AddSeconds(1), TestRunId, TestPlanHash),
};
// Act
var result = await emitter.EmitBatchAsync(events, CancellationToken.None);
// Assert
Assert.Equal(3, result.Emitted);
Assert.Equal(0, result.Deduplicated);
var stored = sink.GetEvents();
Assert.Equal(PackRunEventTypes.PackStarted, stored[0].EventType);
Assert.Equal(PackRunEventTypes.StepCompleted, stored[1].EventType);
Assert.Equal(PackRunEventTypes.StepStarted, stored[2].EventType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Emitter_EmitBatchAsync_HandlesDuplicates()
{
// Arrange
var sink = new InMemoryPackRunTimelineEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var emitter = new PackRunTimelineEventEmitter(
sink,
timeProvider,
NullLogger<PackRunTimelineEventEmitter>.Instance);
var ct = CancellationToken.None;
var evt = PackRunTimelineEvent.Create(
TestTenantId,
PackRunEventTypes.PackStarted,
"test",
DateTimeOffset.UtcNow,
TestRunId,
TestPlanHash);
// Emit once directly
await sink.WriteAsync(evt, ct);
// Act - emit batch with same event
var result = await emitter.EmitBatchAsync([evt], ct);
// Assert
Assert.Equal(0, result.Emitted);
Assert.Equal(1, result.Deduplicated);
Assert.Single(sink.GetEvents()); // Only one event stored
}
#endregion
#region Null Sink Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NullSink_WriteAsync_ReturnsSuccess()
{
// Arrange
var sink = NullPackRunTimelineEventSink.Instance;
var evt = PackRunTimelineEvent.Create(
TestTenantId,
PackRunEventTypes.PackStarted,
"test",
DateTimeOffset.UtcNow,
TestRunId,
TestPlanHash);
// Act
var result = await sink.WriteAsync(evt, CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.False(result.Deduplicated);
Assert.Null(result.Sequence);
}
#endregion
}
/// <summary>
/// Fake time provider for testing.
/// </summary>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public FakeTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
}

View File

@@ -0,0 +1,405 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.AirGap;
using StellaOps.TaskRunner.Core.TaskPacks;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class SealedInstallEnforcerTests
{
private static TaskPackManifest CreateManifest(bool sealedInstall, SealedRequirements? requirements = null)
{
return new TaskPackManifest
{
ApiVersion = "taskrunner/v1",
Kind = "TaskPack",
Metadata = new TaskPackMetadata
{
Name = "test-pack",
Version = "1.0.0"
},
Spec = new TaskPackSpec
{
SealedInstall = sealedInstall,
SealedRequirements = requirements
}
};
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceAsync_WhenPackDoesNotRequireSealedInstall_ReturnsAllowed()
{
var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed());
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var manifest = CreateManifest(sealedInstall: false);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None);
Assert.True(result.Allowed);
Assert.Equal("Pack does not require sealed install", result.Message);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceAsync_WhenEnforcementDisabled_ReturnsAllowed()
{
var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed());
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = false });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var manifest = CreateManifest(sealedInstall: true);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None);
Assert.True(result.Allowed);
Assert.Equal("Enforcement disabled", result.Message);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceAsync_WhenSealedRequiredButEnvironmentNotSealed_ReturnsDenied()
{
var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed());
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var manifest = CreateManifest(sealedInstall: true);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode);
Assert.NotNull(result.Violation);
Assert.True(result.Violation.RequiredSealed);
Assert.False(result.Violation.ActualSealed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceAsync_WhenSealedRequiredAndEnvironmentSealed_ReturnsAllowed()
{
var status = new SealedModeStatus(
Sealed: true,
Mode: "sealed",
SealedAt: DateTimeOffset.UtcNow.AddDays(-1),
SealedBy: "admin@test.com",
BundleVersion: "2025.10.0",
BundleDigest: "sha256:abc123",
LastAdvisoryUpdate: DateTimeOffset.UtcNow.AddHours(-12),
AdvisoryStalenessHours: 12,
TimeAnchor: new TimeAnchorInfo(
DateTimeOffset.UtcNow.AddHours(-1),
"base64signature",
Valid: true,
ExpiresAt: DateTimeOffset.UtcNow.AddDays(30)),
EgressBlocked: true,
NetworkPolicy: "deny-all");
var statusProvider = new MockAirGapStatusProvider(status);
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var manifest = CreateManifest(sealedInstall: true);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None);
Assert.True(result.Allowed);
Assert.Equal("Sealed install requirements satisfied", result.Message);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceAsync_WhenBundleVersionBelowMinimum_ReturnsDenied()
{
var status = new SealedModeStatus(
Sealed: true,
Mode: "sealed",
SealedAt: DateTimeOffset.UtcNow,
SealedBy: null,
BundleVersion: "2024.5.0",
BundleDigest: null,
LastAdvisoryUpdate: DateTimeOffset.UtcNow,
AdvisoryStalenessHours: 0,
TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, true, DateTimeOffset.UtcNow.AddDays(30)),
EgressBlocked: true,
NetworkPolicy: null);
var statusProvider = new MockAirGapStatusProvider(status);
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var requirements = new SealedRequirements(
MinBundleVersion: "2025.10.0",
MaxAdvisoryStalenessHours: 168,
RequireTimeAnchor: true,
AllowedOfflineDurationHours: 720,
RequireSignatureVerification: true);
var manifest = CreateManifest(sealedInstall: true, requirements);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
Assert.NotNull(result.RequirementViolations);
Assert.Single(result.RequirementViolations);
Assert.Equal("min_bundle_version", result.RequirementViolations[0].Requirement);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceAsync_WhenAdvisoryTooStale_ReturnsDenied()
{
var status = new SealedModeStatus(
Sealed: true,
Mode: "sealed",
SealedAt: DateTimeOffset.UtcNow,
SealedBy: null,
BundleVersion: "2025.10.0",
BundleDigest: null,
LastAdvisoryUpdate: DateTimeOffset.UtcNow.AddHours(-200),
AdvisoryStalenessHours: 200,
TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, true, DateTimeOffset.UtcNow.AddDays(30)),
EgressBlocked: true,
NetworkPolicy: null);
var statusProvider = new MockAirGapStatusProvider(status);
var options = Options.Create(new SealedInstallEnforcementOptions
{
Enabled = true,
DenyOnStaleness = true,
StalenessGracePeriodHours = 0
});
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var requirements = new SealedRequirements(
MinBundleVersion: null,
MaxAdvisoryStalenessHours: 168,
RequireTimeAnchor: false,
AllowedOfflineDurationHours: 720,
RequireSignatureVerification: false);
var manifest = CreateManifest(sealedInstall: true, requirements);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
Assert.NotNull(result.RequirementViolations);
Assert.Single(result.RequirementViolations);
Assert.Equal("max_advisory_staleness_hours", result.RequirementViolations[0].Requirement);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceAsync_WhenTimeAnchorMissing_ReturnsDenied()
{
var status = new SealedModeStatus(
Sealed: true,
Mode: "sealed",
SealedAt: DateTimeOffset.UtcNow,
SealedBy: null,
BundleVersion: "2025.10.0",
BundleDigest: null,
LastAdvisoryUpdate: DateTimeOffset.UtcNow,
AdvisoryStalenessHours: 0,
TimeAnchor: null, // No time anchor
EgressBlocked: true,
NetworkPolicy: null);
var statusProvider = new MockAirGapStatusProvider(status);
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var requirements = new SealedRequirements(
MinBundleVersion: null,
MaxAdvisoryStalenessHours: 168,
RequireTimeAnchor: true,
AllowedOfflineDurationHours: 720,
RequireSignatureVerification: false);
var manifest = CreateManifest(sealedInstall: true, requirements);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
Assert.NotNull(result.RequirementViolations);
Assert.Single(result.RequirementViolations);
Assert.Equal("require_time_anchor", result.RequirementViolations[0].Requirement);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceAsync_WhenTimeAnchorInvalid_ReturnsDenied()
{
var status = new SealedModeStatus(
Sealed: true,
Mode: "sealed",
SealedAt: DateTimeOffset.UtcNow,
SealedBy: null,
BundleVersion: "2025.10.0",
BundleDigest: null,
LastAdvisoryUpdate: DateTimeOffset.UtcNow,
AdvisoryStalenessHours: 0,
TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, Valid: false, null),
EgressBlocked: true,
NetworkPolicy: null);
var statusProvider = new MockAirGapStatusProvider(status);
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var requirements = new SealedRequirements(
MinBundleVersion: null,
MaxAdvisoryStalenessHours: 168,
RequireTimeAnchor: true,
AllowedOfflineDurationHours: 720,
RequireSignatureVerification: false);
var manifest = CreateManifest(sealedInstall: true, requirements);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
Assert.NotNull(result.RequirementViolations);
Assert.Contains(result.RequirementViolations, v => v.Requirement == "require_time_anchor");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceAsync_WhenStatusProviderFails_ReturnsDenied()
{
var statusProvider = new FailingAirGapStatusProvider();
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var manifest = CreateManifest(sealedInstall: true);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode);
Assert.Contains("Failed to verify", result.Message);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SealedModeStatus_Unsealed_ReturnsCorrectDefaults()
{
var status = SealedModeStatus.Unsealed();
Assert.False(status.Sealed);
Assert.Equal("unsealed", status.Mode);
Assert.Null(status.SealedAt);
Assert.Null(status.BundleVersion);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SealedModeStatus_Unavailable_ReturnsCorrectDefaults()
{
var status = SealedModeStatus.Unavailable();
Assert.False(status.Sealed);
Assert.Equal("unavailable", status.Mode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SealedRequirements_Default_HasExpectedValues()
{
var defaults = SealedRequirements.Default;
Assert.Null(defaults.MinBundleVersion);
Assert.Equal(168, defaults.MaxAdvisoryStalenessHours);
Assert.True(defaults.RequireTimeAnchor);
Assert.Equal(720, defaults.AllowedOfflineDurationHours);
Assert.True(defaults.RequireSignatureVerification);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EnforcementResult_CreateAllowed_SetsProperties()
{
var result = SealedInstallEnforcementResult.CreateAllowed("Test message");
Assert.True(result.Allowed);
Assert.Null(result.ErrorCode);
Assert.Equal("Test message", result.Message);
Assert.Null(result.Violation);
Assert.Null(result.RequirementViolations);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EnforcementResult_CreateDenied_SetsProperties()
{
var violation = new SealedInstallViolation("pack-1", "1.0.0", true, false, "Seal the environment");
var result = SealedInstallEnforcementResult.CreateDenied(
SealedInstallErrorCodes.SealedInstallViolation,
"Denied message",
violation);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode);
Assert.Equal("Denied message", result.Message);
Assert.NotNull(result.Violation);
Assert.Equal("pack-1", result.Violation.PackId);
}
private sealed class MockAirGapStatusProvider : IAirGapStatusProvider
{
private readonly SealedModeStatus _status;
public MockAirGapStatusProvider(SealedModeStatus status)
{
_status = status;
}
public Task<SealedModeStatus> GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
return Task.FromResult(_status);
}
}
private sealed class FailingAirGapStatusProvider : IAirGapStatusProvider
{
public Task<SealedModeStatus> GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
throw new HttpRequestException("Connection refused");
}
}
}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<NoWarn>xUnit1051</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
<ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.TaskRunner.Client\StellaOps.TaskRunner.Client.csproj" />
<ProjectReference Include="..\StellaOps.TaskRunner.WebService\StellaOps.TaskRunner.WebService.csproj" />
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<!-- OpenApiMetadataFactory is now accessible via WebService project reference -->
<!-- <Compile Include="..\StellaOps.TaskRunner.WebService\OpenApiMetadataFactory.cs" Link="Web/OpenApiMetadataFactory.cs" /> -->
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.TaskRunner.Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/StellaOps.TaskRunner.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,273 @@
using System;
using System.Linq;
using System.Text.Json.Nodes;
using StellaOps.AirGap.Policy;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class TaskPackPlannerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Plan_WithSequentialSteps_ComputesDeterministicHash()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["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);
}
[Trait("Category", TestCategories.Unit)]
[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.");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Plan_WhenConditionEvaluatesFalse_DisablesStep()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(true)
};
var result = planner.Plan(manifest, inputs);
Assert.True(result.Success);
Assert.False(result.Plan!.Steps[2].Enabled);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Plan_WithMapStep_ExpandsIterations()
{
var manifest = TestManifests.Load(TestManifests.Map);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["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<int>());
Assert.Equal("alpha", mapStep.Children![0].Parameters!["item"].Value!.GetValue<string>());
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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<string>());
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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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));
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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));
}
}

View File

@@ -0,0 +1,256 @@
using System.Text;
using StellaOps.TaskRunner.Client.Models;
using StellaOps.TaskRunner.Client.Streaming;
using StellaOps.TaskRunner.Client.Pagination;
using StellaOps.TaskRunner.Client.Lifecycle;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class TaskRunnerClientTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StreamingLogReader_ParsesNdjsonLines()
{
var ct = CancellationToken.None;
var ndjson = """
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Starting","traceId":"abc123"}
{"timestamp":"2025-01-01T00:00:01Z","level":"error","stepId":"step-1","message":"Failed","traceId":"abc123"}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
var entries = await StreamingLogReader.CollectAsync(stream, ct);
Assert.Equal(2, entries.Count);
Assert.Equal("info", entries[0].Level);
Assert.Equal("error", entries[1].Level);
Assert.Equal("step-1", entries[0].StepId);
Assert.Equal("Starting", entries[0].Message);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StreamingLogReader_SkipsEmptyLines()
{
var ct = CancellationToken.None;
var ndjson = """
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Test","traceId":"abc123"}
{"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"Test2","traceId":"abc123"}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
var entries = await StreamingLogReader.CollectAsync(stream, ct);
Assert.Equal(2, entries.Count);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StreamingLogReader_SkipsMalformedLines()
{
var ct = CancellationToken.None;
var ndjson = """
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Valid","traceId":"abc123"}
not valid json
{"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"AlsoValid","traceId":"abc123"}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
var entries = await StreamingLogReader.CollectAsync(stream, ct);
Assert.Equal(2, entries.Count);
Assert.Equal("Valid", entries[0].Message);
Assert.Equal("AlsoValid", entries[1].Message);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StreamingLogReader_FilterByLevel_FiltersCorrectly()
{
var ct = CancellationToken.None;
var entries = new List<RunLogEntry>
{
new(DateTimeOffset.UtcNow, "info", "step-1", "Info message", "trace1"),
new(DateTimeOffset.UtcNow, "error", "step-1", "Error message", "trace1"),
new(DateTimeOffset.UtcNow, "warning", "step-1", "Warning message", "trace1"),
};
var levels = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "error", "warning" };
var filtered = new List<RunLogEntry>();
await foreach (var entry in StreamingLogReader.FilterByLevelAsync(entries.ToAsyncEnumerable(), levels, ct))
{
filtered.Add(entry);
}
Assert.Equal(2, filtered.Count);
Assert.DoesNotContain(filtered, e => e.Level == "info");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StreamingLogReader_GroupByStep_GroupsCorrectly()
{
var ct = CancellationToken.None;
var entries = new List<RunLogEntry>
{
new(DateTimeOffset.UtcNow, "info", "step-1", "Message 1", "trace1"),
new(DateTimeOffset.UtcNow, "info", "step-2", "Message 2", "trace1"),
new(DateTimeOffset.UtcNow, "info", "step-1", "Message 3", "trace1"),
new(DateTimeOffset.UtcNow, "info", null, "Global message", "trace1"),
};
var groups = await StreamingLogReader.GroupByStepAsync(entries.ToAsyncEnumerable(), ct);
Assert.Equal(3, groups.Count);
Assert.Equal(2, groups["step-1"].Count);
Assert.Single(groups["step-2"]);
Assert.Single(groups["(global)"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Paginator_IteratesAllPages()
{
var ct = CancellationToken.None;
var allItems = Enumerable.Range(1, 25).ToList();
var pageSize = 10;
var fetchCalls = 0;
var paginator = new Paginator<int>(
async (offset, limit, token) =>
{
fetchCalls++;
var items = allItems.Skip(offset).Take(limit).ToList();
var hasMore = offset + items.Count < allItems.Count;
return new PagedResponse<int>(items, allItems.Count, hasMore);
},
pageSize);
var collected = await paginator.CollectAsync(ct);
Assert.Equal(25, collected.Count);
Assert.Equal(3, fetchCalls); // 10, 10, 5 items
Assert.Equal(allItems, collected);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Paginator_GetPage_ReturnsCorrectPage()
{
var ct = CancellationToken.None;
var allItems = Enumerable.Range(1, 25).ToList();
var pageSize = 10;
var paginator = new Paginator<int>(
async (offset, limit, token) =>
{
var items = allItems.Skip(offset).Take(limit).ToList();
var hasMore = offset + items.Count < allItems.Count;
return new PagedResponse<int>(items, allItems.Count, hasMore);
},
pageSize);
var page2 = await paginator.GetPageAsync(2, ct);
Assert.Equal(10, page2.Items.Count);
Assert.Equal(11, page2.Items[0]); // Items 11-20
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PaginatorExtensions_TakeAsync_TakesCorrectNumber()
{
var ct = CancellationToken.None;
var items = Enumerable.Range(1, 100).ToAsyncEnumerable();
var taken = new List<int>();
await foreach (var item in items.TakeAsync(5, ct))
{
taken.Add(item);
}
Assert.Equal(5, taken.Count);
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, taken);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PaginatorExtensions_SkipAsync_SkipsCorrectNumber()
{
var ct = CancellationToken.None;
var items = Enumerable.Range(1, 10).ToAsyncEnumerable();
var skipped = new List<int>();
await foreach (var item in items.SkipAsync(5, ct))
{
skipped.Add(item);
}
Assert.Equal(5, skipped.Count);
Assert.Equal(new[] { 6, 7, 8, 9, 10 }, skipped);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PackRunLifecycleHelper_TerminalStatuses_IncludesExpectedStatuses()
{
Assert.Contains("completed", PackRunLifecycleHelper.TerminalStatuses);
Assert.Contains("failed", PackRunLifecycleHelper.TerminalStatuses);
Assert.Contains("cancelled", PackRunLifecycleHelper.TerminalStatuses);
Assert.Contains("rejected", PackRunLifecycleHelper.TerminalStatuses);
Assert.DoesNotContain("running", PackRunLifecycleHelper.TerminalStatuses);
Assert.DoesNotContain("pending", PackRunLifecycleHelper.TerminalStatuses);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PackRunModels_CreatePackRunRequest_SerializesCorrectly()
{
var request = new CreatePackRunRequest(
"my-pack",
"1.0.0",
new Dictionary<string, object> { ["key"] = "value" },
"tenant-1",
"corr-123");
Assert.Equal("my-pack", request.PackId);
Assert.Equal("1.0.0", request.PackVersion);
Assert.NotNull(request.Inputs);
Assert.Equal("value", request.Inputs["key"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PackRunModels_SimulatedStep_HasCorrectProperties()
{
var loopInfo = new LoopInfo("{{ inputs.items }}", "item", 100);
var step = new SimulatedStep(
"step-1",
"loop",
"WillIterate",
loopInfo,
null,
null);
Assert.Equal("step-1", step.StepId);
Assert.Equal("loop", step.Kind);
Assert.NotNull(step.LoopInfo);
Assert.Equal("{{ inputs.items }}", step.LoopInfo.ItemsExpression);
}
}
internal static class AsyncEnumerableExtensions
{
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
{
foreach (var item in source)
{
yield return item;
}
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,561 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Tenancy;
using StellaOps.TaskRunner.Infrastructure.Tenancy;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
/// <summary>
/// Tests for tenant enforcement per TASKRUN-TEN-48-001.
/// </summary>
public sealed class TenantEnforcementTests
{
#region TenantContext Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TenantContext_RequiresTenantId()
{
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext(null!, "project-1"));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("", "project-1"));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext(" ", "project-1"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TenantContext_RequiresProjectId()
{
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("tenant-1", null!));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("tenant-1", ""));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("tenant-1", " "));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TenantContext_TrimsIds()
{
var context = new TenantContext(" tenant-1 ", " project-1 ");
Assert.Equal("tenant-1", context.TenantId);
Assert.Equal("project-1", context.ProjectId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TenantContext_GeneratesStoragePrefix()
{
var context = new TenantContext("Tenant-1", "Project-1");
Assert.Equal("tenant-1/project-1", context.StoragePrefix);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TenantContext_GeneratesFlatPrefix()
{
var context = new TenantContext("Tenant-1", "Project-1");
Assert.Equal("tenant-1_project-1", context.FlatPrefix);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TenantContext_GeneratesLoggingScope()
{
var context = new TenantContext("tenant-1", "project-1");
var scope = context.ToLoggingScope();
Assert.Equal("tenant-1", scope["TenantId"]);
Assert.Equal("project-1", scope["ProjectId"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TenantContext_DefaultRestrictionsAreNone()
{
var context = new TenantContext("tenant-1", "project-1");
Assert.False(context.Restrictions.EgressBlocked);
Assert.False(context.Restrictions.ReadOnly);
Assert.False(context.Restrictions.Suspended);
Assert.Null(context.Restrictions.MaxConcurrentRuns);
}
#endregion
#region StoragePathResolver Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void StoragePathResolver_HierarchicalPaths()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Hierarchical,
StateBasePath = "state",
LogsBasePath = "logs"
};
var resolver = new TenantScopedStoragePathResolver(options, "/data");
var tenant = new TenantContext("tenant-1", "project-1");
var statePath = resolver.GetStatePath(tenant, "run-123");
var logsPath = resolver.GetLogsPath(tenant, "run-123");
Assert.Contains("state", statePath);
Assert.Contains("tenant-1", statePath);
Assert.Contains("project-1", statePath);
Assert.Contains("run-123", statePath);
Assert.Contains("logs", logsPath);
Assert.Contains("tenant-1", logsPath);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void StoragePathResolver_FlatPaths()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Flat,
StateBasePath = "state"
};
var resolver = new TenantScopedStoragePathResolver(options, "/data");
var tenant = new TenantContext("tenant-1", "project-1");
var statePath = resolver.GetStatePath(tenant, "run-123");
Assert.Contains("tenant-1_project-1_run-123", statePath);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void StoragePathResolver_HashedPaths()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Hashed
};
var resolver = new TenantScopedStoragePathResolver(options, "/data");
var tenant = new TenantContext("tenant-1", "project-1");
var basePath = resolver.GetTenantBasePath(tenant);
// Should contain a hash (hex characters)
Assert.DoesNotContain("tenant-1", basePath);
Assert.Contains("project-1", basePath);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void StoragePathResolver_ValidatesPathOwnership()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Hierarchical
};
// Use temp path for cross-platform compatibility
var basePath = Path.Combine(Path.GetTempPath(), "tenant-test-" + Guid.NewGuid().ToString("N")[..8]);
var resolver = new TenantScopedStoragePathResolver(options, basePath);
var tenant1 = new TenantContext("tenant-1", "project-1");
var tenant2 = new TenantContext("tenant-2", "project-1");
var tenant1Path = resolver.GetStatePath(tenant1, "run-123");
var tenant2Path = resolver.GetStatePath(tenant2, "run-123");
Assert.True(resolver.ValidatePathBelongsToTenant(tenant1, tenant1Path));
Assert.False(resolver.ValidatePathBelongsToTenant(tenant1, tenant2Path));
}
#endregion
#region EgressPolicy Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EgressPolicy_AllowsByDefault()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext("tenant-1", "project-1");
var result = await policy.CheckEgressAsync(tenant, "example.com", 443);
Assert.True(result.IsAllowed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EgressPolicy_BlocksGlobalBlocklist()
{
var options = new TenantEgressPolicyOptions
{
AllowByDefault = true,
GlobalBlocklist = ["blocked.com"]
};
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext("tenant-1", "project-1");
var result = await policy.CheckEgressAsync(tenant, "blocked.com", 443);
Assert.False(result.IsAllowed);
Assert.Equal(EgressBlockReason.GlobalPolicy, result.BlockReason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EgressPolicy_BlocksSuspendedTenants()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { Suspended = true });
var result = await policy.CheckEgressAsync(tenant, "example.com", 443);
Assert.False(result.IsAllowed);
Assert.Equal(EgressBlockReason.TenantSuspended, result.BlockReason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EgressPolicy_BlocksRestrictedTenants()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { EgressBlocked = true });
var result = await policy.CheckEgressAsync(tenant, "example.com", 443);
Assert.False(result.IsAllowed);
Assert.Equal(EgressBlockReason.TenantRestriction, result.BlockReason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EgressPolicy_AllowsRestrictedTenantAllowlist()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions
{
EgressBlocked = true,
AllowedEgressDomains = ["allowed.com"]
});
var allowedResult = await policy.CheckEgressAsync(tenant, "allowed.com", 443);
var blockedResult = await policy.CheckEgressAsync(tenant, "other.com", 443);
Assert.True(allowedResult.IsAllowed);
Assert.False(blockedResult.IsAllowed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EgressPolicy_SupportsWildcardDomains()
{
var options = new TenantEgressPolicyOptions
{
AllowByDefault = true,
GlobalBlocklist = ["*.blocked.com"]
};
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext("tenant-1", "project-1");
var result = await policy.CheckEgressAsync(tenant, "sub.blocked.com", 443);
Assert.False(result.IsAllowed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EgressPolicy_RecordsAttempts()
{
var auditLog = new InMemoryEgressAuditLog();
var options = new TenantEgressPolicyOptions
{
AllowByDefault = true,
LogBlockedAttempts = true
};
var policy = CreateEgressPolicy(options, auditLog);
var tenant = new TenantContext("tenant-1", "project-1");
var uri = new Uri("https://example.com/api");
var result = await policy.CheckEgressAsync(tenant, uri);
await policy.RecordEgressAttemptAsync(tenant, "run-123", uri, result);
var records = auditLog.GetAllRecords();
Assert.Single(records);
Assert.Equal("tenant-1", records[0].TenantId);
Assert.Equal("run-123", records[0].RunId);
Assert.True(records[0].WasAllowed);
}
#endregion
#region TenantEnforcer Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TenantEnforcer_RequiresTenantId()
{
var enforcer = CreateTenantEnforcer();
var request = new PackRunTenantRequest("", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.MissingTenantId, result.FailureKind);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TenantEnforcer_RequiresProjectId()
{
var options = new TenancyEnforcementOptions { RequireProjectId = true };
var enforcer = CreateTenantEnforcer(options);
var request = new PackRunTenantRequest("tenant-1", "");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.MissingProjectId, result.FailureKind);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TenantEnforcer_BlocksSuspendedTenants()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { Suspended = true });
tenantProvider.Register(tenant);
var options = new TenancyEnforcementOptions { BlockSuspendedTenants = true };
var enforcer = CreateTenantEnforcer(options, tenantProvider);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.TenantSuspended, result.FailureKind);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TenantEnforcer_BlocksReadOnlyTenants()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { ReadOnly = true });
tenantProvider.Register(tenant);
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.TenantReadOnly, result.FailureKind);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TenantEnforcer_EnforcesConcurrentRunLimit()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { MaxConcurrentRuns = 2 });
tenantProvider.Register(tenant);
var runTracker = new InMemoryConcurrentRunTracker();
await runTracker.IncrementAsync("tenant-1", "run-1");
await runTracker.IncrementAsync("tenant-1", "run-2");
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider, runTracker: runTracker);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.MaxConcurrentRunsReached, result.FailureKind);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TenantEnforcer_AllowsWithinConcurrentLimit()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { MaxConcurrentRuns = 5 });
tenantProvider.Register(tenant);
var runTracker = new InMemoryConcurrentRunTracker();
await runTracker.IncrementAsync("tenant-1", "run-1");
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider, runTracker: runTracker);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.True(result.IsValid);
Assert.NotNull(result.Tenant);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TenantEnforcer_TracksRunStartCompletion()
{
var runTracker = new InMemoryConcurrentRunTracker();
var enforcer = CreateTenantEnforcer(runTracker: runTracker);
var tenant = new TenantContext("tenant-1", "project-1");
await enforcer.RecordRunStartAsync(tenant, "run-1");
Assert.Equal(1, await enforcer.GetConcurrentRunCountAsync(tenant));
await enforcer.RecordRunStartAsync(tenant, "run-2");
Assert.Equal(2, await enforcer.GetConcurrentRunCountAsync(tenant));
await enforcer.RecordRunCompletionAsync(tenant, "run-1");
Assert.Equal(1, await enforcer.GetConcurrentRunCountAsync(tenant));
await enforcer.RecordRunCompletionAsync(tenant, "run-2");
Assert.Equal(0, await enforcer.GetConcurrentRunCountAsync(tenant));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TenantEnforcer_CreatesExecutionContext()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext("tenant-1", "project-1");
tenantProvider.Register(tenant);
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var context = await enforcer.CreateExecutionContextAsync(request, "run-123");
Assert.NotNull(context);
Assert.Equal("tenant-1", context.Tenant.TenantId);
Assert.Equal("project-1", context.Tenant.ProjectId);
Assert.NotNull(context.StoragePaths);
Assert.Contains("tenant-1", context.LoggingScope["TenantId"].ToString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TenantEnforcer_ThrowsOnInvalidRequest()
{
var enforcer = CreateTenantEnforcer();
var request = new PackRunTenantRequest("", "project-1");
await Assert.ThrowsAsync<TenantEnforcementException>(() =>
enforcer.CreateExecutionContextAsync(request, "run-123").AsTask());
}
#endregion
#region ConcurrentRunTracker Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ConcurrentRunTracker_TracksMultipleTenants()
{
var tracker = new InMemoryConcurrentRunTracker();
await tracker.IncrementAsync("tenant-1", "run-1");
await tracker.IncrementAsync("tenant-1", "run-2");
await tracker.IncrementAsync("tenant-2", "run-3");
Assert.Equal(2, await tracker.GetCountAsync("tenant-1"));
Assert.Equal(1, await tracker.GetCountAsync("tenant-2"));
Assert.Equal(0, await tracker.GetCountAsync("tenant-3"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ConcurrentRunTracker_PreventsDoubleIncrement()
{
var tracker = new InMemoryConcurrentRunTracker();
await tracker.IncrementAsync("tenant-1", "run-1");
await tracker.IncrementAsync("tenant-1", "run-1"); // Same run ID
Assert.Equal(1, await tracker.GetCountAsync("tenant-1"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ConcurrentRunTracker_HandlesNonExistentDecrement()
{
var tracker = new InMemoryConcurrentRunTracker();
// Should not throw
await tracker.DecrementAsync("tenant-1", "non-existent");
Assert.Equal(0, await tracker.GetCountAsync("tenant-1"));
}
#endregion
#region Helper Methods
private static TenantEgressPolicy CreateEgressPolicy(
TenantEgressPolicyOptions? options = null,
IEgressAuditLog? auditLog = null)
{
return new TenantEgressPolicy(
options ?? new TenantEgressPolicyOptions(),
auditLog ?? NullEgressAuditLog.Instance,
NullLogger<TenantEgressPolicy>.Instance);
}
private static PackRunTenantEnforcer CreateTenantEnforcer(
TenancyEnforcementOptions? options = null,
ITenantContextProvider? tenantProvider = null,
IConcurrentRunTracker? runTracker = null)
{
var storageOptions = new TenantStoragePathOptions();
var pathResolver = new TenantScopedStoragePathResolver(storageOptions, Path.GetTempPath());
return new PackRunTenantEnforcer(
tenantProvider ?? new InMemoryTenantContextProvider(),
pathResolver,
options ?? new TenancyEnforcementOptions { ValidateTenantExists = false },
runTracker ?? new InMemoryConcurrentRunTracker(),
NullLogger<PackRunTenantEnforcer>.Instance);
}
#endregion
}

View File

@@ -0,0 +1,21 @@
namespace StellaOps.TaskRunner.Tests;
internal static partial class TestManifests
{
public const string SealedEgressBlocked = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: egress-blocked
version: 1.0.0
spec:
steps:
- id: fetch
run:
uses: builtin:http
with:
url: "https://example.com/data"
egress:
- url: "https://example.com"
""";
}

View File

@@ -0,0 +1,465 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Tests;
internal static partial class TestManifests
{
public static TaskPackManifest Load(string yaml)
{
var loader = new TaskPackManifestLoader();
return loader.Deserialize(yaml);
}
public const string Sample = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: sample-pack
version: 1.0.0
description: Sample pack for planner tests
tags: [tests]
spec:
inputs:
- name: dryRun
type: boolean
required: false
default: false
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
approvals:
- id: security-review
grants: ["packs.approve"]
steps:
- id: plan-step
name: Plan
run:
uses: builtin:plan
with:
dryRun: "{{ inputs.dryRun }}"
- id: approval
gate:
approval:
id: security-review
message: "Security approval required."
- id: apply-step
when: "{{ not inputs.dryRun }}"
run:
uses: builtin:apply
""";
public const string RequiredInput = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: required-input-pack
version: 1.2.3
spec:
inputs:
- name: sbomBundle
type: object
required: true
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: noop
run:
uses: builtin:noop
with:
sbom: "{{ inputs.sbomBundle }}"
""";
public const string StepReference = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: step-ref-pack
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: prepare
run:
uses: builtin:prepare
- id: consume
run:
uses: builtin:consume
with:
sourceSummary: "{{ steps.prepare.outputs.summary }}"
""";
public const string Map = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: map-pack
version: 1.0.0
spec:
inputs:
- name: targets
type: array
required: true
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: maintenance-loop
map:
items: "{{ inputs.targets }}"
step:
id: echo-step
run:
uses: builtin:echo
with:
target: "{{ item }}"
""";
public const string Secret = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: secret-pack
version: 1.0.0
spec:
secrets:
- name: apiKey
scope: packs.run
description: API authentication token
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: use-secret
run:
uses: builtin:http
with:
token: "{{ secrets.apiKey }}"
""";
public const string Output = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: output-pack
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: generate
run:
uses: builtin:generate
outputs:
- name: bundlePath
type: file
path: artifacts/report.txt
- name: evidenceModel
type: object
expression: "{{ steps.generate.outputs.evidence }}"
""";
public const string FailurePolicy = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: failure-policy-pack
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: build
run:
uses: builtin:build
failure:
retries:
maxAttempts: 4
backoffSeconds: 30
message: "Build failed."
""";
public const string Parallel = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: parallel-pack
version: 1.1.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fanout
parallel:
maxParallel: 2
continueOnError: true
steps:
- id: lint
run:
uses: builtin:lint
- id: test
run:
uses: builtin:test
failure:
retries:
maxAttempts: 2
backoffSeconds: 10
message: "Parallel execution failed."
""";
public const string PolicyGate = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: policy-gate-pack
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: prepare
run:
uses: builtin:prepare
- id: policy-check
gate:
policy:
policy: security-hold
parameters:
threshold: high
evidenceRef: "{{ steps.prepare.outputs.evidence }}"
""";
public const string EgressAllowed = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: egress-allowed
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fetch
run:
uses: builtin:http
with:
url: https://mirror.internal/api/status
egress:
- url: https://mirror.internal/api/status
""";
public const string EgressBlocked = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: egress-blocked
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fetch
run:
uses: builtin:http
with:
url: https://example.com/api/status
""";
public const string EgressRuntime = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: egress-runtime
version: 1.0.0
spec:
inputs:
- name: targetUrl
type: string
required: false
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fetch
run:
uses: builtin:http
with:
url: "{{ inputs.targetUrl }}"
""";
public const string Loop = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: loop-pack
version: 1.0.0
spec:
inputs:
- name: targets
type: array
required: true
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: process-loop
loop:
items: "{{ inputs.targets }}"
iterator: target
index: idx
maxIterations: 100
aggregation: collect
steps:
- id: process-item
run:
uses: builtin:process
""";
public const string Conditional = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: conditional-pack
version: 1.0.0
spec:
inputs:
- name: environment
type: string
required: true
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: env-branch
conditional:
branches:
- condition: "{{ inputs.environment == 'production' }}"
steps:
- id: deploy-prod
run:
uses: builtin:deploy
with:
target: production
- condition: "{{ inputs.environment == 'staging' }}"
steps:
- id: deploy-staging
run:
uses: builtin:deploy
with:
target: staging
else:
- id: deploy-dev
run:
uses: builtin:deploy
with:
target: development
outputUnion: true
""";
}

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
}