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,508 @@
// -----------------------------------------------------------------------------
// FindingsLedgerIntegrationTests.cs
// Sprint: SPRINT_5100_0010_0001_evidencelocker_tests
// Task: FINDINGS-5100-005
// Description: Integration test: event stream → ledger state → replay → verify identical state
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Findings.Ledger.Core.Domain;
using StellaOps.Findings.Ledger.Core.Events;
using StellaOps.Findings.Ledger.Core.Projection;
using StellaOps.Findings.Ledger.Core.Repositories;
namespace StellaOps.Findings.Ledger.Tests;
/// <summary>
/// Integration Tests for Findings Ledger
/// Task FINDINGS-5100-005: event stream → ledger state → replay → verify identical state
/// </summary>
public sealed class FindingsLedgerIntegrationTests
{
#region FINDINGS-5100-005: Event Stream Ledger State Replay Verify Identical
[Fact]
public async Task EventStream_ToLedgerState_Replay_ProducesIdenticalState()
{
// Arrange
var repository = new InMemoryLedgerEventRepository();
var reducer = new LedgerProjectionReducer();
var tenantId = Guid.NewGuid().ToString("D");
var findingId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
// Create a sequence of events
var events = new List<LedgerEvent>
{
new LedgerEvent(
EventId: Guid.NewGuid(),
TenantId: tenantId,
FindingId: findingId,
EventType: LedgerEventType.FindingCreated,
Timestamp: now,
Sequence: 1,
Payload: JsonSerializer.Serialize(new { cveId = "CVE-2024-1234", severity = "critical" }),
Hash: ComputeEventHash(1, "FindingCreated", now)
),
new LedgerEvent(
EventId: Guid.NewGuid(),
TenantId: tenantId,
FindingId: findingId,
EventType: LedgerEventType.StatusChanged,
Timestamp: now.AddMinutes(5),
Sequence: 2,
Payload: JsonSerializer.Serialize(new { previousStatus = "open", newStatus = "investigating" }),
Hash: ComputeEventHash(2, "StatusChanged", now.AddMinutes(5))
),
new LedgerEvent(
EventId: Guid.NewGuid(),
TenantId: tenantId,
FindingId: findingId,
EventType: LedgerEventType.VexApplied,
Timestamp: now.AddMinutes(10),
Sequence: 3,
Payload: JsonSerializer.Serialize(new { vexStatus = "not_affected", justification = "vulnerable_code_not_present" }),
Hash: ComputeEventHash(3, "VexApplied", now.AddMinutes(10))
)
};
// Store events
foreach (var evt in events)
{
await repository.AppendAsync(evt, CancellationToken.None);
}
// Act - Project ledger state (first time)
var firstProjection = await ProjectLedgerStateAsync(repository, reducer, tenantId, findingId);
// Act - Replay events and project again (second time)
var secondProjection = await ProjectLedgerStateAsync(repository, reducer, tenantId, findingId);
// Assert - States should be identical
firstProjection.FindingId.Should().Be(secondProjection.FindingId);
firstProjection.Status.Should().Be(secondProjection.Status);
firstProjection.CycleHash.Should().Be(secondProjection.CycleHash, "Cycle hash should be identical on replay");
firstProjection.EventCount.Should().Be(secondProjection.EventCount);
firstProjection.LastEventTimestamp.Should().Be(secondProjection.LastEventTimestamp);
}
[Fact]
public async Task EventStream_WithSameEvents_ProducesSameStateHash()
{
// Arrange
var repository1 = new InMemoryLedgerEventRepository();
var repository2 = new InMemoryLedgerEventRepository();
var reducer = new LedgerProjectionReducer();
var tenantId = Guid.NewGuid().ToString("D");
var findingId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
// Same events in both repositories
var events = CreateStandardEventSequence(tenantId, findingId, now);
foreach (var evt in events)
{
await repository1.AppendAsync(evt, CancellationToken.None);
await repository2.AppendAsync(evt, CancellationToken.None);
}
// Act
var projection1 = await ProjectLedgerStateAsync(repository1, reducer, tenantId, findingId);
var projection2 = await ProjectLedgerStateAsync(repository2, reducer, tenantId, findingId);
// Assert
projection1.CycleHash.Should().Be(projection2.CycleHash);
}
[Fact]
public async Task EventStream_DifferentEvents_ProducesDifferentStateHash()
{
// Arrange
var repository1 = new InMemoryLedgerEventRepository();
var repository2 = new InMemoryLedgerEventRepository();
var reducer = new LedgerProjectionReducer();
var tenantId = Guid.NewGuid().ToString("D");
var findingId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
// Different events in each repository
var events1 = CreateStandardEventSequence(tenantId, findingId, now);
var events2 = CreateAlternateEventSequence(tenantId, findingId, now);
foreach (var evt in events1)
await repository1.AppendAsync(evt, CancellationToken.None);
foreach (var evt in events2)
await repository2.AppendAsync(evt, CancellationToken.None);
// Act
var projection1 = await ProjectLedgerStateAsync(repository1, reducer, tenantId, findingId);
var projection2 = await ProjectLedgerStateAsync(repository2, reducer, tenantId, findingId);
// Assert - Different events should produce different hashes
projection1.CycleHash.Should().NotBe(projection2.CycleHash);
}
[Fact]
public async Task ReplayMultipleTimes_AlwaysProducesIdenticalState()
{
// Arrange
var repository = new InMemoryLedgerEventRepository();
var reducer = new LedgerProjectionReducer();
var tenantId = Guid.NewGuid().ToString("D");
var findingId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var events = CreateStandardEventSequence(tenantId, findingId, now);
foreach (var evt in events)
await repository.AppendAsync(evt, CancellationToken.None);
// Act - Replay 10 times
var projections = new List<LedgerProjection>();
for (int i = 0; i < 10; i++)
{
var projection = await ProjectLedgerStateAsync(repository, reducer, tenantId, findingId);
projections.Add(projection);
}
// Assert - All projections should be identical
var firstHash = projections[0].CycleHash;
projections.Should().AllSatisfy(p => p.CycleHash.Should().Be(firstHash));
}
[Fact]
public async Task EventStream_AfterAppendingMore_StateUpdatesCorrectly()
{
// Arrange
var repository = new InMemoryLedgerEventRepository();
var reducer = new LedgerProjectionReducer();
var tenantId = Guid.NewGuid().ToString("D");
var findingId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
// Initial events
var initialEvents = new List<LedgerEvent>
{
new LedgerEvent(
EventId: Guid.NewGuid(),
TenantId: tenantId,
FindingId: findingId,
EventType: LedgerEventType.FindingCreated,
Timestamp: now,
Sequence: 1,
Payload: "{}",
Hash: ComputeEventHash(1, "FindingCreated", now)
)
};
foreach (var evt in initialEvents)
await repository.AppendAsync(evt, CancellationToken.None);
// Act - Get initial state
var initialProjection = await ProjectLedgerStateAsync(repository, reducer, tenantId, findingId);
// Append more events
var additionalEvent = new LedgerEvent(
EventId: Guid.NewGuid(),
TenantId: tenantId,
FindingId: findingId,
EventType: LedgerEventType.StatusChanged,
Timestamp: now.AddMinutes(5),
Sequence: 2,
Payload: JsonSerializer.Serialize(new { newStatus = "resolved" }),
Hash: ComputeEventHash(2, "StatusChanged", now.AddMinutes(5))
);
await repository.AppendAsync(additionalEvent, CancellationToken.None);
// Act - Get updated state
var updatedProjection = await ProjectLedgerStateAsync(repository, reducer, tenantId, findingId);
// Assert
updatedProjection.EventCount.Should().Be(initialProjection.EventCount + 1);
updatedProjection.CycleHash.Should().NotBe(initialProjection.CycleHash);
updatedProjection.LastEventTimestamp.Should().Be(now.AddMinutes(5));
}
[Fact]
public async Task ConcurrentReplays_ProduceIdenticalResults()
{
// Arrange
var repository = new InMemoryLedgerEventRepository();
var reducer = new LedgerProjectionReducer();
var tenantId = Guid.NewGuid().ToString("D");
var findingId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var events = CreateStandardEventSequence(tenantId, findingId, now);
foreach (var evt in events)
await repository.AppendAsync(evt, CancellationToken.None);
// Act - Concurrent replays
var tasks = Enumerable.Range(0, 5)
.Select(_ => ProjectLedgerStateAsync(repository, reducer, tenantId, findingId))
.ToArray();
var projections = await Task.WhenAll(tasks);
// Assert
var firstHash = projections[0].CycleHash;
projections.Should().AllSatisfy(p => p.CycleHash.Should().Be(firstHash));
}
#endregion
#region Snapshot Tests
[Fact]
public async Task LedgerState_AtPointInTime_IsReproducible()
{
// Arrange
var repository = new InMemoryLedgerEventRepository();
var reducer = new LedgerProjectionReducer();
var tenantId = Guid.NewGuid().ToString("D");
var findingId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var events = CreateStandardEventSequence(tenantId, findingId, now);
foreach (var evt in events)
await repository.AppendAsync(evt, CancellationToken.None);
// Act - Project at specific point in time (after 2 events)
var snapshotTime = now.AddMinutes(6);
var snapshot1 = await ProjectLedgerStateAtTimeAsync(repository, reducer, tenantId, findingId, snapshotTime);
var snapshot2 = await ProjectLedgerStateAtTimeAsync(repository, reducer, tenantId, findingId, snapshotTime);
// Assert
snapshot1.CycleHash.Should().Be(snapshot2.CycleHash);
snapshot1.EventCount.Should().Be(snapshot2.EventCount);
}
#endregion
#region Helpers
private static async Task<LedgerProjection> ProjectLedgerStateAsync(
InMemoryLedgerEventRepository repository,
LedgerProjectionReducer reducer,
string tenantId,
Guid findingId)
{
var events = await repository.GetEventsAsync(tenantId, findingId, CancellationToken.None);
return reducer.Project(events.ToList());
}
private static async Task<LedgerProjection> ProjectLedgerStateAtTimeAsync(
InMemoryLedgerEventRepository repository,
LedgerProjectionReducer reducer,
string tenantId,
Guid findingId,
DateTimeOffset asOf)
{
var events = await repository.GetEventsAsync(tenantId, findingId, CancellationToken.None);
var filteredEvents = events.Where(e => e.Timestamp <= asOf).ToList();
return reducer.Project(filteredEvents);
}
private static List<LedgerEvent> CreateStandardEventSequence(string tenantId, Guid findingId, DateTimeOffset baseTime)
{
return new List<LedgerEvent>
{
new LedgerEvent(
EventId: Guid.NewGuid(),
TenantId: tenantId,
FindingId: findingId,
EventType: LedgerEventType.FindingCreated,
Timestamp: baseTime,
Sequence: 1,
Payload: JsonSerializer.Serialize(new { cveId = "CVE-2024-1234" }),
Hash: ComputeEventHash(1, "FindingCreated", baseTime)
),
new LedgerEvent(
EventId: Guid.NewGuid(),
TenantId: tenantId,
FindingId: findingId,
EventType: LedgerEventType.StatusChanged,
Timestamp: baseTime.AddMinutes(5),
Sequence: 2,
Payload: JsonSerializer.Serialize(new { newStatus = "investigating" }),
Hash: ComputeEventHash(2, "StatusChanged", baseTime.AddMinutes(5))
),
new LedgerEvent(
EventId: Guid.NewGuid(),
TenantId: tenantId,
FindingId: findingId,
EventType: LedgerEventType.VexApplied,
Timestamp: baseTime.AddMinutes(10),
Sequence: 3,
Payload: JsonSerializer.Serialize(new { vexStatus = "not_affected" }),
Hash: ComputeEventHash(3, "VexApplied", baseTime.AddMinutes(10))
)
};
}
private static List<LedgerEvent> CreateAlternateEventSequence(string tenantId, Guid findingId, DateTimeOffset baseTime)
{
return new List<LedgerEvent>
{
new LedgerEvent(
EventId: Guid.NewGuid(),
TenantId: tenantId,
FindingId: findingId,
EventType: LedgerEventType.FindingCreated,
Timestamp: baseTime,
Sequence: 1,
Payload: JsonSerializer.Serialize(new { cveId = "CVE-2024-5678" }), // Different CVE
Hash: ComputeEventHash(1, "FindingCreated", baseTime)
),
new LedgerEvent(
EventId: Guid.NewGuid(),
TenantId: tenantId,
FindingId: findingId,
EventType: LedgerEventType.StatusChanged,
Timestamp: baseTime.AddMinutes(5),
Sequence: 2,
Payload: JsonSerializer.Serialize(new { newStatus = "resolved" }), // Different status
Hash: ComputeEventHash(2, "StatusChanged", baseTime.AddMinutes(5))
)
};
}
private static string ComputeEventHash(int sequence, string eventType, DateTimeOffset timestamp)
{
var input = $"{sequence}:{eventType}:{timestamp:O}";
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
#endregion
}
#region Supporting Types (if not available in the project)
/// <summary>
/// Simplified in-memory repository for testing.
/// </summary>
internal class InMemoryLedgerEventRepository
{
private readonly List<LedgerEvent> _events = new();
private readonly object _lock = new();
public Task AppendAsync(LedgerEvent evt, CancellationToken ct)
{
lock (_lock)
{
_events.Add(evt);
}
return Task.CompletedTask;
}
public Task<IEnumerable<LedgerEvent>> GetEventsAsync(string tenantId, Guid findingId, CancellationToken ct)
{
lock (_lock)
{
var filtered = _events
.Where(e => e.TenantId == tenantId && e.FindingId == findingId)
.OrderBy(e => e.Sequence)
.ToList();
return Task.FromResult<IEnumerable<LedgerEvent>>(filtered);
}
}
}
/// <summary>
/// Simplified projection reducer for testing.
/// </summary>
internal class LedgerProjectionReducer
{
public LedgerProjection Project(IList<LedgerEvent> events)
{
if (events.Count == 0)
return new LedgerProjection(Guid.Empty, "unknown", "", 0, DateTimeOffset.MinValue);
var findingId = events[0].FindingId;
var status = "open";
var lastTimestamp = events[0].Timestamp;
foreach (var evt in events)
{
if (evt.EventType == LedgerEventType.StatusChanged)
{
using var doc = JsonDocument.Parse(evt.Payload);
if (doc.RootElement.TryGetProperty("newStatus", out var newStatus))
{
status = newStatus.GetString() ?? status;
}
}
if (evt.Timestamp > lastTimestamp)
lastTimestamp = evt.Timestamp;
}
// Compute cycle hash from all events
var cycleHash = ComputeCycleHash(events);
return new LedgerProjection(findingId, status, cycleHash, events.Count, lastTimestamp);
}
private static string ComputeCycleHash(IList<LedgerEvent> events)
{
using var sha256 = SHA256.Create();
var combined = new StringBuilder();
foreach (var evt in events.OrderBy(e => e.Sequence))
{
combined.Append(evt.Hash);
}
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(combined.ToString()));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
/// <summary>
/// Ledger event type enumeration.
/// </summary>
internal enum LedgerEventType
{
FindingCreated,
StatusChanged,
VexApplied,
LabelAdded,
LabelRemoved
}
/// <summary>
/// Ledger event record.
/// </summary>
internal record LedgerEvent(
Guid EventId,
string TenantId,
Guid FindingId,
LedgerEventType EventType,
DateTimeOffset Timestamp,
int Sequence,
string Payload,
string Hash
);
/// <summary>
/// Ledger projection record.
/// </summary>
internal record LedgerProjection(
Guid FindingId,
string Status,
string CycleHash,
int EventCount,
DateTimeOffset LastEventTimestamp
);
#endregion

View File

@@ -0,0 +1,297 @@
// -----------------------------------------------------------------------------
// FindingsLedgerWebServiceContractTests.cs
// Sprint: SPRINT_5100_0010_0001_evidencelocker_tests
// Task: FINDINGS-5100-004
// Description: W1 contract tests for Findings.Ledger.WebService (query findings, replay events)
// -----------------------------------------------------------------------------
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 Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Findings.Ledger.Tests;
/// <summary>
/// W1 Contract Tests for Findings.Ledger.WebService
/// Task FINDINGS-5100-004: OpenAPI schema snapshot validation for findings queries and replay
/// </summary>
public sealed class FindingsLedgerWebServiceContractTests : IDisposable
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
private bool _disposed;
public FindingsLedgerWebServiceContractTests()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Configure test services as needed
});
});
_client = _factory.CreateClient();
}
#region GET /api/v1/findings/{findingId}/summary
[Fact]
public async Task GetFindingSummary_ValidId_Returns_Expected_Schema()
{
// Arrange
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
var findingId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/v1/findings/{findingId}/summary");
// Assert - Should be NotFound for non-existent, but schema should be correct
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
if (response.StatusCode == HttpStatusCode.OK)
{
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
// Verify FindingSummary schema
root.TryGetProperty("findingId", out _).Should().BeTrue("findingId should be present");
root.TryGetProperty("status", out _).Should().BeTrue("status should be present");
}
}
[Fact]
public async Task GetFindingSummary_InvalidGuid_Returns_BadRequest()
{
// Arrange
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
// Act
var response = await _client.GetAsync("/api/v1/findings/not-a-guid/summary");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task GetFindingSummary_NotFound_Returns_404()
{
// Arrange
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
var nonExistentId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/v1/findings/{nonExistentId}/summary");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
#endregion
#region GET /api/v1/findings/summaries (Paginated)
[Fact]
public async Task GetFindingSummaries_Returns_Paginated_Schema()
{
// Arrange
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
// Act
var response = await _client.GetAsync("/api/v1/findings/summaries?page=1&pageSize=10");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
// Verify FindingSummaryPage schema
root.TryGetProperty("items", out var items).Should().BeTrue("items should be present");
items.ValueKind.Should().Be(JsonValueKind.Array);
root.TryGetProperty("totalCount", out _).Should().BeTrue("totalCount should be present");
root.TryGetProperty("page", out _).Should().BeTrue("page should be present");
root.TryGetProperty("pageSize", out _).Should().BeTrue("pageSize should be present");
}
[Fact]
public async Task GetFindingSummaries_With_Filters_Returns_Filtered_Results()
{
// Arrange
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
// Act
var response = await _client.GetAsync(
"/api/v1/findings/summaries?status=open&severity=critical&minConfidence=0.8");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task GetFindingSummaries_PageSize_Clamped_To_100()
{
// Arrange
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
// Act
var response = await _client.GetAsync("/api/v1/findings/summaries?page=1&pageSize=500");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
// pageSize should be clamped to max 100
var pageSize = doc.RootElement.GetProperty("pageSize").GetInt32();
pageSize.Should().BeLessThanOrEqualTo(100);
}
#endregion
#region Auth Tests
[Fact]
public async Task GetFindingSummary_Without_Auth_Returns_Unauthorized()
{
// Arrange - No auth headers
_client.DefaultRequestHeaders.Clear();
// Act
var response = await _client.GetAsync($"/api/v1/findings/{Guid.NewGuid()}/summary");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task GetFindingSummaries_Without_Auth_Returns_Unauthorized()
{
// Arrange - No auth headers
_client.DefaultRequestHeaders.Clear();
// Act
var response = await _client.GetAsync("/api/v1/findings/summaries");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
#endregion
#region Evidence Graph Endpoints
[Fact]
public async Task EvidenceGraph_Endpoint_Exists()
{
// Arrange
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
// Act
var response = await _client.GetAsync($"/api/v1/evidence/graph/{Guid.NewGuid()}");
// Assert - Should return NotFound for non-existent, but endpoint should exist
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
#endregion
#region Reachability Map Endpoints
[Fact]
public async Task ReachabilityMap_Endpoint_Exists()
{
// Arrange
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
// Act
var response = await _client.GetAsync($"/api/v1/reachability/{Guid.NewGuid()}/map");
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
#endregion
#region Runtime Timeline Endpoints
[Fact]
public async Task RuntimeTimeline_Endpoint_Exists()
{
// Arrange
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
// Act
var response = await _client.GetAsync($"/api/v1/runtime/timeline/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
#endregion
#region Contract Schema Validation
[Fact]
public async Task FindingSummary_Schema_Has_Required_Fields()
{
// This test validates the FindingSummary contract has all expected fields
// by checking the OpenAPI schema or response examples
// Arrange
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
// Act - Get the OpenAPI spec
var response = await _client.GetAsync("/swagger/v1/swagger.json");
// Assert
if (response.StatusCode == HttpStatusCode.OK)
{
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
// Navigate to FindingSummary schema
if (doc.RootElement.TryGetProperty("components", out var components) &&
components.TryGetProperty("schemas", out var schemas) &&
schemas.TryGetProperty("FindingSummary", out var findingSummarySchema))
{
// Verify required properties
if (findingSummarySchema.TryGetProperty("properties", out var props))
{
props.TryGetProperty("findingId", out _).Should().BeTrue();
props.TryGetProperty("status", out _).Should().BeTrue();
}
}
}
}
#endregion
#region Helpers
private static void ConfigureAuthHeaders(HttpClient client, string tenantId)
{
client.DefaultRequestHeaders.Clear();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId);
client.DefaultRequestHeaders.Add("X-Scopes", "findings:read findings:write");
}
public void Dispose()
{
if (_disposed) return;
_client.Dispose();
_factory.Dispose();
_disposed = true;
}
#endregion
}