// ----------------------------------------------------------------------------- // EvidenceLockerIntegrationTests.cs // Sprint: SPRINT_5100_0010_0001_evidencelocker_tests // Task: EVIDENCE-5100-007 // Description: Integration test: store artifact → retrieve artifact → verify hash matches // ----------------------------------------------------------------------------- using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; using FluentAssertions; using StellaOps.Auth.Abstractions; using StellaOps.TestKit; namespace StellaOps.EvidenceLocker.Tests; /// /// Integration Tests for EvidenceLocker /// Task EVIDENCE-5100-007: store artifact → retrieve artifact → verify hash matches /// public sealed class EvidenceLockerIntegrationTests : IDisposable { private readonly EvidenceLockerWebApplicationFactory _factory; private readonly HttpClient _client; private bool _disposed; public EvidenceLockerIntegrationTests() { _factory = new EvidenceLockerWebApplicationFactory(); _client = _factory.CreateClient(); } #region EVIDENCE-5100-007: Store → Retrieve → Verify Hash [Trait("Category", TestCategories.Unit)] [Fact] public async Task StoreArtifact_ThenRetrieve_HashMatches() { // Arrange var tenantId = Guid.NewGuid().ToString("D"); ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}"); var configContent = "{\"setting\": \"value\"}"; var sha256Hash = ComputeSha256(configContent); var payload = new { kind = 1, // Evaluation metadata = new Dictionary { ["run"] = "integration-test", ["correlationId"] = Guid.NewGuid().ToString("D") }, materials = new[] { new { section = "inputs", path = "config.json", sha256 = sha256Hash, sizeBytes = (long)Encoding.UTF8.GetByteCount(configContent), mediaType = "application/json" } } }; // Act - Store var storeResponse = await _client.PostAsJsonAsync( "/evidence/snapshot", payload, TestContext.Current.CancellationToken); storeResponse.EnsureSuccessStatusCode(); var storeResult = await storeResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); var bundleId = storeResult.GetProperty("bundleId").GetString(); var storedRootHash = storeResult.GetProperty("rootHash").GetString(); bundleId.Should().NotBeNullOrEmpty(); storedRootHash.Should().NotBeNullOrEmpty(); // Act - Retrieve var retrieveResponse = await _client.GetAsync( $"/evidence/{bundleId}", TestContext.Current.CancellationToken); retrieveResponse.EnsureSuccessStatusCode(); var retrieveResult = await retrieveResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); var retrievedRootHash = retrieveResult.GetProperty("rootHash").GetString(); var retrievedBundleId = retrieveResult.GetProperty("bundleId").GetString(); // Assert - Hash matches retrievedBundleId.Should().Be(bundleId); retrievedRootHash.Should().Be(storedRootHash, "Root hash should match between store and retrieve"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task StoreArtifact_ThenDownload_ContainsCorrectManifest() { // Arrange var tenantId = Guid.NewGuid().ToString("D"); ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}"); var payload = CreateTestBundlePayload(); // Act - Store var storeResponse = await _client.PostAsJsonAsync( "/evidence/snapshot", payload, TestContext.Current.CancellationToken); storeResponse.EnsureSuccessStatusCode(); var storeResult = await storeResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); var bundleId = storeResult.GetProperty("bundleId").GetString(); // Act - Download var downloadResponse = await _client.GetAsync( $"/evidence/{bundleId}/download", TestContext.Current.CancellationToken); downloadResponse.EnsureSuccessStatusCode(); // Assert downloadResponse.Content.Headers.ContentType?.MediaType.Should().Be("application/gzip"); var archiveBytes = await downloadResponse.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); archiveBytes.Should().NotBeEmpty(); // Verify archive contains manifest with correct bundleId var entries = ReadGzipTarEntries(archiveBytes); entries.Should().ContainKey("manifest.json"); using var manifestDoc = JsonDocument.Parse(entries["manifest.json"]); manifestDoc.RootElement.GetProperty("bundleId").GetString().Should().Be(bundleId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task StoreMultipleArtifacts_EachHasUniqueHash() { // Arrange var tenantId = Guid.NewGuid().ToString("D"); ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}"); var hashes = new List(); // Act - Store 3 different bundles for (int i = 0; i < 3; i++) { var payload = new { kind = 1, metadata = new Dictionary { ["iteration"] = i.ToString(), ["uniqueId"] = Guid.NewGuid().ToString("D") }, materials = new[] { new { section = "inputs", path = $"config-{i}.json", sha256 = ComputeSha256($"content-{i}-{Guid.NewGuid()}"), sizeBytes = 64L + i, mediaType = "application/json" } } }; var response = await _client.PostAsJsonAsync( "/evidence/snapshot", payload, TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); hashes.Add(result.GetProperty("rootHash").GetString()!); } // Assert - All hashes should be unique hashes.Should().OnlyHaveUniqueItems("Each bundle should have a unique root hash"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task StoreArtifact_SignatureIsValid() { // Arrange var tenantId = Guid.NewGuid().ToString("D"); ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}"); var payload = CreateTestBundlePayload(); // Act - Store var storeResponse = await _client.PostAsJsonAsync( "/evidence/snapshot", payload, TestContext.Current.CancellationToken); storeResponse.EnsureSuccessStatusCode(); var storeResult = await storeResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); // Assert - Signature should be present and valid storeResult.TryGetProperty("signature", out var signature).Should().BeTrue(); signature.TryGetProperty("signature", out var sigValue).Should().BeTrue(); sigValue.GetString().Should().NotBeNullOrEmpty(); // Timestamp token may or may not be present depending on configuration signature.TryGetProperty("timestampToken", out var timestampToken).Should().BeTrue(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task StoreArtifact_ThenRetrieve_MetadataPreserved() { // Arrange var tenantId = Guid.NewGuid().ToString("D"); ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}"); var metadata = new Dictionary { ["environment"] = "production", ["pipelineId"] = "pipe-123", ["buildNumber"] = "456" }; var payload = new { kind = 1, metadata = metadata, materials = new[] { new { section = "inputs", path = "config.json", sha256 = new string('a', 64), sizeBytes = 128L, mediaType = "application/json" } } }; // Act - Store var storeResponse = await _client.PostAsJsonAsync( "/evidence/snapshot", payload, TestContext.Current.CancellationToken); storeResponse.EnsureSuccessStatusCode(); var storeResult = await storeResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); var bundleId = storeResult.GetProperty("bundleId").GetString(); // Act - Retrieve var retrieveResponse = await _client.GetAsync( $"/evidence/{bundleId}", TestContext.Current.CancellationToken); retrieveResponse.EnsureSuccessStatusCode(); var retrieveResult = await retrieveResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); // Assert - Metadata preserved retrieveResult.TryGetProperty("metadata", out var retrievedMetadata).Should().BeTrue(); var metadataDict = retrievedMetadata.Deserialize>(); metadataDict.Should().ContainKey("environment"); metadataDict!["environment"].Should().Be("production"); metadataDict.Should().ContainKey("pipelineId"); metadataDict["pipelineId"].Should().Be("pipe-123"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task StoreArtifact_TimelineEventEmitted() { // Arrange var tenantId = Guid.NewGuid().ToString("D"); ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate}"); _factory.TimelinePublisher.PublishedEvents.Clear(); var payload = CreateTestBundlePayload(); // Act var storeResponse = await _client.PostAsJsonAsync( "/evidence/snapshot", payload, TestContext.Current.CancellationToken); storeResponse.EnsureSuccessStatusCode(); var storeResult = await storeResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); var bundleId = storeResult.GetProperty("bundleId").GetString(); // Assert - Timeline event emitted _factory.TimelinePublisher.PublishedEvents.Should().NotBeEmpty(); _factory.TimelinePublisher.PublishedEvents.Should().Contain(e => e.Contains(bundleId!)); } #endregion #region Portable Bundle Integration [Trait("Category", TestCategories.Unit)] [Fact] public async Task StoreArtifact_PortableDownload_IsSanitized() { // Arrange var tenantId = Guid.NewGuid().ToString("D"); ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}"); var payload = CreateTestBundlePayload(); // Act - Store var storeResponse = await _client.PostAsJsonAsync( "/evidence/snapshot", payload, TestContext.Current.CancellationToken); storeResponse.EnsureSuccessStatusCode(); var storeResult = await storeResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); var bundleId = storeResult.GetProperty("bundleId").GetString(); // Act - Portable download var portableResponse = await _client.GetAsync( $"/evidence/{bundleId}/portable", TestContext.Current.CancellationToken); portableResponse.EnsureSuccessStatusCode(); // Assert portableResponse.Content.Headers.ContentType?.MediaType.Should().Be("application/gzip"); var archiveBytes = await portableResponse.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); var entries = ReadGzipTarEntries(archiveBytes); // Portable bundle should have manifest but be sanitized entries.Should().ContainKey("manifest.json"); } #endregion #region Helpers private static object CreateTestBundlePayload() { return new { kind = 1, metadata = new Dictionary { ["test"] = "integration", ["timestamp"] = DateTime.UtcNow.ToString("O") }, materials = new[] { new { section = "inputs", path = "config.json", sha256 = new string('a', 64), sizeBytes = 128L, mediaType = "application/json" } } }; } private static string ComputeSha256(string content) { var bytes = Encoding.UTF8.GetBytes(content); var hashBytes = SHA256.HashData(bytes); return Convert.ToHexString(hashBytes).ToLowerInvariant(); } private static Dictionary ReadGzipTarEntries(byte[] archiveBytes) { var entries = new Dictionary(); using var compressedStream = new MemoryStream(archiveBytes); using var gzipStream = new System.IO.Compression.GZipStream( compressedStream, System.IO.Compression.CompressionMode.Decompress); using var tarStream = new MemoryStream(); gzipStream.CopyTo(tarStream); tarStream.Position = 0; using var tarReader = new System.Formats.Tar.TarReader(tarStream); while (tarReader.GetNextEntry() is { } entry) { if (entry.DataStream is not null) { using var contentStream = new MemoryStream(); using StellaOps.TestKit; entry.DataStream.CopyTo(contentStream); entries[entry.Name] = Encoding.UTF8.GetString(contentStream.ToArray()); } } return entries; } private static void ConfigureAuthHeaders(HttpClient client, string tenantId, string scopes) { client.DefaultRequestHeaders.Clear(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token"); client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId); client.DefaultRequestHeaders.Add("X-Scopes", scopes); } public void Dispose() { if (_disposed) return; _client.Dispose(); _factory.Dispose(); _disposed = true; } #endregion }