using System.Collections.Immutable; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Policy.Exceptions.Models; using StellaOps.Policy.Exceptions.Services; using Xunit; using StellaOps.TestKit; namespace StellaOps.Policy.Exceptions.Tests; public sealed class EvidenceRequirementValidatorTests { [Trait("Category", TestCategories.Unit)] [Fact] public async Task ValidateForApprovalAsync_NoHooks_ReturnsValid() { var validator = CreateValidator(new StubHookRegistry([])); var exception = CreateException(); var result = await validator.ValidateForApprovalAsync(exception); result.IsValid.Should().BeTrue(); result.MissingEvidence.Should().BeEmpty(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ValidateForApprovalAsync_MissingEvidence_ReturnsInvalid() { var hooks = ImmutableArray.Create(new EvidenceHook { HookId = "hook-1", Type = EvidenceType.FeatureFlagDisabled, Description = "Feature flag disabled", IsMandatory = true }); var validator = CreateValidator(new StubHookRegistry(hooks)); var exception = CreateException(); var result = await validator.ValidateForApprovalAsync(exception); result.IsValid.Should().BeFalse(); result.MissingEvidence.Should().HaveCount(1); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ValidateForApprovalAsync_TrustScoreTooLow_ReturnsInvalid() { var hooks = ImmutableArray.Create(new EvidenceHook { HookId = "hook-1", Type = EvidenceType.BackportMerged, Description = "Backport merged", IsMandatory = true, MinTrustScore = 0.8m }); var validator = CreateValidator( new StubHookRegistry(hooks), trustScore: 0.5m); var exception = CreateException(new EvidenceRequirements { Hooks = hooks, SubmittedEvidence = ImmutableArray.Create(new SubmittedEvidence { EvidenceId = "e-1", HookId = "hook-1", Type = EvidenceType.BackportMerged, Reference = "ref", SubmittedAt = DateTimeOffset.UtcNow, SubmittedBy = "tester", ValidationStatus = EvidenceValidationStatus.Valid }) }); var result = await validator.ValidateForApprovalAsync(exception); result.IsValid.Should().BeFalse(); result.InvalidEvidence.Should().HaveCount(1); } private static EvidenceRequirementValidator CreateValidator( IEvidenceHookRegistry registry, decimal trustScore = 1.0m, bool schemaValid = true, bool signatureValid = true) { return new EvidenceRequirementValidator( registry, new StubAttestationVerifier(signatureValid), new StubTrustScoreService(trustScore), new StubSchemaValidator(schemaValid), NullLogger.Instance); } private static ExceptionObject CreateException(EvidenceRequirements? requirements = null) { return new ExceptionObject { ExceptionId = "EXC-TEST", Version = 1, Status = ExceptionStatus.Active, Type = ExceptionType.Vulnerability, Scope = new ExceptionScope { VulnerabilityId = "CVE-2024-0001" }, OwnerId = "owner", RequesterId = "requester", CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), ReasonCode = ExceptionReason.AcceptedRisk, Rationale = "This rationale is long enough to satisfy the minimum character requirement.", EvidenceRequirements = requirements }; } private sealed class StubHookRegistry(ImmutableArray hooks) : IEvidenceHookRegistry { public Task> GetRequiredHooksAsync( ExceptionType exceptionType, ExceptionScope scope, CancellationToken ct = default) => Task.FromResult(hooks); } private sealed class StubAttestationVerifier(bool isValid) : IAttestationVerifier { public Task VerifyAsync(string dsseEnvelope, CancellationToken ct = default) => Task.FromResult(new EvidenceVerificationResult(isValid, isValid ? null : "invalid")); } private sealed class StubTrustScoreService(decimal score) : ITrustScoreService { public Task GetScoreAsync(string reference, CancellationToken ct = default) => Task.FromResult(score); } private sealed class StubSchemaValidator(bool isValid) : IEvidenceSchemaValidator { public Task ValidateAsync( string schemaId, string? content, CancellationToken ct = default) => Task.FromResult(new EvidenceSchemaValidationResult(isValid, isValid ? null : "schema invalid")); } }