product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
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
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
[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();
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceLockerWebServiceContractTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0001_evidencelocker_tests
|
||||
// Tasks: EVIDENCE-5100-004, EVIDENCE-5100-005, EVIDENCE-5100-006
|
||||
// Description: W1 contract tests for EvidenceLocker.WebService
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// W1 Contract Tests for EvidenceLocker.WebService
|
||||
/// Task EVIDENCE-5100-004: OpenAPI schema snapshot validation
|
||||
/// Task EVIDENCE-5100-005: Auth tests (store artifact requires permissions)
|
||||
/// Task EVIDENCE-5100-006: OTel trace assertions (artifact_id, tenant_id tags)
|
||||
/// </summary>
|
||||
public sealed class EvidenceLockerWebServiceContractTests : IDisposable
|
||||
{
|
||||
private readonly EvidenceLockerWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
private bool _disposed;
|
||||
|
||||
// OpenAPI snapshot path for schema validation
|
||||
private const string OpenApiSnapshotPath = "Snapshots/EvidenceLocker.WebService.OpenApi.json";
|
||||
private const string SwaggerEndpoint = "/swagger/v1/swagger.json";
|
||||
|
||||
public EvidenceLockerWebServiceContractTests()
|
||||
{
|
||||
_factory = new EvidenceLockerWebApplicationFactory();
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
#region EVIDENCE-5100-004: Contract Tests (OpenAPI Snapshot)
|
||||
|
||||
[Fact]
|
||||
public async Task StoreArtifact_Endpoint_Returns_Expected_Schema()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate}");
|
||||
|
||||
var payload = CreateValidSnapshotPayload();
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Verify contract schema: bundleId, rootHash, signature
|
||||
root.TryGetProperty("bundleId", out var bundleId).Should().BeTrue("bundleId should be present");
|
||||
bundleId.GetString().Should().NotBeNullOrEmpty();
|
||||
|
||||
root.TryGetProperty("rootHash", out var rootHash).Should().BeTrue("rootHash should be present");
|
||||
rootHash.GetString().Should().NotBeNullOrEmpty();
|
||||
|
||||
root.TryGetProperty("signature", out var signature).Should().BeTrue("signature should be present");
|
||||
signature.ValueKind.Should().Be(JsonValueKind.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveArtifact_Endpoint_Returns_Expected_Schema()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
// Create an artifact first
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
var bundleId = created.GetProperty("bundleId").GetString();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/evidence/{bundleId}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Verify contract schema for retrieved bundle
|
||||
root.TryGetProperty("bundleId", out _).Should().BeTrue("bundleId should be present");
|
||||
root.TryGetProperty("rootHash", out _).Should().BeTrue("rootHash should be present");
|
||||
root.TryGetProperty("status", out _).Should().BeTrue("status should be present");
|
||||
root.TryGetProperty("createdAt", out _).Should().BeTrue("createdAt should be present");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadArtifact_Endpoint_Returns_GzipMediaType()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
// Create an artifact first
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
var bundleId = created.GetProperty("bundleId").GetString();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/evidence/{bundleId}/download", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/gzip");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Contract_ErrorResponse_Schema_Is_Consistent()
|
||||
{
|
||||
// Arrange - No auth headers (should fail)
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - Unauthorized should return consistent error schema
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Contract_NotFound_Response_Schema()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceRead}");
|
||||
var nonExistentId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/evidence/{nonExistentId}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EVIDENCE-5100-005: Auth Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StoreArtifact_Without_Auth_Returns_Unauthorized()
|
||||
{
|
||||
// Arrange - No auth headers
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreArtifact_Without_CreateScope_Returns_Forbidden()
|
||||
{
|
||||
// Arrange - Auth but no create scope
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceRead);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreArtifact_With_CreateScope_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveArtifact_Without_ReadScope_Returns_Forbidden()
|
||||
{
|
||||
// Arrange - Create with proper scope
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
var bundleId = created.GetProperty("bundleId").GetString();
|
||||
|
||||
// Change to no read scope
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/evidence/{bundleId}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CrossTenant_Access_Returns_NotFound_Or_Forbidden()
|
||||
{
|
||||
// Arrange - Create bundle as tenant A
|
||||
var tenantA = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantA, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
var bundleId = created.GetProperty("bundleId").GetString();
|
||||
|
||||
// Try to access as tenant B
|
||||
var tenantB = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantB, scopes: $"{StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/evidence/{bundleId}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - Should not be accessible across tenants
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.NotFound, HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_Without_ReadScope_Returns_Forbidden()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
var bundleId = created.GetProperty("bundleId").GetString();
|
||||
|
||||
// Remove read scope
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/evidence/{bundleId}/download", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EVIDENCE-5100-006: OTel Trace Assertions
|
||||
|
||||
[Fact]
|
||||
public async Task StoreArtifact_Emits_OTel_Trace_With_ArtifactId()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
||||
|
||||
var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = source => source.Name.Contains("StellaOps", StringComparison.OrdinalIgnoreCase),
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
ActivityStarted = activity => { },
|
||||
ActivityStopped = activity => { }
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
|
||||
Activity? capturedActivity = null;
|
||||
listener.ActivityStopped = activity =>
|
||||
{
|
||||
if (activity.OperationName.Contains("evidence", StringComparison.OrdinalIgnoreCase) ||
|
||||
activity.DisplayName.Contains("evidence", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
capturedActivity = activity;
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
var bundleId = created.GetProperty("bundleId").GetString();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// The timeline event should contain the bundle ID
|
||||
var timelineEvent = _factory.TimelinePublisher.PublishedEvents.FirstOrDefault();
|
||||
timelineEvent.Should().NotBeNull();
|
||||
timelineEvent.Should().Contain(bundleId!);
|
||||
|
||||
listener.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreArtifact_Timeline_Contains_TenantId()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// Assert
|
||||
var timelineEvents = _factory.TimelinePublisher.PublishedEvents;
|
||||
timelineEvents.Should().NotBeEmpty("Timeline events should be published");
|
||||
|
||||
// The timeline should contain tenant context
|
||||
// Note: Actual assertion depends on how tenant_id is encoded in timeline events
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveArtifact_Emits_Trace_With_BundleId()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
CreateValidSnapshotPayload(),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
var bundleId = created.GetProperty("bundleId").GetString();
|
||||
|
||||
// Clear timeline events before retrieve
|
||||
_factory.TimelinePublisher.ClearEvents();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/evidence/{bundleId}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
// Timeline events may or may not be emitted on read depending on configuration
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Error_Response_Does_Not_Leak_Internal_Details()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceRead);
|
||||
|
||||
// Act - Request non-existent bundle
|
||||
var response = await _client.GetAsync($"/evidence/{Guid.NewGuid()}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
content.Should().NotContain("Exception");
|
||||
content.Should().NotContain("StackTrace");
|
||||
content.Should().NotContain("InnerException");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static object CreateValidSnapshotPayload()
|
||||
{
|
||||
return new
|
||||
{
|
||||
kind = 1, // EvidenceBundleKind.Evaluation
|
||||
metadata = new Dictionary<string, string>
|
||||
{
|
||||
["run"] = "test",
|
||||
["orchestratorJobId"] = $"job-{Guid.NewGuid():N}"
|
||||
},
|
||||
materials = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
section = "inputs",
|
||||
path = "config.json",
|
||||
sha256 = new string('a', 64),
|
||||
sizeBytes = 256L,
|
||||
mediaType = "application/json"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension for test timeline publisher to support clearing events.
|
||||
/// </summary>
|
||||
internal static class TimelinePublisherTestExtensions
|
||||
{
|
||||
public static void ClearEvents(this TestTimelinePublisher publisher)
|
||||
{
|
||||
publisher.PublishedEvents.Clear();
|
||||
publisher.IncidentEvents.Clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user