save checkpoint: save features

This commit is contained in:
master
2026-02-12 10:27:23 +02:00
parent dca86e1248
commit 5bca406787
8837 changed files with 1796879 additions and 5294 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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