Add unit tests for PackRunAttestation and SealedInstallEnforcer
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
release-manifest-verify / verify (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
release-manifest-verify / verify (push) Has been cancelled
- Implement comprehensive tests for PackRunAttestationService, covering attestation generation, verification, and event emission. - Add tests for SealedInstallEnforcer to validate sealed install requirements and enforcement logic. - Introduce a MonacoLoaderService stub for testing purposes to prevent Monaco workers/styles from loading during Karma runs.
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.RiskFeed;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Risk feed API endpoints (EXCITITOR-RISK-66-001).
|
||||
/// Publishes risk-engine ready feeds with status, justification, and provenance
|
||||
/// without derived severity (aggregation-only per AOC baseline).
|
||||
/// </summary>
|
||||
public static class RiskFeedEndpoints
|
||||
{
|
||||
public static void MapRiskFeedEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/risk/v1");
|
||||
|
||||
// POST /risk/v1/feed - Generate risk feed
|
||||
group.MapPost("/feed", async (
|
||||
HttpContext context,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IRiskFeedService riskFeedService,
|
||||
[FromBody] RiskFeedRequestDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_RISK_PARAMS", message = "Request body is required" }
|
||||
});
|
||||
}
|
||||
|
||||
var domainRequest = new RiskFeedRequest(
|
||||
tenantId: tenant,
|
||||
advisoryKeys: request.AdvisoryKeys,
|
||||
artifacts: request.Artifacts,
|
||||
since: request.Since,
|
||||
limit: request.Limit ?? 1000);
|
||||
|
||||
var feedResponse = await riskFeedService
|
||||
.GenerateFeedAsync(domainRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var responseDto = MapToResponse(feedResponse);
|
||||
return Results.Ok(responseDto);
|
||||
}).WithName("GenerateRiskFeed");
|
||||
|
||||
// GET /risk/v1/feed/item - Get single risk feed item
|
||||
group.MapGet("/feed/item", async (
|
||||
HttpContext context,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IRiskFeedService riskFeedService,
|
||||
[FromQuery] string? advisoryKey,
|
||||
[FromQuery] string? artifact,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey) || string.IsNullOrWhiteSpace(artifact))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_RISK_PARAMS", message = "advisoryKey and artifact query parameters are required" }
|
||||
});
|
||||
}
|
||||
|
||||
var item = await riskFeedService
|
||||
.GetItemAsync(tenant, advisoryKey, artifact, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
error = new { code = "ERR_RISK_NOT_FOUND", message = "No risk feed item found for the specified advisory and artifact" }
|
||||
});
|
||||
}
|
||||
|
||||
var dto = MapToItemDto(item);
|
||||
return Results.Ok(dto);
|
||||
}).WithName("GetRiskFeedItem");
|
||||
|
||||
// GET /risk/v1/feed/by-advisory - Get risk feed items by advisory key
|
||||
group.MapGet("/feed/by-advisory/{advisoryKey}", async (
|
||||
HttpContext context,
|
||||
string advisoryKey,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IRiskFeedService riskFeedService,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_RISK_PARAMS", message = "advisoryKey is required" }
|
||||
});
|
||||
}
|
||||
|
||||
var request = new RiskFeedRequest(
|
||||
tenantId: tenant,
|
||||
advisoryKeys: [advisoryKey],
|
||||
limit: limit ?? 100);
|
||||
|
||||
var feedResponse = await riskFeedService
|
||||
.GenerateFeedAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var responseDto = MapToResponse(feedResponse);
|
||||
return Results.Ok(responseDto);
|
||||
}).WithName("GetRiskFeedByAdvisory");
|
||||
|
||||
// GET /risk/v1/feed/by-artifact/{artifact} - Get risk feed items by artifact
|
||||
group.MapGet("/feed/by-artifact/{**artifact}", async (
|
||||
HttpContext context,
|
||||
string artifact,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IRiskFeedService riskFeedService,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifact))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_RISK_PARAMS", message = "artifact is required" }
|
||||
});
|
||||
}
|
||||
|
||||
var request = new RiskFeedRequest(
|
||||
tenantId: tenant,
|
||||
artifacts: [artifact],
|
||||
limit: limit ?? 100);
|
||||
|
||||
var feedResponse = await riskFeedService
|
||||
.GenerateFeedAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var responseDto = MapToResponse(feedResponse);
|
||||
return Results.Ok(responseDto);
|
||||
}).WithName("GetRiskFeedByArtifact");
|
||||
}
|
||||
|
||||
private static RiskFeedResponseDto MapToResponse(RiskFeedResponse response)
|
||||
{
|
||||
var items = response.Items
|
||||
.Select(MapToItemDto)
|
||||
.ToList();
|
||||
|
||||
return new RiskFeedResponseDto(
|
||||
Items: items,
|
||||
GeneratedAt: response.GeneratedAt,
|
||||
NextPageToken: response.NextPageToken);
|
||||
}
|
||||
|
||||
private static RiskFeedItemDto MapToItemDto(RiskFeedItem item)
|
||||
{
|
||||
var provenance = new RiskFeedProvenanceDto(
|
||||
TenantId: item.Provenance.TenantId,
|
||||
LinksetId: item.Provenance.LinksetId,
|
||||
ContentHash: item.Provenance.ContentHash,
|
||||
Confidence: item.Provenance.Confidence.ToString().ToLowerInvariant(),
|
||||
HasConflicts: item.Provenance.HasConflicts,
|
||||
GeneratedAt: item.Provenance.GeneratedAt,
|
||||
AttestationId: item.Provenance.AttestationId);
|
||||
|
||||
var sources = item.Sources
|
||||
.Select(s => new RiskFeedSourceDto(
|
||||
ObservationId: s.ObservationId,
|
||||
ProviderId: s.ProviderId,
|
||||
Status: s.Status,
|
||||
Justification: s.Justification,
|
||||
Confidence: s.Confidence))
|
||||
.ToList();
|
||||
|
||||
return new RiskFeedItemDto(
|
||||
AdvisoryKey: item.AdvisoryKey,
|
||||
Artifact: item.Artifact,
|
||||
Status: item.Status.ToString().ToLowerInvariant(),
|
||||
Justification: item.Justification?.ToString().ToLowerInvariant(),
|
||||
Provenance: provenance,
|
||||
ObservedAt: item.ObservedAt,
|
||||
Sources: sources);
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(
|
||||
HttpContext context,
|
||||
VexMongoStorageOptions options,
|
||||
out string tenant,
|
||||
out IResult? problem)
|
||||
{
|
||||
problem = null;
|
||||
tenant = string.Empty;
|
||||
|
||||
var headerTenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant))
|
||||
{
|
||||
tenant = headerTenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(options.DefaultTenant))
|
||||
{
|
||||
tenant = options.DefaultTenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
else
|
||||
{
|
||||
problem = Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header is required" }
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTO
|
||||
public sealed record RiskFeedRequestDto(
|
||||
[property: JsonPropertyName("advisoryKeys")] IEnumerable<string>? AdvisoryKeys,
|
||||
[property: JsonPropertyName("artifacts")] IEnumerable<string>? Artifacts,
|
||||
[property: JsonPropertyName("since")] DateTimeOffset? Since,
|
||||
[property: JsonPropertyName("limit")] int? Limit);
|
||||
|
||||
// Response DTOs
|
||||
public sealed record RiskFeedResponseDto(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<RiskFeedItemDto> Items,
|
||||
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
|
||||
[property: JsonPropertyName("nextPageToken")] string? NextPageToken);
|
||||
|
||||
public sealed record RiskFeedItemDto(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("artifact")] string Artifact,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("justification")] string? Justification,
|
||||
[property: JsonPropertyName("provenance")] RiskFeedProvenanceDto Provenance,
|
||||
[property: JsonPropertyName("observedAt")] DateTimeOffset ObservedAt,
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<RiskFeedSourceDto> Sources);
|
||||
|
||||
public sealed record RiskFeedProvenanceDto(
|
||||
[property: JsonPropertyName("tenantId")] string TenantId,
|
||||
[property: JsonPropertyName("linksetId")] string LinksetId,
|
||||
[property: JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: JsonPropertyName("confidence")] string Confidence,
|
||||
[property: JsonPropertyName("hasConflicts")] bool HasConflicts,
|
||||
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
|
||||
[property: JsonPropertyName("attestationId")] string? AttestationId);
|
||||
|
||||
public sealed record RiskFeedSourceDto(
|
||||
[property: JsonPropertyName("observationId")] string ObservationId,
|
||||
[property: JsonPropertyName("providerId")] string ProviderId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("justification")] string? Justification,
|
||||
[property: JsonPropertyName("confidence")] double? Confidence);
|
||||
@@ -90,6 +90,9 @@ services.AddSingleton<IVexHashingService>(sp =>
|
||||
services.AddSingleton<IVexObservationProjectionService, VexObservationProjectionService>();
|
||||
services.AddScoped<IVexObservationQueryService, VexObservationQueryService>();
|
||||
|
||||
// EXCITITOR-RISK-66-001: Risk feed service for Risk Engine integration
|
||||
services.AddScoped<StellaOps.Excititor.Core.RiskFeed.IRiskFeedService, StellaOps.Excititor.Core.RiskFeed.RiskFeedService>();
|
||||
|
||||
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
|
||||
if (rekorSection.Exists())
|
||||
{
|
||||
@@ -2323,6 +2326,9 @@ PolicyEndpoints.MapPolicyEndpoints(app);
|
||||
ObservationEndpoints.MapObservationEndpoints(app);
|
||||
LinksetEndpoints.MapLinksetEndpoints(app);
|
||||
|
||||
// Risk Feed APIs (EXCITITOR-RISK-66-001)
|
||||
RiskFeedEndpoints.MapRiskFeedEndpoints(app);
|
||||
|
||||
app.Run();
|
||||
|
||||
internal sealed record ExcititorTimelineEvent(
|
||||
|
||||
Reference in New Issue
Block a user