audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Contracts.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Visibility enum.
|
||||
/// </summary>
|
||||
public sealed class VisibilityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Visibility.Public)]
|
||||
[InlineData(Visibility.Internal)]
|
||||
[InlineData(Visibility.Protected)]
|
||||
[InlineData(Visibility.Private)]
|
||||
public void Visibility_AllValues_AreDefined(Visibility visibility)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(visibility));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Visibility_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<Visibility>();
|
||||
Assert.Equal(4, values.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Visibility_JsonSerialization_UsesStringValue()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(Visibility.Public);
|
||||
Assert.Equal("\"Public\"", json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CallKind enum.
|
||||
/// </summary>
|
||||
public sealed class CallKindTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(CallKind.Direct)]
|
||||
[InlineData(CallKind.Virtual)]
|
||||
[InlineData(CallKind.Delegate)]
|
||||
[InlineData(CallKind.Reflection)]
|
||||
[InlineData(CallKind.Dynamic)]
|
||||
[InlineData(CallKind.Plt)]
|
||||
[InlineData(CallKind.Iat)]
|
||||
public void CallKind_AllValues_AreDefined(CallKind kind)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(kind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallKind_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<CallKind>();
|
||||
Assert.Equal(7, values.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallKind_JsonSerialization_UsesStringValue()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(CallKind.Direct);
|
||||
Assert.Equal("\"Direct\"", json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for EntrypointType enum.
|
||||
/// </summary>
|
||||
public sealed class EntrypointTypeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(EntrypointType.HttpHandler)]
|
||||
[InlineData(EntrypointType.GrpcMethod)]
|
||||
[InlineData(EntrypointType.CliCommand)]
|
||||
[InlineData(EntrypointType.BackgroundJob)]
|
||||
[InlineData(EntrypointType.ScheduledJob)]
|
||||
[InlineData(EntrypointType.MessageHandler)]
|
||||
[InlineData(EntrypointType.EventSubscriber)]
|
||||
[InlineData(EntrypointType.WebSocketHandler)]
|
||||
[InlineData(EntrypointType.EventHandler)]
|
||||
[InlineData(EntrypointType.Lambda)]
|
||||
[InlineData(EntrypointType.Unknown)]
|
||||
public void EntrypointType_AllValues_AreDefined(EntrypointType type)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointType_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<EntrypointType>();
|
||||
Assert.Equal(11, values.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SinkCategory enum.
|
||||
/// </summary>
|
||||
public sealed class SinkCategoryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(SinkCategory.CmdExec)]
|
||||
[InlineData(SinkCategory.UnsafeDeser)]
|
||||
[InlineData(SinkCategory.SqlRaw)]
|
||||
[InlineData(SinkCategory.SqlInjection)]
|
||||
[InlineData(SinkCategory.Ssrf)]
|
||||
[InlineData(SinkCategory.FileWrite)]
|
||||
[InlineData(SinkCategory.PathTraversal)]
|
||||
[InlineData(SinkCategory.TemplateInjection)]
|
||||
[InlineData(SinkCategory.CryptoWeak)]
|
||||
[InlineData(SinkCategory.AuthzBypass)]
|
||||
[InlineData(SinkCategory.LdapInjection)]
|
||||
[InlineData(SinkCategory.XPathInjection)]
|
||||
[InlineData(SinkCategory.XxeInjection)]
|
||||
[InlineData(SinkCategory.CodeInjection)]
|
||||
[InlineData(SinkCategory.LogInjection)]
|
||||
[InlineData(SinkCategory.Reflection)]
|
||||
public void SinkCategory_KnownValues_AreDefined(SinkCategory category)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(category));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SinkCategory_JsonSerialization_UsesJsonAttribute()
|
||||
{
|
||||
// SinkCategory has JsonStringEnumMemberName attributes
|
||||
var json = JsonSerializer.Serialize(SinkCategory.CmdExec);
|
||||
Assert.Equal("\"CMD_EXEC\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SinkCategory_JsonDeserialization_FromJsonAttribute()
|
||||
{
|
||||
var category = JsonSerializer.Deserialize<SinkCategory>("\"SQL_INJECTION\"");
|
||||
Assert.Equal(SinkCategory.SqlInjection, category);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CMD_EXEC", SinkCategory.CmdExec)]
|
||||
[InlineData("UNSAFE_DESER", SinkCategory.UnsafeDeser)]
|
||||
[InlineData("SQL_RAW", SinkCategory.SqlRaw)]
|
||||
[InlineData("SSRF", SinkCategory.Ssrf)]
|
||||
[InlineData("FILE_WRITE", SinkCategory.FileWrite)]
|
||||
[InlineData("PATH_TRAVERSAL", SinkCategory.PathTraversal)]
|
||||
[InlineData("CRYPTO_WEAK", SinkCategory.CryptoWeak)]
|
||||
[InlineData("AUTHZ_BYPASS", SinkCategory.AuthzBypass)]
|
||||
[InlineData("XXE", SinkCategory.XxeInjection)]
|
||||
[InlineData("CODE_INJECTION", SinkCategory.CodeInjection)]
|
||||
public void SinkCategory_JsonRoundTrip_PreservesValue(string jsonValue, SinkCategory expected)
|
||||
{
|
||||
var json = $"\"{jsonValue}\"";
|
||||
var deserialized = JsonSerializer.Deserialize<SinkCategory>(json);
|
||||
Assert.Equal(expected, deserialized);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(expected);
|
||||
Assert.Equal(json, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CallEdgeExplanationType enum.
|
||||
/// </summary>
|
||||
public sealed class CallEdgeExplanationTypeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(CallEdgeExplanationType.Import)]
|
||||
[InlineData(CallEdgeExplanationType.DynamicLoad)]
|
||||
[InlineData(CallEdgeExplanationType.Reflection)]
|
||||
[InlineData(CallEdgeExplanationType.Ffi)]
|
||||
[InlineData(CallEdgeExplanationType.EnvGuard)]
|
||||
[InlineData(CallEdgeExplanationType.FeatureFlag)]
|
||||
[InlineData(CallEdgeExplanationType.PlatformArch)]
|
||||
public void CallEdgeExplanationType_KnownValues_AreDefined(CallEdgeExplanationType type)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallEdgeExplanationType_JsonSerialization_UsesStringValue()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(CallEdgeExplanationType.Import);
|
||||
Assert.Equal("\"Import\"", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Contracts\StellaOps.Scanner.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.ProofIntegration\StellaOps.Scanner.ProofIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,145 @@
|
||||
using StellaOps.Scanner.ProofIntegration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.ProofIntegration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for VulnerabilityFinding record.
|
||||
/// </summary>
|
||||
public sealed class VulnerabilityFindingTests
|
||||
{
|
||||
[Fact]
|
||||
public void VulnerabilityFinding_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var finding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-12345",
|
||||
PackagePurl = "pkg:npm/lodash@4.17.20",
|
||||
PackageName = "lodash",
|
||||
PackageVersion = "4.17.20",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
|
||||
Assert.Equal("CVE-2026-12345", finding.CveId);
|
||||
Assert.Equal("pkg:npm/lodash@4.17.20", finding.PackagePurl);
|
||||
Assert.Equal("lodash", finding.PackageName);
|
||||
Assert.Equal("4.17.20", finding.PackageVersion);
|
||||
Assert.Equal("HIGH", finding.Severity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("LOW")]
|
||||
[InlineData("MEDIUM")]
|
||||
[InlineData("HIGH")]
|
||||
[InlineData("CRITICAL")]
|
||||
public void VulnerabilityFinding_SeverityLevels_AreAccepted(string severity)
|
||||
{
|
||||
var finding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-00001",
|
||||
PackagePurl = "pkg:pypi/requests@2.28.0",
|
||||
PackageName = "requests",
|
||||
PackageVersion = "2.28.0",
|
||||
Severity = severity
|
||||
};
|
||||
|
||||
Assert.Equal(severity, finding.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnerabilityFinding_WithDifferentEcosystems_WorksCorrectly()
|
||||
{
|
||||
// npm
|
||||
var npmFinding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-NPM01",
|
||||
PackagePurl = "pkg:npm/@angular/core@17.0.0",
|
||||
PackageName = "@angular/core",
|
||||
PackageVersion = "17.0.0",
|
||||
Severity = "MEDIUM"
|
||||
};
|
||||
Assert.Contains("npm", npmFinding.PackagePurl);
|
||||
|
||||
// pypi
|
||||
var pypiFinding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-PYPI01",
|
||||
PackagePurl = "pkg:pypi/django@4.2.0",
|
||||
PackageName = "django",
|
||||
PackageVersion = "4.2.0",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
Assert.Contains("pypi", pypiFinding.PackagePurl);
|
||||
|
||||
// nuget
|
||||
var nugetFinding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-NUGET01",
|
||||
PackagePurl = "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
PackageName = "Newtonsoft.Json",
|
||||
PackageVersion = "13.0.3",
|
||||
Severity = "LOW"
|
||||
};
|
||||
Assert.Contains("nuget", nugetFinding.PackagePurl);
|
||||
|
||||
// golang
|
||||
var goFinding = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-GO01",
|
||||
PackagePurl = "pkg:golang/github.com/gin-gonic/gin@1.9.0",
|
||||
PackageName = "github.com/gin-gonic/gin",
|
||||
PackageVersion = "1.9.0",
|
||||
Severity = "CRITICAL"
|
||||
};
|
||||
Assert.Contains("golang", goFinding.PackagePurl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnerabilityFinding_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var finding1 = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-00001",
|
||||
PackagePurl = "pkg:npm/lodash@4.17.20",
|
||||
PackageName = "lodash",
|
||||
PackageVersion = "4.17.20",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
|
||||
var finding2 = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-00001",
|
||||
PackagePurl = "pkg:npm/lodash@4.17.20",
|
||||
PackageName = "lodash",
|
||||
PackageVersion = "4.17.20",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
|
||||
Assert.Equal(finding1, finding2);
|
||||
Assert.Equal(finding1.GetHashCode(), finding2.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnerabilityFinding_DifferentCve_NotEqual()
|
||||
{
|
||||
var finding1 = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-00001",
|
||||
PackagePurl = "pkg:npm/lodash@4.17.20",
|
||||
PackageName = "lodash",
|
||||
PackageVersion = "4.17.20",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
|
||||
var finding2 = new VulnerabilityFinding
|
||||
{
|
||||
CveId = "CVE-2026-00002",
|
||||
PackagePurl = "pkg:npm/lodash@4.17.20",
|
||||
PackageName = "lodash",
|
||||
PackageVersion = "4.17.20",
|
||||
Severity = "HIGH"
|
||||
};
|
||||
|
||||
Assert.NotEqual(finding1, finding2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -207,10 +207,17 @@ public class SourceConfigValidatorTests
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"acceptedFormats": ["CycloneDX", "SPDX"],
|
||||
"validationRules": {
|
||||
"requireSignature": false,
|
||||
"maxFileSizeBytes": 10485760
|
||||
"allowedTools": ["stella-cli"],
|
||||
"validation": {
|
||||
"requireSignedSbom": false,
|
||||
"maxSbomSizeBytes": 10485760,
|
||||
"allowedFormats": ["CycloneDxJson", "SpdxJson"]
|
||||
},
|
||||
"attribution": {
|
||||
"requireBuildId": false,
|
||||
"requireRepository": false,
|
||||
"requireCommitSha": false,
|
||||
"requirePipelineId": false
|
||||
}
|
||||
}
|
||||
""");
|
||||
@@ -228,7 +235,16 @@ public class SourceConfigValidatorTests
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"acceptedFormats": ["InvalidFormat"]
|
||||
"allowedTools": ["stella-cli"],
|
||||
"validation": {
|
||||
"allowedFormats": ["InvalidFormat"]
|
||||
},
|
||||
"attribution": {
|
||||
"requireBuildId": false,
|
||||
"requireRepository": false,
|
||||
"requireCommitSha": false,
|
||||
"requirePipelineId": false
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -241,7 +257,7 @@ public class SourceConfigValidatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CliConfig_Empty_ReturnsWarning()
|
||||
public void Validate_CliConfig_Empty_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("{}");
|
||||
@@ -250,8 +266,8 @@ public class SourceConfigValidatorTests
|
||||
var result = _validator.Validate(SbomSourceType.Cli, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Warnings.Should().Contain(w => w.Contains("validation rules"));
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("allowedTools"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -267,8 +283,16 @@ public class SourceConfigValidatorTests
|
||||
"repositoryUrl": "https://github.com/example/repo",
|
||||
"provider": "GitHub",
|
||||
"authMethod": "Token",
|
||||
"branchConfig": {
|
||||
"defaultBranch": "main"
|
||||
"branches": {
|
||||
"include": ["main"]
|
||||
},
|
||||
"triggers": {
|
||||
"onPush": true,
|
||||
"onPullRequest": false,
|
||||
"onTag": false
|
||||
},
|
||||
"scanOptions": {
|
||||
"analyzers": ["nuget"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
@@ -288,7 +312,18 @@ public class SourceConfigValidatorTests
|
||||
{
|
||||
"repositoryUrl": "git@github.com:example/repo.git",
|
||||
"provider": "GitHub",
|
||||
"authMethod": "SshKey"
|
||||
"authMethod": "Ssh",
|
||||
"branches": {
|
||||
"include": ["main"]
|
||||
},
|
||||
"triggers": {
|
||||
"onPush": false,
|
||||
"onPullRequest": false,
|
||||
"onTag": true
|
||||
},
|
||||
"scanOptions": {
|
||||
"analyzers": ["nuget"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -305,7 +340,16 @@ public class SourceConfigValidatorTests
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"provider": "GitHub"
|
||||
"provider": "GitHub",
|
||||
"branches": {
|
||||
"include": ["main"]
|
||||
},
|
||||
"triggers": {
|
||||
"onPush": true
|
||||
},
|
||||
"scanOptions": {
|
||||
"analyzers": ["nuget"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -324,7 +368,16 @@ public class SourceConfigValidatorTests
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"repositoryUrl": "https://github.com/example/repo",
|
||||
"provider": "InvalidProvider"
|
||||
"provider": "InvalidProvider",
|
||||
"branches": {
|
||||
"include": ["main"]
|
||||
},
|
||||
"triggers": {
|
||||
"onPush": true
|
||||
},
|
||||
"scanOptions": {
|
||||
"analyzers": ["nuget"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -337,13 +390,19 @@ public class SourceConfigValidatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_GitConfig_NoBranchConfig_ReturnsWarning()
|
||||
public void Validate_GitConfig_MissingBranches_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"repositoryUrl": "https://github.com/example/repo",
|
||||
"provider": "GitHub"
|
||||
"provider": "GitHub",
|
||||
"triggers": {
|
||||
"onPush": true
|
||||
},
|
||||
"scanOptions": {
|
||||
"analyzers": ["nuget"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -351,8 +410,8 @@ public class SourceConfigValidatorTests
|
||||
var result = _validator.Validate(SbomSourceType.Git, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Warnings.Should().Contain(w => w.Contains("branch configuration"));
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("branches"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0769-M | DONE | Revalidated 2026-01-07 (test project). |
|
||||
| AUDIT-0769-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0769-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| AUDIT-0738-M | DONE | Revalidated 2026-01-12 (test project). |
|
||||
| AUDIT-0738-T | DONE | Revalidated 2026-01-12. |
|
||||
| AUDIT-0738-A | DONE | Applied 2026-01-14. |
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Handlers;
|
||||
using StellaOps.Scanner.Sources.Persistence;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Tests.Triggers;
|
||||
|
||||
public sealed class SourceTriggerDispatcherTests
|
||||
{
|
||||
private static readonly FakeTimeProvider TimeProvider = new(
|
||||
new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
private static readonly JsonDocument MinimalConfig = JsonDocument.Parse("{}");
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public async Task DispatchAsync_QueuesTargetsAndCompletesRun()
|
||||
{
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var source = CreateSource(guidProvider);
|
||||
source.Activate("tester", TimeProvider);
|
||||
|
||||
var sourceRepo = new InMemorySourceRepository();
|
||||
sourceRepo.Add(source);
|
||||
|
||||
var runRepo = new InMemoryRunRepository();
|
||||
var handler = new InMemoryHandler(new[]
|
||||
{
|
||||
ScanTarget.Image("registry.example.com/app:1.0.0"),
|
||||
ScanTarget.Image("registry.example.com/app:1.1.0")
|
||||
});
|
||||
|
||||
var queue = new InMemoryScanJobQueue(guidProvider);
|
||||
|
||||
var dispatcher = new SourceTriggerDispatcher(
|
||||
sourceRepo,
|
||||
runRepo,
|
||||
new[] { handler },
|
||||
queue,
|
||||
NullLogger<SourceTriggerDispatcher>.Instance,
|
||||
TimeProvider,
|
||||
guidProvider);
|
||||
|
||||
var result = await dispatcher.DispatchAsync(
|
||||
source.SourceId,
|
||||
SbomSourceRunTrigger.Manual,
|
||||
"manual",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.JobsQueued.Should().Be(2);
|
||||
result.Run.ItemsDiscovered.Should().Be(2);
|
||||
result.Run.ItemsSucceeded.Should().Be(2);
|
||||
result.Run.Status.Should().Be(SbomSourceRunStatus.Succeeded);
|
||||
queue.Requests.Should().HaveCount(2);
|
||||
runRepo.Runs.Should().ContainKey(result.Run.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public async Task ProcessScheduledSourcesAsync_DispatchesDueSources()
|
||||
{
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var source = CreateSource(guidProvider);
|
||||
source.Activate("tester", TimeProvider);
|
||||
|
||||
var sourceRepo = new InMemorySourceRepository
|
||||
{
|
||||
DueSources = new List<SbomSource> { source }
|
||||
};
|
||||
sourceRepo.Add(source);
|
||||
|
||||
var runRepo = new InMemoryRunRepository();
|
||||
var handler = new InMemoryHandler(new[] { ScanTarget.Image("registry.example.com/app:1.0.0") });
|
||||
var queue = new InMemoryScanJobQueue(guidProvider);
|
||||
|
||||
var dispatcher = new SourceTriggerDispatcher(
|
||||
sourceRepo,
|
||||
runRepo,
|
||||
new[] { handler },
|
||||
queue,
|
||||
NullLogger<SourceTriggerDispatcher>.Instance,
|
||||
TimeProvider,
|
||||
guidProvider);
|
||||
|
||||
var processed = await dispatcher.ProcessScheduledSourcesAsync(
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
processed.Should().Be(1);
|
||||
runRepo.Runs.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public async Task DispatchAsync_DisabledSource_ReturnsFailedRun()
|
||||
{
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var source = CreateSource(guidProvider);
|
||||
source.Disable("tester", TimeProvider);
|
||||
|
||||
var sourceRepo = new InMemorySourceRepository();
|
||||
sourceRepo.Add(source);
|
||||
|
||||
var runRepo = new InMemoryRunRepository();
|
||||
var handler = new InMemoryHandler(Array.Empty<ScanTarget>());
|
||||
var queue = new InMemoryScanJobQueue(guidProvider);
|
||||
|
||||
var dispatcher = new SourceTriggerDispatcher(
|
||||
sourceRepo,
|
||||
runRepo,
|
||||
new[] { handler },
|
||||
queue,
|
||||
NullLogger<SourceTriggerDispatcher>.Instance,
|
||||
TimeProvider,
|
||||
guidProvider);
|
||||
|
||||
var result = await dispatcher.DispatchAsync(
|
||||
source.SourceId,
|
||||
SbomSourceRunTrigger.Manual,
|
||||
"manual",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("disabled");
|
||||
result.Run.Status.Should().Be(SbomSourceRunStatus.Failed);
|
||||
}
|
||||
|
||||
private static SbomSource CreateSource(IGuidProvider guidProvider)
|
||||
{
|
||||
return SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "source-1",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: MinimalConfig,
|
||||
createdBy: "tester",
|
||||
timeProvider: TimeProvider,
|
||||
guidProvider: guidProvider);
|
||||
}
|
||||
|
||||
private sealed class InMemorySourceRepository : ISbomSourceRepository
|
||||
{
|
||||
public Dictionary<Guid, SbomSource> Sources { get; } = new();
|
||||
public IReadOnlyList<SbomSource> DueSources { get; set; } = Array.Empty<SbomSource>();
|
||||
|
||||
public void Add(SbomSource source) => Sources[source.SourceId] = source;
|
||||
|
||||
public Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||
=> Task.FromResult(Sources.TryGetValue(sourceId, out var source) ? source : null);
|
||||
|
||||
public Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default)
|
||||
=> Task.FromResult(Sources.TryGetValue(sourceId, out var source) ? source : null);
|
||||
|
||||
public Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
|
||||
=> Task.FromResult(Sources.Values.FirstOrDefault(s => s.TenantId == tenantId && s.Name == name));
|
||||
|
||||
public Task<PagedResponse<SbomSource>> ListAsync(
|
||||
string tenantId,
|
||||
ListSourcesRequest request,
|
||||
CancellationToken ct = default)
|
||||
=> throw new NotSupportedException("ListAsync is not used in these tests.");
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> GetDueScheduledSourcesAsync(
|
||||
DateTimeOffset asOf,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<SbomSource>>(DueSources.Take(limit).ToList());
|
||||
|
||||
public Task CreateAsync(SbomSource source, CancellationToken ct = default)
|
||||
{
|
||||
Sources[source.SourceId] = source;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(SbomSource source, CancellationToken ct = default)
|
||||
{
|
||||
Sources[source.SourceId] = source;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||
{
|
||||
Sources.Remove(sourceId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> NameExistsAsync(
|
||||
string tenantId,
|
||||
string name,
|
||||
Guid? excludeSourceId = null,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult(Sources.Values.Any(s =>
|
||||
s.TenantId == tenantId
|
||||
&& s.Name == name
|
||||
&& s.SourceId != excludeSourceId));
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> SearchByNameAsync(string name, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<SbomSource>>(Sources.Values
|
||||
.Where(s => s.Name.Contains(name, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList());
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> GetDueForScheduledRunAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(DueSources);
|
||||
}
|
||||
|
||||
private sealed class InMemoryRunRepository : ISbomSourceRunRepository
|
||||
{
|
||||
public Dictionary<Guid, SbomSourceRun> Runs { get; } = new();
|
||||
|
||||
public Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default)
|
||||
=> Task.FromResult(Runs.TryGetValue(runId, out var run) ? run : null);
|
||||
|
||||
public Task<PagedResponse<SbomSourceRun>> ListForSourceAsync(
|
||||
Guid sourceId,
|
||||
ListSourceRunsRequest request,
|
||||
CancellationToken ct = default)
|
||||
=> throw new NotSupportedException("ListForSourceAsync is not used in these tests.");
|
||||
|
||||
public Task CreateAsync(SbomSourceRun run, CancellationToken ct = default)
|
||||
{
|
||||
Runs[run.RunId] = run;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(SbomSourceRun run, CancellationToken ct = default)
|
||||
{
|
||||
Runs[run.RunId] = run;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SbomSourceRun>> GetStaleRunsAsync(
|
||||
TimeSpan olderThan,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<SbomSourceRun>>(Array.Empty<SbomSourceRun>());
|
||||
|
||||
public Task<SourceRunStats> GetStatsAsync(Guid sourceId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new SourceRunStats());
|
||||
}
|
||||
|
||||
private sealed class InMemoryScanJobQueue : IScanJobQueue
|
||||
{
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
public List<ScanJobRequest> Requests { get; } = new();
|
||||
|
||||
public InMemoryScanJobQueue(IGuidProvider guidProvider)
|
||||
{
|
||||
_guidProvider = guidProvider;
|
||||
}
|
||||
|
||||
public Task<Guid> EnqueueAsync(ScanJobRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(_guidProvider.NewGuid());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryHandler : ISourceTypeHandler
|
||||
{
|
||||
private readonly IReadOnlyList<ScanTarget> _targets;
|
||||
|
||||
public InMemoryHandler(IReadOnlyList<ScanTarget> targets)
|
||||
{
|
||||
_targets = targets;
|
||||
}
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Docker;
|
||||
|
||||
public Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult(_targets);
|
||||
|
||||
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
|
||||
=> ConfigValidationResult.Success();
|
||||
|
||||
public Task<ConnectionTestResult> TestConnectionAsync(SbomSource source, CancellationToken ct = default)
|
||||
=> Task.FromResult(ConnectionTestResult.Succeeded(TimeProvider));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user