up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 00:45:16 +02:00
parent 3b96b2e3ea
commit 1c6730a1d2
95 changed files with 14504 additions and 463 deletions

View File

@@ -0,0 +1,195 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Models.Observations;
using StellaOps.Concelier.RawModels;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Aoc;
/// <summary>
/// Tests for <see cref="AdvisoryObservationWriteGuard"/> verifying append-only semantics
/// per LNM-21-004.
/// </summary>
public sealed class AdvisoryObservationWriteGuardTests
{
private readonly AdvisoryObservationWriteGuard _guard;
public AdvisoryObservationWriteGuardTests()
{
_guard = new AdvisoryObservationWriteGuard(NullLogger<AdvisoryObservationWriteGuard>.Instance);
}
[Fact]
public void ValidateWrite_NewObservation_ReturnsProceed()
{
// Arrange
var observation = CreateObservation("obs-1", "sha256:abc123");
// Act
var result = _guard.ValidateWrite(observation, existingContentHash: null);
// Assert
result.Should().Be(ObservationWriteDisposition.Proceed);
}
[Fact]
public void ValidateWrite_NewObservation_WithEmptyExistingHash_ReturnsProceed()
{
// Arrange
var observation = CreateObservation("obs-2", "sha256:def456");
// Act
var result = _guard.ValidateWrite(observation, existingContentHash: "");
// Assert
result.Should().Be(ObservationWriteDisposition.Proceed);
}
[Fact]
public void ValidateWrite_NewObservation_WithWhitespaceExistingHash_ReturnsProceed()
{
// Arrange
var observation = CreateObservation("obs-3", "sha256:ghi789");
// Act
var result = _guard.ValidateWrite(observation, existingContentHash: " ");
// Assert
result.Should().Be(ObservationWriteDisposition.Proceed);
}
[Fact]
public void ValidateWrite_IdenticalContent_ReturnsSkipIdentical()
{
// Arrange
const string contentHash = "sha256:abc123";
var observation = CreateObservation("obs-4", contentHash);
// Act
var result = _guard.ValidateWrite(observation, existingContentHash: contentHash);
// Assert
result.Should().Be(ObservationWriteDisposition.SkipIdentical);
}
[Fact]
public void ValidateWrite_IdenticalContent_CaseInsensitive_ReturnsSkipIdentical()
{
// Arrange
var observation = CreateObservation("obs-5", "SHA256:ABC123");
// Act
var result = _guard.ValidateWrite(observation, existingContentHash: "sha256:abc123");
// Assert
result.Should().Be(ObservationWriteDisposition.SkipIdentical);
}
[Fact]
public void ValidateWrite_DifferentContent_ReturnsRejectMutation()
{
// Arrange
var observation = CreateObservation("obs-6", "sha256:newcontent");
// Act
var result = _guard.ValidateWrite(observation, existingContentHash: "sha256:oldcontent");
// Assert
result.Should().Be(ObservationWriteDisposition.RejectMutation);
}
[Fact]
public void ValidateWrite_NullObservation_ThrowsArgumentNullException()
{
// Act
var act = () => _guard.ValidateWrite(null!, existingContentHash: null);
// Assert
act.Should().Throw<ArgumentNullException>()
.WithParameterName("observation");
}
[Theory]
[InlineData("sha256:a", "sha256:b")]
[InlineData("sha256:hash1", "sha256:hash2")]
[InlineData("md5:abc", "sha256:abc")]
public void ValidateWrite_ContentMismatch_ReturnsRejectMutation(string newHash, string existingHash)
{
// Arrange
var observation = CreateObservation("obs-mutation", newHash);
// Act
var result = _guard.ValidateWrite(observation, existingHash);
// Assert
result.Should().Be(ObservationWriteDisposition.RejectMutation);
}
[Theory]
[InlineData("sha256:identical")]
[InlineData("SHA256:IDENTICAL")]
[InlineData("sha512:longerhash1234567890")]
public void ValidateWrite_ExactMatch_ReturnsSkipIdentical(string hash)
{
// Arrange
var observation = CreateObservation("obs-idempotent", hash);
// Act
var result = _guard.ValidateWrite(observation, hash);
// Assert
result.Should().Be(ObservationWriteDisposition.SkipIdentical);
}
private static AdvisoryObservation CreateObservation(string observationId, string contentHash)
{
var source = new AdvisoryObservationSource(
vendor: "test-vendor",
stream: "test-stream",
api: "test-api",
collectorVersion: "1.0.0");
var signature = new AdvisoryObservationSignature(
present: false,
format: null,
keyId: null,
signature: null);
var upstream = new AdvisoryObservationUpstream(
upstreamId: $"upstream-{observationId}",
documentVersion: "1.0",
fetchedAt: DateTimeOffset.UtcNow,
receivedAt: DateTimeOffset.UtcNow,
contentHash: contentHash,
signature: signature);
var content = new AdvisoryObservationContent(
format: "csaf",
specVersion: "2.0",
raw: JsonNode.Parse("{\"test\": true}")!);
var linkset = new AdvisoryObservationLinkset(
aliases: new[] { "CVE-2024-0001" },
purls: null,
cpes: null,
references: null);
var rawLinkset = new RawLinkset
{
Aliases = ImmutableArray.Create("CVE-2024-0001")
};
return new AdvisoryObservation(
observationId: observationId,
tenant: "test-tenant",
source: source,
upstream: upstream,
content: content,
linkset: linkset,
rawLinkset: rawLinkset,
createdAt: DateTimeOffset.UtcNow);
}
}

View File

@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Concelier.Core.Linksets;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Linksets;
/// <summary>
/// Tests for <see cref="AdvisoryLinksetUpdatedEvent"/> verifying event contract compliance
/// per LNM-21-005.
/// </summary>
public sealed class AdvisoryLinksetUpdatedEventTests
{
[Fact]
public void FromLinkset_NewLinkset_CreatesEventWithCreatedDelta()
{
// Arrange
var linkset = CreateLinkset("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1", "obs-2" });
// Act
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(
linkset,
previousLinkset: null,
linksetId: "linkset-123",
traceId: "trace-456");
// Assert
@event.TenantId.Should().Be("urn:tenant:tenant-1");
@event.LinksetId.Should().Be("linkset-123");
@event.AdvisoryId.Should().Be("CVE-2024-1234");
@event.Source.Should().Be("nvd");
@event.ObservationIds.Should().ContainInOrder("obs-1", "obs-2");
@event.Delta.Type.Should().Be("created");
@event.Delta.ObservationsAdded.Should().ContainInOrder("obs-1", "obs-2");
@event.Delta.ObservationsRemoved.Should().BeEmpty();
@event.TraceId.Should().Be("trace-456");
}
[Fact]
public void FromLinkset_UpdatedLinkset_CreatesEventWithUpdatedDelta()
{
// Arrange
var previousLinkset = CreateLinkset("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1", "obs-2" });
var currentLinkset = CreateLinkset("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-2", "obs-3" });
// Act
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(
currentLinkset,
previousLinkset,
linksetId: "linkset-123",
traceId: null);
// Assert
@event.Delta.Type.Should().Be("updated");
@event.Delta.ObservationsAdded.Should().Contain("obs-3");
@event.Delta.ObservationsRemoved.Should().Contain("obs-1");
}
[Fact]
public void FromLinkset_TenantAlreadyUrn_PreservesFormat()
{
// Arrange
var linkset = CreateLinkset("urn:tenant:already-formatted", "ghsa", "GHSA-1234", new[] { "obs-1" });
// Act
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(linkset, null, "linkset-1", null);
// Assert
@event.TenantId.Should().Be("urn:tenant:already-formatted");
}
[Fact]
public void FromLinkset_WithConflicts_IncludesConflictSummaries()
{
// Arrange
var conflicts = new List<AdvisoryLinksetConflict>
{
new("severity", "severity-mismatch", new[] { "nvd:9.8", "ghsa:8.5" }, new[] { "nvd", "ghsa" }),
new("aliases", "alias-inconsistency", new[] { "CVE-2024-1234", "CVE-2024-5678" }, null)
};
var linkset = CreateLinksetWithConflicts("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" }, conflicts);
// Act
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(linkset, null, "linkset-1", null);
// Assert
@event.Conflicts.Should().HaveCount(2);
@event.Conflicts[0].Field.Should().Be("aliases"); // Sorted by field
@event.Conflicts[1].Field.Should().Be("severity");
}
[Fact]
public void FromLinkset_WithProvenance_IncludesProvenanceSummary()
{
// Arrange
var provenance = new AdvisoryLinksetProvenance(
ObservationHashes: new[] { "sha256:abc123", "sha256:def456" },
ToolVersion: "1.0.0",
PolicyHash: "policy-hash-123");
var linkset = CreateLinksetWithProvenance("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" }, provenance);
// Act
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(linkset, null, "linkset-1", null);
// Assert
@event.Provenance.ObservationHashes.Should().ContainInOrder("sha256:abc123", "sha256:def456");
@event.Provenance.ToolVersion.Should().Be("1.0.0");
@event.Provenance.PolicyHash.Should().Be("policy-hash-123");
}
[Fact]
public void FromLinkset_ConfidenceChanged_SetsConfidenceChangedFlag()
{
// Arrange
var previousLinkset = CreateLinksetWithConfidence("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" }, 0.7);
var currentLinkset = CreateLinksetWithConfidence("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" }, 0.85);
// Act
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(currentLinkset, previousLinkset, "linkset-1", null);
// Assert
@event.Delta.ConfidenceChanged.Should().BeTrue();
@event.Confidence.Should().Be(0.85);
}
[Fact]
public void FromLinkset_SameConfidence_SetsConfidenceChangedFlagFalse()
{
// Arrange
var previousLinkset = CreateLinksetWithConfidence("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" }, 0.85);
var currentLinkset = CreateLinksetWithConfidence("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" }, 0.85);
// Act
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(currentLinkset, previousLinkset, "linkset-1", null);
// Assert
@event.Delta.ConfidenceChanged.Should().BeFalse();
}
[Fact]
public void FromLinkset_GeneratesUniqueEventId()
{
// Arrange
var linkset = CreateLinkset("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" });
// Act
var event1 = AdvisoryLinksetUpdatedEvent.FromLinkset(linkset, null, "linkset-1", null);
var event2 = AdvisoryLinksetUpdatedEvent.FromLinkset(linkset, null, "linkset-1", null);
// Assert
event1.EventId.Should().NotBe(event2.EventId);
event1.EventId.Should().NotBe(Guid.Empty);
}
[Fact]
public void FromLinkset_NullLinkset_ThrowsArgumentNullException()
{
// Act
var act = () => AdvisoryLinksetUpdatedEvent.FromLinkset(null!, null, "linkset-1", null);
// Assert
act.Should().Throw<ArgumentNullException>()
.WithParameterName("linkset");
}
private static AdvisoryLinkset CreateLinkset(string tenant, string source, string advisoryId, string[] observationIds)
{
return new AdvisoryLinkset(
TenantId: tenant,
Source: source,
AdvisoryId: advisoryId,
ObservationIds: observationIds.ToImmutableArray(),
Normalized: null,
Provenance: null,
Confidence: null,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
BuiltByJobId: null);
}
private static AdvisoryLinkset CreateLinksetWithConflicts(
string tenant, string source, string advisoryId, string[] observationIds, IReadOnlyList<AdvisoryLinksetConflict> conflicts)
{
return new AdvisoryLinkset(
TenantId: tenant,
Source: source,
AdvisoryId: advisoryId,
ObservationIds: observationIds.ToImmutableArray(),
Normalized: null,
Provenance: null,
Confidence: null,
Conflicts: conflicts,
CreatedAt: DateTimeOffset.UtcNow,
BuiltByJobId: null);
}
private static AdvisoryLinkset CreateLinksetWithProvenance(
string tenant, string source, string advisoryId, string[] observationIds, AdvisoryLinksetProvenance provenance)
{
return new AdvisoryLinkset(
TenantId: tenant,
Source: source,
AdvisoryId: advisoryId,
ObservationIds: observationIds.ToImmutableArray(),
Normalized: null,
Provenance: provenance,
Confidence: null,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
BuiltByJobId: null);
}
private static AdvisoryLinkset CreateLinksetWithConfidence(
string tenant, string source, string advisoryId, string[] observationIds, double? confidence)
{
return new AdvisoryLinkset(
TenantId: tenant,
Source: source,
AdvisoryId: advisoryId,
ObservationIds: observationIds.ToImmutableArray(),
Normalized: null,
Provenance: null,
Confidence: confidence,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
BuiltByJobId: null);
}
}

View File

@@ -2,14 +2,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj" />
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
<PackageReference Include="FluentAssertions" Version="6.12.0" PrivateAssets="All" />
<!-- Test packages inherited from Directory.Build.props -->
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
</Project>