Add unit tests and implementations for MongoDB index models and OpenAPI metadata

- Implemented `MongoIndexModelTests` to verify index models for various stores.
- Created `OpenApiMetadataFactory` with methods to generate OpenAPI metadata.
- Added tests for `OpenApiMetadataFactory` to ensure expected defaults and URL overrides.
- Introduced `ObserverSurfaceSecrets` and `WebhookSurfaceSecrets` for managing secrets.
- Developed `RuntimeSurfaceFsClient` and `WebhookSurfaceFsClient` for manifest retrieval.
- Added dependency injection tests for `SurfaceEnvironmentRegistration` in both Observer and Webhook contexts.
- Implemented tests for secret resolution in `ObserverSurfaceSecretsTests` and `WebhookSurfaceSecretsTests`.
- Created `EnsureLinkNotMergeCollectionsMigrationTests` to validate MongoDB migration logic.
- Added project files for MongoDB tests and NuGet package mirroring.
This commit is contained in:
master
2025-11-17 21:21:56 +02:00
parent d3128aec24
commit 9075bad2d9
146 changed files with 152183 additions and 82 deletions

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Excititor.Attestation.Tests")]

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Immutable timeline event emitted for ingest/linkset changes with deterministic field ordering.
/// </summary>
public sealed record TimelineEvent
{
public TimelineEvent(
string eventId,
string tenant,
string providerId,
string streamId,
string eventType,
string traceId,
string justificationSummary,
DateTimeOffset createdAt,
string? evidenceHash = null,
string? payloadHash = null,
ImmutableDictionary<string, string>? attributes = null)
{
EventId = Ensure(eventId, nameof(eventId));
Tenant = Ensure(tenant, nameof(tenant)).ToLowerInvariant();
ProviderId = Ensure(providerId, nameof(providerId));
StreamId = Ensure(streamId, nameof(streamId));
EventType = Ensure(eventType, nameof(eventType));
TraceId = Ensure(traceId, nameof(traceId));
JustificationSummary = justificationSummary?.Trim() ?? string.Empty;
EvidenceHash = evidenceHash?.Trim();
PayloadHash = payloadHash?.Trim();
CreatedAt = createdAt;
Attributes = Normalize(attributes);
}
public string EventId { get; }
public string Tenant { get; }
public string ProviderId { get; }
public string StreamId { get; }
public string EventType { get; }
public string TraceId { get; }
public string JustificationSummary { get; }
public string? EvidenceHash { get; }
public string? PayloadHash { get; }
public DateTimeOffset CreatedAt { get; }
public ImmutableDictionary<string, string> Attributes { get; }
private static string Ensure(string value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{name} cannot be null or whitespace", name);
}
return value.Trim();
}
private static ImmutableDictionary<string, string> Normalize(ImmutableDictionary<string, string>? attributes)
{
if (attributes is null || attributes.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var kv in attributes)
{
if (string.IsNullOrWhiteSpace(kv.Key) || kv.Value is null)
{
continue;
}
builder[kv.Key.Trim()] = kv.Value;
}
return builder.ToImmutable();
}
}

View File

@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Aggregation-only attestation payload describing evidence supplier identity and the observation/linkset it covers.
/// Used by Advisory AI / Policy to chain trust without Excititor interpreting verdicts.
/// </summary>
public sealed record VexAttestationPayload
{
public VexAttestationPayload(
string attestationId,
string supplierId,
string observationId,
string linksetId,
string vulnerabilityId,
string productKey,
string? justificationSummary,
DateTimeOffset issuedAt,
ImmutableDictionary<string, string>? metadata = null)
{
AttestationId = EnsureNotNullOrWhiteSpace(attestationId, nameof(attestationId));
SupplierId = EnsureNotNullOrWhiteSpace(supplierId, nameof(supplierId));
ObservationId = EnsureNotNullOrWhiteSpace(observationId, nameof(observationId));
LinksetId = EnsureNotNullOrWhiteSpace(linksetId, nameof(linksetId));
VulnerabilityId = EnsureNotNullOrWhiteSpace(vulnerabilityId, nameof(vulnerabilityId));
ProductKey = EnsureNotNullOrWhiteSpace(productKey, nameof(productKey));
JustificationSummary = TrimToNull(justificationSummary);
IssuedAt = issuedAt.ToUniversalTime();
Metadata = NormalizeMetadata(metadata);
}
public string AttestationId { get; }
public string SupplierId { get; }
public string ObservationId { get; }
public string LinksetId { get; }
public string VulnerabilityId { get; }
public string ProductKey { get; }
public string? JustificationSummary { get; }
public DateTimeOffset IssuedAt { get; }
public ImmutableDictionary<string, string> Metadata { get; }
private static ImmutableDictionary<string, string> NormalizeMetadata(ImmutableDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
var key = TrimToNull(pair.Key);
var value = TrimToNull(pair.Value);
if (key is null || value is null)
{
continue;
}
builder[key] = value;
}
return builder.ToImmutable();
}
private static string EnsureNotNullOrWhiteSpace(string value, string name)
=> string.IsNullOrWhiteSpace(value) ? throw new ArgumentException($"{name} must be provided.", name) : value.Trim();
private static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <summary>
/// Lightweight mapping from attestation IDs back to the observation/linkset/product tuple for provenance tracing.
/// </summary>
public sealed record VexAttestationLink
{
public VexAttestationLink(string attestationId, string observationId, string linksetId, string productKey)
{
AttestationId = EnsureNotNullOrWhiteSpace(attestationId, nameof(attestationId));
ObservationId = EnsureNotNullOrWhiteSpace(observationId, nameof(observationId));
LinksetId = EnsureNotNullOrWhiteSpace(linksetId, nameof(linksetId));
ProductKey = EnsureNotNullOrWhiteSpace(productKey, nameof(productKey));
}
public string AttestationId { get; }
public string ObservationId { get; }
public string LinksetId { get; }
public string ProductKey { get; }
private static string EnsureNotNullOrWhiteSpace(string value, string name)
=> string.IsNullOrWhiteSpace(value) ? throw new ArgumentException($"{name} must be provided.", name) : value.Trim();
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public interface IVexAttestationLinkStore
{
ValueTask UpsertAsync(VexAttestationPayload payload, CancellationToken cancellationToken);
ValueTask<VexAttestationPayload?> FindAsync(string attestationId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexAttestationLinkStore : IVexAttestationLinkStore
{
private readonly IMongoCollection<VexAttestationLinkRecord> _collection;
public MongoVexAttestationLinkStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<VexAttestationLinkRecord>(VexMongoCollectionNames.Attestations);
}
public async ValueTask UpsertAsync(VexAttestationPayload payload, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(payload);
var record = VexAttestationLinkRecord.FromDomain(payload);
var filter = Builders<VexAttestationLinkRecord>.Filter.Eq(x => x.AttestationId, record.AttestationId);
var options = new ReplaceOptions { IsUpsert = true };
await _collection.ReplaceOneAsync(filter, record, options, cancellationToken).ConfigureAwait(false);
}
public async ValueTask<VexAttestationPayload?> FindAsync(string attestationId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(attestationId))
{
throw new ArgumentException("Attestation id must be provided.", nameof(attestationId));
}
var filter = Builders<VexAttestationLinkRecord>.Filter.Eq(x => x.AttestationId, attestationId.Trim());
var record = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return record?.ToDomain();
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using MongoDB.Bson.Serialization.Attributes;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
[BsonIgnoreExtraElements]
internal sealed class VexAttestationLinkRecord
{
[BsonId]
public string AttestationId { get; set; } = default!;
public string SupplierId { get; set; } = default!;
public string ObservationId { get; set; } = default!;
public string LinksetId { get; set; } = default!;
public string VulnerabilityId { get; set; } = default!;
public string ProductKey { get; set; } = default!;
public string? JustificationSummary { get; set; }
= null;
public DateTime IssuedAt { get; set; }
= DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc);
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public static VexAttestationLinkRecord FromDomain(VexAttestationPayload payload)
=> new()
{
AttestationId = payload.AttestationId,
SupplierId = payload.SupplierId,
ObservationId = payload.ObservationId,
LinksetId = payload.LinksetId,
VulnerabilityId = payload.VulnerabilityId,
ProductKey = payload.ProductKey,
JustificationSummary = payload.JustificationSummary,
IssuedAt = payload.IssuedAt.UtcDateTime,
Metadata = payload.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal),
};
public VexAttestationPayload ToDomain()
{
var metadata = (Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal))
.ToImmutableDictionary(StringComparer.Ordinal);
return new VexAttestationPayload(
AttestationId,
SupplierId,
ObservationId,
LinksetId,
VulnerabilityId,
ProductKey,
JustificationSummary,
new DateTimeOffset(DateTime.SpecifyKind(IssuedAt, DateTimeKind.Utc)),
metadata);
}
}

View File

@@ -70,10 +70,11 @@ public static class VexMongoCollectionNames
public const string Providers = "vex.providers";
public const string Raw = "vex.raw";
public const string Statements = "vex.statements";
public const string Claims = Statements;
public const string Consensus = "vex.consensus";
public const string Claims = Statements;
public const string Consensus = "vex.consensus";
public const string Exports = "vex.exports";
public const string Cache = "vex.cache";
public const string ConnectorState = "vex.connector_state";
public const string ConsensusHolds = "vex.consensus_holds";
public const string Attestations = "vex.attestations";
}