save checkpoint: save features
This commit is contained in:
@@ -136,7 +136,29 @@ public sealed record SurfacePointersPayload
|
||||
public SurfaceManifestDocument Manifest { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record ScanCompletedEventPayload
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reportId")]
|
||||
public string? ReportId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset? GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("report")]
|
||||
public ReportDocumentPayload? Report { get; init; }
|
||||
|
||||
[JsonPropertyName("reportReady")]
|
||||
public ReportReadyEventPayload? ReportReady { get; init; }
|
||||
}
|
||||
|
||||
public static class OrchestratorEventKinds
|
||||
{
|
||||
public const string ScannerReportReady = "scanner.event.report.ready";
|
||||
public const string ScannerScanCompleted = "scanner.scan.completed";
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ public sealed class AnalyticsStreamOptions
|
||||
public string ConcelierLinksetStream { get; set; } = "concelier:advisory.linkset.updated:v1";
|
||||
public string AttestorStream { get; set; } = "attestor:events";
|
||||
public bool StartFromBeginning { get; set; } = false;
|
||||
public bool ResumeFromCheckpoint { get; set; } = true;
|
||||
public string? ScannerCheckpointFilePath { get; set; }
|
||||
|
||||
public void Normalize()
|
||||
{
|
||||
@@ -62,6 +64,9 @@ public sealed class AnalyticsStreamOptions
|
||||
ConcelierObservationStream = NormalizeName(ConcelierObservationStream);
|
||||
ConcelierLinksetStream = NormalizeName(ConcelierLinksetStream);
|
||||
AttestorStream = NormalizeName(AttestorStream);
|
||||
ScannerCheckpointFilePath = string.IsNullOrWhiteSpace(ScannerCheckpointFilePath)
|
||||
? null
|
||||
: ScannerCheckpointFilePath.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeName(string value)
|
||||
|
||||
@@ -31,6 +31,8 @@ public sealed class AnalyticsIngestionService : BackgroundService
|
||||
private readonly IVulnerabilityCorrelationService? _correlationService;
|
||||
private readonly ILogger<AnalyticsIngestionService> _logger;
|
||||
private readonly IEventStream<OrchestratorEventEnvelope>? _eventStream;
|
||||
private readonly string? _scannerCheckpointFilePath;
|
||||
private readonly SemaphoreSlim _scannerCheckpointLock = new(1, 1);
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
@@ -52,6 +54,7 @@ public sealed class AnalyticsIngestionService : BackgroundService
|
||||
_sbomParser = sbomParser ?? throw new ArgumentNullException(nameof(sbomParser));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_correlationService = correlationService;
|
||||
_scannerCheckpointFilePath = ResolveScannerCheckpointPath(_options.Streams, _options.Cas);
|
||||
|
||||
if (eventStreamFactory is not null && !string.IsNullOrWhiteSpace(_options.Streams.ScannerStream))
|
||||
{
|
||||
@@ -76,9 +79,7 @@ public sealed class AnalyticsIngestionService : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
var position = _options.Streams.StartFromBeginning
|
||||
? StreamPosition.Beginning
|
||||
: StreamPosition.End;
|
||||
var position = await ResolveScannerSubscriptionPositionAsync(stoppingToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Analytics ingestion started; subscribing to {StreamName} from {Position}.",
|
||||
@@ -90,6 +91,7 @@ public sealed class AnalyticsIngestionService : BackgroundService
|
||||
await foreach (var streamEvent in _eventStream.SubscribeAsync(position, stoppingToken))
|
||||
{
|
||||
await HandleEventAsync(streamEvent.Event, stoppingToken).ConfigureAwait(false);
|
||||
await PersistScannerCheckpointAsync(streamEvent.EntryId, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
@@ -105,7 +107,7 @@ public sealed class AnalyticsIngestionService : BackgroundService
|
||||
|
||||
private async Task HandleEventAsync(OrchestratorEventEnvelope envelope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.Equals(envelope.Kind, OrchestratorEventKinds.ScannerReportReady, StringComparison.OrdinalIgnoreCase))
|
||||
if (!IsSupportedScannerEventKind(envelope.Kind))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -116,32 +118,393 @@ public sealed class AnalyticsIngestionService : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.Payload is null || envelope.Payload.Value.ValueKind == JsonValueKind.Undefined)
|
||||
if (!TryResolveScannerPayload(envelope, _jsonOptions, out var payload, out var parseError))
|
||||
{
|
||||
_logger.LogWarning("Scanner report event {EventId} missing payload.", envelope.EventId);
|
||||
return;
|
||||
}
|
||||
|
||||
ReportReadyEventPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = envelope.Payload.Value.Deserialize<ReportReadyEventPayload>(_jsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse scanner report payload for event {EventId}.", envelope.EventId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
_logger.LogWarning("Scanner report payload empty for event {EventId}.", envelope.EventId);
|
||||
_logger.LogWarning(
|
||||
"Failed to parse scanner payload for event {EventId} ({Kind}); reason={Reason}.",
|
||||
envelope.EventId,
|
||||
envelope.Kind,
|
||||
parseError ?? "unknown");
|
||||
return;
|
||||
}
|
||||
|
||||
await IngestSbomAsync(envelope, payload, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<StreamPosition> ResolveScannerSubscriptionPositionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var checkpointEntryId = await ReadScannerCheckpointAsync(cancellationToken).ConfigureAwait(false);
|
||||
var position = ResolveScannerSubscriptionPosition(
|
||||
_options.Streams.StartFromBeginning,
|
||||
_options.Streams.ResumeFromCheckpoint,
|
||||
checkpointEntryId);
|
||||
|
||||
if (position != StreamPosition.Beginning && position != StreamPosition.End)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Resuming scanner ingestion from checkpoint entry {EntryId}.",
|
||||
position.Value);
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
internal static bool IsSupportedScannerEventKind(string? eventKind)
|
||||
{
|
||||
return string.Equals(eventKind, OrchestratorEventKinds.ScannerReportReady, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(eventKind, OrchestratorEventKinds.ScannerScanCompleted, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal static bool TryResolveScannerPayload(
|
||||
OrchestratorEventEnvelope envelope,
|
||||
JsonSerializerOptions serializerOptions,
|
||||
out ReportReadyEventPayload payload,
|
||||
out string? error)
|
||||
{
|
||||
payload = new ReportReadyEventPayload();
|
||||
error = null;
|
||||
|
||||
if (envelope.Payload is null ||
|
||||
envelope.Payload.Value.ValueKind == JsonValueKind.Undefined ||
|
||||
envelope.Payload.Value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
error = "missing_payload";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryDeserializeScannerPayload(envelope.Payload.Value, envelope.Kind, serializerOptions, out payload))
|
||||
{
|
||||
error = "payload_parse_failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryDeserializeScannerPayload(
|
||||
JsonElement payloadElement,
|
||||
string eventKind,
|
||||
JsonSerializerOptions serializerOptions,
|
||||
out ReportReadyEventPayload payload)
|
||||
{
|
||||
payload = new ReportReadyEventPayload();
|
||||
|
||||
if (TryDeserializeReportReadyPayload(payloadElement, serializerOptions, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(eventKind, OrchestratorEventKinds.ScannerScanCompleted, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryMapScanCompletedPayload(payloadElement, serializerOptions, out payload);
|
||||
}
|
||||
|
||||
internal static bool TryDeserializeReportReadyPayload(
|
||||
JsonElement payloadElement,
|
||||
JsonSerializerOptions serializerOptions,
|
||||
out ReportReadyEventPayload payload)
|
||||
{
|
||||
payload = new ReportReadyEventPayload();
|
||||
|
||||
if (TryDeserializeReportReadyObject(payloadElement, serializerOptions, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (payloadElement.TryGetProperty("reportReady", out var reportReadyElement) &&
|
||||
TryDeserializeReportReadyObject(reportReadyElement, serializerOptions, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (payloadElement.TryGetProperty("dsseEnvelope", out var dsseEnvelopeElement) &&
|
||||
TryDeserializeReportReadyDsseEnvelope(dsseEnvelopeElement, serializerOptions, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryDeserializeReportReadyDsseEnvelope(payloadElement, serializerOptions, out payload);
|
||||
}
|
||||
|
||||
internal static bool TryDeserializeReportReadyDsseEnvelope(
|
||||
JsonElement envelopeElement,
|
||||
JsonSerializerOptions serializerOptions,
|
||||
out ReportReadyEventPayload payload)
|
||||
{
|
||||
payload = new ReportReadyEventPayload();
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryExtractDssePayload(envelopeElement, out var payloadBytes, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<ReportReadyEventPayload>(payloadBytes, serializerOptions);
|
||||
if (!IsUsableReportReadyPayload(parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = parsed!;
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryExtractDssePayload(
|
||||
JsonElement envelopeElement,
|
||||
out byte[] payloadBytes,
|
||||
out string? payloadType)
|
||||
{
|
||||
payloadBytes = Array.Empty<byte>();
|
||||
payloadType = null;
|
||||
|
||||
if (!envelopeElement.TryGetProperty("payload", out var payloadElement) ||
|
||||
payloadElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var payloadValue = payloadElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(payloadValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payloadBytes = Convert.FromBase64String(payloadValue);
|
||||
if (envelopeElement.TryGetProperty("payloadType", out var payloadTypeElement) &&
|
||||
payloadTypeElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
payloadType = payloadTypeElement.GetString();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryMapScanCompletedPayload(
|
||||
JsonElement payloadElement,
|
||||
JsonSerializerOptions serializerOptions,
|
||||
out ReportReadyEventPayload payload)
|
||||
{
|
||||
payload = new ReportReadyEventPayload();
|
||||
|
||||
try
|
||||
{
|
||||
var completed = JsonSerializer.Deserialize<ScanCompletedEventPayload>(payloadElement, serializerOptions);
|
||||
if (completed is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsUsableReportReadyPayload(completed.ReportReady))
|
||||
{
|
||||
payload = completed.ReportReady!;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completed.Report is { Surface: not null } report)
|
||||
{
|
||||
payload = new ReportReadyEventPayload
|
||||
{
|
||||
ReportId = FirstNonEmpty(completed.ReportId, report.ReportId, completed.ScanId),
|
||||
ScanId = string.IsNullOrWhiteSpace(completed.ScanId) ? null : completed.ScanId,
|
||||
ImageDigest = FirstNonEmpty(completed.ImageDigest, report.ImageDigest),
|
||||
GeneratedAt = completed.GeneratedAt ?? report.GeneratedAt,
|
||||
Summary = new ReportSummaryPayload(),
|
||||
Report = report
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryDeserializeReportReadyDsseEnvelope(payloadElement, serializerOptions, out payload);
|
||||
}
|
||||
|
||||
internal static StreamPosition ResolveScannerSubscriptionPosition(
|
||||
bool startFromBeginning,
|
||||
bool resumeFromCheckpoint,
|
||||
string? checkpointEntryId)
|
||||
{
|
||||
if (startFromBeginning)
|
||||
{
|
||||
return StreamPosition.Beginning;
|
||||
}
|
||||
|
||||
if (!resumeFromCheckpoint)
|
||||
{
|
||||
return StreamPosition.End;
|
||||
}
|
||||
|
||||
var normalizedEntryId = NormalizeScannerCheckpointEntryId(checkpointEntryId);
|
||||
return string.IsNullOrWhiteSpace(normalizedEntryId)
|
||||
? StreamPosition.End
|
||||
: StreamPosition.After(normalizedEntryId);
|
||||
}
|
||||
|
||||
internal static string? NormalizeScannerCheckpointEntryId(string? entryId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = entryId.Trim();
|
||||
if (trimmed.Length == 0 ||
|
||||
trimmed.Equals(StreamPosition.Beginning.Value, StringComparison.Ordinal) ||
|
||||
trimmed.Equals(StreamPosition.End.Value, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
internal static string? ResolveScannerCheckpointPath(
|
||||
AnalyticsStreamOptions streamOptions,
|
||||
AnalyticsCasOptions casOptions)
|
||||
{
|
||||
if (!streamOptions.ResumeFromCheckpoint)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(streamOptions.ScannerCheckpointFilePath))
|
||||
{
|
||||
var configuredPath = streamOptions.ScannerCheckpointFilePath!;
|
||||
if (Path.IsPathRooted(configuredPath) || string.IsNullOrWhiteSpace(casOptions.RootPath))
|
||||
{
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
return Path.Combine(casOptions.RootPath!, configuredPath);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(casOptions.RootPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.Combine(casOptions.RootPath, ".state", "platform-scanner-stream.checkpoint");
|
||||
}
|
||||
|
||||
private async Task<string?> ReadScannerCheckpointAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_scannerCheckpointFilePath) || !File.Exists(_scannerCheckpointFilePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entryId = await File.ReadAllTextAsync(_scannerCheckpointFilePath, cancellationToken).ConfigureAwait(false);
|
||||
return NormalizeScannerCheckpointEntryId(entryId);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read scanner checkpoint from {Path}.", _scannerCheckpointFilePath);
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unauthorized to read scanner checkpoint from {Path}.", _scannerCheckpointFilePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistScannerCheckpointAsync(string entryId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_scannerCheckpointFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedEntryId = NormalizeScannerCheckpointEntryId(entryId);
|
||||
if (string.IsNullOrWhiteSpace(normalizedEntryId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _scannerCheckpointLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_scannerCheckpointFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(_scannerCheckpointFilePath, normalizedEntryId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to persist scanner checkpoint to {Path}.", _scannerCheckpointFilePath);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unauthorized to persist scanner checkpoint to {Path}.", _scannerCheckpointFilePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerCheckpointLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsUsableReportReadyPayload(ReportReadyEventPayload? payload)
|
||||
=> payload is not null && payload.Report is { Surface: not null };
|
||||
|
||||
private static bool TryDeserializeReportReadyObject(
|
||||
JsonElement payloadElement,
|
||||
JsonSerializerOptions serializerOptions,
|
||||
out ReportReadyEventPayload payload)
|
||||
{
|
||||
payload = new ReportReadyEventPayload();
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<ReportReadyEventPayload>(payloadElement, serializerOptions);
|
||||
if (!IsUsableReportReadyPayload(parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = parsed!;
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private async Task IngestSbomAsync(
|
||||
OrchestratorEventEnvelope envelope,
|
||||
ReportReadyEventPayload payload,
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
# StellaOps.Platform.Analytics Task Board
|
||||
# StellaOps.Platform.Analytics Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-PLATFORM-VERIFY-001 | DONE | run-002 verification captured analytics rollup/materialized-view behavior evidence; feature terminalized as `not_implemented` due missing advisory lock/LISTEN-NOTIFY parity. |
|
||||
| QA-PLATFORM-VERIFY-002 | DONE | run-001 verification passed with maintenance, endpoint paths, analytics service behavior, and Docker schema integration (`38/38` scoped tests). |
|
||||
| QA-PLATFORM-VERIFY-003 | DONE | `platform-service-aggregation-layer` verified with run-001 Tier 0/1/2 endpoint evidence and moved to `docs/features/checked/platform/`. |
|
||||
| QA-PLATFORM-VERIFY-004 | DONE | `platform-setup-wizard-backend-api` verified with run-001 setup endpoint behavior evidence and moved to `docs/features/checked/platform/`. |
|
||||
| QA-PLATFORM-VERIFY-005 | DONE | `sbom-analytics-lake` verified with run-001 Tier 0/1/2 analytics ingestion/schema integration evidence plus tenant allowlist normalization coverage (`171/171` full-suite execution under MTP), moved to `docs/features/checked/platform/`. |
|
||||
| QA-PLATFORM-VERIFY-006 | DONE | `scanner-platform-events` remediated and reverified in run-003: scanner ingestion now supports `scanner.scan.completed`, DSSE scanner payload parsing, and persisted checkpoint resume semantics; Tier 1 (`185/185`) and Tier 2 (`38/38`) passed; feature moved to `docs/features/checked/platform/scanner-platform-events.md`. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Platform/StellaOps.Platform.Analytics/StellaOps.Platform.Analytics.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# Platform WebService Task Board
|
||||
# Platform WebService Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-PLATFORM-VERIFY-001 | DONE | run-002 verification completed; feature terminalized as `not_implemented` due missing advisory lock and LISTEN/NOTIFY implementation signals in `src/Platform` (materialized-view/rollup behaviors verified). |
|
||||
| QA-PLATFORM-VERIFY-002 | DONE | run-001 verification passed with maintenance, endpoint (503 + success), service caching, and schema integration evidence; feature moved to `docs/features/checked/platform/materialized-views-for-analytics.md`. |
|
||||
| QA-PLATFORM-VERIFY-003 | DONE | run-001 verification passed with API aggregation endpoint behavior evidence (live HTTP request/response captures + endpoint tests, `98/98` assembly tests after quota/search gap tests); feature moved to `docs/features/checked/platform/platform-service-aggregation-layer.md`. |
|
||||
| QA-PLATFORM-VERIFY-004 | DONE | run-001 verification passed with setup session/step/finalize/definitions API evidence (`7/7` setup-focused classes, `100/100` assembly tests); feature moved to `docs/features/checked/platform/platform-setup-wizard-backend-api.md`. |
|
||||
| QA-PLATFORM-VERIFY-005 | DONE | run-001 verification passed for `sbom-analytics-lake` with analytics ingestion behavior, dependency-path, vulnerability correlation, schema integration, and tenant allowlist coverage (`171/171` full-suite execution under MTP); feature moved to `docs/features/checked/platform/sbom-analytics-lake.md`. |
|
||||
| QA-PLATFORM-VERIFY-006 | DONE | run-003 remediation/retest verified scanner event ingestion parity: `scanner.scan.completed` handling, DSSE scanner payload parsing, and checkpoint-resume semantics are implemented and passing (`185/185` Tier 1, `38/38` Tier 2); feature moved to `docs/features/checked/platform/scanner-platform-events.md`. |
|
||||
| AUDIT-0761-M | DONE | TreatWarningsAsErrors=true (MAINT complete). |
|
||||
| AUDIT-0761-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0761-A | DONE | Already compliant with TreatWarningsAsErrors. |
|
||||
@@ -17,3 +23,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| TASK-030-013 | BLOCKED | Attestation coverage view delivered; validation blocked pending ingestion datasets. |
|
||||
| TASK-030-017 | BLOCKED | Stored procedures delivered; validation blocked pending ingestion datasets. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Platform.Analytics.Models;
|
||||
using StellaOps.Platform.Analytics.Options;
|
||||
using StellaOps.Platform.Analytics.Services;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.Analytics.Tests;
|
||||
|
||||
public sealed class ScannerPlatformEventsBehaviorTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void IsSupportedScannerEventKind_RecognizesReportReadyAndScanCompleted()
|
||||
{
|
||||
Assert.True(AnalyticsIngestionService.IsSupportedScannerEventKind(OrchestratorEventKinds.ScannerReportReady));
|
||||
Assert.True(AnalyticsIngestionService.IsSupportedScannerEventKind(OrchestratorEventKinds.ScannerScanCompleted));
|
||||
Assert.False(AnalyticsIngestionService.IsSupportedScannerEventKind("scanner.unknown"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryExtractDssePayload_DecodesPayload()
|
||||
{
|
||||
var payloadJson = JsonSerializer.Serialize(CreateReportReadyPayload());
|
||||
var dsseElement = ToElement(new
|
||||
{
|
||||
payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson)),
|
||||
payloadType = "application/vnd.in-toto+json"
|
||||
});
|
||||
|
||||
Assert.True(AnalyticsIngestionService.TryExtractDssePayload(
|
||||
dsseElement,
|
||||
out var payloadBytes,
|
||||
out var payloadType));
|
||||
Assert.Equal("application/vnd.in-toto+json", payloadType);
|
||||
Assert.Equal(payloadJson, Encoding.UTF8.GetString(payloadBytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDeserializeScannerPayload_ReportReadyDsseEnvelope_ParsesReportReadyPayload()
|
||||
{
|
||||
var expected = CreateReportReadyPayload();
|
||||
var payloadJson = JsonSerializer.Serialize(expected);
|
||||
var dsseElement = ToElement(new
|
||||
{
|
||||
payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson)),
|
||||
payloadType = "application/vnd.in-toto+json"
|
||||
});
|
||||
|
||||
var result = AnalyticsIngestionService.TryDeserializeScannerPayload(
|
||||
dsseElement,
|
||||
OrchestratorEventKinds.ScannerReportReady,
|
||||
SerializerOptions,
|
||||
out var parsed);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(expected.ReportId, parsed.ReportId);
|
||||
Assert.Equal(expected.ImageDigest, parsed.ImageDigest);
|
||||
Assert.NotNull(parsed.Report.Surface);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDeserializeScannerPayload_ScanCompletedPayload_MapsToReportReadyPayload()
|
||||
{
|
||||
var source = CreateReportReadyPayload();
|
||||
var completedElement = ToElement(new ScanCompletedEventPayload
|
||||
{
|
||||
ScanId = "scan-completed-1",
|
||||
ReportId = source.ReportId,
|
||||
ImageDigest = source.ImageDigest,
|
||||
GeneratedAt = source.GeneratedAt,
|
||||
Report = source.Report
|
||||
});
|
||||
|
||||
var result = AnalyticsIngestionService.TryDeserializeScannerPayload(
|
||||
completedElement,
|
||||
OrchestratorEventKinds.ScannerScanCompleted,
|
||||
SerializerOptions,
|
||||
out var parsed);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal("scan-completed-1", parsed.ScanId);
|
||||
Assert.Equal(source.ReportId, parsed.ReportId);
|
||||
Assert.Equal(source.ImageDigest, parsed.ImageDigest);
|
||||
Assert.NotNull(parsed.Report.Surface);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveScannerSubscriptionPosition_UsesCheckpointWhenPresent()
|
||||
{
|
||||
var position = AnalyticsIngestionService.ResolveScannerSubscriptionPosition(
|
||||
startFromBeginning: false,
|
||||
resumeFromCheckpoint: true,
|
||||
checkpointEntryId: "1739244123456-0");
|
||||
|
||||
Assert.Equal("1739244123456-0", position.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveScannerSubscriptionPosition_StartFromBeginningOverridesCheckpoint()
|
||||
{
|
||||
var position = AnalyticsIngestionService.ResolveScannerSubscriptionPosition(
|
||||
startFromBeginning: true,
|
||||
resumeFromCheckpoint: true,
|
||||
checkpointEntryId: "1739244123456-0");
|
||||
|
||||
Assert.Equal("0", position.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(" ", null)]
|
||||
[InlineData("$", null)]
|
||||
[InlineData("0", null)]
|
||||
[InlineData("1739244123456-0", "1739244123456-0")]
|
||||
public void NormalizeScannerCheckpointEntryId_NormalizesExpectedValues(string? input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, AnalyticsIngestionService.NormalizeScannerCheckpointEntryId(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveScannerCheckpointPath_UsesConfiguredRelativePathWithCasRoot()
|
||||
{
|
||||
var streamOptions = new AnalyticsStreamOptions
|
||||
{
|
||||
ResumeFromCheckpoint = true,
|
||||
ScannerCheckpointFilePath = "checkpoints/scanner.position"
|
||||
};
|
||||
var casOptions = new AnalyticsCasOptions
|
||||
{
|
||||
RootPath = "/var/lib/stellaops/cas"
|
||||
};
|
||||
|
||||
var path = AnalyticsIngestionService.ResolveScannerCheckpointPath(streamOptions, casOptions);
|
||||
|
||||
Assert.Equal(
|
||||
System.IO.Path.Combine("/var/lib/stellaops/cas", "checkpoints/scanner.position"),
|
||||
path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveScannerCheckpointPath_UsesDefaultPathWhenOnlyCasRootConfigured()
|
||||
{
|
||||
var streamOptions = new AnalyticsStreamOptions
|
||||
{
|
||||
ResumeFromCheckpoint = true
|
||||
};
|
||||
var casOptions = new AnalyticsCasOptions
|
||||
{
|
||||
RootPath = "/var/lib/stellaops/cas"
|
||||
};
|
||||
|
||||
var path = AnalyticsIngestionService.ResolveScannerCheckpointPath(streamOptions, casOptions);
|
||||
|
||||
Assert.Equal(
|
||||
System.IO.Path.Combine("/var/lib/stellaops/cas", ".state", "platform-scanner-stream.checkpoint"),
|
||||
path);
|
||||
}
|
||||
|
||||
private static ReportReadyEventPayload CreateReportReadyPayload()
|
||||
{
|
||||
return new ReportReadyEventPayload
|
||||
{
|
||||
ReportId = "report-001",
|
||||
ScanId = "scan-001",
|
||||
ImageDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
GeneratedAt = new DateTimeOffset(2026, 2, 11, 12, 0, 0, TimeSpan.Zero),
|
||||
Report = new ReportDocumentPayload
|
||||
{
|
||||
ReportId = "report-001",
|
||||
ImageDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
GeneratedAt = new DateTimeOffset(2026, 2, 11, 12, 0, 0, TimeSpan.Zero),
|
||||
Surface = new SurfacePointersPayload
|
||||
{
|
||||
Tenant = "tenant-a",
|
||||
GeneratedAt = new DateTimeOffset(2026, 2, 11, 12, 0, 0, TimeSpan.Zero),
|
||||
ManifestDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
Manifest = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = "tenant-a",
|
||||
GeneratedAt = new DateTimeOffset(2026, 2, 11, 12, 0, 0, TimeSpan.Zero)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement ToElement<T>(T value)
|
||||
{
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(value));
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
@@ -32,4 +34,75 @@ public sealed class QuotaEndpointsTests : IClassFixture<PlatformWebApplicationFa
|
||||
items.Select(item => item.QuotaId).ToArray());
|
||||
Assert.Equal(77000m, items[0].Remaining);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task QuotaAlerts_CreateAndList_AreTenantScoped()
|
||||
{
|
||||
var tenantId = "tenant-quotas-alerts-a";
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-quotas-alerts");
|
||||
|
||||
var createRequest = new PlatformQuotaAlertRequest(
|
||||
QuotaId: "gateway.requests",
|
||||
Threshold: 85m,
|
||||
Condition: "gt",
|
||||
Severity: "high");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/quotas/alerts",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var createdAlert = await createResponse.Content.ReadFromJsonAsync<PlatformQuotaAlert>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(createdAlert);
|
||||
Assert.Equal("gateway.requests", createdAlert!.QuotaId);
|
||||
Assert.Equal("high", createdAlert.Severity);
|
||||
Assert.Equal("actor-quotas-alerts", createdAlert.CreatedBy);
|
||||
|
||||
var listForSameTenant = await client.GetFromJsonAsync<PlatformListResponse<PlatformQuotaAlert>>(
|
||||
"/api/v1/platform/quotas/alerts",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(listForSameTenant);
|
||||
Assert.Contains(
|
||||
listForSameTenant!.Items,
|
||||
alert => string.Equals(alert.AlertId, createdAlert.AlertId, StringComparison.Ordinal));
|
||||
|
||||
client.DefaultRequestHeaders.Remove("X-StellaOps-Tenant");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-quotas-alerts-b");
|
||||
|
||||
var listForOtherTenant = await client.GetFromJsonAsync<PlatformListResponse<PlatformQuotaAlert>>(
|
||||
"/api/v1/platform/quotas/alerts",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(listForOtherTenant);
|
||||
Assert.DoesNotContain(
|
||||
listForOtherTenant!.Items,
|
||||
alert => string.Equals(alert.AlertId, createdAlert.AlertId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task QuotaAlerts_RejectMissingQuotaId()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-quotas-alerts-validation");
|
||||
|
||||
var invalidRequest = new PlatformQuotaAlertRequest(
|
||||
QuotaId: " ",
|
||||
Threshold: 90m,
|
||||
Condition: "gt",
|
||||
Severity: "critical");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/quotas/alerts",
|
||||
invalidRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("quotaId is required.", body!["error"]?.GetValue<string>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,41 @@ public sealed class SearchEndpointsTests : IClassFixture<PlatformWebApplicationF
|
||||
},
|
||||
items);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Search_AppliesSourceFilterAndPagination()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-search-filtered");
|
||||
|
||||
var response = await client.GetFromJsonAsync<PlatformListResponse<PlatformSearchItem>>(
|
||||
"/api/v1/platform/search?sources=scanner,findings&limit=1&offset=1",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(response);
|
||||
Assert.Equal(2, response!.Count);
|
||||
Assert.Equal(1, response.Limit);
|
||||
Assert.Equal(1, response.Offset);
|
||||
var singleItem = Assert.Single(response.Items);
|
||||
Assert.Equal("finding-cve-2025-1001", singleItem.EntityId);
|
||||
Assert.Equal("findings", singleItem.Source);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Search_AliasEndpointHonorsQueryFilter()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-search-alias");
|
||||
|
||||
var response = await client.GetFromJsonAsync<PlatformListResponse<PlatformSearchItem>>(
|
||||
"/api/v1/search?q=tenant",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(response);
|
||||
var item = Assert.Single(response!.Items);
|
||||
Assert.Equal("tenant-acme", item.EntityId);
|
||||
Assert.Equal("authority", item.Source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class SetupEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public SetupEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SetupWorkflow_CreateResumeExecuteSkipFinalizeAndDefinitions_Passes()
|
||||
{
|
||||
var tenantId = $"tenant-setup-{Guid.NewGuid():N}";
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "setup-tester");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/setup/sessions",
|
||||
new CreateSetupSessionRequest(),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<SetupSessionResponse>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal(SetupSessionStatus.InProgress, created!.Session.Status);
|
||||
var sessionId = created.Session.SessionId;
|
||||
|
||||
var resumeResponse = await client.PostAsync(
|
||||
"/api/v1/setup/sessions/resume",
|
||||
content: null,
|
||||
TestContext.Current.CancellationToken);
|
||||
resumeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var resumed = await resumeResponse.Content.ReadFromJsonAsync<SetupSessionResponse>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(resumed);
|
||||
Assert.Equal(sessionId, resumed!.Session.SessionId);
|
||||
|
||||
var definitions = await client.GetFromJsonAsync<SetupStepDefinitionsResponse>(
|
||||
"/api/v1/setup/definitions/steps",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(definitions);
|
||||
Assert.NotEmpty(definitions!.Steps);
|
||||
Assert.True(
|
||||
definitions.Steps.Select(step => step.OrderIndex)
|
||||
.SequenceEqual(definitions.Steps.Select(step => step.OrderIndex).OrderBy(i => i)));
|
||||
|
||||
var requiredFlow = new[]
|
||||
{
|
||||
SetupStepId.Database,
|
||||
SetupStepId.Valkey,
|
||||
SetupStepId.Migrations,
|
||||
SetupStepId.Admin,
|
||||
SetupStepId.Crypto
|
||||
};
|
||||
|
||||
foreach (var step in requiredFlow)
|
||||
{
|
||||
var executeResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/setup/steps/execute",
|
||||
new ExecuteSetupStepRequest(step),
|
||||
TestContext.Current.CancellationToken);
|
||||
executeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var executed = await executeResponse.Content.ReadFromJsonAsync<ExecuteSetupStepResponse>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(executed);
|
||||
Assert.True(executed!.Success);
|
||||
Assert.Equal(SetupStepStatus.Passed, executed.StepState.Status);
|
||||
}
|
||||
|
||||
var skipResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/setup/steps/skip",
|
||||
new SkipSetupStepRequest(SetupStepId.Llm, "llm provider deferred"),
|
||||
TestContext.Current.CancellationToken);
|
||||
skipResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var skipped = await skipResponse.Content.ReadFromJsonAsync<SetupSessionResponse>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(skipped);
|
||||
var llmStep = skipped!.Session.Steps.First(step => step.StepId == SetupStepId.Llm);
|
||||
Assert.Equal(SetupStepStatus.Skipped, llmStep.Status);
|
||||
|
||||
var finalizeResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/setup/sessions/finalize",
|
||||
new FinalizeSetupSessionRequest(),
|
||||
TestContext.Current.CancellationToken);
|
||||
finalizeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var finalized = await finalizeResponse.Content.ReadFromJsonAsync<FinalizeSetupSessionResponse>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(finalized);
|
||||
Assert.Equal(SetupSessionStatus.CompletedPartial, finalized!.FinalStatus);
|
||||
Assert.Contains(finalized.SkippedSteps, step => step.StepId == SetupStepId.Llm);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SkipRequiredStep_ReturnsProblemDetails()
|
||||
{
|
||||
var tenantId = $"tenant-setup-required-{Guid.NewGuid():N}";
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "setup-tester");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/setup/sessions",
|
||||
new CreateSetupSessionRequest(),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var skipResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/setup/steps/skip",
|
||||
new SkipSetupStepRequest(SetupStepId.Database, "should fail"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, skipResponse.StatusCode);
|
||||
var problem = await skipResponse.Content.ReadFromJsonAsync<ProblemDetails>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("Invalid Operation", problem!.Title);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user