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:
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Excititor.Attestation.Tests")]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user