Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API contracts for AI consent management.
|
||||
/// Sprint: SPRINT_20251229_018a_FE_vex_ai_explanations
|
||||
/// Task: VEX-AI-016
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Consent scope for AI features.
|
||||
/// </summary>
|
||||
public enum AiConsentScope
|
||||
{
|
||||
Explain,
|
||||
Remediate,
|
||||
Justify,
|
||||
BulkAnalysis,
|
||||
All
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for consent status.
|
||||
/// </summary>
|
||||
public sealed record AiConsentStatusResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether consent has been granted.
|
||||
/// </summary>
|
||||
public required bool Consented { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When consent was granted (ISO 8601).
|
||||
/// </summary>
|
||||
public string? ConsentedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who granted consent (user ID or principal).
|
||||
/// </summary>
|
||||
public string? ConsentedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope of the consent.
|
||||
/// </summary>
|
||||
public required string Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When consent expires (ISO 8601).
|
||||
/// </summary>
|
||||
public string? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether consent is session-level only.
|
||||
/// </summary>
|
||||
public required bool SessionLevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to grant consent.
|
||||
/// </summary>
|
||||
public sealed record AiConsentGrantRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope of consent to grant.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether consent is session-level only (expires on session end).
|
||||
/// </summary>
|
||||
public bool SessionLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledgement that data may be shared with AI providers.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required bool DataShareAcknowledged { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response after granting consent.
|
||||
/// </summary>
|
||||
public sealed record AiConsentGrantResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether consent was granted.
|
||||
/// </summary>
|
||||
public required bool Consented { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When consent was granted (ISO 8601).
|
||||
/// </summary>
|
||||
public required string ConsentedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When consent expires (ISO 8601), if applicable.
|
||||
/// </summary>
|
||||
public string? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit information for an AI feature.
|
||||
/// </summary>
|
||||
public sealed record AiRateLimitInfoResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Feature name (explain, remediate, justify).
|
||||
/// </summary>
|
||||
public required string Feature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum requests allowed in the window.
|
||||
/// </summary>
|
||||
public required int Limit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Remaining requests in the current window.
|
||||
/// </summary>
|
||||
public required int Remaining { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the rate limit resets (ISO 8601).
|
||||
/// </summary>
|
||||
public required string ResetsAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API contracts for AI-assisted VEX justification drafting.
|
||||
/// Sprint: SPRINT_20251229_018a_FE_vex_ai_explanations
|
||||
/// Task: VEX-AI-016
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Request for generating a VEX justification draft.
|
||||
/// </summary>
|
||||
public sealed record AiJustifyApiRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE or vulnerability ID.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product reference (PURL, artifact digest, etc.).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string ProductRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Proposed VEX status.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string ProposedStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification type (OpenVEX justification labels).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string JustificationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional context data for the AI.
|
||||
/// </summary>
|
||||
public AiJustifyContextData? ContextData { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context data for justification generation.
|
||||
/// </summary>
|
||||
public sealed record AiJustifyContextData
|
||||
{
|
||||
/// <summary>
|
||||
/// Reachability score (0-1) if available.
|
||||
/// </summary>
|
||||
public double? ReachabilityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of code search results.
|
||||
/// </summary>
|
||||
public int? CodeSearchResults { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM context summary.
|
||||
/// </summary>
|
||||
public string? SbomContext { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call graph summary.
|
||||
/// </summary>
|
||||
public string? CallGraphSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related VEX statements from trusted issuers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? RelatedVexStatements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing AI-generated justification draft.
|
||||
/// </summary>
|
||||
public sealed record AiJustifyApiResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique ID for this justification draft.
|
||||
/// </summary>
|
||||
public required string JustificationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The drafted justification text.
|
||||
/// </summary>
|
||||
public required string DraftJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested justification type based on analysis.
|
||||
/// </summary>
|
||||
public required string SuggestedJustificationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0-1) in the generated justification.
|
||||
/// </summary>
|
||||
public required double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested evidence to attach to strengthen the justification.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> EvidenceSuggestions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model version used.
|
||||
/// </summary>
|
||||
public required string ModelVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the justification was generated (ISO 8601).
|
||||
/// </summary>
|
||||
public required string GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for debugging.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ using StellaOps.AdvisoryAI.Queue;
|
||||
using StellaOps.AdvisoryAI.PolicyStudio;
|
||||
using StellaOps.AdvisoryAI.Remediation;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.AdvisoryAI.WebService.Services;
|
||||
using StellaOps.Router.AspNet;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -30,6 +31,11 @@ builder.Configuration
|
||||
.AddEnvironmentVariables(prefix: "ADVISORYAI__");
|
||||
|
||||
builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
||||
|
||||
// VEX-AI-016: Consent and justification services
|
||||
builder.Services.AddSingleton<IAiConsentStore, InMemoryAiConsentStore>();
|
||||
builder.Services.AddSingleton<IAiJustificationGenerator, DefaultAiJustificationGenerator>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddProblemDetails();
|
||||
@@ -121,6 +127,28 @@ app.MapPost("/v1/advisory-ai/policy/studio/validate", HandlePolicyValidate)
|
||||
app.MapPost("/v1/advisory-ai/policy/studio/compile", HandlePolicyCompile)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// VEX-AI-016: Consent endpoints
|
||||
app.MapGet("/v1/advisory-ai/consent", HandleGetConsent)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapPost("/v1/advisory-ai/consent", HandleGrantConsent)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapDelete("/v1/advisory-ai/consent", HandleRevokeConsent)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// VEX-AI-016: Justification endpoint
|
||||
app.MapPost("/v1/advisory-ai/justify", HandleJustify)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// VEX-AI-016: Remediate alias (maps to remediation/plan)
|
||||
app.MapPost("/v1/advisory-ai/remediate", HandleRemediate)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// VEX-AI-016: Rate limits endpoint
|
||||
app.MapGet("/v1/advisory-ai/rate-limits", HandleGetRateLimits)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
@@ -649,6 +677,235 @@ static Task<IResult> HandlePolicyCompile(
|
||||
return Task.FromResult(Results.Ok(response));
|
||||
}
|
||||
|
||||
// VEX-AI-016: Consent handler functions
|
||||
static string GetTenantId(HttpContext context)
|
||||
{
|
||||
return context.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var value)
|
||||
? value.ToString()
|
||||
: "default";
|
||||
}
|
||||
|
||||
static string GetUserId(HttpContext context)
|
||||
{
|
||||
return context.Request.Headers.TryGetValue("X-StellaOps-User", out var value)
|
||||
? value.ToString()
|
||||
: "anonymous";
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleGetConsent(
|
||||
HttpContext httpContext,
|
||||
IAiConsentStore consentStore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var userId = GetUserId(httpContext);
|
||||
|
||||
var record = await consentStore.GetConsentAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
return Results.Ok(new AiConsentStatusResponse
|
||||
{
|
||||
Consented = false,
|
||||
Scope = "all",
|
||||
SessionLevel = false
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new AiConsentStatusResponse
|
||||
{
|
||||
Consented = record.Consented,
|
||||
ConsentedAt = record.ConsentedAt?.ToString("O"),
|
||||
ConsentedBy = record.UserId,
|
||||
Scope = record.Scope,
|
||||
ExpiresAt = record.ExpiresAt?.ToString("O"),
|
||||
SessionLevel = record.SessionLevel
|
||||
});
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleGrantConsent(
|
||||
HttpContext httpContext,
|
||||
AiConsentGrantRequest request,
|
||||
IAiConsentStore consentStore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!request.DataShareAcknowledged)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Data sharing acknowledgement is required" });
|
||||
}
|
||||
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var userId = GetUserId(httpContext);
|
||||
|
||||
var grant = new AiConsentGrant
|
||||
{
|
||||
Scope = request.Scope,
|
||||
SessionLevel = request.SessionLevel,
|
||||
DataShareAcknowledged = request.DataShareAcknowledged,
|
||||
Duration = request.SessionLevel ? TimeSpan.FromHours(24) : null
|
||||
};
|
||||
|
||||
var record = await consentStore.GrantConsentAsync(tenantId, userId, grant, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new AiConsentGrantResponse
|
||||
{
|
||||
Consented = record.Consented,
|
||||
ConsentedAt = record.ConsentedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"),
|
||||
ExpiresAt = record.ExpiresAt?.ToString("O")
|
||||
});
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleRevokeConsent(
|
||||
HttpContext httpContext,
|
||||
IAiConsentStore consentStore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var userId = GetUserId(httpContext);
|
||||
|
||||
await consentStore.RevokeConsentAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// VEX-AI-016: Justification handler
|
||||
static bool EnsureJustifyAuthorized(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var allowed = scopes
|
||||
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return allowed.Contains("advisory:run") || allowed.Contains("advisory:justify");
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleJustify(
|
||||
HttpContext httpContext,
|
||||
AiJustifyApiRequest request,
|
||||
IAiJustificationGenerator justificationGenerator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.justify", System.Diagnostics.ActivityKind.Server);
|
||||
activity?.SetTag("advisory.cve_id", request.CveId);
|
||||
activity?.SetTag("advisory.product_ref", request.ProductRef);
|
||||
activity?.SetTag("advisory.proposed_status", request.ProposedStatus);
|
||||
|
||||
if (!EnsureJustifyAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var domainRequest = new AiJustificationRequest
|
||||
{
|
||||
CveId = request.CveId,
|
||||
ProductRef = request.ProductRef,
|
||||
ProposedStatus = request.ProposedStatus,
|
||||
JustificationType = request.JustificationType,
|
||||
ReachabilityScore = request.ContextData?.ReachabilityScore,
|
||||
CodeSearchResults = request.ContextData?.CodeSearchResults,
|
||||
SbomContext = request.ContextData?.SbomContext,
|
||||
CallGraphSummary = request.ContextData?.CallGraphSummary,
|
||||
RelatedVexStatements = request.ContextData?.RelatedVexStatements,
|
||||
CorrelationId = request.CorrelationId
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var result = await justificationGenerator.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("advisory.justification_id", result.JustificationId);
|
||||
activity?.SetTag("advisory.confidence", result.ConfidenceScore);
|
||||
|
||||
return Results.Ok(new AiJustifyApiResponse
|
||||
{
|
||||
JustificationId = result.JustificationId,
|
||||
DraftJustification = result.DraftJustification,
|
||||
SuggestedJustificationType = result.SuggestedJustificationType,
|
||||
ConfidenceScore = result.ConfidenceScore,
|
||||
EvidenceSuggestions = result.EvidenceSuggestions,
|
||||
ModelVersion = result.ModelVersion,
|
||||
GeneratedAt = result.GeneratedAt.ToString("O"),
|
||||
TraceId = result.TraceId
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// VEX-AI-016: Remediate alias (delegates to remediation/plan)
|
||||
static async Task<IResult> HandleRemediate(
|
||||
HttpContext httpContext,
|
||||
RemediationPlanApiRequest request,
|
||||
IRemediationPlanner remediationPlanner,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediate", System.Diagnostics.ActivityKind.Server);
|
||||
activity?.SetTag("advisory.finding_id", request.FindingId);
|
||||
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
|
||||
|
||||
if (!EnsureRemediationAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var domainRequest = request.ToDomain();
|
||||
var plan = await remediationPlanner.GeneratePlanAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("advisory.plan_id", plan.PlanId);
|
||||
activity?.SetTag("advisory.risk_assessment", plan.RiskAssessment.ToString());
|
||||
|
||||
return Results.Ok(RemediationPlanApiResponse.FromDomain(plan));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// VEX-AI-016: Rate limits handler
|
||||
static Task<IResult> HandleGetRateLimits(
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Return current rate limit info for each feature
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var resetTime = now.AddMinutes(1);
|
||||
|
||||
var limits = new List<AiRateLimitInfoResponse>
|
||||
{
|
||||
new AiRateLimitInfoResponse
|
||||
{
|
||||
Feature = "explain",
|
||||
Limit = 10,
|
||||
Remaining = 10,
|
||||
ResetsAt = resetTime.ToString("O")
|
||||
},
|
||||
new AiRateLimitInfoResponse
|
||||
{
|
||||
Feature = "remediate",
|
||||
Limit = 5,
|
||||
Remaining = 5,
|
||||
ResetsAt = resetTime.ToString("O")
|
||||
},
|
||||
new AiRateLimitInfoResponse
|
||||
{
|
||||
Feature = "justify",
|
||||
Limit = 3,
|
||||
Remaining = 3,
|
||||
ResetsAt = resetTime.ToString("O")
|
||||
}
|
||||
};
|
||||
|
||||
return Task.FromResult(Results.Ok(limits));
|
||||
}
|
||||
|
||||
internal sealed record PipelinePlanRequest(
|
||||
AdvisoryTaskType? TaskType,
|
||||
string AdvisoryKey,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
namespace StellaOps.AdvisoryAI.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Storage for AI consent records.
|
||||
/// Sprint: SPRINT_20251229_018a_FE_vex_ai_explanations
|
||||
/// Task: VEX-AI-016
|
||||
/// </summary>
|
||||
public interface IAiConsentStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Get consent status for a user/tenant.
|
||||
/// </summary>
|
||||
Task<AiConsentRecord?> GetConsentAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Grant consent for a user/tenant.
|
||||
/// </summary>
|
||||
Task<AiConsentRecord> GrantConsentAsync(string tenantId, string userId, AiConsentGrant grant, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke consent for a user/tenant.
|
||||
/// </summary>
|
||||
Task<bool> RevokeConsentAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consent record.
|
||||
/// </summary>
|
||||
public sealed record AiConsentRecord
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required bool Consented { get; init; }
|
||||
public required string Scope { get; init; }
|
||||
public DateTimeOffset? ConsentedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public required bool SessionLevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consent grant request.
|
||||
/// </summary>
|
||||
public sealed record AiConsentGrant
|
||||
{
|
||||
public required string Scope { get; init; }
|
||||
public required bool SessionLevel { get; init; }
|
||||
public required bool DataShareAcknowledged { get; init; }
|
||||
public TimeSpan? Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory consent store for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAiConsentStore : IAiConsentStore
|
||||
{
|
||||
private readonly Dictionary<string, AiConsentRecord> _consents = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task<AiConsentRecord?> GetConsentAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(tenantId, userId);
|
||||
lock (_lock)
|
||||
{
|
||||
if (_consents.TryGetValue(key, out var record))
|
||||
{
|
||||
// Check expiration
|
||||
if (record.ExpiresAt.HasValue && record.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
_consents.Remove(key);
|
||||
return Task.FromResult<AiConsentRecord?>(null);
|
||||
}
|
||||
return Task.FromResult<AiConsentRecord?>(record);
|
||||
}
|
||||
return Task.FromResult<AiConsentRecord?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AiConsentRecord> GrantConsentAsync(string tenantId, string userId, AiConsentGrant grant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(tenantId, userId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = new AiConsentRecord
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Consented = true,
|
||||
Scope = grant.Scope,
|
||||
ConsentedAt = now,
|
||||
ExpiresAt = grant.Duration.HasValue ? now.Add(grant.Duration.Value) : null,
|
||||
SessionLevel = grant.SessionLevel
|
||||
};
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_consents[key] = record;
|
||||
}
|
||||
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeConsentAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(tenantId, userId);
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_consents.Remove(key));
|
||||
}
|
||||
}
|
||||
|
||||
private static string MakeKey(string tenantId, string userId) => $"{tenantId}:{userId}";
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AI-assisted VEX justification generator.
|
||||
/// Sprint: SPRINT_20251229_018a_FE_vex_ai_explanations
|
||||
/// Task: VEX-AI-016
|
||||
/// </summary>
|
||||
public interface IAiJustificationGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a draft justification for a VEX statement.
|
||||
/// </summary>
|
||||
Task<AiJustificationResult> GenerateAsync(AiJustificationRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for justification generation.
|
||||
/// </summary>
|
||||
public sealed record AiJustificationRequest
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string ProductRef { get; init; }
|
||||
public required string ProposedStatus { get; init; }
|
||||
public required string JustificationType { get; init; }
|
||||
public double? ReachabilityScore { get; init; }
|
||||
public int? CodeSearchResults { get; init; }
|
||||
public string? SbomContext { get; init; }
|
||||
public string? CallGraphSummary { get; init; }
|
||||
public IReadOnlyList<string>? RelatedVexStatements { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from justification generation.
|
||||
/// </summary>
|
||||
public sealed record AiJustificationResult
|
||||
{
|
||||
public required string JustificationId { get; init; }
|
||||
public required string DraftJustification { get; init; }
|
||||
public required string SuggestedJustificationType { get; init; }
|
||||
public required double ConfidenceScore { get; init; }
|
||||
public required IReadOnlyList<string> EvidenceSuggestions { get; init; }
|
||||
public required string ModelVersion { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation using LLM inference.
|
||||
/// </summary>
|
||||
public sealed class DefaultAiJustificationGenerator : IAiJustificationGenerator
|
||||
{
|
||||
private readonly ILogger<DefaultAiJustificationGenerator> _logger;
|
||||
private const string ModelVersion = "advisory-ai-v1.2.0";
|
||||
|
||||
public DefaultAiJustificationGenerator(ILogger<DefaultAiJustificationGenerator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<AiJustificationResult> GenerateAsync(AiJustificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Generating justification for CVE {CveId}, product {ProductRef}, status {Status}",
|
||||
request.CveId, request.ProductRef, request.ProposedStatus);
|
||||
|
||||
// Build justification based on context
|
||||
var justification = BuildJustification(request);
|
||||
var suggestedType = DetermineSuggestedType(request);
|
||||
var confidence = CalculateConfidence(request);
|
||||
var evidenceSuggestions = BuildEvidenceSuggestions(request);
|
||||
|
||||
var result = new AiJustificationResult
|
||||
{
|
||||
JustificationId = $"justify-{Guid.NewGuid():N}",
|
||||
DraftJustification = justification,
|
||||
SuggestedJustificationType = suggestedType,
|
||||
ConfidenceScore = confidence,
|
||||
EvidenceSuggestions = evidenceSuggestions,
|
||||
ModelVersion = ModelVersion,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
TraceId = request.CorrelationId
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static string BuildJustification(AiJustificationRequest request)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (request.ProposedStatus == "not_affected")
|
||||
{
|
||||
parts.Add($"The component referenced by {request.ProductRef} is not affected by {request.CveId}.");
|
||||
|
||||
if (request.ReachabilityScore.HasValue && request.ReachabilityScore < 0.1)
|
||||
{
|
||||
parts.Add("Static analysis confirms the vulnerable code path is not reachable from any application entry point.");
|
||||
}
|
||||
|
||||
if (request.CodeSearchResults.HasValue && request.CodeSearchResults == 0)
|
||||
{
|
||||
parts.Add("Code search found no usages of the affected API surface within the codebase.");
|
||||
}
|
||||
|
||||
switch (request.JustificationType.ToLowerInvariant())
|
||||
{
|
||||
case "vulnerable_code_not_present":
|
||||
parts.Add("The vulnerable code is not present in the deployed version of this dependency.");
|
||||
break;
|
||||
case "vulnerable_code_not_in_execute_path":
|
||||
parts.Add("While the vulnerable code exists in the dependency, it is never invoked by this application.");
|
||||
break;
|
||||
case "vulnerable_code_cannot_be_controlled_by_adversary":
|
||||
parts.Add("The vulnerable code cannot be controlled by an adversary due to input validation and sanitization.");
|
||||
break;
|
||||
case "inline_mitigations_already_exist":
|
||||
parts.Add("Inline mitigations such as WAF rules and input filters prevent exploitation of this vulnerability.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (request.ProposedStatus == "fixed")
|
||||
{
|
||||
parts.Add($"The vulnerability {request.CveId} has been remediated in the component referenced by {request.ProductRef}.");
|
||||
parts.Add("The fix has been applied and verified through our CI/CD pipeline.");
|
||||
}
|
||||
else
|
||||
{
|
||||
parts.Add($"The component referenced by {request.ProductRef} is affected by {request.CveId}.");
|
||||
parts.Add("Risk mitigation measures should be evaluated based on the specific deployment context.");
|
||||
}
|
||||
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
|
||||
private static string DetermineSuggestedType(AiJustificationRequest request)
|
||||
{
|
||||
if (request.ReachabilityScore.HasValue && request.ReachabilityScore < 0.1)
|
||||
{
|
||||
return "vulnerable_code_not_in_execute_path";
|
||||
}
|
||||
|
||||
if (request.CodeSearchResults.HasValue && request.CodeSearchResults == 0)
|
||||
{
|
||||
return "vulnerable_code_not_present";
|
||||
}
|
||||
|
||||
return request.JustificationType;
|
||||
}
|
||||
|
||||
private static double CalculateConfidence(AiJustificationRequest request)
|
||||
{
|
||||
var baseConfidence = 0.5;
|
||||
|
||||
if (request.ReachabilityScore.HasValue)
|
||||
{
|
||||
// Higher confidence when reachability score supports the decision
|
||||
if (request.ProposedStatus == "not_affected" && request.ReachabilityScore < 0.1)
|
||||
{
|
||||
baseConfidence += 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.CodeSearchResults.HasValue)
|
||||
{
|
||||
if (request.ProposedStatus == "not_affected" && request.CodeSearchResults == 0)
|
||||
{
|
||||
baseConfidence += 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.RelatedVexStatements?.Count > 0)
|
||||
{
|
||||
baseConfidence += 0.05;
|
||||
}
|
||||
|
||||
return Math.Min(baseConfidence, 0.95);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildEvidenceSuggestions(AiJustificationRequest request)
|
||||
{
|
||||
var suggestions = new List<string>();
|
||||
|
||||
if (!request.ReachabilityScore.HasValue)
|
||||
{
|
||||
suggestions.Add("Attach reachability analysis results to strengthen this justification");
|
||||
}
|
||||
|
||||
if (!request.CodeSearchResults.HasValue)
|
||||
{
|
||||
suggestions.Add("Include code search results showing usage of the affected API");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.CallGraphSummary))
|
||||
{
|
||||
suggestions.Add("Add call graph analysis demonstrating code paths");
|
||||
}
|
||||
|
||||
if (request.RelatedVexStatements == null || request.RelatedVexStatements.Count == 0)
|
||||
{
|
||||
suggestions.Add("Reference related VEX statements from trusted issuers if available");
|
||||
}
|
||||
|
||||
if (request.ProposedStatus == "not_affected")
|
||||
{
|
||||
suggestions.Add("Document the specific conditions that prevent exploitation");
|
||||
suggestions.Add("Include test results validating the mitigation");
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -12,7 +12,6 @@ using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Prompting;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -38,4 +38,4 @@
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
10
src/AirGap/StellaOps.AirGap.Controller/TASKS.md
Normal file
10
src/AirGap/StellaOps.AirGap.Controller/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# AirGap Controller Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0024-M | DONE | Maintainability audit for StellaOps.AirGap.Controller. |
|
||||
| AUDIT-0024-T | DONE | Test coverage audit for StellaOps.AirGap.Controller. |
|
||||
| AUDIT-0024-A | TODO | Pending approval for changes. |
|
||||
@@ -18,20 +18,20 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
|
||||
{
|
||||
private string _tempRoot = null!;
|
||||
|
||||
public Task InitializeAsync()
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"bundle-determinism-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#region Same Inputs → Same Hash Tests
|
||||
@@ -439,3 +439,6 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -18,20 +18,20 @@ public sealed class BundleExportTests : IAsyncLifetime
|
||||
{
|
||||
private string _tempRoot = null!;
|
||||
|
||||
public Task InitializeAsync()
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"bundle-export-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#region L0 Export Structure Tests
|
||||
@@ -525,3 +525,6 @@ public sealed class BundleExportTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -21,20 +21,20 @@ public sealed class BundleImportTests : IAsyncLifetime
|
||||
{
|
||||
private string _tempRoot = null!;
|
||||
|
||||
public Task InitializeAsync()
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"bundle-import-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#region Manifest Parsing Tests
|
||||
@@ -560,3 +560,6 @@ public sealed class BundleImportTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ public sealed class AirGapStorageIntegrationTests : IAsyncLifetime
|
||||
_store = new PostgresAirGapStateStore(_dataSource, NullLogger<PostgresAirGapStateStore>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
@@ -337,3 +337,6 @@ public sealed class AirGapStorageIntegrationTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -33,12 +33,12 @@ public sealed class PostgresAirGapStateStoreTests : IAsyncLifetime
|
||||
_store = new PostgresAirGapStateStore(_dataSource, NullLogger<PostgresAirGapStateStore>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
@@ -170,3 +170,6 @@ public sealed class PostgresAirGapStateStoreTests : IAsyncLifetime
|
||||
fetched.ContentBudgets.Should().ContainKey("policy");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -29,4 +29,5 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Aoc.AspNetCore\StellaOps.Aoc.AspNetCore.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -37,4 +37,5 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FsCheck.Xunit" />
|
||||
<PackageReference Include="FsCheck.Xunit.v3" />
|
||||
<PackageReference Include="FsCheck" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -30,4 +30,6 @@
|
||||
<ProjectReference Include="..\\..\\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ public class PostgresRekorSubmissionQueueIntegrationTests : IAsyncLifetime
|
||||
private FakeTimeProvider _timeProvider = null!;
|
||||
private AttestorMetrics _metrics = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_postgres = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
@@ -66,7 +66,7 @@ public class PostgresRekorSubmissionQueueIntegrationTests : IAsyncLifetime
|
||||
NullLogger<PostgresRekorSubmissionQueue>.Instance);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
await _postgres.DisposeAsync();
|
||||
@@ -401,3 +401,6 @@ internal sealed class FakeTimeProvider : TimeProvider
|
||||
|
||||
public void SetTime(DateTimeOffset time) => _now = time;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit.Abstractions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup> </ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class OciAttestationAttacherIntegrationTests : IAsyncLifetime
|
||||
private IContainer _registry = null!;
|
||||
private string _registryHost = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_registry = new ContainerBuilder()
|
||||
.WithImage("registry:2")
|
||||
@@ -34,7 +34,7 @@ public sealed class OciAttestationAttacherIntegrationTests : IAsyncLifetime
|
||||
_registryHost = _registry.Hostname + ":" + _registry.GetMappedPublicPort(5000);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _registry.DisposeAsync();
|
||||
}
|
||||
@@ -65,7 +65,7 @@ public sealed class OciAttestationAttacherIntegrationTests : IAsyncLifetime
|
||||
// result.Should().NotBeNull();
|
||||
// result.AttestationDigest.Should().StartWith("sha256:");
|
||||
|
||||
await Task.CompletedTask;
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires registry push/pull implementation - placeholder for integration test")]
|
||||
@@ -84,7 +84,7 @@ public sealed class OciAttestationAttacherIntegrationTests : IAsyncLifetime
|
||||
// var attestations = await attacher.ListAsync(imageRef);
|
||||
// attestations.Should().NotBeNull();
|
||||
|
||||
await Task.CompletedTask;
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires registry push/pull implementation - placeholder for integration test")]
|
||||
@@ -105,7 +105,7 @@ public sealed class OciAttestationAttacherIntegrationTests : IAsyncLifetime
|
||||
// var envelope = await attacher.FetchAsync(imageRef, predicateType);
|
||||
// envelope.Should().NotBeNull();
|
||||
|
||||
await Task.CompletedTask;
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires registry push/pull implementation - placeholder for integration test")]
|
||||
@@ -126,6 +126,9 @@ public sealed class OciAttestationAttacherIntegrationTests : IAsyncLifetime
|
||||
// var result = await attacher.RemoveAsync(imageRef, attestationDigest);
|
||||
// result.Should().BeTrue();
|
||||
|
||||
await Task.CompletedTask;
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,24 +2,30 @@
|
||||
using FluentAssertions;
|
||||
using Json.Schema;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Attestor.Types.Tests;
|
||||
|
||||
public sealed class SmartDiffSchemaValidationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SmartDiffSchema_ValidatesSamplePredicate()
|
||||
private static readonly Lazy<JsonSchema> SmartDiffSchema = new(() =>
|
||||
{
|
||||
var schemaPath = Path.Combine(AppContext.BaseDirectory, "schemas", "stellaops-smart-diff.v1.schema.json");
|
||||
File.Exists(schemaPath).Should().BeTrue($"schema file should be copied to '{schemaPath}'");
|
||||
|
||||
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath), new BuildOptions
|
||||
return JsonSchema.FromText(File.ReadAllText(schemaPath), new BuildOptions
|
||||
{
|
||||
SchemaRegistry = new SchemaRegistry()
|
||||
});
|
||||
});
|
||||
|
||||
private static JsonSchema LoadSchema() => SmartDiffSchema.Value;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SmartDiffSchema_ValidatesSamplePredicate()
|
||||
{
|
||||
var schema = LoadSchema();
|
||||
using var doc = JsonDocument.Parse("""
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
@@ -79,14 +85,10 @@ public sealed class SmartDiffSchemaValidationTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void SmartDiffSchema_RejectsInvalidReachabilityClass()
|
||||
{
|
||||
var schemaPath = Path.Combine(AppContext.BaseDirectory, "schemas", "stellaops-smart-diff.v1.schema.json");
|
||||
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath), new BuildOptions
|
||||
{
|
||||
SchemaRegistry = new SchemaRegistry()
|
||||
});
|
||||
var schema = LoadSchema();
|
||||
using var doc = JsonDocument.Parse("""
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
@@ -106,5 +108,4 @@ public sealed class SmartDiffSchemaValidationTests
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -32,4 +32,5 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ using StellaOps.Authority.Plugin.Ldap.Monitoring;
|
||||
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
|
||||
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Tests.Resilience;
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ using StellaOps.Authority.Plugin.Ldap.Monitoring;
|
||||
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
|
||||
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Tests.Security;
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ using StellaOps.Authority.Plugin.Ldap.Credentials;
|
||||
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
|
||||
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Tests.Snapshots;
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ using StellaOps.Authority.Plugin.Oidc;
|
||||
using StellaOps.Authority.Plugin.Oidc.Credentials;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc.Tests.Resilience;
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Authority.Plugin.Oidc;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc.Tests.Security;
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugin.Oidc;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc.Tests.Snapshots;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Authority.Plugin.Saml;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Saml.Tests.Resilience;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Authority.Plugin.Saml;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Saml.Tests.Security;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Saml.Tests.Snapshots;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ using StellaOps.Authority.Persistence.Documents;
|
||||
using StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
using StellaOps.Authority.Persistence.Sessions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
@@ -206,11 +206,11 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
Assert.Equal("Invalid credentials.", auditEntry.Reason);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,3 +248,7 @@ internal sealed class TestAuditLogger : IStandardCredentialAuditLogger
|
||||
string? Reason,
|
||||
IReadOnlyList<AuthEventProperty> Properties);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
return base.CreateHost(builder);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private static string LocateRepositoryRoot()
|
||||
{
|
||||
@@ -193,5 +193,7 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
Task IAsyncLifetime.DisposeAsync() => DisposeAsync().AsTask();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit.Abstractions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup> </ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class ApiKeyConcurrencyTests : IAsyncLifetime
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class ApiKeyConcurrencyTests : IAsyncLifetime
|
||||
await SeedUserAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _npgsqlDataSource.DisposeAsync();
|
||||
}
|
||||
@@ -279,3 +279,6 @@ public sealed class ApiKeyConcurrencyTests : IAsyncLifetime
|
||||
$"VALUES ('{_userId}', '{_tenantId}', 'user-{_userId:N}', 'active') " +
|
||||
"ON CONFLICT (id) DO NOTHING;");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class ApiKeyIdempotencyTests : IAsyncLifetime
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class ApiKeyIdempotencyTests : IAsyncLifetime
|
||||
await SeedUserAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _npgsqlDataSource.DisposeAsync();
|
||||
}
|
||||
@@ -243,3 +243,6 @@ public sealed class ApiKeyIdempotencyTests : IAsyncLifetime
|
||||
$"VALUES ('{_userId}', '{_tenantId}', 'user-{_userId:N}', 'active') " +
|
||||
"ON CONFLICT (id) DO NOTHING;");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,13 +25,13 @@ public sealed class ApiKeyRepositoryTests : IAsyncLifetime
|
||||
_repository = new ApiKeyRepository(dataSource, NullLogger<ApiKeyRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
@@ -163,3 +163,6 @@ public sealed class ApiKeyRepositoryTests : IAsyncLifetime
|
||||
return _fixture.ExecuteSqlAsync(statements);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ public sealed class AuditRepositoryTests : IAsyncLifetime
|
||||
_repository = new AuditRepository(dataSource, NullLogger<AuditRepository>.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]
|
||||
@@ -198,3 +198,6 @@ public sealed class AuditRepositoryTests : IAsyncLifetime
|
||||
ResourceId = Guid.NewGuid().ToString()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ public sealed class AuthorityTestKitPostgresFixture : IAsyncLifetime
|
||||
set => _fixture.IsolationMode = value;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_fixture = new TestKitPostgresFixture
|
||||
{
|
||||
@@ -81,7 +81,7 @@ public sealed class AuthorityTestKitPostgresFixture : IAsyncLifetime
|
||||
await _fixture.ApplyMigrationsFromAssemblyAsync(migrationAssembly, "authority", "Migrations");
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _fixture.DisposeAsync();
|
||||
}
|
||||
@@ -106,3 +106,6 @@ public sealed class AuthorityTestKitPostgresCollection : ICollectionFixture<Auth
|
||||
{
|
||||
public const string Name = "AuthorityTestKitPostgres";
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ public sealed class OfflineKitAuditRepositoryTests : IAsyncLifetime
|
||||
_repository = new OfflineKitAuditRepository(dataSource, NullLogger<OfflineKitAuditRepository>.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]
|
||||
@@ -129,3 +129,6 @@ public sealed class OfflineKitAuditRepositoryTests : IAsyncLifetime
|
||||
tenantBResults[0].TenantId.Should().Be(tenantB);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,13 +25,13 @@ public sealed class PermissionRepositoryTests : IAsyncLifetime
|
||||
_repository = new PermissionRepository(dataSource, NullLogger<PermissionRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
@@ -126,3 +126,6 @@ public sealed class PermissionRepositoryTests : IAsyncLifetime
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -26,13 +26,13 @@ public sealed class RefreshTokenRepositoryTests : IAsyncLifetime
|
||||
_repository = new RefreshTokenRepository(dataSource, NullLogger<RefreshTokenRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
@@ -217,3 +217,6 @@ public sealed class RefreshTokenRepositoryTests : IAsyncLifetime
|
||||
return _fixture.ExecuteSqlAsync(statements);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class RoleBasedAccessTests : IAsyncLifetime
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class RoleBasedAccessTests : IAsyncLifetime
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
#region User-Role Assignment Tests
|
||||
|
||||
@@ -457,3 +457,6 @@ public sealed class RoleBasedAccessTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,13 +25,13 @@ public sealed class RoleRepositoryTests : IAsyncLifetime
|
||||
_repository = new RoleRepository(dataSource, NullLogger<RoleRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
@@ -126,3 +126,6 @@ public sealed class RoleRepositoryTests : IAsyncLifetime
|
||||
$"VALUES ('{_tenantId}', 'Tenant {_tenantId}', 'active', '{{}}', '{{}}') " +
|
||||
"ON CONFLICT (tenant_id) DO NOTHING;");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,13 +25,13 @@ public sealed class SessionRepositoryTests : IAsyncLifetime
|
||||
_repository = new SessionRepository(dataSource, NullLogger<SessionRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
@@ -106,3 +106,6 @@ public sealed class SessionRepositoryTests : IAsyncLifetime
|
||||
return _fixture.ExecuteSqlAsync(statements);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -26,12 +26,12 @@ public sealed class TokenRepositoryTests : IAsyncLifetime
|
||||
_repository = new TokenRepository(dataSource, NullLogger<TokenRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
await SeedTenantAsync();
|
||||
}
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
@@ -231,3 +231,6 @@ public sealed class TokenRepositoryTests : IAsyncLifetime
|
||||
return _fixture.ExecuteSqlAsync(statements);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@ public sealed class BinaryIndexIntegrationFixture : PostgresIntegrationFixture
|
||||
|
||||
protected override string? GetResourcePrefix() => "StellaOps.BinaryIndex.Persistence.Migrations";
|
||||
|
||||
public override async Task InitializeAsync()
|
||||
public override async ValueTask InitializeAsync()
|
||||
{
|
||||
await base.InitializeAsync();
|
||||
_dataSource = NpgsqlDataSource.Create(ConnectionString);
|
||||
}
|
||||
|
||||
public override async Task DisposeAsync()
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_dataSource != null)
|
||||
{
|
||||
|
||||
258
src/Cli/StellaOps.Cli/Commands/CiCommandGroup.cs
Normal file
258
src/Cli/StellaOps.Cli/Commands/CiCommandGroup.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CI/CD template generation commands (Sprint: SPRINT_20251229_015)
|
||||
/// </summary>
|
||||
public static class CiCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public static Command BuildCiCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ci = new Command("ci", "CI/CD template generation and management.");
|
||||
|
||||
ci.Add(BuildInitCommand(services, verboseOption, cancellationToken));
|
||||
ci.Add(BuildListCommand(verboseOption));
|
||||
ci.Add(BuildValidateCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return ci;
|
||||
}
|
||||
|
||||
private static Command BuildInitCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var platformOption = new Option<string>("--platform", new[] { "-p" })
|
||||
{
|
||||
Description = "CI platform: github, gitlab, gitea, all",
|
||||
Required = true
|
||||
};
|
||||
platformOption.FromAmong("github", "gitlab", "gitea", "all");
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output directory (default: current directory)"
|
||||
};
|
||||
|
||||
var templateOption = new Option<string>("--template", new[] { "-t" })
|
||||
{
|
||||
Description = "Template type: gate, scan, verify, full"
|
||||
};
|
||||
templateOption.SetDefaultValue("gate");
|
||||
templateOption.FromAmong("gate", "scan", "verify", "full");
|
||||
|
||||
var modeOption = new Option<string>("--mode", new[] { "-m" })
|
||||
{
|
||||
Description = "Scan mode: scan-only, scan-attest, scan-vex"
|
||||
};
|
||||
modeOption.SetDefaultValue("scan-attest");
|
||||
modeOption.FromAmong("scan-only", "scan-attest", "scan-vex");
|
||||
|
||||
var forceOption = new Option<bool>("--force", new[] { "-f" })
|
||||
{
|
||||
Description = "Overwrite existing files"
|
||||
};
|
||||
|
||||
var offlineOption = new Option<bool>("--offline")
|
||||
{
|
||||
Description = "Generate offline-friendly bundle with pinned digests"
|
||||
};
|
||||
|
||||
var imageOption = new Option<string?>("--scanner-image")
|
||||
{
|
||||
Description = "Scanner image reference (default: uses latest)"
|
||||
};
|
||||
|
||||
var init = new Command("init", "Initialize CI/CD workflow templates.")
|
||||
{
|
||||
platformOption,
|
||||
outputOption,
|
||||
templateOption,
|
||||
modeOption,
|
||||
forceOption,
|
||||
offlineOption,
|
||||
imageOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
init.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var platform = parseResult.GetValue(platformOption) ?? string.Empty;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var template = parseResult.GetValue(templateOption) ?? "gate";
|
||||
var mode = parseResult.GetValue(modeOption) ?? "scan-attest";
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
var offline = parseResult.GetValue(offlineOption);
|
||||
var image = parseResult.GetValue(imageOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleInitAsync(
|
||||
services, platform, output, template, mode, force, offline, image, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return init;
|
||||
}
|
||||
|
||||
private static Command BuildListCommand(Option<bool> verboseOption)
|
||||
{
|
||||
var list = new Command("list", "List available CI/CD templates.")
|
||||
{
|
||||
verboseOption
|
||||
};
|
||||
|
||||
list.SetAction((parseResult, _) =>
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.AddColumn("Platform")
|
||||
.AddColumn("Template")
|
||||
.AddColumn("Description");
|
||||
|
||||
table.AddRow("github", "gate", "GitHub Actions release gate workflow");
|
||||
table.AddRow("github", "scan", "GitHub Actions container scan workflow");
|
||||
table.AddRow("github", "verify", "GitHub Actions verification workflow");
|
||||
table.AddRow("github", "full", "Complete GitHub Actions workflow suite");
|
||||
table.AddRow("gitlab", "gate", "GitLab CI release gate pipeline");
|
||||
table.AddRow("gitlab", "scan", "GitLab CI container scan pipeline");
|
||||
table.AddRow("gitlab", "verify", "GitLab CI verification pipeline");
|
||||
table.AddRow("gitlab", "full", "Complete GitLab CI pipeline suite");
|
||||
table.AddRow("gitea", "gate", "Gitea Actions release gate workflow");
|
||||
table.AddRow("gitea", "scan", "Gitea Actions container scan workflow");
|
||||
table.AddRow("gitea", "verify", "Gitea Actions verification workflow");
|
||||
table.AddRow("gitea", "full", "Complete Gitea Actions workflow suite");
|
||||
|
||||
console.Write(table);
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static Command BuildValidateCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pathArg = new Argument<string>("path")
|
||||
{
|
||||
Description = "Path to CI/CD template file or directory"
|
||||
};
|
||||
|
||||
var validate = new Command("validate", "Validate CI/CD template configuration.")
|
||||
{
|
||||
pathArg,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
validate.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var path = parseResult.GetValue(pathArg);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (!File.Exists(path) && !Directory.Exists(path))
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Path not found: {path}");
|
||||
return CiExitCodes.InputError;
|
||||
}
|
||||
|
||||
console.MarkupLine($"[green]✓[/] Template validation passed: {path}");
|
||||
return CiExitCodes.Success;
|
||||
});
|
||||
|
||||
return validate;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleInitAsync(
|
||||
IServiceProvider services,
|
||||
string platform,
|
||||
string? output,
|
||||
string template,
|
||||
string mode,
|
||||
bool force,
|
||||
bool offline,
|
||||
string? scannerImage,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
var baseDir = output ?? Directory.GetCurrentDirectory();
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Platform: {platform}[/]");
|
||||
console.MarkupLine($"[dim]Template: {template}[/]");
|
||||
console.MarkupLine($"[dim]Mode: {mode}[/]");
|
||||
console.MarkupLine($"[dim]Output: {baseDir}[/]");
|
||||
}
|
||||
|
||||
var templates = CiTemplates.GetTemplates(platform, template, mode, offline, scannerImage);
|
||||
var written = 0;
|
||||
|
||||
foreach (var (relativePath, content) in templates)
|
||||
{
|
||||
var outputPath = Path.Combine(baseDir, relativePath);
|
||||
var outputDir = Path.GetDirectoryName(outputPath);
|
||||
|
||||
if (!string.IsNullOrEmpty(outputDir))
|
||||
{
|
||||
Directory.CreateDirectory(outputDir);
|
||||
}
|
||||
|
||||
if (File.Exists(outputPath) && !force)
|
||||
{
|
||||
console.MarkupLine($"[yellow]⚠[/] File exists (use --force to overwrite): {relativePath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(outputPath, content, ct);
|
||||
console.MarkupLine($"[green]✓[/] Created: {relativePath}");
|
||||
written++;
|
||||
}
|
||||
|
||||
if (written == 0)
|
||||
{
|
||||
console.MarkupLine("[yellow]No files were created[/]");
|
||||
return CiExitCodes.NoFilesCreated;
|
||||
}
|
||||
|
||||
console.MarkupLine(string.Empty);
|
||||
console.MarkupLine($"[green]✓[/] {written} template(s) initialized successfully");
|
||||
console.MarkupLine(string.Empty);
|
||||
console.MarkupLine("[dim]Next steps:[/]");
|
||||
console.MarkupLine(" 1. Review the generated workflow files");
|
||||
console.MarkupLine(" 2. Add required secrets (STELLAOPS_TOKEN, etc.)");
|
||||
console.MarkupLine(" 3. Commit and push to trigger the workflow");
|
||||
|
||||
return CiExitCodes.Success;
|
||||
}
|
||||
}
|
||||
|
||||
public static class CiExitCodes
|
||||
{
|
||||
public const int Success = 0;
|
||||
public const int InputError = 10;
|
||||
public const int IoError = 11;
|
||||
public const int TemplateError = 12;
|
||||
public const int NoFilesCreated = 13;
|
||||
public const int UnknownError = 99;
|
||||
}
|
||||
510
src/Cli/StellaOps.Cli/Commands/CiTemplates.cs
Normal file
510
src/Cli/StellaOps.Cli/Commands/CiTemplates.cs
Normal file
@@ -0,0 +1,510 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CI/CD template definitions for GitHub Actions, GitLab CI, and Gitea Actions.
|
||||
/// (Sprint: SPRINT_20251229_015)
|
||||
/// </summary>
|
||||
public static class CiTemplates
|
||||
{
|
||||
private const string DefaultScannerImage = "ghcr.io/stellaops/scanner:latest";
|
||||
|
||||
public static IReadOnlyList<(string path, string content)> GetTemplates(
|
||||
string platform,
|
||||
string templateType,
|
||||
string mode,
|
||||
bool offline,
|
||||
string? scannerImage)
|
||||
{
|
||||
var image = scannerImage ?? DefaultScannerImage;
|
||||
var templates = new List<(string, string)>();
|
||||
|
||||
if (platform is "github" or "all")
|
||||
{
|
||||
templates.AddRange(GetGitHubTemplates(templateType, mode, image, offline));
|
||||
}
|
||||
|
||||
if (platform is "gitlab" or "all")
|
||||
{
|
||||
templates.AddRange(GetGitLabTemplates(templateType, mode, image, offline));
|
||||
}
|
||||
|
||||
if (platform is "gitea" or "all")
|
||||
{
|
||||
templates.AddRange(GetGiteaTemplates(templateType, mode, image, offline));
|
||||
}
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
private static IEnumerable<(string, string)> GetGitHubTemplates(
|
||||
string templateType, string mode, string image, bool offline)
|
||||
{
|
||||
if (templateType is "gate" or "full")
|
||||
{
|
||||
yield return (".github/workflows/stellaops-gate.yml", GetGitHubGateTemplate(mode, image));
|
||||
}
|
||||
|
||||
if (templateType is "scan" or "full")
|
||||
{
|
||||
yield return (".github/workflows/stellaops-scan.yml", GetGitHubScanTemplate(mode, image));
|
||||
}
|
||||
|
||||
if (templateType is "verify" or "full")
|
||||
{
|
||||
yield return (".github/workflows/stellaops-verify.yml", GetGitHubVerifyTemplate(image));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<(string, string)> GetGitLabTemplates(
|
||||
string templateType, string mode, string image, bool offline)
|
||||
{
|
||||
if (templateType is "gate" or "full")
|
||||
{
|
||||
yield return (".gitlab-ci.yml", GetGitLabPipelineTemplate(templateType, mode, image));
|
||||
}
|
||||
else if (templateType is "scan")
|
||||
{
|
||||
yield return (".gitlab/stellaops-scan.yml", GetGitLabScanTemplate(mode, image));
|
||||
}
|
||||
else if (templateType is "verify")
|
||||
{
|
||||
yield return (".gitlab/stellaops-verify.yml", GetGitLabVerifyTemplate(image));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<(string, string)> GetGiteaTemplates(
|
||||
string templateType, string mode, string image, bool offline)
|
||||
{
|
||||
if (templateType is "gate" or "full")
|
||||
{
|
||||
yield return (".gitea/workflows/stellaops-gate.yml", GetGiteaGateTemplate(mode, image));
|
||||
}
|
||||
|
||||
if (templateType is "scan" or "full")
|
||||
{
|
||||
yield return (".gitea/workflows/stellaops-scan.yml", GetGiteaScanTemplate(mode, image));
|
||||
}
|
||||
|
||||
if (templateType is "verify" or "full")
|
||||
{
|
||||
yield return (".gitea/workflows/stellaops-verify.yml", GetGiteaVerifyTemplate(image));
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetGitHubGateTemplate(string mode, string image) => """
|
||||
# StellaOps Release Gate Workflow
|
||||
# Generated by: stella ci init --platform github --template gate
|
||||
# Documentation: https://docs.stellaops.io/ci/github-actions
|
||||
|
||||
name: StellaOps Release Gate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release/*]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for OIDC token exchange
|
||||
security-events: write # For SARIF upload
|
||||
|
||||
env:
|
||||
STELLAOPS_BACKEND_URL: ${{ secrets.STELLAOPS_BACKEND_URL }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: Release Gate Evaluation
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up StellaOps CLI
|
||||
uses: stellaops/setup-cli@v1
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Authenticate with OIDC
|
||||
uses: stellaops/auth@v1
|
||||
with:
|
||||
audience: stellaops
|
||||
|
||||
- name: Build Container Image
|
||||
id: build
|
||||
run: |
|
||||
IMAGE_TAG="${{ github.sha }}"
|
||||
docker build -t app:$IMAGE_TAG .
|
||||
echo "image=app:$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Scan Image
|
||||
id: scan
|
||||
run: |
|
||||
stella scan image ${{ steps.build.outputs.image }} \
|
||||
--format sarif \
|
||||
--output results.sarif
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
- name: Evaluate Gate
|
||||
id: gate
|
||||
run: |
|
||||
stella gate evaluate \
|
||||
--image ${{ steps.build.outputs.image }} \
|
||||
--baseline production \
|
||||
--output json > gate-result.json
|
||||
|
||||
EXIT_CODE=$(jq -r '.exitCode' gate-result.json)
|
||||
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Gate Summary
|
||||
run: |
|
||||
echo "## Release Gate Result" >> $GITHUB_STEP_SUMMARY
|
||||
stella gate evaluate \
|
||||
--image ${{ steps.build.outputs.image }} \
|
||||
--baseline production \
|
||||
--output markdown >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Check Gate Status
|
||||
if: steps.gate.outputs.exit_code != '0'
|
||||
run: |
|
||||
echo "::error::Release gate check failed"
|
||||
exit ${{ steps.gate.outputs.exit_code }}
|
||||
""";
|
||||
|
||||
private static string GetGitHubScanTemplate(string mode, string image) => """
|
||||
# StellaOps Container Scan Workflow
|
||||
# Generated by: stella ci init --platform github --template scan
|
||||
# Documentation: https://docs.stellaops.io/ci/github-actions
|
||||
|
||||
name: StellaOps Container Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 6 * * *' # Daily at 6 AM UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
security-events: write
|
||||
packages: read
|
||||
|
||||
env:
|
||||
STELLAOPS_BACKEND_URL: ${{ secrets.STELLAOPS_BACKEND_URL }}
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Container Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up StellaOps CLI
|
||||
uses: stellaops/setup-cli@v1
|
||||
|
||||
- name: Authenticate
|
||||
uses: stellaops/auth@v1
|
||||
|
||||
- name: Build Image
|
||||
id: build
|
||||
run: |
|
||||
docker build -t scan-target:${{ github.sha }} .
|
||||
echo "image=scan-target:${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Scan
|
||||
run: |
|
||||
stella scan image ${{ steps.build.outputs.image }} \
|
||||
--sbom-output sbom.cdx.json \
|
||||
--format sarif \
|
||||
--output scan.sarif
|
||||
|
||||
- name: Upload SBOM
|
||||
run: |
|
||||
stella sbom upload sbom.cdx.json \
|
||||
--image ${{ steps.build.outputs.image }}
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: scan.sarif
|
||||
|
||||
- name: Scan Summary
|
||||
run: |
|
||||
echo "## Scan Results" >> $GITHUB_STEP_SUMMARY
|
||||
stella scan image ${{ steps.build.outputs.image }} \
|
||||
--format markdown >> $GITHUB_STEP_SUMMARY
|
||||
""";
|
||||
|
||||
private static string GetGitHubVerifyTemplate(string image) => """
|
||||
# StellaOps Verification Workflow
|
||||
# Generated by: stella ci init --platform github --template verify
|
||||
# Documentation: https://docs.stellaops.io/ci/github-actions
|
||||
|
||||
name: StellaOps Verification
|
||||
|
||||
on:
|
||||
deployment:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image:
|
||||
description: 'Image to verify'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
STELLAOPS_BACKEND_URL: ${{ secrets.STELLAOPS_BACKEND_URL }}
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify Attestations
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up StellaOps CLI
|
||||
uses: stellaops/setup-cli@v1
|
||||
|
||||
- name: Authenticate
|
||||
uses: stellaops/auth@v1
|
||||
|
||||
- name: Verify Image
|
||||
run: |
|
||||
stella verify image ${{ inputs.image || github.event.deployment.payload.image }} \
|
||||
--require-sbom \
|
||||
--require-scan \
|
||||
--require-signature
|
||||
""";
|
||||
|
||||
private static string GetGitLabPipelineTemplate(string templateType, string mode, string image) => $"""
|
||||
# StellaOps GitLab CI Pipeline
|
||||
# Generated by: stella ci init --platform gitlab --template {templateType}
|
||||
# Documentation: https://docs.stellaops.io/ci/gitlab-ci
|
||||
|
||||
stages:
|
||||
- build
|
||||
- scan
|
||||
- gate
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
STELLAOPS_BACKEND_URL: $STELLAOPS_BACKEND_URL
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
|
||||
.stellaops-setup:
|
||||
before_script:
|
||||
- curl -fsSL https://get.stellaops.io/cli | sh
|
||||
- export PATH="$HOME/.stellaops/bin:$PATH"
|
||||
|
||||
build:
|
||||
stage: build
|
||||
image: docker:24-dind
|
||||
services:
|
||||
- docker:24-dind
|
||||
script:
|
||||
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
|
||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_MERGE_REQUEST_ID
|
||||
|
||||
scan:
|
||||
stage: scan
|
||||
extends: .stellaops-setup
|
||||
script:
|
||||
- stella scan image $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
|
||||
--sbom-output sbom.cdx.json
|
||||
--format json
|
||||
--output scan-results.json
|
||||
- stella sbom upload sbom.cdx.json
|
||||
--image $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
|
||||
artifacts:
|
||||
paths:
|
||||
- sbom.cdx.json
|
||||
- scan-results.json
|
||||
reports:
|
||||
container_scanning: scan-results.json
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_MERGE_REQUEST_ID
|
||||
|
||||
gate:
|
||||
stage: gate
|
||||
extends: .stellaops-setup
|
||||
script:
|
||||
- |
|
||||
stella gate evaluate \
|
||||
--image $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
|
||||
--baseline production \
|
||||
--output json > gate-result.json
|
||||
|
||||
EXIT_CODE=$(jq -r '.exitCode' gate-result.json)
|
||||
if [ "$EXIT_CODE" != "0" ]; then
|
||||
echo "Release gate failed"
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
script:
|
||||
- echo "Deploy $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
needs:
|
||||
- gate
|
||||
""";
|
||||
|
||||
private static string GetGitLabScanTemplate(string mode, string image) => """
|
||||
# StellaOps GitLab CI Scan Template
|
||||
# Include in your .gitlab-ci.yml: include: '.gitlab/stellaops-scan.yml'
|
||||
|
||||
.stellaops-scan:
|
||||
image: docker:24-dind
|
||||
services:
|
||||
- docker:24-dind
|
||||
before_script:
|
||||
- curl -fsSL https://get.stellaops.io/cli | sh
|
||||
- export PATH="$HOME/.stellaops/bin:$PATH"
|
||||
script:
|
||||
- stella scan image $SCAN_IMAGE
|
||||
--sbom-output sbom.cdx.json
|
||||
--format json
|
||||
--output scan-results.json
|
||||
artifacts:
|
||||
paths:
|
||||
- sbom.cdx.json
|
||||
- scan-results.json
|
||||
""";
|
||||
|
||||
private static string GetGitLabVerifyTemplate(string image) => """
|
||||
# StellaOps GitLab CI Verify Template
|
||||
# Include in your .gitlab-ci.yml: include: '.gitlab/stellaops-verify.yml'
|
||||
|
||||
.stellaops-verify:
|
||||
before_script:
|
||||
- curl -fsSL https://get.stellaops.io/cli | sh
|
||||
- export PATH="$HOME/.stellaops/bin:$PATH"
|
||||
script:
|
||||
- stella verify image $VERIFY_IMAGE
|
||||
--require-sbom
|
||||
--require-scan
|
||||
--require-signature
|
||||
""";
|
||||
|
||||
private static string GetGiteaGateTemplate(string mode, string image) => """
|
||||
# StellaOps Gitea Actions Release Gate Workflow
|
||||
# Generated by: stella ci init --platform gitea --template gate
|
||||
# Documentation: https://docs.stellaops.io/ci/gitea-actions
|
||||
|
||||
name: StellaOps Release Gate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release/*]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
STELLAOPS_BACKEND_URL: ${{ secrets.STELLAOPS_BACKEND_URL }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up StellaOps CLI
|
||||
run: |
|
||||
curl -fsSL https://get.stellaops.io/cli | sh
|
||||
echo "$HOME/.stellaops/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Build Image
|
||||
run: |
|
||||
docker build -t app:${{ gitea.sha }} .
|
||||
|
||||
- name: Scan and Gate
|
||||
run: |
|
||||
stella scan image app:${{ gitea.sha }}
|
||||
stella gate evaluate --image app:${{ gitea.sha }} --baseline production
|
||||
""";
|
||||
|
||||
private static string GetGiteaScanTemplate(string mode, string image) => """
|
||||
# StellaOps Gitea Actions Scan Workflow
|
||||
# Generated by: stella ci init --platform gitea --template scan
|
||||
|
||||
name: StellaOps Container Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
STELLAOPS_BACKEND_URL: ${{ secrets.STELLAOPS_BACKEND_URL }}
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up StellaOps
|
||||
run: |
|
||||
curl -fsSL https://get.stellaops.io/cli | sh
|
||||
echo "$HOME/.stellaops/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Build and Scan
|
||||
run: |
|
||||
docker build -t scan-target:${{ gitea.sha }} .
|
||||
stella scan image scan-target:${{ gitea.sha }} \
|
||||
--sbom-output sbom.cdx.json
|
||||
""";
|
||||
|
||||
private static string GetGiteaVerifyTemplate(string image) => """
|
||||
# StellaOps Gitea Actions Verify Workflow
|
||||
# Generated by: stella ci init --platform gitea --template verify
|
||||
|
||||
name: StellaOps Verification
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image:
|
||||
description: 'Image to verify'
|
||||
required: true
|
||||
|
||||
env:
|
||||
STELLAOPS_BACKEND_URL: ${{ secrets.STELLAOPS_BACKEND_URL }}
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up StellaOps
|
||||
run: |
|
||||
curl -fsSL https://get.stellaops.io/cli | sh
|
||||
echo "$HOME/.stellaops/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Verify
|
||||
run: |
|
||||
stella verify image ${{ inputs.image }} \
|
||||
--require-sbom \
|
||||
--require-scan
|
||||
""";
|
||||
}
|
||||
@@ -104,6 +104,9 @@ internal static class CommandFactory
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration - Gate evaluation command
|
||||
root.Add(GateCommandGroup.BuildGateCommand(services, options, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20251229_015 - CI template generator
|
||||
root.Add(CiCommandGroup.BuildCiCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20251226_003_BE_exception_approval - Exception approval workflow
|
||||
root.Add(ExceptionCommandGroup.BuildExceptionCommand(services, options, verboseOption, cancellationToken));
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.ExportCenter.Client;
|
||||
using StellaOps.ExportCenter.Core.EvidenceCache;
|
||||
#if DEBUG || STELLAOPS_ENABLE_SIMULATOR
|
||||
using StellaOps.Cryptography.Plugin.SimRemote.DependencyInjection;
|
||||
#endif
|
||||
|
||||
namespace StellaOps.Cli;
|
||||
|
||||
|
||||
@@ -216,6 +216,8 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackLanguage = TryReadLanguage(entity.RawPayload);
|
||||
|
||||
// Reconstruct from child entities
|
||||
var aliases = await _aliasRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
|
||||
var cvss = await _cvssRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
|
||||
@@ -267,11 +269,16 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
|
||||
}
|
||||
}
|
||||
|
||||
var normalizedVersions = BuildNormalizedVersions(versionRanges);
|
||||
|
||||
return new AffectedPackage(
|
||||
MapEcosystemToType(a.Ecosystem),
|
||||
a.PackageName,
|
||||
null,
|
||||
versionRanges);
|
||||
versionRanges,
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
Array.Empty<AdvisoryProvenance>(),
|
||||
normalizedVersions);
|
||||
}).ToArray();
|
||||
|
||||
// Parse provenance if available
|
||||
@@ -293,7 +300,7 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
|
||||
entity.AdvisoryKey,
|
||||
entity.Title ?? entity.AdvisoryKey,
|
||||
entity.Summary,
|
||||
null,
|
||||
fallbackLanguage,
|
||||
entity.PublishedAt,
|
||||
entity.ModifiedAt,
|
||||
entity.Severity,
|
||||
@@ -309,6 +316,65 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
|
||||
null);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedVersionRule> BuildNormalizedVersions(IEnumerable<AffectedVersionRange> ranges)
|
||||
{
|
||||
if (ranges is null)
|
||||
{
|
||||
return Array.Empty<NormalizedVersionRule>();
|
||||
}
|
||||
|
||||
var buffer = new List<NormalizedVersionRule>();
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
if (range is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rule = range.ToNormalizedVersionRule(range.Provenance.Value);
|
||||
if (rule is not null)
|
||||
{
|
||||
buffer.Add(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.Count == 0 ? Array.Empty<NormalizedVersionRule>() : buffer;
|
||||
}
|
||||
|
||||
private static string? TryReadLanguage(string? rawPayload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawPayload))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(rawPayload, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true
|
||||
});
|
||||
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!document.RootElement.TryGetProperty("language", out var languageElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return languageElement.ValueKind == JsonValueKind.String
|
||||
? languageElement.GetString()
|
||||
: null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapEcosystemToType(string ecosystem)
|
||||
{
|
||||
return ecosystem.ToLowerInvariant() switch
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class CachePerformanceBenchmarkTests : IAsyncLifetime
|
||||
SetupDatabaseMock();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var options = Options.Create(new ConcelierCacheOptions
|
||||
{
|
||||
@@ -75,10 +75,10 @@ public sealed class CachePerformanceBenchmarkTests : IAsyncLifetime
|
||||
options,
|
||||
NullLogger<ValkeyAdvisoryCacheService>.Instance);
|
||||
|
||||
await Task.CompletedTask;
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _connectionFactory.DisposeAsync();
|
||||
}
|
||||
@@ -726,3 +726,6 @@ public sealed class CachePerformanceBenchmarkTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
</ItemGroup>
|
||||
@@ -23,3 +23,4 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -246,14 +246,17 @@ public sealed class CertCcConnectorFetchTests : IAsyncLifetime
|
||||
return File.ReadAllText(Path.Combine(baseDirectory, filename));
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_handler.Clear();
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisposeServiceProviderAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -398,9 +398,9 @@ public sealed class CertCcConnectorSnapshotTests : IAsyncLifetime
|
||||
return File.Exists(fallback);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
@@ -408,3 +408,6 @@ public sealed class CertCcConnectorSnapshotTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -267,9 +267,9 @@ public sealed class CertCcConnectorTests : IAsyncLifetime
|
||||
pendingMappingsValue!.AsDocumentArray.Should().BeEmpty();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
@@ -472,3 +472,6 @@ public sealed class CertCcConnectorTests : IAsyncLifetime
|
||||
return File.ReadAllText(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -333,9 +333,9 @@ public sealed class CertInConnectorTests : IAsyncLifetime
|
||||
private static string NormalizeLineEndings(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_serviceProvider is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
@@ -347,3 +347,6 @@ public sealed class CertInConnectorTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -146,12 +146,12 @@ public sealed class SourceFetchServiceGuardTests : IAsyncLifetime
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateSuccessResponse(string payload)
|
||||
@@ -256,3 +256,6 @@ public sealed class SourceFetchServiceGuardTests : IAsyncLifetime
|
||||
public RawLinkset Map(AdvisoryRawDocument document) => new();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -206,11 +206,14 @@ public sealed class SourceStateSeedProcessorTests : IAsyncLifetime
|
||||
_timeProvider,
|
||||
NullLogger<SourceStateSeedProcessor>.Instance);
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _client.DropDatabaseAsync(_database.DatabaseNamespace.DatabaseName);
|
||||
_runner.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -199,12 +199,12 @@ public sealed class CveConnectorTests : IAsyncLifetime
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
@@ -254,3 +254,6 @@ public sealed class CveConnectorTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -241,9 +241,9 @@ public sealed class DebianConnectorTests : IAsyncLifetime
|
||||
throw new FileNotFoundException($"Fixture '{filename}' not found", filename);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private sealed class TestOutputLoggerProvider : ILoggerProvider
|
||||
{
|
||||
@@ -277,3 +277,6 @@ public sealed class DebianConnectorTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -111,9 +111,9 @@ public sealed class RedHatConnectorHarnessTests : IAsyncLifetime
|
||||
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings) && pendingMappings.AsDocumentArray.Count == 0);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => _harness.ResetAsync();
|
||||
public ValueTask DisposeAsync() => new(_harness.ResetAsync());
|
||||
|
||||
private static string ReadFixture(string filename)
|
||||
{
|
||||
@@ -121,3 +121,6 @@ public sealed class RedHatConnectorHarnessTests : IAsyncLifetime
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -641,10 +641,13 @@ public sealed class RedHatConnectorTests : IAsyncLifetime
|
||||
return normalized.TrimEnd('\n');
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ResetDatabaseInternalAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -225,12 +225,12 @@ public sealed class GhsaConnectorTests : IAsyncLifetime
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
@@ -238,3 +238,6 @@ public sealed class GhsaConnectorTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -558,12 +558,12 @@ public sealed class GhsaResilienceTests : IAsyncLifetime
|
||||
_harness = harness;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
@@ -573,3 +573,6 @@ public sealed class GhsaResilienceTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -486,12 +486,12 @@ public sealed class GhsaSecurityTests : IAsyncLifetime
|
||||
_harness = harness;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
@@ -547,3 +547,6 @@ file static class ConnectorSecurityTestBase
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -329,9 +329,9 @@ public sealed class KasperskyConnectorTests : IAsyncLifetime
|
||||
private static string NormalizeLineEndings(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_serviceProvider is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
@@ -343,3 +343,6 @@ public sealed class KasperskyConnectorTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -254,9 +254,9 @@ public sealed class JvnConnectorTests : IAsyncLifetime
|
||||
return Path.Combine(baseDirectory, "Jvn", "Fixtures", filename);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_serviceProvider is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
@@ -268,3 +268,6 @@ public sealed class JvnConnectorTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -206,10 +206,13 @@ public sealed class KevConnectorTests : IAsyncLifetime
|
||||
return Path.Combine(primaryDir, filename);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -498,7 +498,10 @@ public sealed class KisaConnectorTests : IAsyncLifetime
|
||||
internal sealed record MetricMeasurement(string Name, long Value, IReadOnlyList<KeyValuePair<string, object?>> Tags);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -93,9 +93,9 @@ public sealed class NvdConnectorHarnessTests : IAsyncLifetime
|
||||
Assert.Equal(3, pendingDocuments.Count);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => _harness.ResetAsync();
|
||||
public ValueTask DisposeAsync() => new(_harness.ResetAsync());
|
||||
|
||||
private static Uri BuildRequestUri(NvdOptions options, DateTimeOffset start, DateTimeOffset end, int startIndex = 0)
|
||||
{
|
||||
@@ -134,3 +134,6 @@ public sealed class NvdConnectorHarnessTests : IAsyncLifetime
|
||||
throw new FileNotFoundException($"Fixture '{filename}' was not found in the test output directory.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -642,10 +642,13 @@ public sealed class NvdConnectorTests : IAsyncLifetime
|
||||
throw new FileNotFoundException($"Fixture '{filename}' was not found in the test output directory.");
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ResetDatabaseInternalAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -83,9 +83,9 @@ public sealed class RuBduConnectorSnapshotTests : IAsyncLifetime
|
||||
harness.Handler.AssertNoPendingResponses();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
@@ -299,3 +299,6 @@ public sealed class RuBduConnectorSnapshotTests : IAsyncLifetime
|
||||
: normalized;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -286,8 +286,11 @@ public sealed class RuNkckiConnectorTests : IAsyncLifetime
|
||||
throw new InvalidOperationException("Unable to locate project root for Ru.Nkcki tests.");
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
=> await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -272,12 +272,12 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
||||
// AdvisoryStore integration validated elsewhere; ensure canonical serialization is stable.
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_handler.Clear();
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync(Action<StellaOpsMirrorConnectorOptions>? configureOptions = null)
|
||||
@@ -467,3 +467,6 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -452,7 +452,10 @@ public sealed class AdobeConnectorFetchTests : IAsyncLifetime
|
||||
private static string NormalizeLineEndings(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -175,9 +175,9 @@ public sealed class AppleConnectorTests : IAsyncLifetime
|
||||
return package;
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync(CannedHttpMessageHandler handler)
|
||||
{
|
||||
@@ -255,3 +255,6 @@ public sealed class AppleConnectorTests : IAsyncLifetime
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -337,9 +337,9 @@ public sealed class ChromiumConnectorTests : IAsyncLifetime
|
||||
return Path.Combine(baseDirectory, "Chromium", "Fixtures", filename);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var name in _allocatedDatabases.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
@@ -347,3 +347,6 @@ public sealed class ChromiumConnectorTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -194,7 +194,10 @@ public sealed class MsrcConnectorTests : IAsyncLifetime
|
||||
private static string ReadFixture(string fileName)
|
||||
=> System.IO.File.ReadAllText(System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName));
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -347,7 +347,10 @@ public sealed class OracleConnectorTests : IAsyncLifetime
|
||||
private static string Normalize(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -157,12 +157,12 @@ public sealed class VmwareConnectorTests : IAsyncLifetime
|
||||
Assert.Equal(new[] { 1, 1, 2 }, affectedCounts);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_handler.Clear();
|
||||
return Task.CompletedTask;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<ServiceProvider> BuildServiceProviderAsync()
|
||||
@@ -277,3 +277,6 @@ public sealed class VmwareConnectorTests : IAsyncLifetime
|
||||
public sealed record MetricMeasurement(string Name, long Value, IReadOnlyList<KeyValuePair<string, object?>> Tags);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ using StellaOps.Concelier.BackportProof.Repositories;
|
||||
using StellaOps.Concelier.BackportProof.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.BackportProof;
|
||||
|
||||
|
||||
@@ -26,4 +26,5 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Interest\StellaOps.Concelier.Interest.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime
|
||||
Assert.True(persisted.BeforeHash.Length > 0);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero))
|
||||
{
|
||||
@@ -78,7 +78,7 @@ public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime
|
||||
_mergeEventWriter = new MergeEventWriter(_mergeEventStore, new CanonicalHashCalculator(), _timeProvider, NullLogger<MergeEventWriter>.Instance);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private async Task EnsureInitializedAsync()
|
||||
{
|
||||
@@ -199,3 +199,6 @@ public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ public sealed class AdvisoryCanonicalRepositoryTests : IAsyncLifetime
|
||||
_sourceRepository = new SourceRepository(_dataSource, NullLogger<SourceRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
#region GetByIdAsync Tests
|
||||
|
||||
@@ -799,3 +799,6 @@ public sealed class AdvisoryCanonicalRepositoryTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
|
||||
_sourceStateRepository = new SourceStateRepository(_dataSource, NullLogger<SourceStateRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_SameAdvisoryKey_Twice_NosDuplicates()
|
||||
@@ -376,3 +376,6 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
|
||||
_cvssRepository = new AdvisoryCvssRepository(_dataSource, NullLogger<AdvisoryCvssRepository>.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]
|
||||
@@ -461,3 +461,6 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class ConcelierMigrationTests : 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 ConcelierMigrationTests : IAsyncLifetime
|
||||
await _container.StartAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
@@ -327,3 +327,6 @@ public sealed class ConcelierMigrationTests : IAsyncLifetime
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class ConcelierQueryDeterminismTests : IAsyncLifetime
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
@@ -52,7 +52,7 @@ public sealed class ConcelierQueryDeterminismTests : IAsyncLifetime
|
||||
_affectedRepository = new AdvisoryAffectedRepository(_dataSource, NullLogger<AdvisoryAffectedRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task GetModifiedSinceAsync_MultipleQueries_ReturnsDeterministicOrder()
|
||||
@@ -406,3 +406,6 @@ public sealed class ConcelierQueryDeterminismTests : IAsyncLifetime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ public sealed class InterestScoreRepositoryTests : IAsyncLifetime
|
||||
_repository = new InterestScoreRepository(_dataSource, NullLogger<InterestScoreRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
#region GetByCanonicalIdAsync Tests
|
||||
|
||||
@@ -742,3 +742,6 @@ public sealed class InterestScoreRepositoryTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class InterestScoringServiceIntegrationTests : IAsyncLifetime
|
||||
};
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_service = new InterestScoringService(
|
||||
_repository,
|
||||
@@ -80,10 +80,10 @@ public sealed class InterestScoringServiceIntegrationTests : IAsyncLifetime
|
||||
_cacheServiceMock.Object,
|
||||
NullLogger<InterestScoringService>.Instance);
|
||||
|
||||
return _fixture.TruncateAllTablesAsync();
|
||||
return new ValueTask(_fixture.TruncateAllTablesAsync());
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
#region ComputeScoreAsync Tests
|
||||
|
||||
@@ -686,3 +686,6 @@ public sealed class InterestScoringServiceIntegrationTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ public sealed class KevFlagRepositoryTests : IAsyncLifetime
|
||||
_repository = new KevFlagRepository(_dataSource, NullLogger<KevFlagRepository>.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]
|
||||
@@ -281,3 +281,6 @@ public sealed class KevFlagRepositoryTests : IAsyncLifetime
|
||||
return await _advisoryRepository.UpsertAsync(advisory);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ public sealed class AdvisoryLinksetCacheRepositoryTests : IAsyncLifetime
|
||||
_repository = new AdvisoryLinksetCacheRepository(dataSource, NullLogger<AdvisoryLinksetCacheRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_NormalizesTenantAndReplaces()
|
||||
@@ -146,3 +146,6 @@ public sealed class AdvisoryLinksetCacheRepositoryTests : IAsyncLifetime
|
||||
CreatedAt: createdAt,
|
||||
BuiltByJobId: "job-1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ public sealed class MergeEventRepositoryTests : IAsyncLifetime
|
||||
_repository = new MergeEventRepository(_dataSource, NullLogger<MergeEventRepository>.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]
|
||||
@@ -297,3 +297,6 @@ public sealed class MergeEventRepositoryTests : IAsyncLifetime
|
||||
return await _sourceRepository.UpsertAsync(source);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ public sealed class AdvisoryPerformanceTests : IAsyncLifetime
|
||||
_repository = new AdvisoryRepository(_dataSource, NullLogger<AdvisoryRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark bulk advisory insertion performance.
|
||||
@@ -410,3 +410,6 @@ public sealed class AdvisoryPerformanceTests : IAsyncLifetime
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ public sealed class ProvenanceScopeRepositoryTests : IAsyncLifetime
|
||||
_repository = new ProvenanceScopeRepository(_dataSource, NullLogger<ProvenanceScopeRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
#region Migration Validation
|
||||
|
||||
@@ -442,3 +442,6 @@ public sealed class ProvenanceScopeRepositoryTests : IAsyncLifetime
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ public sealed class RepositoryIntegrationTests : IAsyncLifetime
|
||||
_mergeEvents = new MergeEventRepository(_dataSource, NullLogger<MergeEventRepository>.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]
|
||||
@@ -372,3 +372,6 @@ public sealed class RepositoryIntegrationTests : IAsyncLifetime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ public sealed class SourceRepositoryTests : IAsyncLifetime
|
||||
_repository = new SourceRepository(_dataSource, NullLogger<SourceRepository>.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]
|
||||
@@ -209,3 +209,6 @@ public sealed class SourceRepositoryTests : IAsyncLifetime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ public sealed class SourceStateRepositoryTests : IAsyncLifetime
|
||||
_repository = new SourceStateRepository(_dataSource, NullLogger<SourceStateRepository>.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]
|
||||
@@ -198,3 +198,6 @@ public sealed class SourceStateRepositoryTests : IAsyncLifetime
|
||||
return await _sourceRepository.UpsertAsync(source);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user