tests fixes and sprints work
This commit is contained in:
@@ -84,7 +84,18 @@
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [],
|
||||
"provenance": []
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "affected",
|
||||
"value": "ExampleCo Router X",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vendor",
|
||||
@@ -93,7 +104,18 @@
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [],
|
||||
"provenance": []
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "affected",
|
||||
"value": "ExampleCo Router Y",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
@@ -152,11 +174,11 @@
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"source": "acsc",
|
||||
"kind": "document",
|
||||
"value": "https://origin.example/feeds/multi/rss",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "multi",
|
||||
@@ -166,11 +188,11 @@
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"source": "acsc",
|
||||
"kind": "document",
|
||||
"value": "https://origin.example/feeds/multi/rss",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Persistence.Postgres;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Tests;
|
||||
|
||||
[Collection(ConcelierPostgresCollection.Name)]
|
||||
public sealed class SbomRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow =
|
||||
new(2026, 1, 20, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly ConcelierPostgresFixture _fixture;
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly SbomRepository _repository;
|
||||
|
||||
public SbomRepositoryTests(ConcelierPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
_dataSource = new ConcelierDataSource(
|
||||
Options.Create(options),
|
||||
NullLogger<ConcelierDataSource>.Instance);
|
||||
|
||||
_repository = new SbomRepository(
|
||||
_dataSource,
|
||||
NullLogger<SbomRepository>.Instance,
|
||||
new FixedTimeProvider(FixedNow),
|
||||
new SequentialGuidProvider());
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
|
||||
|
||||
public async ValueTask DisposeAsync() => await _dataSource.DisposeAsync();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StoreAsync_RoundTripsBySerialAndDigest()
|
||||
{
|
||||
var sbom = CreateSbom(serialNumber: "urn:sha256:deadbeef");
|
||||
|
||||
await _repository.StoreAsync(sbom);
|
||||
|
||||
var bySerial = await _repository.GetBySerialNumberAsync(sbom.SerialNumber);
|
||||
var byDigest = await _repository.GetByArtifactDigestAsync("sha256:deadbeef");
|
||||
|
||||
bySerial.Should().NotBeNull();
|
||||
bySerial!.SpecVersion.Should().Be("1.7");
|
||||
bySerial.Metadata.RootComponentRef.Should().Be("root");
|
||||
|
||||
byDigest.Should().NotBeNull();
|
||||
byDigest!.SerialNumber.Should().Be(sbom.SerialNumber);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Queries_ReturnServicesCryptoAndVulnerabilities()
|
||||
{
|
||||
var sbom = CreateSbom(serialNumber: "urn:sha256:feedface");
|
||||
|
||||
await _repository.StoreAsync(sbom);
|
||||
|
||||
var services = await _repository.GetServicesForArtifactAsync(sbom.SerialNumber);
|
||||
var cryptoComponents = await _repository.GetComponentsWithCryptoAsync("sha256:feedface");
|
||||
var vulnerabilities = await _repository.GetEmbeddedVulnerabilitiesAsync("sha256:feedface");
|
||||
|
||||
services.Should().ContainSingle(service => service.Name == "api-gateway");
|
||||
var cryptoComponent = cryptoComponents.Should()
|
||||
.ContainSingle(component => component.BomRef == "root")
|
||||
.Which;
|
||||
cryptoComponent.ModelCard.Should().NotBeNull();
|
||||
cryptoComponent.ModelCard!.ModelParameters!.Task.Should().Be("classification");
|
||||
vulnerabilities.Should().ContainSingle(vuln => vuln.Id == "CVE-2026-0001");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByArtifactDigestAsync_ReturnsCompositionsAndDeclarations()
|
||||
{
|
||||
var sbom = CreateSbom(serialNumber: "urn:sha256:cccccccc");
|
||||
|
||||
await _repository.StoreAsync(sbom);
|
||||
|
||||
var byDigest = await _repository.GetByArtifactDigestAsync("sha256:cccccccc");
|
||||
|
||||
byDigest.Should().NotBeNull();
|
||||
byDigest!.Compositions.Should().ContainSingle(composition =>
|
||||
composition.Aggregate == CompositionAggregate.Complete);
|
||||
byDigest.Declarations.Should().NotBeNull();
|
||||
byDigest.Declarations!.Attestations.Should().ContainSingle(attestation =>
|
||||
attestation.Predicate == "build");
|
||||
byDigest.Declarations!.Affirmations.Should().ContainSingle(affirmation =>
|
||||
affirmation.Statement == "SBOM verified");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LicenseQueries_ReturnExpectedResults()
|
||||
{
|
||||
var sbomAlpha = CreateLicensedSbom(
|
||||
"urn:sha256:aaaaaaaa",
|
||||
"root-alpha",
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "root-alpha",
|
||||
Name = "alpha-app",
|
||||
Licenses =
|
||||
[
|
||||
new ParsedLicense
|
||||
{
|
||||
SpdxId = "MIT"
|
||||
}
|
||||
]
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "lib-gpl",
|
||||
Name = "lib-gpl",
|
||||
Licenses =
|
||||
[
|
||||
new ParsedLicense
|
||||
{
|
||||
Expression = new WithException(
|
||||
new SimpleLicense("GPL-2.0-only"),
|
||||
"Classpath-exception-2.0")
|
||||
}
|
||||
]
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "lib-empty",
|
||||
Name = "lib-empty"
|
||||
});
|
||||
|
||||
var sbomBeta = CreateLicensedSbom(
|
||||
"urn:sha256:bbbbbbbb",
|
||||
"root-beta",
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "root-beta",
|
||||
Name = "beta-app",
|
||||
Licenses =
|
||||
[
|
||||
new ParsedLicense
|
||||
{
|
||||
SpdxId = "Apache-2.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "lib-prop",
|
||||
Name = "lib-prop",
|
||||
Licenses =
|
||||
[
|
||||
new ParsedLicense
|
||||
{
|
||||
SpdxId = "LicenseRef-Proprietary"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await _repository.StoreAsync(sbomAlpha);
|
||||
await _repository.StoreAsync(sbomBeta);
|
||||
|
||||
var licenses = await _repository.GetLicensesForArtifactAsync("sha256:aaaaaaaa");
|
||||
licenses.Should().Contain(license => license.SpdxId == "MIT");
|
||||
licenses.Should().Contain(license => license.Expression is WithException);
|
||||
|
||||
var mitComponents = await _repository.GetComponentsByLicenseAsync("MIT");
|
||||
mitComponents.Should().ContainSingle(component => component.BomRef == "root-alpha");
|
||||
|
||||
var gplComponents = await _repository.GetComponentsByLicenseAsync("GPL-2.0-only");
|
||||
gplComponents.Should().ContainSingle(component => component.BomRef == "lib-gpl");
|
||||
|
||||
var noLicense = await _repository.GetComponentsWithoutLicenseAsync("sha256:aaaaaaaa");
|
||||
noLicense.Should().ContainSingle(component => component.BomRef == "lib-empty");
|
||||
|
||||
var permissive = await _repository.GetComponentsByLicenseCategoryAsync(
|
||||
"sha256:aaaaaaaa",
|
||||
LicenseCategory.Permissive);
|
||||
permissive.Should().ContainSingle(component => component.BomRef == "root-alpha");
|
||||
|
||||
var weakCopyleft = await _repository.GetComponentsByLicenseCategoryAsync(
|
||||
"sha256:aaaaaaaa",
|
||||
LicenseCategory.WeakCopyleft);
|
||||
weakCopyleft.Should().ContainSingle(component => component.BomRef == "lib-gpl");
|
||||
|
||||
var proprietary = await _repository.GetComponentsByLicenseCategoryAsync(
|
||||
"sha256:bbbbbbbb",
|
||||
LicenseCategory.Proprietary);
|
||||
proprietary.Should().ContainSingle(component => component.BomRef == "lib-prop");
|
||||
|
||||
var summary = await _repository.GetLicenseInventoryAsync("sha256:aaaaaaaa");
|
||||
summary.TotalComponents.Should().Be(3);
|
||||
summary.ComponentsWithLicense.Should().Be(2);
|
||||
summary.ComponentsWithoutLicense.Should().Be(1);
|
||||
summary.LicenseDistribution.Should().ContainKey("MIT");
|
||||
summary.LicenseDistribution.Should().ContainKey("GPL-2.0-only");
|
||||
summary.Expressions.Should().Contain("GPL-2.0-only WITH Classpath-exception-2.0");
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateSbom(string serialNumber)
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "cyclonedx",
|
||||
SpecVersion = "1.7",
|
||||
SerialNumber = serialNumber,
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "root",
|
||||
Name = "acme-app",
|
||||
CryptoProperties = new ParsedCryptoProperties
|
||||
{
|
||||
AssetType = CryptoAssetType.Algorithm
|
||||
},
|
||||
ModelCard = new ParsedModelCard
|
||||
{
|
||||
ModelParameters = new ParsedModelParameters
|
||||
{
|
||||
Task = "classification"
|
||||
}
|
||||
}
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "lib-1",
|
||||
Name = "lib-one"
|
||||
}
|
||||
],
|
||||
Services =
|
||||
[
|
||||
new ParsedService
|
||||
{
|
||||
BomRef = "svc-api",
|
||||
Name = "api-gateway",
|
||||
Endpoints = ["https://api.example.test"]
|
||||
}
|
||||
],
|
||||
Vulnerabilities =
|
||||
[
|
||||
new ParsedVulnerability
|
||||
{
|
||||
Id = "CVE-2026-0001"
|
||||
}
|
||||
],
|
||||
Compositions =
|
||||
[
|
||||
new ParsedComposition
|
||||
{
|
||||
Aggregate = CompositionAggregate.Complete,
|
||||
Assemblies = ["root"],
|
||||
Dependencies = ["lib-1"],
|
||||
Vulnerabilities = ["CVE-2026-0001"]
|
||||
}
|
||||
],
|
||||
Declarations = new ParsedDeclarations
|
||||
{
|
||||
Attestations =
|
||||
[
|
||||
new ParsedAttestation
|
||||
{
|
||||
Subjects = ["root"],
|
||||
Predicate = "build",
|
||||
Evidence = "evidence-ref"
|
||||
}
|
||||
],
|
||||
Affirmations =
|
||||
[
|
||||
new ParsedAffirmation
|
||||
{
|
||||
Statement = "SBOM verified",
|
||||
Signatories = ["acme"]
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Name = "acme-app",
|
||||
RootComponentRef = "root"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateLicensedSbom(
|
||||
string serialNumber,
|
||||
string rootComponentRef,
|
||||
params ParsedComponent[] components)
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "cyclonedx",
|
||||
SpecVersion = "1.7",
|
||||
SerialNumber = serialNumber,
|
||||
Components = components.ToImmutableArray(),
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Name = "licensed-app",
|
||||
RootComponentRef = rootComponentRef
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0231-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0231-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0231-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| TASK-015-011 | DONE | Added SbomRepository integration coverage. |
|
||||
| TASK-015-007d | DONE | Added license query coverage for SbomRepository. |
|
||||
| TASK-015-013 | DONE | Added SbomRepository integration coverage for model cards and policy fields. |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class SbomAdvisoryMatcherVexTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MatchAsync_FiltersNotAffectedVexStatements()
|
||||
{
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var purl = "pkg:npm/example@1.0.0";
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2026-5000", purl);
|
||||
|
||||
var canonicalService = new Mock<ICanonicalAdvisoryService>();
|
||||
canonicalService
|
||||
.Setup(service => service.GetByArtifactAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
var sbomRepository = new Mock<ISbomRepository>();
|
||||
var parsedSbom = CreateSbom();
|
||||
sbomRepository
|
||||
.Setup(repo => repo.GetByArtifactDigestAsync("sha256:abc", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(parsedSbom);
|
||||
|
||||
var vexConsumer = new Mock<IVexConsumer>();
|
||||
var vexResult = new VexConsumptionResult
|
||||
{
|
||||
Statements =
|
||||
[
|
||||
new ConsumedVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-5000",
|
||||
Status = VexStatus.NotAffected,
|
||||
Source = VexSource.SbomEmbedded,
|
||||
TrustLevel = VexTrustLevel.Trusted
|
||||
}
|
||||
]
|
||||
};
|
||||
vexConsumer
|
||||
.Setup(consumer => consumer.ConsumeAsync(parsedSbom.Vulnerabilities, It.IsAny<VexConsumptionPolicy>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(vexResult);
|
||||
|
||||
var policyLoader = new Mock<IVexConsumptionPolicyLoader>();
|
||||
policyLoader
|
||||
.Setup(loader => loader.LoadAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(VexConsumptionPolicyDefaults.Default);
|
||||
|
||||
var options = Options.Create(new VexConsumptionOptions { Enabled = true });
|
||||
var logger = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
|
||||
var matcher = new SbomAdvisoryMatcher(
|
||||
canonicalService.Object,
|
||||
logger.Object,
|
||||
timeProvider: null,
|
||||
vexConsumer.Object,
|
||||
sbomRepository.Object,
|
||||
policyLoader.Object,
|
||||
options);
|
||||
|
||||
var matches = await matcher.MatchAsync(sbomId, "sha256:abc", new[] { purl }, null, null);
|
||||
|
||||
Assert.Empty(matches);
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateSbom()
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.7",
|
||||
SerialNumber = "urn:uuid:sbom",
|
||||
Vulnerabilities =
|
||||
[
|
||||
new ParsedVulnerability
|
||||
{
|
||||
Id = "CVE-2026-5000",
|
||||
Analysis = new ParsedVulnAnalysis
|
||||
{
|
||||
State = VexState.NotAffected,
|
||||
Justification = VexJustification.ComponentNotPresent,
|
||||
FirstIssued = DateTimeOffset.Parse("2026-01-20T00:00:00Z"),
|
||||
LastUpdated = DateTimeOffset.Parse("2026-01-20T00:00:00Z")
|
||||
}
|
||||
}
|
||||
],
|
||||
Components = [],
|
||||
Dependencies = [],
|
||||
Services = [],
|
||||
Compositions = [],
|
||||
Annotations = [],
|
||||
Metadata = new ParsedSbomMetadata()
|
||||
};
|
||||
}
|
||||
|
||||
private static CanonicalAdvisory CreateCanonicalAdvisory(Guid id, string cve, string affectsKey)
|
||||
{
|
||||
return new CanonicalAdvisory
|
||||
{
|
||||
Id = id,
|
||||
Cve = cve,
|
||||
AffectsKey = affectsKey,
|
||||
MergeHash = "hash",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-01-20T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-01-20T00:00:00Z")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,51 @@ public class SbomParserTests
|
||||
result.Purls.Should().Contain("pkg:npm/axios@1.6.0");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ParseAsync_CycloneDX_ExtractsExternalReferenceCpesAndUnresolved()
|
||||
{
|
||||
var content = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"metadata": {
|
||||
"component": {
|
||||
"name": "root",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:npm/root@1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"name": "with-cpe",
|
||||
"version": "1.2.3",
|
||||
"purl": "pkg:npm/with-cpe@1.2.3",
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "cpe",
|
||||
"url": "cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "no-purl",
|
||||
"version": "2.0.0"
|
||||
}
|
||||
],
|
||||
"dependencies": [],
|
||||
"compositions": []
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||
|
||||
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
||||
|
||||
result.Cpes.Should().Contain("cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*");
|
||||
result.UnresolvedComponents.Should().ContainSingle(c => c.Name == "no-purl");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SPDX Tests
|
||||
@@ -318,6 +363,102 @@ public class SbomParserTests
|
||||
result.Cpes.Should().Contain("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ParseAsync_SPDX_TracksUnresolvedPackages()
|
||||
{
|
||||
var spdxContent = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"packages": [
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package",
|
||||
"name": "nopurl",
|
||||
"versionInfo": "1.0.0",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(spdxContent));
|
||||
|
||||
var result = await _parser.ParseAsync(stream, SbomFormat.SPDX);
|
||||
|
||||
result.Purls.Should().BeEmpty();
|
||||
result.UnresolvedComponents.Should().ContainSingle(c => c.Name == "nopurl");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ParseAsync_SPDX3_ExtractsPurlsAndCpes()
|
||||
{
|
||||
var content = """
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "SpdxDocument",
|
||||
"spdxId": "urn:doc"
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "spdx:pkg:1",
|
||||
"name": "pkg1",
|
||||
"packageUrl": "pkg:npm/pkg1@1.0.0",
|
||||
"packageVersion": "1.0.0",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"externalIdentifierType": "cpe23Type",
|
||||
"identifier": "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "spdx:pkg:2",
|
||||
"name": "pkg2",
|
||||
"softwareVersion": "2.0.0",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"externalIdentifierType": "purl",
|
||||
"identifier": "pkg:maven/org.example/app@2.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "spdx:pkg:3",
|
||||
"name": "pkg3"
|
||||
},
|
||||
{
|
||||
"@type": "Profile",
|
||||
"spdxId": "spdx:profile:1"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||
|
||||
var result = await _parser.ParseAsync(stream, SbomFormat.SPDX);
|
||||
|
||||
result.PrimaryName.Should().Be("pkg1");
|
||||
result.PrimaryVersion.Should().Be("1.0.0");
|
||||
result.Purls.Should().Contain(new[]
|
||||
{
|
||||
"pkg:npm/pkg1@1.0.0",
|
||||
"pkg:maven/org.example/app@2.0.0"
|
||||
});
|
||||
result.Cpes.Should().Contain("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*");
|
||||
result.UnresolvedComponents.Should().ContainSingle(c => c.Name == "pkg3");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Format Detection Tests
|
||||
@@ -375,7 +516,27 @@ public class SbomParserTests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DetectFormatAsync_UnknownFormat_ReturnsNotDetected()
|
||||
public async Task DetectFormatAsync_SPDX3_DetectsFormat()
|
||||
{
|
||||
var content = """
|
||||
{
|
||||
"@context": ["https://spdx.org/rdf/3.0.1/spdx-context.jsonld"],
|
||||
"@graph": []
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||
|
||||
var result = await _parser.DetectFormatAsync(stream);
|
||||
|
||||
result.IsDetected.Should().BeTrue();
|
||||
result.Format.Should().Be(SbomFormat.SPDX);
|
||||
result.SpecVersion.Should().Be("3.0");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DetectFormatAsync_UnknownFormat_ReturnsNotDetected()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SpdxLicenseExpressionValidatorTests.cs
|
||||
// Sprint: SPRINT_20260119_015_Concelier_sbom_full_extraction
|
||||
// Task: TASK-015-007c - SPDX license expression validation tests
|
||||
// -----------------------------------------------------------------------------
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.SbomIntegration.Licensing;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class SpdxLicenseExpressionValidatorTests
|
||||
{
|
||||
private readonly SpdxLicenseExpressionValidator _validator = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_KnownExpression_IsValid()
|
||||
{
|
||||
var result = _validator.ValidateString("MIT AND Apache-2.0");
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
result.ReferencedLicenses.Should().Contain(new[] { "MIT", "Apache-2.0" });
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_UnknownLicense_IsInvalid()
|
||||
{
|
||||
var result = _validator.ValidateString("NoSuchLicense");
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.UnknownLicenses.Should().ContainSingle("NoSuchLicense");
|
||||
result.Errors.Should().Contain(error => error.Contains("Unknown SPDX license identifier"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_LicenseRef_IsWarningOnly()
|
||||
{
|
||||
var result = _validator.ValidateString("LicenseRef-Internal");
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.UnknownLicenses.Should().ContainSingle("LicenseRef-Internal");
|
||||
result.Warnings.Should().Contain(warning => warning.Contains("LicenseRef identifier"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_DeprecatedLicense_IsWarningOnly()
|
||||
{
|
||||
var result = _validator.ValidateString("AGPL-1.0");
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.DeprecatedLicenses.Should().ContainSingle("AGPL-1.0");
|
||||
result.Warnings.Should().Contain(warning => warning.Contains("Deprecated SPDX license identifier"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_KnownException_IsValid()
|
||||
{
|
||||
var result = _validator.ValidateString("MIT WITH Classpath-exception-2.0");
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
result.ReferencedExceptions.Should().ContainSingle("Classpath-exception-2.0");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_UnknownException_IsInvalid()
|
||||
{
|
||||
var result = _validator.ValidateString("MIT WITH Missing-exception");
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(error => error.Contains("Unknown SPDX license exception"));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -23,4 +27,4 @@
|
||||
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Additional source of truth: `docs/implplan/SPRINT_20260119_015_Concelier_sbom_full_extraction.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0238-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0238-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0238-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| TASK-015-012 | DONE | Added ParsedSbomParser branch coverage across CycloneDX/SPDX helpers (services, dataflows, licenses, crypto, VEX, AI profiles, references) plus round-trip JSON equivalence checks; line-rate 0.9586 and tests pass. |
|
||||
| TASK-020-011 | DONE | Added unit tests for VEX consumption, merge, and reporter behaviors. |
|
||||
| TASK-020-012 | DONE | Added integration test for CycloneDX embedded VEX parsing. |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexConflictResolverTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Resolve_UsesHighestTrust()
|
||||
{
|
||||
var resolver = new VexConflictResolver();
|
||||
var statements = new[]
|
||||
{
|
||||
new VexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-2000",
|
||||
Status = VexStatus.Affected,
|
||||
Source = VexSource.External,
|
||||
TrustLevel = VexTrustLevel.Unverified,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-20T00:00:00Z")
|
||||
},
|
||||
new VexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-2000",
|
||||
Status = VexStatus.Fixed,
|
||||
Source = VexSource.External,
|
||||
TrustLevel = VexTrustLevel.Verified,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-19T00:00:00Z")
|
||||
}
|
||||
};
|
||||
|
||||
var resolution = resolver.Resolve(
|
||||
"CVE-2026-2000",
|
||||
statements,
|
||||
VexConflictResolutionStrategy.HighestTrust);
|
||||
|
||||
Assert.NotNull(resolution.Selected);
|
||||
Assert.Equal(VexTrustLevel.Verified, resolution.Selected!.TrustLevel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexConsumerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ConsumeAsync_ReturnsNotAffectedStatement()
|
||||
{
|
||||
var vulnerability = CreateVulnerability(
|
||||
VexState.NotAffected,
|
||||
VexJustification.ComponentNotPresent,
|
||||
DateTimeOffset.Parse("2026-01-20T00:00:00Z"));
|
||||
|
||||
var consumer = CreateConsumer();
|
||||
var result = await consumer.ConsumeAsync(
|
||||
new[] { vulnerability },
|
||||
VexConsumptionPolicyDefaults.Default,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Statements);
|
||||
Assert.Equal(VexStatus.NotAffected, result.Statements[0].Status);
|
||||
Assert.Equal(VexTrustLevel.Trusted, result.Statements[0].TrustLevel);
|
||||
Assert.Empty(result.Warnings);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ConsumeAsync_MissingJustification_FiltersStatement()
|
||||
{
|
||||
var vulnerability = CreateVulnerability(
|
||||
VexState.NotAffected,
|
||||
null,
|
||||
DateTimeOffset.Parse("2026-01-20T00:00:00Z"));
|
||||
|
||||
var consumer = CreateConsumer();
|
||||
var result = await consumer.ConsumeAsync(
|
||||
new[] { vulnerability },
|
||||
VexConsumptionPolicyDefaults.Default,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Empty(result.Statements);
|
||||
Assert.Contains(result.Warnings, warning => warning.Code == "vex.justification.missing");
|
||||
}
|
||||
|
||||
private static ParsedVulnerability CreateVulnerability(
|
||||
VexState state,
|
||||
VexJustification? justification,
|
||||
DateTimeOffset timestamp)
|
||||
{
|
||||
return new ParsedVulnerability
|
||||
{
|
||||
Id = "CVE-2026-0001",
|
||||
Analysis = new ParsedVulnAnalysis
|
||||
{
|
||||
State = state,
|
||||
Justification = justification,
|
||||
Response = [],
|
||||
Detail = "reviewed",
|
||||
FirstIssued = timestamp,
|
||||
LastUpdated = timestamp
|
||||
},
|
||||
Affects = [],
|
||||
Ratings = []
|
||||
};
|
||||
}
|
||||
|
||||
private static VexConsumer CreateConsumer()
|
||||
{
|
||||
var evaluator = new VexTrustEvaluator(new StubTimeProvider());
|
||||
var resolver = new VexConflictResolver();
|
||||
var merger = new VexMerger(resolver);
|
||||
var extractors = new IVexStatementExtractor[]
|
||||
{
|
||||
new CycloneDxVexExtractor(),
|
||||
new SpdxVexExtractor()
|
||||
};
|
||||
|
||||
return new VexConsumer(evaluator, merger, extractors);
|
||||
}
|
||||
|
||||
private sealed class StubTimeProvider : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow()
|
||||
=> DateTimeOffset.Parse("2026-01-20T01:00:00Z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexConsumptionReporterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToJson_IncludesStatements()
|
||||
{
|
||||
var reporter = new VexConsumptionReporter();
|
||||
var report = new VexConsumptionReport
|
||||
{
|
||||
OverallTrustLevel = VexTrustLevel.Trusted,
|
||||
Statements =
|
||||
[
|
||||
new ConsumedVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-4000",
|
||||
Status = VexStatus.Affected,
|
||||
Source = VexSource.SbomEmbedded,
|
||||
TrustLevel = VexTrustLevel.Trusted
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var json = reporter.ToJson(report);
|
||||
|
||||
Assert.Contains("CVE-2026-4000", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToSarif_EmitsResults()
|
||||
{
|
||||
var reporter = new VexConsumptionReporter();
|
||||
var report = new VexConsumptionReport
|
||||
{
|
||||
Statements =
|
||||
[
|
||||
new ConsumedVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-4001",
|
||||
Status = VexStatus.Affected,
|
||||
Source = VexSource.External,
|
||||
TrustLevel = VexTrustLevel.Unverified
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var sarif = reporter.ToSarif(report);
|
||||
|
||||
Assert.Contains("vex-affected", sarif);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexExtractorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CycloneDxExtractor_MapsBomRefToPurl()
|
||||
{
|
||||
var sbom = CreateSbom("CycloneDX");
|
||||
var extractor = new CycloneDxVexExtractor();
|
||||
|
||||
var statements = extractor.Extract(sbom);
|
||||
|
||||
Assert.Single(statements);
|
||||
Assert.Contains("pkg:npm/example@1.0.0", statements[0].AffectedComponents);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SpdxExtractor_HandlesSpdxFormat()
|
||||
{
|
||||
var sbom = CreateSbom("SPDX");
|
||||
var extractor = new SpdxVexExtractor();
|
||||
|
||||
Assert.True(extractor.CanHandle(sbom));
|
||||
var statements = extractor.Extract(sbom);
|
||||
Assert.Single(statements);
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateSbom(string format)
|
||||
{
|
||||
var component = new ParsedComponent
|
||||
{
|
||||
BomRef = "comp-1",
|
||||
Name = "example",
|
||||
Purl = "pkg:npm/example@1.0.0"
|
||||
};
|
||||
|
||||
var vulnerability = new ParsedVulnerability
|
||||
{
|
||||
Id = "CVE-2026-1000",
|
||||
Analysis = new ParsedVulnAnalysis
|
||||
{
|
||||
State = VexState.NotAffected,
|
||||
Justification = VexJustification.ComponentNotPresent,
|
||||
Response = ImmutableArray<string>.Empty,
|
||||
Detail = "not present",
|
||||
FirstIssued = DateTimeOffset.Parse("2026-01-20T00:00:00Z"),
|
||||
LastUpdated = DateTimeOffset.Parse("2026-01-20T00:00:00Z")
|
||||
},
|
||||
Affects =
|
||||
[
|
||||
new ParsedVulnAffects { Ref = "comp-1" }
|
||||
]
|
||||
};
|
||||
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = format,
|
||||
SpecVersion = "1.7",
|
||||
SerialNumber = "urn:uuid:example",
|
||||
Components = [component],
|
||||
Vulnerabilities = [vulnerability],
|
||||
Metadata = new ParsedSbomMetadata()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Parsing;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ConsumeFromSbomAsync_ParsesEmbeddedCycloneDxVex()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000001",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-20T00:00:00Z",
|
||||
"component": {
|
||||
"bom-ref": "comp-1",
|
||||
"type": "library",
|
||||
"name": "example",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:npm/example@1.0.0"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"vendor": "test",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"bom-ref": "comp-1",
|
||||
"type": "library",
|
||||
"name": "example",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:npm/example@1.0.0"
|
||||
}
|
||||
],
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "CVE-2026-9999",
|
||||
"analysis": {
|
||||
"state": "not_affected",
|
||||
"justification": "component_not_present",
|
||||
"response": ["will_not_fix"],
|
||||
"detail": "component absent",
|
||||
"lastUpdated": "2026-01-20T00:00:00Z"
|
||||
},
|
||||
"affects": [
|
||||
{ "ref": "comp-1" }
|
||||
],
|
||||
"ratings": [
|
||||
{
|
||||
"method": "CVSSv3",
|
||||
"score": 7.5,
|
||||
"severity": "high"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
var parser = new ParsedSbomParser(NullLogger<ParsedSbomParser>.Instance);
|
||||
var sbom = await parser.ParseAsync(stream, SbomFormat.CycloneDX, CancellationToken.None);
|
||||
|
||||
var consumer = CreateConsumer();
|
||||
var result = await consumer.ConsumeFromSbomAsync(sbom, VexConsumptionPolicyDefaults.Default, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Statements);
|
||||
Assert.Equal("CVE-2026-9999", result.Statements[0].VulnerabilityId);
|
||||
Assert.Equal(VexStatus.NotAffected, result.Statements[0].Status);
|
||||
Assert.Contains("pkg:npm/example@1.0.0", result.Statements[0].AffectedComponents);
|
||||
}
|
||||
|
||||
private static VexConsumer CreateConsumer()
|
||||
{
|
||||
var evaluator = new VexTrustEvaluator(new StubTimeProvider());
|
||||
var resolver = new VexConflictResolver();
|
||||
var merger = new VexMerger(resolver);
|
||||
var extractors = new IVexStatementExtractor[]
|
||||
{
|
||||
new CycloneDxVexExtractor(),
|
||||
new SpdxVexExtractor()
|
||||
};
|
||||
|
||||
return new VexConsumer(evaluator, merger, extractors);
|
||||
}
|
||||
|
||||
private sealed class StubTimeProvider : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow()
|
||||
=> DateTimeOffset.Parse("2026-01-20T01:00:00Z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexMergerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_ExternalPriorityPrefersExternalStatements()
|
||||
{
|
||||
var resolver = new VexConflictResolver();
|
||||
var merger = new VexMerger(resolver);
|
||||
|
||||
var embedded = new[]
|
||||
{
|
||||
new VexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-3000",
|
||||
Status = VexStatus.Affected,
|
||||
Source = VexSource.SbomEmbedded,
|
||||
TrustLevel = VexTrustLevel.Trusted,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-20T00:00:00Z")
|
||||
}
|
||||
};
|
||||
|
||||
var external = new[]
|
||||
{
|
||||
new VexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-3000",
|
||||
Status = VexStatus.Fixed,
|
||||
Source = VexSource.External,
|
||||
TrustLevel = VexTrustLevel.Unverified,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-21T00:00:00Z")
|
||||
}
|
||||
};
|
||||
|
||||
var mergePolicy = new VexMergePolicy
|
||||
{
|
||||
Mode = VexMergeMode.ExternalPriority
|
||||
};
|
||||
|
||||
var merged = merger.Merge(
|
||||
embedded,
|
||||
external,
|
||||
mergePolicy,
|
||||
VexConflictResolutionStrategy.MostRecent);
|
||||
|
||||
Assert.Single(merged.Statements);
|
||||
Assert.Equal(VexSource.External, merged.Statements[0].Source);
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,7 @@ public sealed class FederationEndpointTests
|
||||
};
|
||||
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<IOptions<ConcelierOptions>>(Options.Create(options));
|
||||
services.AddSingleton<IOptions<ConcelierOptions>>(Microsoft.Extensions.Options.Options.Create(options));
|
||||
services.AddSingleton<TimeProvider>(new FixedTimeProvider(_fixedNow));
|
||||
services.AddSingleton<IBundleExportService>(new FakeBundleExportService());
|
||||
services.AddSingleton<IBundleImportService>(new FakeBundleImportService(_fixedNow));
|
||||
@@ -309,8 +309,6 @@ public sealed class FederationEndpointTests
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public override long GetTimestamp() => 0;
|
||||
|
||||
public override TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp) => TimeSpan.Zero;
|
||||
}
|
||||
|
||||
private sealed class FakeBundleExportService : IBundleExportService
|
||||
|
||||
Reference in New Issue
Block a user