Refactor code structure and optimize performance across multiple modules

This commit is contained in:
StellaOps Bot
2025-12-26 20:03:22 +02:00
parent c786faae84
commit b4fc66feb6
3353 changed files with 88254 additions and 1590657 deletions

View File

@@ -5,6 +5,8 @@ using System.Text.Json;
using StellaOps.ExportCenter.Client.Models;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Client.Tests;
/// <summary>
@@ -14,7 +16,8 @@ public sealed class ExportCenterClientTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetDiscoveryMetadataAsync_ReturnsMetadata()
{
var expectedMetadata = new OpenApiDiscoveryMetadata(
@@ -44,7 +47,8 @@ public sealed class ExportCenterClientTests
Assert.Equal("3.0.3", result.SpecVersion);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListProfilesAsync_ReturnsProfiles()
{
var expectedResponse = new ExportProfileListResponse(
@@ -79,7 +83,8 @@ public sealed class ExportCenterClientTests
Assert.False(result.HasMore);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListProfilesAsync_WithPagination_IncludesParameters()
{
var expectedResponse = new ExportProfileListResponse([], null, false);
@@ -97,7 +102,8 @@ public sealed class ExportCenterClientTests
await client.ListProfilesAsync(continuationToken: "abc123", limit: 10);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetProfileAsync_WhenNotFound_ReturnsNull()
{
var handler = new MockHttpMessageHandler(request =>
@@ -112,7 +118,8 @@ public sealed class ExportCenterClientTests
Assert.Null(result);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateEvidenceExportAsync_ReturnsResponse()
{
var expectedResponse = new CreateEvidenceExportResponse(
@@ -137,7 +144,8 @@ public sealed class ExportCenterClientTests
Assert.Equal("pending", result.Status);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetEvidenceExportStatusAsync_ReturnsStatus()
{
var expectedStatus = new EvidenceExportStatus(
@@ -167,7 +175,8 @@ public sealed class ExportCenterClientTests
Assert.Equal(100, result.Progress);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DownloadEvidenceExportAsync_ReturnsStream()
{
var bundleContent = "test bundle content"u8.ToArray();
@@ -191,7 +200,8 @@ public sealed class ExportCenterClientTests
Assert.Equal(bundleContent, ms.ToArray());
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DownloadEvidenceExportAsync_WhenNotReady_ReturnsNull()
{
var handler = new MockHttpMessageHandler(request =>
@@ -206,7 +216,8 @@ public sealed class ExportCenterClientTests
Assert.Null(result);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateAttestationExportAsync_ReturnsResponse()
{
var expectedResponse = new CreateAttestationExportResponse(
@@ -230,7 +241,8 @@ public sealed class ExportCenterClientTests
Assert.Equal("att-run-123", result.RunId);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAttestationExportStatusAsync_IncludesTransparencyLogField()
{
var expectedStatus = new AttestationExportStatus(

View File

@@ -1,6 +1,8 @@
using StellaOps.ExportCenter.Client.Streaming;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Client.Tests;
public sealed class ExportDownloadHelperTests : IDisposable
@@ -21,7 +23,8 @@ public sealed class ExportDownloadHelperTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DownloadToFileAsync_WritesContentToFile()
{
var content = "test content"u8.ToArray();
@@ -35,7 +38,8 @@ public sealed class ExportDownloadHelperTests : IDisposable
Assert.Equal(content, await File.ReadAllBytesAsync(outputPath));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DownloadToFileAsync_ReportsProgress()
{
var content = new byte[10000];
@@ -51,7 +55,8 @@ public sealed class ExportDownloadHelperTests : IDisposable
Assert.Equal(content.Length, progressReports[^1].bytes);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ComputeSha256Async_ReturnsCorrectHash()
{
var content = "test content for hashing"u8.ToArray();
@@ -64,7 +69,8 @@ public sealed class ExportDownloadHelperTests : IDisposable
Assert.All(hash, c => Assert.True(char.IsLetterOrDigit(c)));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DownloadAndVerifyAsync_SucceedsWithCorrectHash()
{
var content = "deterministic content"u8.ToArray();
@@ -81,7 +87,8 @@ public sealed class ExportDownloadHelperTests : IDisposable
Assert.True(File.Exists(outputPath));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DownloadAndVerifyAsync_ThrowsOnHashMismatch()
{
var content = "actual content"u8.ToArray();
@@ -96,7 +103,8 @@ public sealed class ExportDownloadHelperTests : IDisposable
Assert.False(File.Exists(outputPath));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DownloadAndVerifyAsync_HandlesSha256Prefix()
{
var content = "prefixed hash test"u8.ToArray();
@@ -113,7 +121,8 @@ public sealed class ExportDownloadHelperTests : IDisposable
Assert.Equal(hash, actualHash);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CopyWithProgressAsync_CopiesCorrectly()
{
var content = new byte[5000];
@@ -127,7 +136,8 @@ public sealed class ExportDownloadHelperTests : IDisposable
Assert.Equal(content, destination.ToArray());
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateProgressLogger_ReturnsWorkingCallback()
{
var messages = new List<string>();
@@ -144,7 +154,8 @@ public sealed class ExportDownloadHelperTests : IDisposable
Assert.Contains("300", messages[1]);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateProgressLogger_FormatsWithoutTotalBytes()
{
var messages = new List<string>();
@@ -156,7 +167,8 @@ public sealed class ExportDownloadHelperTests : IDisposable
Assert.DoesNotContain("%", messages[0]);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateProgressLogger_FormatsWithTotalBytes()
{
var messages = new List<string>();

View File

@@ -2,11 +2,13 @@ using StellaOps.ExportCenter.Client.Lifecycle;
using StellaOps.ExportCenter.Client.Models;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Client.Tests;
public sealed class ExportJobLifecycleHelperTests
{
[Theory]
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("completed", true)]
[InlineData("failed", true)]
[InlineData("cancelled", true)]
@@ -19,7 +21,8 @@ public sealed class ExportJobLifecycleHelperTests
Assert.Equal(expected, result);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WaitForEvidenceExportCompletionAsync_ReturnsOnTerminalStatus()
{
var callCount = 0;
@@ -51,7 +54,8 @@ public sealed class ExportJobLifecycleHelperTests
Assert.Equal(3, callCount);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WaitForEvidenceExportCompletionAsync_ThrowsOnNotFound()
{
var mockClient = new MockExportCenterClient
@@ -64,7 +68,8 @@ public sealed class ExportJobLifecycleHelperTests
mockClient, "nonexistent", TimeSpan.FromMilliseconds(10), TimeSpan.FromSeconds(1)));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WaitForAttestationExportCompletionAsync_ReturnsOnTerminalStatus()
{
var callCount = 0;
@@ -96,7 +101,8 @@ public sealed class ExportJobLifecycleHelperTests
Assert.True(result.TransparencyLogIncluded);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateEvidenceExportAndWaitAsync_CreatesAndWaits()
{
var createCalled = false;
@@ -126,7 +132,8 @@ public sealed class ExportJobLifecycleHelperTests
Assert.Equal("completed", result.Status);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TerminalStatuses_ContainsExpectedValues()
{
Assert.Contains("completed", ExportJobLifecycleHelper.TerminalStatuses);

View File

@@ -28,6 +28,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.ExportCenter.Client\StellaOps.ExportCenter.Client.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -5,6 +5,8 @@ using System.Text.Json;
using StellaOps.ExportCenter.Core.AttestationBundle;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class AttestationBundleBuilderTests : IDisposable
@@ -25,7 +27,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
// No cleanup needed
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ProducesValidExport()
{
var request = CreateTestRequest();
@@ -39,7 +42,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.True(result.ExportStream.Length > 0);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_MetadataContainsCorrectValues()
{
var exportId = Guid.NewGuid();
@@ -66,7 +70,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Equal("attestation-bundle/v1", result.Metadata.Version);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ProducesDeterministicOutput()
{
var exportId = new Guid("11111111-2222-3333-4444-555555555555");
@@ -90,7 +95,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Equal(bytes1, bytes2);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ArchiveContainsExpectedFiles()
{
var request = CreateTestRequest();
@@ -105,7 +111,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Contains("verify-attestation.sh", fileNames);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithTransparencyEntries_IncludesTransparencyFile()
{
var entries = new List<string>
@@ -128,7 +135,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Contains("transparency.ndjson", fileNames);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithoutTransparencyEntries_OmitsTransparencyFile()
{
var request = CreateTestRequest();
@@ -139,7 +147,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.DoesNotContain("transparency.ndjson", fileNames);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_TransparencyEntriesSortedLexically()
{
var entries = new List<string>
@@ -168,7 +177,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Contains("z-log", lines[2]);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_DsseEnvelopeIsUnmodified()
{
var originalDsse = CreateTestDsseEnvelope();
@@ -185,7 +195,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Equal(originalDsse, extractedDsse);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_StatementIsUnmodified()
{
var originalStatement = CreateTestStatement();
@@ -202,7 +213,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Equal(originalStatement, extractedStatement);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_TarEntriesHaveDeterministicMetadata()
{
var request = CreateTestRequest();
@@ -220,7 +232,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_VerifyScriptHasExecutePermission()
{
var request = CreateTestRequest();
@@ -233,7 +246,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.True(scriptEntry.Mode.HasFlag(UnixFileMode.UserExecute));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_VerifyScriptIsPosixCompliant()
{
var request = CreateTestRequest();
@@ -249,7 +263,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.DoesNotContain("wget", script);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_VerifyScriptContainsAttestationId()
{
var attestationId = Guid.NewGuid();
@@ -266,7 +281,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Contains(attestationId.ToString("D"), script);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ChecksumsContainsAllFiles()
{
var request = CreateTestRequest();
@@ -279,7 +295,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Contains("metadata.json", checksums);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithSubjectDigests_IncludesInMetadata()
{
var digests = new List<AttestationSubjectDigest>
@@ -304,7 +321,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Equal("sha256:abc123", result.Metadata.SubjectDigests[0].Digest);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForEmptyExportId()
{
var request = new AttestationBundleExportRequest(
@@ -317,7 +335,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Throws<ArgumentException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForEmptyAttestationId()
{
var request = new AttestationBundleExportRequest(
@@ -330,7 +349,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Throws<ArgumentException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForEmptyTenantId()
{
var request = new AttestationBundleExportRequest(
@@ -343,7 +363,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Throws<ArgumentException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForEmptyDsseEnvelope()
{
var request = new AttestationBundleExportRequest(
@@ -356,7 +377,8 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Throws<ArgumentException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForEmptyStatement()
{
var request = new AttestationBundleExportRequest(
@@ -369,13 +391,15 @@ public sealed class AttestationBundleBuilderTests : IDisposable
Assert.Throws<ArgumentException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForNullRequest()
{
Assert.Throws<ArgumentNullException>(() => _builder.Build(null!));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_DefaultStatementVersionIsV1()
{
var request = new AttestationBundleExportRequest(

View File

@@ -6,6 +6,8 @@ using StellaOps.Cryptography;
using StellaOps.ExportCenter.Core.BootstrapPack;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class BootstrapPackBuilderTests : IDisposable
@@ -30,7 +32,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithCharts_ProducesValidPack()
{
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: test-chart\nversion: 1.0.0");
@@ -51,7 +54,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
Assert.Single(result.Manifest.Charts);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithImages_ProducesValidPack()
{
var blobPath = CreateTestFile("blob", "image layer content");
@@ -75,7 +79,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
Assert.Equal("registry.example.com/app", result.Manifest.Images[0].Repository);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithChartsAndImages_IncludesAll()
{
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: stellaops\nversion: 2.0.0");
@@ -96,7 +101,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
Assert.Single(result.Manifest.Images);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ProducesDeterministicOutput()
{
var chartPath = CreateTestFile("Chart-determ.yaml", "apiVersion: v2\nname: determ\nversion: 1.0.0");
@@ -120,7 +126,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
Assert.Equal(bytes1, bytes2);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ArchiveContainsExpectedFiles()
{
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: archive-test\nversion: 1.0.0");
@@ -146,7 +153,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
Assert.True(fileNames.Any(f => f.StartsWith("images/blobs/")));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_TarEntriesHaveDeterministicMetadata()
{
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: metadata-test\nversion: 1.0.0");
@@ -171,7 +179,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithChartDirectory_IncludesAllFiles()
{
var chartDir = Path.Combine(_tempDir, "test-chart");
@@ -196,7 +205,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
Assert.Contains("charts/dir-chart-1.0.0/templates/deployment.yaml", fileNames);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithSignatures_IncludesSignatureEntry()
{
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: sig-test\nversion: 1.0.0");
@@ -217,7 +227,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
Assert.Contains("signatures/mirror-bundle.sig", fileNames);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_OciIndexContainsImageReferences()
{
var blobPath = CreateTestFile("layer", "image content");
@@ -240,7 +251,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
Assert.Equal("sha256:img123", index.Manifests[0].Digest);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForEmptyInputs()
{
var request = new BootstrapPackBuildRequest(
@@ -252,7 +264,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
Assert.Throws<ArgumentException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForMissingChartPath()
{
var request = new BootstrapPackBuildRequest(
@@ -264,7 +277,8 @@ public sealed class BootstrapPackBuilderTests : IDisposable
Assert.Throws<FileNotFoundException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ManifestVersionIsCorrect()
{
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: version-test\nversion: 1.0.0");

View File

@@ -3,6 +3,8 @@ using StellaOps.Cryptography;
using StellaOps.ExportCenter.Core.Encryption;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public class BundleEncryptionServiceTests : IDisposable
@@ -34,7 +36,8 @@ public class BundleEncryptionServiceTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EncryptAsync_WithModeNone_ReturnsSuccessWithoutEncryption()
{
var request = new BundleEncryptRequest
@@ -52,7 +55,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.Null(result.Metadata);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EncryptAsync_WithAgeMode_EncryptsFiles()
{
var (publicKey, _) = TestAgeKeyGenerator.GenerateKeyPair();
@@ -103,7 +107,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.True(encryptedContent.Length > plaintext.Length);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EncryptAsync_AndDecryptAsync_RoundTripsSuccessfully()
{
var (publicKey, privateKey) = TestAgeKeyGenerator.GenerateKeyPair();
@@ -172,7 +177,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.Equal(plaintext, decryptedContent);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EncryptAsync_WithMultipleRecipients_WrapsForEach()
{
var (publicKey1, _) = TestAgeKeyGenerator.GenerateKeyPair();
@@ -215,7 +221,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.NotEqual(wrappedKey1, wrappedKey2);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EncryptAsync_WithMultipleFiles_EncryptsAll()
{
var (publicKey, _) = TestAgeKeyGenerator.GenerateKeyPair();
@@ -258,7 +265,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.Equal(3, nonces.Count);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DecryptAsync_WithWrongKey_Fails()
{
var (publicKey, _) = TestAgeKeyGenerator.GenerateKeyPair();
@@ -321,7 +329,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.False(decryptResult.Success);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DecryptAsync_WithNoMatchingKey_ReturnsError()
{
var runId = Guid.NewGuid();
@@ -349,7 +358,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.Contains("No matching key", result.ErrorMessage);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ValidateOptions_WithNoRecipients_ReturnsError()
{
var options = new BundleEncryptionOptions
@@ -365,7 +375,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.Contains(errors, e => e.Contains("recipient"));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ValidateOptions_WithInvalidRecipient_ReturnsError()
{
var options = new BundleEncryptionOptions
@@ -381,7 +392,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.Contains(errors, e => e.Contains("Invalid age public key"));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ValidateOptions_WithEmptyAadFormat_ReturnsError()
{
var (publicKey, _) = TestAgeKeyGenerator.GenerateKeyPair();
@@ -398,7 +410,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.Contains(errors, e => e.Contains("AAD format"));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ValidateOptions_WithKmsAndNoKeyId_ReturnsError()
{
var options = new BundleEncryptionOptions
@@ -414,7 +427,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.Contains(errors, e => e.Contains("KMS key ID"));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ValidateOptions_WithModeNone_ReturnsNoErrors()
{
var options = new BundleEncryptionOptions
@@ -427,7 +441,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.Empty(errors);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EncryptAsync_WithNoRecipientsConfigured_ReturnsError()
{
var request = new BundleEncryptRequest
@@ -449,7 +464,8 @@ public class BundleEncryptionServiceTests : IDisposable
Assert.Contains("recipient", result.ErrorMessage);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DecryptAsync_WithTamperedCiphertext_Fails()
{
var (publicKey, privateKey) = TestAgeKeyGenerator.GenerateKeyPair();

View File

@@ -5,11 +5,14 @@ using System.Text;
using System.Linq;
using StellaOps.ExportCenter.Core.DevPortalOffline;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class DevPortalOfflineBundleBuilderTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ComposesExpectedArchive()
{
var tempRoot = Directory.CreateTempSubdirectory();
@@ -126,7 +129,8 @@ public sealed class DevPortalOfflineBundleBuilderTests
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsWhenNoContent()
{
var builder = new DevPortalOfflineBundleBuilder(new FakeCryptoHash(), new FixedTimeProvider(DateTimeOffset.UtcNow));
@@ -136,7 +140,8 @@ public sealed class DevPortalOfflineBundleBuilderTests
Assert.Contains("does not contain any files", exception.Message, StringComparison.Ordinal);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_UsesOptionalSources()
{
var tempRoot = Directory.CreateTempSubdirectory();
@@ -165,7 +170,8 @@ public sealed class DevPortalOfflineBundleBuilderTests
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsWhenSourceDirectoryMissing()
{
var builder = new DevPortalOfflineBundleBuilder(new FakeCryptoHash(), new FixedTimeProvider(DateTimeOffset.UtcNow));

View File

@@ -9,11 +9,14 @@ using System.Threading.Tasks;
using StellaOps.ExportCenter.Core.DevPortalOffline;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public class DevPortalOfflineJobTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_StoresArtefacts()
{
var tempRoot = Directory.CreateTempSubdirectory();
@@ -81,7 +84,8 @@ public class DevPortalOfflineJobTests
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_SanitizesBundleFileName()
{
var builder = new DevPortalOfflineBundleBuilder(new FakeCryptoHash(), new FixedTimeProvider(DateTimeOffset.UtcNow));

View File

@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.ExportCenter.Core.Notifications;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class ExportNotificationEmitterTests
@@ -24,7 +25,8 @@ public sealed class ExportNotificationEmitterTests
NullLogger<ExportNotificationEmitter>.Instance);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitAirgapReadyAsync_PublishesToSink()
{
var notification = CreateTestNotification();
@@ -36,7 +38,8 @@ public sealed class ExportNotificationEmitterTests
Assert.Equal(1, _sink.Count);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitAirgapReadyAsync_UsesCorrectChannel()
{
var notification = CreateTestNotification();
@@ -47,7 +50,8 @@ public sealed class ExportNotificationEmitterTests
Assert.Single(messages);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitAirgapReadyAsync_SerializesPayloadWithSnakeCase()
{
var notification = CreateTestNotification();
@@ -63,7 +67,8 @@ public sealed class ExportNotificationEmitterTests
Assert.Contains("\"artifact_sha256\":", payload);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitAirgapReadyAsync_RoutesToDlqOnFailure()
{
var failingSink = new FailingNotificationSink(maxFailures: 10);
@@ -82,7 +87,8 @@ public sealed class ExportNotificationEmitterTests
Assert.Equal(1, _dlq.Count);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitAirgapReadyAsync_DlqEntryContainsCorrectData()
{
var failingSink = new FailingNotificationSink(maxFailures: 10);
@@ -108,7 +114,8 @@ public sealed class ExportNotificationEmitterTests
Assert.NotEmpty(entry.OriginalPayload);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitAirgapReadyAsync_RetriesTransientFailures()
{
var failingSink = new FailingNotificationSink(maxFailures: 2);
@@ -128,7 +135,8 @@ public sealed class ExportNotificationEmitterTests
Assert.Equal(0, _dlq.Count);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitToTimelineAsync_UsesTimelineChannel()
{
var notification = CreateTestNotification();
@@ -141,7 +149,8 @@ public sealed class ExportNotificationEmitterTests
Assert.Single(messages);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitAirgapReadyAsync_IncludesMetadataInPayload()
{
var notification = new ExportAirgapReadyNotification
@@ -173,7 +182,8 @@ public sealed class ExportNotificationEmitterTests
Assert.Contains("\"source_uri\":\"https://source.example.com/bundle\"", payload);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitAirgapReadyAsync_WithWebhook_DeliversToWebhook()
{
var webhookClient = new FakeWebhookClient();
@@ -193,7 +203,8 @@ public sealed class ExportNotificationEmitterTests
Assert.Equal(1, webhookClient.DeliveryCount);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitAirgapReadyAsync_WithWebhookFailure_RoutesToDlq()
{
var webhookClient = new FakeWebhookClient(alwaysFail: true);
@@ -213,7 +224,8 @@ public sealed class ExportNotificationEmitterTests
Assert.Equal(1, _dlq.Count);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EmitAirgapReadyAsync_ThrowsOnNullNotification()
{
await Assert.ThrowsAsync<ArgumentNullException>(
@@ -296,7 +308,8 @@ public sealed class ExportNotificationEmitterTests
public sealed class ExportWebhookClientTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeSignature_ProducesDeterministicOutput()
{
var payload = "{\"export_id\":\"abc123\"}";
@@ -309,7 +322,8 @@ public sealed class ExportWebhookClientTests
Assert.Equal(sig1, sig2);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeSignature_StartsWithSha256Prefix()
{
var payload = "{\"test\":true}";
@@ -321,7 +335,8 @@ public sealed class ExportWebhookClientTests
Assert.StartsWith("sha256=", signature);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeSignature_ChangesWithDifferentPayload()
{
var sentAt = DateTimeOffset.UtcNow;
@@ -333,7 +348,8 @@ public sealed class ExportWebhookClientTests
Assert.NotEqual(sig1, sig2);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeSignature_ChangesWithDifferentTimestamp()
{
var payload = "{\"test\":true}";
@@ -345,7 +361,8 @@ public sealed class ExportWebhookClientTests
Assert.NotEqual(sig1, sig2);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeSignature_ChangesWithDifferentKey()
{
var payload = "{\"test\":true}";
@@ -357,7 +374,8 @@ public sealed class ExportWebhookClientTests
Assert.NotEqual(sig1, sig2);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeSignature_AcceptsBase64Key()
{
var payload = "{\"test\":true}";
@@ -369,7 +387,8 @@ public sealed class ExportWebhookClientTests
Assert.StartsWith("sha256=", signature);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeSignature_AcceptsHexKey()
{
var payload = "{\"test\":true}";
@@ -381,7 +400,8 @@ public sealed class ExportWebhookClientTests
Assert.StartsWith("sha256=", signature);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifySignature_ReturnsTrueForValidSignature()
{
var payload = "{\"test\":true}";
@@ -394,7 +414,8 @@ public sealed class ExportWebhookClientTests
Assert.True(isValid);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifySignature_ReturnsFalseForInvalidSignature()
{
var payload = "{\"test\":true}";
@@ -406,7 +427,8 @@ public sealed class ExportWebhookClientTests
Assert.False(isValid);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifySignature_ReturnsFalseForTamperedPayload()
{
var sentAt = DateTimeOffset.UtcNow;
@@ -421,7 +443,8 @@ public sealed class ExportWebhookClientTests
public sealed class InMemoryExportNotificationSinkTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PublishAsync_StoresMessage()
{
var sink = new InMemoryExportNotificationSink();
@@ -431,7 +454,8 @@ public sealed class InMemoryExportNotificationSinkTests
Assert.Equal(1, sink.Count);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetMessages_ReturnsMessagesByChannel()
{
var sink = new InMemoryExportNotificationSink();
@@ -447,7 +471,8 @@ public sealed class InMemoryExportNotificationSinkTests
Assert.Single(messagesB);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Clear_RemovesAllMessages()
{
var sink = new InMemoryExportNotificationSink();
@@ -462,7 +487,8 @@ public sealed class InMemoryExportNotificationSinkTests
public sealed class InMemoryExportNotificationDlqTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnqueueAsync_StoresEntry()
{
var dlq = new InMemoryExportNotificationDlq();
@@ -473,7 +499,8 @@ public sealed class InMemoryExportNotificationDlqTests
Assert.Equal(1, dlq.Count);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetPendingAsync_ReturnsAllEntries()
{
var dlq = new InMemoryExportNotificationDlq();
@@ -486,7 +513,8 @@ public sealed class InMemoryExportNotificationDlqTests
Assert.Equal(2, pending.Count);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetPendingAsync_FiltersByTenant()
{
var dlq = new InMemoryExportNotificationDlq();
@@ -501,7 +529,8 @@ public sealed class InMemoryExportNotificationDlqTests
Assert.All(pending, e => Assert.Equal("tenant-1", e.TenantId));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetPendingAsync_RespectsLimit()
{
var dlq = new InMemoryExportNotificationDlq();

View File

@@ -8,11 +8,14 @@ using Microsoft.Extensions.Options;
using StellaOps.ExportCenter.Core.DevPortalOffline;
using StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public class HmacDevPortalOfflineManifestSignerTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignAsync_ComputesDeterministicSignature()
{
var options = new DevPortalOfflineManifestSigningOptions
@@ -51,7 +54,8 @@ public class HmacDevPortalOfflineManifestSignerTests
Assert.Equal(expectedSignature, signature.Signature);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignAsync_ThrowsForUnsupportedAlgorithm()
{
var options = new DevPortalOfflineManifestSigningOptions

View File

@@ -6,6 +6,8 @@ using StellaOps.Cryptography;
using StellaOps.ExportCenter.Core.MirrorBundle;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class MirrorBundleBuilderTests : IDisposable
@@ -30,7 +32,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_FullBundle_ProducesValidArchive()
{
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-2024-1234\"}");
@@ -54,7 +57,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.Equal("mirror:full", result.Manifest.Profile);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_DeltaBundle_IncludesDeltaMetadata()
{
var vexPath = CreateTestFile("vex.jsonl.zst", "{\"id\":\"VEX-001\"}");
@@ -77,7 +81,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.Equal("mirror:delta", result.Manifest.Profile);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithEncryption_IncludesEncryptionMetadata()
{
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-2024-5678\"}");
@@ -103,7 +108,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.Single(result.Manifest.Encryption.Recipients);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ProducesDeterministicOutput()
{
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-2024-DETERM\"}");
@@ -132,7 +138,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.Equal(bytes1, bytes2);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ArchiveContainsExpectedFiles()
{
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-2024-ARCHIVE\"}");
@@ -160,7 +167,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.Contains("data/raw/advisories/advisories.jsonl.zst", fileNames);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_TarEntriesHaveDeterministicMetadata()
{
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-2024-METADATA\"}");
@@ -189,7 +197,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_SbomWithSubject_UsesCorrectPath()
{
var sbomPath = CreateTestFile("sbom.json", "{\"bomFormat\":\"CycloneDX\"}");
@@ -212,7 +221,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.Contains("data/raw/sboms/registry.example.com-app-v1.2.3/sbom.json", fileNames);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_NormalizedData_UsesNormalizedPath()
{
var normalizedPath = CreateTestFile("advisories-normalized.jsonl.zst", "{\"id\":\"CVE-2024-NORM\"}");
@@ -235,7 +245,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.Contains("data/normalized/advisories/advisories-normalized.jsonl.zst", fileNames);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_CountsAreAccurate()
{
var advisory1 = CreateTestFile("advisory1.jsonl.zst", "{\"id\":\"CVE-1\"}");
@@ -263,7 +274,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.Equal(1, result.Manifest.Counts.Sboms);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForMissingDataSource()
{
var request = new MirrorBundleBuildRequest(
@@ -279,7 +291,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.Throws<FileNotFoundException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForDeltaWithoutOptions()
{
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-DELTA\"}");
@@ -297,7 +310,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.Throws<ArgumentException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ProvenanceDocumentContainsSubjects()
{
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-PROVENANCE\"}");
@@ -319,7 +333,8 @@ public sealed class MirrorBundleBuilderTests : IDisposable
Assert.NotEmpty(result.ProvenanceDocument.Builder.ExporterVersion);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ExportDocumentContainsManifestDigest()
{
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-EXPORT\"}");

View File

@@ -4,6 +4,8 @@ using StellaOps.Cryptography;
using StellaOps.ExportCenter.Core.MirrorBundle;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class MirrorBundleSigningTests
@@ -17,7 +19,8 @@ public sealed class MirrorBundleSigningTests
_signer = new HmacMirrorBundleManifestSigner(_cryptoHmac, "test-signing-key-12345", "test-key-id");
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignExportDocumentAsync_ReturnsDsseEnvelope()
{
var exportJson = """{"runId":"abc123","tenantId":"tenant-1"}""";
@@ -32,7 +35,8 @@ public sealed class MirrorBundleSigningTests
Assert.NotEmpty(result.Signatures[0].Signature);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignManifestAsync_ReturnsDsseEnvelope()
{
var manifestYaml = "profile: mirror:full\nrunId: abc123";
@@ -45,7 +49,8 @@ public sealed class MirrorBundleSigningTests
Assert.Single(result.Signatures);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignArchiveAsync_ReturnsBase64Signature()
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("test archive content"));
@@ -58,7 +63,8 @@ public sealed class MirrorBundleSigningTests
Assert.NotEmpty(decoded);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignArchiveAsync_ResetStreamPosition()
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("test archive content"));
@@ -69,7 +75,8 @@ public sealed class MirrorBundleSigningTests
Assert.Equal(0, stream.Position);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignExportDocumentAsync_PayloadIsBase64Encoded()
{
var exportJson = """{"runId":"encoded-test"}""";
@@ -80,7 +87,8 @@ public sealed class MirrorBundleSigningTests
Assert.Equal(exportJson, decodedPayload);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignExportDocumentAsync_IsDeterministic()
{
var exportJson = """{"runId":"deterministic-test"}""";
@@ -92,7 +100,8 @@ public sealed class MirrorBundleSigningTests
Assert.Equal(result1.Payload, result2.Payload);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ToJson_SerializesCorrectly()
{
var signature = new MirrorBundleDsseSignature(
@@ -112,28 +121,32 @@ public sealed class MirrorBundleSigningTests
Assert.NotNull(parsed);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Constructor_ThrowsForEmptyKey()
{
Assert.Throws<ArgumentException>(() =>
new HmacMirrorBundleManifestSigner(_cryptoHmac, "", "key-id"));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Constructor_ThrowsForNullKey()
{
Assert.Throws<ArgumentException>(() =>
new HmacMirrorBundleManifestSigner(_cryptoHmac, null!, "key-id"));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Constructor_ThrowsForNullCryptoHmac()
{
Assert.Throws<ArgumentNullException>(() =>
new HmacMirrorBundleManifestSigner(null!, "test-key", "key-id"));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Constructor_UsesDefaultKeyIdWhenEmpty()
{
var signer = new HmacMirrorBundleManifestSigner(_cryptoHmac, "test-key", "");
@@ -142,7 +155,8 @@ public sealed class MirrorBundleSigningTests
Assert.Equal("mirror-bundle-hmac", result.Signatures[0].KeyId);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignArchiveAsync_ThrowsForNonSeekableStream()
{
using var nonSeekable = new NonSeekableMemoryStream(Encoding.UTF8.GetBytes("test"));

View File

@@ -5,6 +5,8 @@ using StellaOps.ExportCenter.Core.MirrorBundle;
using StellaOps.ExportCenter.Core.Planner;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public class MirrorDeltaAdapterTests : IDisposable
@@ -43,31 +45,36 @@ public class MirrorDeltaAdapterTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AdapterId_IsMirrorDelta()
{
Assert.Equal("mirror:delta", _adapter.AdapterId);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DisplayName_IsMirrorDeltaBundle()
{
Assert.Equal("Mirror Delta Bundle", _adapter.DisplayName);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SupportedFormats_ContainsMirror()
{
Assert.Contains(ExportFormat.Mirror, _adapter.SupportedFormats);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SupportsStreaming_IsFalse()
{
Assert.False(_adapter.SupportsStreaming);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateConfigAsync_WithMissingOutputDirectory_ReturnsError()
{
var config = new ExportAdapterConfig
@@ -83,7 +90,8 @@ public class MirrorDeltaAdapterTests : IDisposable
Assert.Contains(errors, e => e.Contains("Output directory"));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateConfigAsync_WithValidConfig_ReturnsNoErrors()
{
var config = new ExportAdapterConfig
@@ -98,7 +106,8 @@ public class MirrorDeltaAdapterTests : IDisposable
Assert.Empty(errors);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ComputeDeltaAsync_WithNoBaseManifest_ReturnsAllItemsAsAdded()
{
var tenantId = Guid.NewGuid();
@@ -141,7 +150,8 @@ public class MirrorDeltaAdapterTests : IDisposable
Assert.Empty(result.UnchangedItems);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ComputeDeltaAsync_WithBaseManifest_DetectsChanges()
{
var tenantId = Guid.NewGuid();
@@ -238,7 +248,8 @@ public class MirrorDeltaAdapterTests : IDisposable
Assert.Contains(result.RemovedItems, r => r.ItemId == "item-3");
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ComputeDeltaAsync_WithResetBaseline_ReturnsAllAsAdded()
{
var tenantId = Guid.NewGuid();
@@ -291,7 +302,8 @@ public class MirrorDeltaAdapterTests : IDisposable
Assert.Empty(result.UnchangedItems);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ComputeDeltaAsync_WithDigestMismatch_ReturnsError()
{
var tenantId = Guid.NewGuid();
@@ -325,7 +337,8 @@ public class MirrorDeltaAdapterTests : IDisposable
Assert.Contains("mismatch", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ContentStore_StoresAndRetrieves()
{
var content = "test content"u8.ToArray();
@@ -344,7 +357,8 @@ public class MirrorDeltaAdapterTests : IDisposable
Assert.Equal(content, ms.ToArray());
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ContentStore_GetLocalPath_ReturnsPathForStoredContent()
{
var content = "test content"u8.ToArray();
@@ -357,7 +371,8 @@ public class MirrorDeltaAdapterTests : IDisposable
Assert.True(File.Exists(localPath));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ContentStore_GetLocalPath_ReturnsNullForMissingContent()
{
var localPath = _contentStore.GetLocalPath("nonexistent-hash");

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using StellaOps.ExportCenter.Core.OfflineKit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class OfflineKitDistributorTests : IDisposable
@@ -30,7 +31,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DistributeToMirror_CopiesFilesToMirrorLocation()
{
var sourceKit = SetupSourceKit();
@@ -43,7 +45,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.True(Directory.Exists(Path.Combine(mirrorBase, "export", "attestations", kitVersion)));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DistributeToMirror_CreatesManifestOfflineJson()
{
var sourceKit = SetupSourceKit();
@@ -57,7 +60,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.True(File.Exists(result.ManifestPath));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DistributeToMirror_ManifestContainsAttestationEntry()
{
var sourceKit = SetupSourceKitWithAttestation();
@@ -79,7 +83,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.Contains("stella attest bundle verify", attestationEntry.GetProperty("cliExample").GetString());
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DistributeToMirror_CreatesManifestChecksum()
{
var sourceKit = SetupSourceKit();
@@ -92,7 +97,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.True(File.Exists(result.ManifestPath + ".sha256"));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DistributeToMirror_PreservesBytesExactly()
{
var sourceKit = SetupSourceKitWithAttestation();
@@ -110,7 +116,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.Equal(sourceBytes, targetBytes);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DistributeToMirror_ReturnsCorrectFileCount()
{
var sourceKit = SetupSourceKitWithMultipleFiles();
@@ -123,7 +130,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.True(result.CopiedFileCount >= 3); // At least 3 files
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DistributeToMirror_SourceNotFound_ReturnsFailed()
{
var mirrorBase = Path.Combine(_tempDir, "mirror");
@@ -135,7 +143,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.Contains("not found", result.ErrorMessage);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyDistribution_MatchingKits_ReturnsSuccess()
{
var sourceKit = SetupSourceKitWithAttestation();
@@ -151,7 +160,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.Empty(verifyResult.Mismatches);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyDistribution_MissingFile_ReportsError()
{
var sourceKit = SetupSourceKitWithAttestation();
@@ -168,7 +178,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.NotEmpty(result.Mismatches);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyDistribution_ModifiedFile_ReportsHashMismatch()
{
var sourceKit = SetupSourceKitWithAttestation();
@@ -188,7 +199,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.Contains(verifyResult.Mismatches, m => m.Contains("Hash mismatch"));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DistributeToMirror_ManifestHasCorrectVersion()
{
var sourceKit = SetupSourceKit();
@@ -204,7 +216,8 @@ public sealed class OfflineKitDistributorTests : IDisposable
Assert.Equal(kitVersion, manifest.GetProperty("kitVersion").GetString());
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DistributeToMirror_MirrorBundleEntry_HasCorrectPaths()
{
var sourceKit = SetupSourceKitWithMirror();

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using StellaOps.ExportCenter.Core.OfflineKit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class OfflineKitPackagerTests : IDisposable
@@ -30,7 +31,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddAttestationBundle_CreatesArtifactAndChecksum()
{
var request = CreateTestAttestationRequest();
@@ -42,7 +44,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.True(File.Exists(Path.Combine(_tempDir, result.ChecksumPath)));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddAttestationBundle_PreservesBytesExactly()
{
var originalBytes = Encoding.UTF8.GetBytes("test-attestation-bundle-content");
@@ -60,7 +63,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal(originalBytes, writtenBytes);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddAttestationBundle_ChecksumFileContainsCorrectFormat()
{
var request = CreateTestAttestationRequest();
@@ -73,7 +77,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Contains(" ", checksumContent); // Two spaces before filename
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddAttestationBundle_RejectsOverwrite()
{
var request = CreateTestAttestationRequest();
@@ -88,7 +93,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Contains("immutable", result2.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddMirrorBundle_CreatesArtifactAndChecksum()
{
var request = CreateTestMirrorRequest();
@@ -100,7 +106,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.True(File.Exists(Path.Combine(_tempDir, result.ChecksumPath)));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddBootstrapPack_CreatesArtifactAndChecksum()
{
var request = CreateTestBootstrapRequest();
@@ -112,7 +119,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.True(File.Exists(Path.Combine(_tempDir, result.ChecksumPath)));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddRiskBundle_CreatesArtifactAndChecksum()
{
var request = CreateTestRiskBundleRequest();
@@ -124,7 +132,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.True(File.Exists(Path.Combine(_tempDir, result.ChecksumPath)));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddRiskBundle_PreservesBytesExactly()
{
var originalBytes = Encoding.UTF8.GetBytes("test-risk-bundle-content");
@@ -147,7 +156,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal(originalBytes, writtenBytes);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddRiskBundle_RejectsOverwrite()
{
var request = CreateTestRiskBundleRequest();
@@ -162,7 +172,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Contains("immutable", result2.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateAttestationEntry_HasCorrectKind()
{
var request = CreateTestAttestationRequest();
@@ -172,7 +183,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal("attestation-export", entry.Kind);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateAttestationEntry_HasCorrectPaths()
{
var request = CreateTestAttestationRequest();
@@ -183,7 +195,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal("checksums/attestations/export-attestation-bundle-v1.tgz.sha256", entry.Checksum);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateAttestationEntry_FormatsRootHashWithPrefix()
{
var request = new OfflineKitAttestationRequest(
@@ -199,7 +212,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal("sha256:abc123def456", entry.RootHash);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateMirrorEntry_HasCorrectKind()
{
var request = CreateTestMirrorRequest();
@@ -209,7 +223,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal("mirror-bundle", entry.Kind);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateBootstrapEntry_HasCorrectKind()
{
var request = CreateTestBootstrapRequest();
@@ -219,7 +234,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal("bootstrap-pack", entry.Kind);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateRiskBundleEntry_HasCorrectKind()
{
var request = CreateTestRiskBundleRequest();
@@ -229,7 +245,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal("risk-bundle", entry.Kind);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateRiskBundleEntry_HasCorrectPaths()
{
var request = CreateTestRiskBundleRequest();
@@ -240,7 +257,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal("checksums/risk-bundles/export-risk-bundle-v1.tgz.sha256", entry.Checksum);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateRiskBundleEntry_IncludesProviderInfo()
{
var providers = new List<OfflineKitRiskProviderInfo>
@@ -267,7 +285,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.True(entry.Providers[1].Optional);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void WriteManifest_CreatesManifestFile()
{
var kitId = "kit-" + Guid.NewGuid().ToString("N");
@@ -281,7 +300,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.True(File.Exists(Path.Combine(_tempDir, "manifest.json")));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void WriteManifest_ContainsCorrectVersion()
{
var kitId = "kit-" + Guid.NewGuid().ToString("N");
@@ -295,7 +315,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal("offline-kit/v1", manifest.GetProperty("version").GetString());
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void WriteManifest_ContainsKitId()
{
var kitId = "test-kit-123";
@@ -309,7 +330,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal(kitId, manifest.GetProperty("kitId").GetString());
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void WriteManifest_RejectsOverwrite()
{
var kitId = "kit-001";
@@ -323,7 +345,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
_packager.WriteManifest(_tempDir, kitId, entries));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GenerateChecksumFileContent_HasCorrectFormat()
{
var content = OfflineKitPackager.GenerateChecksumFileContent("abc123def456", "test.tgz");
@@ -331,7 +354,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal("abc123def456 test.tgz", content);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyBundleHash_ReturnsTrueForMatchingHash()
{
var bundleBytes = Encoding.UTF8.GetBytes("test-content");
@@ -342,7 +366,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.True(result);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyBundleHash_ReturnsFalseForMismatchedHash()
{
var bundleBytes = Encoding.UTF8.GetBytes("test-content");
@@ -352,14 +377,16 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.False(result);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddAttestationBundle_ThrowsForNullRequest()
{
Assert.Throws<ArgumentNullException>(() =>
_packager.AddAttestationBundle(_tempDir, null!));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddAttestationBundle_ThrowsForEmptyOutputDirectory()
{
var request = CreateTestAttestationRequest();
@@ -368,7 +395,8 @@ public sealed class OfflineKitPackagerTests : IDisposable
_packager.AddAttestationBundle(string.Empty, request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DirectoryStructure_FollowsOfflineKitLayout()
{
var attestationRequest = CreateTestAttestationRequest();

View File

@@ -4,11 +4,13 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class OpenApiDiscoveryEndpointsTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveryResponse_ContainsRequiredFields()
{
var response = new WebService.OpenApiDiscoveryResponse
@@ -29,7 +31,8 @@ public sealed class OpenApiDiscoveryEndpointsTests
Assert.Equal("#/components/schemas/ErrorEnvelope", response.ErrorEnvelopeSchema);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveryResponse_SupportedProfilesCanBeNull()
{
var response = new WebService.OpenApiDiscoveryResponse
@@ -46,7 +49,8 @@ public sealed class OpenApiDiscoveryEndpointsTests
Assert.Null(response.ProfilesSupported);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveryResponse_SupportedProfiles_ContainsExpectedValues()
{
var profiles = new[] { "attestation", "mirror", "bootstrap", "airgap-evidence" };
@@ -68,7 +72,8 @@ public sealed class OpenApiDiscoveryEndpointsTests
Assert.Contains("airgap-evidence", response.ProfilesSupported);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveryResponse_SerializesToCamelCase()
{
var response = new WebService.OpenApiDiscoveryResponse
@@ -94,7 +99,8 @@ public sealed class OpenApiDiscoveryEndpointsTests
Assert.Contains("\"generatedAt\":", json);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveryResponse_JsonUrlIsOptional()
{
var response = new WebService.OpenApiDiscoveryResponse
@@ -111,7 +117,8 @@ public sealed class OpenApiDiscoveryEndpointsTests
Assert.Equal("/openapi/export-center.json", response.JsonUrl);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveryResponse_ChecksumSha256IsOptional()
{
var response = new WebService.OpenApiDiscoveryResponse
@@ -128,7 +135,8 @@ public sealed class OpenApiDiscoveryEndpointsTests
Assert.Equal("abc123", response.ChecksumSha256);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void MinimalSpec_ContainsOpenApi303Header()
{
// The minimal spec should be a valid OpenAPI 3.0.3 document
@@ -136,7 +144,8 @@ public sealed class OpenApiDiscoveryEndpointsTests
Assert.NotEmpty(minimalSpecCheck);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveryResponse_GeneratedAtIsDateTimeOffset()
{
var generatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
@@ -154,7 +163,8 @@ public sealed class OpenApiDiscoveryEndpointsTests
Assert.Equal(generatedAt, response.GeneratedAt);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveryResponse_CanSerializeToJsonWithNulls()
{
var response = new WebService.OpenApiDiscoveryResponse

View File

@@ -6,6 +6,8 @@ using StellaOps.Cryptography;
using StellaOps.ExportCenter.Core.PortableEvidence;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class PortableEvidenceExportBuilderTests : IDisposable
@@ -30,7 +32,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ProducesValidExport()
{
var portableBundlePath = CreateTestPortableBundle();
@@ -50,7 +53,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.True(result.ExportStream.Length > 0);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ExportDocumentContainsCorrectMetadata()
{
var exportId = Guid.NewGuid();
@@ -77,7 +81,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.NotEmpty(result.ExportDocument.RootHash);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ProducesDeterministicOutput()
{
var exportId = new Guid("11111111-2222-3333-4444-555555555555");
@@ -102,7 +107,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.Equal(bytes1, bytes2);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ArchiveContainsExpectedFiles()
{
var portableBundlePath = CreateTestPortableBundle();
@@ -122,7 +128,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.Contains("README.md", fileNames);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_TarEntriesHaveDeterministicMetadata()
{
var portableBundlePath = CreateTestPortableBundle();
@@ -147,7 +154,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
}
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_PortableBundleIsIncludedUnmodified()
{
var originalContent = "original-portable-bundle-content-bytes";
@@ -164,7 +172,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.Equal(originalContent, extractedContent);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ChecksumsContainsAllFiles()
{
var portableBundlePath = CreateTestPortableBundle();
@@ -181,7 +190,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.Contains("portable-bundle-v1.tgz", checksums);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ReadmeContainsBundleInfo()
{
var bundleId = Guid.NewGuid();
@@ -200,7 +210,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.Contains("stella evidence verify", readme);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_VerifyScriptIsPosixCompliant()
{
var portableBundlePath = CreateTestPortableBundle();
@@ -220,7 +231,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.DoesNotContain("wget", script);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_VerifyScriptHasExecutePermission()
{
var portableBundlePath = CreateTestPortableBundle();
@@ -238,7 +250,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.True(scriptEntry.Mode.HasFlag(UnixFileMode.UserExecute));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WithMetadata_IncludesInExportDocument()
{
var portableBundlePath = CreateTestPortableBundle();
@@ -262,7 +275,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.Equal("v3.0.0", result.ExportDocument.Metadata["scannerVersion"]);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForMissingPortableBundle()
{
var request = new PortableEvidenceExportRequest(
@@ -274,7 +288,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.Throws<FileNotFoundException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_ThrowsForEmptyBundleId()
{
var portableBundlePath = CreateTestPortableBundle();
@@ -287,7 +302,8 @@ public sealed class PortableEvidenceExportBuilderTests : IDisposable
Assert.Throws<ArgumentException>(() => _builder.Build(request));
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_VersionIsCorrect()
{
var portableBundlePath = CreateTestPortableBundle();

View File

@@ -1,11 +1,14 @@
using System.IO.Compression;
using StellaOps.ExportCenter.RiskBundles;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class RiskBundleBuilderTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WritesManifestAndFiles_Deterministically()
{
using var temp = new TempDir();
@@ -50,7 +53,8 @@ public sealed class RiskBundleBuilderTests
Assert.Contains("providers/cisa-kev/signature", entries);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_WhenMandatoryProviderMissing_Throws()
{
using var temp = new TempDir();

View File

@@ -5,11 +5,14 @@ using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.ExportCenter.RiskBundles;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public sealed class RiskBundleJobTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_StoresManifestAndBundle()
{
using var temp = new TempDir();

View File

@@ -2,11 +2,13 @@ using System.Text.Json;
using StellaOps.Cryptography;
using StellaOps.ExportCenter.RiskBundles;
using StellaOps.TestKit;
namespace StellaOps.ExportCenter.Tests;
public class RiskBundleSignerTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignAsync_ProducesDsseEnvelope()
{
var signer = new HmacRiskBundleManifestSigner(new FakeCryptoHmac(), "secret-key", "test-key");

View File

@@ -133,6 +133,7 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,499 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.ProofChain.MediaTypes;
using StellaOps.Attestor.ProofChain.Predicates.AI;
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
/// <summary>
/// Interface for discovering AI attestations from OCI registries.
/// Sprint: SPRINT_20251226_018_AI_attestations
/// Task: AIATTEST-17
/// </summary>
public interface IAIAttestationOciDiscovery
{
/// <summary>
/// Finds all AI attestations for an image.
/// </summary>
Task<IReadOnlyList<AIAttestationInfo>> FindAllAsync(
string registry, string repository, string imageDigest,
CancellationToken ct = default);
/// <summary>
/// Finds AI explanation attestations.
/// </summary>
Task<IReadOnlyList<AIAttestationInfo>> FindExplanationsAsync(
string registry, string repository, string imageDigest,
CancellationToken ct = default);
/// <summary>
/// Finds AI remediation plan attestations.
/// </summary>
Task<IReadOnlyList<AIAttestationInfo>> FindRemediationPlansAsync(
string registry, string repository, string imageDigest,
CancellationToken ct = default);
/// <summary>
/// Finds AI VEX draft attestations.
/// </summary>
Task<IReadOnlyList<AIAttestationInfo>> FindVexDraftsAsync(
string registry, string repository, string imageDigest,
CancellationToken ct = default);
/// <summary>
/// Finds AI policy draft attestations.
/// </summary>
Task<IReadOnlyList<AIAttestationInfo>> FindPolicyDraftsAsync(
string registry, string repository, string imageDigest,
CancellationToken ct = default);
/// <summary>
/// Downloads and parses an AI attestation predicate.
/// </summary>
Task<AIAttestationContent?> GetAttestationContentAsync(
string registry, string repository, string attestationDigest,
CancellationToken ct = default);
/// <summary>
/// Finds attestations by authority level.
/// </summary>
Task<IReadOnlyList<AIAttestationInfo>> FindByAuthorityAsync(
string registry, string repository, string imageDigest,
AIArtifactAuthority authority,
CancellationToken ct = default);
}
/// <summary>
/// Discovers AI-generated artifact attestations from OCI registries.
/// Sprint: SPRINT_20251226_018_AI_attestations
/// Task: AIATTEST-17
/// </summary>
public sealed class AIAttestationOciDiscovery : IAIAttestationOciDiscovery
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private readonly IOciReferrerDiscovery _referrerDiscovery;
private readonly ILogger<AIAttestationOciDiscovery> _logger;
public AIAttestationOciDiscovery(
IOciReferrerDiscovery referrerDiscovery,
ILogger<AIAttestationOciDiscovery> logger)
{
_referrerDiscovery = referrerDiscovery ?? throw new ArgumentNullException(nameof(referrerDiscovery));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyList<AIAttestationInfo>> FindAllAsync(
string registry, string repository, string imageDigest,
CancellationToken ct = default)
{
_logger.LogDebug("Finding all AI attestations for {Registry}/{Repository}@{Digest}",
registry, repository, imageDigest);
var allAttestations = new List<AIAttestationInfo>();
// Find all AI artifact types (both signed and unsigned)
var mediaTypes = new[]
{
AIArtifactMediaTypes.AIExplanation,
AIArtifactMediaTypes.AIRemediation,
AIArtifactMediaTypes.AIVexDraft,
AIArtifactMediaTypes.AIPolicyDraft,
// Also search for signed versions
"application/vnd.stellaops.ai.explanation.dsse+json",
"application/vnd.stellaops.ai.remediation.dsse+json",
"application/vnd.stellaops.ai.vexdraft.dsse+json",
"application/vnd.stellaops.ai.policydraft.dsse+json"
};
foreach (var mediaType in mediaTypes)
{
var result = await _referrerDiscovery.ListReferrersAsync(
registry, repository, imageDigest,
new ReferrerFilterOptions { ArtifactType = mediaType },
ct);
if (result.IsSuccess)
{
allAttestations.AddRange(result.Referrers.Select(r => ToAIAttestationInfo(r, mediaType)));
}
}
_logger.LogInformation("Found {Count} AI attestations for {Digest}",
allAttestations.Count, imageDigest);
return allAttestations;
}
public Task<IReadOnlyList<AIAttestationInfo>> FindExplanationsAsync(
string registry, string repository, string imageDigest,
CancellationToken ct = default)
=> FindByMediaTypesAsync(registry, repository, imageDigest,
AIArtifactMediaTypes.AIExplanation,
"application/vnd.stellaops.ai.explanation.dsse+json", ct);
public Task<IReadOnlyList<AIAttestationInfo>> FindRemediationPlansAsync(
string registry, string repository, string imageDigest,
CancellationToken ct = default)
=> FindByMediaTypesAsync(registry, repository, imageDigest,
AIArtifactMediaTypes.AIRemediation,
"application/vnd.stellaops.ai.remediation.dsse+json", ct);
public Task<IReadOnlyList<AIAttestationInfo>> FindVexDraftsAsync(
string registry, string repository, string imageDigest,
CancellationToken ct = default)
=> FindByMediaTypesAsync(registry, repository, imageDigest,
AIArtifactMediaTypes.AIVexDraft,
"application/vnd.stellaops.ai.vexdraft.dsse+json", ct);
public Task<IReadOnlyList<AIAttestationInfo>> FindPolicyDraftsAsync(
string registry, string repository, string imageDigest,
CancellationToken ct = default)
=> FindByMediaTypesAsync(registry, repository, imageDigest,
AIArtifactMediaTypes.AIPolicyDraft,
"application/vnd.stellaops.ai.policydraft.dsse+json", ct);
public async Task<AIAttestationContent?> GetAttestationContentAsync(
string registry, string repository, string attestationDigest,
CancellationToken ct = default)
{
_logger.LogDebug("Getting AI attestation content {Registry}/{Repository}@{Digest}",
registry, repository, attestationDigest);
try
{
// Get the manifest to find layers
var manifest = await _referrerDiscovery.GetReferrerManifestAsync(
registry, repository, attestationDigest, ct);
if (manifest is null || manifest.Layers.Count == 0)
{
_logger.LogWarning("No layers found in attestation {Digest}", attestationDigest);
return null;
}
// Download the first layer (attestation content)
var contentLayer = manifest.Layers[0];
var content = await _referrerDiscovery.GetLayerContentAsync(
registry, repository, contentLayer.Digest, ct);
if (content is null)
{
_logger.LogWarning("Failed to download attestation content {Digest}", contentLayer.Digest);
return null;
}
// Parse the content
var json = Encoding.UTF8.GetString(content);
var isSigned = contentLayer.MediaType?.Contains("dsse") == true ||
manifest.ArtifactType?.Contains("dsse") == true;
if (isSigned)
{
// Parse DSSE envelope and extract payload
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json, SerializerOptions);
if (envelope?.Payload is not null)
{
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload));
return ParseAttestationContent(payloadJson, manifest.ArtifactType, isSigned);
}
}
return ParseAttestationContent(json, manifest.ArtifactType, isSigned);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get attestation content {Digest}", attestationDigest);
return null;
}
}
public async Task<IReadOnlyList<AIAttestationInfo>> FindByAuthorityAsync(
string registry, string repository, string imageDigest,
AIArtifactAuthority authority,
CancellationToken ct = default)
{
var all = await FindAllAsync(registry, repository, imageDigest, ct);
return all
.Where(a => a.Authority == authority)
.ToList();
}
private async Task<IReadOnlyList<AIAttestationInfo>> FindByMediaTypesAsync(
string registry, string repository, string imageDigest,
string unsignedMediaType, string signedMediaType,
CancellationToken ct)
{
var attestations = new List<AIAttestationInfo>();
var unsignedResult = await _referrerDiscovery.ListReferrersAsync(
registry, repository, imageDigest,
new ReferrerFilterOptions { ArtifactType = unsignedMediaType },
ct);
if (unsignedResult.IsSuccess)
{
attestations.AddRange(unsignedResult.Referrers.Select(r =>
ToAIAttestationInfo(r, unsignedMediaType)));
}
var signedResult = await _referrerDiscovery.ListReferrersAsync(
registry, repository, imageDigest,
new ReferrerFilterOptions { ArtifactType = signedMediaType },
ct);
if (signedResult.IsSuccess)
{
attestations.AddRange(signedResult.Referrers.Select(r =>
ToAIAttestationInfo(r, signedMediaType)));
}
return attestations;
}
private static AIAttestationInfo ToAIAttestationInfo(ReferrerInfo referrer, string mediaType)
{
var artifactType = GetArtifactTypeFromMediaType(mediaType);
var isSigned = mediaType.Contains("dsse");
// Extract authority from annotations if available
AIArtifactAuthority? authority = null;
if (referrer.Annotations.TryGetValue(AIArtifactMediaTypes.AuthorityAnnotation, out var authorityStr))
{
if (Enum.TryParse<AIArtifactAuthority>(authorityStr, true, out var parsed))
{
authority = parsed;
}
}
// Extract model ID from annotations if available
string? modelId = null;
if (referrer.Annotations.TryGetValue(AIArtifactMediaTypes.ModelIdAnnotation, out var modelIdStr))
{
modelId = modelIdStr;
}
// Extract artifact ID from annotations if available
string? artifactId = null;
if (referrer.Annotations.TryGetValue("org.stellaops.ai.artifact-id", out var artifactIdStr))
{
artifactId = artifactIdStr;
}
return new AIAttestationInfo
{
Digest = referrer.Digest,
ArtifactType = artifactType,
MediaType = mediaType,
Size = referrer.Size,
IsSigned = isSigned,
Authority = authority,
ModelId = modelId,
ArtifactId = artifactId,
Annotations = referrer.Annotations
};
}
private static AIArtifactType GetArtifactTypeFromMediaType(string mediaType)
{
return mediaType switch
{
var t when t.Contains("explanation") => AIArtifactType.Explanation,
var t when t.Contains("remediation") => AIArtifactType.RemediationPlan,
var t when t.Contains("vexdraft") => AIArtifactType.VexDraft,
var t when t.Contains("policydraft") => AIArtifactType.PolicyDraft,
_ => AIArtifactType.Unknown
};
}
private static AIAttestationContent? ParseAttestationContent(
string json, string? artifactType, bool isSigned)
{
try
{
var statement = JsonSerializer.Deserialize<InTotoStatementEnvelope>(json, SerializerOptions);
if (statement?.Predicate is null)
return null;
var predicateJson = statement.Predicate.GetRawText();
var artifactTypeEnum = GetArtifactTypeFromMediaType(artifactType ?? string.Empty);
return new AIAttestationContent
{
StatementJson = json,
PredicateJson = predicateJson,
PredicateType = statement.PredicateType,
ArtifactType = artifactTypeEnum,
IsSigned = isSigned,
Subject = statement.Subject?.FirstOrDefault()
};
}
catch
{
return null;
}
}
}
/// <summary>
/// Information about a discovered AI attestation.
/// </summary>
public sealed record AIAttestationInfo
{
/// <summary>
/// OCI digest of the attestation.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Type of AI artifact.
/// </summary>
public required AIArtifactType ArtifactType { get; init; }
/// <summary>
/// OCI media type.
/// </summary>
public required string MediaType { get; init; }
/// <summary>
/// Size in bytes.
/// </summary>
public long Size { get; init; }
/// <summary>
/// Whether the attestation is signed (DSSE).
/// </summary>
public bool IsSigned { get; init; }
/// <summary>
/// Authority level if available from annotations.
/// </summary>
public AIArtifactAuthority? Authority { get; init; }
/// <summary>
/// Model ID if available from annotations.
/// </summary>
public string? ModelId { get; init; }
/// <summary>
/// Artifact ID if available from annotations.
/// </summary>
public string? ArtifactId { get; init; }
/// <summary>
/// All annotations from the manifest.
/// </summary>
public IReadOnlyDictionary<string, string> Annotations { get; init; } = new Dictionary<string, string>();
}
/// <summary>
/// Type of AI artifact.
/// </summary>
public enum AIArtifactType
{
Unknown,
Explanation,
RemediationPlan,
VexDraft,
PolicyDraft
}
/// <summary>
/// Parsed content of an AI attestation.
/// </summary>
public sealed record AIAttestationContent
{
/// <summary>
/// Full in-toto statement JSON.
/// </summary>
public required string StatementJson { get; init; }
/// <summary>
/// Predicate JSON only.
/// </summary>
public required string PredicateJson { get; init; }
/// <summary>
/// Predicate type URI.
/// </summary>
public string? PredicateType { get; init; }
/// <summary>
/// Artifact type.
/// </summary>
public AIArtifactType ArtifactType { get; init; }
/// <summary>
/// Whether the attestation was signed.
/// </summary>
public bool IsSigned { get; init; }
/// <summary>
/// Subject information.
/// </summary>
public InTotoSubjectEnvelope? Subject { get; init; }
}
/// <summary>
/// DSSE envelope for parsing signed attestations.
/// </summary>
internal sealed record DsseEnvelope
{
[JsonPropertyName("payload")]
public string? Payload { get; init; }
[JsonPropertyName("payloadType")]
public string? PayloadType { get; init; }
[JsonPropertyName("signatures")]
public IReadOnlyList<DsseSignature>? Signatures { get; init; }
}
/// <summary>
/// DSSE signature.
/// </summary>
internal sealed record DsseSignature
{
[JsonPropertyName("keyid")]
public string? KeyId { get; init; }
[JsonPropertyName("sig")]
public string? Sig { get; init; }
}
/// <summary>
/// In-toto statement envelope for parsing.
/// </summary>
internal sealed record InTotoStatementEnvelope
{
[JsonPropertyName("_type")]
public string? Type { get; init; }
[JsonPropertyName("subject")]
public IReadOnlyList<InTotoSubjectEnvelope>? Subject { get; init; }
[JsonPropertyName("predicateType")]
public string? PredicateType { get; init; }
[JsonPropertyName("predicate")]
public JsonElement? Predicate { get; init; }
}
/// <summary>
/// In-toto subject envelope for parsing.
/// </summary>
public sealed record InTotoSubjectEnvelope
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("digest")]
public IReadOnlyDictionary<string, string>? Digest { get; init; }
}

View File

@@ -0,0 +1,430 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.ProofChain.MediaTypes;
using StellaOps.Attestor.ProofChain.Predicates.AI;
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
/// <summary>
/// Interface for publishing AI attestations to OCI registries.
/// Sprint: SPRINT_20251226_018_AI_attestations
/// Task: AIATTEST-16
/// </summary>
public interface IAIAttestationOciPublisher
{
/// <summary>
/// Publishes an AI explanation attestation.
/// </summary>
Task<AIAttestationPublishResult> PublishExplanationAsync(
AIExplanationPredicate predicate,
AIAttestationPublishOptions options,
CancellationToken ct = default);
/// <summary>
/// Publishes an AI remediation plan attestation.
/// </summary>
Task<AIAttestationPublishResult> PublishRemediationPlanAsync(
AIRemediationPlanPredicate predicate,
AIAttestationPublishOptions options,
CancellationToken ct = default);
/// <summary>
/// Publishes an AI VEX draft attestation.
/// </summary>
Task<AIAttestationPublishResult> PublishVexDraftAsync(
AIVexDraftPredicate predicate,
AIAttestationPublishOptions options,
CancellationToken ct = default);
/// <summary>
/// Publishes an AI policy draft attestation.
/// </summary>
Task<AIAttestationPublishResult> PublishPolicyDraftAsync(
AIPolicyDraftPredicate predicate,
AIAttestationPublishOptions options,
CancellationToken ct = default);
/// <summary>
/// Publishes a generic AI artifact predicate.
/// </summary>
Task<AIAttestationPublishResult> PublishAsync(
AIArtifactBasePredicate predicate,
string mediaType,
AIAttestationPublishOptions options,
CancellationToken ct = default);
}
/// <summary>
/// Publishes AI-generated artifact attestations to OCI registries as referrer artifacts.
/// Sprint: SPRINT_20251226_018_AI_attestations
/// Task: AIATTEST-16
/// </summary>
public sealed class AIAttestationOciPublisher : IAIAttestationOciPublisher
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly IOciReferrerFallback _fallback;
private readonly IAIAttestationSigner? _signer;
private readonly ILogger<AIAttestationOciPublisher> _logger;
public AIAttestationOciPublisher(
IOciReferrerFallback fallback,
IAIAttestationSigner? signer,
ILogger<AIAttestationOciPublisher> logger)
{
_fallback = fallback ?? throw new ArgumentNullException(nameof(fallback));
_signer = signer;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<AIAttestationPublishResult> PublishExplanationAsync(
AIExplanationPredicate predicate,
AIAttestationPublishOptions options,
CancellationToken ct = default)
=> PublishAsync(predicate, AIArtifactMediaTypes.AIExplanation, options, ct);
public Task<AIAttestationPublishResult> PublishRemediationPlanAsync(
AIRemediationPlanPredicate predicate,
AIAttestationPublishOptions options,
CancellationToken ct = default)
=> PublishAsync(predicate, AIArtifactMediaTypes.AIRemediation, options, ct);
public Task<AIAttestationPublishResult> PublishVexDraftAsync(
AIVexDraftPredicate predicate,
AIAttestationPublishOptions options,
CancellationToken ct = default)
=> PublishAsync(predicate, AIArtifactMediaTypes.AIVexDraft, options, ct);
public Task<AIAttestationPublishResult> PublishPolicyDraftAsync(
AIPolicyDraftPredicate predicate,
AIAttestationPublishOptions options,
CancellationToken ct = default)
=> PublishAsync(predicate, AIArtifactMediaTypes.AIPolicyDraft, options, ct);
public async Task<AIAttestationPublishResult> PublishAsync(
AIArtifactBasePredicate predicate,
string mediaType,
AIAttestationPublishOptions options,
CancellationToken ct = default)
{
_logger.LogInformation(
"Publishing AI attestation {ArtifactId} ({Type}) to {Registry}/{Repository}",
predicate.ArtifactId, mediaType, options.Registry, options.Repository);
try
{
// Create in-toto statement wrapping the predicate
var statement = CreateInTotoStatement(predicate, mediaType, options);
var statementJson = JsonSerializer.Serialize(statement, SerializerOptions);
// Determine content and artifact type
byte[] content;
string artifactType;
string contentMediaType;
if (options.SignAttestation && _signer is not null)
{
// Sign the statement and wrap in DSSE envelope
var envelope = await _signer.SignAsync(statementJson, ct);
content = Encoding.UTF8.GetBytes(envelope);
artifactType = GetSignedArtifactType(mediaType);
contentMediaType = OciMediaTypes.DsseEnvelope;
}
else
{
// Push unsigned statement
content = Encoding.UTF8.GetBytes(statementJson);
artifactType = mediaType;
contentMediaType = OciMediaTypes.InTotoStatement;
}
// Prepare push request
var request = new ReferrerPushRequest
{
Registry = options.Registry,
Repository = options.Repository,
Content = content,
ContentMediaType = contentMediaType,
ArtifactType = artifactType,
SubjectDigest = options.SubjectDigest,
LayerAnnotations = CreateLayerAnnotations(predicate, mediaType),
ManifestAnnotations = CreateManifestAnnotations(predicate, options)
};
// Push with fallback support for older registries
var result = await _fallback.PushWithFallbackAsync(request,
new FallbackOptions { CreateFallbackTag = options.CreateFallbackTag },
ct);
if (!result.IsSuccess)
{
return new AIAttestationPublishResult
{
IsSuccess = false,
Error = result.Error
};
}
_logger.LogInformation(
"Published AI attestation {ArtifactId} as {Digest}",
predicate.ArtifactId, result.Digest);
return new AIAttestationPublishResult
{
IsSuccess = true,
ArtifactId = predicate.ArtifactId,
ArtifactDigest = result.Digest,
Registry = options.Registry,
Repository = options.Repository,
ReferrerUri = result.ReferrerUri,
IsSigned = options.SignAttestation && _signer is not null,
Authority = predicate.Authority,
MediaType = mediaType
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to publish AI attestation {ArtifactId}",
predicate.ArtifactId);
return new AIAttestationPublishResult
{
IsSuccess = false,
Error = ex.Message
};
}
}
private static InTotoStatement CreateInTotoStatement(
AIArtifactBasePredicate predicate,
string mediaType,
AIAttestationPublishOptions options)
{
var predicateType = AIArtifactMediaTypes.GetPredicateTypeForMediaType(mediaType)
?? $"ai-artifact.stella/v1";
return new InTotoStatement
{
Type = "https://in-toto.io/Statement/v1",
Subject = new[]
{
new InTotoSubject
{
Name = options.SubjectName ?? options.Repository,
Digest = new Dictionary<string, string>
{
["sha256"] = options.SubjectDigest?.Replace("sha256:", "") ?? predicate.ArtifactId.Replace("sha256:", "")
}
}
},
PredicateType = predicateType,
Predicate = predicate
};
}
private static Dictionary<string, string> CreateLayerAnnotations(
AIArtifactBasePredicate predicate,
string mediaType)
{
return new Dictionary<string, string>
{
[AIArtifactMediaTypes.ArtifactTypeAnnotation] = mediaType,
[AIArtifactMediaTypes.AuthorityAnnotation] = predicate.Authority.ToString().ToLowerInvariant(),
[AIArtifactMediaTypes.ModelIdAnnotation] = predicate.ModelId.ToString(),
[AIArtifactMediaTypes.ReplayableAnnotation] = "true",
["org.opencontainers.image.created"] = predicate.GeneratedAt
};
}
private static Dictionary<string, string> CreateManifestAnnotations(
AIArtifactBasePredicate predicate,
AIAttestationPublishOptions options)
{
var annotations = new Dictionary<string, string>
{
["org.stellaops.ai.artifact-id"] = predicate.ArtifactId,
["org.stellaops.ai.prompt-template"] = predicate.PromptTemplateVersion,
["org.stellaops.ai.output-hash"] = predicate.OutputHash,
["org.opencontainers.image.created"] = predicate.GeneratedAt
};
if (!string.IsNullOrEmpty(options.CustomAnnotationPrefix))
{
foreach (var (key, value) in options.CustomAnnotations ?? new Dictionary<string, string>())
{
annotations[$"{options.CustomAnnotationPrefix}.{key}"] = value;
}
}
return annotations;
}
private static string GetSignedArtifactType(string mediaType) => mediaType switch
{
AIArtifactMediaTypes.AIExplanation => "application/vnd.stellaops.ai.explanation.dsse+json",
AIArtifactMediaTypes.AIRemediation => "application/vnd.stellaops.ai.remediation.dsse+json",
AIArtifactMediaTypes.AIVexDraft => "application/vnd.stellaops.ai.vexdraft.dsse+json",
AIArtifactMediaTypes.AIPolicyDraft => "application/vnd.stellaops.ai.policydraft.dsse+json",
_ => $"{mediaType}.dsse"
};
}
/// <summary>
/// Options for publishing AI attestations to OCI.
/// </summary>
public sealed record AIAttestationPublishOptions
{
/// <summary>
/// Target registry hostname.
/// </summary>
public required string Registry { get; init; }
/// <summary>
/// Target repository name.
/// </summary>
public required string Repository { get; init; }
/// <summary>
/// Digest of the subject image this attestation references.
/// </summary>
public string? SubjectDigest { get; init; }
/// <summary>
/// Name of the subject (defaults to repository).
/// </summary>
public string? SubjectName { get; init; }
/// <summary>
/// Whether to sign the attestation with DSSE.
/// </summary>
public bool SignAttestation { get; init; } = true;
/// <summary>
/// Create fallback tag for registries without referrers API.
/// </summary>
public bool CreateFallbackTag { get; init; } = true;
/// <summary>
/// Custom annotation prefix.
/// </summary>
public string? CustomAnnotationPrefix { get; init; }
/// <summary>
/// Custom annotations to add.
/// </summary>
public IReadOnlyDictionary<string, string>? CustomAnnotations { get; init; }
}
/// <summary>
/// Result of publishing an AI attestation.
/// </summary>
public sealed record AIAttestationPublishResult
{
/// <summary>
/// Whether the publish was successful.
/// </summary>
public required bool IsSuccess { get; init; }
/// <summary>
/// AI artifact ID.
/// </summary>
public string? ArtifactId { get; init; }
/// <summary>
/// OCI digest of the pushed artifact.
/// </summary>
public string? ArtifactDigest { get; init; }
/// <summary>
/// Registry the artifact was pushed to.
/// </summary>
public string? Registry { get; init; }
/// <summary>
/// Repository the artifact was pushed to.
/// </summary>
public string? Repository { get; init; }
/// <summary>
/// Full URI for the pushed referrer.
/// </summary>
public string? ReferrerUri { get; init; }
/// <summary>
/// Whether the attestation was signed.
/// </summary>
public bool IsSigned { get; init; }
/// <summary>
/// Authority level of the attestation.
/// </summary>
public AIArtifactAuthority? Authority { get; init; }
/// <summary>
/// Media type of the attestation.
/// </summary>
public string? MediaType { get; init; }
/// <summary>
/// Error message if publish failed.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Interface for signing AI attestations with DSSE.
/// </summary>
public interface IAIAttestationSigner
{
/// <summary>
/// Signs a statement and returns the DSSE envelope JSON.
/// </summary>
Task<string> SignAsync(string statementJson, CancellationToken ct = default);
}
/// <summary>
/// In-toto statement wrapper for AI predicates.
/// </summary>
internal sealed record InTotoStatement
{
[JsonPropertyName("_type")]
public required string Type { get; init; }
[JsonPropertyName("subject")]
public required IReadOnlyList<InTotoSubject> Subject { get; init; }
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
[JsonPropertyName("predicate")]
public required object Predicate { get; init; }
}
/// <summary>
/// In-toto subject descriptor.
/// </summary>
internal sealed record InTotoSubject
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("digest")]
public required IReadOnlyDictionary<string, string> Digest { get; init; }
}
/// <summary>
/// OCI media type constants.
/// </summary>
internal static class OciMediaTypes
{
public const string DsseEnvelope = "application/vnd.dsse.envelope.v1+json";
public const string InTotoStatement = "application/vnd.in-toto+json";
public const string ImageManifest = "application/vnd.oci.image.manifest.v1+json";
}

View File

@@ -24,5 +24,6 @@
<ProjectReference Include="..\..\..\Policy\__Libraries\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="..\..\..\Policy\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
</ItemGroup>
</Project>