up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 23:44:42 +02:00
parent ef6e4b2067
commit 3b96b2e3ea
298 changed files with 47516 additions and 1168 deletions

View File

@@ -0,0 +1,170 @@
using System;
using System.Linq;
using StellaOps.Excititor.Core.Canonicalization;
using Xunit;
namespace StellaOps.Excititor.Core.UnitTests.Canonicalization;
public class VexAdvisoryKeyCanonicalizerTests
{
private readonly VexAdvisoryKeyCanonicalizer _canonicalizer = new();
[Theory]
[InlineData("CVE-2025-12345", "CVE-2025-12345", VexAdvisoryScope.Global)]
[InlineData("cve-2025-12345", "CVE-2025-12345", VexAdvisoryScope.Global)]
[InlineData("CVE-2024-1234567", "CVE-2024-1234567", VexAdvisoryScope.Global)]
public void Canonicalize_Cve_ReturnsGlobalScope(string input, string expectedKey, VexAdvisoryScope expectedScope)
{
var result = _canonicalizer.Canonicalize(input);
Assert.Equal(expectedKey, result.AdvisoryKey);
Assert.Equal(expectedScope, result.Scope);
Assert.Single(result.Links);
Assert.Equal("cve", result.Links[0].Type);
Assert.True(result.Links[0].IsOriginal);
}
[Theory]
[InlineData("GHSA-abcd-efgh-ijkl", "ECO:GHSA-ABCD-EFGH-IJKL", VexAdvisoryScope.Ecosystem)]
[InlineData("ghsa-1234-5678-90ab", "ECO:GHSA-1234-5678-90AB", VexAdvisoryScope.Ecosystem)]
public void Canonicalize_Ghsa_ReturnsEcosystemScope(string input, string expectedKey, VexAdvisoryScope expectedScope)
{
var result = _canonicalizer.Canonicalize(input);
Assert.Equal(expectedKey, result.AdvisoryKey);
Assert.Equal(expectedScope, result.Scope);
Assert.Equal("ghsa", result.Links[0].Type);
}
[Theory]
[InlineData("RHSA-2025:1234", "VND:RHSA-2025:1234", VexAdvisoryScope.Vendor)]
[InlineData("RHBA-2024:5678", "VND:RHBA-2024:5678", VexAdvisoryScope.Vendor)]
public void Canonicalize_Rhsa_ReturnsVendorScope(string input, string expectedKey, VexAdvisoryScope expectedScope)
{
var result = _canonicalizer.Canonicalize(input);
Assert.Equal(expectedKey, result.AdvisoryKey);
Assert.Equal(expectedScope, result.Scope);
Assert.Equal("rhsa", result.Links[0].Type);
}
[Theory]
[InlineData("DSA-5678", "DST:DSA-5678", VexAdvisoryScope.Distribution)]
[InlineData("DSA-1234-1", "DST:DSA-1234-1", VexAdvisoryScope.Distribution)]
[InlineData("USN-6543", "DST:USN-6543", VexAdvisoryScope.Distribution)]
[InlineData("USN-1234-2", "DST:USN-1234-2", VexAdvisoryScope.Distribution)]
public void Canonicalize_DistributionIds_ReturnsDistributionScope(string input, string expectedKey, VexAdvisoryScope expectedScope)
{
var result = _canonicalizer.Canonicalize(input);
Assert.Equal(expectedKey, result.AdvisoryKey);
Assert.Equal(expectedScope, result.Scope);
}
[Fact]
public void Canonicalize_WithAliases_PreservesAllLinks()
{
var aliases = new[] { "RHSA-2025:1234", "GHSA-abcd-efgh-ijkl" };
var result = _canonicalizer.Canonicalize("CVE-2025-12345", aliases);
Assert.Equal("CVE-2025-12345", result.AdvisoryKey);
Assert.Equal(3, result.Links.Length);
var original = result.Links.Single(l => l.IsOriginal);
Assert.Equal("CVE-2025-12345", original.Identifier);
Assert.Equal("cve", original.Type);
var nonOriginal = result.Links.Where(l => !l.IsOriginal).ToArray();
Assert.Equal(2, nonOriginal.Length);
Assert.Contains(nonOriginal, l => l.Type == "rhsa");
Assert.Contains(nonOriginal, l => l.Type == "ghsa");
}
[Fact]
public void Canonicalize_WithDuplicateAliases_DeduplicatesLinks()
{
var aliases = new[] { "CVE-2025-12345", "cve-2025-12345", "RHSA-2025:1234" };
var result = _canonicalizer.Canonicalize("CVE-2025-12345", aliases);
// Should have 2 links: original CVE and RHSA (duplicates removed)
Assert.Equal(2, result.Links.Length);
}
[Fact]
public void Canonicalize_UnknownFormat_ReturnsUnknownScope()
{
var result = _canonicalizer.Canonicalize("VENDOR-CUSTOM-12345");
Assert.Equal("UNK:VENDOR-CUSTOM-12345", result.AdvisoryKey);
Assert.Equal(VexAdvisoryScope.Unknown, result.Scope);
Assert.Equal("other", result.Links[0].Type);
}
[Fact]
public void Canonicalize_NullInput_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() => _canonicalizer.Canonicalize(null!));
}
[Fact]
public void Canonicalize_EmptyInput_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() => _canonicalizer.Canonicalize(""));
}
[Fact]
public void Canonicalize_WhitespaceInput_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() => _canonicalizer.Canonicalize(" "));
}
[Fact]
public void ExtractCveFromAliases_WithCve_ReturnsCve()
{
var aliases = new[] { "RHSA-2025:1234", "CVE-2025-99999", "GHSA-xxxx-yyyy-zzzz" };
var cve = _canonicalizer.ExtractCveFromAliases(aliases);
Assert.Equal("CVE-2025-99999", cve);
}
[Fact]
public void ExtractCveFromAliases_WithoutCve_ReturnsNull()
{
var aliases = new[] { "RHSA-2025:1234", "GHSA-xxxx-yyyy-zzzz" };
var cve = _canonicalizer.ExtractCveFromAliases(aliases);
Assert.Null(cve);
}
[Fact]
public void ExtractCveFromAliases_NullInput_ReturnsNull()
{
var cve = _canonicalizer.ExtractCveFromAliases(null);
Assert.Null(cve);
}
[Fact]
public void OriginalId_ReturnsOriginalIdentifier()
{
var result = _canonicalizer.Canonicalize("CVE-2025-12345");
Assert.Equal("CVE-2025-12345", result.OriginalId);
}
[Fact]
public void Aliases_ReturnsNonOriginalIdentifiers()
{
var aliases = new[] { "RHSA-2025:1234", "GHSA-abcd-efgh-ijkl" };
var result = _canonicalizer.Canonicalize("CVE-2025-12345", aliases);
var aliasArray = result.Aliases.ToArray();
Assert.Equal(2, aliasArray.Length);
Assert.Contains("RHSA-2025:1234", aliasArray);
Assert.Contains("GHSA-abcd-efgh-ijkl", aliasArray);
}
}

View File

@@ -0,0 +1,235 @@
using System;
using System.Linq;
using StellaOps.Excititor.Core.Canonicalization;
using Xunit;
namespace StellaOps.Excititor.Core.UnitTests.Canonicalization;
public class VexProductKeyCanonicalizerTests
{
private readonly VexProductKeyCanonicalizer _canonicalizer = new();
[Theory]
[InlineData("pkg:npm/leftpad@1.0.0", "pkg:npm/leftpad@1.0.0", VexProductKeyType.Purl, VexProductScope.Package)]
[InlineData("pkg:maven/org.apache.log4j/log4j-core@2.17.0", "pkg:maven/org.apache.log4j/log4j-core@2.17.0", VexProductKeyType.Purl, VexProductScope.Package)]
[InlineData("PKG:pypi/requests@2.28.0", "pkg:pypi/requests@2.28.0", VexProductKeyType.Purl, VexProductScope.Package)]
public void Canonicalize_Purl_ReturnsPackageScope(string input, string expectedKey, VexProductKeyType expectedType, VexProductScope expectedScope)
{
var result = _canonicalizer.Canonicalize(input);
Assert.Equal(expectedKey, result.ProductKey);
Assert.Equal(expectedType, result.KeyType);
Assert.Equal(expectedScope, result.Scope);
Assert.Single(result.Links);
Assert.Equal("purl", result.Links[0].Type);
Assert.True(result.Links[0].IsOriginal);
}
[Theory]
[InlineData("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", "cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", VexProductKeyType.Cpe, VexProductScope.Component)]
[InlineData("cpe:/a:apache:log4j:2.14.0", "cpe:/a:apache:log4j:2.14.0", VexProductKeyType.Cpe, VexProductScope.Component)]
[InlineData("CPE:2.3:a:vendor:product:1.0", "cpe:2.3:a:vendor:product:1.0", VexProductKeyType.Cpe, VexProductScope.Component)]
public void Canonicalize_Cpe_ReturnsComponentScope(string input, string expectedKey, VexProductKeyType expectedType, VexProductScope expectedScope)
{
var result = _canonicalizer.Canonicalize(input);
Assert.Equal(expectedKey, result.ProductKey);
Assert.Equal(expectedType, result.KeyType);
Assert.Equal(expectedScope, result.Scope);
Assert.Equal("cpe", result.Links[0].Type);
}
[Theory]
[InlineData("openssl-3.0.9-1.el9.x86_64", VexProductKeyType.RpmNevra, VexProductScope.OsPackage)]
[InlineData("kernel-5.14.0-284.25.1.el9_2.x86_64", VexProductKeyType.RpmNevra, VexProductScope.OsPackage)]
public void Canonicalize_RpmNevra_ReturnsOsPackageScope(string input, VexProductKeyType expectedType, VexProductScope expectedScope)
{
var result = _canonicalizer.Canonicalize(input);
Assert.StartsWith("rpm:", result.ProductKey);
Assert.Equal(expectedType, result.KeyType);
Assert.Equal(expectedScope, result.Scope);
Assert.Equal("rpmnevra", result.Links[0].Type);
}
[Theory]
[InlineData("oci:ghcr.io/example/app@sha256:abc123", VexProductKeyType.OciImage, VexProductScope.Container)]
[InlineData("oci:docker.io/library/nginx:1.25", VexProductKeyType.OciImage, VexProductScope.Container)]
public void Canonicalize_OciImage_ReturnsContainerScope(string input, VexProductKeyType expectedType, VexProductScope expectedScope)
{
var result = _canonicalizer.Canonicalize(input);
Assert.Equal(input, result.ProductKey);
Assert.Equal(expectedType, result.KeyType);
Assert.Equal(expectedScope, result.Scope);
Assert.Equal("ociimage", result.Links[0].Type);
}
[Theory]
[InlineData("platform:redhat:rhel:9", VexProductKeyType.Platform, VexProductScope.Platform)]
[InlineData("platform:ubuntu:jammy:22.04", VexProductKeyType.Platform, VexProductScope.Platform)]
public void Canonicalize_Platform_ReturnsPlatformScope(string input, VexProductKeyType expectedType, VexProductScope expectedScope)
{
var result = _canonicalizer.Canonicalize(input);
Assert.Equal(input, result.ProductKey);
Assert.Equal(expectedType, result.KeyType);
Assert.Equal(expectedScope, result.Scope);
Assert.Equal("platform", result.Links[0].Type);
}
[Fact]
public void Canonicalize_WithPurl_PrefersPurlAsCanonicalKey()
{
var result = _canonicalizer.Canonicalize(
originalKey: "openssl-3.0.9",
purl: "pkg:rpm/redhat/openssl@3.0.9");
Assert.Equal("pkg:rpm/redhat/openssl@3.0.9", result.ProductKey);
Assert.Equal(VexProductScope.Package, result.Scope);
Assert.Equal(2, result.Links.Length);
var original = result.Links.Single(l => l.IsOriginal);
Assert.Equal("openssl-3.0.9", original.Identifier);
var purlLink = result.Links.Single(l => l.Type == "purl");
Assert.Equal("pkg:rpm/redhat/openssl@3.0.9", purlLink.Identifier);
}
[Fact]
public void Canonicalize_WithCpe_PrefersCpeWhenNoPurl()
{
var result = _canonicalizer.Canonicalize(
originalKey: "openssl",
cpe: "cpe:2.3:a:openssl:openssl:3.0.9:*:*:*:*:*:*:*");
Assert.Equal("cpe:2.3:a:openssl:openssl:3.0.9:*:*:*:*:*:*:*", result.ProductKey);
Assert.Equal(VexProductScope.Component, result.Scope);
Assert.Equal(2, result.Links.Length);
}
[Fact]
public void Canonicalize_WithComponentIdentifiers_PreservesAllLinks()
{
var componentIds = new[]
{
"pkg:rpm/redhat/openssl@3.0.9",
"cpe:2.3:a:openssl:openssl:3.0.9:*:*:*:*:*:*:*"
};
var result = _canonicalizer.Canonicalize(
originalKey: "openssl-3.0.9",
componentIdentifiers: componentIds);
// PURL should be chosen as canonical key
Assert.Equal("pkg:rpm/redhat/openssl@3.0.9", result.ProductKey);
Assert.Equal(3, result.Links.Length);
var original = result.Links.Single(l => l.IsOriginal);
Assert.Equal("openssl-3.0.9", original.Identifier);
}
[Fact]
public void Canonicalize_WithDuplicates_DeduplicatesLinks()
{
var componentIds = new[]
{
"pkg:npm/leftpad@1.0.0",
"pkg:npm/leftpad@1.0.0", // Duplicate
};
var result = _canonicalizer.Canonicalize(
originalKey: "pkg:npm/leftpad@1.0.0",
componentIdentifiers: componentIds);
Assert.Single(result.Links);
}
[Fact]
public void Canonicalize_UnknownFormat_ReturnsOtherType()
{
var result = _canonicalizer.Canonicalize("some-custom-product-id");
Assert.Equal("product:some-custom-product-id", result.ProductKey);
Assert.Equal(VexProductKeyType.Other, result.KeyType);
Assert.Equal(VexProductScope.Unknown, result.Scope);
Assert.Equal("other", result.Links[0].Type);
}
[Fact]
public void Canonicalize_NullInput_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() => _canonicalizer.Canonicalize(null!));
}
[Fact]
public void Canonicalize_EmptyInput_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() => _canonicalizer.Canonicalize(""));
}
[Fact]
public void ExtractPurlFromIdentifiers_WithPurl_ReturnsPurl()
{
var identifiers = new[]
{
"cpe:2.3:a:openssl:openssl:3.0.9:*:*:*:*:*:*:*",
"pkg:rpm/redhat/openssl@3.0.9",
"openssl-3.0.9"
};
var purl = _canonicalizer.ExtractPurlFromIdentifiers(identifiers);
Assert.Equal("pkg:rpm/redhat/openssl@3.0.9", purl);
}
[Fact]
public void ExtractPurlFromIdentifiers_WithoutPurl_ReturnsNull()
{
var identifiers = new[]
{
"cpe:2.3:a:openssl:openssl:3.0.9:*:*:*:*:*:*:*",
"openssl-3.0.9"
};
var purl = _canonicalizer.ExtractPurlFromIdentifiers(identifiers);
Assert.Null(purl);
}
[Fact]
public void ExtractPurlFromIdentifiers_NullInput_ReturnsNull()
{
var purl = _canonicalizer.ExtractPurlFromIdentifiers(null);
Assert.Null(purl);
}
[Fact]
public void OriginalKey_ReturnsOriginalIdentifier()
{
var result = _canonicalizer.Canonicalize("pkg:npm/leftpad@1.0.0");
Assert.Equal("pkg:npm/leftpad@1.0.0", result.OriginalKey);
}
[Fact]
public void Purl_ReturnsPurlLink()
{
var result = _canonicalizer.Canonicalize(
originalKey: "openssl",
purl: "pkg:rpm/redhat/openssl@3.0.9");
Assert.Equal("pkg:rpm/redhat/openssl@3.0.9", result.Purl);
}
[Fact]
public void Cpe_ReturnsCpeLink()
{
var result = _canonicalizer.Canonicalize(
originalKey: "openssl",
cpe: "cpe:2.3:a:openssl:openssl:3.0.9:*:*:*:*:*:*:*");
Assert.Equal("cpe:2.3:a:openssl:openssl:3.0.9:*:*:*:*:*:*:*", result.Cpe);
}
}

View File

@@ -11,6 +11,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Immutable;
using StellaOps.Excititor.Core.Observations;
using Xunit;
namespace StellaOps.Excititor.Core.UnitTests;
public class TimelineEventTests
{
[Fact]
public void Constructor_NormalizesFields_AndPreservesValues()
{
var now = DateTimeOffset.UtcNow;
var attributes = ImmutableDictionary<string, string>.Empty
.Add("key1", "value1")
.Add("key2", "value2");
var evt = new TimelineEvent(
eventId: " evt-001 ",
tenant: " TENANT-A ",
providerId: " provider-x ",
streamId: " csaf ",
eventType: " vex.observation.ingested ",
traceId: " trace-abc-123 ",
justificationSummary: " Component not present in runtime ",
createdAt: now,
evidenceHash: " sha256:deadbeef ",
payloadHash: " sha256:cafebabe ",
attributes: attributes);
Assert.Equal("evt-001", evt.EventId);
Assert.Equal("tenant-a", evt.Tenant); // lowercase
Assert.Equal("provider-x", evt.ProviderId);
Assert.Equal("csaf", evt.StreamId);
Assert.Equal("vex.observation.ingested", evt.EventType);
Assert.Equal("trace-abc-123", evt.TraceId);
Assert.Equal("Component not present in runtime", evt.JustificationSummary);
Assert.Equal(now, evt.CreatedAt);
Assert.Equal("sha256:deadbeef", evt.EvidenceHash);
Assert.Equal("sha256:cafebabe", evt.PayloadHash);
Assert.Equal(2, evt.Attributes.Count);
Assert.Equal("value1", evt.Attributes["key1"]);
}
[Fact]
public void Constructor_ThrowsOnNullOrWhiteSpaceRequiredFields()
{
var now = DateTimeOffset.UtcNow;
Assert.Throws<ArgumentException>(() => new TimelineEvent(
eventId: null!,
tenant: "tenant",
providerId: "provider",
streamId: "stream",
eventType: "type",
traceId: "trace",
justificationSummary: "summary",
createdAt: now));
Assert.Throws<ArgumentException>(() => new TimelineEvent(
eventId: " ",
tenant: "tenant",
providerId: "provider",
streamId: "stream",
eventType: "type",
traceId: "trace",
justificationSummary: "summary",
createdAt: now));
Assert.Throws<ArgumentException>(() => new TimelineEvent(
eventId: "evt-001",
tenant: null!,
providerId: "provider",
streamId: "stream",
eventType: "type",
traceId: "trace",
justificationSummary: "summary",
createdAt: now));
}
[Fact]
public void Constructor_HandlesNullOptionalFields()
{
var now = DateTimeOffset.UtcNow;
var evt = new TimelineEvent(
eventId: "evt-001",
tenant: "tenant-a",
providerId: "provider-x",
streamId: "csaf",
eventType: "vex.observation.ingested",
traceId: "trace-abc-123",
justificationSummary: null!,
createdAt: now,
evidenceHash: null,
payloadHash: null,
attributes: null);
Assert.Equal(string.Empty, evt.JustificationSummary);
Assert.Null(evt.EvidenceHash);
Assert.Null(evt.PayloadHash);
Assert.Empty(evt.Attributes);
}
[Fact]
public void Constructor_FiltersNullAttributeKeysAndValues()
{
var now = DateTimeOffset.UtcNow;
var attributes = ImmutableDictionary<string, string>.Empty
.Add("valid-key", "valid-value")
.Add(" ", "bad-key")
.Add("null-value", null!);
var evt = new TimelineEvent(
eventId: "evt-001",
tenant: "tenant-a",
providerId: "provider-x",
streamId: "csaf",
eventType: "vex.observation.ingested",
traceId: "trace-abc-123",
justificationSummary: "summary",
createdAt: now,
attributes: attributes);
// Only valid key-value pair should remain
Assert.Single(evt.Attributes);
Assert.True(evt.Attributes.ContainsKey("valid-key"));
}
[Fact]
public void EventTypes_Constants_AreCorrect()
{
Assert.Equal("vex.observation.ingested", VexTimelineEventTypes.ObservationIngested);
Assert.Equal("vex.observation.updated", VexTimelineEventTypes.ObservationUpdated);
Assert.Equal("vex.observation.superseded", VexTimelineEventTypes.ObservationSuperseded);
Assert.Equal("vex.linkset.created", VexTimelineEventTypes.LinksetCreated);
Assert.Equal("vex.linkset.updated", VexTimelineEventTypes.LinksetUpdated);
Assert.Equal("vex.linkset.conflict_detected", VexTimelineEventTypes.LinksetConflictDetected);
Assert.Equal("vex.linkset.conflict_resolved", VexTimelineEventTypes.LinksetConflictResolved);
Assert.Equal("vex.evidence.sealed", VexTimelineEventTypes.EvidenceSealed);
Assert.Equal("vex.attestation.attached", VexTimelineEventTypes.AttestationAttached);
Assert.Equal("vex.attestation.verified", VexTimelineEventTypes.AttestationVerified);
}
[Fact]
public void AttributeKeys_Constants_AreCorrect()
{
Assert.Equal("observation_id", VexTimelineEventAttributes.ObservationId);
Assert.Equal("linkset_id", VexTimelineEventAttributes.LinksetId);
Assert.Equal("vulnerability_id", VexTimelineEventAttributes.VulnerabilityId);
Assert.Equal("product_key", VexTimelineEventAttributes.ProductKey);
Assert.Equal("status", VexTimelineEventAttributes.Status);
Assert.Equal("conflict_type", VexTimelineEventAttributes.ConflictType);
Assert.Equal("attestation_id", VexTimelineEventAttributes.AttestationId);
}
}

View File

@@ -0,0 +1,209 @@
using System;
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Attestation.Evidence;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.Excititor.Core.Observations;
using Xunit;
namespace StellaOps.Excititor.Core.UnitTests;
public class VexEvidenceAttestorTests
{
[Fact]
public async Task AttestManifestAsync_CreatesValidAttestation()
{
var signer = new FakeSigner();
var attestor = new VexEvidenceAttestor(signer, NullLogger<VexEvidenceAttestor>.Instance);
var item = new VexEvidenceSnapshotItem(
"obs-001",
"provider-a",
"sha256:0000000000000000000000000000000000000000000000000000000000000001",
"linkset-1");
var manifest = new VexLockerManifest(
tenant: "test-tenant",
manifestId: "locker:excititor:test-tenant:2025-11-27:0001",
createdAt: DateTimeOffset.Parse("2025-11-27T10:00:00Z"),
items: new[] { item });
var result = await attestor.AttestManifestAsync(manifest);
Assert.NotNull(result);
Assert.NotNull(result.SignedManifest);
Assert.NotNull(result.DsseEnvelopeJson);
Assert.StartsWith("sha256:", result.DsseEnvelopeHash);
Assert.StartsWith("attest:evidence:test-tenant:", result.AttestationId);
Assert.NotNull(result.SignedManifest.Signature);
}
[Fact]
public async Task AttestManifestAsync_EnvelopeContainsCorrectPayload()
{
var signer = new FakeSigner();
var attestor = new VexEvidenceAttestor(signer, NullLogger<VexEvidenceAttestor>.Instance);
var item = new VexEvidenceSnapshotItem(
"obs-001",
"provider-a",
"sha256:abc123",
"linkset-1");
var manifest = new VexLockerManifest(
tenant: "test-tenant",
manifestId: "test-manifest",
createdAt: DateTimeOffset.UtcNow,
items: new[] { item });
var result = await attestor.AttestManifestAsync(manifest);
var envelope = JsonSerializer.Deserialize<JsonObject>(result.DsseEnvelopeJson);
Assert.NotNull(envelope);
Assert.Equal("application/vnd.in-toto+json", envelope["payloadType"]?.GetValue<string>());
var payload = Convert.FromBase64String(envelope["payload"]?.GetValue<string>() ?? "");
var statement = JsonSerializer.Deserialize<JsonObject>(payload);
Assert.NotNull(statement);
Assert.Equal(VexEvidenceInTotoStatement.InTotoStatementType, statement["_type"]?.GetValue<string>());
Assert.Equal(VexEvidenceInTotoStatement.EvidenceLockerPredicateType, statement["predicateType"]?.GetValue<string>());
}
[Fact]
public async Task VerifyAttestationAsync_ReturnsValidForCorrectAttestation()
{
var signer = new FakeSigner();
var attestor = new VexEvidenceAttestor(signer, NullLogger<VexEvidenceAttestor>.Instance);
var item = new VexEvidenceSnapshotItem(
"obs-001",
"provider-a",
"sha256:abc123",
"linkset-1");
var manifest = new VexLockerManifest(
tenant: "test-tenant",
manifestId: "test-manifest",
createdAt: DateTimeOffset.UtcNow,
items: new[] { item });
var attestation = await attestor.AttestManifestAsync(manifest);
var verification = await attestor.VerifyAttestationAsync(manifest, attestation.DsseEnvelopeJson);
Assert.True(verification.IsValid);
Assert.Null(verification.FailureReason);
Assert.True(verification.Diagnostics.ContainsKey("envelope_hash"));
}
[Fact]
public async Task VerifyAttestationAsync_ReturnsInvalidForWrongManifest()
{
var signer = new FakeSigner();
var attestor = new VexEvidenceAttestor(signer, NullLogger<VexEvidenceAttestor>.Instance);
var item = new VexEvidenceSnapshotItem(
"obs-001",
"provider-a",
"sha256:abc123",
"linkset-1");
var manifest1 = new VexLockerManifest(
tenant: "test-tenant",
manifestId: "manifest-1",
createdAt: DateTimeOffset.UtcNow,
items: new[] { item });
var manifest2 = new VexLockerManifest(
tenant: "test-tenant",
manifestId: "manifest-2",
createdAt: DateTimeOffset.UtcNow,
items: new[] { item });
var attestation = await attestor.AttestManifestAsync(manifest1);
var verification = await attestor.VerifyAttestationAsync(manifest2, attestation.DsseEnvelopeJson);
Assert.False(verification.IsValid);
Assert.Contains("Manifest ID mismatch", verification.FailureReason);
}
[Fact]
public async Task VerifyAttestationAsync_ReturnsInvalidForInvalidJson()
{
var signer = new FakeSigner();
var attestor = new VexEvidenceAttestor(signer, NullLogger<VexEvidenceAttestor>.Instance);
var item = new VexEvidenceSnapshotItem(
"obs-001",
"provider-a",
"sha256:abc123",
"linkset-1");
var manifest = new VexLockerManifest(
tenant: "test-tenant",
manifestId: "test-manifest",
createdAt: DateTimeOffset.UtcNow,
items: new[] { item });
var verification = await attestor.VerifyAttestationAsync(manifest, "not valid json");
Assert.False(verification.IsValid);
Assert.Contains("JSON parse error", verification.FailureReason);
}
[Fact]
public async Task VerifyAttestationAsync_ReturnsInvalidForEmptyEnvelope()
{
var signer = new FakeSigner();
var attestor = new VexEvidenceAttestor(signer, NullLogger<VexEvidenceAttestor>.Instance);
var item = new VexEvidenceSnapshotItem(
"obs-001",
"provider-a",
"sha256:abc123",
"linkset-1");
var manifest = new VexLockerManifest(
tenant: "test-tenant",
manifestId: "test-manifest",
createdAt: DateTimeOffset.UtcNow,
items: new[] { item });
var verification = await attestor.VerifyAttestationAsync(manifest, "");
Assert.False(verification.IsValid);
Assert.Equal("DSSE envelope is required.", verification.FailureReason);
}
[Fact]
public void VexEvidenceAttestationPredicate_FromManifest_CapturesAllFields()
{
var item = new VexEvidenceSnapshotItem(
"obs-001",
"provider-a",
"sha256:abc123",
"linkset-1");
var metadata = ImmutableDictionary<string, string>.Empty.Add("sealed", "true");
var manifest = new VexLockerManifest(
tenant: "test-tenant",
manifestId: "test-manifest",
createdAt: DateTimeOffset.Parse("2025-11-27T10:00:00Z"),
items: new[] { item },
metadata: metadata);
var predicate = VexEvidenceAttestationPredicate.FromManifest(manifest);
Assert.Equal("test-manifest", predicate.ManifestId);
Assert.Equal("test-tenant", predicate.Tenant);
Assert.Equal(manifest.MerkleRoot, predicate.MerkleRoot);
Assert.Equal(1, predicate.ItemCount);
Assert.Equal("true", predicate.Metadata["sealed"]);
}
private sealed class FakeSigner : IVexSigner
{
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
{
var signature = Convert.ToBase64String(payload.Span.ToArray());
return ValueTask.FromResult(new VexSignedPayload(signature, "fake-key-001"));
}
}
}

View File

@@ -0,0 +1,199 @@
using System;
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.Excititor.Core.Observations;
using Xunit;
namespace StellaOps.Excititor.Core.UnitTests;
public class VexEvidenceLockerTests
{
[Fact]
public void VexEvidenceSnapshotItem_NormalizesFields()
{
var item = new VexEvidenceSnapshotItem(
observationId: " obs-001 ",
providerId: " PROVIDER-A ",
contentHash: " sha256:abc123 ",
linksetId: " CVE-2024-0001:pkg:npm/lodash ");
Assert.Equal("obs-001", item.ObservationId);
Assert.Equal("provider-a", item.ProviderId);
Assert.Equal("sha256:abc123", item.ContentHash);
Assert.Equal("CVE-2024-0001:pkg:npm/lodash", item.LinksetId);
Assert.Null(item.DsseEnvelopeHash);
Assert.Equal("ingest", item.Provenance.Source);
}
[Fact]
public void VexEvidenceProvenance_CreatesCorrectProvenance()
{
var provenance = new VexEvidenceProvenance("mirror", 5, "sha256:manifest123");
Assert.Equal("mirror", provenance.Source);
Assert.Equal(5, provenance.MirrorGeneration);
Assert.Equal("sha256:manifest123", provenance.ExportCenterManifest);
}
[Fact]
public void VexLockerManifest_SortsItemsDeterministically()
{
var item1 = new VexEvidenceSnapshotItem("obs-002", "provider-b", "sha256:bbb", "linkset-1");
var item2 = new VexEvidenceSnapshotItem("obs-001", "provider-a", "sha256:aaa", "linkset-1");
var item3 = new VexEvidenceSnapshotItem("obs-001", "provider-b", "sha256:ccc", "linkset-2");
var manifest = new VexLockerManifest(
tenant: "test-tenant",
manifestId: "locker:excititor:test:2025-11-27:0001",
createdAt: DateTimeOffset.Parse("2025-11-27T10:00:00Z"),
items: new[] { item1, item2, item3 });
// Should be sorted by observationId, then providerId
Assert.Equal(3, manifest.Items.Length);
Assert.Equal("obs-001", manifest.Items[0].ObservationId);
Assert.Equal("provider-a", manifest.Items[0].ProviderId);
Assert.Equal("obs-001", manifest.Items[1].ObservationId);
Assert.Equal("provider-b", manifest.Items[1].ProviderId);
Assert.Equal("obs-002", manifest.Items[2].ObservationId);
}
[Fact]
public void VexLockerManifest_ComputesMerkleRoot()
{
var item1 = new VexEvidenceSnapshotItem("obs-001", "provider-a", "sha256:0000000000000000000000000000000000000000000000000000000000000001", "linkset-1");
var item2 = new VexEvidenceSnapshotItem("obs-002", "provider-a", "sha256:0000000000000000000000000000000000000000000000000000000000000002", "linkset-1");
var manifest = new VexLockerManifest(
tenant: "test",
manifestId: "test-manifest",
createdAt: DateTimeOffset.UtcNow,
items: new[] { item1, item2 });
Assert.StartsWith("sha256:", manifest.MerkleRoot);
Assert.Equal(71, manifest.MerkleRoot.Length); // "sha256:" + 64 hex chars
}
[Fact]
public void VexLockerManifest_CreateManifestId_GeneratesCorrectFormat()
{
var id = VexLockerManifest.CreateManifestId("TestTenant", DateTimeOffset.Parse("2025-11-27T15:30:00Z"), 42);
Assert.Equal("locker:excititor:testtenant:2025-11-27:0042", id);
}
[Fact]
public void VexLockerManifest_WithSignature_PreservesData()
{
var item = new VexEvidenceSnapshotItem("obs-001", "provider-a", "sha256:abc123", "linkset-1");
var manifest = new VexLockerManifest(
tenant: "test",
manifestId: "test-manifest",
createdAt: DateTimeOffset.UtcNow,
items: new[] { item });
var signed = manifest.WithSignature("dsse-signature-base64");
Assert.Null(manifest.Signature);
Assert.Equal("dsse-signature-base64", signed.Signature);
Assert.Equal(manifest.MerkleRoot, signed.MerkleRoot);
Assert.Equal(manifest.Items.Length, signed.Items.Length);
}
[Fact]
public void VexEvidenceLockerService_CreateSnapshotItem_FromObservation()
{
var observation = BuildTestObservation("obs-001", "provider-a", "sha256:content123");
var service = new VexEvidenceLockerService();
var item = service.CreateSnapshotItem(observation, "linkset-001");
Assert.Equal("obs-001", item.ObservationId);
Assert.Equal("provider-a", item.ProviderId);
Assert.Equal("sha256:content123", item.ContentHash);
Assert.Equal("linkset-001", item.LinksetId);
}
[Fact]
public void VexEvidenceLockerService_BuildManifest_CreatesValidManifest()
{
var obs1 = BuildTestObservation("obs-001", "provider-a", "sha256:aaa");
var obs2 = BuildTestObservation("obs-002", "provider-b", "sha256:bbb");
var service = new VexEvidenceLockerService();
var manifest = service.BuildManifest(
tenant: "test-tenant",
observations: new[] { obs2, obs1 },
linksetIdSelector: o => $"linkset:{o.ObservationId}",
timestamp: DateTimeOffset.Parse("2025-11-27T10:00:00Z"),
sequence: 1,
isSealed: true);
Assert.Equal("test-tenant", manifest.Tenant);
Assert.Equal("locker:excititor:test-tenant:2025-11-27:0001", manifest.ManifestId);
Assert.Equal(2, manifest.Items.Length);
Assert.Equal("obs-001", manifest.Items[0].ObservationId); // sorted
Assert.Equal("true", manifest.Metadata["sealed"]);
}
[Fact]
public void VexEvidenceLockerService_VerifyManifest_ReturnsTrueForValidManifest()
{
var item = new VexEvidenceSnapshotItem("obs-001", "provider-a", "sha256:0000000000000000000000000000000000000000000000000000000000000001", "linkset-1");
var manifest = new VexLockerManifest(
tenant: "test",
manifestId: "test-manifest",
createdAt: DateTimeOffset.UtcNow,
items: new[] { item });
var service = new VexEvidenceLockerService();
Assert.True(service.VerifyManifest(manifest));
}
[Fact]
public void VexLockerManifest_EmptyItems_ProducesEmptyMerkleRoot()
{
var manifest = new VexLockerManifest(
tenant: "test",
manifestId: "test-manifest",
createdAt: DateTimeOffset.UtcNow,
items: Array.Empty<VexEvidenceSnapshotItem>());
Assert.StartsWith("sha256:", manifest.MerkleRoot);
Assert.Empty(manifest.Items);
}
private static VexObservation BuildTestObservation(string id, string provider, string contentHash)
{
var upstream = new VexObservationUpstream(
upstreamId: $"upstream-{id}",
documentVersion: "1",
fetchedAt: DateTimeOffset.UtcNow,
receivedAt: DateTimeOffset.UtcNow,
contentHash: contentHash,
signature: new VexObservationSignature(false, null, null, null));
var content = new VexObservationContent(
format: "openvex",
specVersion: "1.0.0",
raw: JsonNode.Parse("{}")!,
metadata: null);
var linkset = new VexObservationLinkset(
aliases: Array.Empty<string>(),
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<VexObservationReference>());
return new VexObservation(
observationId: id,
tenant: "test",
providerId: provider,
streamId: "ingest",
upstream: upstream,
statements: ImmutableArray<VexObservationStatement>.Empty,
content: content,
linkset: linkset,
createdAt: DateTimeOffset.UtcNow);
}
}

View File

@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using EphemeralMongo;
using MongoRunner = EphemeralMongo.MongoRunner;
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.WebService.Tests;
/// <summary>
/// Tests for OpenAPI discovery endpoints (WEB-OAS-61-001).
/// Validates /.well-known/openapi and /openapi/excititor.json endpoints.
/// </summary>
public sealed class OpenApiDiscoveryEndpointTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
private readonly IMongoRunner _runner;
public OpenApiDiscoveryEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: config =>
{
var rootPath = Path.Combine(Path.GetTempPath(), "excititor-openapi-tests");
Directory.CreateDirectory(rootPath);
var settings = new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-openapi-tests",
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
["Excititor:Storage:Mongo:GridFsInlineThresholdBytes"] = "256",
["Excititor:Artifacts:FileSystem:RootPath"] = rootPath,
};
config.AddInMemoryCollection(settings!);
},
configureServices: services =>
{
TestServiceOverrides.Apply(services);
services.AddSingleton<IVexSigner, FakeSigner>();
services.AddSingleton<IVexPolicyEvaluator, FakePolicyEvaluator>();
services.AddSingleton(new VexConnectorDescriptor("excititor:test", VexProviderKind.Distro, "Test Connector"));
});
}
[Fact]
public async Task WellKnownOpenApi_ReturnsServiceMetadata()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/.well-known/openapi");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal("excititor", root.GetProperty("service").GetString());
Assert.Equal("3.1.0", root.GetProperty("specVersion").GetString());
Assert.Equal("application/json", root.GetProperty("format").GetString());
Assert.Equal("/openapi/excititor.json", root.GetProperty("url").GetString());
Assert.Equal("#/components/schemas/Error", root.GetProperty("errorEnvelopeSchema").GetString());
Assert.True(root.TryGetProperty("version", out _), "Response should include version");
}
[Fact]
public async Task OpenApiSpec_ReturnsValidOpenApi31Document()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/openapi/excititor.json");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Verify OpenAPI version
Assert.Equal("3.1.0", root.GetProperty("openapi").GetString());
// Verify info object
var info = root.GetProperty("info");
Assert.Equal("StellaOps Excititor API", info.GetProperty("title").GetString());
Assert.True(info.TryGetProperty("version", out _), "Info should include version");
Assert.True(info.TryGetProperty("description", out _), "Info should include description");
// Verify paths exist
Assert.True(root.TryGetProperty("paths", out var paths), "Spec should include paths");
Assert.True(paths.TryGetProperty("/excititor/status", out _), "Paths should include /excititor/status");
}
[Fact]
public async Task OpenApiSpec_IncludesErrorSchemaComponent()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/openapi/excititor.json");
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Verify components/schemas/Error exists
Assert.True(root.TryGetProperty("components", out var components), "Spec should include components");
Assert.True(components.TryGetProperty("schemas", out var schemas), "Components should include schemas");
Assert.True(schemas.TryGetProperty("Error", out var errorSchema), "Schemas should include Error");
// Verify Error schema structure
Assert.Equal("object", errorSchema.GetProperty("type").GetString());
Assert.True(errorSchema.TryGetProperty("properties", out var props), "Error schema should have properties");
Assert.True(props.TryGetProperty("error", out _), "Error schema should have error property");
}
[Fact]
public async Task OpenApiSpec_IncludesTimelineEndpoint()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/openapi/excititor.json");
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var paths = root.GetProperty("paths");
Assert.True(paths.TryGetProperty("/obs/excititor/timeline", out var timelinePath),
"Paths should include /obs/excititor/timeline");
// Verify it has a GET operation
Assert.True(timelinePath.TryGetProperty("get", out var getOp), "Timeline path should have GET operation");
Assert.True(getOp.TryGetProperty("summary", out _), "GET operation should have summary");
}
[Fact]
public async Task OpenApiSpec_IncludesLinkHeaderExample()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/openapi/excititor.json");
var json = await response.Content.ReadAsStringAsync();
// Verify the spec contains a Link header reference for OpenAPI describedby
// JSON escapes quotes, so check for the essential parts
Assert.Contains("/openapi/excititor.json", json);
Assert.Contains("describedby", json);
}
[Fact]
public async Task WellKnownOpenApi_ContentTypeIsJson()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/.well-known/openapi");
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
}
[Fact]
public async Task OpenApiSpec_ContentTypeIsJson()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/openapi/excititor.json");
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class FakeSigner : IVexSigner
{
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
}
private sealed class FakePolicyEvaluator : IVexPolicyEvaluator
{
public string Version => "test";
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
public double GetProviderWeight(VexProvider provider) => 1.0;
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
{
rejectionReason = null;
return true;
}
}
}

View File

@@ -40,5 +40,6 @@
<Compile Include="GraphStatusFactoryTests.cs" />
<Compile Include="GraphTooltipFactoryTests.cs" />
<Compile Include="AttestationVerifyEndpointTests.cs" />
<Compile Include="OpenApiDiscoveryEndpointTests.cs" />
</ItemGroup>
</Project>

View File

@@ -16,7 +16,9 @@ using StellaOps.Aoc;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.Core.Orchestration;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Orchestration;
using StellaOps.Excititor.Worker.Scheduling;
using StellaOps.Excititor.Worker.Signature;
using StellaOps.Plugin;
@@ -115,7 +117,8 @@ public sealed class DefaultVexProviderRunnerIntegrationTests : IAsyncLifetime
storedCount.Should().Be(9); // documents before the failing digest persist
guard.FailDigest = null;
time.Advance(TimeSpan.FromMinutes(10));
// Advance past the quarantine duration (30 mins) since AOC guard failures are non-retryable
time.Advance(TimeSpan.FromMinutes(35));
await runner.RunAsync(schedule, CancellationToken.None);
var finalCount = await rawCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
@@ -177,12 +180,23 @@ public sealed class DefaultVexProviderRunnerIntegrationTests : IAsyncLifetime
},
};
var orchestratorOptions = Microsoft.Extensions.Options.Options.Create(new VexWorkerOrchestratorOptions { Enabled = false });
var orchestratorClient = new NoopOrchestratorClient();
var heartbeatService = new VexWorkerHeartbeatService(
orchestratorClient,
orchestratorOptions,
timeProvider,
NullLogger<VexWorkerHeartbeatService>.Instance);
return new DefaultVexProviderRunner(
services,
new PluginCatalog(),
orchestratorClient,
heartbeatService,
NullLogger<DefaultVexProviderRunner>.Instance,
timeProvider,
Microsoft.Extensions.Options.Options.Create(options));
Microsoft.Extensions.Options.Options.Create(options),
orchestratorOptions);
}
private static List<DocumentSpec> CreateDocumentSpecs(int count)
@@ -330,6 +344,39 @@ public sealed class DefaultVexProviderRunnerIntegrationTests : IAsyncLifetime
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopOrchestratorClient : IVexWorkerOrchestratorClient
{
public ValueTask<VexWorkerJobContext> StartJobAsync(string tenant, string connectorId, string? checkpoint, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new VexWorkerJobContext(tenant, connectorId, Guid.NewGuid(), checkpoint, DateTimeOffset.UtcNow));
public ValueTask SendHeartbeatAsync(VexWorkerJobContext context, VexWorkerHeartbeat heartbeat, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask RecordArtifactAsync(VexWorkerJobContext context, VexWorkerArtifact artifact, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask CompleteJobAsync(VexWorkerJobContext context, VexWorkerJobResult result, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask FailJobAsync(VexWorkerJobContext context, string errorCode, string? errorMessage, int? retryAfterSeconds, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask FailJobAsync(VexWorkerJobContext context, VexWorkerError error, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask<VexWorkerCommand?> GetPendingCommandAsync(VexWorkerJobContext context, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<VexWorkerCommand?>(null);
public ValueTask AcknowledgeCommandAsync(VexWorkerJobContext context, long commandSequence, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask SaveCheckpointAsync(VexWorkerJobContext context, VexWorkerCheckpoint checkpoint, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask<VexWorkerCheckpoint?> LoadCheckpointAsync(string connectorId, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<VexWorkerCheckpoint?>(null);
}
private sealed class DirectSessionProvider : IVexMongoSessionProvider
{
private readonly IMongoClient _client;

View File

@@ -19,13 +19,15 @@ using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Scheduling;
using StellaOps.Excititor.Worker.Signature;
using StellaOps.Aoc;
using Xunit;
using System.Runtime.CompilerServices;
using StellaOps.IssuerDirectory.Client;
using StellaOps.Excititor.Core.Orchestration;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Orchestration;
using StellaOps.Excititor.Worker.Scheduling;
using StellaOps.Excititor.Worker.Signature;
using StellaOps.Aoc;
using Xunit;
using System.Runtime.CompilerServices;
using StellaOps.IssuerDirectory.Client;
namespace StellaOps.Excititor.Worker.Tests;
@@ -286,12 +288,12 @@ public sealed class DefaultVexProviderRunnerTests
.Add("verification.issuer", "issuer-from-verifier")
.Add("verification.keyId", "key-from-verifier");
var attestationVerifier = new StubAttestationVerifier(true, diagnostics);
var signatureVerifier = new WorkerSignatureVerifier(
NullLogger<WorkerSignatureVerifier>.Instance,
attestationVerifier,
time,
TestIssuerDirectoryClient.Instance);
var attestationVerifier = new StubAttestationVerifier(true, diagnostics);
var signatureVerifier = new WorkerSignatureVerifier(
NullLogger<WorkerSignatureVerifier>.Instance,
attestationVerifier,
time,
TestIssuerDirectoryClient.Instance);
var connector = TestConnector.WithDocuments("excititor:test", document);
var stateRepository = new InMemoryStateRepository();
@@ -332,6 +334,45 @@ public sealed class DefaultVexProviderRunnerTests
{
var now = new DateTimeOffset(2025, 10, 21, 17, 0, 0, TimeSpan.Zero);
var time = new FixedTimeProvider(now);
// Use a network exception which is classified as retryable
var connector = TestConnector.Failure("excititor:test", new System.Net.Http.HttpRequestException("network failure"));
var stateRepository = new InMemoryStateRepository();
stateRepository.Save(new VexConnectorState(
"excititor:test",
LastUpdated: now.AddDays(-2),
DocumentDigests: ImmutableArray<string>.Empty,
ResumeTokens: ImmutableDictionary<string, string>.Empty,
LastSuccessAt: now.AddDays(-1),
FailureCount: 1,
NextEligibleRun: null,
LastFailureReason: null));
var services = CreateServiceProvider(connector, stateRepository);
var runner = CreateRunner(services, time, options =>
{
options.Retry.BaseDelay = TimeSpan.FromMinutes(5);
options.Retry.MaxDelay = TimeSpan.FromMinutes(60);
options.Retry.FailureThreshold = 3;
options.Retry.QuarantineDuration = TimeSpan.FromHours(12);
options.Retry.JitterRatio = 0;
});
await Assert.ThrowsAsync<System.Net.Http.HttpRequestException>(async () => await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None).AsTask());
var state = stateRepository.Get("excititor:test");
state.Should().NotBeNull();
state!.FailureCount.Should().Be(2);
state.LastFailureReason.Should().Be("network failure");
// Exponential backoff: 5 mins * 2^(2-1) = 10 mins
state.NextEligibleRun.Should().Be(now + TimeSpan.FromMinutes(10));
}
[Fact]
public async Task RunAsync_NonRetryableFailure_AppliesQuarantine()
{
var now = new DateTimeOffset(2025, 10, 21, 17, 0, 0, TimeSpan.Zero);
var time = new FixedTimeProvider(now);
// InvalidOperationException is classified as non-retryable
var connector = TestConnector.Failure("excititor:test", new InvalidOperationException("boom"));
var stateRepository = new InMemoryStateRepository();
stateRepository.Save(new VexConnectorState(
@@ -360,7 +401,8 @@ public sealed class DefaultVexProviderRunnerTests
state.Should().NotBeNull();
state!.FailureCount.Should().Be(2);
state.LastFailureReason.Should().Be("boom");
state.NextEligibleRun.Should().Be(now + TimeSpan.FromMinutes(10));
// Non-retryable errors apply quarantine immediately
state.NextEligibleRun.Should().Be(now + TimeSpan.FromHours(12));
}
private static ServiceProvider CreateServiceProvider(
@@ -390,12 +432,22 @@ public sealed class DefaultVexProviderRunnerTests
{
var options = new VexWorkerOptions();
configure(options);
var orchestratorOptions = Microsoft.Extensions.Options.Options.Create(new VexWorkerOrchestratorOptions { Enabled = false });
var orchestratorClient = new NoopOrchestratorClient();
var heartbeatService = new VexWorkerHeartbeatService(
orchestratorClient,
orchestratorOptions,
timeProvider,
NullLogger<VexWorkerHeartbeatService>.Instance);
return new DefaultVexProviderRunner(
serviceProvider,
new PluginCatalog(),
orchestratorClient,
heartbeatService,
NullLogger<DefaultVexProviderRunner>.Instance,
timeProvider,
Microsoft.Extensions.Options.Options.Create(options));
Microsoft.Extensions.Options.Options.Create(options),
orchestratorOptions);
}
private sealed class FixedTimeProvider : TimeProvider
@@ -467,64 +519,97 @@ public sealed class DefaultVexProviderRunnerTests
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class StubNormalizerRouter : IVexNormalizerRouter
{
private readonly ImmutableArray<VexClaim> _claims;
public StubNormalizerRouter(IEnumerable<VexClaim> claims)
{
_claims = claims.ToImmutableArray();
}
public int CallCount { get; private set; }
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
{
CallCount++;
return ValueTask.FromResult(new VexClaimBatch(document, _claims, ImmutableDictionary<string, string>.Empty));
}
}
private sealed class TestIssuerDirectoryClient : IIssuerDirectoryClient
{
public static TestIssuerDirectoryClient Instance { get; } = new();
private static readonly IssuerTrustResponseModel DefaultTrust = new(null, null, 1m);
public ValueTask<IReadOnlyList<IssuerKeyModel>> GetIssuerKeysAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyList<IssuerKeyModel>>(Array.Empty<IssuerKeyModel>());
public ValueTask<IssuerTrustResponseModel> GetIssuerTrustAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
=> ValueTask.FromResult(DefaultTrust);
public ValueTask<IssuerTrustResponseModel> SetIssuerTrustAsync(
string tenantId,
string issuerId,
decimal weight,
string? reason,
CancellationToken cancellationToken)
=> ValueTask.FromResult(DefaultTrust);
public ValueTask DeleteIssuerTrustAsync(
string tenantId,
string issuerId,
string? reason,
CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
private sealed class StubNormalizerRouter : IVexNormalizerRouter
{
private readonly ImmutableArray<VexClaim> _claims;
public StubNormalizerRouter(IEnumerable<VexClaim> claims)
{
_claims = claims.ToImmutableArray();
}
public int CallCount { get; private set; }
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
{
CallCount++;
return ValueTask.FromResult(new VexClaimBatch(document, _claims, ImmutableDictionary<string, string>.Empty));
}
}
private sealed class TestIssuerDirectoryClient : IIssuerDirectoryClient
{
public static TestIssuerDirectoryClient Instance { get; } = new();
private static readonly IssuerTrustResponseModel DefaultTrust = new(null, null, 1m);
public ValueTask<IReadOnlyList<IssuerKeyModel>> GetIssuerKeysAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyList<IssuerKeyModel>>(Array.Empty<IssuerKeyModel>());
public ValueTask<IssuerTrustResponseModel> GetIssuerTrustAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
=> ValueTask.FromResult(DefaultTrust);
public ValueTask<IssuerTrustResponseModel> SetIssuerTrustAsync(
string tenantId,
string issuerId,
decimal weight,
string? reason,
CancellationToken cancellationToken)
=> ValueTask.FromResult(DefaultTrust);
public ValueTask DeleteIssuerTrustAsync(
string tenantId,
string issuerId,
string? reason,
CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopOrchestratorClient : IVexWorkerOrchestratorClient
{
public ValueTask<VexWorkerJobContext> StartJobAsync(string tenant, string connectorId, string? checkpoint, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new VexWorkerJobContext(tenant, connectorId, Guid.NewGuid(), checkpoint, DateTimeOffset.UtcNow));
public ValueTask SendHeartbeatAsync(VexWorkerJobContext context, VexWorkerHeartbeat heartbeat, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask RecordArtifactAsync(VexWorkerJobContext context, VexWorkerArtifact artifact, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask CompleteJobAsync(VexWorkerJobContext context, VexWorkerJobResult result, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask FailJobAsync(VexWorkerJobContext context, string errorCode, string? errorMessage, int? retryAfterSeconds, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask FailJobAsync(VexWorkerJobContext context, VexWorkerError error, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask<VexWorkerCommand?> GetPendingCommandAsync(VexWorkerJobContext context, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<VexWorkerCommand?>(null);
public ValueTask AcknowledgeCommandAsync(VexWorkerJobContext context, long commandSequence, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask SaveCheckpointAsync(VexWorkerJobContext context, VexWorkerCheckpoint checkpoint, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask<VexWorkerCheckpoint?> LoadCheckpointAsync(string connectorId, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<VexWorkerCheckpoint?>(null);
}
private sealed class InMemoryStateRepository : IVexConnectorStateRepository
@@ -545,6 +630,9 @@ public sealed class DefaultVexProviderRunnerTests
Save(state);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyCollection<VexConnectorState>>(_states.Values.ToList());
}
private sealed class TestConnector : IVexConnector
@@ -670,25 +758,25 @@ public sealed class DefaultVexProviderRunnerTests
}
}
private sealed class StubAttestationVerifier : IVexAttestationVerifier
{
private readonly bool _isValid;
private readonly VexAttestationDiagnostics _diagnostics;
public StubAttestationVerifier(bool isValid, ImmutableDictionary<string, string> diagnostics)
{
_isValid = isValid;
_diagnostics = VexAttestationDiagnostics.FromBuilder(diagnostics.ToBuilder());
}
public int Invocations { get; private set; }
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
Invocations++;
return ValueTask.FromResult(new VexAttestationVerification(_isValid, _diagnostics));
}
}
private sealed class StubAttestationVerifier : IVexAttestationVerifier
{
private readonly bool _isValid;
private readonly VexAttestationDiagnostics _diagnostics;
public StubAttestationVerifier(bool isValid, ImmutableDictionary<string, string> diagnostics)
{
_isValid = isValid;
_diagnostics = VexAttestationDiagnostics.FromBuilder(diagnostics.ToBuilder());
}
public int Invocations { get; private set; }
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
Invocations++;
return ValueTask.FromResult(new VexAttestationVerification(_isValid, _diagnostics));
}
}
private static VexRawDocument CreateAttestationRawDocument(DateTimeOffset observedAt)
{

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core.Orchestration;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Orchestration;
using Xunit;
namespace StellaOps.Excititor.Worker.Tests.Orchestration;
public class VexWorkerOrchestratorClientTests
{
private readonly InMemoryConnectorStateRepository _stateRepository = new();
private readonly FakeTimeProvider _timeProvider = new();
private readonly IOptions<VexWorkerOrchestratorOptions> _options = Microsoft.Extensions.Options.Options.Create(new VexWorkerOrchestratorOptions
{
Enabled = true,
DefaultTenant = "test-tenant"
});
[Fact]
public async Task StartJobAsync_CreatesJobContext()
{
var client = CreateClient();
var context = await client.StartJobAsync("tenant-a", "connector-001", "checkpoint-123");
Assert.NotNull(context);
Assert.Equal("tenant-a", context.Tenant);
Assert.Equal("connector-001", context.ConnectorId);
Assert.Equal("checkpoint-123", context.Checkpoint);
Assert.NotEqual(Guid.Empty, context.RunId);
}
[Fact]
public async Task SendHeartbeatAsync_UpdatesConnectorState()
{
var client = CreateClient();
var context = await client.StartJobAsync("tenant-a", "connector-001", null);
var heartbeat = new VexWorkerHeartbeat(
VexWorkerHeartbeatStatus.Running,
Progress: 50,
QueueDepth: null,
LastArtifactHash: "sha256:abc123",
LastArtifactKind: "vex-document",
ErrorCode: null,
RetryAfterSeconds: null);
await client.SendHeartbeatAsync(context, heartbeat);
var state = await _stateRepository.GetAsync("connector-001", CancellationToken.None);
Assert.NotNull(state);
Assert.Equal("Running", state.LastHeartbeatStatus);
Assert.NotNull(state.LastHeartbeatAt);
}
[Fact]
public async Task RecordArtifactAsync_TracksArtifactHash()
{
var client = CreateClient();
var context = await client.StartJobAsync("tenant-a", "connector-001", null);
var artifact = new VexWorkerArtifact(
"sha256:deadbeef",
"vex-raw-document",
"provider-001",
"doc-001",
_timeProvider.GetUtcNow());
await client.RecordArtifactAsync(context, artifact);
var state = await _stateRepository.GetAsync("connector-001", CancellationToken.None);
Assert.NotNull(state);
Assert.Equal("sha256:deadbeef", state.LastArtifactHash);
Assert.Equal("vex-raw-document", state.LastArtifactKind);
Assert.Contains("sha256:deadbeef", state.DocumentDigests);
}
[Fact]
public async Task CompleteJobAsync_UpdatesStateWithResults()
{
var client = CreateClient();
var context = await client.StartJobAsync("tenant-a", "connector-001", null);
var completedAt = _timeProvider.GetUtcNow();
var result = new VexWorkerJobResult(
DocumentsProcessed: 10,
ClaimsGenerated: 25,
LastCheckpoint: "checkpoint-new",
LastArtifactHash: "sha256:final",
CompletedAt: completedAt);
await client.CompleteJobAsync(context, result);
var state = await _stateRepository.GetAsync("connector-001", CancellationToken.None);
Assert.NotNull(state);
Assert.Equal("Succeeded", state.LastHeartbeatStatus);
Assert.Equal("checkpoint-new", state.LastCheckpoint);
Assert.Equal("sha256:final", state.LastArtifactHash);
Assert.Equal(0, state.FailureCount);
Assert.Null(state.NextEligibleRun);
}
[Fact]
public async Task FailJobAsync_UpdatesStateWithError()
{
var client = CreateClient();
var context = await client.StartJobAsync("tenant-a", "connector-001", null);
await client.FailJobAsync(context, "CONN_ERROR", "Connection failed", retryAfterSeconds: 60);
var state = await _stateRepository.GetAsync("connector-001", CancellationToken.None);
Assert.NotNull(state);
Assert.Equal("Failed", state.LastHeartbeatStatus);
Assert.Equal(1, state.FailureCount);
Assert.Contains("CONN_ERROR", state.LastFailureReason);
Assert.NotNull(state.NextEligibleRun);
}
[Fact]
public void VexWorkerJobContext_SequenceIncrements()
{
var context = new VexWorkerJobContext(
"tenant-a",
"connector-001",
Guid.NewGuid(),
null,
DateTimeOffset.UtcNow);
Assert.Equal(0, context.Sequence);
Assert.Equal(1, context.NextSequence());
Assert.Equal(2, context.NextSequence());
Assert.Equal(3, context.NextSequence());
}
private VexWorkerOrchestratorClient CreateClient()
=> new(
_stateRepository,
_timeProvider,
_options,
NullLogger<VexWorkerOrchestratorClient>.Instance);
private sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now = new(2025, 11, 27, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
private readonly Dictionary<string, VexConnectorState> _states = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
{
_states.TryGetValue(connectorId, out var state);
return ValueTask.FromResult(state);
}
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
{
_states[state.ConnectorId] = state;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyCollection<VexConnectorState>>(_states.Values.ToList());
}
}

View File

@@ -15,7 +15,7 @@ public sealed class TenantAuthorityClientFactoryTests
{
var options = new TenantAuthorityOptions();
options.BaseUrls.Add("tenant-a", "https://authority.example/");
var factory = new TenantAuthorityClientFactory(Options.Create(options));
var factory = new TenantAuthorityClientFactory(Microsoft.Extensions.Options.Options.Create(options));
using var client = factory.Create("tenant-a");
@@ -29,7 +29,7 @@ public sealed class TenantAuthorityClientFactoryTests
{
var options = new TenantAuthorityOptions();
options.BaseUrls.Add("tenant-a", "https://authority.example/");
var factory = new TenantAuthorityClientFactory(Options.Create(options));
var factory = new TenantAuthorityClientFactory(Microsoft.Extensions.Options.Options.Create(options));
FluentActions.Invoking(() => factory.Create(string.Empty))
.Should().Throw<ArgumentException>();
@@ -40,7 +40,7 @@ public sealed class TenantAuthorityClientFactoryTests
{
var options = new TenantAuthorityOptions();
options.BaseUrls.Add("tenant-a", "https://authority.example/");
var factory = new TenantAuthorityClientFactory(Options.Create(options));
var factory = new TenantAuthorityClientFactory(Microsoft.Extensions.Options.Options.Create(options));
FluentActions.Invoking(() => factory.Create("tenant-b"))
.Should().Throw<InvalidOperationException>();