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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user