wip - advisories and ui extensions

This commit is contained in:
StellaOps Bot
2025-12-29 08:39:52 +02:00
parent c2b9cd8d1f
commit 1b61c72c90
56 changed files with 15187 additions and 24 deletions

View File

@@ -0,0 +1,380 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Domain;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.Configuration;
public class SourceConfigValidatorTests
{
private readonly SourceConfigValidator _validator = new(NullLogger<SourceConfigValidator>.Instance);
#region Zastava Configuration Tests
[Fact]
public void Validate_ValidZastavaConfig_ReturnsSuccess()
{
// Arrange
var config = JsonDocument.Parse("""
{
"registryType": "Harbor",
"registryUrl": "https://harbor.example.com",
"filters": {
"repositoryPatterns": ["library/*"]
}
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Zastava, config);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
public void Validate_ZastavaConfig_MissingRegistryType_ReturnsFalure()
{
// Arrange
var config = JsonDocument.Parse("""
{
"registryUrl": "https://harbor.example.com"
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Zastava, config);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("registryType"));
}
[Fact]
public void Validate_ZastavaConfig_InvalidRegistryType_ReturnsFalure()
{
// Arrange
var config = JsonDocument.Parse("""
{
"registryType": "InvalidRegistry",
"registryUrl": "https://harbor.example.com"
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Zastava, config);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Invalid registryType"));
}
[Fact]
public void Validate_ZastavaConfig_MissingRegistryUrl_ReturnsFalure()
{
// Arrange
var config = JsonDocument.Parse("""
{
"registryType": "Harbor"
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Zastava, config);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("registryUrl"));
}
[Fact]
public void Validate_ZastavaConfig_NoFilters_ReturnsWarning()
{
// Arrange
var config = JsonDocument.Parse("""
{
"registryType": "Harbor",
"registryUrl": "https://harbor.example.com"
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Zastava, config);
// Assert
result.IsValid.Should().BeTrue();
result.Warnings.Should().Contain(w => w.Contains("No filters"));
}
#endregion
#region Docker Configuration Tests
[Fact]
public void Validate_ValidDockerConfig_WithImages_ReturnsSuccess()
{
// Arrange
var config = JsonDocument.Parse("""
{
"registryUrl": "https://registry.example.com",
"images": [
{
"repository": "library/nginx",
"tag": "latest"
}
]
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Docker, config);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_ValidDockerConfig_WithDiscovery_ReturnsSuccess()
{
// Arrange
var config = JsonDocument.Parse("""
{
"registryUrl": "https://registry.example.com",
"discoveryOptions": {
"repositoryPattern": "library/*",
"maxTagsPerRepo": 5
}
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Docker, config);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_DockerConfig_NoImagesOrDiscovery_ReturnsFalure()
{
// Arrange
var config = JsonDocument.Parse("""
{
"registryUrl": "https://registry.example.com"
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Docker, config);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("images") || e.Contains("discoveryOptions"));
}
[Fact]
public void Validate_DockerConfig_ImageMissingRepository_ReturnsFalure()
{
// Arrange
var config = JsonDocument.Parse("""
{
"images": [
{
"tag": "latest"
}
]
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Docker, config);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("repository"));
}
#endregion
#region CLI Configuration Tests
[Fact]
public void Validate_ValidCliConfig_ReturnsSuccess()
{
// Arrange
var config = JsonDocument.Parse("""
{
"acceptedFormats": ["CycloneDX", "SPDX"],
"validationRules": {
"requireSignature": false,
"maxFileSizeBytes": 10485760
}
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Cli, config);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_CliConfig_InvalidFormat_ReturnsFalure()
{
// Arrange
var config = JsonDocument.Parse("""
{
"acceptedFormats": ["InvalidFormat"]
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Cli, config);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Invalid SBOM format"));
}
[Fact]
public void Validate_CliConfig_Empty_ReturnsWarning()
{
// Arrange
var config = JsonDocument.Parse("{}");
// Act
var result = _validator.Validate(SbomSourceType.Cli, config);
// Assert
result.IsValid.Should().BeTrue();
result.Warnings.Should().Contain(w => w.Contains("validation rules"));
}
#endregion
#region Git Configuration Tests
[Fact]
public void Validate_ValidGitConfig_HttpsUrl_ReturnsSuccess()
{
// Arrange
var config = JsonDocument.Parse("""
{
"repositoryUrl": "https://github.com/example/repo",
"provider": "GitHub",
"authMethod": "Token",
"branchConfig": {
"defaultBranch": "main"
}
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Git, config);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_ValidGitConfig_SshUrl_ReturnsSuccess()
{
// Arrange
var config = JsonDocument.Parse("""
{
"repositoryUrl": "git@github.com:example/repo.git",
"provider": "GitHub",
"authMethod": "SshKey"
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Git, config);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_GitConfig_MissingRepositoryUrl_ReturnsFalure()
{
// Arrange
var config = JsonDocument.Parse("""
{
"provider": "GitHub"
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Git, config);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("repositoryUrl"));
}
[Fact]
public void Validate_GitConfig_InvalidProvider_ReturnsFalure()
{
// Arrange
var config = JsonDocument.Parse("""
{
"repositoryUrl": "https://github.com/example/repo",
"provider": "InvalidProvider"
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Git, config);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Invalid provider"));
}
[Fact]
public void Validate_GitConfig_NoBranchConfig_ReturnsWarning()
{
// Arrange
var config = JsonDocument.Parse("""
{
"repositoryUrl": "https://github.com/example/repo",
"provider": "GitHub"
}
""");
// Act
var result = _validator.Validate(SbomSourceType.Git, config);
// Assert
result.IsValid.Should().BeTrue();
result.Warnings.Should().Contain(w => w.Contains("branch configuration"));
}
#endregion
#region Schema Tests
[Theory]
[InlineData(SbomSourceType.Zastava)]
[InlineData(SbomSourceType.Docker)]
[InlineData(SbomSourceType.Cli)]
[InlineData(SbomSourceType.Git)]
public void GetConfigurationSchema_ReturnsValidJsonSchema(SbomSourceType sourceType)
{
// Act
var schema = _validator.GetConfigurationSchema(sourceType);
// Assert
schema.Should().NotBeNullOrEmpty();
var parsed = JsonDocument.Parse(schema);
parsed.RootElement.GetProperty("$schema").GetString()
.Should().Contain("json-schema.org");
}
#endregion
}

View File

@@ -0,0 +1,222 @@
using FluentAssertions;
using StellaOps.Scanner.Sources.Domain;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.Domain;
public class SbomSourceRunTests
{
[Fact]
public void Create_WithValidInputs_CreatesRunInPendingStatus()
{
// Arrange
var sourceId = Guid.NewGuid();
var correlationId = Guid.NewGuid().ToString("N");
// Act
var run = SbomSourceRun.Create(
sourceId: sourceId,
tenantId: "tenant-1",
trigger: SbomSourceRunTrigger.Manual,
correlationId: correlationId,
triggerDetails: "Triggered by user");
// Assert
run.RunId.Should().NotBeEmpty();
run.SourceId.Should().Be(sourceId);
run.TenantId.Should().Be("tenant-1");
run.Trigger.Should().Be(SbomSourceRunTrigger.Manual);
run.CorrelationId.Should().Be(correlationId);
run.TriggerDetails.Should().Be("Triggered by user");
run.Status.Should().Be(SbomSourceRunStatus.Pending);
run.ItemsDiscovered.Should().Be(0);
run.ItemsScanned.Should().Be(0);
}
[Fact]
public void Start_SetsStatusToRunning()
{
// Arrange
var run = CreateTestRun();
// Act
run.Start();
// Assert
run.Status.Should().Be(SbomSourceRunStatus.Running);
}
[Fact]
public void SetDiscoveredItems_UpdatesDiscoveryCount()
{
// Arrange
var run = CreateTestRun();
run.Start();
// Act
run.SetDiscoveredItems(10);
// Assert
run.ItemsDiscovered.Should().Be(10);
}
[Fact]
public void RecordItemSuccess_IncrementsCounts()
{
// Arrange
var run = CreateTestRun();
run.Start();
run.SetDiscoveredItems(5);
// Act
var scanJobId = Guid.NewGuid();
run.RecordItemSuccess(scanJobId);
run.RecordItemSuccess(Guid.NewGuid());
// Assert
run.ItemsScanned.Should().Be(2);
run.ItemsSucceeded.Should().Be(2);
run.ScanJobIds.Should().Contain(scanJobId);
}
[Fact]
public void RecordItemFailure_IncrementsCounts()
{
// Arrange
var run = CreateTestRun();
run.Start();
run.SetDiscoveredItems(5);
// Act
run.RecordItemFailure();
run.RecordItemFailure();
// Assert
run.ItemsScanned.Should().Be(2);
run.ItemsFailed.Should().Be(2);
run.ItemsSucceeded.Should().Be(0);
}
[Fact]
public void RecordItemSkipped_IncrementsCounts()
{
// Arrange
var run = CreateTestRun();
run.Start();
run.SetDiscoveredItems(5);
// Act
run.RecordItemSkipped();
// Assert
run.ItemsScanned.Should().Be(1);
run.ItemsSkipped.Should().Be(1);
}
[Fact]
public void Complete_SetsSuccessStatusAndDuration()
{
// Arrange
var run = CreateTestRun();
run.Start();
run.SetDiscoveredItems(3);
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(Guid.NewGuid());
// Act
run.Complete();
// Assert
run.Status.Should().Be(SbomSourceRunStatus.Succeeded);
run.CompletedAt.Should().NotBeNull();
run.DurationMs.Should().BeGreaterOrEqualTo(0);
}
[Fact]
public void Fail_SetsFailedStatusAndErrorMessage()
{
// Arrange
var run = CreateTestRun();
run.Start();
// Act
run.Fail("Connection timeout", new { retries = 3 });
// Assert
run.Status.Should().Be(SbomSourceRunStatus.Failed);
run.ErrorMessage.Should().Be("Connection timeout");
run.ErrorDetails.Should().NotBeNull();
run.CompletedAt.Should().NotBeNull();
}
[Fact]
public void Cancel_SetsCancelledStatus()
{
// Arrange
var run = CreateTestRun();
run.Start();
// Act
run.Cancel();
// Assert
run.Status.Should().Be(SbomSourceRunStatus.Cancelled);
run.CompletedAt.Should().NotBeNull();
}
[Fact]
public void MixedResults_TracksAllCountsCorrectly()
{
// Arrange
var run = CreateTestRun();
run.Start();
run.SetDiscoveredItems(10);
// Act
run.RecordItemSuccess(Guid.NewGuid()); // 1 success
run.RecordItemSuccess(Guid.NewGuid()); // 2 successes
run.RecordItemFailure(); // 1 failure
run.RecordItemSkipped(); // 1 skipped
run.RecordItemSuccess(Guid.NewGuid()); // 3 successes
run.RecordItemFailure(); // 2 failures
// Assert
run.ItemsScanned.Should().Be(6);
run.ItemsSucceeded.Should().Be(3);
run.ItemsFailed.Should().Be(2);
run.ItemsSkipped.Should().Be(1);
run.ScanJobIds.Should().HaveCount(3);
}
[Theory]
[InlineData(SbomSourceRunTrigger.Manual, "Manual trigger")]
[InlineData(SbomSourceRunTrigger.Scheduled, "Cron: 0 * * * *")]
[InlineData(SbomSourceRunTrigger.Webhook, "Harbor push event")]
[InlineData(SbomSourceRunTrigger.Push, "Registry push event")]
public void Create_WithDifferentTriggers_StoresTriggerInfo(
SbomSourceRunTrigger trigger,
string details)
{
// Arrange & Act
var run = SbomSourceRun.Create(
sourceId: Guid.NewGuid(),
tenantId: "tenant-1",
trigger: trigger,
correlationId: Guid.NewGuid().ToString("N"),
triggerDetails: details);
// Assert
run.Trigger.Should().Be(trigger);
run.TriggerDetails.Should().Be(details);
}
private static SbomSourceRun CreateTestRun()
{
return SbomSourceRun.Create(
sourceId: Guid.NewGuid(),
tenantId: "tenant-1",
trigger: SbomSourceRunTrigger.Manual,
correlationId: Guid.NewGuid().ToString("N"));
}
}

View File

@@ -0,0 +1,232 @@
using System.Text.Json;
using FluentAssertions;
using StellaOps.Scanner.Sources.Domain;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.Domain;
public class SbomSourceTests
{
private static readonly JsonDocument SampleConfig = JsonDocument.Parse("""
{
"registryType": "Harbor",
"registryUrl": "https://harbor.example.com"
}
""");
[Fact]
public void Create_WithValidInputs_CreatesSourceInDraftStatus()
{
// Arrange & Act
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "test-source",
sourceType: SbomSourceType.Zastava,
configuration: SampleConfig,
createdBy: "user-1");
// Assert
source.SourceId.Should().NotBeEmpty();
source.TenantId.Should().Be("tenant-1");
source.Name.Should().Be("test-source");
source.SourceType.Should().Be(SbomSourceType.Zastava);
source.Status.Should().Be(SbomSourceStatus.Draft);
source.CreatedBy.Should().Be("user-1");
source.Paused.Should().BeFalse();
source.ConsecutiveFailures.Should().Be(0);
}
[Fact]
public void Create_WithCronSchedule_CalculatesNextScheduledRun()
{
// Arrange & Act
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "scheduled-source",
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
cronSchedule: "0 * * * *"); // Every hour
// Assert
source.CronSchedule.Should().Be("0 * * * *");
source.NextScheduledRun.Should().NotBeNull();
source.NextScheduledRun.Should().BeAfter(DateTimeOffset.UtcNow);
}
[Fact]
public void Create_WithZastavaType_GeneratesWebhookEndpointAndSecret()
{
// Arrange & Act
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "webhook-source",
sourceType: SbomSourceType.Zastava,
configuration: SampleConfig,
createdBy: "user-1");
// Assert
source.WebhookEndpoint.Should().NotBeNullOrEmpty();
source.WebhookSecret.Should().NotBeNullOrEmpty();
source.WebhookSecret!.Length.Should().BeGreaterOrEqualTo(32);
}
[Fact]
public void Activate_FromDraft_ChangesStatusToActive()
{
// Arrange
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "test-source",
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1");
// Act
source.Activate("activator");
// Assert
source.Status.Should().Be(SbomSourceStatus.Active);
source.UpdatedBy.Should().Be("activator");
}
[Fact]
public void Pause_WhenActive_PausesSource()
{
// Arrange
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "test-source",
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1");
source.Activate("activator");
// Act
source.Pause("Maintenance window", "TICKET-123", "operator");
// Assert
source.Paused.Should().BeTrue();
source.PauseReason.Should().Be("Maintenance window");
source.PauseTicket.Should().Be("TICKET-123");
source.PausedAt.Should().NotBeNull();
}
[Fact]
public void Resume_WhenPaused_UnpausesSource()
{
// Arrange
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "test-source",
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1");
source.Activate("activator");
source.Pause("Maintenance", null, "operator");
// Act
source.Resume("operator");
// Assert
source.Paused.Should().BeFalse();
source.PauseReason.Should().BeNull();
source.PausedAt.Should().BeNull();
}
[Fact]
public void RecordSuccessfulRun_ResetsConsecutiveFailures()
{
// Arrange
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "test-source",
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1");
source.Activate("activator");
// Simulate some failures
source.RecordFailedRun("Error 1");
source.RecordFailedRun("Error 2");
source.ConsecutiveFailures.Should().Be(2);
// Act
source.RecordSuccessfulRun();
// Assert
source.ConsecutiveFailures.Should().Be(0);
source.LastRunStatus.Should().Be(SbomSourceRunStatus.Succeeded);
source.LastRunError.Should().BeNull();
}
[Fact]
public void RecordFailedRun_MultipleTimes_MovesToErrorStatus()
{
// Arrange
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "test-source",
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1");
source.Activate("activator");
// Act - fail 5 times (threshold is 5)
for (var i = 0; i < 5; i++)
{
source.RecordFailedRun($"Error {i + 1}");
}
// Assert
source.Status.Should().Be(SbomSourceStatus.Error);
source.ConsecutiveFailures.Should().Be(5);
}
[Fact]
public void IsRateLimited_WhenUnderLimit_ReturnsFalse()
{
// Arrange
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "test-source",
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1");
source.MaxScansPerHour = 10;
source.Activate("activator");
// Act
var isLimited = source.IsRateLimited();
// Assert
isLimited.Should().BeFalse();
}
[Fact]
public void UpdateConfiguration_ChangesConfigAndUpdatesTimestamp()
{
// Arrange
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "test-source",
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1");
var newConfig = JsonDocument.Parse("""
{
"registryType": "DockerHub",
"registryUrl": "https://registry-1.docker.io"
}
""");
// Act
source.UpdateConfiguration(newConfig, "updater");
// Assert
source.Configuration.RootElement.GetProperty("registryType").GetString()
.Should().Be("DockerHub");
source.UpdatedBy.Should().Be("updater");
}
}

View File

@@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
</Project>