Refactor code structure and optimize performance across multiple modules

This commit is contained in:
StellaOps Bot
2025-12-26 20:03:22 +02:00
parent c786faae84
commit b4fc66feb6
3353 changed files with 88254 additions and 1590657 deletions

View File

@@ -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 [];
}
}
}

View File

@@ -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; }
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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; }
}

View File

@@ -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"
};
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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"));

View File

@@ -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"));

View File

@@ -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);

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);

View File

@@ -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 = """

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 = """

View File

@@ -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());

View File

@@ -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"));

View File

@@ -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

View File

@@ -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"));

View File

@@ -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);

View File

@@ -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();

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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>

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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>

View File

@@ -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");

View File

@@ -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")]

View File

@@ -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");

View File

@@ -35,4 +35,7 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -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 = """

View File

@@ -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(" "));

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -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

View File

@@ -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)]

View File

@@ -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

View File

@@ -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);

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -25,5 +25,6 @@
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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());

View File

@@ -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 = """

View File

@@ -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);

View File

@@ -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 = """

View File

@@ -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;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 = """

View File

@@ -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 = """

View File

@@ -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(

View File

@@ -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();

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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">