489 lines
18 KiB
C#
489 lines
18 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <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)
|
|
|
|
[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<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);
|
|
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<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");
|
|
}
|
|
|
|
[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<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);
|
|
}
|
|
|
|
[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<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);
|
|
}
|
|
|
|
[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<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
|
|
|
|
[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<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();
|
|
}
|
|
|
|
[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<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
|
|
}
|
|
|
|
[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<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();
|
|
}
|
|
}
|