audit, advisories and doctors/setup work
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.TimeProvider.Testing;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests.Evidence;
|
||||
|
||||
public sealed class VexEvidenceLinkerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LinkAsync_CreatesDeterministicLink()
|
||||
{
|
||||
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new VexEvidenceLinkOptions
|
||||
{
|
||||
ValidateSignatures = false,
|
||||
AllowUnverifiedEvidence = true
|
||||
});
|
||||
var linker = new VexEvidenceLinker(
|
||||
new InMemoryVexEvidenceLinkStore(),
|
||||
new NoopBinaryDiffVexEntryResolver(),
|
||||
new NoopEvidenceSignatureValidator(),
|
||||
time,
|
||||
options);
|
||||
|
||||
var source = new EvidenceSource
|
||||
{
|
||||
Type = EvidenceType.BinaryDiff,
|
||||
Uri = "oci://registry/evidence@sha256:abc",
|
||||
Digest = "sha256:abc",
|
||||
PredicateType = BinaryDiffPredicate.PredicateType,
|
||||
Confidence = 0.92,
|
||||
Justification = VexJustification.CodeNotPresent,
|
||||
EvidenceCreatedAt = time.GetUtcNow()
|
||||
};
|
||||
|
||||
var link = await linker.LinkAsync("vex:CVE-2026-0001:pkg:demo", source);
|
||||
|
||||
Assert.Equal("vex:CVE-2026-0001:pkg:demo", link.VexEntryId);
|
||||
Assert.Equal("sha256:abc", link.EnvelopeDigest);
|
||||
Assert.Equal(VexJustification.CodeNotPresent, link.Justification);
|
||||
Assert.False(link.SignatureValidated);
|
||||
Assert.Equal(VexEvidenceLinkIds.BuildLinkId(link.VexEntryId, source), link.LinkId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetLinksAsync_OrdersByConfidenceThenTime()
|
||||
{
|
||||
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new VexEvidenceLinkOptions
|
||||
{
|
||||
ValidateSignatures = false,
|
||||
AllowUnverifiedEvidence = true
|
||||
});
|
||||
var store = new InMemoryVexEvidenceLinkStore();
|
||||
var linker = new VexEvidenceLinker(
|
||||
store,
|
||||
new NoopBinaryDiffVexEntryResolver(),
|
||||
new NoopEvidenceSignatureValidator(),
|
||||
time,
|
||||
options);
|
||||
|
||||
var entryId = "vex:CVE-2026-0002:pkg:demo";
|
||||
await linker.LinkAsync(entryId, new EvidenceSource
|
||||
{
|
||||
Type = EvidenceType.BinaryDiff,
|
||||
Uri = "oci://registry/evidence@sha256:001",
|
||||
Digest = "sha256:001",
|
||||
PredicateType = BinaryDiffPredicate.PredicateType,
|
||||
Confidence = 0.5,
|
||||
Justification = VexJustification.CodeNotPresent,
|
||||
EvidenceCreatedAt = time.GetUtcNow()
|
||||
});
|
||||
|
||||
time.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
await linker.LinkAsync(entryId, new EvidenceSource
|
||||
{
|
||||
Type = EvidenceType.BinaryDiff,
|
||||
Uri = "oci://registry/evidence@sha256:002",
|
||||
Digest = "sha256:002",
|
||||
PredicateType = BinaryDiffPredicate.PredicateType,
|
||||
Confidence = 0.9,
|
||||
Justification = VexJustification.CodeNotReachable,
|
||||
EvidenceCreatedAt = time.GetUtcNow()
|
||||
});
|
||||
|
||||
var links = await linker.GetLinksAsync(entryId);
|
||||
|
||||
Assert.Equal(2, links.Links.Length);
|
||||
Assert.Equal("sha256:002", links.Links[0].EnvelopeDigest);
|
||||
Assert.Equal("sha256:001", links.Links[1].EnvelopeDigest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AutoLinkFromBinaryDiffAsync_RespectsThresholdAndJustification()
|
||||
{
|
||||
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new VexEvidenceLinkOptions
|
||||
{
|
||||
ValidateSignatures = false,
|
||||
AllowUnverifiedEvidence = true,
|
||||
ConfidenceThreshold = 0.8
|
||||
});
|
||||
var linker = new VexEvidenceLinker(
|
||||
new InMemoryVexEvidenceLinkStore(),
|
||||
new TestBinaryDiffResolver(),
|
||||
new NoopEvidenceSignatureValidator(),
|
||||
time,
|
||||
options);
|
||||
|
||||
var predicate = BuildPredicate(time.GetUtcNow(), 0.95, SectionStatus.Modified);
|
||||
var links = await linker.AutoLinkFromBinaryDiffAsync(predicate, "oci://registry/evidence@sha256:deadbeef");
|
||||
|
||||
Assert.Single(links);
|
||||
Assert.Equal(VexJustification.CodeNotPresent, links[0].Justification);
|
||||
Assert.Equal("sha256:deadbeef", links[0].EnvelopeDigest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AutoLinkFromBinaryDiffAsync_SkipsLowConfidence()
|
||||
{
|
||||
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new VexEvidenceLinkOptions
|
||||
{
|
||||
ValidateSignatures = false,
|
||||
AllowUnverifiedEvidence = true,
|
||||
ConfidenceThreshold = 0.9
|
||||
});
|
||||
var linker = new VexEvidenceLinker(
|
||||
new InMemoryVexEvidenceLinkStore(),
|
||||
new TestBinaryDiffResolver(),
|
||||
new NoopEvidenceSignatureValidator(),
|
||||
time,
|
||||
options);
|
||||
|
||||
var predicate = BuildPredicate(time.GetUtcNow(), 0.4, SectionStatus.Modified);
|
||||
var links = await linker.AutoLinkFromBinaryDiffAsync(predicate, "oci://registry/evidence@sha256:deadbeef");
|
||||
|
||||
Assert.Empty(links);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LinkAsync_RejectsUnverifiedEvidenceWhenRequired()
|
||||
{
|
||||
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new VexEvidenceLinkOptions
|
||||
{
|
||||
ValidateSignatures = true,
|
||||
AllowUnverifiedEvidence = false
|
||||
});
|
||||
var linker = new VexEvidenceLinker(
|
||||
new InMemoryVexEvidenceLinkStore(),
|
||||
new NoopBinaryDiffVexEntryResolver(),
|
||||
new AlwaysFailSignatureValidator(),
|
||||
time,
|
||||
options);
|
||||
|
||||
var source = new EvidenceSource
|
||||
{
|
||||
Type = EvidenceType.BinaryDiff,
|
||||
Uri = "oci://registry/evidence@sha256:dead",
|
||||
Digest = "sha256:dead",
|
||||
PredicateType = BinaryDiffPredicate.PredicateType,
|
||||
Confidence = 0.9,
|
||||
Justification = VexJustification.CodeNotReachable,
|
||||
EvidenceCreatedAt = time.GetUtcNow()
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
linker.LinkAsync("vex:CVE-2026-0010:pkg:demo", source));
|
||||
}
|
||||
|
||||
private static BinaryDiffPredicate BuildPredicate(
|
||||
DateTimeOffset timestamp,
|
||||
double confidence,
|
||||
SectionStatus textStatus)
|
||||
{
|
||||
var finding = new BinaryDiffFinding
|
||||
{
|
||||
Path = "/usr/bin/demo",
|
||||
ChangeType = ChangeType.Modified,
|
||||
BinaryFormat = BinaryFormat.Elf,
|
||||
Verdict = Verdict.Patched,
|
||||
Confidence = confidence,
|
||||
SectionDeltas =
|
||||
[
|
||||
new SectionDelta
|
||||
{
|
||||
Section = ".text",
|
||||
Status = textStatus
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return new BinaryDiffPredicate
|
||||
{
|
||||
Subjects =
|
||||
[
|
||||
new BinaryDiffSubject
|
||||
{
|
||||
Name = "demo",
|
||||
Digest = ImmutableDictionary<string, string>.Empty
|
||||
}
|
||||
],
|
||||
Inputs = new BinaryDiffInputs
|
||||
{
|
||||
Base = new BinaryDiffImageReference
|
||||
{
|
||||
Digest = "sha256:base"
|
||||
},
|
||||
Target = new BinaryDiffImageReference
|
||||
{
|
||||
Digest = "sha256:target"
|
||||
}
|
||||
},
|
||||
Findings = [finding],
|
||||
Metadata = new BinaryDiffMetadata
|
||||
{
|
||||
ToolVersion = "1.0.0",
|
||||
AnalysisTimestamp = timestamp,
|
||||
TotalBinaries = 1,
|
||||
ModifiedBinaries = 1,
|
||||
AnalyzedSections = ImmutableArray<string>.Empty
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestBinaryDiffResolver : IBinaryDiffVexEntryResolver
|
||||
{
|
||||
public ImmutableArray<string> ResolveEntryIds(BinaryDiffPredicate diff, BinaryDiffFinding finding)
|
||||
=> ImmutableArray.Create("vex:CVE-2026-0009:pkg:demo");
|
||||
}
|
||||
|
||||
private sealed class AlwaysFailSignatureValidator : IVexEvidenceSignatureValidator
|
||||
{
|
||||
public Task<EvidenceSignatureValidation> ValidateAsync(EvidenceSource source, CancellationToken ct = default)
|
||||
=> Task.FromResult(new EvidenceSignatureValidation
|
||||
{
|
||||
IsVerified = false,
|
||||
FailureReason = "signature_invalid"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0313-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0313-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0313-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| VEX-LINK-TESTS-0001 | DONE | SPRINT_20260113_003_001 - Evidence linker tests. |
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Text;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using StellaOps.Excititor.Attestation.Verification;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Policy;
|
||||
@@ -163,6 +164,33 @@ public sealed class ExportEngineTests
|
||||
Assert.Equal(manifest.QuietProvenance, store.LastSavedManifest!.QuietProvenance);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExportAsync_PopulatesEvidenceLinksWhenAvailable()
|
||||
{
|
||||
var store = new InMemoryExportStore();
|
||||
var evaluator = new StaticPolicyEvaluator("baseline/v1");
|
||||
var dataSource = new InMemoryExportDataSource();
|
||||
var exporter = new DummyExporter(VexExportFormat.Json);
|
||||
var evidenceLinker = new TestEvidenceLinker();
|
||||
var engine = new VexExportEngine(
|
||||
store,
|
||||
evaluator,
|
||||
dataSource,
|
||||
new[] { exporter },
|
||||
NullLogger<VexExportEngine>.Instance,
|
||||
evidenceLinker: evidenceLinker);
|
||||
|
||||
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
|
||||
var context = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow);
|
||||
|
||||
await engine.ExportAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(exporter.LastRequest);
|
||||
Assert.False(exporter.LastRequest!.EvidenceLinks.IsDefaultOrEmpty);
|
||||
Assert.Contains(evidenceLinker.EntryId, exporter.LastRequest.EvidenceLinks!.Keys);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExportAsync_IncludesQuietProvenanceMetadata()
|
||||
@@ -397,15 +425,70 @@ public sealed class ExportEngineTests
|
||||
|
||||
public VexExportFormat Format { get; }
|
||||
|
||||
public VexExportRequest? LastRequest { get; private set; }
|
||||
|
||||
public VexContentAddress Digest(VexExportRequest request)
|
||||
=> new("sha256", "deadbeef");
|
||||
|
||||
public ValueTask<VexExportResult> SerializeAsync(VexExportRequest request, Stream output, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes("{}");
|
||||
output.Write(bytes);
|
||||
return ValueTask.FromResult(new VexExportResult(new VexContentAddress("sha256", "deadbeef"), bytes.Length, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestEvidenceLinker : IVexEvidenceLinker
|
||||
{
|
||||
public TestEvidenceLinker()
|
||||
{
|
||||
EntryId = VexEvidenceLinkIds.BuildVexEntryId("CVE-2025-0001", "pkg:demo/app");
|
||||
}
|
||||
|
||||
public string EntryId { get; }
|
||||
|
||||
public Task<VexEvidenceLink> LinkAsync(string vexEntryId, EvidenceSource source, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<VexEvidenceLinkSet> GetLinksAsync(string vexEntryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.Equals(vexEntryId, EntryId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(new VexEvidenceLinkSet
|
||||
{
|
||||
VexEntryId = vexEntryId,
|
||||
Links = ImmutableArray<VexEvidenceLink>.Empty
|
||||
});
|
||||
}
|
||||
|
||||
var link = new VexEvidenceLink
|
||||
{
|
||||
LinkId = "vexlink:test",
|
||||
VexEntryId = vexEntryId,
|
||||
EvidenceType = EvidenceType.BinaryDiff,
|
||||
EvidenceUri = "oci://registry/evidence@sha256:abc",
|
||||
EnvelopeDigest = "sha256:abc",
|
||||
PredicateType = "stellaops.binarydiff.v1",
|
||||
Confidence = 0.9,
|
||||
Justification = VexJustification.CodeNotReachable,
|
||||
EvidenceCreatedAt = DateTimeOffset.UtcNow,
|
||||
LinkedAt = DateTimeOffset.UtcNow,
|
||||
SignatureValidated = false
|
||||
};
|
||||
|
||||
return Task.FromResult(new VexEvidenceLinkSet
|
||||
{
|
||||
VexEntryId = vexEntryId,
|
||||
Links = ImmutableArray.Create(link)
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<VexEvidenceLink>> AutoLinkFromBinaryDiffAsync(
|
||||
StellaOps.Attestor.StandardPredicates.BinaryDiff.BinaryDiffPredicate diff,
|
||||
string dsseEnvelopeUri,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ImmutableArray<VexEvidenceLink>.Empty);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0316-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0316-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0316-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| VEX-LINK-EXPORT-TESTS-0001 | DONE | SPRINT_20260113_003_001 - Evidence link wiring tests. |
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using StellaOps.Excititor.Formats.CycloneDX;
|
||||
|
||||
|
||||
@@ -70,4 +71,68 @@ public sealed class CycloneDxExporterTests
|
||||
result.Metadata["cyclonedx.componentCount"].Should().Be("1");
|
||||
result.Digest.Algorithm.Should().Be("sha256");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SerializeAsync_IncludesEvidenceMetadata()
|
||||
{
|
||||
var claim = new VexClaim(
|
||||
"CVE-2025-7001",
|
||||
"vendor:evidence",
|
||||
new VexProduct("pkg:demo/agent@2.0.0", "Demo Agent", "2.0.0", "pkg:demo/agent@2.0.0"),
|
||||
VexClaimStatus.NotAffected,
|
||||
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc2", new Uri("https://example.com/cyclonedx/2")),
|
||||
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
|
||||
justification: VexJustification.CodeNotReachable);
|
||||
|
||||
var entryId = VexEvidenceLinkIds.BuildVexEntryId(claim.VulnerabilityId, claim.Product.Key);
|
||||
var evidence = new VexEvidenceLink
|
||||
{
|
||||
LinkId = "vexlink:test",
|
||||
VexEntryId = entryId,
|
||||
EvidenceType = EvidenceType.BinaryDiff,
|
||||
EvidenceUri = "oci://registry/evidence@sha256:feed",
|
||||
EnvelopeDigest = "sha256:feed",
|
||||
PredicateType = "stellaops.binarydiff.v1",
|
||||
Confidence = 0.95,
|
||||
Justification = VexJustification.CodeNotPresent,
|
||||
EvidenceCreatedAt = new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
|
||||
LinkedAt = new DateTimeOffset(2025, 10, 12, 0, 0, 1, TimeSpan.Zero),
|
||||
SignatureValidated = true
|
||||
};
|
||||
|
||||
var evidenceLinks = ImmutableDictionary<string, VexEvidenceLinkSet>.Empty.Add(
|
||||
entryId,
|
||||
new VexEvidenceLinkSet
|
||||
{
|
||||
VexEntryId = entryId,
|
||||
Links = ImmutableArray.Create(evidence)
|
||||
});
|
||||
|
||||
var request = new VexExportRequest(
|
||||
VexQuery.Empty,
|
||||
ImmutableArray<VexConsensus>.Empty,
|
||||
ImmutableArray.Create(claim),
|
||||
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
|
||||
evidenceLinks);
|
||||
|
||||
var exporter = new CycloneDxExporter();
|
||||
await using var stream = new MemoryStream();
|
||||
await exporter.SerializeAsync(request, stream, CancellationToken.None);
|
||||
|
||||
stream.Position = 0;
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
var vulnerability = document.RootElement.GetProperty("vulnerabilities").EnumerateArray().Single();
|
||||
vulnerability.GetProperty("analysis").GetProperty("detail").GetString()
|
||||
.Should().Be("Evidence: oci://registry/evidence@sha256:feed");
|
||||
|
||||
var properties = vulnerability.GetProperty("properties").EnumerateArray().ToArray();
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:type");
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:uri");
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:confidence");
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:predicate-type");
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:envelope-digest");
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:signature-validated");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0320-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0320-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0320-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| VEX-LINK-CYCLONEDX-TESTS-0001 | DONE | SPRINT_20260113_003_001 - Evidence properties tests. |
|
||||
|
||||
@@ -414,6 +414,42 @@ public sealed class DefaultVexProviderRunnerTests
|
||||
state.NextEligibleRun.Should().Be(now + TimeSpan.FromHours(12));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeDeterministicSample_IsStable()
|
||||
{
|
||||
var sample1 = DefaultVexProviderRunner.ComputeDeterministicSample("excititor:test", 2);
|
||||
var sample2 = DefaultVexProviderRunner.ComputeDeterministicSample("excititor:test", 2);
|
||||
|
||||
sample1.Should().Be(sample2);
|
||||
sample1.Should().BeGreaterThanOrEqualTo(0d);
|
||||
sample1.Should().BeLessThanOrEqualTo(1d);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CalculateDelayWithJitter_IsDeterministic()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 21, 22, 0, 0, TimeSpan.Zero);
|
||||
var time = new FixedTimeProvider(now);
|
||||
var connector = TestConnector.Success("excititor:test");
|
||||
var stateRepository = new InMemoryStateRepository();
|
||||
var services = CreateServiceProvider(connector, stateRepository);
|
||||
var runner = CreateRunner(services, time, options =>
|
||||
{
|
||||
options.Retry.BaseDelay = TimeSpan.FromMinutes(1);
|
||||
options.Retry.MaxDelay = TimeSpan.FromMinutes(10);
|
||||
options.Retry.JitterRatio = 0.2;
|
||||
});
|
||||
|
||||
var delay1 = runner.CalculateDelayWithJitter(connector.Id, 2);
|
||||
var delay2 = runner.CalculateDelayWithJitter(connector.Id, 2);
|
||||
|
||||
delay1.Should().Be(delay2);
|
||||
delay1.Should().BeGreaterThanOrEqualTo(TimeSpan.FromMinutes(1));
|
||||
delay1.Should().BeLessThanOrEqualTo(TimeSpan.FromMinutes(10));
|
||||
}
|
||||
|
||||
private static ServiceProvider CreateServiceProvider(
|
||||
IVexConnector connector,
|
||||
InMemoryStateRepository stateRepository,
|
||||
@@ -590,7 +626,12 @@ public sealed class DefaultVexProviderRunnerTests
|
||||
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));
|
||||
=> ValueTask.FromResult(new VexWorkerJobContext(
|
||||
tenant,
|
||||
connectorId,
|
||||
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
checkpoint,
|
||||
DateTimeOffset.UnixEpoch));
|
||||
|
||||
public ValueTask SendHeartbeatAsync(VexWorkerJobContext context, VexWorkerHeartbeat heartbeat, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
@@ -26,7 +27,8 @@ public class VexWorkerOrchestratorClientTests
|
||||
[Fact]
|
||||
public async Task StartJobAsync_CreatesJobContext()
|
||||
{
|
||||
var client = CreateClient();
|
||||
var expectedRunId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var client = CreateClient(guidGenerator: new FixedGuidGenerator(expectedRunId));
|
||||
|
||||
var context = await client.StartJobAsync("tenant-a", "connector-001", "checkpoint-123");
|
||||
|
||||
@@ -34,7 +36,7 @@ public class VexWorkerOrchestratorClientTests
|
||||
Assert.Equal("tenant-a", context.Tenant);
|
||||
Assert.Equal("connector-001", context.ConnectorId);
|
||||
Assert.Equal("checkpoint-123", context.Checkpoint);
|
||||
Assert.NotEqual(Guid.Empty, context.RunId);
|
||||
Assert.Equal(expectedRunId, context.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -63,8 +65,8 @@ public class VexWorkerOrchestratorClientTests
|
||||
[Fact]
|
||||
public async Task StartJobAsync_UsesOrchestratorClaim_WhenAvailable()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
var leaseId = Guid.NewGuid();
|
||||
var jobId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
var leaseId = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||
|
||||
var handler = new StubHandler(request =>
|
||||
{
|
||||
@@ -104,9 +106,12 @@ public class VexWorkerOrchestratorClientTests
|
||||
[Fact]
|
||||
public async Task SendHeartbeatAsync_ExtendsLeaseViaOrchestrator()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
var leaseId = Guid.NewGuid();
|
||||
var leaseUntil = DateTimeOffset.Parse("2025-12-01T12:05:00Z");
|
||||
var jobId = Guid.Parse("44444444-4444-4444-4444-444444444444");
|
||||
var leaseId = Guid.Parse("55555555-5555-5555-5555-555555555555");
|
||||
var leaseUntil = DateTimeOffset.Parse(
|
||||
"2025-12-01T12:05:00Z",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind);
|
||||
|
||||
var handler = new StubHandler(request =>
|
||||
{
|
||||
@@ -170,8 +175,8 @@ public class VexWorkerOrchestratorClientTests
|
||||
[Fact]
|
||||
public async Task SendHeartbeatAsync_StoresThrottleCommand_On429()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
var leaseId = Guid.NewGuid();
|
||||
var jobId = Guid.Parse("66666666-6666-6666-6666-666666666666");
|
||||
var leaseId = Guid.Parse("77777777-7777-7777-7777-777777777777");
|
||||
|
||||
var handler = new StubHandler(request =>
|
||||
{
|
||||
@@ -295,9 +300,9 @@ public class VexWorkerOrchestratorClientTests
|
||||
var context = new VexWorkerJobContext(
|
||||
"tenant-a",
|
||||
"connector-001",
|
||||
Guid.NewGuid(),
|
||||
Guid.Parse("88888888-8888-8888-8888-888888888888"),
|
||||
null,
|
||||
DateTimeOffset.UtcNow);
|
||||
new DateTimeOffset(2025, 11, 27, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
Assert.Equal(0, context.Sequence);
|
||||
Assert.Equal(1, context.NextSequence());
|
||||
@@ -307,7 +312,8 @@ public class VexWorkerOrchestratorClientTests
|
||||
|
||||
private VexWorkerOrchestratorClient CreateClient(
|
||||
HttpClient? httpClient = null,
|
||||
Action<VexWorkerOrchestratorOptions>? configure = null)
|
||||
Action<VexWorkerOrchestratorOptions>? configure = null,
|
||||
IGuidGenerator? guidGenerator = null)
|
||||
{
|
||||
var opts = new VexWorkerOrchestratorOptions
|
||||
{
|
||||
@@ -320,6 +326,7 @@ public class VexWorkerOrchestratorClientTests
|
||||
return new VexWorkerOrchestratorClient(
|
||||
_stateRepository,
|
||||
_timeProvider,
|
||||
guidGenerator ?? new FixedGuidGenerator(Guid.Parse("99999999-9999-9999-9999-999999999999")),
|
||||
Microsoft.Extensions.Options.Options.Create(opts),
|
||||
NullLogger<VexWorkerOrchestratorClient>.Instance,
|
||||
httpClient);
|
||||
@@ -380,4 +387,13 @@ public class VexWorkerOrchestratorClientTests
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FixedGuidGenerator : IGuidGenerator
|
||||
{
|
||||
private readonly Guid _value;
|
||||
|
||||
public FixedGuidGenerator(Guid value) => _value = value;
|
||||
|
||||
public Guid NewGuid() => _value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
using StellaOps.Excititor.Worker.Scheduling;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Tests.Scheduling;
|
||||
|
||||
public sealed class VexConsensusRefreshServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScheduleRefresh_IgnoresWhenDisabled()
|
||||
{
|
||||
var options = new VexWorkerOptions { DisableConsensus = true };
|
||||
var monitor = new TestOptionsMonitor(options);
|
||||
var service = new VexConsensusRefreshService(
|
||||
new StubScopeFactory(),
|
||||
monitor,
|
||||
NullLogger<VexConsensusRefreshService>.Instance,
|
||||
new FixedTimeProvider());
|
||||
|
||||
service.ScheduleRefresh("CVE-2025-0001", "pkg:example/test");
|
||||
|
||||
Assert.Equal(0, GetScheduledCount(service));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleRefresh_DeduplicatesRequests()
|
||||
{
|
||||
var options = new VexWorkerOptions { DisableConsensus = false };
|
||||
var monitor = new TestOptionsMonitor(options);
|
||||
var service = new VexConsensusRefreshService(
|
||||
new StubScopeFactory(),
|
||||
monitor,
|
||||
NullLogger<VexConsensusRefreshService>.Instance,
|
||||
new FixedTimeProvider());
|
||||
|
||||
service.ScheduleRefresh("CVE-2025-0001", "pkg:example/test");
|
||||
service.ScheduleRefresh("CVE-2025-0001", "pkg:example/test");
|
||||
|
||||
Assert.Equal(1, GetScheduledCount(service));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleRefresh_RespectsUpdatedOptions()
|
||||
{
|
||||
var options = new VexWorkerOptions { DisableConsensus = true };
|
||||
var monitor = new TestOptionsMonitor(options);
|
||||
var service = new VexConsensusRefreshService(
|
||||
new StubScopeFactory(),
|
||||
monitor,
|
||||
NullLogger<VexConsensusRefreshService>.Instance,
|
||||
new FixedTimeProvider());
|
||||
|
||||
service.ScheduleRefresh("CVE-2025-0002", "pkg:example/test");
|
||||
Assert.Equal(0, GetScheduledCount(service));
|
||||
|
||||
monitor.Update(new VexWorkerOptions { DisableConsensus = false });
|
||||
service.ScheduleRefresh("CVE-2025-0002", "pkg:example/test");
|
||||
Assert.Equal(1, GetScheduledCount(service));
|
||||
}
|
||||
|
||||
private static int GetScheduledCount(VexConsensusRefreshService service)
|
||||
{
|
||||
var field = typeof(VexConsensusRefreshService).GetField("_scheduledKeys", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var keys = (ConcurrentDictionary<string, byte>?)field?.GetValue(service);
|
||||
return keys?.Count ?? 0;
|
||||
}
|
||||
|
||||
private sealed class StubScopeFactory : IServiceScopeFactory
|
||||
{
|
||||
public IServiceScope CreateScope() => new StubScope();
|
||||
}
|
||||
|
||||
private sealed class StubScope : IServiceScope
|
||||
{
|
||||
public IServiceProvider ServiceProvider => new ServiceCollection().BuildServiceProvider();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor : IOptionsMonitor<VexWorkerOptions>
|
||||
{
|
||||
private VexWorkerOptions _current;
|
||||
private event Action<VexWorkerOptions, string?>? _listeners;
|
||||
|
||||
public TestOptionsMonitor(VexWorkerOptions current) => _current = current;
|
||||
|
||||
public VexWorkerOptions CurrentValue => _current;
|
||||
|
||||
public VexWorkerOptions Get(string? name) => _current;
|
||||
|
||||
public IDisposable OnChange(Action<VexWorkerOptions, string?> listener)
|
||||
{
|
||||
_listeners += listener;
|
||||
return new CallbackDisposable(() => _listeners -= listener);
|
||||
}
|
||||
|
||||
public void Update(VexWorkerOptions options)
|
||||
{
|
||||
_current = options;
|
||||
_listeners?.Invoke(options, null);
|
||||
}
|
||||
|
||||
private sealed class CallbackDisposable : IDisposable
|
||||
{
|
||||
private readonly Action _dispose;
|
||||
|
||||
public CallbackDisposable(Action dispose) => _dispose = dispose;
|
||||
|
||||
public void Dispose() => _dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow = new(2025, 11, 27, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
using StellaOps.Excititor.Worker.Scheduling;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Tests.Scheduling;
|
||||
|
||||
public sealed class VexWorkerHostedServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_NoSchedules_DoesNotInvokeRunner()
|
||||
{
|
||||
var options = Options.Create(new VexWorkerOptions());
|
||||
var runner = new RecordingRunner();
|
||||
var service = new TestableVexWorkerHostedService(options, runner);
|
||||
|
||||
await service.RunAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, runner.Invocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ScheduleRunsOnceBeforeCancellation()
|
||||
{
|
||||
var workerOptions = new VexWorkerOptions();
|
||||
workerOptions.Providers.Add(new VexWorkerProviderOptions
|
||||
{
|
||||
ProviderId = "excititor:test",
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromHours(1),
|
||||
InitialDelay = TimeSpan.Zero,
|
||||
});
|
||||
|
||||
var options = Options.Create(workerOptions);
|
||||
var runner = new RecordingRunner();
|
||||
var service = new TestableVexWorkerHostedService(options, runner);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var runTask = service.RunAsync(cts.Token);
|
||||
await runner.WaitForFirstRunAsync();
|
||||
cts.Cancel();
|
||||
|
||||
await runTask;
|
||||
Assert.Equal(1, runner.Invocations);
|
||||
}
|
||||
|
||||
private sealed class TestableVexWorkerHostedService : VexWorkerHostedService
|
||||
{
|
||||
public TestableVexWorkerHostedService(IOptions<VexWorkerOptions> options, IVexProviderRunner runner)
|
||||
: base(options, runner, NullLogger<VexWorkerHostedService>.Instance, new FixedTimeProvider())
|
||||
{
|
||||
}
|
||||
|
||||
public Task RunAsync(CancellationToken cancellationToken) => ExecuteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class RecordingRunner : IVexProviderRunner
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _firstRun = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public int Invocations { get; private set; }
|
||||
|
||||
public ValueTask RunAsync(VexWorkerSchedule schedule, CancellationToken cancellationToken)
|
||||
{
|
||||
Invocations++;
|
||||
_firstRun.TrySetResult(true);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WaitForFirstRunAsync()
|
||||
=> _firstRun.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow = new(2025, 11, 27, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -23,6 +24,7 @@ public sealed class WorkerSignatureVerifierTests
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsMetadata_WhenSignatureHintsPresent()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 12, 1, 10, 0, 0, TimeSpan.Zero);
|
||||
var content = Encoding.UTF8.GetBytes("{\"id\":\"1\"}");
|
||||
var digest = ComputeDigest(content);
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
@@ -31,14 +33,14 @@ public sealed class WorkerSignatureVerifierTests
|
||||
.Add("vex.signature.subject", "subject")
|
||||
.Add("vex.signature.issuer", "issuer")
|
||||
.Add("vex.signature.keyId", "kid")
|
||||
.Add("vex.signature.verifiedAt", DateTimeOffset.UtcNow.ToString("O"))
|
||||
.Add("vex.signature.verifiedAt", now.ToString("O"))
|
||||
.Add("vex.signature.transparencyLogReference", "rekor://entry");
|
||||
|
||||
var document = new VexRawDocument(
|
||||
"provider-a",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.org/vex.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
now,
|
||||
digest,
|
||||
content,
|
||||
metadata);
|
||||
@@ -60,13 +62,14 @@ public sealed class WorkerSignatureVerifierTests
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Throws_WhenChecksumMismatch()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 12, 1, 11, 0, 0, TimeSpan.Zero);
|
||||
var content = Encoding.UTF8.GetBytes("{\"id\":\"1\"}");
|
||||
var metadata = ImmutableDictionary<string, string>.Empty;
|
||||
var document = new VexRawDocument(
|
||||
"provider-a",
|
||||
VexDocumentFormat.CycloneDx,
|
||||
new Uri("https://example.org/vex.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
now,
|
||||
"sha256:deadbeef",
|
||||
content,
|
||||
metadata);
|
||||
@@ -82,7 +85,7 @@ public sealed class WorkerSignatureVerifierTests
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Attestation_UsesVerifier()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = new DateTimeOffset(2025, 12, 2, 8, 0, 0, TimeSpan.Zero);
|
||||
var (document, metadata) = CreateAttestationDocument(now, subject: "export-1", includeRekor: true);
|
||||
|
||||
var attestationVerifier = new StubAttestationVerifier(true);
|
||||
@@ -103,7 +106,7 @@ public sealed class WorkerSignatureVerifierTests
|
||||
[Fact]
|
||||
public async Task VerifyAsync_AttestationThrows_WhenVerifierInvalid()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = new DateTimeOffset(2025, 12, 2, 9, 0, 0, TimeSpan.Zero);
|
||||
var (document, metadata) = CreateAttestationDocument(now, subject: "export-2", includeRekor: true);
|
||||
|
||||
var attestationVerifier = new StubAttestationVerifier(false);
|
||||
@@ -131,7 +134,7 @@ public sealed class WorkerSignatureVerifierTests
|
||||
var verifier = new WorkerSignatureVerifier(
|
||||
NullLogger<WorkerSignatureVerifier>.Instance,
|
||||
attestationVerifier,
|
||||
new FixedTimeProvider(now),
|
||||
new FixedTimeProvider(now.AddMinutes(5)),
|
||||
StubIssuerDirectoryClient.DefaultFor("tenant-a", "issuer-from-attestation", "kid-from-attestation"));
|
||||
|
||||
var result = await verifier.VerifyAsync(document, CancellationToken.None);
|
||||
@@ -147,7 +150,7 @@ public sealed class WorkerSignatureVerifierTests
|
||||
[Fact]
|
||||
public async Task VerifyAsync_AttachesIssuerTrustMetadata()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = new DateTimeOffset(2025, 12, 3, 10, 0, 0, TimeSpan.Zero);
|
||||
var content = Encoding.UTF8.GetBytes("{\"id\":\"trust\"}");
|
||||
var digest = ComputeDigest(content);
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
@@ -180,6 +183,46 @@ public sealed class WorkerSignatureVerifierTests
|
||||
result.Trust!.IssuerId.Should().Be("issuer-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ParsesVerifiedAt_InInvariantCulture()
|
||||
{
|
||||
var originalCulture = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("fr-FR");
|
||||
|
||||
var now = new DateTimeOffset(2025, 12, 4, 9, 0, 0, TimeSpan.Zero);
|
||||
var content = Encoding.UTF8.GetBytes("{\"id\":\"culture\"}");
|
||||
var digest = ComputeDigest(content);
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("tenant", "tenant-a")
|
||||
.Add("vex.signature.type", "cosign")
|
||||
.Add("vex.signature.verifiedAt", now.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
var document = new VexRawDocument(
|
||||
"provider-a",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.org/vex-culture.json"),
|
||||
now,
|
||||
digest,
|
||||
content,
|
||||
metadata);
|
||||
|
||||
var verifier = new WorkerSignatureVerifier(
|
||||
NullLogger<WorkerSignatureVerifier>.Instance,
|
||||
issuerDirectoryClient: StubIssuerDirectoryClient.Empty());
|
||||
|
||||
var result = await verifier.VerifyAsync(document, CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.VerifiedAt.Should().Be(now);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = originalCulture;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Worker.Auth;
|
||||
@@ -18,7 +18,9 @@ public sealed class TenantAuthorityClientFactoryTests
|
||||
{
|
||||
var options = new TenantAuthorityOptions();
|
||||
options.BaseUrls.Add("tenant-a", "https://authority.example/");
|
||||
var factory = new TenantAuthorityClientFactory(Microsoft.Extensions.Options.Options.Create(options));
|
||||
var factory = new TenantAuthorityClientFactory(
|
||||
Options.Create(options),
|
||||
new StubHttpClientFactory());
|
||||
|
||||
using var client = factory.Create("tenant-a");
|
||||
|
||||
@@ -33,7 +35,9 @@ public sealed class TenantAuthorityClientFactoryTests
|
||||
{
|
||||
var options = new TenantAuthorityOptions();
|
||||
options.BaseUrls.Add("tenant-a", "https://authority.example/");
|
||||
var factory = new TenantAuthorityClientFactory(Microsoft.Extensions.Options.Options.Create(options));
|
||||
var factory = new TenantAuthorityClientFactory(
|
||||
Options.Create(options),
|
||||
new StubHttpClientFactory());
|
||||
|
||||
FluentActions.Invoking(() => factory.Create(string.Empty))
|
||||
.Should().Throw<ArgumentException>();
|
||||
@@ -45,9 +49,16 @@ public sealed class TenantAuthorityClientFactoryTests
|
||||
{
|
||||
var options = new TenantAuthorityOptions();
|
||||
options.BaseUrls.Add("tenant-a", "https://authority.example/");
|
||||
var factory = new TenantAuthorityClientFactory(Microsoft.Extensions.Options.Options.Create(options));
|
||||
var factory = new TenantAuthorityClientFactory(
|
||||
Options.Create(options),
|
||||
new StubHttpClientFactory());
|
||||
|
||||
FluentActions.Invoking(() => factory.Create("tenant-b"))
|
||||
.Should().Throw<InvalidOperationException>();
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user