425 lines
15 KiB
C#
425 lines
15 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Integration Tests for EvidenceLocker
|
|
/// Task EVIDENCE-5100-007: store artifact → retrieve artifact → verify hash matches
|
|
/// </summary>
|
|
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<string, string>
|
|
{
|
|
["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<JsonElement>(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<JsonElement>(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<JsonElement>(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<string>();
|
|
|
|
// Act - Store 3 different bundles
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
var payload = new
|
|
{
|
|
kind = 1,
|
|
metadata = new Dictionary<string, string>
|
|
{
|
|
["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<JsonElement>(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<JsonElement>(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<string, string>
|
|
{
|
|
["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<JsonElement>(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<JsonElement>(TestContext.Current.CancellationToken);
|
|
|
|
// Assert - Metadata preserved
|
|
retrieveResult.TryGetProperty("metadata", out var retrievedMetadata).Should().BeTrue();
|
|
var metadataDict = retrievedMetadata.Deserialize<Dictionary<string, string>>();
|
|
|
|
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<JsonElement>(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<JsonElement>(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<string, string>
|
|
{
|
|
["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<string, string> ReadGzipTarEntries(byte[] archiveBytes)
|
|
{
|
|
var entries = new Dictionary<string, string>();
|
|
|
|
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
|
|
}
|