Refactor code structure and optimize performance across multiple modules
This commit is contained in:
@@ -0,0 +1,698 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExceptionApprovalRulesService.cs
|
||||
// Sprint: SPRINT_20251226_003_BE_exception_approval
|
||||
// Task: EXCEPT-03 - Approval rules engine (G1/G2/G3+ levels)
|
||||
// Description: Service for validating exception approval requests against
|
||||
// gate-level rules and determining approval requirements
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Approval requirements for a gate level.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRequirements
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate level these requirements apply to.
|
||||
/// </summary>
|
||||
public required GateLevel GateLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of approvers required.
|
||||
/// </summary>
|
||||
public int MinApprovers { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Required approver roles (any of these can approve for their slot).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> RequiredRoles { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maximum TTL allowed in days.
|
||||
/// </summary>
|
||||
public int MaxTtlDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether self-approval is allowed.
|
||||
/// </summary>
|
||||
public bool AllowSelfApproval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether evidence is required.
|
||||
/// </summary>
|
||||
public bool RequireEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether compensating controls are required.
|
||||
/// </summary>
|
||||
public bool RequireCompensatingControls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum rationale length.
|
||||
/// </summary>
|
||||
public int MinRationaleLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates default requirements for a gate level.
|
||||
/// </summary>
|
||||
public static ApprovalRequirements GetDefault(GateLevel level) => level switch
|
||||
{
|
||||
GateLevel.G0 => new ApprovalRequirements
|
||||
{
|
||||
GateLevel = level,
|
||||
MinApprovers = 0,
|
||||
MaxTtlDays = 90,
|
||||
AllowSelfApproval = true,
|
||||
RequireEvidence = false,
|
||||
MinRationaleLength = 0
|
||||
},
|
||||
GateLevel.G1 => new ApprovalRequirements
|
||||
{
|
||||
GateLevel = level,
|
||||
MinApprovers = 1,
|
||||
MaxTtlDays = 60,
|
||||
AllowSelfApproval = true,
|
||||
RequireEvidence = false,
|
||||
MinRationaleLength = 20
|
||||
},
|
||||
GateLevel.G2 => new ApprovalRequirements
|
||||
{
|
||||
GateLevel = level,
|
||||
MinApprovers = 1,
|
||||
RequiredRoles = ["code-owner"],
|
||||
MaxTtlDays = 30,
|
||||
AllowSelfApproval = false,
|
||||
RequireEvidence = true,
|
||||
MinRationaleLength = 50
|
||||
},
|
||||
GateLevel.G3 => new ApprovalRequirements
|
||||
{
|
||||
GateLevel = level,
|
||||
MinApprovers = 2,
|
||||
RequiredRoles = ["delivery-manager", "product-manager"],
|
||||
MaxTtlDays = 14,
|
||||
AllowSelfApproval = false,
|
||||
RequireEvidence = true,
|
||||
RequireCompensatingControls = true,
|
||||
MinRationaleLength = 100
|
||||
},
|
||||
GateLevel.G4 => new ApprovalRequirements
|
||||
{
|
||||
GateLevel = level,
|
||||
MinApprovers = 3,
|
||||
RequiredRoles = ["ciso", "delivery-manager", "product-manager"],
|
||||
MaxTtlDays = 7,
|
||||
AllowSelfApproval = false,
|
||||
RequireEvidence = true,
|
||||
RequireCompensatingControls = true,
|
||||
MinRationaleLength = 200
|
||||
},
|
||||
_ => new ApprovalRequirements
|
||||
{
|
||||
GateLevel = level,
|
||||
MinApprovers = 1,
|
||||
MaxTtlDays = 30
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation result for an approval request.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRequestValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the request is valid.
|
||||
/// </summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors (if any).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Warnings (non-blocking issues).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The determined gate level.
|
||||
/// </summary>
|
||||
public GateLevel DeterminedGateLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The requirements for this gate level.
|
||||
/// </summary>
|
||||
public ApprovalRequirements? Requirements { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Success result.
|
||||
/// </summary>
|
||||
public static ApprovalRequestValidationResult Success(
|
||||
GateLevel level,
|
||||
ApprovalRequirements requirements) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
DeterminedGateLevel = level,
|
||||
Requirements = requirements
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Failure result.
|
||||
/// </summary>
|
||||
public static ApprovalRequestValidationResult Failure(params string[] errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation result for an approval action.
|
||||
/// </summary>
|
||||
public sealed record ApprovalActionValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the action is valid.
|
||||
/// </summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors (if any).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether this approval completes the workflow.
|
||||
/// </summary>
|
||||
public bool CompletesWorkflow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Remaining approvers needed after this action.
|
||||
/// </summary>
|
||||
public int RemainingApproversNeeded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Roles that still need to approve.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> RemainingRequiredRoles { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Success result.
|
||||
/// </summary>
|
||||
public static ApprovalActionValidationResult Success(
|
||||
bool completesWorkflow,
|
||||
int remainingApprovers = 0,
|
||||
IReadOnlyList<string>? remainingRoles = null) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
CompletesWorkflow = completesWorkflow,
|
||||
RemainingApproversNeeded = remainingApprovers,
|
||||
RemainingRequiredRoles = remainingRoles ?? []
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Failure result.
|
||||
/// </summary>
|
||||
public static ApprovalActionValidationResult Failure(params string[] errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for exception approval rules engine.
|
||||
/// </summary>
|
||||
public interface IExceptionApprovalRulesService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets approval requirements for a gate level.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="gateLevel">The gate level.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Approval requirements for the gate level.</returns>
|
||||
Task<ApprovalRequirements> GetRequirementsAsync(
|
||||
string tenantId,
|
||||
GateLevel gateLevel,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates an approval request against the rules.
|
||||
/// </summary>
|
||||
/// <param name="request">The approval request to validate.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
Task<ApprovalRequestValidationResult> ValidateRequestAsync(
|
||||
ExceptionApprovalRequestEntity request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates an approval action.
|
||||
/// </summary>
|
||||
/// <param name="request">The approval request being acted upon.</param>
|
||||
/// <param name="approverId">The approver performing the action.</param>
|
||||
/// <param name="approverRoles">Roles of the approver.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
Task<ApprovalActionValidationResult> ValidateApprovalActionAsync(
|
||||
ExceptionApprovalRequestEntity request,
|
||||
string approverId,
|
||||
IReadOnlySet<string> approverRoles,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a request can be auto-approved based on gate level.
|
||||
/// </summary>
|
||||
/// <param name="request">The approval request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if auto-approval is allowed.</returns>
|
||||
Task<bool> CanAutoApproveAsync(
|
||||
ExceptionApprovalRequestEntity request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Determines required approvers for a request based on gate level and rules.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="gateLevel">The gate level.</param>
|
||||
/// <param name="requestorId">The requestor (excluded from approvers if self-approval not allowed).</param>
|
||||
/// <param name="availableApprovers">Available approvers with their roles.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of suggested approver IDs.</returns>
|
||||
Task<IReadOnlyList<string>> DetermineRequiredApproversAsync(
|
||||
string tenantId,
|
||||
GateLevel gateLevel,
|
||||
string requestorId,
|
||||
IReadOnlyDictionary<string, IReadOnlySet<string>> availableApprovers,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for approval rules.
|
||||
/// </summary>
|
||||
public sealed class ExceptionApprovalRulesOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:ExceptionApproval";
|
||||
|
||||
/// <summary>
|
||||
/// Default request expiry in days (how long a request can remain pending).
|
||||
/// </summary>
|
||||
public int DefaultRequestExpiryDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use tenant-specific rules (falling back to defaults if not found).
|
||||
/// </summary>
|
||||
public bool UseTenantRules { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum justification length for any request.
|
||||
/// </summary>
|
||||
public int MinJustificationLength { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of exception approval rules engine.
|
||||
/// </summary>
|
||||
public sealed class ExceptionApprovalRulesService : IExceptionApprovalRulesService
|
||||
{
|
||||
private readonly IExceptionApprovalRepository _repository;
|
||||
private readonly ExceptionApprovalRulesOptions _options;
|
||||
private readonly ILogger<ExceptionApprovalRulesService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ExceptionApprovalRulesService(
|
||||
IExceptionApprovalRepository repository,
|
||||
IOptions<ExceptionApprovalRulesOptions>? options,
|
||||
ILogger<ExceptionApprovalRulesService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_repository = repository;
|
||||
_options = options?.Value ?? new ExceptionApprovalRulesOptions();
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApprovalRequirements> GetRequirementsAsync(
|
||||
string tenantId,
|
||||
GateLevel gateLevel,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_options.UseTenantRules)
|
||||
{
|
||||
var rule = await _repository.GetApprovalRuleAsync(tenantId, gateLevel, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (rule is not null && rule.Enabled)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Using tenant-specific approval rule for tenant {TenantId}, gate level {GateLevel}",
|
||||
tenantId,
|
||||
gateLevel);
|
||||
|
||||
return MapRuleToRequirements(rule);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Using default approval requirements for gate level {GateLevel}",
|
||||
gateLevel);
|
||||
|
||||
return ApprovalRequirements.GetDefault(gateLevel);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApprovalRequestValidationResult> ValidateRequestAsync(
|
||||
ExceptionApprovalRequestEntity request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
var requirements = await GetRequirementsAsync(
|
||||
request.TenantId,
|
||||
request.GateLevel,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Validate justification length
|
||||
if (string.IsNullOrWhiteSpace(request.Justification))
|
||||
{
|
||||
errors.Add("Justification is required.");
|
||||
}
|
||||
else if (request.Justification.Length < _options.MinJustificationLength)
|
||||
{
|
||||
errors.Add($"Justification must be at least {_options.MinJustificationLength} characters.");
|
||||
}
|
||||
|
||||
// Validate rationale for higher gate levels
|
||||
if (requirements.MinRationaleLength > 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Rationale))
|
||||
{
|
||||
errors.Add($"Rationale is required for gate level {request.GateLevel}.");
|
||||
}
|
||||
else if (request.Rationale.Length < requirements.MinRationaleLength)
|
||||
{
|
||||
errors.Add($"Rationale must be at least {requirements.MinRationaleLength} characters for gate level {request.GateLevel}.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate evidence
|
||||
if (requirements.RequireEvidence)
|
||||
{
|
||||
var evidenceRefs = ParseJsonArray(request.EvidenceRefs);
|
||||
if (evidenceRefs.Count == 0)
|
||||
{
|
||||
errors.Add($"Evidence is required for gate level {request.GateLevel}.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate compensating controls
|
||||
if (requirements.RequireCompensatingControls)
|
||||
{
|
||||
var controls = ParseJsonArray(request.CompensatingControls);
|
||||
if (controls.Count == 0)
|
||||
{
|
||||
errors.Add($"Compensating controls are required for gate level {request.GateLevel}.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate TTL
|
||||
if (request.RequestedTtlDays > requirements.MaxTtlDays)
|
||||
{
|
||||
errors.Add($"Requested TTL ({request.RequestedTtlDays} days) exceeds maximum allowed ({requirements.MaxTtlDays} days) for gate level {request.GateLevel}.");
|
||||
}
|
||||
|
||||
// Validate required approvers
|
||||
if (request.RequiredApproverIds.Length < requirements.MinApprovers)
|
||||
{
|
||||
errors.Add($"At least {requirements.MinApprovers} approver(s) required for gate level {request.GateLevel}.");
|
||||
}
|
||||
|
||||
// Check for scope - at least one scope constraint must be specified
|
||||
if (string.IsNullOrWhiteSpace(request.VulnerabilityId) &&
|
||||
string.IsNullOrWhiteSpace(request.PurlPattern) &&
|
||||
string.IsNullOrWhiteSpace(request.ArtifactDigest) &&
|
||||
string.IsNullOrWhiteSpace(request.ImagePattern))
|
||||
{
|
||||
warnings.Add("No scope constraints specified. Exception will apply broadly.");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return new ApprovalRequestValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = errors,
|
||||
Warnings = warnings,
|
||||
DeterminedGateLevel = request.GateLevel
|
||||
};
|
||||
}
|
||||
|
||||
return new ApprovalRequestValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Warnings = warnings,
|
||||
DeterminedGateLevel = request.GateLevel,
|
||||
Requirements = requirements
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApprovalActionValidationResult> ValidateApprovalActionAsync(
|
||||
ExceptionApprovalRequestEntity request,
|
||||
string approverId,
|
||||
IReadOnlySet<string> approverRoles,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(approverId);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
var requirements = await GetRequirementsAsync(
|
||||
request.TenantId,
|
||||
request.GateLevel,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check if request is in a valid state for approval
|
||||
if (request.Status != ApprovalRequestStatus.Pending &&
|
||||
request.Status != ApprovalRequestStatus.Partial)
|
||||
{
|
||||
errors.Add($"Request is in {request.Status} status and cannot be approved.");
|
||||
return ApprovalActionValidationResult.Failure([.. errors]);
|
||||
}
|
||||
|
||||
// Check if already approved by this approver
|
||||
if (request.ApprovedByIds.Contains(approverId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add("You have already approved this request.");
|
||||
return ApprovalActionValidationResult.Failure([.. errors]);
|
||||
}
|
||||
|
||||
// Check self-approval
|
||||
if (!requirements.AllowSelfApproval &&
|
||||
string.Equals(request.RequestorId, approverId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Self-approval is not allowed for gate level {request.GateLevel}.");
|
||||
return ApprovalActionValidationResult.Failure([.. errors]);
|
||||
}
|
||||
|
||||
// Check required approvers list
|
||||
if (request.RequiredApproverIds.Length > 0 &&
|
||||
!request.RequiredApproverIds.Contains(approverId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add("You are not in the required approvers list for this request.");
|
||||
return ApprovalActionValidationResult.Failure([.. errors]);
|
||||
}
|
||||
|
||||
// Check required roles
|
||||
var satisfiedRoles = new List<string>();
|
||||
var remainingRoles = new List<string>();
|
||||
|
||||
if (requirements.RequiredRoles.Count > 0)
|
||||
{
|
||||
// Get roles already satisfied by previous approvers
|
||||
// For simplicity, we track which roles the current approver satisfies
|
||||
foreach (var requiredRole in requirements.RequiredRoles)
|
||||
{
|
||||
if (approverRoles.Contains(requiredRole))
|
||||
{
|
||||
satisfiedRoles.Add(requiredRole);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if this role was satisfied by a previous approver
|
||||
// This would require additional context - for now, just track remaining
|
||||
remainingRoles.Add(requiredRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate remaining approvers needed
|
||||
var approvedCount = request.ApprovedByIds.Length + 1; // +1 for current approver
|
||||
var remainingApprovers = Math.Max(0, requirements.MinApprovers - approvedCount);
|
||||
var completesWorkflow = remainingApprovers == 0 && remainingRoles.Count == 0;
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return ApprovalActionValidationResult.Failure([.. errors]);
|
||||
}
|
||||
|
||||
return ApprovalActionValidationResult.Success(
|
||||
completesWorkflow,
|
||||
remainingApprovers,
|
||||
remainingRoles);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> CanAutoApproveAsync(
|
||||
ExceptionApprovalRequestEntity request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var requirements = await GetRequirementsAsync(
|
||||
request.TenantId,
|
||||
request.GateLevel,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Auto-approve if no approvers required (G0 level typically)
|
||||
if (requirements.MinApprovers == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Request {RequestId} can be auto-approved (gate level {GateLevel} requires 0 approvers)",
|
||||
request.RequestId,
|
||||
request.GateLevel);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> DetermineRequiredApproversAsync(
|
||||
string tenantId,
|
||||
GateLevel gateLevel,
|
||||
string requestorId,
|
||||
IReadOnlyDictionary<string, IReadOnlySet<string>> availableApprovers,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requirements = await GetRequirementsAsync(tenantId, gateLevel, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (requirements.MinApprovers == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var selectedApprovers = new List<string>();
|
||||
var requiredRolesCovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// First pass: select approvers that cover required roles
|
||||
foreach (var requiredRole in requirements.RequiredRoles)
|
||||
{
|
||||
if (requiredRolesCovered.Contains(requiredRole))
|
||||
continue;
|
||||
|
||||
foreach (var (approverId, roles) in availableApprovers)
|
||||
{
|
||||
// Skip requestor if self-approval not allowed
|
||||
if (!requirements.AllowSelfApproval &&
|
||||
string.Equals(approverId, requestorId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already selected
|
||||
if (selectedApprovers.Contains(approverId, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (roles.Contains(requiredRole))
|
||||
{
|
||||
selectedApprovers.Add(approverId);
|
||||
// Mark all roles this approver covers
|
||||
foreach (var role in roles)
|
||||
{
|
||||
requiredRolesCovered.Add(role);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: add more approvers if minimum not met
|
||||
while (selectedApprovers.Count < requirements.MinApprovers)
|
||||
{
|
||||
var addedApprover = false;
|
||||
foreach (var (approverId, _) in availableApprovers)
|
||||
{
|
||||
if (!requirements.AllowSelfApproval &&
|
||||
string.Equals(approverId, requestorId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!selectedApprovers.Contains(approverId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
selectedApprovers.Add(approverId);
|
||||
addedApprover = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No more approvers available
|
||||
if (!addedApprover)
|
||||
break;
|
||||
}
|
||||
|
||||
return selectedApprovers;
|
||||
}
|
||||
|
||||
private static ApprovalRequirements MapRuleToRequirements(ExceptionApprovalRuleEntity rule)
|
||||
{
|
||||
return new ApprovalRequirements
|
||||
{
|
||||
GateLevel = rule.GateLevel,
|
||||
MinApprovers = rule.MinApprovers,
|
||||
RequiredRoles = rule.RequiredRoles,
|
||||
MaxTtlDays = rule.MaxTtlDays,
|
||||
AllowSelfApproval = rule.AllowSelfApproval,
|
||||
RequireEvidence = rule.RequireEvidence,
|
||||
RequireCompensatingControls = rule.RequireCompensatingControls,
|
||||
MinRationaleLength = rule.MinRationaleLength
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseJsonArray(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "[]")
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20251226_003_BE_exception_approval
|
||||
// Task: EXCEPT-05, EXCEPT-06, EXCEPT-07 - Exception approval API endpoints
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Exception approval workflow API endpoints.
|
||||
/// </summary>
|
||||
public static class ExceptionApprovalEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps exception approval endpoints to the application.
|
||||
/// </summary>
|
||||
public static void MapExceptionApprovalEndpoints(this WebApplication app)
|
||||
{
|
||||
var exceptions = app.MapGroup("/api/v1/policy/exception")
|
||||
.WithTags("Exception Approvals");
|
||||
|
||||
// POST /api/v1/policy/exception/request - Create a new exception approval request
|
||||
exceptions.MapPost("/request", CreateApprovalRequestAsync)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRequest))
|
||||
.WithName("CreateExceptionApprovalRequest")
|
||||
.WithDescription("Create a new exception approval request");
|
||||
|
||||
// GET /api/v1/policy/exception/request/{requestId} - Get an approval request
|
||||
exceptions.MapGet("/request/{requestId}", GetApprovalRequestAsync)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
|
||||
.WithName("GetExceptionApprovalRequest")
|
||||
.WithDescription("Get an exception approval request by ID");
|
||||
|
||||
// GET /api/v1/policy/exception/requests - List approval requests
|
||||
exceptions.MapGet("/requests", ListApprovalRequestsAsync)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
|
||||
.WithName("ListExceptionApprovalRequests")
|
||||
.WithDescription("List exception approval requests for the tenant");
|
||||
|
||||
// GET /api/v1/policy/exception/pending - List pending approvals for current user
|
||||
exceptions.MapGet("/pending", ListPendingApprovalsAsync)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsApprove))
|
||||
.WithName("ListPendingApprovals")
|
||||
.WithDescription("List pending exception approvals for the current user");
|
||||
|
||||
// POST /api/v1/policy/exception/{requestId}/approve - Approve an exception request
|
||||
exceptions.MapPost("/{requestId}/approve", ApproveRequestAsync)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsApprove))
|
||||
.WithName("ApproveExceptionRequest")
|
||||
.WithDescription("Approve an exception request");
|
||||
|
||||
// POST /api/v1/policy/exception/{requestId}/reject - Reject an exception request
|
||||
exceptions.MapPost("/{requestId}/reject", RejectRequestAsync)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsApprove))
|
||||
.WithName("RejectExceptionRequest")
|
||||
.WithDescription("Reject an exception request with a reason");
|
||||
|
||||
// POST /api/v1/policy/exception/{requestId}/cancel - Cancel an exception request
|
||||
exceptions.MapPost("/{requestId}/cancel", CancelRequestAsync)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRequest))
|
||||
.WithName("CancelExceptionRequest")
|
||||
.WithDescription("Cancel an exception request (requestor only)");
|
||||
|
||||
// GET /api/v1/policy/exception/{requestId}/audit - Get audit trail for a request
|
||||
exceptions.MapGet("/{requestId}/audit", GetAuditTrailAsync)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
|
||||
.WithName("GetExceptionApprovalAudit")
|
||||
.WithDescription("Get the audit trail for an exception approval request");
|
||||
|
||||
// GET /api/v1/policy/exception/rules - Get approval rules for the tenant
|
||||
exceptions.MapGet("/rules", GetApprovalRulesAsync)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
|
||||
.WithName("GetExceptionApprovalRules")
|
||||
.WithDescription("Get exception approval rules for the tenant");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Endpoint Handlers
|
||||
// ========================================================================
|
||||
|
||||
private static async Task<IResult> CreateApprovalRequestAsync(
|
||||
HttpContext httpContext,
|
||||
CreateApprovalRequestDto request,
|
||||
IExceptionApprovalRepository repository,
|
||||
IExceptionApprovalRulesService rulesService,
|
||||
ILogger<ExceptionApprovalRequestEntity> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required",
|
||||
Status = 400
|
||||
});
|
||||
}
|
||||
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var requestorId = GetActorId(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(requestorId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
// Generate request ID
|
||||
var requestId = $"EAR-{DateTimeOffset.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
|
||||
// Parse gate level
|
||||
if (!Enum.TryParse<GateLevel>(request.GateLevel, ignoreCase: true, out var gateLevel))
|
||||
{
|
||||
gateLevel = GateLevel.G1; // Default to G1 if not specified
|
||||
}
|
||||
|
||||
// Parse reason code
|
||||
if (!Enum.TryParse<ExceptionReasonCode>(request.ReasonCode, ignoreCase: true, out var reasonCode))
|
||||
{
|
||||
reasonCode = ExceptionReasonCode.Other;
|
||||
}
|
||||
|
||||
// Get requirements for validation
|
||||
var requirements = await rulesService.GetRequirementsAsync(tenantId, gateLevel, cancellationToken);
|
||||
|
||||
// Validate TTL
|
||||
var requestedTtl = request.RequestedTtlDays ?? 30;
|
||||
if (requestedTtl > requirements.MaxTtlDays)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid TTL",
|
||||
Status = 400,
|
||||
Detail = $"Requested TTL ({requestedTtl} days) exceeds maximum allowed ({requirements.MaxTtlDays} days) for gate level {gateLevel}."
|
||||
});
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entity = new ExceptionApprovalRequestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RequestId = requestId,
|
||||
TenantId = tenantId,
|
||||
ExceptionId = request.ExceptionId,
|
||||
RequestorId = requestorId,
|
||||
RequiredApproverIds = request.RequiredApproverIds?.ToArray() ?? [],
|
||||
ApprovedByIds = [],
|
||||
Status = ApprovalRequestStatus.Pending,
|
||||
GateLevel = gateLevel,
|
||||
Justification = request.Justification ?? string.Empty,
|
||||
Rationale = request.Rationale,
|
||||
ReasonCode = reasonCode,
|
||||
EvidenceRefs = JsonSerializer.Serialize(request.EvidenceRefs ?? []),
|
||||
CompensatingControls = JsonSerializer.Serialize(request.CompensatingControls ?? []),
|
||||
TicketRef = request.TicketRef,
|
||||
VulnerabilityId = request.VulnerabilityId,
|
||||
PurlPattern = request.PurlPattern,
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
ImagePattern = request.ImagePattern,
|
||||
Environments = request.Environments?.ToArray() ?? [],
|
||||
RequestedTtlDays = requestedTtl,
|
||||
CreatedAt = now,
|
||||
RequestExpiresAt = now.AddDays(7), // 7-day approval window
|
||||
ExceptionExpiresAt = now.AddDays(requestedTtl),
|
||||
Metadata = request.Metadata is not null ? JsonSerializer.Serialize(request.Metadata) : "{}",
|
||||
Version = 1,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
// Validate request
|
||||
var validation = await rulesService.ValidateRequestAsync(entity, cancellationToken);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Validation failed",
|
||||
Status = 400,
|
||||
Detail = string.Join("; ", validation.Errors)
|
||||
});
|
||||
}
|
||||
|
||||
// Check if auto-approve is allowed
|
||||
if (await rulesService.CanAutoApproveAsync(entity, cancellationToken))
|
||||
{
|
||||
entity = entity with
|
||||
{
|
||||
Status = ApprovalRequestStatus.Approved,
|
||||
ResolvedAt = now
|
||||
};
|
||||
|
||||
logger.LogInformation(
|
||||
"Exception request {RequestId} auto-approved (gate level {GateLevel})",
|
||||
requestId,
|
||||
gateLevel);
|
||||
}
|
||||
|
||||
// Create the request
|
||||
var created = await repository.CreateRequestAsync(entity, cancellationToken);
|
||||
|
||||
// Record audit entry
|
||||
await repository.RecordAuditAsync(new ExceptionApprovalAuditEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RequestId = requestId,
|
||||
TenantId = tenantId,
|
||||
SequenceNumber = 1,
|
||||
ActionType = "requested",
|
||||
ActorId = requestorId,
|
||||
OccurredAt = now,
|
||||
PreviousStatus = null,
|
||||
NewStatus = created.Status.ToString().ToLowerInvariant(),
|
||||
Description = $"Exception approval request created for {request.VulnerabilityId ?? request.PurlPattern ?? "general exception"}",
|
||||
Details = JsonSerializer.Serialize(new { gateLevel = gateLevel.ToString(), reasonCode = reasonCode.ToString() }),
|
||||
ClientInfo = BuildClientInfo(httpContext)
|
||||
}, cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Exception approval request created: {RequestId}, GateLevel={GateLevel}, Requestor={Requestor}",
|
||||
requestId,
|
||||
gateLevel,
|
||||
requestorId);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/policy/exception/request/{requestId}",
|
||||
MapToDto(created, validation.Warnings));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetApprovalRequestAsync(
|
||||
HttpContext httpContext,
|
||||
string requestId,
|
||||
IExceptionApprovalRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var request = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
|
||||
if (request is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Request not found",
|
||||
Status = 404,
|
||||
Detail = $"Exception approval request '{requestId}' not found."
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(MapToDto(request));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListApprovalRequestsAsync(
|
||||
HttpContext httpContext,
|
||||
IExceptionApprovalRepository repository,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int limit = 100,
|
||||
[FromQuery] int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
ApprovalRequestStatus? statusFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(status) &&
|
||||
Enum.TryParse<ApprovalRequestStatus>(status, ignoreCase: true, out var parsed))
|
||||
{
|
||||
statusFilter = parsed;
|
||||
}
|
||||
|
||||
var requests = await repository.ListRequestsAsync(
|
||||
tenantId,
|
||||
statusFilter,
|
||||
Math.Min(limit, 500),
|
||||
Math.Max(offset, 0),
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new ApprovalRequestListResponse
|
||||
{
|
||||
Items = requests.Select(r => MapToSummaryDto(r)).ToList(),
|
||||
Limit = limit,
|
||||
Offset = offset
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPendingApprovalsAsync(
|
||||
HttpContext httpContext,
|
||||
IExceptionApprovalRepository repository,
|
||||
[FromQuery] int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var approverId = GetActorId(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(approverId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var requests = await repository.ListPendingForApproverAsync(
|
||||
tenantId,
|
||||
approverId,
|
||||
Math.Min(limit, 500),
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new ApprovalRequestListResponse
|
||||
{
|
||||
Items = requests.Select(r => MapToSummaryDto(r)).ToList(),
|
||||
Limit = limit,
|
||||
Offset = 0
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> ApproveRequestAsync(
|
||||
HttpContext httpContext,
|
||||
string requestId,
|
||||
ApproveRequestDto? request,
|
||||
IExceptionApprovalRepository repository,
|
||||
IExceptionApprovalRulesService rulesService,
|
||||
ILogger<ExceptionApprovalRequestEntity> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var approverId = GetActorId(httpContext);
|
||||
var approverRoles = GetActorRoles(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(approverId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var existing = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Request not found",
|
||||
Status = 404,
|
||||
Detail = $"Exception approval request '{requestId}' not found."
|
||||
});
|
||||
}
|
||||
|
||||
// Validate approval action
|
||||
var validation = await rulesService.ValidateApprovalActionAsync(
|
||||
existing,
|
||||
approverId,
|
||||
approverRoles,
|
||||
cancellationToken);
|
||||
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Approval not allowed",
|
||||
Status = 400,
|
||||
Detail = string.Join("; ", validation.Errors)
|
||||
});
|
||||
}
|
||||
|
||||
var approved = await repository.ApproveAsync(
|
||||
tenantId,
|
||||
requestId,
|
||||
approverId,
|
||||
request?.Comment,
|
||||
cancellationToken);
|
||||
|
||||
if (approved is null)
|
||||
{
|
||||
return Results.Problem(new ProblemDetails
|
||||
{
|
||||
Title = "Approval failed",
|
||||
Status = 500,
|
||||
Detail = "Failed to record approval. The request may have been modified."
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Exception request {RequestId} approved by {ApproverId}, CompletesWorkflow={Completes}",
|
||||
requestId,
|
||||
approverId,
|
||||
validation.CompletesWorkflow);
|
||||
|
||||
return Results.Ok(MapToDto(approved));
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectRequestAsync(
|
||||
HttpContext httpContext,
|
||||
string requestId,
|
||||
RejectRequestDto request,
|
||||
IExceptionApprovalRepository repository,
|
||||
ILogger<ExceptionApprovalRequestEntity> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Rejection reason required",
|
||||
Status = 400,
|
||||
Detail = "A reason is required when rejecting an exception request."
|
||||
});
|
||||
}
|
||||
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var rejectorId = GetActorId(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(rejectorId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var existing = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Request not found",
|
||||
Status = 404,
|
||||
Detail = $"Exception approval request '{requestId}' not found."
|
||||
});
|
||||
}
|
||||
|
||||
if (existing.Status != ApprovalRequestStatus.Pending &&
|
||||
existing.Status != ApprovalRequestStatus.Partial)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Cannot reject",
|
||||
Status = 400,
|
||||
Detail = $"Request is in {existing.Status} status and cannot be rejected."
|
||||
});
|
||||
}
|
||||
|
||||
var rejected = await repository.RejectAsync(
|
||||
tenantId,
|
||||
requestId,
|
||||
rejectorId,
|
||||
request.Reason,
|
||||
cancellationToken);
|
||||
|
||||
if (rejected is null)
|
||||
{
|
||||
return Results.Problem(new ProblemDetails
|
||||
{
|
||||
Title = "Rejection failed",
|
||||
Status = 500,
|
||||
Detail = "Failed to record rejection. The request may have been modified."
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Exception request {RequestId} rejected by {RejectorId}: {Reason}",
|
||||
requestId,
|
||||
rejectorId,
|
||||
request.Reason);
|
||||
|
||||
return Results.Ok(MapToDto(rejected));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CancelRequestAsync(
|
||||
HttpContext httpContext,
|
||||
string requestId,
|
||||
CancelRequestDto? request,
|
||||
IExceptionApprovalRepository repository,
|
||||
ILogger<ExceptionApprovalRequestEntity> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var actorId = GetActorId(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(actorId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var existing = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Request not found",
|
||||
Status = 404,
|
||||
Detail = $"Exception approval request '{requestId}' not found."
|
||||
});
|
||||
}
|
||||
|
||||
// Only requestor can cancel
|
||||
if (!string.Equals(existing.RequestorId, actorId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var cancelled = await repository.CancelRequestAsync(
|
||||
tenantId,
|
||||
requestId,
|
||||
actorId,
|
||||
request?.Reason,
|
||||
cancellationToken);
|
||||
|
||||
if (!cancelled)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Cannot cancel",
|
||||
Status = 400,
|
||||
Detail = "Request cannot be cancelled. It may already be resolved."
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Exception request {RequestId} cancelled by {ActorId}",
|
||||
requestId,
|
||||
actorId);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAuditTrailAsync(
|
||||
HttpContext httpContext,
|
||||
string requestId,
|
||||
IExceptionApprovalRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var audit = await repository.GetAuditTrailAsync(tenantId, requestId, cancellationToken);
|
||||
|
||||
return Results.Ok(new AuditTrailResponse
|
||||
{
|
||||
RequestId = requestId,
|
||||
Entries = audit.Select(MapToAuditDto).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetApprovalRulesAsync(
|
||||
HttpContext httpContext,
|
||||
IExceptionApprovalRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var rules = await repository.ListApprovalRulesAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new ApprovalRulesResponse
|
||||
{
|
||||
Rules = rules.Select(MapToRuleDto).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Helper Methods
|
||||
// ========================================================================
|
||||
|
||||
private static string? GetTenantId(HttpContext httpContext)
|
||||
{
|
||||
return httpContext.User.Claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value
|
||||
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string? GetActorId(HttpContext httpContext)
|
||||
{
|
||||
return httpContext.User.Claims.FirstOrDefault(c => c.Type == "sub")?.Value
|
||||
?? httpContext.User.Identity?.Name;
|
||||
}
|
||||
|
||||
private static HashSet<string> GetActorRoles(HttpContext httpContext)
|
||||
{
|
||||
return httpContext.User.Claims
|
||||
.Where(c => c.Type == "role" || c.Type == "roles")
|
||||
.Select(c => c.Value)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildClientInfo(HttpContext httpContext)
|
||||
{
|
||||
var info = new Dictionary<string, string?>
|
||||
{
|
||||
["ip"] = httpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
["user_agent"] = httpContext.Request.Headers.UserAgent.FirstOrDefault(),
|
||||
["correlation_id"] = httpContext.Request.Headers["X-Correlation-Id"].FirstOrDefault()
|
||||
};
|
||||
return JsonSerializer.Serialize(info);
|
||||
}
|
||||
|
||||
private static ApprovalRequestDto MapToDto(
|
||||
ExceptionApprovalRequestEntity entity,
|
||||
IReadOnlyList<string>? warnings = null)
|
||||
{
|
||||
return new ApprovalRequestDto
|
||||
{
|
||||
RequestId = entity.RequestId,
|
||||
Id = entity.Id,
|
||||
TenantId = entity.TenantId,
|
||||
ExceptionId = entity.ExceptionId,
|
||||
RequestorId = entity.RequestorId,
|
||||
RequiredApproverIds = entity.RequiredApproverIds,
|
||||
ApprovedByIds = entity.ApprovedByIds,
|
||||
RejectedById = entity.RejectedById,
|
||||
Status = entity.Status.ToString(),
|
||||
GateLevel = entity.GateLevel.ToString(),
|
||||
Justification = entity.Justification,
|
||||
Rationale = entity.Rationale,
|
||||
ReasonCode = entity.ReasonCode.ToString(),
|
||||
EvidenceRefs = ParseJsonArray(entity.EvidenceRefs),
|
||||
CompensatingControls = ParseJsonArray(entity.CompensatingControls),
|
||||
TicketRef = entity.TicketRef,
|
||||
VulnerabilityId = entity.VulnerabilityId,
|
||||
PurlPattern = entity.PurlPattern,
|
||||
ArtifactDigest = entity.ArtifactDigest,
|
||||
ImagePattern = entity.ImagePattern,
|
||||
Environments = entity.Environments,
|
||||
RequestedTtlDays = entity.RequestedTtlDays,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
RequestExpiresAt = entity.RequestExpiresAt,
|
||||
ExceptionExpiresAt = entity.ExceptionExpiresAt,
|
||||
ResolvedAt = entity.ResolvedAt,
|
||||
RejectionReason = entity.RejectionReason,
|
||||
Version = entity.Version,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
Warnings = warnings ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static ApprovalRequestSummaryDto MapToSummaryDto(ExceptionApprovalRequestEntity entity)
|
||||
{
|
||||
return new ApprovalRequestSummaryDto
|
||||
{
|
||||
RequestId = entity.RequestId,
|
||||
Status = entity.Status.ToString(),
|
||||
GateLevel = entity.GateLevel.ToString(),
|
||||
RequestorId = entity.RequestorId,
|
||||
VulnerabilityId = entity.VulnerabilityId,
|
||||
PurlPattern = entity.PurlPattern,
|
||||
ReasonCode = entity.ReasonCode.ToString(),
|
||||
CreatedAt = entity.CreatedAt,
|
||||
RequestExpiresAt = entity.RequestExpiresAt,
|
||||
ApprovedCount = entity.ApprovedByIds.Length,
|
||||
RequiredCount = entity.RequiredApproverIds.Length
|
||||
};
|
||||
}
|
||||
|
||||
private static AuditEntryDto MapToAuditDto(ExceptionApprovalAuditEntity entity)
|
||||
{
|
||||
return new AuditEntryDto
|
||||
{
|
||||
Id = entity.Id,
|
||||
SequenceNumber = entity.SequenceNumber,
|
||||
ActionType = entity.ActionType,
|
||||
ActorId = entity.ActorId,
|
||||
OccurredAt = entity.OccurredAt,
|
||||
PreviousStatus = entity.PreviousStatus,
|
||||
NewStatus = entity.NewStatus,
|
||||
Description = entity.Description
|
||||
};
|
||||
}
|
||||
|
||||
private static ApprovalRuleDto MapToRuleDto(ExceptionApprovalRuleEntity entity)
|
||||
{
|
||||
return new ApprovalRuleDto
|
||||
{
|
||||
Id = entity.Id,
|
||||
Name = entity.Name,
|
||||
Description = entity.Description,
|
||||
GateLevel = entity.GateLevel.ToString(),
|
||||
MinApprovers = entity.MinApprovers,
|
||||
RequiredRoles = entity.RequiredRoles,
|
||||
MaxTtlDays = entity.MaxTtlDays,
|
||||
AllowSelfApproval = entity.AllowSelfApproval,
|
||||
RequireEvidence = entity.RequireEvidence,
|
||||
RequireCompensatingControls = entity.RequireCompensatingControls,
|
||||
MinRationaleLength = entity.MinRationaleLength,
|
||||
Enabled = entity.Enabled
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string> ParseJsonArray(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "[]")
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DTO Models
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an exception approval request.
|
||||
/// </summary>
|
||||
public sealed record CreateApprovalRequestDto
|
||||
{
|
||||
public string? ExceptionId { get; init; }
|
||||
public required string Justification { get; init; }
|
||||
public string? Rationale { get; init; }
|
||||
public string? GateLevel { get; init; }
|
||||
public string? ReasonCode { get; init; }
|
||||
public List<string>? EvidenceRefs { get; init; }
|
||||
public List<string>? CompensatingControls { get; init; }
|
||||
public string? TicketRef { get; init; }
|
||||
public string? VulnerabilityId { get; init; }
|
||||
public string? PurlPattern { get; init; }
|
||||
public string? ArtifactDigest { get; init; }
|
||||
public string? ImagePattern { get; init; }
|
||||
public List<string>? Environments { get; init; }
|
||||
public List<string>? RequiredApproverIds { get; init; }
|
||||
public int? RequestedTtlDays { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to approve an exception.
|
||||
/// </summary>
|
||||
public sealed record ApproveRequestDto
|
||||
{
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to reject an exception.
|
||||
/// </summary>
|
||||
public sealed record RejectRequestDto
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to cancel an exception request.
|
||||
/// </summary>
|
||||
public sealed record CancelRequestDto
|
||||
{
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full approval request response.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRequestDto
|
||||
{
|
||||
public required string RequestId { get; init; }
|
||||
public Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public string? ExceptionId { get; init; }
|
||||
public required string RequestorId { get; init; }
|
||||
public string[] RequiredApproverIds { get; init; } = [];
|
||||
public string[] ApprovedByIds { get; init; } = [];
|
||||
public string? RejectedById { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string GateLevel { get; init; }
|
||||
public required string Justification { get; init; }
|
||||
public string? Rationale { get; init; }
|
||||
public required string ReasonCode { get; init; }
|
||||
public List<string> EvidenceRefs { get; init; } = [];
|
||||
public List<string> CompensatingControls { get; init; } = [];
|
||||
public string? TicketRef { get; init; }
|
||||
public string? VulnerabilityId { get; init; }
|
||||
public string? PurlPattern { get; init; }
|
||||
public string? ArtifactDigest { get; init; }
|
||||
public string? ImagePattern { get; init; }
|
||||
public string[] Environments { get; init; } = [];
|
||||
public int RequestedTtlDays { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset RequestExpiresAt { get; init; }
|
||||
public DateTimeOffset? ExceptionExpiresAt { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public string? RejectionReason { get; init; }
|
||||
public int Version { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary approval request for listings.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRequestSummaryDto
|
||||
{
|
||||
public required string RequestId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string GateLevel { get; init; }
|
||||
public required string RequestorId { get; init; }
|
||||
public string? VulnerabilityId { get; init; }
|
||||
public string? PurlPattern { get; init; }
|
||||
public required string ReasonCode { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset RequestExpiresAt { get; init; }
|
||||
public int ApprovedCount { get; init; }
|
||||
public int RequiredCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approval request list response.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRequestListResponse
|
||||
{
|
||||
public required IReadOnlyList<ApprovalRequestSummaryDto> Items { get; init; }
|
||||
public int Limit { get; init; }
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit entry response.
|
||||
/// </summary>
|
||||
public sealed record AuditEntryDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public int SequenceNumber { get; init; }
|
||||
public required string ActionType { get; init; }
|
||||
public required string ActorId { get; init; }
|
||||
public DateTimeOffset OccurredAt { get; init; }
|
||||
public string? PreviousStatus { get; init; }
|
||||
public required string NewStatus { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit trail response.
|
||||
/// </summary>
|
||||
public sealed record AuditTrailResponse
|
||||
{
|
||||
public required string RequestId { get; init; }
|
||||
public required IReadOnlyList<AuditEntryDto> Entries { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approval rule response.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRuleDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required string GateLevel { get; init; }
|
||||
public int MinApprovers { get; init; }
|
||||
public string[] RequiredRoles { get; init; } = [];
|
||||
public int MaxTtlDays { get; init; }
|
||||
public bool AllowSelfApproval { get; init; }
|
||||
public bool RequireEvidence { get; init; }
|
||||
public bool RequireCompensatingControls { get; init; }
|
||||
public int MinRationaleLength { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approval rules list response.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRulesResponse
|
||||
{
|
||||
public required IReadOnlyList<ApprovalRuleDto> Rules { get; init; }
|
||||
}
|
||||
@@ -143,6 +143,14 @@ builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.GateBypassAuditOp
|
||||
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IGateBypassAuditor,
|
||||
StellaOps.Policy.Engine.Services.GateBypassAuditor>();
|
||||
|
||||
// Exception approval services (Sprint: SPRINT_20251226_003_BE_exception_approval)
|
||||
builder.Services.Configure<StellaOps.Policy.Engine.Services.ExceptionApprovalRulesOptions>(
|
||||
builder.Configuration.GetSection(StellaOps.Policy.Engine.Services.ExceptionApprovalRulesOptions.SectionName));
|
||||
builder.Services.AddScoped<StellaOps.Policy.Storage.Postgres.Repositories.IExceptionApprovalRepository,
|
||||
StellaOps.Policy.Storage.Postgres.Repositories.ExceptionApprovalRepository>();
|
||||
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IExceptionApprovalRulesService,
|
||||
StellaOps.Policy.Engine.Services.ExceptionApprovalRulesService>();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
|
||||
@@ -519,6 +527,9 @@ app.MapGateEndpoints();
|
||||
// Registry webhook endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
||||
app.MapRegistryWebhooks();
|
||||
|
||||
// Exception approval endpoints (Sprint: SPRINT_20251226_003_BE_exception_approval)
|
||||
app.MapExceptionApprovalEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
-- Policy Schema Migration 013: Exception Approval Workflow
|
||||
-- Implements role-based exception approval workflows
|
||||
-- Sprint: SPRINT_20251226_003_BE_exception_approval
|
||||
-- Category: A (safe, can run at startup)
|
||||
--
|
||||
-- Purpose: Add approval workflow infrastructure:
|
||||
-- - Approval request entity with multi-approver support
|
||||
-- - Gate-level approval requirements (G1=peer, G2=code owner, G3+=DM+PM)
|
||||
-- - Time-limited overrides with TTL enforcement
|
||||
-- - Comprehensive audit trail
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 1: Create exception_approval_requests table
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.exception_approval_requests (
|
||||
-- Primary key
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Request identifier for external reference (EAR-XXXXX)
|
||||
request_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
-- Multi-tenancy
|
||||
tenant_id TEXT NOT NULL,
|
||||
|
||||
-- Reference to parent exception (can be NULL for new exception requests)
|
||||
exception_id TEXT,
|
||||
|
||||
-- Who requested the exception
|
||||
requestor_id TEXT NOT NULL,
|
||||
|
||||
-- Required approvers based on gate level
|
||||
required_approver_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Approvers who have approved (subset of required)
|
||||
approved_by_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Approvers who rejected
|
||||
rejected_by_id TEXT,
|
||||
|
||||
-- Request status
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'partial', 'approved', 'rejected', 'expired', 'cancelled')),
|
||||
|
||||
-- Gate level determining approval requirements (0-4)
|
||||
gate_level INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (gate_level >= 0 AND gate_level <= 4),
|
||||
|
||||
-- Justification for the exception
|
||||
justification TEXT NOT NULL,
|
||||
|
||||
-- Detailed rationale (min 50 chars for G2+)
|
||||
rationale TEXT,
|
||||
|
||||
-- Categorized reason code
|
||||
reason_code TEXT NOT NULL DEFAULT 'other'
|
||||
CHECK (reason_code IN (
|
||||
'false_positive', 'accepted_risk', 'compensating_control',
|
||||
'test_only', 'vendor_not_affected', 'scheduled_fix',
|
||||
'deprecation_in_progress', 'runtime_mitigation',
|
||||
'network_isolation', 'other'
|
||||
)),
|
||||
|
||||
-- Content-addressed evidence references
|
||||
evidence_refs JSONB NOT NULL DEFAULT '[]',
|
||||
|
||||
-- Compensating controls in place
|
||||
compensating_controls JSONB NOT NULL DEFAULT '[]',
|
||||
|
||||
-- External ticket reference (e.g., JIRA-1234)
|
||||
ticket_ref TEXT,
|
||||
|
||||
-- Scope: vulnerability ID (CVE-XXXX-XXXXX)
|
||||
vulnerability_id TEXT,
|
||||
|
||||
-- Scope: PURL pattern
|
||||
purl_pattern TEXT,
|
||||
|
||||
-- Scope: specific artifact digest
|
||||
artifact_digest TEXT,
|
||||
|
||||
-- Scope: image reference pattern
|
||||
image_pattern TEXT,
|
||||
|
||||
-- Scope: environments (empty = all)
|
||||
environments TEXT[] NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Requested TTL in days
|
||||
requested_ttl_days INTEGER NOT NULL DEFAULT 30
|
||||
CHECK (requested_ttl_days > 0 AND requested_ttl_days <= 365),
|
||||
|
||||
-- When the request was created
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- When the request expires (auto-reject after)
|
||||
request_expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'),
|
||||
|
||||
-- When the exception would expire if approved
|
||||
exception_expires_at TIMESTAMPTZ,
|
||||
|
||||
-- When the request was resolved (approved/rejected/cancelled)
|
||||
resolved_at TIMESTAMPTZ,
|
||||
|
||||
-- Rejection reason (if rejected)
|
||||
rejection_reason TEXT,
|
||||
|
||||
-- Additional metadata
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Version for optimistic concurrency
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
-- Last update timestamp
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 2: Create exception_approval_audit table
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.exception_approval_audit (
|
||||
-- Primary key
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Reference to approval request
|
||||
request_id TEXT NOT NULL,
|
||||
|
||||
-- Multi-tenancy
|
||||
tenant_id TEXT NOT NULL,
|
||||
|
||||
-- Sequence number within this request's audit trail
|
||||
sequence_number INTEGER NOT NULL,
|
||||
|
||||
-- Action type
|
||||
action_type TEXT NOT NULL
|
||||
CHECK (action_type IN (
|
||||
'requested', 'approved', 'rejected', 'escalated',
|
||||
'reminder_sent', 'expired', 'cancelled', 'evidence_added',
|
||||
'approver_added', 'approver_removed', 'ttl_extended'
|
||||
)),
|
||||
|
||||
-- Identity of the actor
|
||||
actor_id TEXT NOT NULL,
|
||||
|
||||
-- When this action occurred
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Previous status
|
||||
previous_status TEXT,
|
||||
|
||||
-- New status after action
|
||||
new_status TEXT NOT NULL,
|
||||
|
||||
-- Human-readable description
|
||||
description TEXT,
|
||||
|
||||
-- Additional structured details
|
||||
details JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Client info for audit (IP, user agent, correlation ID)
|
||||
client_info JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Unique sequence per request
|
||||
UNIQUE (request_id, sequence_number)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 3: Create approval_rules table for configurable requirements
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.exception_approval_rules (
|
||||
-- Primary key
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Multi-tenancy
|
||||
tenant_id TEXT NOT NULL,
|
||||
|
||||
-- Rule name
|
||||
name TEXT NOT NULL,
|
||||
|
||||
-- Rule description
|
||||
description TEXT,
|
||||
|
||||
-- Gate level this rule applies to
|
||||
gate_level INTEGER NOT NULL
|
||||
CHECK (gate_level >= 0 AND gate_level <= 4),
|
||||
|
||||
-- Minimum number of approvers required
|
||||
min_approvers INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (min_approvers >= 0 AND min_approvers <= 10),
|
||||
|
||||
-- Required approver roles (e.g., 'code-owner', 'security-lead', 'pm')
|
||||
required_roles TEXT[] NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Max TTL allowed in days
|
||||
max_ttl_days INTEGER NOT NULL DEFAULT 30
|
||||
CHECK (max_ttl_days > 0 AND max_ttl_days <= 365),
|
||||
|
||||
-- Whether self-approval is allowed
|
||||
allow_self_approval BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Whether evidence is required
|
||||
require_evidence BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Whether compensating controls are required
|
||||
require_compensating_controls BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Minimum rationale length
|
||||
min_rationale_length INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (min_rationale_length >= 0 AND min_rationale_length <= 1000),
|
||||
|
||||
-- Rule priority (higher = more specific)
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Whether rule is active
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- When the rule was created
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- When the rule was last updated
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Unique rule per tenant and gate level
|
||||
UNIQUE (tenant_id, gate_level, name)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 4: Create indexes
|
||||
-- ============================================================================
|
||||
|
||||
-- Approval requests: tenant lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_requests_tenant
|
||||
ON policy.exception_approval_requests(tenant_id);
|
||||
|
||||
-- Approval requests: status filter
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_requests_status
|
||||
ON policy.exception_approval_requests(tenant_id, status);
|
||||
|
||||
-- Approval requests: requestor lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_requests_requestor
|
||||
ON policy.exception_approval_requests(requestor_id);
|
||||
|
||||
-- Approval requests: pending approvals for approver
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_requests_pending
|
||||
ON policy.exception_approval_requests(tenant_id, status)
|
||||
WHERE status IN ('pending', 'partial');
|
||||
|
||||
-- Approval requests: expiry check
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_requests_expiry
|
||||
ON policy.exception_approval_requests(request_expires_at)
|
||||
WHERE status IN ('pending', 'partial');
|
||||
|
||||
-- Approval requests: vulnerability lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_requests_vuln
|
||||
ON policy.exception_approval_requests(vulnerability_id)
|
||||
WHERE vulnerability_id IS NOT NULL;
|
||||
|
||||
-- Audit: request lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_audit_request
|
||||
ON policy.exception_approval_audit(request_id);
|
||||
|
||||
-- Audit: time-based queries (BRIN for append-only pattern)
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_audit_time
|
||||
ON policy.exception_approval_audit USING BRIN (occurred_at);
|
||||
|
||||
-- Rules: tenant and gate level lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_rules_lookup
|
||||
ON policy.exception_approval_rules(tenant_id, gate_level, enabled);
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 5: Enable Row-Level Security
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE policy.exception_approval_requests ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE policy.exception_approval_audit ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE policy.exception_approval_rules ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policies for approval requests
|
||||
DROP POLICY IF EXISTS approval_requests_tenant_isolation ON policy.exception_approval_requests;
|
||||
CREATE POLICY approval_requests_tenant_isolation ON policy.exception_approval_requests
|
||||
FOR ALL
|
||||
USING (tenant_id = current_setting('app.current_tenant', true));
|
||||
|
||||
-- RLS policies for audit
|
||||
DROP POLICY IF EXISTS approval_audit_tenant_isolation ON policy.exception_approval_audit;
|
||||
CREATE POLICY approval_audit_tenant_isolation ON policy.exception_approval_audit
|
||||
FOR ALL
|
||||
USING (tenant_id = current_setting('app.current_tenant', true));
|
||||
|
||||
-- RLS policies for rules
|
||||
DROP POLICY IF EXISTS approval_rules_tenant_isolation ON policy.exception_approval_rules;
|
||||
CREATE POLICY approval_rules_tenant_isolation ON policy.exception_approval_rules
|
||||
FOR ALL
|
||||
USING (tenant_id = current_setting('app.current_tenant', true));
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 6: Insert default approval rules
|
||||
-- ============================================================================
|
||||
|
||||
-- Default rules for common tenant (will be copied to new tenants)
|
||||
INSERT INTO policy.exception_approval_rules
|
||||
(id, tenant_id, name, description, gate_level, min_approvers, required_roles,
|
||||
max_ttl_days, allow_self_approval, require_evidence, min_rationale_length, priority)
|
||||
VALUES
|
||||
-- G0: No approval needed (auto-approve)
|
||||
(gen_random_uuid(), '__default__', 'g0_auto',
|
||||
'Informational findings - auto-approved', 0, 0, '{}',
|
||||
90, true, false, 0, 100),
|
||||
|
||||
-- G1: One peer approval
|
||||
(gen_random_uuid(), '__default__', 'g1_peer',
|
||||
'Low severity - peer review required', 1, 1, '{}',
|
||||
60, true, false, 20, 100),
|
||||
|
||||
-- G2: Code owner approval
|
||||
(gen_random_uuid(), '__default__', 'g2_owner',
|
||||
'Medium severity - code owner approval required', 2, 1, ARRAY['code-owner'],
|
||||
30, false, true, 50, 100),
|
||||
|
||||
-- G3: DM + PM approval
|
||||
(gen_random_uuid(), '__default__', 'g3_leadership',
|
||||
'High severity - leadership approval required', 3, 2, ARRAY['delivery-manager', 'product-manager'],
|
||||
14, false, true, 100, 100),
|
||||
|
||||
-- G4: CISO + DM + PM approval
|
||||
(gen_random_uuid(), '__default__', 'g4_executive',
|
||||
'Critical severity - executive approval required', 4, 3, ARRAY['ciso', 'delivery-manager', 'product-manager'],
|
||||
7, false, true, 200, 100)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 7: Create helper functions
|
||||
-- ============================================================================
|
||||
|
||||
-- Function to expire pending approval requests
|
||||
CREATE OR REPLACE FUNCTION policy.expire_pending_approval_requests()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
expired_count INTEGER;
|
||||
BEGIN
|
||||
WITH expired AS (
|
||||
UPDATE policy.exception_approval_requests
|
||||
SET
|
||||
status = 'expired',
|
||||
resolved_at = NOW(),
|
||||
version = version + 1,
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
status IN ('pending', 'partial')
|
||||
AND request_expires_at <= NOW()
|
||||
RETURNING request_id, tenant_id, version
|
||||
),
|
||||
audit_entries AS (
|
||||
INSERT INTO policy.exception_approval_audit (
|
||||
request_id,
|
||||
tenant_id,
|
||||
sequence_number,
|
||||
action_type,
|
||||
actor_id,
|
||||
occurred_at,
|
||||
previous_status,
|
||||
new_status,
|
||||
description
|
||||
)
|
||||
SELECT
|
||||
e.request_id,
|
||||
e.tenant_id,
|
||||
COALESCE(
|
||||
(SELECT MAX(sequence_number) + 1
|
||||
FROM policy.exception_approval_audit
|
||||
WHERE request_id = e.request_id),
|
||||
1
|
||||
),
|
||||
'expired',
|
||||
'system',
|
||||
NOW(),
|
||||
'pending',
|
||||
'expired',
|
||||
'Approval request expired without sufficient approvals'
|
||||
FROM expired e
|
||||
RETURNING request_id
|
||||
)
|
||||
SELECT COUNT(*) INTO expired_count FROM audit_entries;
|
||||
|
||||
RETURN expired_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Function to get approval requirements for a gate level
|
||||
CREATE OR REPLACE FUNCTION policy.get_approval_requirements(
|
||||
p_tenant_id TEXT,
|
||||
p_gate_level INTEGER
|
||||
)
|
||||
RETURNS TABLE (
|
||||
min_approvers INTEGER,
|
||||
required_roles TEXT[],
|
||||
max_ttl_days INTEGER,
|
||||
allow_self_approval BOOLEAN,
|
||||
require_evidence BOOLEAN,
|
||||
require_compensating_controls BOOLEAN,
|
||||
min_rationale_length INTEGER
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
r.min_approvers,
|
||||
r.required_roles,
|
||||
r.max_ttl_days,
|
||||
r.allow_self_approval,
|
||||
r.require_evidence,
|
||||
r.require_compensating_controls,
|
||||
r.min_rationale_length
|
||||
FROM policy.exception_approval_rules r
|
||||
WHERE (r.tenant_id = p_tenant_id OR r.tenant_id = '__default__')
|
||||
AND r.gate_level = p_gate_level
|
||||
AND r.enabled = true
|
||||
ORDER BY
|
||||
CASE WHEN r.tenant_id = p_tenant_id THEN 0 ELSE 1 END,
|
||||
r.priority DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- Return default if no rule found
|
||||
IF NOT FOUND THEN
|
||||
RETURN QUERY SELECT 1, ARRAY[]::TEXT[], 30, false, false, false, 0;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 8: Add comments for documentation
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON TABLE policy.exception_approval_requests IS
|
||||
'Approval workflow requests for policy exceptions';
|
||||
|
||||
COMMENT ON TABLE policy.exception_approval_audit IS
|
||||
'Immutable audit trail of approval workflow actions';
|
||||
|
||||
COMMENT ON TABLE policy.exception_approval_rules IS
|
||||
'Configurable approval requirements by gate level';
|
||||
|
||||
COMMENT ON COLUMN policy.exception_approval_requests.gate_level IS
|
||||
'Gate level: 0=info, 1=low, 2=medium, 3=high, 4=critical';
|
||||
|
||||
COMMENT ON COLUMN policy.exception_approval_requests.status IS
|
||||
'Workflow status: pending → partial → approved/rejected/expired/cancelled';
|
||||
|
||||
COMMENT ON FUNCTION policy.expire_pending_approval_requests() IS
|
||||
'Marks pending approval requests as expired. Returns count of expired.';
|
||||
|
||||
COMMENT ON FUNCTION policy.get_approval_requirements(TEXT, INTEGER) IS
|
||||
'Gets approval requirements for a tenant and gate level.';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,246 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Approval request status enumeration.
|
||||
/// </summary>
|
||||
public enum ApprovalRequestStatus
|
||||
{
|
||||
/// <summary>Request pending approval.</summary>
|
||||
Pending,
|
||||
/// <summary>Request partially approved (needs more approvers).</summary>
|
||||
Partial,
|
||||
/// <summary>Request fully approved.</summary>
|
||||
Approved,
|
||||
/// <summary>Request rejected by an approver.</summary>
|
||||
Rejected,
|
||||
/// <summary>Request expired without resolution.</summary>
|
||||
Expired,
|
||||
/// <summary>Request cancelled by requestor.</summary>
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate level for determining approval requirements.
|
||||
/// </summary>
|
||||
public enum GateLevel
|
||||
{
|
||||
/// <summary>Informational - auto-approve.</summary>
|
||||
G0 = 0,
|
||||
/// <summary>Low severity - peer review.</summary>
|
||||
G1 = 1,
|
||||
/// <summary>Medium severity - code owner.</summary>
|
||||
G2 = 2,
|
||||
/// <summary>High severity - leadership.</summary>
|
||||
G3 = 3,
|
||||
/// <summary>Critical severity - executive.</summary>
|
||||
G4 = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reason codes for exception requests.
|
||||
/// </summary>
|
||||
public enum ExceptionReasonCode
|
||||
{
|
||||
FalsePositive,
|
||||
AcceptedRisk,
|
||||
CompensatingControl,
|
||||
TestOnly,
|
||||
VendorNotAffected,
|
||||
ScheduledFix,
|
||||
DeprecationInProgress,
|
||||
RuntimeMitigation,
|
||||
NetworkIsolation,
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing an exception approval request.
|
||||
/// </summary>
|
||||
public sealed class ExceptionApprovalRequestEntity
|
||||
{
|
||||
/// <summary>Unique identifier.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>External request identifier (EAR-XXXXX).</summary>
|
||||
public required string RequestId { get; init; }
|
||||
|
||||
/// <summary>Tenant identifier.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Reference to parent exception (null for new requests).</summary>
|
||||
public string? ExceptionId { get; init; }
|
||||
|
||||
/// <summary>User who requested the exception.</summary>
|
||||
public required string RequestorId { get; init; }
|
||||
|
||||
/// <summary>Required approvers based on gate level.</summary>
|
||||
public required string[] RequiredApproverIds { get; init; }
|
||||
|
||||
/// <summary>Approvers who have approved.</summary>
|
||||
public string[] ApprovedByIds { get; init; } = [];
|
||||
|
||||
/// <summary>Approver who rejected (if any).</summary>
|
||||
public string? RejectedById { get; init; }
|
||||
|
||||
/// <summary>Request status.</summary>
|
||||
public ApprovalRequestStatus Status { get; init; } = ApprovalRequestStatus.Pending;
|
||||
|
||||
/// <summary>Gate level determining approval requirements.</summary>
|
||||
public GateLevel GateLevel { get; init; } = GateLevel.G1;
|
||||
|
||||
/// <summary>Justification for the exception.</summary>
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>Detailed rationale.</summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>Categorized reason code.</summary>
|
||||
public ExceptionReasonCode ReasonCode { get; init; } = ExceptionReasonCode.Other;
|
||||
|
||||
/// <summary>Content-addressed evidence references as JSON.</summary>
|
||||
public string EvidenceRefs { get; init; } = "[]";
|
||||
|
||||
/// <summary>Compensating controls as JSON.</summary>
|
||||
public string CompensatingControls { get; init; } = "[]";
|
||||
|
||||
/// <summary>External ticket reference.</summary>
|
||||
public string? TicketRef { get; init; }
|
||||
|
||||
/// <summary>Scope: vulnerability ID.</summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Scope: PURL pattern.</summary>
|
||||
public string? PurlPattern { get; init; }
|
||||
|
||||
/// <summary>Scope: artifact digest.</summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>Scope: image reference pattern.</summary>
|
||||
public string? ImagePattern { get; init; }
|
||||
|
||||
/// <summary>Scope: environments.</summary>
|
||||
public string[] Environments { get; init; } = [];
|
||||
|
||||
/// <summary>Requested TTL in days.</summary>
|
||||
public int RequestedTtlDays { get; init; } = 30;
|
||||
|
||||
/// <summary>When the request was created.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>When the request expires.</summary>
|
||||
public DateTimeOffset RequestExpiresAt { get; init; }
|
||||
|
||||
/// <summary>When the exception would expire if approved.</summary>
|
||||
public DateTimeOffset? ExceptionExpiresAt { get; init; }
|
||||
|
||||
/// <summary>When the request was resolved.</summary>
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
|
||||
/// <summary>Rejection reason.</summary>
|
||||
public string? RejectionReason { get; init; }
|
||||
|
||||
/// <summary>Additional metadata as JSON.</summary>
|
||||
public string Metadata { get; init; } = "{}";
|
||||
|
||||
/// <summary>Version for optimistic concurrency.</summary>
|
||||
public int Version { get; init; } = 1;
|
||||
|
||||
/// <summary>Last update timestamp.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing an approval audit entry.
|
||||
/// </summary>
|
||||
public sealed class ExceptionApprovalAuditEntity
|
||||
{
|
||||
/// <summary>Unique identifier.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>Reference to approval request.</summary>
|
||||
public required string RequestId { get; init; }
|
||||
|
||||
/// <summary>Tenant identifier.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Sequence number within request's audit trail.</summary>
|
||||
public required int SequenceNumber { get; init; }
|
||||
|
||||
/// <summary>Action type.</summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>Identity of the actor.</summary>
|
||||
public required string ActorId { get; init; }
|
||||
|
||||
/// <summary>When this action occurred.</summary>
|
||||
public DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
/// <summary>Previous status.</summary>
|
||||
public string? PreviousStatus { get; init; }
|
||||
|
||||
/// <summary>New status after action.</summary>
|
||||
public required string NewStatus { get; init; }
|
||||
|
||||
/// <summary>Human-readable description.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Additional structured details as JSON.</summary>
|
||||
public string Details { get; init; } = "{}";
|
||||
|
||||
/// <summary>Client info as JSON.</summary>
|
||||
public string ClientInfo { get; init; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing approval rules by gate level.
|
||||
/// </summary>
|
||||
public sealed class ExceptionApprovalRuleEntity
|
||||
{
|
||||
/// <summary>Unique identifier.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>Tenant identifier.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Rule name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Rule description.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Gate level this rule applies to.</summary>
|
||||
public GateLevel GateLevel { get; init; }
|
||||
|
||||
/// <summary>Minimum number of approvers required.</summary>
|
||||
public int MinApprovers { get; init; } = 1;
|
||||
|
||||
/// <summary>Required approver roles.</summary>
|
||||
public string[] RequiredRoles { get; init; } = [];
|
||||
|
||||
/// <summary>Max TTL allowed in days.</summary>
|
||||
public int MaxTtlDays { get; init; } = 30;
|
||||
|
||||
/// <summary>Whether self-approval is allowed.</summary>
|
||||
public bool AllowSelfApproval { get; init; }
|
||||
|
||||
/// <summary>Whether evidence is required.</summary>
|
||||
public bool RequireEvidence { get; init; }
|
||||
|
||||
/// <summary>Whether compensating controls are required.</summary>
|
||||
public bool RequireCompensatingControls { get; init; }
|
||||
|
||||
/// <summary>Minimum rationale length.</summary>
|
||||
public int MinRationaleLength { get; init; }
|
||||
|
||||
/// <summary>Rule priority.</summary>
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>Whether rule is active.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>When the rule was created.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>When the rule was last updated.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for exception approval workflow operations.
|
||||
/// </summary>
|
||||
public sealed class ExceptionApprovalRepository : RepositoryBase<PolicyDataSource>, IExceptionApprovalRepository
|
||||
{
|
||||
public ExceptionApprovalRepository(PolicyDataSource dataSource, ILogger<ExceptionApprovalRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Approval Request Operations
|
||||
// ========================================================================
|
||||
|
||||
public async Task<ExceptionApprovalRequestEntity> CreateRequestAsync(
|
||||
ExceptionApprovalRequestEntity request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.exception_approval_requests (
|
||||
id, request_id, tenant_id, exception_id, requestor_id,
|
||||
required_approver_ids, approved_by_ids, status, gate_level,
|
||||
justification, rationale, reason_code, evidence_refs,
|
||||
compensating_controls, ticket_ref, vulnerability_id, purl_pattern,
|
||||
artifact_digest, image_pattern, environments, requested_ttl_days,
|
||||
created_at, request_expires_at, exception_expires_at, metadata, version, updated_at
|
||||
)
|
||||
VALUES (
|
||||
@id, @request_id, @tenant_id, @exception_id, @requestor_id,
|
||||
@required_approver_ids, @approved_by_ids, @status, @gate_level,
|
||||
@justification, @rationale, @reason_code, @evidence_refs::jsonb,
|
||||
@compensating_controls::jsonb, @ticket_ref, @vulnerability_id, @purl_pattern,
|
||||
@artifact_digest, @image_pattern, @environments, @requested_ttl_days,
|
||||
@created_at, @request_expires_at, @exception_expires_at, @metadata::jsonb, @version, @updated_at
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(request.TenantId, "writer", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
|
||||
AddParameter(cmd, "id", request.Id);
|
||||
AddParameter(cmd, "request_id", request.RequestId);
|
||||
AddParameter(cmd, "tenant_id", request.TenantId);
|
||||
AddParameter(cmd, "exception_id", request.ExceptionId);
|
||||
AddParameter(cmd, "requestor_id", request.RequestorId);
|
||||
AddParameter(cmd, "required_approver_ids", request.RequiredApproverIds);
|
||||
AddParameter(cmd, "approved_by_ids", request.ApprovedByIds);
|
||||
AddParameter(cmd, "status", request.Status.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "gate_level", (int)request.GateLevel);
|
||||
AddParameter(cmd, "justification", request.Justification);
|
||||
AddParameter(cmd, "rationale", request.Rationale);
|
||||
AddParameter(cmd, "reason_code", MapReasonCode(request.ReasonCode));
|
||||
AddParameter(cmd, "evidence_refs", request.EvidenceRefs);
|
||||
AddParameter(cmd, "compensating_controls", request.CompensatingControls);
|
||||
AddParameter(cmd, "ticket_ref", request.TicketRef);
|
||||
AddParameter(cmd, "vulnerability_id", request.VulnerabilityId);
|
||||
AddParameter(cmd, "purl_pattern", request.PurlPattern);
|
||||
AddParameter(cmd, "artifact_digest", request.ArtifactDigest);
|
||||
AddParameter(cmd, "image_pattern", request.ImagePattern);
|
||||
AddParameter(cmd, "environments", request.Environments);
|
||||
AddParameter(cmd, "requested_ttl_days", request.RequestedTtlDays);
|
||||
AddParameter(cmd, "created_at", request.CreatedAt);
|
||||
AddParameter(cmd, "request_expires_at", request.RequestExpiresAt);
|
||||
AddParameter(cmd, "exception_expires_at", request.ExceptionExpiresAt);
|
||||
AddParameter(cmd, "metadata", request.Metadata);
|
||||
AddParameter(cmd, "version", request.Version);
|
||||
AddParameter(cmd, "updated_at", request.UpdatedAt);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
await reader.ReadAsync(ct);
|
||||
return MapApprovalRequest(reader);
|
||||
}
|
||||
|
||||
public async Task<ExceptionApprovalRequestEntity?> GetRequestAsync(
|
||||
string tenantId,
|
||||
string requestId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exception_approval_requests
|
||||
WHERE tenant_id = @tenant_id AND request_id = @request_id
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "request_id", requestId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return MapApprovalRequest(reader);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<ExceptionApprovalRequestEntity?> GetRequestByIdAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exception_approval_requests
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return MapApprovalRequest(reader);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListRequestsAsync(
|
||||
string tenantId,
|
||||
ApprovalRequestStatus? status = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM policy.exception_approval_requests
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
sql += " AND status = @status";
|
||||
}
|
||||
|
||||
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
if (status.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "status", status.Value.ToString().ToLowerInvariant());
|
||||
}
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
|
||||
var results = new List<ExceptionApprovalRequestEntity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapApprovalRequest(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListPendingForApproverAsync(
|
||||
string tenantId,
|
||||
string approverId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exception_approval_requests
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND status IN ('pending', 'partial')
|
||||
AND @approver_id = ANY(required_approver_ids)
|
||||
AND NOT (@approver_id = ANY(approved_by_ids))
|
||||
ORDER BY created_at ASC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "approver_id", approverId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
|
||||
var results = new List<ExceptionApprovalRequestEntity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapApprovalRequest(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListByRequestorAsync(
|
||||
string tenantId,
|
||||
string requestorId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exception_approval_requests
|
||||
WHERE tenant_id = @tenant_id AND requestor_id = @requestor_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "requestor_id", requestorId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
|
||||
var results = new List<ExceptionApprovalRequestEntity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapApprovalRequest(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateRequestAsync(
|
||||
ExceptionApprovalRequestEntity request,
|
||||
int expectedVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.exception_approval_requests
|
||||
SET approved_by_ids = @approved_by_ids,
|
||||
rejected_by_id = @rejected_by_id,
|
||||
status = @status,
|
||||
resolved_at = @resolved_at,
|
||||
rejection_reason = @rejection_reason,
|
||||
version = @new_version,
|
||||
updated_at = @updated_at
|
||||
WHERE tenant_id = @tenant_id AND request_id = @request_id AND version = @expected_version
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(request.TenantId, "writer", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "tenant_id", request.TenantId);
|
||||
AddParameter(cmd, "request_id", request.RequestId);
|
||||
AddParameter(cmd, "approved_by_ids", request.ApprovedByIds);
|
||||
AddParameter(cmd, "rejected_by_id", request.RejectedById);
|
||||
AddParameter(cmd, "status", request.Status.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "resolved_at", request.ResolvedAt);
|
||||
AddParameter(cmd, "rejection_reason", request.RejectionReason);
|
||||
AddParameter(cmd, "new_version", request.Version);
|
||||
AddParameter(cmd, "expected_version", expectedVersion);
|
||||
AddParameter(cmd, "updated_at", request.UpdatedAt);
|
||||
|
||||
var rows = await cmd.ExecuteNonQueryAsync(ct);
|
||||
return rows == 1;
|
||||
}
|
||||
|
||||
public async Task<ExceptionApprovalRequestEntity?> ApproveAsync(
|
||||
string tenantId,
|
||||
string requestId,
|
||||
string approverId,
|
||||
string? comment,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = await GetRequestAsync(tenantId, requestId, ct);
|
||||
if (request is null)
|
||||
return null;
|
||||
|
||||
if (request.Status is not (ApprovalRequestStatus.Pending or ApprovalRequestStatus.Partial))
|
||||
return request;
|
||||
|
||||
// Add approver to approved list
|
||||
var approvedByIds = request.ApprovedByIds.Append(approverId).Distinct().ToArray();
|
||||
var requiredCount = request.RequiredApproverIds.Length;
|
||||
var approvedCount = approvedByIds.Length;
|
||||
|
||||
var newStatus = approvedCount >= requiredCount
|
||||
? ApprovalRequestStatus.Approved
|
||||
: ApprovalRequestStatus.Partial;
|
||||
|
||||
var updated = request with
|
||||
{
|
||||
ApprovedByIds = approvedByIds,
|
||||
Status = newStatus,
|
||||
ResolvedAt = newStatus == ApprovalRequestStatus.Approved ? DateTimeOffset.UtcNow : null,
|
||||
Version = request.Version + 1,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (await UpdateRequestAsync(updated, request.Version, ct))
|
||||
{
|
||||
// Record audit entry
|
||||
await RecordAuditAsync(new ExceptionApprovalAuditEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RequestId = requestId,
|
||||
TenantId = tenantId,
|
||||
SequenceNumber = await GetNextSequenceAsync(tenantId, requestId, ct),
|
||||
ActionType = "approved",
|
||||
ActorId = approverId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = request.Status.ToString().ToLowerInvariant(),
|
||||
NewStatus = newStatus.ToString().ToLowerInvariant(),
|
||||
Description = comment ?? $"Approved by {approverId}"
|
||||
}, ct);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<ExceptionApprovalRequestEntity?> RejectAsync(
|
||||
string tenantId,
|
||||
string requestId,
|
||||
string rejectorId,
|
||||
string reason,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = await GetRequestAsync(tenantId, requestId, ct);
|
||||
if (request is null)
|
||||
return null;
|
||||
|
||||
if (request.Status is not (ApprovalRequestStatus.Pending or ApprovalRequestStatus.Partial))
|
||||
return request;
|
||||
|
||||
var updated = request with
|
||||
{
|
||||
RejectedById = rejectorId,
|
||||
Status = ApprovalRequestStatus.Rejected,
|
||||
ResolvedAt = DateTimeOffset.UtcNow,
|
||||
RejectionReason = reason,
|
||||
Version = request.Version + 1,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (await UpdateRequestAsync(updated, request.Version, ct))
|
||||
{
|
||||
await RecordAuditAsync(new ExceptionApprovalAuditEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RequestId = requestId,
|
||||
TenantId = tenantId,
|
||||
SequenceNumber = await GetNextSequenceAsync(tenantId, requestId, ct),
|
||||
ActionType = "rejected",
|
||||
ActorId = rejectorId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = request.Status.ToString().ToLowerInvariant(),
|
||||
NewStatus = "rejected",
|
||||
Description = reason
|
||||
}, ct);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> CancelRequestAsync(
|
||||
string tenantId,
|
||||
string requestId,
|
||||
string actorId,
|
||||
string? reason,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = await GetRequestAsync(tenantId, requestId, ct);
|
||||
if (request is null)
|
||||
return false;
|
||||
|
||||
if (request.Status is not (ApprovalRequestStatus.Pending or ApprovalRequestStatus.Partial))
|
||||
return false;
|
||||
|
||||
var updated = request with
|
||||
{
|
||||
Status = ApprovalRequestStatus.Cancelled,
|
||||
ResolvedAt = DateTimeOffset.UtcNow,
|
||||
Version = request.Version + 1,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (await UpdateRequestAsync(updated, request.Version, ct))
|
||||
{
|
||||
await RecordAuditAsync(new ExceptionApprovalAuditEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RequestId = requestId,
|
||||
TenantId = tenantId,
|
||||
SequenceNumber = await GetNextSequenceAsync(tenantId, requestId, ct),
|
||||
ActionType = "cancelled",
|
||||
ActorId = actorId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = request.Status.ToString().ToLowerInvariant(),
|
||||
NewStatus = "cancelled",
|
||||
Description = reason ?? "Request cancelled by requestor"
|
||||
}, ct);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<int> ExpirePendingRequestsAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string sql = "SELECT policy.expire_pending_approval_requests()";
|
||||
|
||||
await using var conn = await DataSource.OpenSystemConnectionAsync(ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Audit Trail Operations
|
||||
// ========================================================================
|
||||
|
||||
public async Task<ExceptionApprovalAuditEntity> RecordAuditAsync(
|
||||
ExceptionApprovalAuditEntity audit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.exception_approval_audit (
|
||||
id, request_id, tenant_id, sequence_number, action_type,
|
||||
actor_id, occurred_at, previous_status, new_status,
|
||||
description, details, client_info
|
||||
)
|
||||
VALUES (
|
||||
@id, @request_id, @tenant_id, @sequence_number, @action_type,
|
||||
@actor_id, @occurred_at, @previous_status, @new_status,
|
||||
@description, @details::jsonb, @client_info::jsonb
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(audit.TenantId, "writer", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "id", audit.Id);
|
||||
AddParameter(cmd, "request_id", audit.RequestId);
|
||||
AddParameter(cmd, "tenant_id", audit.TenantId);
|
||||
AddParameter(cmd, "sequence_number", audit.SequenceNumber);
|
||||
AddParameter(cmd, "action_type", audit.ActionType);
|
||||
AddParameter(cmd, "actor_id", audit.ActorId);
|
||||
AddParameter(cmd, "occurred_at", audit.OccurredAt);
|
||||
AddParameter(cmd, "previous_status", audit.PreviousStatus);
|
||||
AddParameter(cmd, "new_status", audit.NewStatus);
|
||||
AddParameter(cmd, "description", audit.Description);
|
||||
AddParameter(cmd, "details", audit.Details);
|
||||
AddParameter(cmd, "client_info", audit.ClientInfo);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
await reader.ReadAsync(ct);
|
||||
return MapAuditEntry(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExceptionApprovalAuditEntity>> GetAuditTrailAsync(
|
||||
string tenantId,
|
||||
string requestId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exception_approval_audit
|
||||
WHERE tenant_id = @tenant_id AND request_id = @request_id
|
||||
ORDER BY sequence_number ASC
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "request_id", requestId);
|
||||
|
||||
var results = new List<ExceptionApprovalAuditEntity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapAuditEntry(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<int> GetNextSequenceAsync(string tenantId, string requestId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COALESCE(MAX(sequence_number), 0) + 1
|
||||
FROM policy.exception_approval_audit
|
||||
WHERE request_id = @request_id
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "request_id", requestId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Approval Rules Operations
|
||||
// ========================================================================
|
||||
|
||||
public async Task<ExceptionApprovalRuleEntity?> GetApprovalRuleAsync(
|
||||
string tenantId,
|
||||
GateLevel gateLevel,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exception_approval_rules
|
||||
WHERE (tenant_id = @tenant_id OR tenant_id = '__default__')
|
||||
AND gate_level = @gate_level
|
||||
AND enabled = true
|
||||
ORDER BY
|
||||
CASE WHEN tenant_id = @tenant_id THEN 0 ELSE 1 END,
|
||||
priority DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "gate_level", (int)gateLevel);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return MapApprovalRule(reader);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExceptionApprovalRuleEntity>> ListApprovalRulesAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exception_approval_rules
|
||||
WHERE tenant_id = @tenant_id OR tenant_id = '__default__'
|
||||
ORDER BY gate_level, priority DESC
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
|
||||
var results = new List<ExceptionApprovalRuleEntity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapApprovalRule(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ExceptionApprovalRuleEntity> UpsertApprovalRuleAsync(
|
||||
ExceptionApprovalRuleEntity rule,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.exception_approval_rules (
|
||||
id, tenant_id, name, description, gate_level, min_approvers,
|
||||
required_roles, max_ttl_days, allow_self_approval, require_evidence,
|
||||
require_compensating_controls, min_rationale_length, priority, enabled,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @name, @description, @gate_level, @min_approvers,
|
||||
@required_roles, @max_ttl_days, @allow_self_approval, @require_evidence,
|
||||
@require_compensating_controls, @min_rationale_length, @priority, @enabled,
|
||||
@created_at, @updated_at
|
||||
)
|
||||
ON CONFLICT (tenant_id, gate_level, name) DO UPDATE SET
|
||||
description = EXCLUDED.description,
|
||||
min_approvers = EXCLUDED.min_approvers,
|
||||
required_roles = EXCLUDED.required_roles,
|
||||
max_ttl_days = EXCLUDED.max_ttl_days,
|
||||
allow_self_approval = EXCLUDED.allow_self_approval,
|
||||
require_evidence = EXCLUDED.require_evidence,
|
||||
require_compensating_controls = EXCLUDED.require_compensating_controls,
|
||||
min_rationale_length = EXCLUDED.min_rationale_length,
|
||||
priority = EXCLUDED.priority,
|
||||
enabled = EXCLUDED.enabled,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(rule.TenantId, "writer", ct);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
AddParameter(cmd, "id", rule.Id);
|
||||
AddParameter(cmd, "tenant_id", rule.TenantId);
|
||||
AddParameter(cmd, "name", rule.Name);
|
||||
AddParameter(cmd, "description", rule.Description);
|
||||
AddParameter(cmd, "gate_level", (int)rule.GateLevel);
|
||||
AddParameter(cmd, "min_approvers", rule.MinApprovers);
|
||||
AddParameter(cmd, "required_roles", rule.RequiredRoles);
|
||||
AddParameter(cmd, "max_ttl_days", rule.MaxTtlDays);
|
||||
AddParameter(cmd, "allow_self_approval", rule.AllowSelfApproval);
|
||||
AddParameter(cmd, "require_evidence", rule.RequireEvidence);
|
||||
AddParameter(cmd, "require_compensating_controls", rule.RequireCompensatingControls);
|
||||
AddParameter(cmd, "min_rationale_length", rule.MinRationaleLength);
|
||||
AddParameter(cmd, "priority", rule.Priority);
|
||||
AddParameter(cmd, "enabled", rule.Enabled);
|
||||
AddParameter(cmd, "created_at", rule.CreatedAt);
|
||||
AddParameter(cmd, "updated_at", rule.UpdatedAt);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
await reader.ReadAsync(ct);
|
||||
return MapApprovalRule(reader);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Mapping Helpers
|
||||
// ========================================================================
|
||||
|
||||
private static ExceptionApprovalRequestEntity MapApprovalRequest(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ExceptionApprovalRequestEntity
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
RequestId = reader.GetString(reader.GetOrdinal("request_id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
ExceptionId = GetNullableString(reader, reader.GetOrdinal("exception_id")),
|
||||
RequestorId = reader.GetString(reader.GetOrdinal("requestor_id")),
|
||||
RequiredApproverIds = GetStringArray(reader, "required_approver_ids"),
|
||||
ApprovedByIds = GetStringArray(reader, "approved_by_ids"),
|
||||
RejectedById = GetNullableString(reader, reader.GetOrdinal("rejected_by_id")),
|
||||
Status = ParseApprovalStatus(reader.GetString(reader.GetOrdinal("status"))),
|
||||
GateLevel = (GateLevel)reader.GetInt32(reader.GetOrdinal("gate_level")),
|
||||
Justification = reader.GetString(reader.GetOrdinal("justification")),
|
||||
Rationale = GetNullableString(reader, reader.GetOrdinal("rationale")),
|
||||
ReasonCode = ParseReasonCode(reader.GetString(reader.GetOrdinal("reason_code"))),
|
||||
EvidenceRefs = reader.GetString(reader.GetOrdinal("evidence_refs")),
|
||||
CompensatingControls = reader.GetString(reader.GetOrdinal("compensating_controls")),
|
||||
TicketRef = GetNullableString(reader, reader.GetOrdinal("ticket_ref")),
|
||||
VulnerabilityId = GetNullableString(reader, reader.GetOrdinal("vulnerability_id")),
|
||||
PurlPattern = GetNullableString(reader, reader.GetOrdinal("purl_pattern")),
|
||||
ArtifactDigest = GetNullableString(reader, reader.GetOrdinal("artifact_digest")),
|
||||
ImagePattern = GetNullableString(reader, reader.GetOrdinal("image_pattern")),
|
||||
Environments = GetStringArray(reader, "environments"),
|
||||
RequestedTtlDays = reader.GetInt32(reader.GetOrdinal("requested_ttl_days")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
RequestExpiresAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("request_expires_at")),
|
||||
ExceptionExpiresAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("exception_expires_at")),
|
||||
ResolvedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("resolved_at")),
|
||||
RejectionReason = GetNullableString(reader, reader.GetOrdinal("rejection_reason")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
Version = reader.GetInt32(reader.GetOrdinal("version")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
|
||||
};
|
||||
}
|
||||
|
||||
private static ExceptionApprovalAuditEntity MapAuditEntry(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ExceptionApprovalAuditEntity
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
RequestId = reader.GetString(reader.GetOrdinal("request_id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
SequenceNumber = reader.GetInt32(reader.GetOrdinal("sequence_number")),
|
||||
ActionType = reader.GetString(reader.GetOrdinal("action_type")),
|
||||
ActorId = reader.GetString(reader.GetOrdinal("actor_id")),
|
||||
OccurredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("occurred_at")),
|
||||
PreviousStatus = GetNullableString(reader, reader.GetOrdinal("previous_status")),
|
||||
NewStatus = reader.GetString(reader.GetOrdinal("new_status")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
Details = reader.GetString(reader.GetOrdinal("details")),
|
||||
ClientInfo = reader.GetString(reader.GetOrdinal("client_info"))
|
||||
};
|
||||
}
|
||||
|
||||
private static ExceptionApprovalRuleEntity MapApprovalRule(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ExceptionApprovalRuleEntity
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
GateLevel = (GateLevel)reader.GetInt32(reader.GetOrdinal("gate_level")),
|
||||
MinApprovers = reader.GetInt32(reader.GetOrdinal("min_approvers")),
|
||||
RequiredRoles = GetStringArray(reader, "required_roles"),
|
||||
MaxTtlDays = reader.GetInt32(reader.GetOrdinal("max_ttl_days")),
|
||||
AllowSelfApproval = reader.GetBoolean(reader.GetOrdinal("allow_self_approval")),
|
||||
RequireEvidence = reader.GetBoolean(reader.GetOrdinal("require_evidence")),
|
||||
RequireCompensatingControls = reader.GetBoolean(reader.GetOrdinal("require_compensating_controls")),
|
||||
MinRationaleLength = reader.GetInt32(reader.GetOrdinal("min_rationale_length")),
|
||||
Priority = reader.GetInt32(reader.GetOrdinal("priority")),
|
||||
Enabled = reader.GetBoolean(reader.GetOrdinal("enabled")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
|
||||
};
|
||||
}
|
||||
|
||||
private static string[] GetStringArray(NpgsqlDataReader reader, string columnName)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(columnName);
|
||||
if (reader.IsDBNull(ordinal))
|
||||
return [];
|
||||
|
||||
return reader.GetFieldValue<string[]>(ordinal) ?? [];
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetNullableDateTimeOffset(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetFieldValue<DateTimeOffset>(ordinal);
|
||||
}
|
||||
|
||||
private static ApprovalRequestStatus ParseApprovalStatus(string status) => status switch
|
||||
{
|
||||
"pending" => ApprovalRequestStatus.Pending,
|
||||
"partial" => ApprovalRequestStatus.Partial,
|
||||
"approved" => ApprovalRequestStatus.Approved,
|
||||
"rejected" => ApprovalRequestStatus.Rejected,
|
||||
"expired" => ApprovalRequestStatus.Expired,
|
||||
"cancelled" => ApprovalRequestStatus.Cancelled,
|
||||
_ => ApprovalRequestStatus.Pending
|
||||
};
|
||||
|
||||
private static ExceptionReasonCode ParseReasonCode(string code) => code switch
|
||||
{
|
||||
"false_positive" => ExceptionReasonCode.FalsePositive,
|
||||
"accepted_risk" => ExceptionReasonCode.AcceptedRisk,
|
||||
"compensating_control" => ExceptionReasonCode.CompensatingControl,
|
||||
"test_only" => ExceptionReasonCode.TestOnly,
|
||||
"vendor_not_affected" => ExceptionReasonCode.VendorNotAffected,
|
||||
"scheduled_fix" => ExceptionReasonCode.ScheduledFix,
|
||||
"deprecation_in_progress" => ExceptionReasonCode.DeprecationInProgress,
|
||||
"runtime_mitigation" => ExceptionReasonCode.RuntimeMitigation,
|
||||
"network_isolation" => ExceptionReasonCode.NetworkIsolation,
|
||||
_ => ExceptionReasonCode.Other
|
||||
};
|
||||
|
||||
private static string MapReasonCode(ExceptionReasonCode code) => code switch
|
||||
{
|
||||
ExceptionReasonCode.FalsePositive => "false_positive",
|
||||
ExceptionReasonCode.AcceptedRisk => "accepted_risk",
|
||||
ExceptionReasonCode.CompensatingControl => "compensating_control",
|
||||
ExceptionReasonCode.TestOnly => "test_only",
|
||||
ExceptionReasonCode.VendorNotAffected => "vendor_not_affected",
|
||||
ExceptionReasonCode.ScheduledFix => "scheduled_fix",
|
||||
ExceptionReasonCode.DeprecationInProgress => "deprecation_in_progress",
|
||||
ExceptionReasonCode.RuntimeMitigation => "runtime_mitigation",
|
||||
ExceptionReasonCode.NetworkIsolation => "network_isolation",
|
||||
_ => "other"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for exception approval workflow operations.
|
||||
/// </summary>
|
||||
public interface IExceptionApprovalRepository
|
||||
{
|
||||
// ========================================================================
|
||||
// Approval Request Operations
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new approval request.
|
||||
/// </summary>
|
||||
Task<ExceptionApprovalRequestEntity> CreateRequestAsync(
|
||||
ExceptionApprovalRequestEntity request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an approval request by ID.
|
||||
/// </summary>
|
||||
Task<ExceptionApprovalRequestEntity?> GetRequestAsync(
|
||||
string tenantId,
|
||||
string requestId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an approval request by internal UUID.
|
||||
/// </summary>
|
||||
Task<ExceptionApprovalRequestEntity?> GetRequestByIdAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists approval requests for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListRequestsAsync(
|
||||
string tenantId,
|
||||
ApprovalRequestStatus? status = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists pending approval requests for an approver.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListPendingForApproverAsync(
|
||||
string tenantId,
|
||||
string approverId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists approval requests by requestor.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListByRequestorAsync(
|
||||
string tenantId,
|
||||
string requestorId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an approval request with optimistic concurrency.
|
||||
/// </summary>
|
||||
Task<bool> UpdateRequestAsync(
|
||||
ExceptionApprovalRequestEntity request,
|
||||
int expectedVersion,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records an approval action.
|
||||
/// </summary>
|
||||
Task<ExceptionApprovalRequestEntity?> ApproveAsync(
|
||||
string tenantId,
|
||||
string requestId,
|
||||
string approverId,
|
||||
string? comment,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records a rejection action.
|
||||
/// </summary>
|
||||
Task<ExceptionApprovalRequestEntity?> RejectAsync(
|
||||
string tenantId,
|
||||
string requestId,
|
||||
string rejectorId,
|
||||
string reason,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels an approval request (by requestor).
|
||||
/// </summary>
|
||||
Task<bool> CancelRequestAsync(
|
||||
string tenantId,
|
||||
string requestId,
|
||||
string actorId,
|
||||
string? reason,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Expires pending requests past their expiry time.
|
||||
/// </summary>
|
||||
Task<int> ExpirePendingRequestsAsync(CancellationToken ct = default);
|
||||
|
||||
// ========================================================================
|
||||
// Audit Trail Operations
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Records an audit entry.
|
||||
/// </summary>
|
||||
Task<ExceptionApprovalAuditEntity> RecordAuditAsync(
|
||||
ExceptionApprovalAuditEntity audit,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets audit trail for a request.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExceptionApprovalAuditEntity>> GetAuditTrailAsync(
|
||||
string tenantId,
|
||||
string requestId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// ========================================================================
|
||||
// Approval Rules Operations
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Gets approval rules for a gate level.
|
||||
/// </summary>
|
||||
Task<ExceptionApprovalRuleEntity?> GetApprovalRuleAsync(
|
||||
string tenantId,
|
||||
GateLevel gateLevel,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all approval rules for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExceptionApprovalRuleEntity>> ListApprovalRulesAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates an approval rule.
|
||||
/// </summary>
|
||||
Task<ExceptionApprovalRuleEntity> UpsertApprovalRuleAsync(
|
||||
ExceptionApprovalRuleEntity rule,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
using Xunit;
|
||||
using StellaOps.Policy.Engine.AdvisoryAI;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class AdvisoryAiKnobsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Get_ReturnsDefaultsWithHash()
|
||||
{
|
||||
var service = new AdvisoryAiKnobsService(TimeProvider.System);
|
||||
@@ -15,7 +17,8 @@ public sealed class AdvisoryAiKnobsServiceTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Set_NormalizesOrdering()
|
||||
{
|
||||
var service = new AdvisoryAiKnobsService(TimeProvider.System);
|
||||
|
||||
@@ -2,11 +2,13 @@ using Xunit;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class EvidenceSummaryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Summarize_BuildsDeterministicSummary()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 11, 26, 0, 0, 0, TimeSpan.Zero));
|
||||
@@ -33,7 +35,8 @@ public sealed class EvidenceSummaryServiceTests
|
||||
response.Summary.Signals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Summarize_RequiresEvidenceHash()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(DateTimeOffset.UnixEpoch);
|
||||
|
||||
@@ -3,11 +3,13 @@ using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class LedgerExportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_ProducesOrderedNdjson()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T15:00:00Z"));
|
||||
|
||||
@@ -2,11 +2,13 @@ using Xunit;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class OrchestratorJobServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SubmitAsync_NormalizesOrderingAndHashes()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T10:00:00Z"));
|
||||
@@ -39,7 +41,8 @@ public sealed class OrchestratorJobServiceTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(job.DeterminismHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SubmitAsync_IsDeterministicAcrossOrdering()
|
||||
{
|
||||
var requestedAt = DateTimeOffset.Parse("2025-11-24T11:00:00Z");
|
||||
@@ -75,7 +78,8 @@ public sealed class OrchestratorJobServiceTests
|
||||
Assert.Equal(first.DeterminismHash, second.DeterminismHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Preview_DoesNotPersist()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T12:00:00Z"));
|
||||
|
||||
@@ -4,11 +4,13 @@ using StellaOps.Policy.Engine.Overlay;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class OverlayProjectionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildSnapshotAsync_ProducesHeaderAndSortedProjections()
|
||||
{
|
||||
var service = new OverlayProjectionService(new PolicyEvaluationService(), TimeProvider.System);
|
||||
|
||||
@@ -6,13 +6,15 @@ using StellaOps.Policy.Engine.Tests.Fakes;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PathScopeSimulationBridgeServiceTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SimulateAsync_OrdersByInputAndProducesMetrics()
|
||||
{
|
||||
var bridge = CreateBridge();
|
||||
@@ -37,7 +39,8 @@ public sealed class PathScopeSimulationBridgeServiceTests
|
||||
Assert.Equal(2, result.Metrics.Evaluated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WhatIfProducesDeltas()
|
||||
{
|
||||
var bridge = CreateBridge();
|
||||
@@ -57,7 +60,8 @@ public sealed class PathScopeSimulationBridgeServiceTests
|
||||
Assert.Single(result.Deltas!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SimulateAsync_PublishesEventsAndSavesOverlays()
|
||||
{
|
||||
var sink = new FakeOverlayEventSink();
|
||||
|
||||
@@ -3,11 +3,13 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PathScopeSimulationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StreamAsync_ReturnsDeterministicOrdering()
|
||||
{
|
||||
var service = new PathScopeSimulationService();
|
||||
@@ -32,7 +34,8 @@ public sealed class PathScopeSimulationServiceTests
|
||||
Assert.Contains("\"filePath\":\"b/file.js\"", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StreamAsync_ThrowsOnMissingTarget()
|
||||
{
|
||||
var service = new PathScopeSimulationService();
|
||||
|
||||
@@ -6,11 +6,13 @@ using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public class PolicyActivationAuditorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordActivation_WhenDisabled_DoesNothing()
|
||||
{
|
||||
var options = new PolicyEngineOptions();
|
||||
@@ -24,7 +26,8 @@ public class PolicyActivationAuditorTests
|
||||
Assert.Empty(logger.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordActivation_WhenEnabled_WritesScopedLog()
|
||||
{
|
||||
var options = new PolicyEngineOptions();
|
||||
|
||||
@@ -2,11 +2,13 @@ using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public class PolicyActivationSettingsTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveRequirement_WhenForceEnabled_IgnoresRequest()
|
||||
{
|
||||
var options = new PolicyEngineOptions();
|
||||
@@ -17,7 +19,8 @@ public class PolicyActivationSettingsTests
|
||||
Assert.True(settings.ResolveRequirement(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveRequirement_UsesRequestedValue_WhenProvided()
|
||||
{
|
||||
var options = new PolicyEngineOptions();
|
||||
@@ -27,7 +30,8 @@ public class PolicyActivationSettingsTests
|
||||
Assert.False(settings.ResolveRequirement(false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveRequirement_FallsBackToDefault_WhenRequestMissing()
|
||||
{
|
||||
var options = new PolicyEngineOptions();
|
||||
|
||||
@@ -8,6 +8,7 @@ using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyBundleServiceTests
|
||||
@@ -18,7 +19,8 @@ public sealed class PolicyBundleServiceTests
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CompileAndStoreAsync_SucceedsAndStoresBundle()
|
||||
{
|
||||
var services = CreateServices();
|
||||
@@ -32,7 +34,8 @@ public sealed class PolicyBundleServiceTests
|
||||
Assert.True(response.SizeBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CompileAndStoreAsync_FailsWithBadSyntax()
|
||||
{
|
||||
var services = CreateServices();
|
||||
@@ -45,7 +48,8 @@ public sealed class PolicyBundleServiceTests
|
||||
Assert.NotEmpty(response.Diagnostics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CompileAndStoreAsync_ReturnsAocMetadata()
|
||||
{
|
||||
var services = CreateServices();
|
||||
@@ -63,7 +67,8 @@ public sealed class PolicyBundleServiceTests
|
||||
Assert.True(response.AocMetadata.ComplexityScore >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CompileAndStoreAsync_IncludesProvenanceWhenProvided()
|
||||
{
|
||||
var services = CreateServices();
|
||||
@@ -95,7 +100,8 @@ public sealed class PolicyBundleServiceTests
|
||||
Assert.Equal("main", bundle.AocMetadata.Provenance.Branch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CompileAndStoreAsync_NullAocMetadataOnFailure()
|
||||
{
|
||||
var services = CreateServices();
|
||||
@@ -107,7 +113,8 @@ public sealed class PolicyBundleServiceTests
|
||||
Assert.Null(response.AocMetadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CompileAndStoreAsync_SourceDigestIsDeterministic()
|
||||
{
|
||||
var services = CreateServices();
|
||||
|
||||
@@ -7,6 +7,7 @@ using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyCompilationServiceTests
|
||||
@@ -27,7 +28,8 @@ public sealed class PolicyCompilationServiceTests
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_ReturnsComplexityReport_WhenWithinLimits()
|
||||
{
|
||||
var service = CreateService(maxComplexityScore: 1000, maxDurationMilliseconds: 1000, simulatedDurationMilliseconds: 12.3);
|
||||
@@ -44,7 +46,8 @@ public sealed class PolicyCompilationServiceTests
|
||||
Assert.True(result.Diagnostics.IsDefaultOrEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_Fails_WhenComplexityExceedsThreshold()
|
||||
{
|
||||
var service = CreateService(maxComplexityScore: 1, maxDurationMilliseconds: 1000, simulatedDurationMilliseconds: 2);
|
||||
@@ -60,7 +63,8 @@ public sealed class PolicyCompilationServiceTests
|
||||
Assert.Equal(PolicyIssueSeverity.Error, diagnostic.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_Fails_WhenDurationExceedsThreshold()
|
||||
{
|
||||
var service = CreateService(maxComplexityScore: 1000, maxDurationMilliseconds: 1, simulatedDurationMilliseconds: 5.2);
|
||||
|
||||
@@ -5,11 +5,13 @@ using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyCompilerTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_BaselinePolicy_Succeeds()
|
||||
{
|
||||
const string source = """
|
||||
@@ -79,7 +81,8 @@ public sealed class PolicyCompilerTests
|
||||
Assert.Equal("status", firstAction.Target[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_MissingBecause_ReportsDiagnostic()
|
||||
{
|
||||
const string source = """
|
||||
|
||||
@@ -8,6 +8,7 @@ using StellaOps.Policy.Engine.Snapshots;
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
using StellaOps.Policy.Engine.Violations;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyDecisionServiceTests
|
||||
@@ -78,7 +79,8 @@ public sealed class PolicyDecisionServiceTests
|
||||
return (decisionService, snapshot.SnapshotId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetDecisionsAsync_ReturnsDecisionsWithEvidence()
|
||||
{
|
||||
var (service, snapshotId) = BuildService();
|
||||
@@ -97,7 +99,8 @@ public sealed class PolicyDecisionServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetDecisionsAsync_BuildsSummaryStatistics()
|
||||
{
|
||||
var (service, snapshotId) = BuildService();
|
||||
@@ -110,7 +113,8 @@ public sealed class PolicyDecisionServiceTests
|
||||
Assert.NotEmpty(response.Summary.TopSeveritySources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetDecisionsAsync_FiltersById()
|
||||
{
|
||||
var (service, snapshotId) = BuildService();
|
||||
@@ -124,7 +128,8 @@ public sealed class PolicyDecisionServiceTests
|
||||
Assert.Equal("CVE-2021-44228", response.Decisions[0].AdvisoryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetDecisionsAsync_FiltersByTenant()
|
||||
{
|
||||
var (service, snapshotId) = BuildService();
|
||||
@@ -137,7 +142,8 @@ public sealed class PolicyDecisionServiceTests
|
||||
Assert.All(response.Decisions, d => Assert.Equal("acme", d.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetDecisionsAsync_LimitsTopSources()
|
||||
{
|
||||
var (service, snapshotId) = BuildService();
|
||||
@@ -153,7 +159,8 @@ public sealed class PolicyDecisionServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetDecisionsAsync_ExcludesEvidenceWhenNotRequested()
|
||||
{
|
||||
var (service, snapshotId) = BuildService();
|
||||
@@ -166,7 +173,8 @@ public sealed class PolicyDecisionServiceTests
|
||||
Assert.All(response.Decisions, d => Assert.Null(d.Evidence));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetDecisionsAsync_ReturnsDeterministicOrder()
|
||||
{
|
||||
var (service, snapshotId) = BuildService();
|
||||
@@ -180,7 +188,8 @@ public sealed class PolicyDecisionServiceTests
|
||||
response2.Decisions.Select(d => d.ComponentPurl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetDecisionsAsync_ThrowsOnEmptySnapshotId()
|
||||
{
|
||||
var (service, _) = BuildService();
|
||||
@@ -189,7 +198,8 @@ public sealed class PolicyDecisionServiceTests
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.GetDecisionsAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetDecisionsAsync_TopSourcesHaveRanks()
|
||||
{
|
||||
var (service, snapshotId) = BuildService();
|
||||
|
||||
@@ -15,6 +15,7 @@ using StellaOps.Policy.Unknowns.Services;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyEvaluatorTests
|
||||
@@ -81,7 +82,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
private readonly PolicyCompiler compiler = new();
|
||||
private readonly PolicyEvaluationService evaluationService = new();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_BlockCriticalRuleMatches()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
@@ -94,7 +96,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.Equal("blocked", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_EscalateAdjustsSeverity()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
@@ -108,7 +111,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.Equal("Critical", result.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_VexOverrideSetsStatusAndAnnotation()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
@@ -127,7 +131,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.Equal("stmt-001", result.Annotations["winning_statement"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_WarnRuleEmitsWarning()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
@@ -145,7 +150,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.Contains(result.Warnings, message => message.Contains("EOL", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_ExceptionSuppressesCriticalFinding()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
@@ -183,7 +189,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.Equal("suppressed", result.Annotations["exception.status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_ExceptionDowngradesSeverity()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
@@ -223,7 +230,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.Equal("Medium", result.Annotations["exception.severity"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_MoreSpecificExceptionWins()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
@@ -283,7 +291,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_RubyDevComponentBlocked()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
@@ -309,7 +318,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.Equal("blocked", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_RubyGitComponentWarns()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
@@ -337,7 +347,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.Contains(result.Warnings, warning => warning.Contains("Git-sourced", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_UnknownBudgetExceeded_BlocksEvaluation()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
@@ -602,7 +613,8 @@ policy "macOS Security Policy" syntax "stella-dsl@1" {
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_MacOs_UnsignedAppBlocked()
|
||||
{
|
||||
var document = compiler.Compile(MacOsPolicy);
|
||||
@@ -632,7 +644,8 @@ policy "macOS Security Policy" syntax "stella-dsl@1" {
|
||||
Assert.Equal("blocked", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_MacOs_SignedAppPasses()
|
||||
{
|
||||
var document = compiler.Compile(MacOsPolicy);
|
||||
@@ -660,7 +673,8 @@ policy "macOS Security Policy" syntax "stella-dsl@1" {
|
||||
Assert.False(result.Matched && result.Status == "blocked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_MacOs_HighRiskEntitlementsWarns()
|
||||
{
|
||||
var document = compiler.Compile(MacOsPolicy);
|
||||
@@ -693,7 +707,8 @@ policy "macOS Security Policy" syntax "stella-dsl@1" {
|
||||
Assert.Equal("warned", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_MacOs_CategoryMatchesCameraAccess()
|
||||
{
|
||||
var document = compiler.Compile(MacOsPolicy);
|
||||
@@ -727,7 +742,8 @@ policy "macOS Security Policy" syntax "stella-dsl@1" {
|
||||
result.Status == "warned");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_MacOs_HardenedRuntimeWarnsWhenMissing()
|
||||
{
|
||||
var document = compiler.Compile(MacOsPolicy);
|
||||
|
||||
@@ -2,13 +2,15 @@ using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public class PolicyPackRepositoryTests
|
||||
{
|
||||
private readonly InMemoryPolicyPackRepository repository = new();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ActivateRevision_WithSingleApprover_ActivatesImmediately()
|
||||
{
|
||||
await repository.CreateAsync("pack-1", "Pack", CancellationToken.None);
|
||||
@@ -22,7 +24,8 @@ public class PolicyPackRepositoryTests
|
||||
Assert.Single(result.Revision.Approvals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ActivateRevision_WithTwoPersonRequirement_ReturnsPendingUntilSecondApproval()
|
||||
{
|
||||
await repository.CreateAsync("pack-2", "Pack", CancellationToken.None);
|
||||
|
||||
@@ -12,6 +12,7 @@ using StellaOps.Policy.Engine.Signals.Entropy;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
@@ -38,7 +39,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsDecisionFromCompiledPolicy()
|
||||
{
|
||||
var harness = CreateHarness();
|
||||
@@ -55,7 +57,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.False(response.Cached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_UsesCacheOnSecondCall()
|
||||
{
|
||||
var harness = CreateHarness();
|
||||
@@ -75,7 +78,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.Equal(response1.CorrelationId, response2.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_BypassCacheWhenRequested()
|
||||
{
|
||||
var harness = CreateHarness();
|
||||
@@ -93,7 +97,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.False(response2.Cached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ThrowsOnMissingBundle()
|
||||
{
|
||||
var harness = CreateHarness();
|
||||
@@ -103,7 +108,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
() => harness.Service.EvaluateAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_GeneratesDeterministicCorrelationId()
|
||||
{
|
||||
var harness = CreateHarness();
|
||||
@@ -123,7 +129,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.Equal(response1.CorrelationId, response2.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateBatchAsync_ReturnsMultipleResults()
|
||||
{
|
||||
var harness = CreateHarness();
|
||||
@@ -141,7 +148,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.Equal(3, responses.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateBatchAsync_UsesCacheForDuplicates()
|
||||
{
|
||||
var harness = CreateHarness();
|
||||
@@ -164,7 +172,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.Contains(responses, r => !r.Cached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DifferentContextsGetDifferentCacheKeys()
|
||||
{
|
||||
var harness = CreateHarness();
|
||||
@@ -183,7 +192,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.NotEqual(response1.CorrelationId, response2.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EnrichesReachabilityFromFacts()
|
||||
{
|
||||
const string policy = """
|
||||
@@ -232,7 +242,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.Equal("warn", response.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_GatesUnreachableWithoutEvidenceRef_ToUnderInvestigation()
|
||||
{
|
||||
const string policy = """
|
||||
@@ -286,7 +297,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.Equal("under_investigation", response.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_GatesUnreachableWithLowConfidence_ToUnderInvestigation()
|
||||
{
|
||||
const string policy = """
|
||||
@@ -340,7 +352,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.Equal("under_investigation", response.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllowsUnreachableWithEvidenceRefAndHighConfidence()
|
||||
{
|
||||
const string policy = """
|
||||
|
||||
@@ -3,11 +3,13 @@ using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyRuntimeEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsDeterministicDecisionAndCaches()
|
||||
{
|
||||
var repo = new InMemoryPolicyPackRepository();
|
||||
@@ -35,7 +37,8 @@ public sealed class PolicyRuntimeEvaluatorTests
|
||||
Assert.Equal(1, first.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ThrowsWhenBundleMissing()
|
||||
{
|
||||
var evaluator = new PolicyRuntimeEvaluator(new InMemoryPolicyPackRepository());
|
||||
|
||||
@@ -2,11 +2,13 @@ using Xunit;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyWorkerServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsDeterministicResults()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T13:00:00Z"));
|
||||
@@ -45,7 +47,8 @@ public sealed class PolicyWorkerServiceTests
|
||||
Assert.Equal(result.ResultHash, fetched!.ResultHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IsIdempotentOnRetry()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T14:00:00Z"));
|
||||
|
||||
@@ -12,6 +12,7 @@ using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Provcache;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -49,7 +50,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
NullLogger<ProvcachePolicyEvaluationCache>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAsync_CacheHit_ReturnsEntry()
|
||||
{
|
||||
// Arrange
|
||||
@@ -69,7 +71,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
result.Source.Should().Be(CacheSource.Redis);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAsync_CacheMiss_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
@@ -88,7 +91,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
result.Source.Should().Be(CacheSource.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAsync_BypassHeader_SkipsCache()
|
||||
{
|
||||
// Arrange
|
||||
@@ -106,7 +110,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SetAsync_StoresEntryInProvcache()
|
||||
{
|
||||
// Arrange
|
||||
@@ -127,7 +132,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SetAsync_FailureDoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
@@ -142,7 +148,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
await _cache.SetAsync(key, entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InvalidateAsync_CallsProvcache()
|
||||
{
|
||||
// Arrange
|
||||
@@ -161,7 +168,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InvalidateByPolicyDigestAsync_InvalidatesAllMatchingEntries()
|
||||
{
|
||||
// Arrange
|
||||
@@ -194,7 +202,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetBatchAsync_ProcessesAllKeys()
|
||||
{
|
||||
// Arrange
|
||||
@@ -224,7 +233,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
result.NotFound.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetStats_ReturnsAccumulatedStatistics()
|
||||
{
|
||||
// Act
|
||||
@@ -235,7 +245,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
stats.TotalRequests.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAsync_WhenProvcacheThrows_TreatsAsMiss()
|
||||
{
|
||||
// Arrange
|
||||
@@ -253,7 +264,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
result.Entry.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VeriKey_Construction_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
@@ -334,7 +346,8 @@ public sealed class ProvcachePolicyEvaluationCacheTests
|
||||
/// </summary>
|
||||
public sealed class CacheBypassAccessorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpCacheBypassAccessor_NoHeader_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
@@ -348,7 +361,8 @@ public sealed class CacheBypassAccessorTests
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpCacheBypassAccessor_BypassHeaderTrue_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
@@ -363,7 +377,8 @@ public sealed class CacheBypassAccessorTests
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpCacheBypassAccessor_BypassHeaderFalse_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
@@ -378,7 +393,8 @@ public sealed class CacheBypassAccessorTests
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpCacheBypassAccessor_RefreshHeaderTrue_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
@@ -393,7 +409,8 @@ public sealed class CacheBypassAccessorTests
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpCacheBypassAccessor_BypassDisabledInOptions_AlwaysReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
@@ -408,7 +425,8 @@ public sealed class CacheBypassAccessorTests
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NullCacheBypassAccessor_AlwaysReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -4,11 +4,13 @@ using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class SnapshotServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_ProducesSnapshotFromLedger()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T16:00:00Z"));
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using Xunit;
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class TrustWeightingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Get_ReturnsDefaultsWithHash()
|
||||
{
|
||||
var service = new TrustWeightingService(TimeProvider.System);
|
||||
@@ -16,7 +18,8 @@ public sealed class TrustWeightingServiceTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Set_NormalizesOrderingAndScale()
|
||||
{
|
||||
var service = new TrustWeightingService(TimeProvider.System);
|
||||
|
||||
@@ -6,6 +6,7 @@ using StellaOps.Policy.Engine.Snapshots;
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
using StellaOps.Policy.Engine.Violations;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class ViolationServicesTests
|
||||
@@ -62,7 +63,8 @@ public sealed class ViolationServicesTests
|
||||
return (eventService, fusionService, conflictService, snapshot.SnapshotId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EmitAsync_BuildsEvents()
|
||||
{
|
||||
var (eventService, _, _, snapshotId) = BuildPipeline();
|
||||
@@ -73,7 +75,8 @@ public sealed class ViolationServicesTests
|
||||
Assert.All(events, e => Assert.Equal("policy.violation.detected", e.ViolationCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FuseAsync_ProducesWeightedSeverity()
|
||||
{
|
||||
var (eventService, fusionService, _, snapshotId) = BuildPipeline();
|
||||
@@ -85,7 +88,8 @@ public sealed class ViolationServicesTests
|
||||
Assert.All(fused, f => Assert.False(string.IsNullOrWhiteSpace(f.SeverityFused)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ConflictsAsync_DetectsDivergentSeverities()
|
||||
{
|
||||
var (eventService, fusionService, conflictService, snapshotId) = BuildPipeline();
|
||||
|
||||
@@ -5,11 +5,13 @@ using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
public sealed class EvidenceRequirementValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateForApprovalAsync_NoHooks_ReturnsValid()
|
||||
{
|
||||
var validator = CreateValidator(new StubHookRegistry([]));
|
||||
@@ -21,7 +23,8 @@ public sealed class EvidenceRequirementValidatorTests
|
||||
result.MissingEvidence.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateForApprovalAsync_MissingEvidence_ReturnsInvalid()
|
||||
{
|
||||
var hooks = ImmutableArray.Create(new EvidenceHook
|
||||
@@ -41,7 +44,8 @@ public sealed class EvidenceRequirementValidatorTests
|
||||
result.MissingEvidence.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateForApprovalAsync_TrustScoreTooLow_ReturnsInvalid()
|
||||
{
|
||||
var hooks = ImmutableArray.Create(new EvidenceHook
|
||||
|
||||
@@ -3,11 +3,13 @@ using FluentAssertions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
public sealed class EvidenceRequirementsTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceRequirements_ShouldBeSatisfied_WhenAllMandatoryHooksValid()
|
||||
{
|
||||
var hooks = ImmutableArray.Create(
|
||||
@@ -47,7 +49,8 @@ public sealed class EvidenceRequirementsTests
|
||||
requirements.MissingEvidence.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceRequirements_ShouldReportMissing_WhenMandatoryHookMissing()
|
||||
{
|
||||
var hooks = ImmutableArray.Create(new EvidenceHook
|
||||
|
||||
@@ -6,6 +6,7 @@ using StellaOps.Policy.Exceptions.Repositories;
|
||||
using StellaOps.Policy.Exceptions.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -22,7 +23,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
_evaluator = new ExceptionEvaluator(_repositoryMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenNoExceptionsFound_ShouldReturnNoMatch()
|
||||
{
|
||||
// Arrange
|
||||
@@ -45,7 +47,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.PrimaryRationale.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionMatchesVulnerability_ShouldReturnMatch()
|
||||
{
|
||||
// Arrange
|
||||
@@ -73,7 +76,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.PrimaryRationale.Should().Contain("false positive");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionMatchesArtifactDigest_ShouldReturnMatch()
|
||||
{
|
||||
// Arrange
|
||||
@@ -97,7 +101,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.MatchingExceptions.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionMatchesPolicyRule_ShouldReturnMatch()
|
||||
{
|
||||
// Arrange
|
||||
@@ -119,7 +124,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.HasException.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionHasWrongVulnerabilityId_ShouldNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
@@ -141,7 +147,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.HasException.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionHasWrongArtifactDigest_ShouldNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
@@ -163,7 +170,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.HasException.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenEnvironmentDoesNotMatch_ShouldNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
@@ -188,7 +196,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.HasException.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenEnvironmentMatches_ShouldReturnMatch()
|
||||
{
|
||||
// Arrange
|
||||
@@ -213,7 +222,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.HasException.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionHasEmptyEnvironments_ShouldMatchAny()
|
||||
{
|
||||
// Arrange
|
||||
@@ -238,7 +248,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.HasException.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithMultipleMatchingExceptions_ShouldReturnMostSpecificFirst()
|
||||
{
|
||||
// Arrange
|
||||
@@ -271,7 +282,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.MatchingExceptions[0].ExceptionId.Should().Be("EXC-SPECIFIC");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ShouldCollectAllEvidenceRefs()
|
||||
{
|
||||
// Arrange
|
||||
@@ -302,7 +314,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
result.AllEvidenceRefs.Should().Contain(["sha256:evidence1", "sha256:evidence2", "sha256:evidence3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateBatchAsync_ShouldEvaluateAllContexts()
|
||||
{
|
||||
// Arrange
|
||||
@@ -330,7 +343,8 @@ public sealed class ExceptionEvaluatorTests
|
||||
results[2].HasException.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenPurlPatternMatchesExactly_ShouldReturnMatch()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -3,6 +3,7 @@ using FluentAssertions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -10,7 +11,8 @@ namespace StellaOps.Policy.Exceptions.Tests;
|
||||
/// </summary>
|
||||
public sealed class ExceptionEventTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ForCreated_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -36,7 +38,8 @@ public sealed class ExceptionEventTests
|
||||
evt.OccurredAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ForCreated_WithoutDescription_ShouldUseDefault()
|
||||
{
|
||||
// Act
|
||||
@@ -46,7 +49,8 @@ public sealed class ExceptionEventTests
|
||||
evt.Description.Should().Be("Exception created");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ForApproved_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -70,7 +74,8 @@ public sealed class ExceptionEventTests
|
||||
evt.NewStatus.Should().Be(ExceptionStatus.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ForApproved_WithoutDescription_ShouldIncludeActorId()
|
||||
{
|
||||
// Act
|
||||
@@ -80,7 +85,8 @@ public sealed class ExceptionEventTests
|
||||
evt.Description.Should().Contain("approver@example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ForActivated_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -100,7 +106,8 @@ public sealed class ExceptionEventTests
|
||||
evt.Description.Should().Be("Exception activated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ForRevoked_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -123,7 +130,8 @@ public sealed class ExceptionEventTests
|
||||
evt.Details["reason"].Should().Be(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ForExpired_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -142,7 +150,8 @@ public sealed class ExceptionEventTests
|
||||
evt.Description.Should().Be("Exception expired automatically");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ForExtended_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -166,7 +175,8 @@ public sealed class ExceptionEventTests
|
||||
evt.Details.Should().ContainKey("new_expiry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ForExtended_WithoutReason_ShouldIncludeDates()
|
||||
{
|
||||
// Arrange
|
||||
@@ -181,7 +191,8 @@ public sealed class ExceptionEventTests
|
||||
evt.Description.Should().Contain("to");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(ExceptionEventType.Created)]
|
||||
[InlineData(ExceptionEventType.Updated)]
|
||||
[InlineData(ExceptionEventType.Approved)]
|
||||
@@ -198,7 +209,8 @@ public sealed class ExceptionEventTests
|
||||
Enum.IsDefined(eventType).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AllFactoryMethods_ShouldGenerateUniqueEventIds()
|
||||
{
|
||||
// Act
|
||||
@@ -216,7 +228,8 @@ public sealed class ExceptionEventTests
|
||||
events.Select(e => e.EventId).Distinct().Should().HaveCount(events.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AllFactoryMethods_ShouldSetOccurredAtToNow()
|
||||
{
|
||||
// Arrange
|
||||
@@ -238,7 +251,8 @@ public sealed class ExceptionEventTests
|
||||
/// </summary>
|
||||
public sealed class ExceptionHistoryTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionHistory_WithEvents_ShouldCalculateCorrectStats()
|
||||
{
|
||||
// Arrange
|
||||
@@ -262,7 +276,8 @@ public sealed class ExceptionHistoryTests
|
||||
history.LastEventAt.Should().Be(events[2].OccurredAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionHistory_WithNoEvents_ShouldReturnNullTimestamps()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -278,7 +293,8 @@ public sealed class ExceptionHistoryTests
|
||||
history.LastEventAt.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionHistory_WithSingleEvent_ShouldHaveSameFirstAndLast()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -3,6 +3,7 @@ using FluentAssertions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -10,7 +11,8 @@ namespace StellaOps.Policy.Exceptions.Tests;
|
||||
/// </summary>
|
||||
public sealed class ExceptionObjectTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_WithValidScope_ShouldBeValid()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -23,7 +25,8 @@ public sealed class ExceptionObjectTests
|
||||
scope.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionScope_WithNoConstraints_ShouldBeInvalid()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -33,7 +36,8 @@ public sealed class ExceptionObjectTests
|
||||
scope.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionScope_WithArtifactDigest_ShouldBeValid()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -46,7 +50,8 @@ public sealed class ExceptionObjectTests
|
||||
scope.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionScope_WithPurlPattern_ShouldBeValid()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -59,7 +64,8 @@ public sealed class ExceptionObjectTests
|
||||
scope.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionScope_WithPolicyRuleId_ShouldBeValid()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -72,7 +78,8 @@ public sealed class ExceptionObjectTests
|
||||
scope.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_IsEffective_WhenActiveAndNotExpired_ShouldBeTrue()
|
||||
{
|
||||
// Arrange
|
||||
@@ -85,7 +92,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.HasExpired.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_IsEffective_WhenActiveButExpired_ShouldBeFalse()
|
||||
{
|
||||
// Arrange
|
||||
@@ -98,7 +106,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.HasExpired.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_IsEffective_WhenProposed_ShouldBeFalse()
|
||||
{
|
||||
// Arrange
|
||||
@@ -110,7 +119,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.IsEffective.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_IsEffective_WhenRevoked_ShouldBeFalse()
|
||||
{
|
||||
// Arrange
|
||||
@@ -122,7 +132,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.IsEffective.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_IsEffective_WhenExpiredStatus_ShouldBeFalse()
|
||||
{
|
||||
// Arrange
|
||||
@@ -134,7 +145,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.IsEffective.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(ExceptionStatus.Proposed)]
|
||||
[InlineData(ExceptionStatus.Approved)]
|
||||
[InlineData(ExceptionStatus.Active)]
|
||||
@@ -149,7 +161,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.Status.Should().Be(status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Vulnerability)]
|
||||
[InlineData(ExceptionType.Policy)]
|
||||
[InlineData(ExceptionType.Unknown)]
|
||||
@@ -163,7 +176,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.Type.Should().Be(type);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(ExceptionReason.FalsePositive)]
|
||||
[InlineData(ExceptionReason.AcceptedRisk)]
|
||||
[InlineData(ExceptionReason.CompensatingControl)]
|
||||
@@ -183,7 +197,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.ReasonCode.Should().Be(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_WithMultipleApprovers_ShouldStoreAll()
|
||||
{
|
||||
// Arrange
|
||||
@@ -195,7 +210,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.ApproverIds.Should().Contain(["approver1", "approver2", "approver3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_WithEvidenceRefs_ShouldStoreAll()
|
||||
{
|
||||
// Arrange
|
||||
@@ -210,7 +226,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.EvidenceRefs.Should().Contain("sha256:evidence1hash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_IsBlockedByRecheck_WhenBlockTriggered_ShouldBeTrue()
|
||||
{
|
||||
// Arrange
|
||||
@@ -227,7 +244,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.RequiresReapproval.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_RequiresReapproval_WhenReapprovalTriggered_ShouldBeTrue()
|
||||
{
|
||||
// Arrange
|
||||
@@ -244,7 +262,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.IsBlockedByRecheck.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionObject_WithMetadata_ShouldStoreKeyValuePairs()
|
||||
{
|
||||
// Arrange
|
||||
@@ -260,7 +279,8 @@ public sealed class ExceptionObjectTests
|
||||
exception.Metadata["priority"].Should().Be("high");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionScope_WithEnvironments_ShouldStoreAll()
|
||||
{
|
||||
// Arrange
|
||||
@@ -275,7 +295,8 @@ public sealed class ExceptionObjectTests
|
||||
scope.Environments.Should().Contain(["prod", "staging", "dev"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ExceptionScope_WithTenantId_ShouldStoreValue()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -4,11 +4,13 @@ using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
public sealed class RecheckEvaluationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoPolicy_ReturnsNoTrigger()
|
||||
{
|
||||
var service = new RecheckEvaluationService();
|
||||
@@ -26,7 +28,8 @@ public sealed class RecheckEvaluationServiceTests
|
||||
result.RecommendedAction.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EpssAbove_Triggers()
|
||||
{
|
||||
var service = new RecheckEvaluationService();
|
||||
@@ -60,7 +63,8 @@ public sealed class RecheckEvaluationServiceTests
|
||||
result.RecommendedAction.Should().Be(RecheckAction.RequireReapproval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EnvironmentScope_FiltersConditions()
|
||||
{
|
||||
var service = new RecheckEvaluationService();
|
||||
@@ -92,7 +96,8 @@ public sealed class RecheckEvaluationServiceTests
|
||||
result.IsTriggered.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ActionPriority_PicksBlock()
|
||||
{
|
||||
var service = new RecheckEvaluationService();
|
||||
@@ -133,7 +138,8 @@ public sealed class RecheckEvaluationServiceTests
|
||||
result.RecommendedAction.Should().Be(RecheckAction.Block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ExpiryWithin_UsesThreshold()
|
||||
{
|
||||
var service = new RecheckEvaluationService();
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -26,11 +26,14 @@ using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class GatewayActivationTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ActivateRevision_UsesServiceTokenFallback_And_RecordsMetrics()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
@@ -114,7 +117,8 @@ public sealed class GatewayActivationTests
|
||||
measurement.Source == "service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ActivateRevision_CompletesDualControlWorkflow()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
@@ -154,7 +158,8 @@ public sealed class GatewayActivationTests
|
||||
Assert.Equal(2, recordingHandler.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsUnauthorized()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
@@ -240,7 +245,8 @@ public sealed class GatewayActivationTests
|
||||
measurement.Source == "service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsBadGateway()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
@@ -326,7 +332,8 @@ public sealed class GatewayActivationTests
|
||||
measurement.Source == "service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ActivateRevision_RetriesOnTooManyRequests()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
|
||||
@@ -21,11 +21,14 @@ using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public class PolicyEngineClientTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ActivateRevision_UsesServiceTokenWhenForwardingContextMissing()
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
@@ -61,7 +64,8 @@ public class PolicyEngineClientTests
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Metrics_RecordActivation_EmitsExpectedTags()
|
||||
{
|
||||
using var metrics = new PolicyGatewayMetrics();
|
||||
|
||||
@@ -12,11 +12,14 @@ using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class PolicyGatewayDpopProofGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CreateProof_Throws_WhenDpopDisabled()
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
@@ -34,7 +37,8 @@ public sealed class PolicyGatewayDpopProofGeneratorTests
|
||||
Assert.Equal("DPoP proof requested while DPoP is disabled.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CreateProof_Throws_WhenKeyFileMissing()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
@@ -61,7 +65,8 @@ public sealed class PolicyGatewayDpopProofGeneratorTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CreateProof_UsesConfiguredAlgorithmAndEmbedsTokenHash()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.4.25258.110" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Globalization;
|
||||
using FluentAssertions;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Pack.Tests;
|
||||
|
||||
public class EnvironmentOverrideTests
|
||||
@@ -21,7 +22,8 @@ public class EnvironmentOverrideTests
|
||||
.Build();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("production.yaml")]
|
||||
[InlineData("staging.yaml")]
|
||||
[InlineData("development.yaml")]
|
||||
@@ -31,7 +33,8 @@ public class EnvironmentOverrideTests
|
||||
File.Exists(overridePath).Should().BeTrue($"{fileName} should exist");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("production.yaml", "production")]
|
||||
[InlineData("staging.yaml", "staging")]
|
||||
[InlineData("development.yaml", "development")]
|
||||
@@ -45,7 +48,8 @@ public class EnvironmentOverrideTests
|
||||
metadata!["environment"].Should().Be(expectedEnv);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("production.yaml")]
|
||||
[InlineData("staging.yaml")]
|
||||
[InlineData("development.yaml")]
|
||||
@@ -58,7 +62,8 @@ public class EnvironmentOverrideTests
|
||||
policy["kind"].Should().Be("PolicyOverride");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("production.yaml")]
|
||||
[InlineData("staging.yaml")]
|
||||
[InlineData("development.yaml")]
|
||||
@@ -73,7 +78,8 @@ public class EnvironmentOverrideTests
|
||||
metadata["parent"].Should().Be("starter-day1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DevelopmentOverride_DowngradesBlockingRulesToWarnings()
|
||||
{
|
||||
var overridePath = Path.Combine(_overridesPath, "development.yaml");
|
||||
@@ -101,7 +107,8 @@ public class EnvironmentOverrideTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DevelopmentOverride_HasHigherUnknownsThreshold()
|
||||
{
|
||||
var overridePath = Path.Combine(_overridesPath, "development.yaml");
|
||||
@@ -116,7 +123,8 @@ public class EnvironmentOverrideTests
|
||||
threshold.Should().BeGreaterThan(0.05, "Development should have a higher unknowns threshold than production default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DevelopmentOverride_DisablesSigningRequirements()
|
||||
{
|
||||
var overridePath = Path.Combine(_overridesPath, "development.yaml");
|
||||
@@ -140,7 +148,8 @@ public class EnvironmentOverrideTests
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ProductionOverride_HasStricterSettings()
|
||||
{
|
||||
var overridePath = Path.Combine(_overridesPath, "production.yaml");
|
||||
@@ -162,7 +171,8 @@ public class EnvironmentOverrideTests
|
||||
ParseBool(settings["requireSignedVerdict"]).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ProductionOverride_HasAdditionalExceptionApprovalRule()
|
||||
{
|
||||
var overridePath = Path.Combine(_overridesPath, "production.yaml");
|
||||
@@ -181,7 +191,8 @@ public class EnvironmentOverrideTests
|
||||
exceptionRule.Should().NotBeNull("Production should have exception approval rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StagingOverride_HasModerateSettings()
|
||||
{
|
||||
var overridePath = Path.Combine(_overridesPath, "staging.yaml");
|
||||
|
||||
@@ -9,6 +9,7 @@ using Json.Schema;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Pack.Tests;
|
||||
|
||||
public class PolicyPackSchemaTests
|
||||
@@ -41,14 +42,16 @@ public class PolicyPackSchemaTests
|
||||
return JsonNode.Parse(jsonString)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_Exists()
|
||||
{
|
||||
var schemaPath = Path.Combine(_testDataPath, "policy-pack.schema.json");
|
||||
File.Exists(schemaPath).Should().BeTrue("policy-pack.schema.json should exist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_IsValidJsonSchema()
|
||||
{
|
||||
_schema.Should().NotBeNull("Schema should be parseable");
|
||||
@@ -89,7 +92,8 @@ public class PolicyPackSchemaTests
|
||||
result.IsValid ? "" : $"{fileName} should validate against schema. Errors: {FormatErrors(result)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_RequiresApiVersion()
|
||||
{
|
||||
var invalidPolicy = JsonNode.Parse("""
|
||||
@@ -104,7 +108,8 @@ public class PolicyPackSchemaTests
|
||||
result.IsValid.Should().BeFalse("Policy without apiVersion should fail validation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_RequiresKind()
|
||||
{
|
||||
var invalidPolicy = JsonNode.Parse("""
|
||||
@@ -119,7 +124,8 @@ public class PolicyPackSchemaTests
|
||||
result.IsValid.Should().BeFalse("Policy without kind should fail validation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_RequiresMetadata()
|
||||
{
|
||||
var invalidPolicy = JsonNode.Parse("""
|
||||
@@ -134,7 +140,8 @@ public class PolicyPackSchemaTests
|
||||
result.IsValid.Should().BeFalse("Policy without metadata should fail validation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_RequiresSpec()
|
||||
{
|
||||
var invalidPolicy = JsonNode.Parse("""
|
||||
@@ -149,7 +156,8 @@ public class PolicyPackSchemaTests
|
||||
result.IsValid.Should().BeFalse("Policy without spec should fail validation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_ValidatesApiVersionFormat()
|
||||
{
|
||||
var invalidPolicy = JsonNode.Parse("""
|
||||
@@ -165,7 +173,8 @@ public class PolicyPackSchemaTests
|
||||
result.IsValid.Should().BeFalse("Policy with invalid apiVersion format should fail validation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_ValidatesKindEnum()
|
||||
{
|
||||
var invalidPolicy = JsonNode.Parse("""
|
||||
@@ -181,7 +190,8 @@ public class PolicyPackSchemaTests
|
||||
result.IsValid.Should().BeFalse("Policy with invalid kind should fail validation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_AcceptsValidPolicyPack()
|
||||
{
|
||||
var validPolicy = JsonNode.Parse("""
|
||||
@@ -214,7 +224,8 @@ public class PolicyPackSchemaTests
|
||||
result.IsValid ? "" : $"Valid policy should pass validation. Errors: {FormatErrors(result)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_AcceptsValidPolicyOverride()
|
||||
{
|
||||
var validOverride = JsonNode.Parse("""
|
||||
@@ -246,7 +257,8 @@ public class PolicyPackSchemaTests
|
||||
result.IsValid ? "" : $"Valid override should pass validation. Errors: {FormatErrors(result)}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("allow")]
|
||||
[InlineData("warn")]
|
||||
[InlineData("block")]
|
||||
|
||||
@@ -8,6 +8,7 @@ using FluentAssertions;
|
||||
using Json.Schema;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Pack.Tests;
|
||||
|
||||
public class StarterPolicyPackTests
|
||||
@@ -23,14 +24,16 @@ public class StarterPolicyPackTests
|
||||
.Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StarterDay1Policy_Exists()
|
||||
{
|
||||
var policyPath = Path.Combine(_testDataPath, "starter-day1.yaml");
|
||||
File.Exists(policyPath).Should().BeTrue("starter-day1.yaml should exist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StarterDay1Policy_HasValidYamlStructure()
|
||||
{
|
||||
var policyPath = Path.Combine(_testDataPath, "starter-day1.yaml");
|
||||
@@ -40,7 +43,8 @@ public class StarterPolicyPackTests
|
||||
act.Should().NotThrow("YAML should be valid and parseable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StarterDay1Policy_HasRequiredFields()
|
||||
{
|
||||
var policyPath = Path.Combine(_testDataPath, "starter-day1.yaml");
|
||||
@@ -53,7 +57,8 @@ public class StarterPolicyPackTests
|
||||
policy.Should().ContainKey("spec", "Policy should have spec field");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StarterDay1Policy_HasCorrectApiVersion()
|
||||
{
|
||||
var policyPath = Path.Combine(_testDataPath, "starter-day1.yaml");
|
||||
@@ -63,7 +68,8 @@ public class StarterPolicyPackTests
|
||||
policy["apiVersion"].Should().Be("policy.stellaops.io/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StarterDay1Policy_HasCorrectKind()
|
||||
{
|
||||
var policyPath = Path.Combine(_testDataPath, "starter-day1.yaml");
|
||||
@@ -73,7 +79,8 @@ public class StarterPolicyPackTests
|
||||
policy["kind"].Should().Be("PolicyPack");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StarterDay1Policy_HasValidMetadata()
|
||||
{
|
||||
var policyPath = Path.Combine(_testDataPath, "starter-day1.yaml");
|
||||
@@ -90,7 +97,8 @@ public class StarterPolicyPackTests
|
||||
metadata["version"].ToString().Should().MatchRegex(@"^\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?$", "version should be semver");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StarterDay1Policy_HasRulesSection()
|
||||
{
|
||||
var policyPath = Path.Combine(_testDataPath, "starter-day1.yaml");
|
||||
@@ -106,7 +114,8 @@ public class StarterPolicyPackTests
|
||||
rules!.Should().HaveCountGreaterThan(0, "Policy should have at least one rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StarterDay1Policy_HasSettingsSection()
|
||||
{
|
||||
var policyPath = Path.Combine(_testDataPath, "starter-day1.yaml");
|
||||
@@ -122,7 +131,8 @@ public class StarterPolicyPackTests
|
||||
settings!.Should().ContainKey("defaultAction");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("block-reachable-high-critical")]
|
||||
[InlineData("warn-reachable-medium")]
|
||||
[InlineData("allow-unreachable")]
|
||||
@@ -146,7 +156,8 @@ public class StarterPolicyPackTests
|
||||
ruleNames.Should().Contain(ruleName, $"Policy should contain rule '{ruleName}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StarterDay1Policy_HasDefaultAllowRuleWithLowestPriority()
|
||||
{
|
||||
var policyPath = Path.Combine(_testDataPath, "starter-day1.yaml");
|
||||
|
||||
@@ -35,4 +35,7 @@
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,11 +3,14 @@ using System.Text.Json;
|
||||
using StellaOps.Policy.RiskProfile.Canonicalization;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.RiskProfile.Tests;
|
||||
|
||||
public class RiskProfileCanonicalizerTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_SortsSignalsAndOverrides()
|
||||
{
|
||||
const string input = """
|
||||
@@ -39,7 +42,8 @@ public class RiskProfileCanonicalizerTests
|
||||
Assert.Equal(expected, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeDigest_IgnoresOrderingNoise()
|
||||
{
|
||||
const string a = """
|
||||
@@ -55,7 +59,8 @@ public class RiskProfileCanonicalizerTests
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_ReplacesSignalsAndWeights()
|
||||
{
|
||||
const string baseProfile = """
|
||||
|
||||
@@ -2,13 +2,15 @@ using System.Text.Json;
|
||||
using StellaOps.Policy.RiskProfile.Validation;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.RiskProfile.Tests;
|
||||
|
||||
public class RiskProfileValidatorTests
|
||||
{
|
||||
private readonly RiskProfileValidator _validator = new();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Valid_profile_passes_schema()
|
||||
{
|
||||
var profile = """
|
||||
@@ -43,7 +45,8 @@ public class RiskProfileValidatorTests
|
||||
Assert.True(result.IsValid, string.Join(" | ", result.Errors ?? Array.Empty<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Missing_required_fields_fails_schema()
|
||||
{
|
||||
var invalidProfile = """
|
||||
@@ -56,7 +59,8 @@ public class RiskProfileValidatorTests
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Empty_payload_throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => _validator.Validate(" "));
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentAssertions;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -11,7 +12,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
{
|
||||
#region CVSS v2 Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV2_ComputeFromVector_HighSeverity_ReturnsCorrectScore()
|
||||
{
|
||||
// Arrange - CVE-2002-0392 Apache Chunked-Encoding
|
||||
@@ -27,7 +29,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
result.Severity.Should().Be("High");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV2_ComputeFromVector_MediumSeverity_ReturnsCorrectScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -43,7 +46,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
result.Severity.Should().Be("Medium");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV2_ComputeFromVector_WithTemporal_ReducesScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -60,7 +64,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
temporalResult.TemporalScore.Should().BeLessThan(baseResult.BaseScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV2_IsValidVector_ValidVector_ReturnsTrue()
|
||||
{
|
||||
var engine = new CvssV2Engine();
|
||||
@@ -68,7 +73,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
engine.IsValidVector("CVSS2#AV:N/AC:L/Au:N/C:C/I:C/A:C").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV2_IsValidVector_InvalidVector_ReturnsFalse()
|
||||
{
|
||||
var engine = new CvssV2Engine();
|
||||
@@ -81,7 +87,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
|
||||
#region CVSS v3 Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV3_ComputeFromVector_CriticalSeverity_ReturnsCorrectScore()
|
||||
{
|
||||
// Arrange - Maximum severity vector
|
||||
@@ -97,7 +104,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
result.Severity.Should().Be("Critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV3_ComputeFromVector_HighSeverity_ReturnsCorrectScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -113,7 +121,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
result.Severity.Should().Be("Critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV3_ComputeFromVector_MediumSeverity_ReturnsCorrectScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -128,7 +137,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
result.Severity.Should().BeOneOf("Low", "Medium");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV3_ComputeFromVector_V30_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -143,7 +153,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
result.BaseScore.Should().BeGreaterThan(9.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV3_IsValidVector_ValidVector_ReturnsTrue()
|
||||
{
|
||||
var engine = new CvssV3Engine(CvssVersion.V3_1);
|
||||
@@ -151,7 +162,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
engine.IsValidVector("CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV3_IsValidVector_InvalidVector_ReturnsFalse()
|
||||
{
|
||||
var engine = new CvssV3Engine(CvssVersion.V3_1);
|
||||
@@ -160,7 +172,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
engine.IsValidVector("").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssV3_ScopeChanged_AffectsScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -180,7 +193,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
|
||||
#region Factory Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssEngineFactory_DetectVersion_V4_DetectsCorrectly()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -188,7 +202,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
version.Should().Be(CvssVersion.V4_0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssEngineFactory_DetectVersion_V31_DetectsCorrectly()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -196,7 +211,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
version.Should().Be(CvssVersion.V3_1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssEngineFactory_DetectVersion_V30_DetectsCorrectly()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -204,7 +220,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
version.Should().Be(CvssVersion.V3_0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssEngineFactory_DetectVersion_V2_DetectsCorrectly()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -212,7 +229,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
factory.DetectVersion("CVSS2#AV:N/AC:L/Au:N/C:C/I:C/A:C").Should().Be(CvssVersion.V2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssEngineFactory_DetectVersion_Invalid_ReturnsNull()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -221,7 +239,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
factory.DetectVersion(null!).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssEngineFactory_Create_V2_ReturnsCorrectEngine()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -229,7 +248,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
engine.Version.Should().Be(CvssVersion.V2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssEngineFactory_Create_V31_ReturnsCorrectEngine()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -237,7 +257,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
engine.Version.Should().Be(CvssVersion.V3_1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssEngineFactory_Create_V40_ReturnsCorrectEngine()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -245,7 +266,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
engine.Version.Should().Be(CvssVersion.V4_0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssEngineFactory_ComputeFromVector_AutoDetects()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -261,7 +283,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
v31Result.BaseScore.Should().BeGreaterThan(9.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CvssEngineFactory_ComputeFromVector_InvalidVector_ThrowsException()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -273,7 +296,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
|
||||
#region Cross-Version Determinism Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AllEngines_SameInput_ReturnsDeterministicOutput()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
@@ -297,7 +321,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
|
||||
#region Real-World CVE Vector Tests
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "Critical")] // Log4Shell style
|
||||
[InlineData("CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", 6.1, "Medium")] // XSS style
|
||||
[InlineData("CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", 7.8, "High")] // Local privilege escalation
|
||||
@@ -310,7 +335,8 @@ public sealed class CvssMultiVersionEngineTests
|
||||
result.Severity.Should().Be(expectedSeverity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("AV:N/AC:L/Au:N/C:C/I:C/A:C", 10.0, "High")] // Remote code execution
|
||||
[InlineData("AV:N/AC:M/Au:N/C:P/I:P/A:P", 6.8, "Medium")] // Moderate network vuln
|
||||
[InlineData("AV:L/AC:L/Au:N/C:P/I:N/A:N", 2.1, "Low")] // Local info disclosure
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Scoring.Tests.Fakes;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -18,7 +19,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
|
||||
#region Full Pipeline Tests - V4 Receipt
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FullPipeline_V4_CreatesReceiptWithDeterministicHash()
|
||||
{
|
||||
// Arrange
|
||||
@@ -53,7 +55,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
receipt.InputHash.Should().HaveLength(64); // SHA-256 hex
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FullPipeline_V4_WithThreatMetrics_AdjustsScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -100,7 +103,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
|
||||
#region Cross-Version Factory Tests
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("AV:N/AC:L/Au:N/C:C/I:C/A:C", CvssVersion.V2, 10.0)]
|
||||
[InlineData("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", CvssVersion.V3_1, 10.0)]
|
||||
[InlineData("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H", CvssVersion.V4_0, 10.0)]
|
||||
@@ -114,7 +118,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
result.BaseScore.Should().Be(expectedScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CrossVersion_AllVersions_ReturnCorrectSeverityLabels()
|
||||
{
|
||||
// Arrange - Maximum severity vectors for each version
|
||||
@@ -137,7 +142,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Determinism_SameInput_ProducesSameInputHash()
|
||||
{
|
||||
// Arrange
|
||||
@@ -184,7 +190,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
receipt1.Severity.Should().Be(receipt2.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Determinism_EngineScoring_IsIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -217,7 +224,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
|
||||
#region Version Detection Tests
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("AV:N/AC:L/Au:N/C:C/I:C/A:C", CvssVersion.V2)]
|
||||
[InlineData("CVSS2#AV:N/AC:L/Au:N/C:C/I:C/A:C", CvssVersion.V2)]
|
||||
[InlineData("CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", CvssVersion.V3_0)]
|
||||
@@ -232,7 +240,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
detected.Should().Be(expectedVersion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("CVSS:5.0/AV:N")]
|
||||
@@ -250,7 +259,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ErrorHandling_InvalidVector_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
@@ -258,7 +268,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ErrorHandling_NullVector_ThrowsException()
|
||||
{
|
||||
// Act & Assert
|
||||
@@ -270,7 +281,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
|
||||
#region Real-World CVE Tests
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("CVE-2021-44228", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 10.0, "Critical")] // Log4Shell
|
||||
[InlineData("CVE-2022-22965", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "Critical")] // Spring4Shell
|
||||
[InlineData("CVE-2014-0160", "AV:N/AC:L/Au:N/C:P/I:N/A:N", 5.0, "Medium")] // Heartbleed (V2)
|
||||
@@ -291,7 +303,8 @@ public sealed class CvssPipelineIntegrationTests
|
||||
|
||||
#region Severity Threshold Tests
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(0.0, CvssSeverity.None)]
|
||||
[InlineData(0.1, CvssSeverity.Low)]
|
||||
[InlineData(3.9, CvssSeverity.Low)]
|
||||
|
||||
@@ -4,13 +4,16 @@ using FluentAssertions;
|
||||
using StellaOps.Policy.Scoring.Policies;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
public sealed class CvssPolicyLoaderTests
|
||||
{
|
||||
private readonly CvssPolicyLoader _loader = new();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_ValidPolicy_ComputesDeterministicHashAndReturnsPolicy()
|
||||
{
|
||||
// Arrange
|
||||
@@ -43,7 +46,8 @@ public sealed class CvssPolicyLoaderTests
|
||||
roundTrip.Policy!.Hash.Should().Be(result.Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_InvalidPolicy_ReturnsValidationErrors()
|
||||
{
|
||||
// Arrange: missing required fields
|
||||
|
||||
@@ -14,7 +14,8 @@ public sealed class CvssV4EngineTests
|
||||
|
||||
#region Base Score Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_MaximumSeverity_ReturnsScore10()
|
||||
{
|
||||
// Arrange - Highest severity: Network/Low/None/None/None/High across all impacts
|
||||
@@ -29,7 +30,8 @@ public sealed class CvssV4EngineTests
|
||||
scores.EffectiveScoreType.Should().Be(EffectiveScoreType.Base);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_MinimumSeverity_ReturnsLowScore()
|
||||
{
|
||||
// Arrange - Lowest severity: Physical/High/Present/High/Active/None across all impacts
|
||||
@@ -43,7 +45,8 @@ public sealed class CvssV4EngineTests
|
||||
scores.EffectiveScore.Should().BeLessThan(2.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_MediumSeverity_ReturnsScoreInRange()
|
||||
{
|
||||
// Arrange - Medium severity combination
|
||||
@@ -73,7 +76,8 @@ public sealed class CvssV4EngineTests
|
||||
|
||||
#region Threat Score Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_WithAttackedThreat_ReturnsThreatScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -89,7 +93,8 @@ public sealed class CvssV4EngineTests
|
||||
scores.EffectiveScoreType.Should().Be(EffectiveScoreType.Threat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_WithProofOfConceptThreat_ReducesScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -105,7 +110,8 @@ public sealed class CvssV4EngineTests
|
||||
scores.ThreatScore.Value.Should().BeGreaterThan(9.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_WithUnreportedThreat_ReducesScoreMore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -120,7 +126,8 @@ public sealed class CvssV4EngineTests
|
||||
scores.ThreatScore!.Value.Should().BeLessThan(9.5); // Unreported = 0.91 multiplier
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_WithNotDefinedThreat_ReturnsOnlyBaseScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -139,7 +146,8 @@ public sealed class CvssV4EngineTests
|
||||
|
||||
#region Environmental Score Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_WithHighSecurityRequirements_IncreasesScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -160,7 +168,8 @@ public sealed class CvssV4EngineTests
|
||||
scoresWithEnv.EnvironmentalScore!.Value.Should().BeGreaterThan(scoresWithoutEnv.BaseScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_WithLowSecurityRequirements_DecreasesScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -181,7 +190,8 @@ public sealed class CvssV4EngineTests
|
||||
scoresWithEnv.EnvironmentalScore!.Value.Should().BeLessThan(scoresWithoutEnv.BaseScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_WithModifiedMetrics_AppliesModifications()
|
||||
{
|
||||
// Arrange - Start with network-based vuln, modify to local
|
||||
@@ -204,7 +214,8 @@ public sealed class CvssV4EngineTests
|
||||
|
||||
#region Full Score Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_WithAllMetrics_ReturnsFullScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -227,7 +238,8 @@ public sealed class CvssV4EngineTests
|
||||
|
||||
#region Vector String Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildVectorString_BaseOnly_ReturnsCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
@@ -251,7 +263,8 @@ public sealed class CvssV4EngineTests
|
||||
vector.Should().Contain("SA:H");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildVectorString_WithThreat_IncludesThreatMetric()
|
||||
{
|
||||
// Arrange
|
||||
@@ -265,7 +278,8 @@ public sealed class CvssV4EngineTests
|
||||
vector.Should().Contain("E:A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ParseVector_ValidVector_ReturnsCorrectMetrics()
|
||||
{
|
||||
// Arrange
|
||||
@@ -288,7 +302,8 @@ public sealed class CvssV4EngineTests
|
||||
result.BaseMetrics.SubsequentSystemAvailability.Should().Be(ImpactMetricValue.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ParseVector_WithThreat_ParsesThreatMetric()
|
||||
{
|
||||
// Arrange
|
||||
@@ -302,7 +317,8 @@ public sealed class CvssV4EngineTests
|
||||
result.ThreatMetrics!.ExploitMaturity.Should().Be(ExploitMaturity.Attacked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ParseVector_InvalidPrefix_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -313,7 +329,8 @@ public sealed class CvssV4EngineTests
|
||||
.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ParseVector_MissingMetric_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange - Missing AV metric
|
||||
@@ -328,7 +345,8 @@ public sealed class CvssV4EngineTests
|
||||
|
||||
#region Severity Tests
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(0.0, CvssSeverity.None)]
|
||||
[InlineData(0.1, CvssSeverity.Low)]
|
||||
[InlineData(3.9, CvssSeverity.Low)]
|
||||
@@ -347,7 +365,8 @@ public sealed class CvssV4EngineTests
|
||||
severity.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetSeverity_CustomThresholds_UsesCustomValues()
|
||||
{
|
||||
// Arrange
|
||||
@@ -370,7 +389,8 @@ public sealed class CvssV4EngineTests
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeScores_SameInput_ReturnsSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
@@ -387,7 +407,8 @@ public sealed class CvssV4EngineTests
|
||||
scores1.EffectiveScore.Should().Be(scores3.EffectiveScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildVectorString_SameInput_ReturnsSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
@@ -401,7 +422,8 @@ public sealed class CvssV4EngineTests
|
||||
vector1.Should().Be(vector2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Roundtrip_BuildAndParse_PreservesMetrics()
|
||||
{
|
||||
// Arrange
|
||||
@@ -429,7 +451,8 @@ public sealed class CvssV4EngineTests
|
||||
/// <summary>
|
||||
/// Tests using sample vectors from FIRST CVSS v4.0 examples.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H", 10.0)]
|
||||
[InlineData("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", 9.4)]
|
||||
[InlineData("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N", 6.8)]
|
||||
@@ -438,6 +461,7 @@ public sealed class CvssV4EngineTests
|
||||
// Arrange
|
||||
var metricSet = _engine.ParseVector(vector);
|
||||
|
||||
using StellaOps.TestKit;
|
||||
// Act
|
||||
var scores = _engine.ComputeScores(metricSet.BaseMetrics);
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ using FluentAssertions;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
public class CvssVectorInteropTests
|
||||
{
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", "CVSS:4.0/AV:N/AC:L/PR:N/UI:N/VC:H/VI:H/VA:H")]
|
||||
[InlineData("CVSS:3.1/AV:L/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L", "CVSS:4.0/AV:L/AC:H/PR:H/UI:R/VC:L/VI:L/VA:L")]
|
||||
public void ConvertV31ToV4_ProducesDeterministicVector(string v31, string expectedPrefix)
|
||||
|
||||
@@ -4,6 +4,7 @@ using StellaOps.Policy.Scoring.Engine;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -31,7 +32,8 @@ public sealed class MacroVectorLookupTests
|
||||
|
||||
#region Completeness Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LookupTable_ContainsAtLeast324Entries()
|
||||
{
|
||||
// Assert - The lookup table may contain more entries than the theoretical 324
|
||||
@@ -40,7 +42,8 @@ public sealed class MacroVectorLookupTests
|
||||
MacroVectorLookup.EntryCount.Should().BeGreaterThanOrEqualTo(324);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AllMacroVectorCombinations_ExistInLookupTable()
|
||||
{
|
||||
// Arrange
|
||||
@@ -68,7 +71,8 @@ public sealed class MacroVectorLookupTests
|
||||
missing.Should().BeEmpty($"All combinations should have precise scores. Missing: {string.Join(", ", missing.Take(10))}...");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AllMacroVectorCombinations_ReturnValidScores()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -98,7 +102,8 @@ public sealed class MacroVectorLookupTests
|
||||
|
||||
#region Boundary Value Tests
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("000000", 10.0)] // Maximum severity
|
||||
[InlineData("222222", 0.0)] // Minimum severity (or very low)
|
||||
public void BoundaryMacroVectors_ReturnExpectedScores(string macroVector, double expectedScore)
|
||||
@@ -110,7 +115,8 @@ public sealed class MacroVectorLookupTests
|
||||
score.Should().Be(expectedScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void MaximumSeverityMacroVector_ReturnsScore10()
|
||||
{
|
||||
// Arrange
|
||||
@@ -123,7 +129,8 @@ public sealed class MacroVectorLookupTests
|
||||
score.Should().Be(10.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void MinimumSeverityMacroVector_ReturnsVeryLowScore()
|
||||
{
|
||||
// Arrange
|
||||
@@ -136,7 +143,8 @@ public sealed class MacroVectorLookupTests
|
||||
score.Should().BeLessThanOrEqualTo(1.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("000000", "100000")] // EQ1 increase reduces score
|
||||
[InlineData("000000", "010000")] // EQ2 increase reduces score
|
||||
[InlineData("000000", "001000")] // EQ3 increase reduces score
|
||||
@@ -158,7 +166,8 @@ public sealed class MacroVectorLookupTests
|
||||
|
||||
#region Score Progression Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ScoreProgression_EQ1Increase_ReducesScoreMonotonically()
|
||||
{
|
||||
// Test that for fixed EQ2-EQ6, increasing EQ1 reduces score
|
||||
@@ -181,7 +190,8 @@ public sealed class MacroVectorLookupTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ScoreProgression_EQ2Increase_ReducesScoreMonotonically()
|
||||
{
|
||||
// Test that for fixed EQ1, EQ3-EQ6, increasing EQ2 reduces score
|
||||
@@ -205,7 +215,8 @@ public sealed class MacroVectorLookupTests
|
||||
|
||||
#region Invalid Input Tests
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("12345")] // Too short
|
||||
@@ -219,7 +230,8 @@ public sealed class MacroVectorLookupTests
|
||||
score.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("300000")] // EQ1 out of range
|
||||
[InlineData("020000")] // Valid but testing fallback path
|
||||
[InlineData("ABCDEF")] // Non-numeric
|
||||
@@ -234,7 +246,8 @@ public sealed class MacroVectorLookupTests
|
||||
score.Should().BeLessThanOrEqualTo(10.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("300000")] // EQ1 = 3 (invalid)
|
||||
[InlineData("030000")] // EQ2 = 3 (invalid, max is 1)
|
||||
[InlineData("003000")] // EQ3 = 3 (invalid)
|
||||
@@ -255,7 +268,8 @@ public sealed class MacroVectorLookupTests
|
||||
|
||||
#region HasPreciseScore Tests
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("000000", true)]
|
||||
[InlineData("111111", true)]
|
||||
[InlineData("222222", true)]
|
||||
@@ -270,7 +284,8 @@ public sealed class MacroVectorLookupTests
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("300000")] // Invalid EQ1
|
||||
[InlineData("ABCDEF")] // Non-numeric
|
||||
[InlineData("12345")] // Too short
|
||||
@@ -287,7 +302,8 @@ public sealed class MacroVectorLookupTests
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetBaseScore_SameInput_ReturnsSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
@@ -303,7 +319,8 @@ public sealed class MacroVectorLookupTests
|
||||
score2.Should().Be(score3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AllScores_AreRoundedToOneDecimal()
|
||||
{
|
||||
// Act & Assert
|
||||
@@ -326,7 +343,8 @@ public sealed class MacroVectorLookupTests
|
||||
|
||||
#region Performance Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetBaseScore_10000Lookups_CompletesInUnderOneMillisecond()
|
||||
{
|
||||
// Arrange
|
||||
@@ -356,7 +374,8 @@ public sealed class MacroVectorLookupTests
|
||||
sw.Elapsed.TotalMilliseconds.Should().BeLessThan(100, "10000 lookups should complete in under 100ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AllCombinations_LookupPerformance()
|
||||
{
|
||||
// Arrange
|
||||
@@ -383,7 +402,8 @@ public sealed class MacroVectorLookupTests
|
||||
/// Tests against FIRST CVSS v4.0 calculator reference scores.
|
||||
/// These scores are verified against the official calculator.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("000000", 10.0)] // Max severity
|
||||
[InlineData("000001", 9.7)] // One step from max
|
||||
[InlineData("000010", 9.3)]
|
||||
@@ -412,7 +432,8 @@ public sealed class MacroVectorLookupTests
|
||||
|
||||
#region Score Distribution Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ScoreDistribution_HasReasonableSpread()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -437,7 +458,8 @@ public sealed class MacroVectorLookupTests
|
||||
uniqueScores.Should().BeGreaterThan(50, "Should have diverse score values");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ScoreDistribution_ByCategory()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
@@ -7,6 +7,7 @@ using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Scoring.Tests.Fakes;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
public sealed class ReceiptBuilderTests
|
||||
@@ -14,7 +15,8 @@ public sealed class ReceiptBuilderTests
|
||||
private readonly ICvssV4Engine _engine = new CvssV4Engine();
|
||||
private readonly InMemoryReceiptRepository _repository = new();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_ComputesDeterministicHashAndStoresReceipt()
|
||||
{
|
||||
// Arrange
|
||||
@@ -72,7 +74,8 @@ public sealed class ReceiptBuilderTests
|
||||
_repository.Contains(receipt1.ReceiptId).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_InputHashIgnoresPropertyOrder()
|
||||
{
|
||||
var policy = new CvssPolicy
|
||||
@@ -140,7 +143,8 @@ public sealed class ReceiptBuilderTests
|
||||
r1.InputHash.Should().Be(r2.InputHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithSigningKey_AttachesDsseReference()
|
||||
{
|
||||
// Arrange
|
||||
@@ -188,7 +192,8 @@ public sealed class ReceiptBuilderTests
|
||||
receipt.AttestationRefs[0].Should().StartWith("dsse:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_EnforcesEvidenceRequirements()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
@@ -62,7 +63,8 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
|
||||
}
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGetById_RoundTripsEvaluationRun()
|
||||
{
|
||||
// Arrange
|
||||
@@ -88,7 +90,8 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
|
||||
fetched.Status.Should().Be(EvaluationStatus.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByProjectId_ReturnsProjectEvaluations()
|
||||
{
|
||||
// Arrange
|
||||
@@ -103,7 +106,8 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
|
||||
runs[0].ProjectId.Should().Be("project-abc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByArtifactId_ReturnsArtifactEvaluations()
|
||||
{
|
||||
// Arrange
|
||||
@@ -125,7 +129,8 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
|
||||
runs[0].ArtifactId.Should().Be(artifactId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByStatus_ReturnsRunsWithStatus()
|
||||
{
|
||||
// Arrange
|
||||
@@ -149,7 +154,8 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
|
||||
pendingRuns[0].ProjectId.Should().Be("project-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetRecent_ReturnsRecentEvaluations()
|
||||
{
|
||||
// Arrange
|
||||
@@ -163,7 +169,8 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
|
||||
recentRuns.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MarkStarted_UpdatesStatusAndStartedAt()
|
||||
{
|
||||
// Arrange
|
||||
@@ -180,7 +187,8 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
|
||||
fetched.StartedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MarkCompleted_UpdatesAllCompletionFields()
|
||||
{
|
||||
// Arrange
|
||||
@@ -213,7 +221,8 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
|
||||
fetched.CompletedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MarkFailed_SetsErrorMessage()
|
||||
{
|
||||
// Arrange
|
||||
@@ -231,7 +240,8 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
|
||||
fetched.ErrorMessage.Should().Be("Policy engine timeout");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetStats_ReturnsCorrectStatistics()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -7,6 +7,7 @@ using StellaOps.Policy.Exceptions.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -32,7 +33,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_ShouldPersistExceptionAndCreateEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -53,7 +55,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
history.Events[0].ActorId.Should().Be("test-actor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WhenExists_ShouldReturnException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -73,7 +76,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
fetched.ReasonCode.Should().Be(ExceptionReason.AcceptedRisk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WhenNotExists_ShouldReturnNull()
|
||||
{
|
||||
// Act
|
||||
@@ -83,7 +87,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ShouldIncrementVersionAndCreateEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -116,7 +121,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
history.Events[1].EventType.Should().Be(ExceptionEventType.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithConcurrencyConflict_ShouldThrow()
|
||||
{
|
||||
// Arrange
|
||||
@@ -135,7 +141,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
_repository.UpdateAsync(staleUpdate, ExceptionEventType.Updated, "updater"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_ShouldFilterByStatus()
|
||||
{
|
||||
// Arrange
|
||||
@@ -156,7 +163,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results[0].ExceptionId.Should().Be("EXC-FILTER-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_ShouldFilterByType()
|
||||
{
|
||||
// Arrange
|
||||
@@ -175,7 +183,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results[0].ExceptionId.Should().Be("EXC-TYPE-002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_ShouldFilterByVulnerabilityId()
|
||||
{
|
||||
// Arrange
|
||||
@@ -194,7 +203,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results[0].ExceptionId.Should().Be("EXC-VID-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_ShouldSupportPagination()
|
||||
{
|
||||
// Arrange
|
||||
@@ -211,7 +221,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetActiveByScopeAsync_ShouldMatchVulnerabilityId()
|
||||
{
|
||||
// Arrange
|
||||
@@ -228,7 +239,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results[0].ExceptionId.Should().Be("EXC-SCOPE-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetActiveByScopeAsync_ShouldExcludeInactiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -247,7 +259,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetExpiringAsync_ShouldReturnExceptionsExpiringSoon()
|
||||
{
|
||||
// Arrange
|
||||
@@ -269,7 +282,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results[0].ExceptionId.Should().Be("EXC-EXPIRING-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetExpiredActiveAsync_ShouldReturnExpiredButActiveExceptions()
|
||||
{
|
||||
// Arrange - Create with past expiry
|
||||
@@ -287,7 +301,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results[0].ExceptionId.Should().Be("EXC-EXPIRED-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetHistoryAsync_ShouldReturnEventsInOrder()
|
||||
{
|
||||
// Arrange
|
||||
@@ -325,7 +340,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
history.Events[2].SequenceNumber.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetCountsAsync_ShouldReturnCorrectCounts()
|
||||
{
|
||||
// Arrange
|
||||
@@ -347,7 +363,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
counts.ExpiringSoon.Should().BeGreaterOrEqualTo(1); // At least the one expiring in 3 days
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithMetadata_ShouldPersistCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -369,7 +386,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
fetched.Metadata["ticket"].Should().Be("SEC-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithEvidenceRefs_ShouldPersistCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -390,7 +408,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
fetched.EvidenceRefs.Should().Contain("https://evidence.example.com/doc1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithCompensatingControls_ShouldPersistCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -409,7 +428,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
fetched.CompensatingControls.Should().Contain("WAF blocking malicious patterns");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithEnvironments_ShouldPersistCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
@@ -27,7 +28,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGetById_RoundTripsException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -55,7 +57,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
fetched.Status.Should().Be(ExceptionStatus.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByName_ReturnsCorrectException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -70,7 +73,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
fetched!.Id.Should().Be(exception.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAll_ReturnsAllExceptionsForTenant()
|
||||
{
|
||||
// Arrange
|
||||
@@ -87,7 +91,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
exceptions.Select(e => e.Name).Should().Contain(["exception1", "exception2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAll_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
@@ -111,7 +116,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
activeExceptions[0].Name.Should().Be("active");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetActiveForProject_ReturnsProjectExceptions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -144,7 +150,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
exceptions[0].Name.Should().Be("project-exception");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetActiveForRule_ReturnsRuleExceptions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -167,7 +174,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
exceptions[0].Name.Should().Be("rule-exception");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Update_ModifiesException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -192,7 +200,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
fetched.Description.Should().Be("Updated description");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Approve_SetsApprovalDetails()
|
||||
{
|
||||
// Arrange
|
||||
@@ -209,7 +218,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
fetched.ApprovedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Revoke_SetsRevokedStatusAndDetails()
|
||||
{
|
||||
// Arrange
|
||||
@@ -227,7 +237,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
fetched.RevokedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Expire_ExpiresOldExceptions()
|
||||
{
|
||||
// Arrange - Create an exception that expires in the past
|
||||
@@ -251,7 +262,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
|
||||
fetched!.Status.Should().Be(ExceptionStatus.Expired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Delete_RemovesException()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
@@ -29,7 +30,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGetById_RoundTripsPack()
|
||||
{
|
||||
// Arrange
|
||||
@@ -54,7 +56,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
|
||||
fetched.DisplayName.Should().Be("Security Baseline Pack");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByName_ReturnsCorrectPack()
|
||||
{
|
||||
// Arrange
|
||||
@@ -69,7 +72,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
|
||||
fetched!.Id.Should().Be(pack.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAll_ReturnsAllPacksForTenant()
|
||||
{
|
||||
// Arrange
|
||||
@@ -86,7 +90,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
|
||||
packs.Select(p => p.Name).Should().Contain(["pack1", "pack2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAll_ExcludesDeprecated()
|
||||
{
|
||||
// Arrange
|
||||
@@ -109,7 +114,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
|
||||
packs[0].Name.Should().Be("active");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetBuiltin_ReturnsOnlyBuiltinPacks()
|
||||
{
|
||||
// Arrange
|
||||
@@ -132,7 +138,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
|
||||
builtinPacks[0].Name.Should().Be("builtin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Update_ModifiesPack()
|
||||
{
|
||||
// Arrange
|
||||
@@ -157,7 +164,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
|
||||
fetched.Description.Should().Be("Updated description");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SetActiveVersion_UpdatesActiveVersion()
|
||||
{
|
||||
// Arrange
|
||||
@@ -174,7 +182,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
|
||||
fetched!.ActiveVersion.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PublishAndActivateVersions_FollowsWorkflow()
|
||||
{
|
||||
// Arrange
|
||||
@@ -208,7 +217,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
|
||||
finalPack!.ActiveVersion.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Deprecate_MarksParkAsDeprecated()
|
||||
{
|
||||
// Arrange
|
||||
@@ -224,7 +234,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
|
||||
fetched!.IsDeprecated.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Delete_RemovesPack()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -37,7 +38,8 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionWorkflow_CreateUpdateActivate_MaintainsVersionIntegrity()
|
||||
{
|
||||
// Arrange - Create initial pack
|
||||
@@ -70,7 +72,8 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
|
||||
afterV3!.ActiveVersion.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionWorkflow_RollbackVersion_RestoresPreviousVersion()
|
||||
{
|
||||
// Arrange - Create pack at version 3
|
||||
@@ -93,7 +96,8 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
|
||||
afterRollback!.ActiveVersion.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionWorkflow_MultiplePacksDifferentVersions_Isolated()
|
||||
{
|
||||
// Arrange - Create multiple packs with different versions
|
||||
@@ -125,7 +129,8 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
|
||||
fetchedPack2!.ActiveVersion.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionWorkflow_DeprecatedPackVersionStillReadable()
|
||||
{
|
||||
// Arrange - Create and deprecate pack
|
||||
@@ -149,7 +154,8 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
|
||||
deprecated.ActiveVersion.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionWorkflow_ConcurrentVersionUpdates_LastWriteWins()
|
||||
{
|
||||
// Arrange - Create pack
|
||||
@@ -177,7 +183,8 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
|
||||
final!.ActiveVersion.Should().BeOneOf(2, 3, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionWorkflow_DeterministicOrdering_VersionsReturnConsistently()
|
||||
{
|
||||
// Arrange - Create multiple packs
|
||||
@@ -208,7 +215,8 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
|
||||
names2.Should().Equal(names3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionWorkflow_UpdateTimestampProgresses_OnVersionChange()
|
||||
{
|
||||
// Arrange
|
||||
@@ -234,7 +242,8 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
|
||||
updated!.UpdatedAt.Should().BeOnOrAfter(initialUpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionWorkflow_ZeroVersionAllowed_AsInitialState()
|
||||
{
|
||||
// Arrange - Create pack with version 0 (no active version)
|
||||
@@ -255,7 +264,8 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
|
||||
fetched!.ActiveVersion.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionWorkflow_BuiltinPackVersioning_WorksLikeCustomPacks()
|
||||
{
|
||||
// Arrange - Create builtin pack
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
@@ -27,7 +28,8 @@ public sealed class PolicyAuditRepositoryTests : IAsyncLifetime
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_ReturnsGeneratedId()
|
||||
{
|
||||
// Arrange
|
||||
@@ -47,7 +49,8 @@ public sealed class PolicyAuditRepositoryTests : IAsyncLifetime
|
||||
id.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_ReturnsAuditEntriesOrderedByCreatedAtDesc()
|
||||
{
|
||||
// Arrange
|
||||
@@ -65,7 +68,8 @@ public sealed class PolicyAuditRepositoryTests : IAsyncLifetime
|
||||
audits[0].Action.Should().Be("action2"); // Most recent first
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByResource_ReturnsResourceAudits()
|
||||
{
|
||||
// Arrange
|
||||
@@ -87,7 +91,8 @@ public sealed class PolicyAuditRepositoryTests : IAsyncLifetime
|
||||
audits[0].ResourceId.Should().Be(resourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByResource_WithoutResourceId_ReturnsAllOfType()
|
||||
{
|
||||
// Arrange
|
||||
@@ -113,7 +118,8 @@ public sealed class PolicyAuditRepositoryTests : IAsyncLifetime
|
||||
audits.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByCorrelationId_ReturnsCorrelatedAudits()
|
||||
{
|
||||
// Arrange
|
||||
@@ -143,7 +149,8 @@ public sealed class PolicyAuditRepositoryTests : IAsyncLifetime
|
||||
audits.Should().AllSatisfy(a => a.CorrelationId.Should().Be(correlationId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_StoresJsonbValues()
|
||||
{
|
||||
// Arrange
|
||||
@@ -166,7 +173,8 @@ public sealed class PolicyAuditRepositoryTests : IAsyncLifetime
|
||||
audits[0].NewValue.Should().Contain("8.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeleteOld_RemovesOldAudits()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -10,6 +10,8 @@ using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -49,7 +51,8 @@ public sealed class PostgresExceptionApplicationRepositoryTests : IAsyncLifetime
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RecordAsync_ShouldPersist()
|
||||
{
|
||||
// Arrange
|
||||
@@ -64,7 +67,8 @@ public sealed class PostgresExceptionApplicationRepositoryTests : IAsyncLifetime
|
||||
result.FindingId.Should().Be("FIND-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RecordBatchAsync_ShouldPersistMultiple()
|
||||
{
|
||||
// Arrange
|
||||
@@ -82,7 +86,8 @@ public sealed class PostgresExceptionApplicationRepositoryTests : IAsyncLifetime
|
||||
result.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RecordBatchAsync_EmptyReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
@@ -92,7 +97,8 @@ public sealed class PostgresExceptionApplicationRepositoryTests : IAsyncLifetime
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByExceptionIdAsync_ReturnsMatches()
|
||||
{
|
||||
// Arrange
|
||||
@@ -108,7 +114,8 @@ public sealed class PostgresExceptionApplicationRepositoryTests : IAsyncLifetime
|
||||
results.Should().AllSatisfy(r => r.ExceptionId.Should().Be("EXC-G1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByFindingIdAsync_ReturnsMatches()
|
||||
{
|
||||
// Arrange
|
||||
@@ -124,7 +131,8 @@ public sealed class PostgresExceptionApplicationRepositoryTests : IAsyncLifetime
|
||||
results.Should().AllSatisfy(r => r.FindingId.Should().Be("FIND-G1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByVulnerabilityIdAsync_ReturnsMatches()
|
||||
{
|
||||
// Arrange
|
||||
@@ -140,7 +148,8 @@ public sealed class PostgresExceptionApplicationRepositoryTests : IAsyncLifetime
|
||||
results.Should().AllSatisfy(r => r.VulnerabilityId.Should().Be("CVE-2024-1234"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CountAsync_WithFilter_ReturnsFiltered()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -7,6 +7,7 @@ using StellaOps.Policy.Exceptions.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -35,7 +36,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
|
||||
#region Create Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithValidException_PersistsException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -50,7 +52,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
created.Version.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_PersistsRecheckTrackingFields()
|
||||
{
|
||||
// Arrange
|
||||
@@ -89,7 +92,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
fetched.LastRecheckAt.Should().BeCloseTo(exception.LastRecheckAt!.Value, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_RecordsCreatedEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -106,7 +110,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
history.Events[0].NewStatus.Should().Be(ExceptionStatus.Proposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithClientInfo_IncludesInEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -120,7 +125,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
history.Events[0].ClientInfo.Should().Be("192.168.1.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithWrongVersion_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -135,7 +141,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
|
||||
#region GetById Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithExistingException_ReturnsException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -152,7 +159,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
fetched.Status.Should().Be(ExceptionStatus.Proposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithNonExistingException_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
@@ -166,7 +174,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
|
||||
#region Update Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithValidVersion_UpdatesException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -191,7 +200,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
result.Status.Should().Be(ExceptionStatus.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RecordsEvent()
|
||||
{
|
||||
// Arrange
|
||||
@@ -218,7 +228,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
history.Events[1].NewStatus.Should().Be(ExceptionStatus.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithWrongVersion_ThrowsConcurrencyException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -242,7 +253,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
|
||||
#region Query Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
@@ -265,7 +277,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results[0].ExceptionId.Should().Be(proposed.ExceptionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_FiltersByVulnerabilityId()
|
||||
{
|
||||
// Arrange
|
||||
@@ -284,7 +297,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results[0].Scope.VulnerabilityId.Should().Be("CVE-2024-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_SupportsPagination()
|
||||
{
|
||||
// Arrange
|
||||
@@ -305,7 +319,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
page2.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetActiveByScopeAsync_FindsMatchingActiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -323,7 +338,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetActiveByScopeAsync_ExcludesExpiredExceptions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -342,7 +358,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetExpiringAsync_FindsExceptionsWithinHorizon()
|
||||
{
|
||||
// Arrange
|
||||
@@ -370,7 +387,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
results[0].ExceptionId.Should().Be("EXC-EXPIRING");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetExpiredActiveAsync_FindsExpiredActiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -402,7 +420,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
|
||||
#region History and Counts Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetHistoryAsync_ReturnsChronologicalEvents()
|
||||
{
|
||||
// Arrange
|
||||
@@ -440,7 +459,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
history.Events[2].SequenceNumber.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetHistoryAsync_ForNonExistent_ReturnsEmptyHistory()
|
||||
{
|
||||
// Act
|
||||
@@ -451,7 +471,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
history.Events.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetCountsAsync_ReturnsCorrectCounts()
|
||||
{
|
||||
// Arrange
|
||||
@@ -489,7 +510,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
|
||||
#region Concurrent Update Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ConcurrentUpdates_FailsWithConcurrencyException()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -9,6 +9,7 @@ using StellaOps.Policy.Storage.Postgres.Tests;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
@@ -31,7 +32,8 @@ public sealed class PostgresReceiptRepositoryTests : IAsyncLifetime
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SaveAndGet_RoundTripsReceipt()
|
||||
{
|
||||
var receipt = CreateReceipt(_tenantId);
|
||||
|
||||
@@ -5,6 +5,8 @@ using Npgsql;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
@@ -26,7 +28,8 @@ public sealed class RecheckEvidenceMigrationTests : IAsyncLifetime
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Migration_CreatesRecheckAndEvidenceTables()
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync("default", "reader", CancellationToken.None);
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
@@ -27,7 +28,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGetById_RoundTripsRiskProfile()
|
||||
{
|
||||
// Arrange
|
||||
@@ -56,7 +58,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
fetched.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetActiveByName_ReturnsActiveVersion()
|
||||
{
|
||||
// Arrange
|
||||
@@ -88,7 +91,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
fetched.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAll_ReturnsProfilesForTenant()
|
||||
{
|
||||
// Arrange
|
||||
@@ -105,7 +109,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
profiles.Select(p => p.Name).Should().Contain(["profile1", "profile2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAll_FiltersActiveOnly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -128,7 +133,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
activeProfiles[0].Name.Should().Be("active");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetVersionsByName_ReturnsAllVersions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -159,7 +165,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
versions.Select(v => v.Version).Should().Contain([1, 2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Update_ModifiesProfile()
|
||||
{
|
||||
// Arrange
|
||||
@@ -185,7 +192,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
fetched.Thresholds.Should().Contain("8.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateVersion_CreatesNewVersion()
|
||||
{
|
||||
// Arrange
|
||||
@@ -211,7 +219,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
originalAfter!.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Activate_SetsProfileAsActive()
|
||||
{
|
||||
// Arrange
|
||||
@@ -233,7 +242,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
fetched!.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Deactivate_SetsProfileAsInactive()
|
||||
{
|
||||
// Arrange
|
||||
@@ -249,7 +259,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
fetched!.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Delete_RemovesProfile()
|
||||
{
|
||||
// Arrange
|
||||
@@ -265,7 +276,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateVersion_HistoryRemainsQueryableAndOrdered()
|
||||
{
|
||||
// Arrange
|
||||
@@ -303,7 +315,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
|
||||
active!.Version.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Activate_RevertsToPriorVersionAndDeactivatesCurrent()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -36,7 +37,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_CreateMultipleVersions_AllVersionsRetrievable()
|
||||
{
|
||||
// Arrange - Create profile with multiple versions
|
||||
@@ -67,7 +69,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
allVersions.Select(p => p.Version).Should().BeEquivalentTo([1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_OnlyOneActivePerName_Enforced()
|
||||
{
|
||||
// Arrange - Create profile versions where only one should be active
|
||||
@@ -102,7 +105,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
active.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_ActivateOlderVersion_DeactivatesNewer()
|
||||
{
|
||||
// Arrange - Create two versions, v2 active
|
||||
@@ -136,7 +140,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
fetchedV1!.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_CreateVersion_IncreasesVersionNumber()
|
||||
{
|
||||
// Arrange - Create initial profile
|
||||
@@ -171,7 +176,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
created.Thresholds.Should().Contain("8.5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_GetVersionsByName_OrderedByVersion()
|
||||
{
|
||||
// Arrange - Create versions out of order
|
||||
@@ -212,7 +218,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
versions[2].Version.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_DeterministicOrdering_ConsistentResults()
|
||||
{
|
||||
// Arrange - Create multiple profiles with multiple versions
|
||||
@@ -245,7 +252,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
keys2.Should().Equal(keys3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_ThresholdsAndWeights_PreservedAcrossVersions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -293,7 +301,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
fetchedV2.ScoringWeights.Should().Be(v2Weights);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_DeleteOldVersion_NewerVersionsRemain()
|
||||
{
|
||||
// Arrange
|
||||
@@ -328,7 +337,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
remaining[0].Version.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_MultiTenant_VersionsIsolated()
|
||||
{
|
||||
// Arrange - Create same profile name in different tenants
|
||||
@@ -367,7 +377,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
tenant2Profile.Thresholds.Should().Contain("\"tenant\": \"2\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_DeactivateActiveVersion_NoActiveRemains()
|
||||
{
|
||||
// Arrange
|
||||
@@ -396,7 +407,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
fetched!.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_UpdateDescription_DoesNotAffectVersion()
|
||||
{
|
||||
// Arrange
|
||||
@@ -432,7 +444,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
|
||||
fetched.Description.Should().Be("Updated description");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VersionHistory_TimestampsTracked_OnCreationAndUpdate()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
@@ -67,7 +68,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
|
||||
}
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGetById_RoundTripsRule()
|
||||
{
|
||||
// Arrange
|
||||
@@ -96,7 +98,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
|
||||
fetched.Severity.Should().Be(RuleSeverity.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByName_ReturnsCorrectRule()
|
||||
{
|
||||
// Arrange
|
||||
@@ -111,7 +114,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
|
||||
fetched!.Id.Should().Be(rule.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateBatch_CreatesMultipleRules()
|
||||
{
|
||||
// Arrange
|
||||
@@ -129,7 +133,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
|
||||
count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByPackVersionId_ReturnsAllRulesForVersion()
|
||||
{
|
||||
// Arrange
|
||||
@@ -146,7 +151,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
|
||||
rules.Select(r => r.Name).Should().Contain(["rule1", "rule2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetBySeverity_ReturnsRulesWithSeverity()
|
||||
{
|
||||
// Arrange
|
||||
@@ -179,7 +185,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
|
||||
criticalRules[0].Name.Should().Be("critical-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByCategory_ReturnsRulesInCategory()
|
||||
{
|
||||
// Arrange
|
||||
@@ -212,7 +219,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
|
||||
securityRules[0].Name.Should().Be("security-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByTag_ReturnsRulesWithTag()
|
||||
{
|
||||
// Arrange
|
||||
@@ -245,7 +253,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
|
||||
containerRules[0].Name.Should().Be("container-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CountByPackVersionId_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -6,6 +6,8 @@ using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.Policy.Unknowns.Repositories;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
@@ -27,7 +29,8 @@ public sealed class UnknownsRepositoryTests : IAsyncLifetime
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public async Task DisposeAsync() => await _dataSource.DisposeAsync();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAndGetById_RoundTripsReasonCodeAndEvidence()
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId.ToString());
|
||||
@@ -56,7 +59,8 @@ public sealed class UnknownsRepositoryTests : IAsyncLifetime
|
||||
fetched.Assumptions.Should().ContainSingle("assume-dynamic-imports");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_PersistsReasonCodeAndAssumptions()
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId.ToString());
|
||||
|
||||
@@ -5,11 +5,14 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyBinderTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Bind_ValidYaml_ReturnsSuccess()
|
||||
{
|
||||
const string yaml = """
|
||||
@@ -29,7 +32,8 @@ public sealed class PolicyBinderTests
|
||||
Assert.Empty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Bind_ExceptionsConfigured_ParsesDefinitions()
|
||||
{
|
||||
const string yaml = """
|
||||
@@ -78,7 +82,8 @@ public sealed class PolicyBinderTests
|
||||
Assert.True(routing[0].RequireMfa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Bind_ExceptionDowngradeMissingSeverity_ReturnsError()
|
||||
{
|
||||
const string yaml = """
|
||||
@@ -99,7 +104,8 @@ public sealed class PolicyBinderTests
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "policy.exceptions.effect.downgrade.missingSeverity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Bind_InvalidSeverity_ReturnsError()
|
||||
{
|
||||
const string yaml = """
|
||||
@@ -116,7 +122,8 @@ public sealed class PolicyBinderTests
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "policy.severity.invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Cli_StrictMode_FailsOnWarnings()
|
||||
{
|
||||
const string yaml = """
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyEvaluationTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvaluateFinding_AppliesTrustAndReachabilityWeights()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
@@ -50,7 +52,8 @@ public sealed class PolicyEvaluationTests
|
||||
Assert.Equal("BlockMedium", explanation.RuleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvaluateFinding_QuietWithRequireVexAppliesQuietPenalty()
|
||||
{
|
||||
var ignoreOptions = new PolicyIgnoreOptions(null, null);
|
||||
@@ -99,7 +102,8 @@ public sealed class PolicyEvaluationTests
|
||||
Assert.Equal(PolicyVerdictStatus.Ignored, explanation!.Decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvaluateFinding_UnknownSeverityComputesConfidence()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyPreviewServiceTests
|
||||
@@ -18,7 +19,8 @@ public sealed class PolicyPreviewServiceTests
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ComputesDiffs_ForBlockingRule()
|
||||
{
|
||||
const string yaml = """
|
||||
@@ -63,7 +65,8 @@ rules:
|
||||
Assert.Equal(PolicyVerdictStatus.Pass, response.Diffs.First(diff => diff.Projected.FindingId == "finding-2").Projected.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PreviewAsync_UsesProposedPolicy_WhenProvided()
|
||||
{
|
||||
const string yaml = """
|
||||
@@ -103,7 +106,8 @@ rules:
|
||||
Assert.Equal(1, response.ChangedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
@@ -125,7 +129,8 @@ rules:
|
||||
Assert.NotEmpty(response.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PreviewAsync_QuietWithoutVexDowngradesToWarn()
|
||||
{
|
||||
const string yaml = """
|
||||
|
||||
@@ -2,11 +2,14 @@ using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyScoringConfigTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LoadDefaultReturnsConfig()
|
||||
{
|
||||
var config = PolicyScoringConfigBinder.LoadDefault();
|
||||
@@ -21,7 +24,8 @@ public sealed class PolicyScoringConfigTests
|
||||
Assert.Equal("high", config.UnknownConfidence.Bands[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BindRejectsEmptyContent()
|
||||
{
|
||||
var result = PolicyScoringConfigBinder.Bind(string.Empty, PolicyDocumentFormat.Json);
|
||||
@@ -29,7 +33,8 @@ public sealed class PolicyScoringConfigTests
|
||||
Assert.NotEmpty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BindRejectsInvalidSchema()
|
||||
{
|
||||
const string json = """
|
||||
@@ -47,7 +52,8 @@ public sealed class PolicyScoringConfigTests
|
||||
Assert.Null(result.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DefaultResourceDigestMatchesGolden()
|
||||
{
|
||||
var assembly = typeof(PolicyScoringConfig).Assembly;
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicySnapshotStoreTests
|
||||
@@ -17,7 +18,8 @@ rules:
|
||||
action: block
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SaveAsync_CreatesNewSnapshotAndAuditEntry()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
@@ -47,7 +49,8 @@ rules:
|
||||
Assert.Equal("rev-1", audits[0].RevisionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SaveAsync_DoesNotCreateNewRevisionWhenDigestUnchanged()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
@@ -72,7 +75,8 @@ rules:
|
||||
Assert.Single(audits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SaveAsync_ReturnsFailureWhenValidationFails()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
|
||||
@@ -4,11 +4,14 @@ using System.Threading.Tasks;
|
||||
using StellaOps.Policy;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public class PolicyValidationCliTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_EmitsCanonicalDigest_OnValidPolicy()
|
||||
{
|
||||
var tmp = Path.GetTempFileName();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using StellaOps.Policy;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public class SplCanonicalizerTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_SortsStatementsActionsAndConditions()
|
||||
{
|
||||
const string input = """
|
||||
@@ -54,7 +56,8 @@ public class SplCanonicalizerTests
|
||||
Assert.Equal(expected, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeDigest_IgnoresOrderingNoise()
|
||||
{
|
||||
const string versionA = """
|
||||
@@ -71,7 +74,8 @@ public class SplCanonicalizerTests
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeDigest_DetectsContentChange()
|
||||
{
|
||||
const string baseDoc = """
|
||||
|
||||
@@ -2,11 +2,14 @@ using System.Text.Json;
|
||||
using StellaOps.Policy;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public class SplLayeringEngineTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_ReplacesStatementsById_AndKeepsBaseOnes()
|
||||
{
|
||||
const string baseDoc = """
|
||||
@@ -24,7 +27,8 @@ public class SplLayeringEngineTests
|
||||
Assert.Equal(expected, merged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_MergesMetadataAndDefaultEffect()
|
||||
{
|
||||
const string baseDoc = """
|
||||
@@ -42,7 +46,8 @@ public class SplLayeringEngineTests
|
||||
Assert.Equal(expected, merged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_PreservesUnknownTopLevelAndSpecFields()
|
||||
{
|
||||
const string baseDoc = """
|
||||
|
||||
@@ -2,11 +2,13 @@ using System.Collections.Immutable;
|
||||
using StellaOps.Policy;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public class SplMigrationToolTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToSplPolicyJson_ConvertsRulesAndMetadata()
|
||||
{
|
||||
var rule = PolicyRule.Create(
|
||||
@@ -44,7 +46,8 @@ public class SplMigrationToolTests
|
||||
Assert.Equal(expected, spl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToSplPolicyJson_UsesOverlaySafeIdsAndAudits()
|
||||
{
|
||||
var rule = PolicyRule.Create(
|
||||
|
||||
@@ -2,11 +2,14 @@ using System.Text.Json;
|
||||
using StellaOps.Policy;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public class SplSchemaResourceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Schema_IncludesReachabilityAndExploitability()
|
||||
{
|
||||
var schema = SplSchemaResource.GetSchema();
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -8,6 +8,7 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.PolicyDsl.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -17,7 +18,8 @@ public class DslCompletionProviderTests
|
||||
{
|
||||
#region Catalog Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionCatalog_ReturnsNonNullCatalog()
|
||||
{
|
||||
// Act
|
||||
@@ -27,7 +29,8 @@ public class DslCompletionProviderTests
|
||||
catalog.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Catalog_ContainsScoreFields()
|
||||
{
|
||||
// Arrange
|
||||
@@ -43,7 +46,8 @@ public class DslCompletionProviderTests
|
||||
catalog.ScoreFields.Should().Contain(f => f.Label == "reachability");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Catalog_ContainsScoreBuckets()
|
||||
{
|
||||
// Arrange
|
||||
@@ -58,7 +62,8 @@ public class DslCompletionProviderTests
|
||||
catalog.ScoreBuckets.Should().Contain(b => b.Label == "Watchlist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Catalog_ContainsScoreFlags()
|
||||
{
|
||||
// Arrange
|
||||
@@ -73,7 +78,8 @@ public class DslCompletionProviderTests
|
||||
catalog.ScoreFlags.Should().Contain(f => f.Label == "unreachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Catalog_ContainsAllDimensionAliases()
|
||||
{
|
||||
// Arrange
|
||||
@@ -96,7 +102,8 @@ public class DslCompletionProviderTests
|
||||
catalog.ScoreFields.Should().Contain(f => f.Label == "mitigation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Catalog_ContainsVexStatuses()
|
||||
{
|
||||
// Arrange
|
||||
@@ -109,7 +116,8 @@ public class DslCompletionProviderTests
|
||||
catalog.VexStatuses.Should().Contain(s => s.Label == "fixed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Catalog_ContainsKeywordsAndFunctions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -132,7 +140,8 @@ public class DslCompletionProviderTests
|
||||
|
||||
#region Context-Based Completion Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_ScoreDot_ReturnsScoreFields()
|
||||
{
|
||||
// Arrange
|
||||
@@ -150,7 +159,8 @@ public class DslCompletionProviderTests
|
||||
DslCompletionProvider.GetCompletionCatalog().ScoreFields.Any(sf => sf.Label == c.Label));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_SbomDot_ReturnsSbomFields()
|
||||
{
|
||||
// Arrange
|
||||
@@ -166,7 +176,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "version");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_AdvisoryDot_ReturnsAdvisoryFields()
|
||||
{
|
||||
// Arrange
|
||||
@@ -182,7 +193,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "severity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_VexDot_ReturnsVexFields()
|
||||
{
|
||||
// Arrange
|
||||
@@ -198,7 +210,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "any");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_ScoreBucketEquals_ReturnsBuckets()
|
||||
{
|
||||
// Arrange
|
||||
@@ -215,7 +228,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "Watchlist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_ScoreBucketEqualsQuote_ReturnsBuckets()
|
||||
{
|
||||
// Arrange
|
||||
@@ -229,7 +243,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_ScoreFlagsContains_ReturnsFlags()
|
||||
{
|
||||
// Arrange
|
||||
@@ -245,7 +260,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "vendor-na");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_StatusEquals_ReturnsVexStatuses()
|
||||
{
|
||||
// Arrange
|
||||
@@ -261,7 +277,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "fixed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_JustificationEquals_ReturnsJustifications()
|
||||
{
|
||||
// Arrange
|
||||
@@ -276,7 +293,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "vulnerable_code_not_present");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_AfterThen_ReturnsActions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -292,7 +310,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "escalate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_AfterElse_ReturnsActions()
|
||||
{
|
||||
// Arrange
|
||||
@@ -307,7 +326,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "defer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_EmptyContext_ReturnsAllTopLevel()
|
||||
{
|
||||
// Arrange
|
||||
@@ -332,7 +352,8 @@ public class DslCompletionProviderTests
|
||||
|
||||
#region CompletionItem Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ScoreValueField_HasCorrectDocumentation()
|
||||
{
|
||||
// Arrange
|
||||
@@ -347,7 +368,8 @@ public class DslCompletionProviderTests
|
||||
valueField.Kind.Should().Be(DslCompletionKind.Field);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ScoreBucketField_HasCorrectDocumentation()
|
||||
{
|
||||
// Arrange
|
||||
@@ -363,7 +385,8 @@ public class DslCompletionProviderTests
|
||||
bucketField.Documentation.Should().Contain("Watchlist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ScoreFlags_AllHaveQuotedInsertText()
|
||||
{
|
||||
// Arrange
|
||||
@@ -377,7 +400,8 @@ public class DslCompletionProviderTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ScoreBuckets_AllHaveQuotedInsertText()
|
||||
{
|
||||
// Arrange
|
||||
@@ -391,7 +415,8 @@ public class DslCompletionProviderTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SnippetCompletions_HaveSnippetFlag()
|
||||
{
|
||||
// Arrange
|
||||
@@ -403,7 +428,8 @@ public class DslCompletionProviderTests
|
||||
policyKeyword.InsertText.Should().Contain("${1:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SimpleFields_DoNotHaveSnippetFlag()
|
||||
{
|
||||
// Arrange
|
||||
@@ -419,7 +445,8 @@ public class DslCompletionProviderTests
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_NullContext_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
@@ -427,7 +454,8 @@ public class DslCompletionProviderTests
|
||||
action.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_CaseInsensitive_ScoreBucket()
|
||||
{
|
||||
// Arrange - mixed case
|
||||
@@ -441,7 +469,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "ActNow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetCompletionsForContext_MultipleContextsInLine_ReturnsCorrectCompletions()
|
||||
{
|
||||
// Arrange - score.value already used, now typing score.bucket
|
||||
@@ -455,7 +484,8 @@ public class DslCompletionProviderTests
|
||||
completions.Should().Contain(c => c.Label == "ActNow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Catalog_IsSingleton()
|
||||
{
|
||||
// Act
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentAssertions;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.PolicyDsl.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -11,7 +12,8 @@ public class PolicyCompilerTests
|
||||
{
|
||||
private readonly PolicyCompiler _compiler = new();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_MinimalPolicy_Succeeds()
|
||||
{
|
||||
// Arrange - rule name is an identifier, not a string; then block has no braces; := for assignment
|
||||
@@ -38,7 +40,8 @@ public class PolicyCompilerTests
|
||||
result.Checksum.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_WithMetadata_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -66,7 +69,8 @@ public class PolicyCompilerTests
|
||||
result.Document.Metadata.Should().ContainKey("author");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_WithProfile_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -93,7 +97,8 @@ public class PolicyCompilerTests
|
||||
result.Document.Profiles[0].Name.Should().Be("standard");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_EmptySource_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
@@ -107,7 +112,8 @@ public class PolicyCompilerTests
|
||||
result.Diagnostics.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_InvalidSyntax_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
@@ -123,7 +129,8 @@ public class PolicyCompilerTests
|
||||
result.Success.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_SameSource_ProducesSameChecksum()
|
||||
{
|
||||
// Arrange
|
||||
@@ -148,7 +155,8 @@ public class PolicyCompilerTests
|
||||
result1.Checksum.Should().Be(result2.Checksum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compile_DifferentSource_ProducesDifferentChecksum()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentAssertions;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.PolicyDsl.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -11,7 +12,8 @@ public class PolicyEngineTests
|
||||
{
|
||||
private readonly PolicyEngineFactory _factory = new();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_RuleMatches_ReturnsMatchedRules()
|
||||
{
|
||||
// Arrange
|
||||
@@ -40,7 +42,8 @@ public class PolicyEngineTests
|
||||
evalResult.PolicyChecksum.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_RuleDoesNotMatch_ExecutesElseBranch()
|
||||
{
|
||||
// Arrange
|
||||
@@ -72,7 +75,8 @@ public class PolicyEngineTests
|
||||
evalResult.Actions[0].WasElseBranch.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_MultipleRules_EvaluatesInPriorityOrder()
|
||||
{
|
||||
// Arrange
|
||||
@@ -106,7 +110,8 @@ public class PolicyEngineTests
|
||||
evalResult.MatchedRules[1].Should().Be("low_priority");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_WithAndCondition_MatchesWhenBothTrue()
|
||||
{
|
||||
// Arrange
|
||||
@@ -135,7 +140,8 @@ public class PolicyEngineTests
|
||||
evalResult.MatchedRules.Should().Contain("combined");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_WithOrCondition_MatchesWhenEitherTrue()
|
||||
{
|
||||
// Arrange
|
||||
@@ -163,7 +169,8 @@ public class PolicyEngineTests
|
||||
evalResult.MatchedRules.Should().Contain("either");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evaluate_WithNotCondition_InvertsResult()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentAssertions;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.PolicyDsl.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -9,7 +10,8 @@ namespace StellaOps.PolicyDsl.Tests;
|
||||
/// </summary>
|
||||
public class SignalContextTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_WithSignal_SetsSignalValue()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -21,7 +23,8 @@ public class SignalContextTests
|
||||
context.GetSignal("test").Should().Be("value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_WithFlag_SetsBooleanSignal()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -33,7 +36,8 @@ public class SignalContextTests
|
||||
context.GetSignal<bool>("enabled").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_WithNumber_SetsDecimalSignal()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -45,7 +49,8 @@ public class SignalContextTests
|
||||
context.GetSignal<decimal>("score").Should().Be(0.95m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_WithString_SetsStringSignal()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -57,7 +62,8 @@ public class SignalContextTests
|
||||
context.GetSignal<string>("name").Should().Be("test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_WithFinding_SetsNestedFindingObject()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -74,7 +80,8 @@ public class SignalContextTests
|
||||
finding["cve_id"].Should().Be("CVE-2024-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_WithReachability_SetsNestedReachabilityObject()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -91,7 +98,8 @@ public class SignalContextTests
|
||||
reachability["has_runtime_evidence"].Should().Be(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_WithTrustScore_SetsTrustSignals()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -104,7 +112,8 @@ public class SignalContextTests
|
||||
context.GetSignal<bool>("trust_verified").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SetSignal_UpdatesExistingValue()
|
||||
{
|
||||
// Arrange
|
||||
@@ -118,7 +127,8 @@ public class SignalContextTests
|
||||
context.GetSignal("key").Should().Be("value2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RemoveSignal_RemovesExistingSignal()
|
||||
{
|
||||
// Arrange
|
||||
@@ -132,7 +142,8 @@ public class SignalContextTests
|
||||
context.HasSignal("key").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Clone_CreatesIndependentCopy()
|
||||
{
|
||||
// Arrange
|
||||
@@ -149,7 +160,8 @@ public class SignalContextTests
|
||||
clone.GetSignal("key").Should().Be("modified");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignalNames_ReturnsAllSignalKeys()
|
||||
{
|
||||
// Arrange
|
||||
@@ -163,7 +175,8 @@ public class SignalContextTests
|
||||
context.SignalNames.Should().BeEquivalentTo(new[] { "a", "b", "c" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Signals_ReturnsReadOnlyDictionary()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="TestData\*.dsl">
|
||||
|
||||
Reference in New Issue
Block a user