tests fixes and sprints work
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema": "stellaops/bom-index@1",
|
||||
"image": {
|
||||
"repository": "registry.stella-ops.org/samples/java-multi-license",
|
||||
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"tag": "2025.10.0"
|
||||
},
|
||||
"generatedAt": "2025-10-19T00:00:00Z",
|
||||
"generator": "stellaops/scanner@10.0.0-preview1",
|
||||
"components": [
|
||||
{
|
||||
"purl": "pkg:maven/org.example/dual-license-lib@1.2.0",
|
||||
"layerDigest": "sha256:3333333333333333333333333333333333333333333333333333333333333333",
|
||||
"usage": ["inventory"],
|
||||
"licenses": ["Apache-2.0", "GPL-2.0-only"],
|
||||
"evidence": {
|
||||
"kind": "pom",
|
||||
"path": "lib/dual-license-lib-1.2.0.jar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"purl": "pkg:maven/org.apache.commons/commons-lang3@3.13.0",
|
||||
"layerDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"usage": ["inventory", "runtime"],
|
||||
"licenses": ["Apache-2.0"],
|
||||
"evidence": {
|
||||
"kind": "pom",
|
||||
"path": "lib/commons-lang3-3.13.0.jar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"purl": "pkg:maven/org.eclipse.jetty/jetty-server@11.0.18",
|
||||
"layerDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
"usage": ["inventory", "runtime"],
|
||||
"licenses": ["EPL-2.0"],
|
||||
"evidence": {
|
||||
"kind": "pom",
|
||||
"path": "lib/jetty-server-11.0.18.jar"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,11 +6,13 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.Gates.Opa;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
@@ -179,12 +181,19 @@ public sealed class OpaGateAdapterTests
|
||||
|
||||
public Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(string policyPath, object input, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// For the mock, we just return what we have
|
||||
// Simulate JSON serialization/deserialization like a real OPA client
|
||||
TResult? typedResult = default;
|
||||
if (_result.Result is not null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_result.Result, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
typedResult = JsonSerializer.Deserialize<TResult>(json, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
}
|
||||
|
||||
return Task.FromResult(new OpaTypedResult<TResult>
|
||||
{
|
||||
Success = _result.Success,
|
||||
DecisionId = _result.DecisionId,
|
||||
Result = _result.Result is TResult typed ? typed : default,
|
||||
Result = typedResult,
|
||||
Error = _result.Error
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Policy.Gates;
|
||||
using Xunit;
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed class UnknownsGateCheckerIntegrationTests
|
||||
ForceReviewOnSlaBreach = true,
|
||||
CacheTtlSeconds = 30
|
||||
};
|
||||
_logger = Substitute.For<ILogger<UnknownsGateChecker>>();
|
||||
_logger = NullLogger<UnknownsGateChecker>.Instance;
|
||||
}
|
||||
|
||||
#region Gate Decision Tests
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Licensing;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Integration.Licensing;
|
||||
|
||||
public sealed class LicenseComplianceRealSbomTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public async Task EvaluateAsync_NpmMonorepo_WarnsWithAttribution()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var components = LoadComponentsFromBomIndex("samples/scanner/images/npm-monorepo/bom-index.json");
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, LicensePolicyDefaults.Default);
|
||||
|
||||
Assert.Equal(LicenseComplianceStatus.Warn, report.OverallStatus);
|
||||
Assert.NotEmpty(report.AttributionRequirements);
|
||||
|
||||
var notice = new AttributionGenerator().Generate(report, AttributionFormat.Markdown);
|
||||
Assert.Contains("Third-Party Attributions", notice);
|
||||
Assert.Contains("pkg:npm/%40stella/web@1.5.3", notice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public async Task EvaluateAsync_AlpineBusybox_FailsOnCopyleft()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var components = LoadComponentsFromBomIndex("samples/scanner/images/alpine-busybox/bom-index.json");
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, LicensePolicyDefaults.Default);
|
||||
|
||||
Assert.Equal(LicenseComplianceStatus.Fail, report.OverallStatus);
|
||||
Assert.Contains(report.Findings, finding =>
|
||||
finding.Type == LicenseFindingType.ProhibitedLicense
|
||||
&& string.Equals(finding.LicenseId, "GPL-2.0-only", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public async Task EvaluateAsync_PythonVenv_FailsConditionalMpl()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var components = LoadComponentsFromBomIndex("samples/scanner/images/python-venv/bom-index.json");
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = LicensePolicyDefaults.Default.AllowedLicenses.Add("MPL-2.0")
|
||||
};
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, policy);
|
||||
|
||||
Assert.Equal(LicenseComplianceStatus.Fail, report.OverallStatus);
|
||||
Assert.Contains(report.Findings, finding =>
|
||||
finding.Type == LicenseFindingType.ConditionalLicenseViolation
|
||||
&& string.Equals(finding.LicenseId, "MPL-2.0", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public async Task EvaluateAsync_JavaMultiLicense_WarnsWithAttribution()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var components = LoadComponentsFromBomIndex(
|
||||
"src/Policy/__Tests/StellaOps.Policy.Tests/Fixtures/Licensing/java-multi-license/bom-index.json");
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = LicensePolicyDefaults.Default.AllowedLicenses
|
||||
.AddRange(new[] { "EPL-2.0", "GPL-2.0-only" }),
|
||||
Categories = LicensePolicyDefaults.Default.Categories with { AllowCopyleft = true }
|
||||
};
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, policy);
|
||||
var notice = new AttributionGenerator().Generate(report, AttributionFormat.Markdown);
|
||||
|
||||
Assert.Equal(LicenseComplianceStatus.Warn, report.OverallStatus);
|
||||
Assert.Contains(report.Inventory.Licenses, usage =>
|
||||
usage.Expression.Contains(" OR ", StringComparison.Ordinal));
|
||||
Assert.Contains(report.AttributionRequirements, requirement =>
|
||||
requirement.ComponentPurl.StartsWith("pkg:maven/", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("pkg:maven/org.example/dual-license-lib@1.2.0", notice);
|
||||
}
|
||||
|
||||
private static ImmutableArray<LicenseComponent> LoadComponentsFromBomIndex(string relativePath)
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var segments = new List<string> { repoRoot };
|
||||
segments.AddRange(relativePath.Split('/'));
|
||||
var path = Path.Combine(segments.ToArray());
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
|
||||
if (!document.RootElement.TryGetProperty("components", out var componentsElement)
|
||||
|| componentsElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid bom-index format: {path}");
|
||||
}
|
||||
|
||||
var components = new List<LicenseComponent>();
|
||||
foreach (var component in componentsElement.EnumerateArray())
|
||||
{
|
||||
var purl = component.GetProperty("purl").GetString() ?? "unknown";
|
||||
var licenses = ParseLicenses(component);
|
||||
|
||||
components.Add(new LicenseComponent
|
||||
{
|
||||
Name = purl,
|
||||
Purl = purl,
|
||||
Licenses = licenses
|
||||
});
|
||||
}
|
||||
|
||||
return components.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ParseLicenses(JsonElement component)
|
||||
{
|
||||
if (!component.TryGetProperty("licenses", out var licensesElement)
|
||||
|| licensesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var licenses = new List<string>();
|
||||
foreach (var license in licensesElement.EnumerateArray())
|
||||
{
|
||||
var value = license.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
licenses.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return licenses.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
|
||||
{
|
||||
var directory = new DirectoryInfo(start);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(directory.FullName, "samples", "scanner", "images")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException(
|
||||
"Repo root not found for license compliance integration tests.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NtiaComplianceIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260119_023_Compliance_ntia_supplier
|
||||
// Task: TASK-023-012 - Integration tests with real SBOMs
|
||||
// Description: Integration tests for NTIA compliance using realistic SBOM fixtures
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Policy.NtiaCompliance;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Integration.NtiaCompliance;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for NTIA compliance validation using realistic SBOM scenarios.
|
||||
/// Tests measure typical compliance rates, common missing elements, and supplier data quality.
|
||||
/// </summary>
|
||||
public sealed class NtiaComplianceIntegrationTests
|
||||
{
|
||||
#region Test Fixture: Well-Formed CycloneDX SBOM (Syft-style)
|
||||
|
||||
/// <summary>
|
||||
/// Test with a well-formed SBOM similar to Syft output.
|
||||
/// Expectation: High compliance score (>95%) with all NTIA elements present.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Validate_SyftStyleSbom_AchievesHighCompliance()
|
||||
{
|
||||
var sbom = CreateSyftStyleSbom();
|
||||
var policy = new NtiaCompliancePolicy();
|
||||
var validator = new NtiaBaselineValidator();
|
||||
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
|
||||
Assert.Equal(NtiaComplianceStatus.Pass, report.OverallStatus);
|
||||
Assert.True(report.ComplianceScore >= 95.0, $"Expected compliance >= 95%, got {report.ComplianceScore}%");
|
||||
Assert.All(report.ElementStatuses, status => Assert.True(status.Present, $"Element {status.Element} should be present"));
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateSyftStyleSbom()
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.6",
|
||||
SerialNumber = "urn:uuid:syft-test-sbom",
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:npm/express@4.18.2",
|
||||
Name = "express",
|
||||
Version = "4.18.2",
|
||||
Purl = "pkg:npm/express@4.18.2",
|
||||
Supplier = new ParsedOrganization { Name = "Express Authors", Url = "https://expressjs.com" }
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:npm/lodash@4.17.21",
|
||||
Name = "lodash",
|
||||
Version = "4.17.21",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Supplier = new ParsedOrganization { Name = "Lodash Team" }
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:npm/axios@1.6.0",
|
||||
Name = "axios",
|
||||
Version = "1.6.0",
|
||||
Purl = "pkg:npm/axios@1.6.0",
|
||||
Supplier = new ParsedOrganization { Name = "Axios Contributors" }
|
||||
}
|
||||
],
|
||||
Dependencies =
|
||||
[
|
||||
new ParsedDependency { SourceRef = "pkg:npm/express@4.18.2", DependsOn = ["pkg:npm/lodash@4.17.21"] },
|
||||
new ParsedDependency { SourceRef = "pkg:npm/lodash@4.17.21", DependsOn = ImmutableArray<string>.Empty },
|
||||
new ParsedDependency { SourceRef = "pkg:npm/axios@1.6.0", DependsOn = ImmutableArray<string>.Empty }
|
||||
],
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Authors = ["syft 1.0.0"],
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Fixture: SBOM with Missing Supplier Information
|
||||
|
||||
/// <summary>
|
||||
/// Test with SBOM missing supplier information on most components.
|
||||
/// This simulates vendor-provided SBOMs with incomplete supplier data.
|
||||
/// Expectation: Compliance warning/failure due to missing supplier names.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Validate_MissingSupplierSbom_IdentifiesSupplierGaps()
|
||||
{
|
||||
var sbom = CreateMissingSupplierSbom();
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
SupplierValidation = new SupplierValidationPolicy
|
||||
{
|
||||
MinimumCoveragePercent = 80.0
|
||||
}
|
||||
};
|
||||
var validator = new NtiaBaselineValidator();
|
||||
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
|
||||
// Should identify supplier gaps
|
||||
Assert.NotNull(report.SupplierReport);
|
||||
Assert.True(report.SupplierReport.ComponentsMissingSupplier > 0);
|
||||
Assert.True(report.SupplierReport.CoveragePercent < 50.0,
|
||||
$"Expected supplier coverage < 50%, got {report.SupplierReport.CoveragePercent}%");
|
||||
|
||||
// Should have findings about missing suppliers
|
||||
Assert.Contains(report.Findings, f => f.Type == NtiaFindingType.MissingSupplier ||
|
||||
f.Element == NtiaElement.SupplierName);
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateMissingSupplierSbom()
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.5",
|
||||
SerialNumber = "urn:uuid:missing-supplier-test",
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:maven/org.apache.commons/commons-lang3@3.13.0",
|
||||
Name = "commons-lang3",
|
||||
Version = "3.13.0",
|
||||
Purl = "pkg:maven/org.apache.commons/commons-lang3@3.13.0"
|
||||
// No supplier
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:maven/com.google.guava/guava@32.1.2-jre",
|
||||
Name = "guava",
|
||||
Version = "32.1.2-jre",
|
||||
Purl = "pkg:maven/com.google.guava/guava@32.1.2-jre"
|
||||
// No supplier
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:maven/org.slf4j/slf4j-api@2.0.9",
|
||||
Name = "slf4j-api",
|
||||
Version = "2.0.9",
|
||||
Purl = "pkg:maven/org.slf4j/slf4j-api@2.0.9"
|
||||
// No supplier
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.15.2",
|
||||
Name = "jackson-core",
|
||||
Version = "2.15.2",
|
||||
Purl = "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.15.2",
|
||||
Supplier = new ParsedOrganization { Name = "FasterXML" } // Only one has supplier
|
||||
}
|
||||
],
|
||||
Dependencies =
|
||||
[
|
||||
new ParsedDependency { SourceRef = "pkg:maven/org.apache.commons/commons-lang3@3.13.0", DependsOn = [] },
|
||||
new ParsedDependency { SourceRef = "pkg:maven/com.google.guava/guava@32.1.2-jre", DependsOn = [] },
|
||||
new ParsedDependency { SourceRef = "pkg:maven/org.slf4j/slf4j-api@2.0.9", DependsOn = [] },
|
||||
new ParsedDependency { SourceRef = "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.15.2", DependsOn = [] }
|
||||
],
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Authors = ["vendor-tool"],
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Fixture: SBOM with Placeholder Suppliers
|
||||
|
||||
/// <summary>
|
||||
/// Test with SBOM containing placeholder supplier values.
|
||||
/// Expectation: Placeholders detected and flagged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Validate_PlaceholderSupplierSbom_DetectsPlaceholders()
|
||||
{
|
||||
var sbom = CreatePlaceholderSupplierSbom();
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
SupplierValidation = new SupplierValidationPolicy
|
||||
{
|
||||
RejectPlaceholders = true,
|
||||
PlaceholderPatterns = ["unknown", "n/a", "tbd", "unspecified"]
|
||||
}
|
||||
};
|
||||
var validator = new NtiaBaselineValidator();
|
||||
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
|
||||
// Should detect placeholder suppliers
|
||||
Assert.NotNull(report.SupplierReport);
|
||||
Assert.Contains(report.SupplierReport.Suppliers, s => s.PlaceholderDetected);
|
||||
Assert.Contains(report.Findings, f => f.Type == NtiaFindingType.PlaceholderSupplier);
|
||||
}
|
||||
|
||||
private static ParsedSbom CreatePlaceholderSupplierSbom()
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.6",
|
||||
SerialNumber = "urn:uuid:placeholder-test",
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:pypi/requests@2.31.0",
|
||||
Name = "requests",
|
||||
Version = "2.31.0",
|
||||
Purl = "pkg:pypi/requests@2.31.0",
|
||||
Supplier = new ParsedOrganization { Name = "unknown" } // Placeholder
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:pypi/flask@3.0.0",
|
||||
Name = "flask",
|
||||
Version = "3.0.0",
|
||||
Purl = "pkg:pypi/flask@3.0.0",
|
||||
Supplier = new ParsedOrganization { Name = "N/A" } // Placeholder
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:pypi/django@4.2.7",
|
||||
Name = "django",
|
||||
Version = "4.2.7",
|
||||
Purl = "pkg:pypi/django@4.2.7",
|
||||
Supplier = new ParsedOrganization { Name = "Django Software Foundation" } // Valid
|
||||
}
|
||||
],
|
||||
Dependencies =
|
||||
[
|
||||
new ParsedDependency { SourceRef = "pkg:pypi/requests@2.31.0", DependsOn = [] },
|
||||
new ParsedDependency { SourceRef = "pkg:pypi/flask@3.0.0", DependsOn = [] },
|
||||
new ParsedDependency { SourceRef = "pkg:pypi/django@4.2.7", DependsOn = [] }
|
||||
],
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Authors = ["pip-audit"],
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Fixture: SBOM Missing Unique Identifiers
|
||||
|
||||
/// <summary>
|
||||
/// Test with SBOM missing unique identifiers (PURL, CPE, SWID).
|
||||
/// Expectation: Compliance failure for OtherUniqueIdentifiers element.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Validate_MissingIdentifiersSbom_IdentifiesIdentifierGaps()
|
||||
{
|
||||
var sbom = CreateMissingIdentifiersSbom();
|
||||
var policy = new NtiaCompliancePolicy();
|
||||
var validator = new NtiaBaselineValidator();
|
||||
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
|
||||
// Should identify missing identifiers
|
||||
var identifierStatus = report.ElementStatuses
|
||||
.FirstOrDefault(s => s.Element == NtiaElement.OtherUniqueIdentifiers);
|
||||
Assert.NotNull(identifierStatus);
|
||||
Assert.True(identifierStatus.ComponentsMissing > 0,
|
||||
"Expected components missing unique identifiers");
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateMissingIdentifiersSbom()
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.5",
|
||||
SerialNumber = "urn:uuid:missing-identifiers-test",
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "internal-lib-1",
|
||||
Name = "internal-lib",
|
||||
Version = "1.0.0",
|
||||
// No PURL, CPE, SWID, or hashes
|
||||
Supplier = new ParsedOrganization { Name = "Internal" }
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "legacy-component",
|
||||
Name = "legacy-component",
|
||||
Version = "2.3.4",
|
||||
// No PURL, CPE, SWID, or hashes
|
||||
Supplier = new ParsedOrganization { Name = "Legacy Vendor" }
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:npm/good-component@1.0.0",
|
||||
Name = "good-component",
|
||||
Version = "1.0.0",
|
||||
Purl = "pkg:npm/good-component@1.0.0", // Has PURL
|
||||
Supplier = new ParsedOrganization { Name = "Good Vendor" }
|
||||
}
|
||||
],
|
||||
Dependencies =
|
||||
[
|
||||
new ParsedDependency { SourceRef = "internal-lib-1", DependsOn = [] },
|
||||
new ParsedDependency { SourceRef = "legacy-component", DependsOn = [] },
|
||||
new ParsedDependency { SourceRef = "pkg:npm/good-component@1.0.0", DependsOn = [] }
|
||||
],
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Authors = ["manual-entry"],
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Fixture: SBOM with Orphaned Components (No Dependencies)
|
||||
|
||||
/// <summary>
|
||||
/// Test with SBOM containing orphaned components with no dependency relationships.
|
||||
/// Expectation: Dependency completeness issues flagged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Validate_OrphanedComponentsSbom_IdentifiesDependencyGaps()
|
||||
{
|
||||
var sbom = CreateOrphanedComponentsSbom();
|
||||
var policy = new NtiaCompliancePolicy();
|
||||
var validator = new NtiaBaselineValidator();
|
||||
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
|
||||
// Should identify orphaned components
|
||||
Assert.NotNull(report.DependencyCompleteness);
|
||||
Assert.True(report.DependencyCompleteness.OrphanedComponents.Length > 0,
|
||||
"Expected orphaned components to be detected");
|
||||
Assert.Contains(report.Findings, f => f.Type == NtiaFindingType.MissingDependency);
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateOrphanedComponentsSbom()
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.6",
|
||||
SerialNumber = "urn:uuid:orphaned-test",
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:npm/root@1.0.0",
|
||||
Name = "root",
|
||||
Version = "1.0.0",
|
||||
Purl = "pkg:npm/root@1.0.0",
|
||||
Supplier = new ParsedOrganization { Name = "Root Author" }
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:npm/orphan-a@2.0.0",
|
||||
Name = "orphan-a",
|
||||
Version = "2.0.0",
|
||||
Purl = "pkg:npm/orphan-a@2.0.0",
|
||||
Supplier = new ParsedOrganization { Name = "Orphan Author" }
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:npm/orphan-b@3.0.0",
|
||||
Name = "orphan-b",
|
||||
Version = "3.0.0",
|
||||
Purl = "pkg:npm/orphan-b@3.0.0",
|
||||
Supplier = new ParsedOrganization { Name = "Another Author" }
|
||||
}
|
||||
],
|
||||
Dependencies =
|
||||
[
|
||||
// Only root has dependency info; orphan-a and orphan-b have none
|
||||
new ParsedDependency { SourceRef = "pkg:npm/root@1.0.0", DependsOn = [] }
|
||||
],
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Authors = ["incomplete-scanner"],
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Fixture: FDA Medical Device SBOM
|
||||
|
||||
/// <summary>
|
||||
/// Test with SBOM structured for FDA medical device compliance.
|
||||
/// Expectation: FDA framework requirements evaluated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Validate_FdaMedicalDeviceSbom_EvaluatesFdaCompliance()
|
||||
{
|
||||
var sbom = CreateFdaMedicalDeviceSbom();
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
Frameworks = [RegulatoryFramework.Ntia, RegulatoryFramework.Fda]
|
||||
};
|
||||
var validator = new NtiaBaselineValidator();
|
||||
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
|
||||
// Should evaluate FDA framework
|
||||
Assert.NotNull(report.Frameworks);
|
||||
Assert.Contains(report.Frameworks.Frameworks, f => f.Framework == RegulatoryFramework.Fda);
|
||||
|
||||
// FDA-compliant SBOM should pass
|
||||
var fdaEntry = report.Frameworks.Frameworks.First(f => f.Framework == RegulatoryFramework.Fda);
|
||||
Assert.True(fdaEntry.ComplianceScore >= 80.0,
|
||||
$"Expected FDA compliance >= 80%, got {fdaEntry.ComplianceScore}%");
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateFdaMedicalDeviceSbom()
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.6",
|
||||
SerialNumber = "urn:uuid:fda-medical-device-sbom",
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:generic/medical-firmware@2.1.0",
|
||||
Name = "medical-firmware",
|
||||
Version = "2.1.0",
|
||||
Purl = "pkg:generic/medical-firmware@2.1.0",
|
||||
Supplier = new ParsedOrganization
|
||||
{
|
||||
Name = "MedTech Inc",
|
||||
Url = "https://medtech.example.com"
|
||||
},
|
||||
Hashes =
|
||||
[
|
||||
new ParsedHash { Algorithm = "SHA-256", Value = "a1b2c3d4e5f6..." }
|
||||
]
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:generic/openssl@3.0.11",
|
||||
Name = "openssl",
|
||||
Version = "3.0.11",
|
||||
Purl = "pkg:generic/openssl@3.0.11",
|
||||
Supplier = new ParsedOrganization { Name = "OpenSSL Software Foundation" },
|
||||
Hashes =
|
||||
[
|
||||
new ParsedHash { Algorithm = "SHA-256", Value = "b2c3d4e5f6a7..." }
|
||||
]
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "pkg:generic/zlib@1.3",
|
||||
Name = "zlib",
|
||||
Version = "1.3",
|
||||
Purl = "pkg:generic/zlib@1.3",
|
||||
Supplier = new ParsedOrganization { Name = "zlib Authors" },
|
||||
Hashes =
|
||||
[
|
||||
new ParsedHash { Algorithm = "SHA-256", Value = "c3d4e5f6a7b8..." }
|
||||
]
|
||||
}
|
||||
],
|
||||
Dependencies =
|
||||
[
|
||||
new ParsedDependency
|
||||
{
|
||||
SourceRef = "pkg:generic/medical-firmware@2.1.0",
|
||||
DependsOn = ["pkg:generic/openssl@3.0.11", "pkg:generic/zlib@1.3"]
|
||||
},
|
||||
new ParsedDependency { SourceRef = "pkg:generic/openssl@3.0.11", DependsOn = ["pkg:generic/zlib@1.3"] },
|
||||
new ParsedDependency { SourceRef = "pkg:generic/zlib@1.3", DependsOn = [] }
|
||||
],
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Authors = ["MedTech Compliance Team"],
|
||||
Timestamp = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero),
|
||||
Supplier = "MedTech Inc"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Fixture: Large Enterprise SBOM
|
||||
|
||||
/// <summary>
|
||||
/// Test with large enterprise-scale SBOM (100+ components).
|
||||
/// Expectation: Validates supplier concentration and supply chain transparency metrics.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Validate_LargeEnterpriseSbom_CalculatesSupplyChainMetrics()
|
||||
{
|
||||
var sbom = CreateLargeEnterpriseSbom();
|
||||
var policy = new NtiaCompliancePolicy();
|
||||
var validator = new NtiaBaselineValidator();
|
||||
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
|
||||
// Should calculate supply chain metrics
|
||||
Assert.NotNull(report.SupplyChain);
|
||||
Assert.True(report.SupplyChain.TotalSuppliers > 0, "Expected multiple suppliers");
|
||||
Assert.True(report.SupplyChain.TotalComponents > 50, "Expected 50+ components");
|
||||
Assert.True(report.SupplyChain.ConcentrationIndex >= 0 && report.SupplyChain.ConcentrationIndex <= 1,
|
||||
"Concentration index should be between 0 and 1");
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateLargeEnterpriseSbom()
|
||||
{
|
||||
var components = new List<ParsedComponent>();
|
||||
var dependencies = new List<ParsedDependency>();
|
||||
var suppliers = new[] { "Apache Software Foundation", "Google", "Microsoft", "Red Hat", "Oracle", "IBM", "VMware" };
|
||||
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
var supplier = suppliers[i % suppliers.Length];
|
||||
var bomRef = $"pkg:maven/org.example/lib-{i}@{i}.0.0";
|
||||
components.Add(new ParsedComponent
|
||||
{
|
||||
BomRef = bomRef,
|
||||
Name = $"lib-{i}",
|
||||
Version = $"{i}.0.0",
|
||||
Purl = bomRef,
|
||||
Supplier = new ParsedOrganization { Name = supplier }
|
||||
});
|
||||
dependencies.Add(new ParsedDependency
|
||||
{
|
||||
SourceRef = bomRef,
|
||||
DependsOn = i > 0 ? [$"pkg:maven/org.example/lib-{i - 1}@{i - 1}.0.0"] : []
|
||||
});
|
||||
}
|
||||
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.6",
|
||||
SerialNumber = "urn:uuid:enterprise-sbom",
|
||||
Components = components.ToImmutableArray(),
|
||||
Dependencies = dependencies.ToImmutableArray(),
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Authors = ["enterprise-scanner"],
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Baseline Metrics Tests
|
||||
|
||||
/// <summary>
|
||||
/// Establish baseline metrics for typical SBOM compliance rates.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("syft-style", 95.0)]
|
||||
[InlineData("missing-supplier", 70.0)]
|
||||
[InlineData("placeholder-supplier", 80.0)]
|
||||
[InlineData("missing-identifiers", 80.0)]
|
||||
[InlineData("fda-compliant", 95.0)]
|
||||
public async Task Baseline_ComplianceScores_MeetExpectations(string sbomType, double minExpectedScore)
|
||||
{
|
||||
var sbom = sbomType switch
|
||||
{
|
||||
"syft-style" => CreateSyftStyleSbom(),
|
||||
"missing-supplier" => CreateMissingSupplierSbom(),
|
||||
"placeholder-supplier" => CreatePlaceholderSupplierSbom(),
|
||||
"missing-identifiers" => CreateMissingIdentifiersSbom(),
|
||||
"fda-compliant" => CreateFdaMedicalDeviceSbom(),
|
||||
_ => throw new ArgumentException($"Unknown SBOM type: {sbomType}")
|
||||
};
|
||||
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
Thresholds = new NtiaComplianceThresholds
|
||||
{
|
||||
MinimumCompliancePercent = 0, // Don't fail, just measure
|
||||
AllowPartialCompliance = true
|
||||
}
|
||||
};
|
||||
var validator = new NtiaBaselineValidator();
|
||||
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
|
||||
// Document actual compliance score for baseline establishment
|
||||
Assert.True(report.ComplianceScore >= minExpectedScore * 0.9,
|
||||
$"SBOM type '{sbomType}' compliance {report.ComplianceScore}% below expected minimum {minExpectedScore}%");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Common Gaps Identification
|
||||
|
||||
/// <summary>
|
||||
/// Identify the most common NTIA compliance gaps across different SBOM types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CommonGaps_AcrossSbomTypes_SupplierIsMostCommon()
|
||||
{
|
||||
var sbomTypes = new[]
|
||||
{
|
||||
CreateMissingSupplierSbom(),
|
||||
CreatePlaceholderSupplierSbom(),
|
||||
CreateMissingIdentifiersSbom(),
|
||||
CreateOrphanedComponentsSbom()
|
||||
};
|
||||
|
||||
var policy = new NtiaCompliancePolicy();
|
||||
var validator = new NtiaBaselineValidator();
|
||||
|
||||
var gapCounts = new Dictionary<NtiaElement, int>();
|
||||
|
||||
foreach (var sbom in sbomTypes)
|
||||
{
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
foreach (var status in report.ElementStatuses.Where(s => s.ComponentsMissing > 0))
|
||||
{
|
||||
gapCounts.TryGetValue(status.Element, out var count);
|
||||
gapCounts[status.Element] = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Document common gaps for baseline establishment
|
||||
Assert.True(gapCounts.Count > 0, "Expected to find compliance gaps");
|
||||
|
||||
// Supplier is typically a common gap in real-world SBOMs
|
||||
if (gapCounts.TryGetValue(NtiaElement.SupplierName, out var supplierGaps))
|
||||
{
|
||||
Assert.True(supplierGaps >= 1, "SupplierName should be a gap in at least one SBOM type");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -10,11 +10,12 @@
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="FsCheck" />
|
||||
<PackageReference Include="FsCheck.Xunit.v3" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Determinization/StellaOps.Policy.Determinization.csproj" />
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using StellaOps.Policy.Licensing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.Licensing;
|
||||
|
||||
public sealed class LicenseCompatibilityCheckerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Check_ThrowsOnNullInputs()
|
||||
{
|
||||
var checker = new LicenseCompatibilityChecker();
|
||||
var license = new LicenseDescriptor { Id = "MIT", Category = LicenseCategory.Permissive };
|
||||
var context = new ProjectContext();
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => checker.Check(null!, license, context));
|
||||
Assert.Throws<ArgumentNullException>(() => checker.Check(license, null!, context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_DetectsApacheGpl2Conflict()
|
||||
{
|
||||
var checker = new LicenseCompatibilityChecker();
|
||||
var apache = new LicenseDescriptor { Id = "Apache-2.0", Category = LicenseCategory.Permissive };
|
||||
var gpl2 = new LicenseDescriptor { Id = "GPL-2.0-only", Category = LicenseCategory.StrongCopyleft };
|
||||
|
||||
var result = checker.Check(apache, gpl2, new ProjectContext());
|
||||
|
||||
Assert.False(result.IsCompatible);
|
||||
Assert.Contains("Apache-2.0", result.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_DetectsProprietaryStrongCopyleftConflict()
|
||||
{
|
||||
var checker = new LicenseCompatibilityChecker();
|
||||
var proprietary = new LicenseDescriptor { Id = "LicenseRef-Proprietary", Category = LicenseCategory.Proprietary };
|
||||
var gpl = new LicenseDescriptor { Id = "GPL-3.0-only", Category = LicenseCategory.StrongCopyleft };
|
||||
|
||||
var result = checker.Check(proprietary, gpl, new ProjectContext());
|
||||
|
||||
Assert.False(result.IsCompatible);
|
||||
Assert.NotNull(result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_AllowsStrongCopyleftPairInCommercialContextWithNotice()
|
||||
{
|
||||
var checker = new LicenseCompatibilityChecker();
|
||||
var gpl = new LicenseDescriptor { Id = "GPL-3.0-only", Category = LicenseCategory.StrongCopyleft };
|
||||
var agpl = new LicenseDescriptor { Id = "AGPL-3.0-only", Category = LicenseCategory.StrongCopyleft };
|
||||
var context = new ProjectContext { DistributionModel = DistributionModel.Commercial };
|
||||
|
||||
var result = checker.Check(gpl, agpl, context);
|
||||
|
||||
Assert.True(result.IsCompatible);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Reason));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_AllowsNonConflictingPair()
|
||||
{
|
||||
var checker = new LicenseCompatibilityChecker();
|
||||
var mit = new LicenseDescriptor { Id = "MIT", Category = LicenseCategory.Permissive };
|
||||
var apache = new LicenseDescriptor { Id = "Apache-2.0", Category = LicenseCategory.Permissive };
|
||||
|
||||
var result = checker.Check(mit, apache, new ProjectContext());
|
||||
|
||||
Assert.True(result.IsCompatible);
|
||||
Assert.Null(result.Reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Licensing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.Licensing;
|
||||
|
||||
public sealed class LicenseComplianceEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingLicenseMarksWarning()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var components = new[]
|
||||
{
|
||||
new LicenseComponent
|
||||
{
|
||||
Name = "example",
|
||||
Version = "1.0.0",
|
||||
Purl = "pkg:npm/example@1.0.0"
|
||||
}
|
||||
};
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, LicensePolicyDefaults.Default);
|
||||
|
||||
Assert.Equal(LicenseComplianceStatus.Warn, report.OverallStatus);
|
||||
Assert.Contains(report.Findings, finding => finding.Type == LicenseFindingType.MissingLicense);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ProhibitedLicenseFails()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var components = new[]
|
||||
{
|
||||
new LicenseComponent
|
||||
{
|
||||
Name = "example",
|
||||
Version = "1.0.0",
|
||||
Purl = "pkg:npm/example@1.0.0",
|
||||
LicenseExpression = "GPL-3.0-only"
|
||||
}
|
||||
};
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, LicensePolicyDefaults.Default);
|
||||
|
||||
Assert.Equal(LicenseComplianceStatus.Fail, report.OverallStatus);
|
||||
Assert.Contains(report.Findings, finding => finding.Type == LicenseFindingType.ProhibitedLicense);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_HandlesRealWorldExpressions()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var components = new[]
|
||||
{
|
||||
new LicenseComponent
|
||||
{
|
||||
Name = "lodash",
|
||||
Version = "4.17.21",
|
||||
LicenseExpression = "MIT OR Apache-2.0"
|
||||
},
|
||||
new LicenseComponent
|
||||
{
|
||||
Name = "llvm",
|
||||
Version = "17.0.0",
|
||||
LicenseExpression = "Apache-2.0 WITH LLVM-exception"
|
||||
},
|
||||
new LicenseComponent
|
||||
{
|
||||
Name = "glibc",
|
||||
Version = "2.37",
|
||||
LicenseExpression = "LGPL-2.1-or-later"
|
||||
}
|
||||
};
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, LicensePolicyDefaults.Default);
|
||||
|
||||
Assert.NotNull(report.Inventory);
|
||||
Assert.True(report.Inventory.Licenses.Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_UnknownLicenseHandlingDenyFails()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
UnknownLicenseHandling = UnknownLicenseHandling.Deny
|
||||
};
|
||||
var components = new[]
|
||||
{
|
||||
new LicenseComponent
|
||||
{
|
||||
Name = "mystery",
|
||||
LicenseExpression = "LicenseRef-Unknown"
|
||||
}
|
||||
};
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, policy);
|
||||
|
||||
Assert.Equal(LicenseComplianceStatus.Fail, report.OverallStatus);
|
||||
Assert.Equal(1, report.Inventory.UnknownLicenseCount);
|
||||
Assert.Contains(report.Findings, finding => finding.Type == LicenseFindingType.UnknownLicense);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_InvalidExpressionTracksUnknownLicense()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var components = new[]
|
||||
{
|
||||
new LicenseComponent
|
||||
{
|
||||
Name = "broken",
|
||||
LicenseExpression = "MIT AND"
|
||||
}
|
||||
};
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, LicensePolicyDefaults.Default);
|
||||
|
||||
Assert.Equal(1, report.Inventory.UnknownLicenseCount);
|
||||
Assert.Contains(report.Findings, finding => finding.Type == LicenseFindingType.UnknownLicense);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_BuildsAttributionFindings()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var components = new[]
|
||||
{
|
||||
new LicenseComponent
|
||||
{
|
||||
Name = "apache-lib",
|
||||
LicenseExpression = "Apache-2.0"
|
||||
}
|
||||
};
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, LicensePolicyDefaults.Default);
|
||||
|
||||
Assert.Contains(report.Findings, finding => finding.Type == LicenseFindingType.AttributionRequired);
|
||||
Assert.Contains(report.Findings, finding => finding.Type == LicenseFindingType.PatentClauseRisk);
|
||||
Assert.NotEmpty(report.AttributionRequirements);
|
||||
Assert.Contains(report.Inventory.ByCategory.Keys, category => category == LicenseCategory.Permissive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_UsesLicenseListWhenExpressionMissing()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var components = new[]
|
||||
{
|
||||
new LicenseComponent
|
||||
{
|
||||
Name = "multi-license",
|
||||
Licenses = ImmutableArray.Create("MIT", "Apache-2.0")
|
||||
}
|
||||
};
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, LicensePolicyDefaults.Default);
|
||||
|
||||
Assert.Equal(0, report.Inventory.NoLicenseCount);
|
||||
Assert.DoesNotContain(report.Findings, finding => finding.Type == LicenseFindingType.MissingLicense);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ExemptionsSuppressProhibitedLicense()
|
||||
{
|
||||
var evaluator = new LicenseComplianceEvaluator(LicenseKnowledgeBase.LoadDefault());
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = ImmutableArray.Create("MIT"),
|
||||
ProhibitedLicenses = ImmutableArray<string>.Empty,
|
||||
Categories = new LicenseCategoryRules
|
||||
{
|
||||
AllowCopyleft = true,
|
||||
AllowWeakCopyleft = true,
|
||||
RequireOsiApproved = true
|
||||
},
|
||||
ProjectContext = new ProjectContext
|
||||
{
|
||||
DistributionModel = DistributionModel.OpenSource,
|
||||
LinkingModel = LinkingModel.Dynamic
|
||||
},
|
||||
Exemptions = ImmutableArray.Create(new LicenseExemption
|
||||
{
|
||||
ComponentPattern = "internal-*",
|
||||
Reason = "Internal exemption",
|
||||
AllowedLicenses = ImmutableArray.Create("GPL-3.0-only")
|
||||
})
|
||||
};
|
||||
var components = new[]
|
||||
{
|
||||
new LicenseComponent
|
||||
{
|
||||
Name = "internal-lib",
|
||||
LicenseExpression = "GPL-3.0-only"
|
||||
}
|
||||
};
|
||||
|
||||
var report = await evaluator.EvaluateAsync(components, policy);
|
||||
|
||||
Assert.DoesNotContain(report.Findings, finding => finding.Type == LicenseFindingType.ProhibitedLicense);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Licensing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.Licensing;
|
||||
|
||||
public sealed class LicenseComplianceReporterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToText_IncludesSummaryFindingsAndConflicts()
|
||||
{
|
||||
var reporter = new LicenseComplianceReporter();
|
||||
var report = BuildReport();
|
||||
|
||||
var text = reporter.ToText(report);
|
||||
|
||||
Assert.Contains("License compliance: Fail", text);
|
||||
Assert.Contains("Findings:", text);
|
||||
Assert.Contains("Conflicts:", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToMarkdown_IncludesInventoryAndFindings()
|
||||
{
|
||||
var reporter = new LicenseComplianceReporter();
|
||||
var report = BuildReport();
|
||||
|
||||
var markdown = reporter.ToMarkdown(report);
|
||||
|
||||
Assert.Contains("# License Compliance Report", markdown);
|
||||
Assert.Contains("## Inventory", markdown);
|
||||
Assert.Contains("## Findings", markdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToHtml_IncludesStatusAndInventory()
|
||||
{
|
||||
var reporter = new LicenseComplianceReporter();
|
||||
var report = BuildReport();
|
||||
|
||||
var html = reporter.ToHtml(report);
|
||||
|
||||
Assert.Contains("<h1>License Compliance Report</h1>", html);
|
||||
Assert.Contains("Status: Fail", html);
|
||||
Assert.Contains("<h2>Inventory</h2>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToHtml_IncludesCategoryChartWhenPresent()
|
||||
{
|
||||
var reporter = new LicenseComplianceReporter();
|
||||
var report = BuildReport();
|
||||
|
||||
var html = reporter.ToHtml(report);
|
||||
|
||||
Assert.Contains("Category Breakdown", html);
|
||||
Assert.Contains("conic-gradient", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLegalReview_IncludesNoticeSection()
|
||||
{
|
||||
var reporter = new LicenseComplianceReporter();
|
||||
var report = BuildReport();
|
||||
|
||||
var legal = reporter.ToLegalReview(report);
|
||||
|
||||
Assert.Contains("License Compliance Report", legal);
|
||||
Assert.Contains("NOTICE", legal);
|
||||
Assert.Contains("Third-Party Attributions", legal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToPdf_ReturnsPdfBytes()
|
||||
{
|
||||
var reporter = new LicenseComplianceReporter();
|
||||
var report = BuildReport();
|
||||
|
||||
var pdf = reporter.ToPdf(report);
|
||||
|
||||
Assert.NotEmpty(pdf);
|
||||
var header = Encoding.ASCII.GetString(pdf, 0, Math.Min(pdf.Length, 8));
|
||||
Assert.Contains("%PDF-", header);
|
||||
}
|
||||
|
||||
private static LicenseComplianceReport BuildReport()
|
||||
{
|
||||
var inventory = new LicenseInventory
|
||||
{
|
||||
Licenses = ImmutableArray.Create(new LicenseUsage
|
||||
{
|
||||
LicenseId = "MIT",
|
||||
Expression = "MIT",
|
||||
Category = LicenseCategory.Permissive,
|
||||
Components = ImmutableArray.Create("lib"),
|
||||
Count = 1
|
||||
}),
|
||||
ByCategory = ImmutableDictionary<LicenseCategory, int>.Empty
|
||||
.Add(LicenseCategory.Permissive, 1)
|
||||
.Add(LicenseCategory.StrongCopyleft, 1),
|
||||
UnknownLicenseCount = 0,
|
||||
NoLicenseCount = 0
|
||||
};
|
||||
|
||||
return new LicenseComplianceReport
|
||||
{
|
||||
Inventory = inventory,
|
||||
Findings = ImmutableArray.Create(new LicenseFinding
|
||||
{
|
||||
Type = LicenseFindingType.ProhibitedLicense,
|
||||
LicenseId = "GPL-3.0-only",
|
||||
ComponentName = "lib",
|
||||
ComponentPurl = "pkg:npm/lib@1.0.0",
|
||||
Category = LicenseCategory.StrongCopyleft,
|
||||
Message = "GPL not allowed."
|
||||
}),
|
||||
Conflicts = ImmutableArray.Create(new LicenseConflict
|
||||
{
|
||||
ComponentName = "lib",
|
||||
ComponentPurl = "pkg:npm/lib@1.0.0",
|
||||
LicenseIds = ImmutableArray.Create("MIT", "GPL-3.0-only"),
|
||||
Reason = "Mixed licensing conflict."
|
||||
}),
|
||||
OverallStatus = LicenseComplianceStatus.Fail,
|
||||
AttributionRequirements = ImmutableArray.Create(new AttributionRequirement
|
||||
{
|
||||
ComponentName = "lib",
|
||||
ComponentPurl = "pkg:npm/lib@1.0.0",
|
||||
LicenseId = "MIT",
|
||||
Notices = ImmutableArray.Create("MIT License notice."),
|
||||
IncludeLicenseText = true
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Licensing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.Licensing;
|
||||
|
||||
public sealed class LicenseExpressionEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_UnknownLicenseRespectsPolicy()
|
||||
{
|
||||
var evaluator = CreateEvaluator();
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
UnknownLicenseHandling = UnknownLicenseHandling.Deny
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(new LicenseIdExpression("Unknown-License"), policy);
|
||||
|
||||
Assert.False(result.IsCompliant);
|
||||
Assert.Contains(result.Issues, issue => issue.Type == LicenseFindingType.UnknownLicense);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_AllowListBlocksUnlistedLicense()
|
||||
{
|
||||
var evaluator = CreateEvaluator();
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = ImmutableArray.Create("MIT")
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(new LicenseIdExpression("Apache-2.0"), policy);
|
||||
|
||||
Assert.False(result.IsCompliant);
|
||||
Assert.Contains(result.Issues, issue => issue.Type == LicenseFindingType.ProhibitedLicense);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ExplicitProhibitedLicenseFails()
|
||||
{
|
||||
var evaluator = CreateEvaluator();
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
ProhibitedLicenses = ImmutableArray.Create("MIT")
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(new LicenseIdExpression("MIT"), policy);
|
||||
|
||||
Assert.False(result.IsCompliant);
|
||||
Assert.Contains(result.Issues, issue => issue.Type == LicenseFindingType.ProhibitedLicense);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_RequiresOsiApprovedBlocksNonOsiLicense()
|
||||
{
|
||||
var evaluator = CreateEvaluator();
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = ImmutableArray<string>.Empty,
|
||||
ProhibitedLicenses = ImmutableArray<string>.Empty,
|
||||
Categories = new LicenseCategoryRules
|
||||
{
|
||||
AllowCopyleft = true,
|
||||
AllowWeakCopyleft = true,
|
||||
RequireOsiApproved = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(new LicenseIdExpression("LicenseRef-Commercial"), policy);
|
||||
|
||||
Assert.False(result.IsCompliant);
|
||||
Assert.Contains(result.Issues, issue => issue.Type == LicenseFindingType.ProhibitedLicense);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_CopyleftNotAllowedInCommercialContext()
|
||||
{
|
||||
var evaluator = CreateEvaluator();
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = ImmutableArray<string>.Empty,
|
||||
ProhibitedLicenses = ImmutableArray<string>.Empty,
|
||||
Categories = new LicenseCategoryRules
|
||||
{
|
||||
AllowCopyleft = false,
|
||||
AllowWeakCopyleft = true,
|
||||
RequireOsiApproved = true
|
||||
},
|
||||
ProjectContext = new ProjectContext
|
||||
{
|
||||
DistributionModel = DistributionModel.Commercial,
|
||||
LinkingModel = LinkingModel.Dynamic
|
||||
}
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(new LicenseIdExpression("GPL-3.0-only"), policy);
|
||||
|
||||
Assert.False(result.IsCompliant);
|
||||
Assert.Contains(result.Issues, issue => issue.Type == LicenseFindingType.CopyleftInProprietaryContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ConditionalLicenseRequiresMatchingContext()
|
||||
{
|
||||
var evaluator = CreateEvaluator();
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = ImmutableArray<string>.Empty,
|
||||
ConditionalLicenses = ImmutableArray.Create(new ConditionalLicenseRule
|
||||
{
|
||||
License = "LGPL-2.1-only",
|
||||
Condition = LicenseCondition.DynamicLinkingOnly
|
||||
}),
|
||||
ProjectContext = new ProjectContext
|
||||
{
|
||||
DistributionModel = DistributionModel.OpenSource,
|
||||
LinkingModel = LinkingModel.Static
|
||||
}
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(new LicenseIdExpression("LGPL-2.1-only"), policy);
|
||||
|
||||
Assert.False(result.IsCompliant);
|
||||
Assert.Contains(result.Issues, issue => issue.Type == LicenseFindingType.ConditionalLicenseViolation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithUnknownExceptionFailsCompliance()
|
||||
{
|
||||
var evaluator = CreateEvaluator();
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = ImmutableArray<string>.Empty,
|
||||
ProhibitedLicenses = ImmutableArray<string>.Empty
|
||||
};
|
||||
var expression = new WithExceptionExpression(new LicenseIdExpression("GPL-2.0-only"), "Unknown-exception");
|
||||
|
||||
var result = evaluator.Evaluate(expression, policy);
|
||||
|
||||
Assert.False(result.IsCompliant);
|
||||
Assert.Contains(result.Issues, issue => issue.LicenseId == "Unknown-exception");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_OrLaterResolvesKnownOrLaterLicense()
|
||||
{
|
||||
var evaluator = CreateEvaluator();
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = ImmutableArray<string>.Empty
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(new OrLaterExpression("GPL-2.0"), policy);
|
||||
|
||||
Assert.Contains(result.SelectedLicenses, license => license.Id == "GPL-2.0-or-later");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_AndExpressionDetectsCompatibilityConflict()
|
||||
{
|
||||
var evaluator = CreateEvaluator();
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = ImmutableArray<string>.Empty,
|
||||
ProhibitedLicenses = ImmutableArray<string>.Empty,
|
||||
Categories = new LicenseCategoryRules
|
||||
{
|
||||
AllowCopyleft = true,
|
||||
AllowWeakCopyleft = true,
|
||||
RequireOsiApproved = true
|
||||
},
|
||||
ProjectContext = new ProjectContext
|
||||
{
|
||||
DistributionModel = DistributionModel.OpenSource,
|
||||
LinkingModel = LinkingModel.Dynamic
|
||||
}
|
||||
};
|
||||
var expression = new AndExpression(ImmutableArray.Create<LicenseExpression>(
|
||||
new LicenseIdExpression("Apache-2.0"),
|
||||
new LicenseIdExpression("GPL-2.0-only")));
|
||||
|
||||
var result = evaluator.Evaluate(expression, policy);
|
||||
|
||||
Assert.False(result.IsCompliant);
|
||||
Assert.Contains(result.Issues, issue => issue.Type == LicenseFindingType.LicenseConflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_OrExpressionSelectsLowestRiskAndAlternatives()
|
||||
{
|
||||
var evaluator = CreateEvaluator();
|
||||
var policy = LicensePolicyDefaults.Default with
|
||||
{
|
||||
AllowedLicenses = ImmutableArray<string>.Empty
|
||||
};
|
||||
var expression = new OrExpression(ImmutableArray.Create<LicenseExpression>(
|
||||
new LicenseIdExpression("MIT"),
|
||||
new LicenseIdExpression("LGPL-2.1-only")));
|
||||
|
||||
var result = evaluator.Evaluate(expression, policy);
|
||||
|
||||
Assert.True(result.IsCompliant);
|
||||
Assert.Contains(result.SelectedLicenses, license => license.Id == "MIT");
|
||||
Assert.Contains(result.AlternativeLicenses, license => license.Id == "LGPL-2.1-only");
|
||||
}
|
||||
|
||||
private static LicenseExpressionEvaluator CreateEvaluator()
|
||||
{
|
||||
return new LicenseExpressionEvaluator(
|
||||
LicenseKnowledgeBase.LoadDefault(),
|
||||
new LicenseCompatibilityChecker(),
|
||||
new ProjectContextAnalyzer());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using StellaOps.Policy.Licensing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.Licensing;
|
||||
|
||||
public sealed class LicensePolicyLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_ReadsYamlPolicy()
|
||||
{
|
||||
var yaml = """
|
||||
licensePolicy:
|
||||
projectContext:
|
||||
distributionModel: commercial
|
||||
linkingModel: dynamic
|
||||
allowedLicenses:
|
||||
- MIT
|
||||
- Apache-2.0
|
||||
prohibitedLicenses:
|
||||
- GPL-3.0-only
|
||||
""";
|
||||
var path = WriteTempPolicy(".yaml", yaml);
|
||||
|
||||
try
|
||||
{
|
||||
var loader = new LicensePolicyLoader();
|
||||
var policy = loader.Load(path);
|
||||
|
||||
Assert.Contains("MIT", policy.AllowedLicenses);
|
||||
Assert.Contains("GPL-3.0-only", policy.ProhibitedLicenses);
|
||||
Assert.Equal(DistributionModel.Commercial, policy.ProjectContext.DistributionModel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ReadsYamlPolicyWithExemptions()
|
||||
{
|
||||
var yaml = """
|
||||
licensePolicy:
|
||||
projectContext:
|
||||
distributionModel: saas
|
||||
linkingModel: process
|
||||
allowedLicenses:
|
||||
- MIT
|
||||
conditionalLicenses:
|
||||
- license: LGPL-2.1-only
|
||||
condition: dynamicLinkingOnly
|
||||
exemptions:
|
||||
- componentPattern: "internal-*"
|
||||
reason: "Internal code"
|
||||
allowedLicenses:
|
||||
- GPL-3.0-only
|
||||
""";
|
||||
var path = WriteTempPolicy(".yaml", yaml);
|
||||
|
||||
try
|
||||
{
|
||||
var loader = new LicensePolicyLoader();
|
||||
var policy = loader.Load(path);
|
||||
|
||||
Assert.Equal(DistributionModel.Saas, policy.ProjectContext.DistributionModel);
|
||||
Assert.Single(policy.ConditionalLicenses);
|
||||
Assert.Single(policy.Exemptions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ReadsRootYamlPolicy()
|
||||
{
|
||||
var yaml = """
|
||||
projectContext:
|
||||
distributionModel: internal
|
||||
linkingModel: dynamic
|
||||
allowedLicenses:
|
||||
- MIT
|
||||
""";
|
||||
var path = WriteTempPolicy(".yaml", yaml);
|
||||
|
||||
try
|
||||
{
|
||||
var loader = new LicensePolicyLoader();
|
||||
var policy = loader.Load(path);
|
||||
|
||||
Assert.Equal(DistributionModel.Internal, policy.ProjectContext.DistributionModel);
|
||||
Assert.Contains("MIT", policy.AllowedLicenses);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ReadsJsonPolicyDocument()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"licensePolicy": {
|
||||
"projectContext": {
|
||||
"distributionModel": 2,
|
||||
"linkingModel": 1
|
||||
},
|
||||
"allowedLicenses": ["MIT"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var path = WriteTempPolicy(".json", json);
|
||||
|
||||
try
|
||||
{
|
||||
var loader = new LicensePolicyLoader();
|
||||
var policy = loader.Load(path);
|
||||
|
||||
Assert.Equal(DistributionModel.Commercial, policy.ProjectContext.DistributionModel);
|
||||
Assert.Contains("MIT", policy.AllowedLicenses);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ThrowsWhenExemptionMissingReason()
|
||||
{
|
||||
var yaml = """
|
||||
licensePolicy:
|
||||
exemptions:
|
||||
- componentPattern: "internal-*"
|
||||
allowedLicenses:
|
||||
- GPL-3.0-only
|
||||
""";
|
||||
var path = WriteTempPolicy(".yaml", yaml);
|
||||
|
||||
try
|
||||
{
|
||||
var loader = new LicensePolicyLoader();
|
||||
Assert.Throws<InvalidDataException>(() => loader.Load(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static string WriteTempPolicy(string extension, string content)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"license-policy-{Guid.NewGuid():N}{extension}");
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void DeleteIfExists(string path)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using StellaOps.Policy.Licensing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.Licensing;
|
||||
|
||||
public sealed class SpdxLicenseExpressionParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_HandlesCompoundExpression()
|
||||
{
|
||||
var expression = "(MIT OR Apache-2.0) AND GPL-2.0-only WITH Classpath-exception-2.0";
|
||||
|
||||
var parsed = SpdxLicenseExpressionParser.Parse(expression);
|
||||
|
||||
var andExpr = Assert.IsType<AndExpression>(parsed);
|
||||
Assert.Equal(2, andExpr.Terms.Length);
|
||||
|
||||
var orExpr = Assert.IsType<OrExpression>(andExpr.Terms[0]);
|
||||
Assert.Equal(2, orExpr.Terms.Length);
|
||||
|
||||
var withExpr = Assert.IsType<WithExceptionExpression>(andExpr.Terms[1]);
|
||||
var licenseId = Assert.IsType<LicenseIdExpression>(withExpr.License);
|
||||
Assert.Equal("GPL-2.0-only", licenseId.Id);
|
||||
Assert.Equal("Classpath-exception-2.0", withExpr.ExceptionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_HandlesOrLaterSuffix()
|
||||
{
|
||||
var parsed = SpdxLicenseExpressionParser.Parse("GPL-2.0+");
|
||||
|
||||
var orLater = Assert.IsType<OrLaterExpression>(parsed);
|
||||
Assert.Equal("GPL-2.0", orLater.LicenseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Policy.NtiaCompliance;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.NtiaCompliance;
|
||||
|
||||
public sealed class DependencyCompletenessCheckerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_DetectsOrphanedComponents()
|
||||
{
|
||||
var sbom = new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.6",
|
||||
SerialNumber = "urn:uuid:deps-test",
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent { BomRef = "root", Name = "root" },
|
||||
new ParsedComponent { BomRef = "dep-1", Name = "dep-1" },
|
||||
new ParsedComponent { BomRef = "orphan", Name = "orphan" }
|
||||
],
|
||||
Dependencies =
|
||||
[
|
||||
new ParsedDependency
|
||||
{
|
||||
SourceRef = "root",
|
||||
DependsOn = ImmutableArray.Create("dep-1")
|
||||
}
|
||||
],
|
||||
Metadata = new ParsedSbomMetadata()
|
||||
};
|
||||
|
||||
var checker = new DependencyCompletenessChecker();
|
||||
var report = checker.Evaluate(sbom);
|
||||
|
||||
Assert.Equal(3, report.TotalComponents);
|
||||
Assert.Contains("orphan", report.OrphanedComponents);
|
||||
Assert.Equal(2, report.ComponentsWithDependencies);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Policy.NtiaCompliance;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.NtiaCompliance;
|
||||
|
||||
public sealed class NtiaBaselineValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ValidateAsync_FullyCompliantSbom_Passes()
|
||||
{
|
||||
var sbom = CreateSbom(
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "root",
|
||||
Name = "root",
|
||||
Version = "1.0.0",
|
||||
Purl = "pkg:npm/root@1.0.0",
|
||||
Supplier = new ParsedOrganization { Name = "Acme Corp", Url = "https://example.com" }
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "dep-1",
|
||||
Name = "dep-1",
|
||||
Version = "2.0.0",
|
||||
Purl = "pkg:npm/dep-1@2.0.0",
|
||||
Supplier = new ParsedOrganization { Name = "Acme Corp", Url = "https://example.com" }
|
||||
});
|
||||
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
Thresholds = new NtiaComplianceThresholds
|
||||
{
|
||||
MinimumCompliancePercent = 100.0,
|
||||
AllowPartialCompliance = false
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new NtiaBaselineValidator();
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
|
||||
Assert.Equal(NtiaComplianceStatus.Pass, report.OverallStatus);
|
||||
Assert.Equal(100.0, report.ComplianceScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_MissingSupplier_FailsWhenStrict()
|
||||
{
|
||||
// Create SBOM with no supplier at component level AND no fallback at metadata level
|
||||
var sbom = new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.6",
|
||||
SerialNumber = "urn:uuid:missing-supplier-test",
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "root",
|
||||
Name = "root",
|
||||
Version = "1.0.0",
|
||||
Purl = "pkg:npm/root@1.0.0"
|
||||
// No Supplier here, and no fallback in metadata
|
||||
}
|
||||
],
|
||||
Dependencies =
|
||||
[
|
||||
new ParsedDependency
|
||||
{
|
||||
SourceRef = "root",
|
||||
DependsOn = ImmutableArray<string>.Empty
|
||||
}
|
||||
],
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Authors = ["StellaOps"],
|
||||
Timestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
// No Supplier fallback in metadata
|
||||
}
|
||||
};
|
||||
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
Thresholds = new NtiaComplianceThresholds
|
||||
{
|
||||
MinimumCompliancePercent = 95.0,
|
||||
AllowPartialCompliance = false
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new NtiaBaselineValidator();
|
||||
var report = await validator.ValidateAsync(sbom, policy);
|
||||
|
||||
Assert.Equal(NtiaComplianceStatus.Fail, report.OverallStatus);
|
||||
Assert.Contains(report.Findings, finding => finding.Type == NtiaFindingType.MissingSupplier);
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateSbom(params ParsedComponent[] components)
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.6",
|
||||
SerialNumber = "urn:uuid:baseline-test",
|
||||
Components = components.ToImmutableArray(),
|
||||
Dependencies =
|
||||
[
|
||||
new ParsedDependency
|
||||
{
|
||||
SourceRef = "root",
|
||||
DependsOn = ImmutableArray.Create("dep-1")
|
||||
}
|
||||
],
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Authors = ["StellaOps"],
|
||||
Timestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Supplier = "Acme Corp"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using StellaOps.Policy.NtiaCompliance;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.NtiaCompliance;
|
||||
|
||||
public sealed class NtiaCompliancePolicyLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_JsonPolicy_ParsesElements()
|
||||
{
|
||||
var path = CreateTempPolicy("""
|
||||
{
|
||||
"ntiaCompliancePolicy": {
|
||||
"minimumElements": {
|
||||
"requireAll": true,
|
||||
"elements": ["supplierName", "componentName"]
|
||||
},
|
||||
"thresholds": {
|
||||
"minimumCompliancePercent": 90,
|
||||
"allowPartialCompliance": true
|
||||
}
|
||||
}
|
||||
}
|
||||
""", ".json");
|
||||
|
||||
var loader = new NtiaCompliancePolicyLoader();
|
||||
var policy = loader.Load(path);
|
||||
|
||||
Assert.True(policy.MinimumElements.RequireAll);
|
||||
Assert.Contains(NtiaElement.SupplierName, policy.MinimumElements.Elements);
|
||||
Assert.Contains(NtiaElement.ComponentName, policy.MinimumElements.Elements);
|
||||
Assert.Equal(90, policy.Thresholds.MinimumCompliancePercent);
|
||||
Assert.True(policy.Thresholds.AllowPartialCompliance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_YamlPolicy_ParsesFrameworks()
|
||||
{
|
||||
var path = CreateTempPolicy("""
|
||||
ntiaCompliancePolicy:
|
||||
minimumElements:
|
||||
requireAll: false
|
||||
elements:
|
||||
- supplierName
|
||||
- componentVersion
|
||||
frameworks:
|
||||
- ntia
|
||||
- fda
|
||||
""", ".yaml");
|
||||
|
||||
var loader = new NtiaCompliancePolicyLoader();
|
||||
var policy = loader.Load(path);
|
||||
|
||||
Assert.False(policy.MinimumElements.RequireAll);
|
||||
Assert.Contains(NtiaElement.ComponentVersion, policy.MinimumElements.Elements);
|
||||
Assert.Contains(RegulatoryFramework.Fda, policy.Frameworks);
|
||||
}
|
||||
|
||||
private static string CreateTempPolicy(string content, string extension)
|
||||
{
|
||||
var path = Path.ChangeExtension(Path.GetTempFileName(), extension);
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RegulatoryFrameworkMapperTests.cs
|
||||
// Sprint: SPRINT_20260119_023_Compliance_ntia_supplier
|
||||
// Task: TASK-023-011 - Unit tests for NTIA compliance
|
||||
// Description: Tests for regulatory framework mapping
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Policy.NtiaCompliance;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.NtiaCompliance;
|
||||
|
||||
public sealed class RegulatoryFrameworkMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_NtiaFramework_ReturnsNtiaMapping()
|
||||
{
|
||||
var sbom = CreateMinimalSbom();
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
Frameworks = [RegulatoryFramework.Ntia]
|
||||
};
|
||||
var elementStatuses = BuildPassingElementStatuses();
|
||||
|
||||
var mapper = new RegulatoryFrameworkMapper();
|
||||
var result = mapper.Map(sbom, policy, elementStatuses);
|
||||
|
||||
Assert.Contains(result.Frameworks, f => f.Framework == RegulatoryFramework.Ntia);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_FdaFramework_ReturnsFdaMapping()
|
||||
{
|
||||
var sbom = CreateMinimalSbom();
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
Frameworks = [RegulatoryFramework.Fda]
|
||||
};
|
||||
var elementStatuses = BuildPassingElementStatuses();
|
||||
|
||||
var mapper = new RegulatoryFrameworkMapper();
|
||||
var result = mapper.Map(sbom, policy, elementStatuses);
|
||||
|
||||
Assert.Contains(result.Frameworks, f => f.Framework == RegulatoryFramework.Fda);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_CisaFramework_ReturnsCisaMapping()
|
||||
{
|
||||
var sbom = CreateMinimalSbom();
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
Frameworks = [RegulatoryFramework.Cisa]
|
||||
};
|
||||
var elementStatuses = BuildPassingElementStatuses();
|
||||
|
||||
var mapper = new RegulatoryFrameworkMapper();
|
||||
var result = mapper.Map(sbom, policy, elementStatuses);
|
||||
|
||||
Assert.Contains(result.Frameworks, f => f.Framework == RegulatoryFramework.Cisa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_EuCraFramework_ReturnsEuCraMapping()
|
||||
{
|
||||
var sbom = CreateMinimalSbom();
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
Frameworks = [RegulatoryFramework.EuCra]
|
||||
};
|
||||
var elementStatuses = BuildPassingElementStatuses();
|
||||
|
||||
var mapper = new RegulatoryFrameworkMapper();
|
||||
var result = mapper.Map(sbom, policy, elementStatuses);
|
||||
|
||||
Assert.Contains(result.Frameworks, f => f.Framework == RegulatoryFramework.EuCra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_MultipleFrameworks_ReturnsAllMappings()
|
||||
{
|
||||
var sbom = CreateMinimalSbom();
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
Frameworks = [RegulatoryFramework.Ntia, RegulatoryFramework.Fda, RegulatoryFramework.Cisa]
|
||||
};
|
||||
var elementStatuses = BuildPassingElementStatuses();
|
||||
|
||||
var mapper = new RegulatoryFrameworkMapper();
|
||||
var result = mapper.Map(sbom, policy, elementStatuses);
|
||||
|
||||
Assert.Equal(3, result.Frameworks.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_EmptyFrameworks_ReturnsEmptyResult()
|
||||
{
|
||||
var sbom = CreateMinimalSbom();
|
||||
var policy = new NtiaCompliancePolicy
|
||||
{
|
||||
Frameworks = ImmutableArray<RegulatoryFramework>.Empty
|
||||
};
|
||||
var elementStatuses = BuildPassingElementStatuses();
|
||||
|
||||
var mapper = new RegulatoryFrameworkMapper();
|
||||
var result = mapper.Map(sbom, policy, elementStatuses);
|
||||
|
||||
Assert.True(result.Frameworks.IsEmpty);
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateMinimalSbom()
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.6",
|
||||
SerialNumber = "urn:uuid:framework-test",
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "root",
|
||||
Name = "root",
|
||||
Version = "1.0.0",
|
||||
Purl = "pkg:npm/root@1.0.0",
|
||||
Supplier = new ParsedOrganization { Name = "Acme" }
|
||||
}
|
||||
],
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Authors = ["StellaOps"],
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<NtiaElementStatus> BuildPassingElementStatuses()
|
||||
{
|
||||
return
|
||||
[
|
||||
new NtiaElementStatus { Element = NtiaElement.SupplierName, Present = true, Valid = true, ComponentsCovered = 1 },
|
||||
new NtiaElementStatus { Element = NtiaElement.ComponentName, Present = true, Valid = true, ComponentsCovered = 1 },
|
||||
new NtiaElementStatus { Element = NtiaElement.ComponentVersion, Present = true, Valid = true, ComponentsCovered = 1 },
|
||||
new NtiaElementStatus { Element = NtiaElement.OtherUniqueIdentifiers, Present = true, Valid = true, ComponentsCovered = 1 },
|
||||
new NtiaElementStatus { Element = NtiaElement.DependencyRelationship, Present = true, Valid = true, ComponentsCovered = 1 },
|
||||
new NtiaElementStatus { Element = NtiaElement.AuthorOfSbomData, Present = true, Valid = true, ComponentsCovered = 1 },
|
||||
new NtiaElementStatus { Element = NtiaElement.Timestamp, Present = true, Valid = true, ComponentsCovered = 1 }
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SupplierTrustVerifierTests.cs
|
||||
// Sprint: SPRINT_20260119_023_Compliance_ntia_supplier
|
||||
// Task: TASK-023-011 - Unit tests for NTIA compliance
|
||||
// Description: Tests for supplier trust verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.NtiaCompliance;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.NtiaCompliance;
|
||||
|
||||
public sealed class SupplierTrustVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Verify_WithTrustedSuppliers_MarksAsVerified()
|
||||
{
|
||||
var supplierReport = new SupplierValidationReport
|
||||
{
|
||||
Suppliers =
|
||||
[
|
||||
new SupplierInventoryEntry { Name = "Microsoft", ComponentCount = 5 },
|
||||
new SupplierInventoryEntry { Name = "Google", ComponentCount = 3 }
|
||||
],
|
||||
ComponentsWithSupplier = 8,
|
||||
Status = SupplierValidationStatus.Pass
|
||||
};
|
||||
|
||||
var policy = new SupplierValidationPolicy
|
||||
{
|
||||
TrustedSuppliers = ["Microsoft", "Google"]
|
||||
};
|
||||
|
||||
var verifier = new SupplierTrustVerifier();
|
||||
var result = verifier.Verify(supplierReport, policy);
|
||||
|
||||
Assert.Equal(2, result.VerifiedSuppliers);
|
||||
Assert.Equal(0, result.UnknownSuppliers);
|
||||
Assert.Equal(0, result.BlockedSuppliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithBlockedSupplier_DetectsBlocked()
|
||||
{
|
||||
var supplierReport = new SupplierValidationReport
|
||||
{
|
||||
Suppliers =
|
||||
[
|
||||
new SupplierInventoryEntry { Name = "TrustedCorp", ComponentCount = 5 },
|
||||
new SupplierInventoryEntry { Name = "EvilCorp", ComponentCount = 2 }
|
||||
],
|
||||
ComponentsWithSupplier = 7,
|
||||
Status = SupplierValidationStatus.Pass
|
||||
};
|
||||
|
||||
var policy = new SupplierValidationPolicy
|
||||
{
|
||||
TrustedSuppliers = ["TrustedCorp"],
|
||||
BlockedSuppliers = ["EvilCorp"]
|
||||
};
|
||||
|
||||
var verifier = new SupplierTrustVerifier();
|
||||
var result = verifier.Verify(supplierReport, policy);
|
||||
|
||||
Assert.Equal(1, result.VerifiedSuppliers);
|
||||
Assert.Equal(1, result.BlockedSuppliers);
|
||||
Assert.Equal(0, result.UnknownSuppliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithUnlistedSupplier_TracksAsKnown()
|
||||
{
|
||||
// Suppliers not in trusted/blocked lists are marked as Known (not Unknown)
|
||||
// Unknown is only assigned when PlaceholderDetected is true
|
||||
var supplierReport = new SupplierValidationReport
|
||||
{
|
||||
Suppliers =
|
||||
[
|
||||
new SupplierInventoryEntry { Name = "RandomVendor", ComponentCount = 3 }
|
||||
],
|
||||
ComponentsWithSupplier = 3,
|
||||
Status = SupplierValidationStatus.Pass
|
||||
};
|
||||
|
||||
var policy = new SupplierValidationPolicy
|
||||
{
|
||||
TrustedSuppliers = ["Microsoft", "Google"],
|
||||
BlockedSuppliers = ["EvilCorp"]
|
||||
};
|
||||
|
||||
var verifier = new SupplierTrustVerifier();
|
||||
var result = verifier.Verify(supplierReport, policy);
|
||||
|
||||
Assert.Equal(0, result.VerifiedSuppliers);
|
||||
Assert.Equal(0, result.BlockedSuppliers);
|
||||
Assert.Equal(1, result.KnownSuppliers);
|
||||
Assert.Equal(0, result.UnknownSuppliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithPlaceholderSupplier_TracksAsUnknown()
|
||||
{
|
||||
// Suppliers with PlaceholderDetected = true are marked as Unknown
|
||||
var supplierReport = new SupplierValidationReport
|
||||
{
|
||||
Suppliers =
|
||||
[
|
||||
new SupplierInventoryEntry { Name = "unknown", ComponentCount = 2, PlaceholderDetected = true }
|
||||
],
|
||||
ComponentsWithSupplier = 2,
|
||||
Status = SupplierValidationStatus.Warn
|
||||
};
|
||||
|
||||
var policy = new SupplierValidationPolicy();
|
||||
|
||||
var verifier = new SupplierTrustVerifier();
|
||||
var result = verifier.Verify(supplierReport, policy);
|
||||
|
||||
Assert.Equal(1, result.UnknownSuppliers);
|
||||
Assert.Equal(0, result.KnownSuppliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_CaseInsensitiveTrustMatch()
|
||||
{
|
||||
var supplierReport = new SupplierValidationReport
|
||||
{
|
||||
Suppliers =
|
||||
[
|
||||
new SupplierInventoryEntry { Name = "MICROSOFT", ComponentCount = 5 }
|
||||
],
|
||||
ComponentsWithSupplier = 5,
|
||||
Status = SupplierValidationStatus.Pass
|
||||
};
|
||||
|
||||
var policy = new SupplierValidationPolicy
|
||||
{
|
||||
TrustedSuppliers = ["microsoft"]
|
||||
};
|
||||
|
||||
var verifier = new SupplierTrustVerifier();
|
||||
var result = verifier.Verify(supplierReport, policy);
|
||||
|
||||
Assert.Equal(1, result.VerifiedSuppliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_EmptySupplierList_ReturnsZeroCounts()
|
||||
{
|
||||
var supplierReport = new SupplierValidationReport
|
||||
{
|
||||
Suppliers = ImmutableArray<SupplierInventoryEntry>.Empty,
|
||||
ComponentsWithSupplier = 0,
|
||||
Status = SupplierValidationStatus.Unknown
|
||||
};
|
||||
|
||||
var policy = new SupplierValidationPolicy();
|
||||
|
||||
var verifier = new SupplierTrustVerifier();
|
||||
var result = verifier.Verify(supplierReport, policy);
|
||||
|
||||
Assert.Equal(0, result.VerifiedSuppliers);
|
||||
Assert.Equal(0, result.BlockedSuppliers);
|
||||
Assert.Equal(0, result.UnknownSuppliers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Policy.NtiaCompliance;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Unit.NtiaCompliance;
|
||||
|
||||
public sealed class SupplierValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_WithPlaceholderSupplier_FailsWhenRejected()
|
||||
{
|
||||
var sbom = CreateSbom(
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "component-1",
|
||||
Name = "alpha",
|
||||
Version = "1.0.0",
|
||||
Supplier = new ParsedOrganization { Name = "unknown" }
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "component-2",
|
||||
Name = "beta",
|
||||
Version = "2.0.0",
|
||||
Supplier = new ParsedOrganization { Name = "Acme Corp", Url = "https://example.com" }
|
||||
});
|
||||
|
||||
var policy = new SupplierValidationPolicy
|
||||
{
|
||||
RejectPlaceholders = true,
|
||||
PlaceholderPatterns = ["unknown"],
|
||||
RequireUrl = false
|
||||
};
|
||||
|
||||
var validator = new SupplierValidator();
|
||||
var report = validator.Validate(sbom, policy);
|
||||
|
||||
Assert.Equal(SupplierValidationStatus.Fail, report.Status);
|
||||
Assert.Contains(report.Findings, finding => finding.Type == NtiaFindingType.PlaceholderSupplier);
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateSbom(params ParsedComponent[] components)
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.6",
|
||||
SerialNumber = "urn:uuid:ntia-test",
|
||||
Components = components.ToImmutableArray(),
|
||||
Metadata = new ParsedSbomMetadata()
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user