// -----------------------------------------------------------------------------
// 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;
using StellaOps.TestKit;
namespace StellaOps.EvidenceLocker.Tests;
///
/// 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)
///
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)
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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(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);
using StellaOps.TestKit;
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");
}
[Trait("Category", TestCategories.Unit)]
[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(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");
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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(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);
}
[Trait("Category", TestCategories.Unit)]
[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(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);
}
[Trait("Category", TestCategories.Unit)]
[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(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
[Trait("Category", TestCategories.Unit)]
[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 _) => 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(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();
}
[Trait("Category", TestCategories.Unit)]
[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
}
[Trait("Category", TestCategories.Unit)]
[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(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
}
[Trait("Category", TestCategories.Unit)]
[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
{
["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
}
///
/// Extension for test timeline publisher to support clearing events.
///
internal static class TimelinePublisherTestExtensions
{
public static void ClearEvents(this TestTimelinePublisher publisher)
{
publisher.PublishedEvents.Clear();
publisher.IncidentEvents.Clear();
}
}