save checkpoint: save features
This commit is contained in:
@@ -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