Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
This commit is contained in:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using FluentAssertions.Specialized;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.IssuerDirectory.Client;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests;
public class IssuerDirectoryClientTests
{
private static IIssuerDirectoryClient CreateClient(RecordingHandler handler, IssuerDirectoryClientOptions? options = null)
{
var opts = options ?? DefaultOptions();
var httpClient = new HttpClient(handler)
{
BaseAddress = opts.BaseAddress
};
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var clientOptions = Options.Create(opts);
var clientType = typeof(IssuerDirectoryClientOptions)
.Assembly
.GetType("StellaOps.IssuerDirectory.Client.IssuerDirectoryClient", throwOnError: true)!;
var loggerType = typeof(TestLogger<>).MakeGenericType(clientType);
var logger = Activator.CreateInstance(loggerType)!;
var instance = Activator.CreateInstance(
clientType,
new object[] { httpClient, memoryCache, clientOptions, logger });
return (IIssuerDirectoryClient)instance!;
}
private static IssuerDirectoryClientOptions DefaultOptions()
{
return new IssuerDirectoryClientOptions
{
BaseAddress = new Uri("https://issuer-directory.local/"),
TenantHeader = "X-StellaOps-Tenant",
AuditReasonHeader = "X-StellaOps-Reason"
};
}
[Fact]
public async Task SetIssuerTrustAsync_SendsAuditMetadataAndInvalidatesCache()
{
var handler = new RecordingHandler(
CreateJsonResponse("""
{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}
"""),
CreateJsonResponse("""
{"tenantOverride":{"weight":1.5,"reason":"rollout","updatedAtUtc":"2025-11-03T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-03T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":1.5}
"""),
CreateJsonResponse("""
{"tenantOverride":{"weight":1.5,"reason":"rollout","updatedAtUtc":"2025-11-03T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-03T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":1.5}
"""));
var client = CreateClient(handler);
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
handler.Requests.Should().HaveCount(1);
var result = await client.SetIssuerTrustAsync("tenant-a", "issuer-1", 1.5m, "rollout", CancellationToken.None);
result.EffectiveWeight.Should().Be(1.5m);
handler.Requests.Should().HaveCount(2);
var putRequest = handler.Requests[1];
putRequest.Method.Should().Be(HttpMethod.Put);
putRequest.Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-1/trust"));
putRequest.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantValues).Should().BeTrue();
tenantValues.Should().NotBeNull();
tenantValues!.Should().Equal("tenant-a");
putRequest.Headers.TryGetValue("X-StellaOps-Reason", out var reasonValues).Should().BeTrue();
reasonValues.Should().NotBeNull();
reasonValues!.Should().Equal("rollout");
using var document = JsonDocument.Parse(putRequest.Body ?? string.Empty);
var root = document.RootElement;
root.GetProperty("weight").GetDecimal().Should().Be(1.5m);
root.GetProperty("reason").GetString().Should().Be("rollout");
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
handler.Requests.Should().HaveCount(3);
handler.Requests[2].Method.Should().Be(HttpMethod.Get);
}
[Fact]
public async Task DeleteIssuerTrustAsync_UsesDeleteVerbAndReasonHeaderWhenProvided()
{
var handler = new RecordingHandler(
CreateJsonResponse("""
{"tenantOverride":{"weight":2.0,"reason":"seed","updatedAtUtc":"2025-11-02T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-02T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":2.0}
"""),
new HttpResponseMessage(HttpStatusCode.NoContent),
CreateJsonResponse("""
{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}
"""));
var client = CreateClient(handler);
await client.GetIssuerTrustAsync("tenant-b", "issuer-9", includeGlobal: true, CancellationToken.None);
handler.Requests.Should().HaveCount(1);
await client.DeleteIssuerTrustAsync("tenant-b", "issuer-9", null, CancellationToken.None);
handler.Requests.Should().HaveCount(2);
var deleteRequest = handler.Requests[1];
deleteRequest.Method.Should().Be(HttpMethod.Delete);
deleteRequest.Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-9/trust"));
deleteRequest.Headers.ContainsKey("X-StellaOps-Tenant").Should().BeTrue();
deleteRequest.Headers.ContainsKey("X-StellaOps-Reason").Should().BeFalse();
deleteRequest.Body.Should().BeNull();
await client.GetIssuerTrustAsync("tenant-b", "issuer-9", includeGlobal: true, CancellationToken.None);
handler.Requests.Should().HaveCount(3);
handler.Requests[2].Method.Should().Be(HttpMethod.Get);
}
[Fact]
public async Task SetIssuerTrustAsync_PropagatesFailureAndDoesNotEvictCache()
{
var handler = new RecordingHandler(
CreateJsonResponse("""
{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}
"""),
new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json")
});
var client = CreateClient(handler);
var cached = await client.GetIssuerTrustAsync("tenant-c", "issuer-err", includeGlobal: false, CancellationToken.None);
cached.EffectiveWeight.Should().Be(0m);
handler.Requests.Should().HaveCount(1);
await FluentActions.Invoking(() => client.SetIssuerTrustAsync("tenant-c", "issuer-err", 0.5m, null, CancellationToken.None).AsTask())
.Should().ThrowAsync<HttpRequestException>();
handler.Requests.Should().HaveCount(2);
await client.GetIssuerTrustAsync("tenant-c", "issuer-err", includeGlobal: false, CancellationToken.None);
handler.Requests.Should().HaveCount(2, "cache should remain warm after failure");
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
private sealed record RecordedRequest(HttpMethod Method, Uri Uri, IDictionary<string, string[]> Headers, string? Body);
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses;
public RecordingHandler(params HttpResponseMessage[] responses)
{
_responses = new Queue<HttpResponseMessage>(responses);
}
public List<RecordedRequest> Requests { get; } = new();
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string? body = null;
if (request.Content is not null)
{
body = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
var headers = request.Headers.ToDictionary(
pair => pair.Key,
pair => pair.Value.ToArray());
if (request.Content?.Headers is not null)
{
foreach (var header in request.Content.Headers)
{
headers[header.Key] = header.Value.ToArray();
}
}
Requests.Add(new RecordedRequest(request.Method, request.RequestUri!, headers, body));
if (_responses.Count == 0)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json")
};
}
return _responses.Dequeue();
}
}
private sealed class TestLogger<T> : ILogger<T>
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullDisposable.Instance;
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
}
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -12,5 +12,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.IssuerDirectory.Core\StellaOps.IssuerDirectory.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.IssuerDirectory.Client\StellaOps.IssuerDirectory.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.IssuerDirectory.Core.Observability;
@@ -20,33 +21,39 @@ internal static class IssuerDirectoryMetrics
public static void RecordIssuerChange(string tenantId, string issuerId, string action)
{
IssuerChangeCounter.Add(1, new TagList
{
{ "tenant", NormalizeTag(tenantId) },
{ "issuer", NormalizeTag(issuerId) },
{ "action", action }
});
IssuerChangeCounter.Add(
1,
new[]
{
new KeyValuePair<string, object?>("tenant", NormalizeTag(tenantId)),
new KeyValuePair<string, object?>("issuer", NormalizeTag(issuerId)),
new KeyValuePair<string, object?>("action", action)
});
}
public static void RecordKeyOperation(string tenantId, string issuerId, string operation, string keyType)
{
KeyOperationCounter.Add(1, new TagList
{
{ "tenant", NormalizeTag(tenantId) },
{ "issuer", NormalizeTag(issuerId) },
{ "operation", operation },
{ "key_type", keyType }
});
KeyOperationCounter.Add(
1,
new[]
{
new KeyValuePair<string, object?>("tenant", NormalizeTag(tenantId)),
new KeyValuePair<string, object?>("issuer", NormalizeTag(issuerId)),
new KeyValuePair<string, object?>("operation", operation),
new KeyValuePair<string, object?>("key_type", keyType)
});
}
public static void RecordKeyValidationFailure(string tenantId, string issuerId, string reason)
{
KeyValidationFailureCounter.Add(1, new TagList
{
{ "tenant", NormalizeTag(tenantId) },
{ "issuer", NormalizeTag(issuerId) },
{ "reason", reason }
});
KeyValidationFailureCounter.Add(
1,
new[]
{
new KeyValuePair<string, object?>("tenant", NormalizeTag(tenantId)),
new KeyValuePair<string, object?>("issuer", NormalizeTag(issuerId)),
new KeyValuePair<string, object?>("reason", reason)
});
}
private static string NormalizeTag(string? value)

View File

@@ -6,4 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
</Project>

View File

@@ -3,9 +3,10 @@
|----|--------|----------|------------|-------------|---------------|
| ISSUER-30-001 | DONE (2025-11-01) | Issuer Directory Guild | AUTH-VULN-29-001 | Implement issuer CRUD API with RBAC, audit logging, and tenant scoping; seed CSAF publisher metadata. | APIs deployed; audit logs capture actor/reason; seed data imported; tests cover RBAC. |
| ISSUER-30-002 | DONE (2025-11-01) | Issuer Directory Guild, Security Guild | ISSUER-30-001 | Implement key management endpoints (add/rotate/revoke keys), enforce expiry, validate formats (Ed25519, X.509, DSSE). | Keys stored securely; expiry enforced; validation tests cover key types; docs updated. |
| ISSUER-30-003 | DOING | Issuer Directory Guild, Policy Guild | ISSUER-30-001 | Provide trust weight APIs and tenant overrides with validation (+/- bounds) and audit trails. | Trust overrides persisted; policy integration confirmed; tests cover overrides. |
| ISSUER-30-003 | DONE (2025-11-03) | Issuer Directory Guild, Policy Guild | ISSUER-30-001 | Provide trust weight APIs and tenant overrides with validation (+/- bounds) and audit trails. | Trust overrides persisted; policy integration confirmed; tests cover overrides. |
| ISSUER-30-004 | DONE (2025-11-01) | Issuer Directory Guild, VEX Lens Guild | ISSUER-30-001..003 | Integrate with VEX Lens and Excitor signature verification (client SDK, caching, retries). | Lens/Excitor resolve issuer metadata via SDK; integration tests cover network failures. |
| ISSUER-30-005 | DONE (2025-11-01) | Issuer Directory Guild, Observability Guild | ISSUER-30-001..004 | Instrument metrics/logs (issuer changes, key rotation, verification failures) and dashboards/alerts. | Telemetry live; alerts configured; docs updated. |
| ISSUER-30-006 | DONE (2025-11-02) | Issuer Directory Guild, DevOps Guild | ISSUER-30-001..005 | Provide deployment manifests, backup/restore, secure secret storage, and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup tested; offline kit updated. |
> 2025-11-01: Excititor worker now queries Issuer Directory via during attestation verification, caching active key metadata and trust weights for tenant/global scopes.
> 2025-11-03: Trust override APIs/client helpers merged; reflection-based client tests cover cache eviction and failure paths; Issuer Directory Core tests passed.