consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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. |
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
""";
|
||||
}
|
||||
@@ -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
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
|
||||
}
|
||||
Reference in New Issue
Block a user