product advisories, stella router improval, tests streghthening

This commit is contained in:
StellaOps Bot
2025-12-24 14:20:26 +02:00
parent 5540ce9430
commit 2c2bbf1005
171 changed files with 58943 additions and 135 deletions

View File

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

View File

@@ -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();
}
}