Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user