up
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,5 +40,6 @@
|
||||
<Compile Include="GraphStatusFactoryTests.cs" />
|
||||
<Compile Include="GraphTooltipFactoryTests.cs" />
|
||||
<Compile Include="AttestationVerifyEndpointTests.cs" />
|
||||
<Compile Include="OpenApiDiscoveryEndpointTests.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user