// -----------------------------------------------------------------------------
// 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
}