Refactor code structure and optimize performance across multiple modules
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Client\StellaOps.ExportCenter.Client.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\"}");
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user