Frontend gaps fill work. Testing fixes work. Auditing in progress.

This commit is contained in:
StellaOps Bot
2025-12-30 01:22:58 +02:00
parent 1dc4bcbf10
commit 7a5210e2aa
928 changed files with 183942 additions and 3941 deletions

View File

@@ -0,0 +1,870 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20251229_021a_FE_policy_governance_controls
// Task: GOV-018 - Sealed mode overrides and risk profile events endpoints
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Policy.Gateway.Endpoints;
/// <summary>
/// Policy governance API endpoints for sealed mode and risk profile management.
/// </summary>
public static class GovernanceEndpoints
{
// In-memory stores for development
private static readonly ConcurrentDictionary<string, SealedModeState> SealedModeStates = new();
private static readonly ConcurrentDictionary<string, SealedModeOverrideEntity> Overrides = new();
private static readonly ConcurrentDictionary<string, RiskProfileEntity> RiskProfiles = new();
private static readonly ConcurrentDictionary<string, GovernanceAuditEntry> AuditEntries = new();
/// <summary>
/// Maps governance endpoints to the application.
/// </summary>
public static void MapGovernanceEndpoints(this WebApplication app)
{
var governance = app.MapGroup("/api/v1/governance")
.WithTags("Governance");
// Sealed Mode endpoints
governance.MapGet("/sealed-mode/status", GetSealedModeStatusAsync)
.WithName("GetSealedModeStatus")
.WithDescription("Get sealed mode status");
governance.MapGet("/sealed-mode/overrides", GetSealedModeOverridesAsync)
.WithName("GetSealedModeOverrides")
.WithDescription("List sealed mode overrides");
governance.MapPost("/sealed-mode/toggle", ToggleSealedModeAsync)
.WithName("ToggleSealedMode")
.WithDescription("Toggle sealed mode on/off");
governance.MapPost("/sealed-mode/overrides", CreateSealedModeOverrideAsync)
.WithName("CreateSealedModeOverride")
.WithDescription("Create a sealed mode override");
governance.MapPost("/sealed-mode/overrides/{overrideId}/revoke", RevokeSealedModeOverrideAsync)
.WithName("RevokeSealedModeOverride")
.WithDescription("Revoke a sealed mode override");
// Risk Profile endpoints
governance.MapGet("/risk-profiles", ListRiskProfilesAsync)
.WithName("ListRiskProfiles")
.WithDescription("List risk profiles");
governance.MapGet("/risk-profiles/{profileId}", GetRiskProfileAsync)
.WithName("GetRiskProfile")
.WithDescription("Get a risk profile by ID");
governance.MapPost("/risk-profiles", CreateRiskProfileAsync)
.WithName("CreateRiskProfile")
.WithDescription("Create a new risk profile");
governance.MapPut("/risk-profiles/{profileId}", UpdateRiskProfileAsync)
.WithName("UpdateRiskProfile")
.WithDescription("Update a risk profile");
governance.MapDelete("/risk-profiles/{profileId}", DeleteRiskProfileAsync)
.WithName("DeleteRiskProfile")
.WithDescription("Delete a risk profile");
governance.MapPost("/risk-profiles/{profileId}/activate", ActivateRiskProfileAsync)
.WithName("ActivateRiskProfile")
.WithDescription("Activate a risk profile");
governance.MapPost("/risk-profiles/{profileId}/deprecate", DeprecateRiskProfileAsync)
.WithName("DeprecateRiskProfile")
.WithDescription("Deprecate a risk profile");
governance.MapPost("/risk-profiles/validate", ValidateRiskProfileAsync)
.WithName("ValidateRiskProfile")
.WithDescription("Validate a risk profile");
// Audit endpoints
governance.MapGet("/audit/events", GetAuditEventsAsync)
.WithName("GetGovernanceAuditEvents")
.WithDescription("Get governance audit events");
governance.MapGet("/audit/events/{eventId}", GetAuditEventAsync)
.WithName("GetGovernanceAuditEvent")
.WithDescription("Get a specific audit event");
// Initialize default profiles
InitializeDefaultProfiles();
}
// ========================================================================
// Sealed Mode Handlers
// ========================================================================
private static Task<IResult> GetSealedModeStatusAsync(
HttpContext httpContext,
[FromQuery] string? tenantId)
{
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
var response = new SealedModeStatusResponse
{
IsSealed = state.IsSealed,
SealedAt = state.SealedAt,
SealedBy = state.SealedBy,
Reason = state.Reason,
TrustRoots = state.TrustRoots,
AllowedSources = state.AllowedSources,
Overrides = Overrides.Values
.Where(o => o.TenantId == tenant && o.Active)
.Select(MapOverrideToResponse)
.ToList(),
VerificationStatus = "verified",
LastVerifiedAt = DateTimeOffset.UtcNow.ToString("O")
};
return Task.FromResult(Results.Ok(response));
}
private static Task<IResult> GetSealedModeOverridesAsync(
HttpContext httpContext,
[FromQuery] string? tenantId)
{
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
var overrides = Overrides.Values
.Where(o => o.TenantId == tenant)
.Select(MapOverrideToResponse)
.ToList();
return Task.FromResult(Results.Ok(overrides));
}
private static Task<IResult> ToggleSealedModeAsync(
HttpContext httpContext,
SealedModeToggleRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
if (request.Enable)
{
state = new SealedModeState
{
IsSealed = true,
SealedAt = now.ToString("O"),
SealedBy = actor,
Reason = request.Reason,
TrustRoots = request.TrustRoots ?? [],
AllowedSources = request.AllowedSources ?? []
};
}
else
{
state = new SealedModeState
{
IsSealed = false,
LastUnsealedAt = now.ToString("O")
};
}
SealedModeStates[tenant] = state;
// Audit
RecordAudit(tenant, actor, "sealed_mode_toggled", "sealed-mode", "system_config",
$"{(request.Enable ? "Enabled" : "Disabled")} sealed mode: {request.Reason}");
var response = new SealedModeStatusResponse
{
IsSealed = state.IsSealed,
SealedAt = state.SealedAt,
SealedBy = state.SealedBy,
Reason = state.Reason,
TrustRoots = state.TrustRoots,
AllowedSources = state.AllowedSources,
Overrides = [],
VerificationStatus = "verified",
LastVerifiedAt = now.ToString("O")
};
return Task.FromResult(Results.Ok(response));
}
private static Task<IResult> CreateSealedModeOverrideAsync(
HttpContext httpContext,
SealedModeOverrideRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var overrideId = $"override-{Guid.NewGuid():N}";
var entity = new SealedModeOverrideEntity
{
Id = overrideId,
TenantId = tenant,
Type = request.Type,
Target = request.Target,
Reason = request.Reason,
ApprovalId = $"approval-{Guid.NewGuid():N}",
ApprovedBy = [actor],
ExpiresAt = now.AddHours(request.DurationHours).ToString("O"),
CreatedAt = now.ToString("O"),
Active = true
};
Overrides[overrideId] = entity;
RecordAudit(tenant, actor, "sealed_mode_override_created", overrideId, "sealed_mode_override",
$"Created override for {request.Target}: {request.Reason}");
return Task.FromResult(Results.Ok(MapOverrideToResponse(entity)));
}
private static Task<IResult> RevokeSealedModeOverrideAsync(
HttpContext httpContext,
string overrideId,
RevokeOverrideRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
if (!Overrides.TryGetValue(overrideId, out var entity) || entity.TenantId != tenant)
{
return Task.FromResult(Results.NotFound(new ProblemDetails
{
Title = "Override not found",
Status = 404
}));
}
entity.Active = false;
Overrides[overrideId] = entity;
RecordAudit(tenant, actor, "sealed_mode_override_revoked", overrideId, "sealed_mode_override",
$"Revoked override: {request.Reason}");
return Task.FromResult(Results.NoContent());
}
// ========================================================================
// Risk Profile Handlers
// ========================================================================
private static Task<IResult> ListRiskProfilesAsync(
HttpContext httpContext,
[FromQuery] string? tenantId,
[FromQuery] string? status)
{
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
var profiles = RiskProfiles.Values
.Where(p => p.TenantId == tenant || p.TenantId == "default")
.Where(p => string.IsNullOrEmpty(status) || p.Status.Equals(status, StringComparison.OrdinalIgnoreCase))
.Select(MapProfileToResponse)
.ToList();
return Task.FromResult(Results.Ok(profiles));
}
private static Task<IResult> GetRiskProfileAsync(
HttpContext httpContext,
string profileId)
{
var tenant = GetTenantId(httpContext) ?? "default";
if (!RiskProfiles.TryGetValue(profileId, out var profile))
{
return Task.FromResult(Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Status = 404,
Detail = $"Risk profile '{profileId}' not found."
}));
}
return Task.FromResult(Results.Ok(MapProfileToResponse(profile)));
}
private static Task<IResult> CreateRiskProfileAsync(
HttpContext httpContext,
CreateRiskProfileRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var profileId = $"profile-{Guid.NewGuid():N}";
var entity = new RiskProfileEntity
{
Id = profileId,
TenantId = tenant,
Version = "1.0.0",
Name = request.Name,
Description = request.Description,
Status = "draft",
ExtendsProfile = request.ExtendsProfile,
Signals = request.Signals ?? [],
SeverityOverrides = request.SeverityOverrides ?? [],
ActionOverrides = request.ActionOverrides ?? [],
CreatedAt = now.ToString("O"),
ModifiedAt = now.ToString("O"),
CreatedBy = actor,
ModifiedBy = actor
};
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_created", profileId, "risk_profile",
$"Created risk profile: {request.Name}");
return Task.FromResult(Results.Created($"/api/v1/governance/risk-profiles/{profileId}", MapProfileToResponse(entity)));
}
private static Task<IResult> UpdateRiskProfileAsync(
HttpContext httpContext,
string profileId,
UpdateRiskProfileRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
if (!RiskProfiles.TryGetValue(profileId, out var existing))
{
return Task.FromResult(Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Status = 404
}));
}
var entity = existing with
{
Name = request.Name ?? existing.Name,
Description = request.Description ?? existing.Description,
Signals = request.Signals ?? existing.Signals,
SeverityOverrides = request.SeverityOverrides ?? existing.SeverityOverrides,
ActionOverrides = request.ActionOverrides ?? existing.ActionOverrides,
ModifiedAt = now.ToString("O"),
ModifiedBy = actor
};
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_updated", profileId, "risk_profile",
$"Updated risk profile: {entity.Name}");
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
private static Task<IResult> DeleteRiskProfileAsync(
HttpContext httpContext,
string profileId)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
if (!RiskProfiles.TryRemove(profileId, out var removed))
{
return Task.FromResult(Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Status = 404
}));
}
RecordAudit(tenant, actor, "risk_profile_deleted", profileId, "risk_profile",
$"Deleted risk profile: {removed.Name}");
return Task.FromResult(Results.NoContent());
}
private static Task<IResult> ActivateRiskProfileAsync(
HttpContext httpContext,
string profileId)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
if (!RiskProfiles.TryGetValue(profileId, out var existing))
{
return Task.FromResult(Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Status = 404
}));
}
var entity = existing with
{
Status = "active",
ModifiedAt = now.ToString("O"),
ModifiedBy = actor
};
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_activated", profileId, "risk_profile",
$"Activated risk profile: {entity.Name}");
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
private static Task<IResult> DeprecateRiskProfileAsync(
HttpContext httpContext,
string profileId,
DeprecateProfileRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
if (!RiskProfiles.TryGetValue(profileId, out var existing))
{
return Task.FromResult(Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Status = 404
}));
}
var entity = existing with
{
Status = "deprecated",
ModifiedAt = now.ToString("O"),
ModifiedBy = actor,
DeprecationReason = request.Reason
};
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_deprecated", profileId, "risk_profile",
$"Deprecated risk profile: {entity.Name} - {request.Reason}");
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
private static Task<IResult> ValidateRiskProfileAsync(
HttpContext httpContext,
ValidateRiskProfileRequest request)
{
var errors = new List<ValidationError>();
var warnings = new List<ValidationWarning>();
if (string.IsNullOrWhiteSpace(request.Name))
{
errors.Add(new ValidationError("MISSING_NAME", "Profile name is required", "name"));
}
if (request.Signals == null || request.Signals.Count == 0)
{
errors.Add(new ValidationError("NO_SIGNALS", "At least one signal must be defined", "signals"));
}
else
{
var totalWeight = request.Signals.Where(s => s.Enabled).Sum(s => s.Weight);
if (Math.Abs(totalWeight - 1.0) > 0.01)
{
warnings.Add(new ValidationWarning("WEIGHT_SUM", $"Signal weights sum to {totalWeight:F2}, expected 1.0", "signals"));
}
}
var response = new RiskProfileValidationResponse
{
Valid = errors.Count == 0,
Errors = errors,
Warnings = warnings
};
return Task.FromResult(Results.Ok(response));
}
// ========================================================================
// Audit Handlers
// ========================================================================
private static Task<IResult> GetAuditEventsAsync(
HttpContext httpContext,
[FromQuery] string? tenantId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
var events = AuditEntries.Values
.Where(e => e.TenantId == tenant)
.OrderByDescending(e => e.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(MapAuditToResponse)
.ToList();
var total = AuditEntries.Values.Count(e => e.TenantId == tenant);
var response = new AuditEventsResponse
{
Events = events,
Total = total,
Page = page,
PageSize = pageSize,
HasMore = (page * pageSize) < total
};
return Task.FromResult(Results.Ok(response));
}
private static Task<IResult> GetAuditEventAsync(
HttpContext httpContext,
string eventId)
{
var tenant = GetTenantId(httpContext) ?? "default";
if (!AuditEntries.TryGetValue(eventId, out var entry) || entry.TenantId != tenant)
{
return Task.FromResult(Results.NotFound(new ProblemDetails
{
Title = "Event not found",
Status = 404
}));
}
return Task.FromResult(Results.Ok(MapAuditToResponse(entry)));
}
// ========================================================================
// Helper Methods
// ========================================================================
private static void InitializeDefaultProfiles()
{
if (RiskProfiles.IsEmpty)
{
var now = DateTimeOffset.UtcNow.ToString("O");
RiskProfiles["profile-default"] = new RiskProfileEntity
{
Id = "profile-default",
TenantId = "default",
Version = "1.0.0",
Name = "Default Risk Profile",
Description = "Standard risk evaluation profile",
Status = "active",
Signals = [
new RiskSignal { Name = "cvss_score", Weight = 0.3, Description = "CVSS base score", Enabled = true },
new RiskSignal { Name = "exploit_available", Weight = 0.25, Description = "Known exploit exists", Enabled = true },
new RiskSignal { Name = "reachability", Weight = 0.2, Description = "Code reachability", Enabled = true },
new RiskSignal { Name = "asset_criticality", Weight = 0.15, Description = "Asset business criticality", Enabled = true },
new RiskSignal { Name = "patch_available", Weight = 0.1, Description = "Patch availability", Enabled = true }
],
SeverityOverrides = [],
ActionOverrides = [],
CreatedAt = now,
ModifiedAt = now,
CreatedBy = "system",
ModifiedBy = "system"
};
}
}
private static string? GetTenantId(HttpContext httpContext)
{
return httpContext.User.Claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault();
}
private static string? GetActorId(HttpContext httpContext)
{
return httpContext.User.Claims.FirstOrDefault(c => c.Type == "sub")?.Value
?? httpContext.User.Identity?.Name
?? httpContext.Request.Headers["X-StellaOps-Actor"].FirstOrDefault();
}
private static void RecordAudit(string tenantId, string actor, string eventType, string targetId, string targetType, string summary)
{
var id = $"audit-{Guid.NewGuid():N}";
AuditEntries[id] = new GovernanceAuditEntry
{
Id = id,
TenantId = tenantId,
Type = eventType,
Timestamp = DateTimeOffset.UtcNow.ToString("O"),
Actor = actor,
ActorType = "user",
TargetResource = targetId,
TargetResourceType = targetType,
Summary = summary
};
}
private static SealedModeOverrideResponse MapOverrideToResponse(SealedModeOverrideEntity entity)
{
return new SealedModeOverrideResponse
{
Id = entity.Id,
Type = entity.Type,
Target = entity.Target,
Reason = entity.Reason,
ApprovalId = entity.ApprovalId,
ApprovedBy = entity.ApprovedBy,
ExpiresAt = entity.ExpiresAt,
CreatedAt = entity.CreatedAt,
Active = entity.Active
};
}
private static RiskProfileResponse MapProfileToResponse(RiskProfileEntity entity)
{
return new RiskProfileResponse
{
Id = entity.Id,
Version = entity.Version,
Name = entity.Name,
Description = entity.Description,
Status = entity.Status,
ExtendsProfile = entity.ExtendsProfile,
Signals = entity.Signals,
SeverityOverrides = entity.SeverityOverrides,
ActionOverrides = entity.ActionOverrides,
CreatedAt = entity.CreatedAt,
ModifiedAt = entity.ModifiedAt,
CreatedBy = entity.CreatedBy,
ModifiedBy = entity.ModifiedBy
};
}
private static GovernanceAuditEventResponse MapAuditToResponse(GovernanceAuditEntry entry)
{
return new GovernanceAuditEventResponse
{
Id = entry.Id,
Type = entry.Type,
Timestamp = entry.Timestamp,
Actor = entry.Actor,
ActorType = entry.ActorType,
TargetResource = entry.TargetResource,
TargetResourceType = entry.TargetResourceType,
Summary = entry.Summary,
TenantId = entry.TenantId
};
}
}
// ============================================================================
// Internal Entities
// ============================================================================
internal sealed class SealedModeState
{
public bool IsSealed { get; set; }
public string? SealedAt { get; set; }
public string? SealedBy { get; set; }
public string? Reason { get; set; }
public string? LastUnsealedAt { get; set; }
public List<string> TrustRoots { get; set; } = [];
public List<string> AllowedSources { get; set; } = [];
}
internal sealed record SealedModeOverrideEntity
{
public required string Id { get; init; }
public required string TenantId { get; init; }
public required string Type { get; init; }
public required string Target { get; init; }
public required string Reason { get; init; }
public required string ApprovalId { get; init; }
public required List<string> ApprovedBy { get; init; }
public required string ExpiresAt { get; init; }
public required string CreatedAt { get; init; }
public bool Active { get; set; }
}
internal sealed record RiskProfileEntity
{
public required string Id { get; init; }
public required string TenantId { get; init; }
public required string Version { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public required string Status { get; init; }
public string? ExtendsProfile { get; init; }
public required List<RiskSignal> Signals { get; init; }
public required List<SeverityOverride> SeverityOverrides { get; init; }
public required List<ActionOverride> ActionOverrides { get; init; }
public required string CreatedAt { get; init; }
public required string ModifiedAt { get; init; }
public required string CreatedBy { get; init; }
public required string ModifiedBy { get; init; }
public string? DeprecationReason { get; init; }
}
internal sealed record GovernanceAuditEntry
{
public required string Id { get; init; }
public required string TenantId { get; init; }
public required string Type { get; init; }
public required string Timestamp { get; init; }
public required string Actor { get; init; }
public required string ActorType { get; init; }
public required string TargetResource { get; init; }
public required string TargetResourceType { get; init; }
public required string Summary { get; init; }
}
// ============================================================================
// Request/Response DTOs
// ============================================================================
public sealed record SealedModeToggleRequest
{
public required bool Enable { get; init; }
public string? Reason { get; init; }
public List<string>? TrustRoots { get; init; }
public List<string>? AllowedSources { get; init; }
}
public sealed record SealedModeOverrideRequest
{
public required string Type { get; init; }
public required string Target { get; init; }
public required string Reason { get; init; }
public int DurationHours { get; init; } = 24;
}
public sealed record RevokeOverrideRequest
{
public required string Reason { get; init; }
}
public sealed record SealedModeStatusResponse
{
public required bool IsSealed { get; init; }
public string? SealedAt { get; init; }
public string? SealedBy { get; init; }
public string? Reason { get; init; }
public required List<string> TrustRoots { get; init; }
public required List<string> AllowedSources { get; init; }
public required List<SealedModeOverrideResponse> Overrides { get; init; }
public required string VerificationStatus { get; init; }
public string? LastVerifiedAt { get; init; }
}
public sealed record SealedModeOverrideResponse
{
public required string Id { get; init; }
public required string Type { get; init; }
public required string Target { get; init; }
public required string Reason { get; init; }
public required string ApprovalId { get; init; }
public required List<string> ApprovedBy { get; init; }
public required string ExpiresAt { get; init; }
public required string CreatedAt { get; init; }
public required bool Active { get; init; }
}
public sealed record CreateRiskProfileRequest
{
public required string Name { get; init; }
public string? Description { get; init; }
public string? ExtendsProfile { get; init; }
public List<RiskSignal>? Signals { get; init; }
public List<SeverityOverride>? SeverityOverrides { get; init; }
public List<ActionOverride>? ActionOverrides { get; init; }
}
public sealed record UpdateRiskProfileRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public List<RiskSignal>? Signals { get; init; }
public List<SeverityOverride>? SeverityOverrides { get; init; }
public List<ActionOverride>? ActionOverrides { get; init; }
}
public sealed record DeprecateProfileRequest
{
public required string Reason { get; init; }
}
public sealed record ValidateRiskProfileRequest
{
public string? Name { get; init; }
public List<RiskSignal>? Signals { get; init; }
}
public sealed record RiskProfileResponse
{
public required string Id { get; init; }
public required string Version { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public required string Status { get; init; }
public string? ExtendsProfile { get; init; }
public required List<RiskSignal> Signals { get; init; }
public required List<SeverityOverride> SeverityOverrides { get; init; }
public required List<ActionOverride> ActionOverrides { get; init; }
public required string CreatedAt { get; init; }
public required string ModifiedAt { get; init; }
public required string CreatedBy { get; init; }
public required string ModifiedBy { get; init; }
}
public sealed record RiskProfileValidationResponse
{
public required bool Valid { get; init; }
public required List<ValidationError> Errors { get; init; }
public required List<ValidationWarning> Warnings { get; init; }
}
public sealed record ValidationError(string Code, string Message, string? Path = null);
public sealed record ValidationWarning(string Code, string Message, string? Path = null);
public sealed record RiskSignal
{
public required string Name { get; init; }
public required double Weight { get; init; }
public string? Description { get; init; }
public required bool Enabled { get; init; }
}
public sealed record SeverityOverride
{
public string? Id { get; init; }
public string? TargetSeverity { get; init; }
public object? Condition { get; init; }
public string? Description { get; init; }
public int Priority { get; init; }
}
public sealed record ActionOverride
{
public string? Id { get; init; }
public string? TargetAction { get; init; }
public object? Condition { get; init; }
public string? Description { get; init; }
public int Priority { get; init; }
}
public sealed record AuditEventsResponse
{
public required List<GovernanceAuditEventResponse> Events { get; init; }
public required int Total { get; init; }
public required int Page { get; init; }
public required int PageSize { get; init; }
public required bool HasMore { get; init; }
}
public sealed record GovernanceAuditEventResponse
{
public required string Id { get; init; }
public required string Type { get; init; }
public required string Timestamp { get; init; }
public required string Actor { get; init; }
public required string ActorType { get; init; }
public required string TargetResource { get; init; }
public required string TargetResourceType { get; init; }
public required string Summary { get; init; }
public required string TenantId { get; init; }
}

View File

@@ -530,6 +530,9 @@ app.MapRegistryWebhooks();
// Exception approval endpoints (Sprint: SPRINT_20251226_003_BE_exception_approval)
app.MapExceptionApprovalEndpoints();
// Governance endpoints (Sprint: SPRINT_20251229_021a_FE_policy_governance_controls, Task: GOV-018)
app.MapGovernanceEndpoints();
app.Run();
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)

View File

@@ -56,12 +56,12 @@ public sealed class ScoringApiContractTests : IAsyncLifetime
_pactBuilder = pact.WithHttpInteractions();
}
public Task InitializeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public Task DisposeAsync()
public ValueTask DisposeAsync()
{
// Pact files are generated when the builder disposes
return Task.CompletedTask;
return ValueTask.CompletedTask;
}
#region Scoring Input Contract Tests
@@ -384,8 +384,8 @@ public sealed class ProfileSpecificContractTests : IAsyncLifetime
_pactBuilder = pact.WithHttpInteractions();
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact(DisplayName = "Consumer expects Simple profile to return Simple in scoringProfile")]
public async Task Consumer_Expects_SimpleProfile_InResponse()
@@ -427,3 +427,6 @@ public sealed class ProfileSpecificContractTests : IAsyncLifetime
});
}
}

View File

@@ -14,7 +14,7 @@
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="FsCheck" />
<PackageReference Include="FsCheck.Xunit" />
<PackageReference Include="FsCheck.Xunit.v3" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
@@ -28,4 +28,6 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,397 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.TestKit;
using Xunit;
using GatewayProgram = StellaOps.Policy.Gateway.Program;
namespace StellaOps.Policy.Gateway.Tests;
/// <summary>
/// Tests for governance endpoints (GOV-018).
/// </summary>
public sealed class GovernanceEndpointsTests : IClassFixture<WebApplicationFactory<GatewayProgram>>
{
private readonly HttpClient _client;
private readonly WebApplicationFactory<GatewayProgram> _factory;
public GovernanceEndpointsTests(WebApplicationFactory<GatewayProgram> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.UseSetting("Environment", "Testing");
});
_client = _factory.CreateClient();
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
}
#region Sealed Mode Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSealedModeStatus_ReturnsOk()
{
// Act
var response = await _client.GetAsync("/api/v1/governance/sealed-mode/status", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(result.TryGetProperty("isSealed", out _));
Assert.True(result.TryGetProperty("tenantId", out _));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSealedModeStatus_ReturnsBadRequest_WhenTenantMissing()
{
// Arrange
var clientWithoutTenant = _factory.CreateClient();
// Act
var response = await clientWithoutTenant.GetAsync("/api/v1/governance/sealed-mode/status", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ToggleSealedMode_ReturnsOk()
{
// Arrange
var request = new { enable = true, reason = "Test seal" };
// Act
var response = await _client.PostAsJsonAsync("/api/v1/governance/sealed-mode/toggle", request, cancellationToken: CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(result.GetProperty("isSealed").GetBoolean());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSealedModeOverrides_ReturnsOk()
{
// Act
var response = await _client.GetAsync("/api/v1/governance/sealed-mode/overrides", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(result.TryGetProperty("overrides", out _));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateSealedModeOverride_ReturnsCreated()
{
// Arrange
var request = new
{
overrideType = "PolicyUpdate",
reason = "Emergency hotfix",
durationMinutes = 60,
actor = "admin@example.com"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/governance/sealed-mode/overrides", request, cancellationToken: CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(result.TryGetProperty("overrideId", out _));
Assert.Equal("PolicyUpdate", result.GetProperty("overrideType").GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RevokeSealedModeOverride_ReturnsNotFound_WhenOverrideNotExists()
{
// Act
var response = await _client.PostAsJsonAsync("/api/v1/governance/sealed-mode/overrides/nonexistent/revoke", new { }, cancellationToken: CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
#endregion
#region Risk Profile Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListRiskProfiles_ReturnsOk()
{
// Act
var response = await _client.GetAsync("/api/v1/governance/risk-profiles", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(result.TryGetProperty("profiles", out _));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateRiskProfile_ReturnsCreated()
{
// Arrange
var profileId = $"test-profile-{Guid.NewGuid():N}";
var request = new
{
profileId = profileId,
name = "Test Profile",
description = "Test profile for unit tests",
maxCriticalFindings = 0,
maxHighFindings = 5,
maxRiskScore = 7500
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/governance/risk-profiles", request, cancellationToken: CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal(profileId, result.GetProperty("profileId").GetString());
Assert.Equal("Test Profile", result.GetProperty("name").GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetRiskProfile_ReturnsNotFound_WhenProfileNotExists()
{
// Act
var response = await _client.GetAsync("/api/v1/governance/risk-profiles/nonexistent-profile", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateAndGetRiskProfile_RoundTrip()
{
// Arrange
var profileId = $"roundtrip-{Guid.NewGuid():N}";
var createRequest = new
{
profileId = profileId,
name = "Roundtrip Profile",
description = "Profile for roundtrip test",
maxCriticalFindings = 1,
maxHighFindings = 10,
maxRiskScore = 5000
};
// Act - Create
var createResponse = await _client.PostAsJsonAsync("/api/v1/governance/risk-profiles", createRequest, cancellationToken: CancellationToken.None);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
// Act - Get
var getResponse = await _client.GetAsync($"/api/v1/governance/risk-profiles/{profileId}", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var result = await getResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal(profileId, result.GetProperty("profileId").GetString());
Assert.Equal("Roundtrip Profile", result.GetProperty("name").GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpdateRiskProfile_ReturnsNotFound_WhenProfileNotExists()
{
// Arrange
var request = new
{
name = "Updated Profile",
description = "Updated description",
maxCriticalFindings = 2,
maxHighFindings = 20,
maxRiskScore = 10000
};
// Act
var response = await _client.PutAsJsonAsync("/api/v1/governance/risk-profiles/nonexistent", request, cancellationToken: CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeleteRiskProfile_ReturnsNotFound_WhenProfileNotExists()
{
// Act
var response = await _client.DeleteAsync("/api/v1/governance/risk-profiles/nonexistent", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateUpdateDeleteRiskProfile_FullLifecycle()
{
// Arrange
var profileId = $"lifecycle-{Guid.NewGuid():N}";
// Act - Create
var createRequest = new
{
profileId = profileId,
name = "Lifecycle Profile",
description = "Profile for lifecycle test",
maxCriticalFindings = 0,
maxHighFindings = 5,
maxRiskScore = 7500
};
var createResponse = await _client.PostAsJsonAsync("/api/v1/governance/risk-profiles", createRequest, cancellationToken: CancellationToken.None);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
// Act - Update
var updateRequest = new
{
name = "Updated Lifecycle Profile",
description = "Updated description",
maxCriticalFindings = 1,
maxHighFindings = 10,
maxRiskScore = 10000
};
var updateResponse = await _client.PutAsJsonAsync($"/api/v1/governance/risk-profiles/{profileId}", updateRequest, cancellationToken: CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
// Verify update
var getResponse = await _client.GetAsync($"/api/v1/governance/risk-profiles/{profileId}", CancellationToken.None);
var profile = await getResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal("Updated Lifecycle Profile", profile.GetProperty("name").GetString());
// Act - Delete
var deleteResponse = await _client.DeleteAsync($"/api/v1/governance/risk-profiles/{profileId}", CancellationToken.None);
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
// Verify deletion
var getAfterDeleteResponse = await _client.GetAsync($"/api/v1/governance/risk-profiles/{profileId}", CancellationToken.None);
Assert.Equal(HttpStatusCode.NotFound, getAfterDeleteResponse.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ActivateRiskProfile_ReturnsNotFound_WhenProfileNotExists()
{
// Act
var response = await _client.PostAsJsonAsync("/api/v1/governance/risk-profiles/nonexistent/activate", new { }, cancellationToken: CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeprecateRiskProfile_ReturnsNotFound_WhenProfileNotExists()
{
// Act
var response = await _client.PostAsJsonAsync("/api/v1/governance/risk-profiles/nonexistent/deprecate", new { }, cancellationToken: CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateRiskProfile_ReturnsValidationResult()
{
// Arrange
var request = new
{
name = "Valid Profile",
description = "Test description",
maxCriticalFindings = 0,
maxHighFindings = 5,
maxRiskScore = 7500
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/governance/risk-profiles/validate", request, cancellationToken: CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(result.TryGetProperty("isValid", out _));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateRiskProfile_ReturnsInvalid_WithNegativeValues()
{
// Arrange
var request = new
{
name = "",
maxCriticalFindings = -1,
maxHighFindings = -1,
maxRiskScore = -1
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/governance/risk-profiles/validate", request, cancellationToken: CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.False(result.GetProperty("isValid").GetBoolean());
Assert.True(result.TryGetProperty("errors", out var errors));
Assert.True(errors.GetArrayLength() > 0);
}
#endregion
#region Audit Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAuditEvents_ReturnsOk()
{
// Act
var response = await _client.GetAsync("/api/v1/governance/audit/events", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(result.TryGetProperty("events", out _));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAuditEvent_ReturnsNotFound_WhenEventNotExists()
{
// Act
var response = await _client.GetAsync("/api/v1/governance/audit/events/nonexistent-event", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAuditEvents_ReturnsBadRequest_WhenTenantMissing()
{
// Arrange
var clientWithoutTenant = _factory.CreateClient();
// Act
var response = await clientWithoutTenant.GetAsync("/api/v1/governance/audit/events", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
#endregion
}

View File

@@ -53,14 +53,14 @@ public sealed class PolicyGatewayIntegrationTests : IAsyncLifetime
ActivitySource.AddActivityListener(_activityListener);
}
public Task InitializeAsync()
public ValueTask InitializeAsync()
{
_factory = new PolicyGatewayTestFactory();
_client = _factory.CreateClient();
return Task.CompletedTask;
return ValueTask.CompletedTask;
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
_activityListener.Dispose();
_client.Dispose();
@@ -483,3 +483,6 @@ public sealed record CreateExceptionRequest
}
#endregion

View File

@@ -32,7 +32,7 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
_repository = new EvaluationRunRepository(dataSource, NullLogger<EvaluationRunRepository>.Instance);
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
@@ -62,7 +62,7 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
};
await _packVersionRepository.CreateAsync(packVersion);
}
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -300,3 +300,6 @@ public sealed class EvaluationRunRepositoryTests : IAsyncLifetime
Status = EvaluationStatus.Pending
};
}

View File

@@ -31,8 +31,8 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
_repository = new PostgresExceptionObjectRepository(dataSource, NullLogger<PostgresExceptionObjectRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -485,3 +485,6 @@ public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
#endregion
}

View File

@@ -26,8 +26,8 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
_repository = new ExceptionRepository(dataSource, NullLogger<ExceptionRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -289,3 +289,6 @@ public sealed class ExceptionRepositoryTests : IAsyncLifetime
Status = ExceptionStatus.Active
};
}

View File

@@ -28,8 +28,8 @@ public sealed class PackRepositoryTests : IAsyncLifetime
_packVersionRepository = new PackVersionRepository(dataSource, NullLogger<PackVersionRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -283,3 +283,6 @@ public sealed class PackRepositoryTests : IAsyncLifetime
return created;
}
}

View File

@@ -36,8 +36,8 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
_ruleRepository = new RuleRepository(dataSource, NullLogger<RuleRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -290,3 +290,6 @@ public sealed class PackVersioningWorkflowTests : IAsyncLifetime
updated.IsBuiltin.Should().BeTrue();
}
}

View File

@@ -26,8 +26,8 @@ public sealed class PolicyAuditRepositoryTests : IAsyncLifetime
_repository = new PolicyAuditRepository(dataSource, NullLogger<PolicyAuditRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -200,3 +200,6 @@ public sealed class PolicyAuditRepositoryTests : IAsyncLifetime
ResourceId = Guid.NewGuid().ToString()
};
}

View File

@@ -29,7 +29,7 @@ public sealed class PolicyMigrationTests : IAsyncLifetime
{
private PostgreSqlContainer _container = null!;
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
@@ -41,7 +41,7 @@ public sealed class PolicyMigrationTests : IAsyncLifetime
await _container.StartAsync();
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync();
}
@@ -320,3 +320,6 @@ public sealed class PolicyMigrationTests : IAsyncLifetime
return reader.ReadToEnd();
}
}

View File

@@ -51,7 +51,7 @@ public sealed class PolicyTestKitPostgresFixture : IAsyncLifetime
public TestKitPostgresFixture Fixture => _fixture;
public string ConnectionString => _fixture.ConnectionString;
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
_fixture = new TestKitPostgresFixture();
_fixture.IsolationMode = TestKitPostgresIsolationMode.Truncation;
@@ -59,7 +59,7 @@ public sealed class PolicyTestKitPostgresFixture : IAsyncLifetime
await _fixture.ApplyMigrationsFromAssemblyAsync(MigrationAssembly, "public");
}
public Task DisposeAsync() => _fixture.DisposeAsync();
public ValueTask DisposeAsync() => _fixture.DisposeAsync();
public Task TruncateAllTablesAsync() => _fixture.TruncateAllTablesAsync();
}
@@ -72,3 +72,6 @@ public sealed class PolicyTestKitPostgresCollection : ICollectionFixture<PolicyT
{
public const string Name = "PolicyTestKitPostgres";
}

View File

@@ -42,7 +42,7 @@ public sealed class PolicyQueryDeterminismTests : IAsyncLifetime
_fixture = fixture;
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
@@ -56,7 +56,7 @@ public sealed class PolicyQueryDeterminismTests : IAsyncLifetime
_auditRepository = new PolicyAuditRepository(_dataSource, NullLogger<PolicyAuditRepository>.Instance);
}
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task GetAllPacks_MultipleQueries_ReturnsDeterministicOrder()
@@ -410,3 +410,6 @@ public sealed class PolicyQueryDeterminismTests : IAsyncLifetime
return await _auditRepository.CreateAsync(audit);
}
}

View File

@@ -40,7 +40,7 @@ public sealed class PolicyVersioningImmutabilityTests : IAsyncLifetime
_fixture = fixture;
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
@@ -52,7 +52,7 @@ public sealed class PolicyVersioningImmutabilityTests : IAsyncLifetime
_ruleRepository = new RuleRepository(_dataSource, NullLogger<RuleRepository>.Instance);
}
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task PublishedVersion_CannotBeDeleted()
@@ -302,3 +302,6 @@ public sealed class PolicyVersioningImmutabilityTests : IAsyncLifetime
return created;
}
}

View File

@@ -36,7 +36,7 @@ public sealed class PostgresExceptionApplicationRepositoryTests : IAsyncLifetime
_repository = new PostgresExceptionApplicationRepository(_dataSource);
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
@@ -46,7 +46,7 @@ public sealed class PostgresExceptionApplicationRepositoryTests : IAsyncLifetime
await cmd.ExecuteNonQueryAsync();
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
await _dataSource.DisposeAsync();
}
@@ -175,3 +175,6 @@ public sealed class PostgresExceptionApplicationRepositoryTests : IAsyncLifetime
string eff = "suppress") =>
ExceptionApplication.Create(_tenantId, excId, findId, "affected", "not_affected", "test", eff, vulnId);
}

View File

@@ -32,8 +32,8 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
_repository = new PostgresExceptionObjectRepository(dataSource, NullLogger<PostgresExceptionObjectRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
#region Create Tests
@@ -569,3 +569,6 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
#endregion
}

View File

@@ -30,8 +30,8 @@ public sealed class PostgresReceiptRepositoryTests : IAsyncLifetime
_repository = new PostgresReceiptRepository(dataSource, NullLogger<PostgresReceiptRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -110,3 +110,6 @@ public sealed class PostgresReceiptRepositoryTests : IAsyncLifetime
};
}
}

View File

@@ -25,9 +25,9 @@ public sealed class RecheckEvidenceMigrationTests : IAsyncLifetime
_dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -48,3 +48,6 @@ public sealed class RecheckEvidenceMigrationTests : IAsyncLifetime
result.Should().NotBeNull($"{tableName} should exist after migrations");
}
}

View File

@@ -26,8 +26,8 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
_repository = new RiskProfileRepository(dataSource, NullLogger<RiskProfileRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -360,3 +360,6 @@ public sealed class RiskProfileRepositoryTests : IAsyncLifetime
ScoringWeights = scoringWeights ?? "{}"
};
}

View File

@@ -35,8 +35,8 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
_repository = new RiskProfileRepository(dataSource, NullLogger<RiskProfileRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -485,3 +485,6 @@ public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
afterUpdate.UpdatedAt.Should().BeOnOrAfter(createTime); // UpdatedAt should progress
}
}

View File

@@ -33,7 +33,7 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
_repository = new RuleRepository(dataSource, NullLogger<RuleRepository>.Instance);
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
@@ -67,7 +67,7 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
await _packVersionRepository.CreateAsync(packVersion);
}
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -279,3 +279,6 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
ContentHash = Guid.NewGuid().ToString()
};
}

View File

@@ -27,8 +27,8 @@ public sealed class UnknownsRepositoryTests : IAsyncLifetime
_dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public async Task DisposeAsync() => await _dataSource.DisposeAsync();
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
public async ValueTask DisposeAsync() => await _dataSource.DisposeAsync();
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -123,3 +123,6 @@ public sealed class UnknownsRepositoryTests : IAsyncLifetime
UpdatedAt = timestamp
};
}

View File

@@ -12,7 +12,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="FsCheck" />
<PackageReference Include="FsCheck.Xunit" />
<PackageReference Include="FsCheck.Xunit.v3" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
@@ -21,4 +21,6 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="FsCheck" />
<PackageReference Include="FsCheck.Xunit" />
<PackageReference Include="FsCheck.Xunit.v3" />
</ItemGroup>
<ItemGroup>
@@ -25,4 +25,6 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
</Project>