stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
25
src/__Libraries/StellaOps.Artifact.Core.Tests/AGENTS.md
Normal file
25
src/__Libraries/StellaOps.Artifact.Core.Tests/AGENTS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Artifact Core Tests Charter
|
||||
|
||||
## Mission
|
||||
Own test coverage for artifact core and infrastructure behaviors. Keep tests deterministic and offline-friendly.
|
||||
|
||||
## Responsibilities
|
||||
- Maintain `StellaOps.Artifact.Core.Tests` coverage.
|
||||
- Validate store, index, and bom-ref encoding behavior with stable fixtures.
|
||||
- Record remediation status updates in the active sprint tracker.
|
||||
|
||||
## Key Paths
|
||||
- `ArtifactStore*Tests*.cs`
|
||||
- `CycloneDxExtractor*Tests*.cs`
|
||||
- `S3UnifiedArtifactStore*Tests*.cs`
|
||||
|
||||
## Required Reading
|
||||
- `docs/operations/artifact-migration-runbook.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/technical/testing/TEST_SUITE_OVERVIEW.md`
|
||||
- `docs/code-of-conduct/TESTING_PRACTICES.md`
|
||||
|
||||
## Working Agreement
|
||||
- Use fixed IDs/timestamps and stable ordering in fixtures.
|
||||
- Keep tests offline; no runtime network calls.
|
||||
- Update sprint status when work starts or finishes.
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.Artifact.Core;
|
||||
using StellaOps.Artifact.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ArtifactIndexRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InMemoryIndex_IndexAndFind_SucceedsAsync()
|
||||
{
|
||||
var repo = new InMemoryArtifactIndexRepository(ArtifactTestFixtures.TimeProvider);
|
||||
var entry = CreateEntry(1, "artifact-001", "abc123");
|
||||
|
||||
await repo.IndexAsync(entry);
|
||||
|
||||
var found = await repo.FindByBomRefAsync(entry.BomRef);
|
||||
Assert.Single(found);
|
||||
Assert.Equal(entry.ArtifactId, found[0].ArtifactId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryIndex_Remove_SoftDeletesAsync()
|
||||
{
|
||||
var repo = new InMemoryArtifactIndexRepository(ArtifactTestFixtures.TimeProvider);
|
||||
var entry = CreateEntry(2, "artifact-001", "abc123");
|
||||
|
||||
await repo.IndexAsync(entry);
|
||||
await repo.RemoveAsync(entry.BomRef, entry.SerialNumber, entry.ArtifactId);
|
||||
|
||||
var found = await repo.FindByBomRefAsync(entry.BomRef);
|
||||
Assert.Empty(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryIndex_FindBySha256_ReturnsMatchesAsync()
|
||||
{
|
||||
var repo = new InMemoryArtifactIndexRepository(ArtifactTestFixtures.TimeProvider);
|
||||
const string sha256 = "abc123def456";
|
||||
|
||||
await repo.IndexAsync(CreateEntry(3, "artifact-1", sha256));
|
||||
await repo.IndexAsync(CreateEntry(4, "artifact-2", sha256));
|
||||
|
||||
var found = await repo.FindBySha256Async(sha256);
|
||||
Assert.Equal(2, found.Count);
|
||||
}
|
||||
|
||||
private static ArtifactIndexEntry CreateEntry(int id, string artifactId, string sha256)
|
||||
{
|
||||
return new ArtifactIndexEntry
|
||||
{
|
||||
Id = CreateGuid(id),
|
||||
TenantId = ArtifactTestFixtures.TenantId,
|
||||
BomRef = ArtifactTestFixtures.DefaultBomRef,
|
||||
SerialNumber = ArtifactTestFixtures.DefaultSerialNumber,
|
||||
ArtifactId = artifactId,
|
||||
StorageKey = $"artifacts/{artifactId}.json",
|
||||
Type = ArtifactType.Sbom,
|
||||
ContentType = "application/json",
|
||||
Sha256 = sha256,
|
||||
SizeBytes = 1024,
|
||||
CreatedAt = ArtifactTestFixtures.FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid CreateGuid(int value) => Guid.Parse($"00000000-0000-0000-0000-{value:D12}");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
public sealed partial class ArtifactStorePerformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ListByBomRef_1000Artifacts_Under100msAsync()
|
||||
{
|
||||
const int artifactCount = 1000;
|
||||
const int maxDurationMs = 100;
|
||||
|
||||
var store = CreateStore();
|
||||
var bomRef = "pkg:docker/perf-test/app@sha256:abc123def456";
|
||||
var artifacts = GenerateTestArtifacts(artifactCount, ArtifactTestFixtures.TenantId, bomRef);
|
||||
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
await store.StoreAsync(artifact);
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var list = await store.ListAsync(bomRef);
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal(artifactCount, list.Count);
|
||||
Assert.True(sw.ElapsedMilliseconds < maxDurationMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParallelStore_1000Artifacts_UnderThresholdAsync()
|
||||
{
|
||||
const int artifactCount = 1000;
|
||||
const int maxDurationMs = 10000;
|
||||
|
||||
var store = CreateStore();
|
||||
var bomRef = "pkg:docker/perf-parallel/app@sha256:abc123";
|
||||
var artifacts = GenerateTestArtifacts(artifactCount, ArtifactTestFixtures.TenantId, bomRef);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Parallel.ForEachAsync(artifacts, new ParallelOptions { MaxDegreeOfParallelism = 10 },
|
||||
async (artifact, ct) =>
|
||||
{
|
||||
await store.StoreAsync(artifact, ct);
|
||||
});
|
||||
sw.Stop();
|
||||
|
||||
var stored = await store.ListAsync(bomRef);
|
||||
Assert.Equal(artifactCount, stored.Count);
|
||||
Assert.True(sw.ElapsedMilliseconds < maxDurationMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Diagnostics;
|
||||
using StellaOps.Artifact.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
public sealed partial class ArtifactStorePerformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MixedOperations_CompletesSuccessfullyAsync()
|
||||
{
|
||||
const int operationCount = 1000;
|
||||
var store = CreateStore();
|
||||
var bomRef = "pkg:docker/mixed-test/app@sha256:abc123";
|
||||
|
||||
var preloadArtifacts = GenerateTestArtifacts(500, ArtifactTestFixtures.TenantId, bomRef);
|
||||
foreach (var artifact in preloadArtifacts)
|
||||
{
|
||||
await store.StoreAsync(artifact);
|
||||
}
|
||||
|
||||
var random = new Random(42);
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
for (var i = 0; i < operationCount; i++)
|
||||
{
|
||||
var op = random.Next(4);
|
||||
switch (op)
|
||||
{
|
||||
case 0:
|
||||
using (var stream = new MemoryStream(new byte[] { (byte)(i % 256) }))
|
||||
{
|
||||
await store.StoreAsync(new ArtifactStoreRequest
|
||||
{
|
||||
BomRef = bomRef,
|
||||
SerialNumber = $"urn:uuid:{CreateGuid(10000 + i)}",
|
||||
ArtifactId = $"mixed-artifact-{i}",
|
||||
Content = stream,
|
||||
ContentType = "application/json",
|
||||
Type = ArtifactType.Sbom,
|
||||
TenantId = ArtifactTestFixtures.TenantId
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
var idx = random.Next(preloadArtifacts.Count);
|
||||
await store.ReadAsync(bomRef, preloadArtifacts[idx].SerialNumber, preloadArtifacts[idx].ArtifactId);
|
||||
break;
|
||||
case 2:
|
||||
await store.ListAsync(bomRef);
|
||||
break;
|
||||
case 3:
|
||||
var checkIdx = random.Next(preloadArtifacts.Count);
|
||||
await store.ExistsAsync(bomRef, preloadArtifacts[checkIdx].SerialNumber, preloadArtifacts[checkIdx].ArtifactId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
public sealed partial class ArtifactStorePerformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Store1000Artifacts_CompletesUnderThresholdAsync()
|
||||
{
|
||||
const int artifactCount = 1000;
|
||||
const int maxDurationMs = 30000;
|
||||
|
||||
var store = CreateStore();
|
||||
var bomRef = "pkg:docker/perf-test/app@sha256:abc123def456";
|
||||
var artifacts = GenerateTestArtifacts(artifactCount, ArtifactTestFixtures.TenantId, bomRef);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
await store.StoreAsync(artifact);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < maxDurationMs,
|
||||
$"Store operation took {sw.ElapsedMilliseconds}ms, expected under {maxDurationMs}ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retrieve1000Artifacts_CompletesUnderThresholdAsync()
|
||||
{
|
||||
const int artifactCount = 1000;
|
||||
const int maxDurationMs = 10000;
|
||||
|
||||
var store = CreateStore();
|
||||
var bomRef = "pkg:docker/perf-test/app@sha256:abc123def456";
|
||||
var artifacts = GenerateTestArtifacts(artifactCount, ArtifactTestFixtures.TenantId, bomRef);
|
||||
var stored = new List<(string BomRef, string Serial, string Id)>();
|
||||
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
await store.StoreAsync(artifact);
|
||||
stored.Add((artifact.BomRef, artifact.SerialNumber, artifact.ArtifactId));
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
foreach (var (bRef, serial, id) in stored)
|
||||
{
|
||||
var result = await store.ReadAsync(bRef, serial, id);
|
||||
Assert.True(result.Found);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < maxDurationMs,
|
||||
$"Retrieve operation took {sw.ElapsedMilliseconds}ms, expected under {maxDurationMs}ms");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactStorePerformanceTests.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-002 - Performance test: 1000 artifacts store/retrieve
|
||||
// Description: Performance benchmarks for artifact store operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using StellaOps.Artifact.Core;
|
||||
using StellaOps.Artifact.Infrastructure;
|
||||
using Xunit;
|
||||
@@ -13,230 +6,30 @@ using Xunit;
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
[Trait("Category", "Performance")]
|
||||
public sealed class ArtifactStorePerformanceTests
|
||||
public sealed partial class ArtifactStorePerformanceTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public ArtifactStorePerformanceTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store1000Artifacts_CompletesUnderThreshold()
|
||||
{
|
||||
// Arrange
|
||||
const int artifactCount = 1000;
|
||||
const int maxDurationMs = 30000; // 30 seconds for 1000 artifacts (30ms each avg)
|
||||
|
||||
var store = new InMemoryArtifactStore();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var bomRef = "pkg:docker/perf-test/app@sha256:abc123def456";
|
||||
var artifacts = GenerateTestArtifacts(artifactCount, tenantId, bomRef);
|
||||
|
||||
// Act
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
await store.StoreAsync(artifact);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
_output.WriteLine($"Stored {artifactCount} artifacts in {sw.ElapsedMilliseconds}ms");
|
||||
_output.WriteLine($"Average: {sw.ElapsedMilliseconds / (double)artifactCount:F2}ms per artifact");
|
||||
_output.WriteLine($"Throughput: {artifactCount / sw.Elapsed.TotalSeconds:F2} artifacts/second");
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < maxDurationMs,
|
||||
$"Store operation took {sw.ElapsedMilliseconds}ms, expected under {maxDurationMs}ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retrieve1000Artifacts_CompletesUnderThreshold()
|
||||
{
|
||||
// Arrange
|
||||
const int artifactCount = 1000;
|
||||
const int maxDurationMs = 10000; // 10 seconds for 1000 reads (10ms each avg)
|
||||
|
||||
var store = new InMemoryArtifactStore();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var bomRef = "pkg:docker/perf-test/app@sha256:abc123def456";
|
||||
var artifacts = GenerateTestArtifacts(artifactCount, tenantId, bomRef);
|
||||
|
||||
// Store all artifacts first
|
||||
var storedArtifacts = new List<(string bomRef, string serial, string id)>();
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
await store.StoreAsync(artifact);
|
||||
storedArtifacts.Add((artifact.BomRef, artifact.SerialNumber!, artifact.ArtifactId));
|
||||
}
|
||||
|
||||
// Act - Read them all back
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
foreach (var (bRef, serial, id) in storedArtifacts)
|
||||
{
|
||||
var result = await store.ReadAsync(bRef, serial, id);
|
||||
Assert.True(result.Found);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
_output.WriteLine($"Retrieved {artifactCount} artifacts in {sw.ElapsedMilliseconds}ms");
|
||||
_output.WriteLine($"Average: {sw.ElapsedMilliseconds / (double)artifactCount:F2}ms per artifact");
|
||||
_output.WriteLine($"Throughput: {artifactCount / sw.Elapsed.TotalSeconds:F2} artifacts/second");
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < maxDurationMs,
|
||||
$"Retrieve operation took {sw.ElapsedMilliseconds}ms, expected under {maxDurationMs}ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByBomRef_1000Artifacts_Under100ms()
|
||||
{
|
||||
// Arrange
|
||||
const int artifactCount = 1000;
|
||||
const int maxDurationMs = 100; // 100ms as per completion criteria
|
||||
|
||||
var store = new InMemoryArtifactStore();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var bomRef = "pkg:docker/perf-test/app@sha256:abc123def456";
|
||||
var artifacts = GenerateTestArtifacts(artifactCount, tenantId, bomRef);
|
||||
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
await store.StoreAsync(artifact);
|
||||
}
|
||||
|
||||
// Act
|
||||
var sw = Stopwatch.StartNew();
|
||||
var results = await store.ListAsync(bomRef);
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
_output.WriteLine($"Listed {results.Count} artifacts in {sw.ElapsedMilliseconds}ms");
|
||||
|
||||
Assert.Equal(artifactCount, results.Count);
|
||||
Assert.True(sw.ElapsedMilliseconds < maxDurationMs,
|
||||
$"List operation took {sw.ElapsedMilliseconds}ms, expected under {maxDurationMs}ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParallelStore_1000Artifacts_HandlesContention()
|
||||
{
|
||||
// Arrange
|
||||
const int artifactCount = 1000;
|
||||
const int maxDurationMs = 60000; // 60 seconds with contention
|
||||
|
||||
var store = new InMemoryArtifactStore();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var bomRef = "pkg:docker/perf-test/app@sha256:abc123def456";
|
||||
var artifacts = GenerateTestArtifacts(artifactCount, tenantId, bomRef);
|
||||
|
||||
// Act - Store in parallel
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
await Parallel.ForEachAsync(
|
||||
artifacts,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 10 },
|
||||
async (artifact, ct) =>
|
||||
{
|
||||
await store.StoreAsync(artifact, ct);
|
||||
});
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
_output.WriteLine($"Parallel stored {artifactCount} artifacts in {sw.ElapsedMilliseconds}ms");
|
||||
_output.WriteLine($"Parallelism: 10, Throughput: {artifactCount / sw.Elapsed.TotalSeconds:F2} artifacts/second");
|
||||
|
||||
var stored = await store.ListAsync(bomRef);
|
||||
Assert.Equal(artifactCount, stored.Count);
|
||||
Assert.True(sw.ElapsedMilliseconds < maxDurationMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MixedOperations_CompletesSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
const int operationCount = 1000;
|
||||
var store = new InMemoryArtifactStore();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var bomRef = "pkg:docker/mixed-test/app@sha256:abc123";
|
||||
|
||||
// Pre-populate with 500 artifacts
|
||||
var preloadArtifacts = GenerateTestArtifacts(500, tenantId, bomRef);
|
||||
foreach (var artifact in preloadArtifacts)
|
||||
{
|
||||
await store.StoreAsync(artifact);
|
||||
}
|
||||
|
||||
var random = new Random(42); // Deterministic seed for reproducibility
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Act - Mix of operations
|
||||
for (var i = 0; i < operationCount; i++)
|
||||
{
|
||||
var op = random.Next(4);
|
||||
switch (op)
|
||||
{
|
||||
case 0: // Store
|
||||
using (var stream = new MemoryStream(new byte[] { (byte)(i % 256) }))
|
||||
{
|
||||
await store.StoreAsync(new ArtifactStoreRequest
|
||||
{
|
||||
BomRef = bomRef,
|
||||
SerialNumber = $"urn:uuid:mixed-{i}",
|
||||
ArtifactId = $"mixed-artifact-{i}",
|
||||
Content = stream,
|
||||
ContentType = "application/json",
|
||||
Type = ArtifactType.Sbom,
|
||||
TenantId = tenantId
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 1: // Read existing
|
||||
var idx = random.Next(preloadArtifacts.Count);
|
||||
await store.ReadAsync(bomRef, preloadArtifacts[idx].SerialNumber, preloadArtifacts[idx].ArtifactId);
|
||||
break;
|
||||
case 2: // List
|
||||
await store.ListAsync(bomRef);
|
||||
break;
|
||||
case 3: // Exists check
|
||||
var checkIdx = random.Next(preloadArtifacts.Count);
|
||||
await store.ExistsAsync(bomRef, preloadArtifacts[checkIdx].SerialNumber!, preloadArtifacts[checkIdx].ArtifactId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
_output.WriteLine($"Completed {operationCount} mixed operations in {sw.ElapsedMilliseconds}ms");
|
||||
_output.WriteLine($"Operations/second: {operationCount / sw.Elapsed.TotalSeconds:F2}");
|
||||
}
|
||||
private static InMemoryArtifactStore CreateStore() => new(ArtifactTestFixtures.TimeProvider);
|
||||
|
||||
private static List<ArtifactStoreRequest> GenerateTestArtifacts(int count, Guid tenantId, string bomRef)
|
||||
{
|
||||
var artifacts = new List<ArtifactStoreRequest>();
|
||||
|
||||
var artifacts = new List<ArtifactStoreRequest>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var content = System.Text.Encoding.UTF8.GetBytes($"{{\"index\": {i}, \"data\": \"test-{Guid.NewGuid()}\"}}");
|
||||
var content = Encoding.UTF8.GetBytes($"{{\"index\":{i},\"data\":\"test-{i:D4}\"}}");
|
||||
artifacts.Add(new ArtifactStoreRequest
|
||||
{
|
||||
BomRef = bomRef,
|
||||
SerialNumber = $"urn:uuid:{Guid.NewGuid()}",
|
||||
SerialNumber = $"urn:uuid:{CreateGuid(i + 1)}",
|
||||
ArtifactId = $"artifact-{i:D5}",
|
||||
Content = new MemoryStream(content),
|
||||
ContentType = "application/json",
|
||||
Type = (ArtifactType)(i % 5), // Rotate through types
|
||||
Type = (ArtifactType)(i % 5),
|
||||
TenantId = tenantId
|
||||
});
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
private static Guid CreateGuid(int value) => Guid.Parse($"00000000-0000-0000-0000-{value:D12}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using StellaOps.Artifact.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
public sealed partial class ArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InMemoryStore_Exists_ReturnsTrueForExistingAsync()
|
||||
{
|
||||
var store = ArtifactTestFixtures.CreateStore();
|
||||
var bomRef = ArtifactTestFixtures.DefaultBomRef;
|
||||
var serial = ArtifactTestFixtures.DefaultSerialNumber;
|
||||
var artifactId = "artifact-001";
|
||||
|
||||
using var contentStream = new MemoryStream(new byte[] { 1, 2, 3 });
|
||||
var request = ArtifactTestFixtures.CreateRequest(artifactId, contentStream, bomRef, serial);
|
||||
await store.StoreAsync(request);
|
||||
|
||||
Assert.True(await store.ExistsAsync(bomRef, serial, artifactId));
|
||||
Assert.False(await store.ExistsAsync(bomRef, serial, "nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryStore_Delete_RemovesArtifactAsync()
|
||||
{
|
||||
var store = ArtifactTestFixtures.CreateStore();
|
||||
var bomRef = ArtifactTestFixtures.DefaultBomRef;
|
||||
var serial = ArtifactTestFixtures.DefaultSerialNumber;
|
||||
var artifactId = "artifact-001";
|
||||
|
||||
using var contentStream = new MemoryStream(new byte[] { 1, 2, 3 });
|
||||
var request = ArtifactTestFixtures.CreateRequest(artifactId, contentStream, bomRef, serial);
|
||||
await store.StoreAsync(request);
|
||||
|
||||
Assert.True(await store.ExistsAsync(bomRef, serial, artifactId));
|
||||
|
||||
var deleted = await store.DeleteAsync(bomRef, serial, artifactId);
|
||||
|
||||
Assert.True(deleted);
|
||||
Assert.False(await store.ExistsAsync(bomRef, serial, artifactId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using StellaOps.Artifact.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
public sealed partial class ArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InMemoryStore_List_ReturnsMatchingArtifactsAsync()
|
||||
{
|
||||
var store = ArtifactTestFixtures.CreateStore();
|
||||
var bomRef = ArtifactTestFixtures.DefaultBomRef;
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
using var contentStream = new MemoryStream(new byte[] { (byte)i });
|
||||
var request = ArtifactTestFixtures.CreateRequest($"artifact-{i}", contentStream, bomRef);
|
||||
await store.StoreAsync(request);
|
||||
}
|
||||
|
||||
using var otherStream = new MemoryStream(new byte[] { 99 });
|
||||
var otherRequest = ArtifactTestFixtures.CreateRequest("artifact-other", otherStream, "pkg:docker/other/app@sha256:xyz");
|
||||
await store.StoreAsync(otherRequest);
|
||||
|
||||
var list = await store.ListAsync(bomRef);
|
||||
|
||||
Assert.Equal(2, list.Count);
|
||||
Assert.All(list, a => Assert.Equal(bomRef, a.BomRef));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using StellaOps.Artifact.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
public sealed partial class ArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InMemoryStore_StoreExisting_ReturnsWasCreatedFalseAsync()
|
||||
{
|
||||
var store = ArtifactTestFixtures.CreateStore();
|
||||
using var firstStream = new MemoryStream(new byte[] { 1 });
|
||||
var request = ArtifactTestFixtures.CreateRequest("artifact-001", firstStream);
|
||||
|
||||
var first = await store.StoreAsync(request);
|
||||
Assert.True(first.WasCreated);
|
||||
|
||||
using var secondStream = new MemoryStream(new byte[] { 2 });
|
||||
request = request with { Content = secondStream };
|
||||
var second = await store.StoreAsync(request);
|
||||
|
||||
Assert.False(second.WasCreated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text;
|
||||
using StellaOps.Artifact.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed partial class ArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InMemoryStore_StoreAndRead_SucceedsAsync()
|
||||
{
|
||||
var store = ArtifactTestFixtures.CreateStore();
|
||||
using var contentStream = new MemoryStream(Encoding.UTF8.GetBytes("{\"test\": true}"));
|
||||
var request = ArtifactTestFixtures.CreateRequest("artifact-001", contentStream);
|
||||
|
||||
var storeResult = await store.StoreAsync(request);
|
||||
|
||||
Assert.True(storeResult.Success);
|
||||
Assert.True(storeResult.WasCreated);
|
||||
Assert.NotNull(storeResult.Sha256);
|
||||
Assert.Equal(contentStream.Length, storeResult.SizeBytes);
|
||||
|
||||
var readResult = await store.ReadAsync(request.BomRef, request.SerialNumber, request.ArtifactId);
|
||||
|
||||
Assert.True(readResult.Found);
|
||||
Assert.NotNull(readResult.Content);
|
||||
Assert.NotNull(readResult.Metadata);
|
||||
Assert.Equal(request.BomRef, readResult.Metadata.BomRef);
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactStoreTests.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Tasks: AS-001, AS-002, AS-003 - Unit tests
|
||||
// Description: Unit tests for unified artifact store
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Artifact.Core;
|
||||
using StellaOps.Artifact.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InMemoryStore_StoreAndRead_Succeeds()
|
||||
{
|
||||
var store = new InMemoryArtifactStore();
|
||||
var content = System.Text.Encoding.UTF8.GetBytes("{\"test\": true}");
|
||||
|
||||
using var contentStream = new MemoryStream(content);
|
||||
var request = new ArtifactStoreRequest
|
||||
{
|
||||
BomRef = "pkg:docker/test/app@sha256:abc123",
|
||||
SerialNumber = "urn:uuid:12345678-1234-1234-1234-123456789012",
|
||||
ArtifactId = "artifact-001",
|
||||
Content = contentStream,
|
||||
ContentType = "application/json",
|
||||
Type = ArtifactType.Sbom,
|
||||
TenantId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
var storeResult = await store.StoreAsync(request);
|
||||
|
||||
Assert.True(storeResult.Success);
|
||||
Assert.True(storeResult.WasCreated);
|
||||
Assert.NotNull(storeResult.Sha256);
|
||||
Assert.Equal(content.Length, storeResult.SizeBytes);
|
||||
|
||||
// Read it back
|
||||
var readResult = await store.ReadAsync(
|
||||
request.BomRef,
|
||||
request.SerialNumber,
|
||||
request.ArtifactId);
|
||||
|
||||
Assert.True(readResult.Found);
|
||||
Assert.NotNull(readResult.Content);
|
||||
Assert.NotNull(readResult.Metadata);
|
||||
Assert.Equal(request.BomRef, readResult.Metadata.BomRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryStore_List_ReturnsMatchingArtifacts()
|
||||
{
|
||||
var store = new InMemoryArtifactStore();
|
||||
var bomRef = "pkg:docker/test/app@sha256:abc123";
|
||||
var tenantId = Guid.NewGuid();
|
||||
|
||||
// Store two artifacts with same bom-ref
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
using var contentStream = new MemoryStream(new byte[] { (byte)i });
|
||||
await store.StoreAsync(new ArtifactStoreRequest
|
||||
{
|
||||
BomRef = bomRef,
|
||||
SerialNumber = $"urn:uuid:serial-{i}",
|
||||
ArtifactId = $"artifact-{i}",
|
||||
Content = contentStream,
|
||||
ContentType = "application/json",
|
||||
Type = ArtifactType.Sbom,
|
||||
TenantId = tenantId
|
||||
});
|
||||
}
|
||||
|
||||
// Store one with different bom-ref
|
||||
using var otherStream = new MemoryStream(new byte[] { 99 });
|
||||
await store.StoreAsync(new ArtifactStoreRequest
|
||||
{
|
||||
BomRef = "pkg:docker/other/app@sha256:xyz",
|
||||
SerialNumber = "urn:uuid:other",
|
||||
ArtifactId = "artifact-other",
|
||||
Content = otherStream,
|
||||
ContentType = "application/json",
|
||||
Type = ArtifactType.Sbom,
|
||||
TenantId = tenantId
|
||||
});
|
||||
|
||||
var list = await store.ListAsync(bomRef);
|
||||
|
||||
Assert.Equal(2, list.Count);
|
||||
Assert.All(list, a => Assert.Equal(bomRef, a.BomRef));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryStore_Exists_ReturnsTrueForExisting()
|
||||
{
|
||||
var store = new InMemoryArtifactStore();
|
||||
var bomRef = "pkg:docker/test/app@sha256:abc123";
|
||||
var serial = "urn:uuid:12345678-1234-1234-1234-123456789012";
|
||||
var artifactId = "artifact-001";
|
||||
|
||||
using var contentStream = new MemoryStream(new byte[] { 1, 2, 3 });
|
||||
await store.StoreAsync(new ArtifactStoreRequest
|
||||
{
|
||||
BomRef = bomRef,
|
||||
SerialNumber = serial,
|
||||
ArtifactId = artifactId,
|
||||
Content = contentStream,
|
||||
ContentType = "application/json",
|
||||
Type = ArtifactType.Sbom,
|
||||
TenantId = Guid.NewGuid()
|
||||
});
|
||||
|
||||
Assert.True(await store.ExistsAsync(bomRef, serial, artifactId));
|
||||
Assert.False(await store.ExistsAsync(bomRef, serial, "nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryStore_Delete_RemovesArtifact()
|
||||
{
|
||||
var store = new InMemoryArtifactStore();
|
||||
var bomRef = "pkg:docker/test/app@sha256:abc123";
|
||||
var serial = "urn:uuid:12345678-1234-1234-1234-123456789012";
|
||||
var artifactId = "artifact-001";
|
||||
|
||||
using var contentStream = new MemoryStream(new byte[] { 1, 2, 3 });
|
||||
await store.StoreAsync(new ArtifactStoreRequest
|
||||
{
|
||||
BomRef = bomRef,
|
||||
SerialNumber = serial,
|
||||
ArtifactId = artifactId,
|
||||
Content = contentStream,
|
||||
ContentType = "application/json",
|
||||
Type = ArtifactType.Sbom,
|
||||
TenantId = Guid.NewGuid()
|
||||
});
|
||||
|
||||
Assert.True(await store.ExistsAsync(bomRef, serial, artifactId));
|
||||
|
||||
var deleted = await store.DeleteAsync(bomRef, serial, artifactId);
|
||||
|
||||
Assert.True(deleted);
|
||||
Assert.False(await store.ExistsAsync(bomRef, serial, artifactId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryStore_StoreExisting_ReturnsWasCreatedFalse()
|
||||
{
|
||||
var store = new InMemoryArtifactStore();
|
||||
var request = new ArtifactStoreRequest
|
||||
{
|
||||
BomRef = "pkg:docker/test/app@sha256:abc123",
|
||||
SerialNumber = "urn:uuid:12345678-1234-1234-1234-123456789012",
|
||||
ArtifactId = "artifact-001",
|
||||
Content = new MemoryStream(new byte[] { 1 }),
|
||||
ContentType = "application/json",
|
||||
Type = ArtifactType.Sbom,
|
||||
TenantId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
var first = await store.StoreAsync(request);
|
||||
Assert.True(first.WasCreated);
|
||||
|
||||
// Store again (with new stream)
|
||||
request = request with { Content = new MemoryStream(new byte[] { 2 }) };
|
||||
var second = await store.StoreAsync(request);
|
||||
Assert.False(second.WasCreated);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BomRefEncoderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("pkg:docker/acme/api@sha256:abc", "pkg_docker_acme_api_at_sha256_abc")]
|
||||
[InlineData("simple-ref", "simple-ref")]
|
||||
[InlineData("ref/with/slashes", "ref_with_slashes")]
|
||||
[InlineData("pkg:npm/@scope/pkg", "pkg_npm__at_scope_pkg")]
|
||||
public void Encode_HandlesSpecialCharacters(string input, string expected)
|
||||
{
|
||||
var result = BomRefEncoder.Encode(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_CreatesCorrectStructure()
|
||||
{
|
||||
var bomRef = "pkg:docker/acme/api@sha256:abc";
|
||||
var serial = "urn:uuid:12345";
|
||||
var artifactId = "envelope-001";
|
||||
|
||||
var path = BomRefEncoder.BuildPath(bomRef, serial, artifactId);
|
||||
|
||||
Assert.StartsWith("artifacts/", path);
|
||||
Assert.EndsWith(".json", path);
|
||||
Assert.Contains("envelope-001", path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_EmptyInput_ReturnsUnknown()
|
||||
{
|
||||
Assert.Equal("unknown", BomRefEncoder.Encode(""));
|
||||
Assert.Equal("unknown", BomRefEncoder.Encode(" "));
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CycloneDxExtractorTests
|
||||
{
|
||||
private readonly CycloneDxExtractor _extractor = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_ValidCycloneDx_ExtractsMetadata()
|
||||
{
|
||||
var sbom = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.5",
|
||||
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-18T12:00:00Z",
|
||||
"component": {
|
||||
"type": "application",
|
||||
"bom-ref": "acme-app",
|
||||
"name": "ACME Application",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:docker/acme/app@1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "component-1",
|
||||
"name": "some-lib",
|
||||
"purl": "pkg:npm/some-lib@1.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(sbom));
|
||||
var result = await _extractor.ExtractAsync(stream);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", result.SerialNumber);
|
||||
Assert.Equal("1.5", result.SpecVersion);
|
||||
Assert.Equal(1, result.Version);
|
||||
Assert.Equal("acme-app", result.PrimaryBomRef);
|
||||
Assert.Equal("ACME Application", result.PrimaryName);
|
||||
Assert.Equal("1.0.0", result.PrimaryVersion);
|
||||
Assert.Equal("pkg:docker/acme/app@1.0.0", result.PrimaryPurl);
|
||||
Assert.Single(result.ComponentBomRefs);
|
||||
Assert.Single(result.ComponentPurls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractParsedAsync_ValidCycloneDx_ExtractsParsedSbom()
|
||||
{
|
||||
var sbom = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.5",
|
||||
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-18T12:00:00Z",
|
||||
"component": {
|
||||
"type": "application",
|
||||
"bom-ref": "acme-app",
|
||||
"name": "ACME Application",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:docker/acme/app@1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "component-1",
|
||||
"name": "some-lib",
|
||||
"purl": "pkg:npm/some-lib@1.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(sbom));
|
||||
var result = await _extractor.ExtractParsedAsync(stream);
|
||||
|
||||
Assert.Equal("cyclonedx", result.Format);
|
||||
Assert.Equal("1.5", result.SpecVersion);
|
||||
Assert.Equal("urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", result.SerialNumber);
|
||||
Assert.Equal("ACME Application", result.Metadata.Name);
|
||||
Assert.Equal("acme-app", result.Metadata.RootComponentRef);
|
||||
Assert.Contains(result.Components, component => component.BomRef == "acme-app");
|
||||
Assert.Contains(result.Components, component => component.BomRef == "component-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_MissingOptionalFields_Succeeds()
|
||||
{
|
||||
var sbom = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.4",
|
||||
"version": 1,
|
||||
"components": []
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(sbom));
|
||||
var result = await _extractor.ExtractAsync(stream);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.SerialNumber);
|
||||
Assert.Equal("1.4", result.SpecVersion);
|
||||
Assert.Null(result.PrimaryBomRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_InvalidJson_ReturnsError()
|
||||
{
|
||||
var invalid = "not valid json";
|
||||
|
||||
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(invalid));
|
||||
var result = await _extractor.ExtractAsync(stream);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ArtifactIndexRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InMemoryIndex_IndexAndFind_Succeeds()
|
||||
{
|
||||
var repo = new InMemoryArtifactIndexRepository();
|
||||
var entry = new ArtifactIndexEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = Guid.NewGuid(),
|
||||
BomRef = "pkg:docker/test/app@sha256:abc",
|
||||
SerialNumber = "urn:uuid:12345",
|
||||
ArtifactId = "artifact-001",
|
||||
StorageKey = "artifacts/test/artifact-001.json",
|
||||
Type = ArtifactType.Sbom,
|
||||
ContentType = "application/json",
|
||||
Sha256 = "abc123",
|
||||
SizeBytes = 1024,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await repo.IndexAsync(entry);
|
||||
|
||||
var found = await repo.FindByBomRefAsync(entry.BomRef);
|
||||
Assert.Single(found);
|
||||
Assert.Equal(entry.ArtifactId, found[0].ArtifactId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryIndex_Remove_SoftDeletes()
|
||||
{
|
||||
var repo = new InMemoryArtifactIndexRepository();
|
||||
var entry = new ArtifactIndexEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = Guid.NewGuid(),
|
||||
BomRef = "pkg:docker/test/app@sha256:abc",
|
||||
SerialNumber = "urn:uuid:12345",
|
||||
ArtifactId = "artifact-001",
|
||||
StorageKey = "artifacts/test/artifact-001.json",
|
||||
Type = ArtifactType.Sbom,
|
||||
ContentType = "application/json",
|
||||
Sha256 = "abc123",
|
||||
SizeBytes = 1024,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await repo.IndexAsync(entry);
|
||||
await repo.RemoveAsync(entry.BomRef, entry.SerialNumber, entry.ArtifactId);
|
||||
|
||||
var found = await repo.FindByBomRefAsync(entry.BomRef);
|
||||
Assert.Empty(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryIndex_FindBySha256_ReturnsMatches()
|
||||
{
|
||||
var repo = new InMemoryArtifactIndexRepository();
|
||||
var sha256 = "abc123def456";
|
||||
|
||||
await repo.IndexAsync(new ArtifactIndexEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = Guid.NewGuid(),
|
||||
BomRef = "pkg:docker/test/app1",
|
||||
SerialNumber = "urn:uuid:1",
|
||||
ArtifactId = "artifact-1",
|
||||
StorageKey = "artifacts/1.json",
|
||||
Type = ArtifactType.Sbom,
|
||||
ContentType = "application/json",
|
||||
Sha256 = sha256,
|
||||
SizeBytes = 1024,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await repo.IndexAsync(new ArtifactIndexEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = Guid.NewGuid(),
|
||||
BomRef = "pkg:docker/test/app2",
|
||||
SerialNumber = "urn:uuid:2",
|
||||
ArtifactId = "artifact-2",
|
||||
StorageKey = "artifacts/2.json",
|
||||
Type = ArtifactType.Sbom,
|
||||
ContentType = "application/json",
|
||||
Sha256 = sha256,
|
||||
SizeBytes = 1024,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var found = await repo.FindBySha256Async(sha256);
|
||||
Assert.Equal(2, found.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using StellaOps.Artifact.Core;
|
||||
using StellaOps.Artifact.Infrastructure;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
internal static class ArtifactTestFixtures
|
||||
{
|
||||
internal static readonly DateTimeOffset FixedNow = new(2026, 2, 3, 0, 0, 0, TimeSpan.Zero);
|
||||
internal static readonly TimeProvider TimeProvider = new FixedTimeProvider(FixedNow);
|
||||
internal static readonly Guid TenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
internal const string DefaultBomRef = "pkg:docker/test/app@sha256:abc123";
|
||||
internal const string DefaultSerialNumber = "urn:uuid:12345678-1234-1234-1234-123456789012";
|
||||
|
||||
internal static InMemoryArtifactStore CreateStore() => new(TimeProvider);
|
||||
|
||||
internal static ArtifactStoreRequest CreateRequest(string artifactId, Stream content, string? bomRef = null, string? serialNumber = null)
|
||||
{
|
||||
return new ArtifactStoreRequest
|
||||
{
|
||||
BomRef = bomRef ?? DefaultBomRef,
|
||||
SerialNumber = serialNumber ?? DefaultSerialNumber,
|
||||
ArtifactId = artifactId,
|
||||
Content = content,
|
||||
ContentType = "application/json",
|
||||
Type = ArtifactType.Sbom,
|
||||
TenantId = TenantId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using StellaOps.Artifact.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BomRefEncoderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("pkg:docker/acme/api@sha256:abc", "pkg_docker_acme_api_at_sha256_abc")]
|
||||
[InlineData("simple-ref", "simple-ref")]
|
||||
[InlineData("ref/with/slashes", "ref_with_slashes")]
|
||||
[InlineData("pkg:npm/@scope/pkg", "pkg_npm__at_scope_pkg")]
|
||||
public void Encode_HandlesSpecialCharacters(string input, string expected)
|
||||
{
|
||||
var result = BomRefEncoder.Encode(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_CreatesCorrectStructure()
|
||||
{
|
||||
var bomRef = "pkg:docker/acme/api@sha256:abc";
|
||||
var serial = "urn:uuid:12345";
|
||||
var artifactId = "envelope-001";
|
||||
|
||||
var path = BomRefEncoder.BuildPath(bomRef, serial, artifactId);
|
||||
|
||||
Assert.StartsWith("artifacts/", path);
|
||||
Assert.EndsWith(".json", path);
|
||||
Assert.Contains("envelope-001", path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_EmptyInput_ReturnsUnknown()
|
||||
{
|
||||
Assert.Equal("unknown", BomRefEncoder.Encode(""));
|
||||
Assert.Equal("unknown", BomRefEncoder.Encode(" "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
public sealed partial class CycloneDxExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExtractAsync_ValidCycloneDx_ExtractsMetadataAsync()
|
||||
{
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ValidCycloneDx));
|
||||
var result = await _extractor.ExtractAsync(stream);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", result.SerialNumber);
|
||||
Assert.Equal("1.5", result.SpecVersion);
|
||||
Assert.Equal(1, result.Version);
|
||||
Assert.Equal("acme-app", result.PrimaryBomRef);
|
||||
Assert.Equal("ACME Application", result.PrimaryName);
|
||||
Assert.Equal("1.0.0", result.PrimaryVersion);
|
||||
Assert.Equal("pkg:docker/acme/app@1.0.0", result.PrimaryPurl);
|
||||
Assert.Single(result.ComponentBomRefs);
|
||||
Assert.Single(result.ComponentPurls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_MissingOptionalFields_SucceedsAsync()
|
||||
{
|
||||
var sbom = "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.4\",\"version\":1,\"components\":[]}";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbom));
|
||||
var result = await _extractor.ExtractAsync(stream);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.SerialNumber);
|
||||
Assert.Equal("1.4", result.SpecVersion);
|
||||
Assert.Null(result.PrimaryBomRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_InvalidJson_ReturnsErrorAsync()
|
||||
{
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("not valid json"));
|
||||
var result = await _extractor.ExtractAsync(stream);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
public sealed partial class CycloneDxExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExtractParsedAsync_ValidCycloneDx_ExtractsParsedSbomAsync()
|
||||
{
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ValidCycloneDx));
|
||||
var result = await _extractor.ExtractParsedAsync(stream);
|
||||
|
||||
Assert.Equal("cyclonedx", result.Format);
|
||||
Assert.Equal("1.5", result.SpecVersion);
|
||||
Assert.Equal("urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", result.SerialNumber);
|
||||
Assert.Equal("ACME Application", result.Metadata.Name);
|
||||
Assert.Equal("acme-app", result.Metadata.RootComponentRef);
|
||||
Assert.Contains(result.Components, component => component.BomRef == "acme-app");
|
||||
Assert.Contains(result.Components, component => component.BomRef == "component-1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Artifact.Core;
|
||||
using StellaOps.Concelier.SbomIntegration.Parsing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed partial class CycloneDxExtractorTests
|
||||
{
|
||||
private const string ValidCycloneDx = "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\",\"serialNumber\":\"urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79\",\"version\":1,\"metadata\":{\"timestamp\":\"2026-01-18T12:00:00Z\",\"component\":{\"type\":\"application\",\"bom-ref\":\"acme-app\",\"name\":\"ACME Application\",\"version\":\"1.0.0\",\"purl\":\"pkg:docker/acme/app@1.0.0\"}},\"components\":[{\"type\":\"library\",\"bom-ref\":\"component-1\",\"name\":\"some-lib\",\"purl\":\"pkg:npm/some-lib@1.0.0\"}]}";
|
||||
|
||||
private readonly CycloneDxExtractor _extractor = new(
|
||||
new ParsedSbomParser(NullLogger<ParsedSbomParser>.Instance));
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using StellaOps.Artifact.Infrastructure;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
internal sealed class FakeS3UnifiedClient : IS3UnifiedClient
|
||||
{
|
||||
private readonly Dictionary<string, Dictionary<string, StoredObject>> _objects = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public int StoredObjectCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _objects.Values.Sum(bucket => bucket.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
public Task<bool> ObjectExistsAsync(string bucketName, string key, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_objects.TryGetValue(bucketName, out var bucket) && bucket.ContainsKey(key));
|
||||
}
|
||||
}
|
||||
public Task PutObjectAsync(string bucketName, string key, Stream content, string contentType, IDictionary<string, string> metadata, CancellationToken ct)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
content.CopyTo(ms);
|
||||
var data = ms.ToArray();
|
||||
lock (_lock)
|
||||
{
|
||||
var bucket = GetBucket(bucketName);
|
||||
bucket[key] = new StoredObject(data, new Dictionary<string, string>(metadata));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task<Stream?> GetObjectAsync(string bucketName, string key, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_objects.TryGetValue(bucketName, out var bucket) || !bucket.TryGetValue(key, out var stored))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
return Task.FromResult<Stream?>(new MemoryStream(stored.Content, writable: false));
|
||||
}
|
||||
}
|
||||
public Task<IDictionary<string, string>?> GetObjectMetadataAsync(string bucketName, string key, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_objects.TryGetValue(bucketName, out var bucket) || !bucket.TryGetValue(key, out var stored))
|
||||
{
|
||||
return Task.FromResult<IDictionary<string, string>?>(null);
|
||||
}
|
||||
return Task.FromResult<IDictionary<string, string>?>(new Dictionary<string, string>(stored.Metadata));
|
||||
}
|
||||
}
|
||||
public Task DeleteObjectAsync(string bucketName, string key, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_objects.TryGetValue(bucketName, out var bucket))
|
||||
{
|
||||
bucket.Remove(key);
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task<IReadOnlyList<string>> ListObjectsAsync(string bucketName, string prefix, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_objects.TryGetValue(bucketName, out var bucket))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
var result = bucket.Keys
|
||||
.Where(key => key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.OrderBy(key => key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<string>>(result);
|
||||
}
|
||||
}
|
||||
private Dictionary<string, StoredObject> GetBucket(string bucketName)
|
||||
{
|
||||
if (!_objects.TryGetValue(bucketName, out var bucket))
|
||||
{
|
||||
bucket = new Dictionary<string, StoredObject>(StringComparer.Ordinal);
|
||||
_objects[bucketName] = bucket;
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
private sealed record StoredObject(byte[] Content, IDictionary<string, string> Metadata);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
internal sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Artifact.Core;
|
||||
using StellaOps.Artifact.Infrastructure;
|
||||
using StellaOps.Determinism;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Artifact.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Operational")]
|
||||
public sealed class S3UnifiedArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreAndRead_RoundTripsContentAsync()
|
||||
{
|
||||
var client = new FakeS3UnifiedClient();
|
||||
var indexRepository = new InMemoryArtifactIndexRepository(ArtifactTestFixtures.TimeProvider);
|
||||
var store = CreateStore(client, indexRepository);
|
||||
|
||||
using var contentStream = new MemoryStream(Encoding.UTF8.GetBytes("{\"value\":1}"));
|
||||
var request = ArtifactTestFixtures.CreateRequest("artifact-001", contentStream);
|
||||
|
||||
var stored = await store.StoreAsync(request);
|
||||
|
||||
Assert.True(stored.Success);
|
||||
|
||||
var read = await store.ReadAsync(request.BomRef, request.SerialNumber, request.ArtifactId);
|
||||
|
||||
Assert.True(read.Found);
|
||||
using var reader = new StreamReader(read.Content!);
|
||||
var payload = await reader.ReadToEndAsync();
|
||||
Assert.Equal("{\"value\":1}", payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_DeduplicatesBySha256Async()
|
||||
{
|
||||
var client = new FakeS3UnifiedClient();
|
||||
var indexRepository = new InMemoryArtifactIndexRepository(ArtifactTestFixtures.TimeProvider);
|
||||
var store = CreateStore(client, indexRepository);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("{\"value\":2}");
|
||||
using var firstStream = new MemoryStream(payload);
|
||||
using var secondStream = new MemoryStream(payload);
|
||||
|
||||
var firstRequest = ArtifactTestFixtures.CreateRequest("artifact-001", firstStream);
|
||||
var secondRequest = ArtifactTestFixtures.CreateRequest("artifact-002", secondStream);
|
||||
|
||||
var first = await store.StoreAsync(firstRequest);
|
||||
var second = await store.StoreAsync(secondRequest);
|
||||
|
||||
Assert.True(first.Success);
|
||||
Assert.True(second.Success);
|
||||
Assert.Equal(1, client.StoredObjectCount);
|
||||
Assert.Equal(first.StorageKey, second.StorageKey);
|
||||
}
|
||||
|
||||
private static S3UnifiedArtifactStore CreateStore(FakeS3UnifiedClient client, InMemoryArtifactIndexRepository indexRepository)
|
||||
{
|
||||
var options = new S3UnifiedArtifactStoreOptions
|
||||
{
|
||||
BucketName = "test-bucket",
|
||||
Prefix = "artifacts",
|
||||
EnableDeduplication = true,
|
||||
AllowOverwrite = false,
|
||||
MaxArtifactSizeBytes = 1024 * 1024
|
||||
};
|
||||
|
||||
return new S3UnifiedArtifactStore(
|
||||
client,
|
||||
indexRepository,
|
||||
Options.Create(options),
|
||||
ArtifactTestFixtures.TimeProvider,
|
||||
new SequentialGuidProvider(Guid.Parse("00000000-0000-0000-0000-000000000001")),
|
||||
NullLogger<S3UnifiedArtifactStore>.Instance);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user