chore(sprints): archive 20260226 advisories and expand deterministic tests
This commit is contained in:
@@ -44,4 +44,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| SPRINT_20260224_004-LOC-306 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added dedicated `/settings/language` UX wiring that reuses Platform persisted language preference API for authenticated users. |
|
||||
| SPRINT_20260224_004-LOC-307 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added Ukrainian locale support (`uk-UA`) across Platform translation assets and preference normalization aliases (`uk-UA`/`uk_UA`/`uk`/`ua`). |
|
||||
| SPRINT_20260224_004-LOC-308 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: platform locale catalog endpoint (`GET /api/v1/platform/localization/locales`) is now consumed by both UI and CLI locale-selection paths. |
|
||||
| SPRINT_20260226_230-LOC-001 | DONE | Sprint `docs-archived/implplan/2026-03-03-completed-sprints/SPRINT_20260226_230_Platform_locale_label_translation_corrections.md`: completed non-English translation correction across Platform/Web/shared localization bundles (`bg-BG`, `de-DE`, `es-ES`, `fr-FR`, `ru-RU`, `uk-UA`, `zh-CN`, `zh-TW`), including cleanup of placeholder/transliteration/malformed values (`Ezik`, leaked token markers, mojibake) and a context-quality pass for backend German resource bundles (`graph`, `policy`, `scanner`, `advisoryai`). |
|
||||
| PLATFORM-223-001 | DONE | Sprint `docs-archived/implplan/2026-03-03-completed-sprints/SPRINT_20260226_223_Platform_score_explain_contract_and_replay_alignment.md`: shipped deterministic score explain/replay contract completion (`unknowns`, `proof_ref`, deterministic replay envelope parsing/verification differences) and updated score API/module docs with contract notes. |
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ScoreExplainEndpointContractTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public ScoreExplainEndpointContractTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Explain_WhenDigestExists_ReturnsDeterministicContract()
|
||||
{
|
||||
using var deterministicFactory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IScoreEvaluationService>();
|
||||
services.AddSingleton<IScoreEvaluationService, StaticScoreEvaluationService>();
|
||||
});
|
||||
});
|
||||
|
||||
using var client = CreateClient(deterministicFactory, "tenant-score-explain-valid");
|
||||
const string digest = "sha256:abc123";
|
||||
|
||||
var explainResponseA = await client.GetAsync(
|
||||
"/api/v1/score/explain/SHA256:ABC123",
|
||||
TestContext.Current.CancellationToken);
|
||||
explainResponseA.EnsureSuccessStatusCode();
|
||||
|
||||
using var explainA = JsonDocument.Parse(
|
||||
await explainResponseA.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
var itemA = GetAnyProperty(explainA.RootElement, "item", "Item");
|
||||
|
||||
Assert.Equal("score.explain.v1", GetAnyProperty(itemA, "contract_version", "contractVersion", "ContractVersion").GetString());
|
||||
Assert.Equal(digest, GetAnyProperty(itemA, "digest", "Digest").GetString());
|
||||
Assert.Equal(digest.ToLowerInvariant(), GetAnyProperty(itemA, "deterministic_input_hash", "deterministicInputHash", "DeterministicInputHash").GetString());
|
||||
Assert.True(GetAnyProperty(itemA, "factors", "Factors").GetArrayLength() > 0);
|
||||
Assert.True(GetAnyProperty(itemA, "sources", "Sources").GetArrayLength() > 0);
|
||||
|
||||
var explainResponseB = await client.GetAsync(
|
||||
$"/api/v1/score/explain/{Uri.EscapeDataString(digest)}",
|
||||
TestContext.Current.CancellationToken);
|
||||
explainResponseB.EnsureSuccessStatusCode();
|
||||
|
||||
using var explainB = JsonDocument.Parse(
|
||||
await explainResponseB.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
var itemB = GetAnyProperty(explainB.RootElement, "item", "Item");
|
||||
|
||||
var itemAJson = JsonSerializer.Serialize(itemA);
|
||||
var itemBJson = JsonSerializer.Serialize(itemB);
|
||||
Assert.Equal(itemAJson, itemBJson);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Explain_WhenDigestOmitsAlgorithm_NormalizesToSha256Deterministically()
|
||||
{
|
||||
using var deterministicFactory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IScoreEvaluationService>();
|
||||
services.AddSingleton<IScoreEvaluationService, StaticScoreEvaluationService>();
|
||||
});
|
||||
});
|
||||
|
||||
using var client = CreateClient(deterministicFactory, "tenant-score-explain-normalized");
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/score/explain/ABC123",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
var item = GetAnyProperty(payload.RootElement, "item", "Item");
|
||||
Assert.Equal("sha256:abc123", GetAnyProperty(item, "digest", "Digest").GetString());
|
||||
Assert.Equal("sha256:abc123", GetAnyProperty(item, "deterministic_input_hash", "deterministicInputHash", "DeterministicInputHash").GetString());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Explain_WhenDigestMissing_ReturnsDeterministicNotFound()
|
||||
{
|
||||
using var client = CreateClient(_factory, "tenant-score-explain-not-found");
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/score/explain/sha256:does-not-exist",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
var error = await response.Content.ReadFromJsonAsync<ScoreExplainErrorResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("not_found", error!.Code);
|
||||
Assert.Equal("sha256:does-not-exist", error.Digest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Explain_WhenDigestInvalid_ReturnsDeterministicInvalidInput()
|
||||
{
|
||||
using var client = CreateClient(_factory, "tenant-score-explain-invalid");
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/score/explain/%20",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var error = await response.Content.ReadFromJsonAsync<ScoreExplainErrorResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_input", error!.Code);
|
||||
Assert.Equal(" ", error.Digest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Explain_WhenDigestHasMissingHashSegment_ReturnsDeterministicInvalidInput()
|
||||
{
|
||||
using var client = CreateClient(_factory, "tenant-score-explain-invalid-segment");
|
||||
|
||||
var response = await client.GetAsync(
|
||||
$"/api/v1/score/explain/{Uri.EscapeDataString("sha256:")}",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var error = await response.Content.ReadFromJsonAsync<ScoreExplainErrorResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_input", error!.Code);
|
||||
Assert.Equal("sha256:", error.Digest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Explain_WhenBackendUnavailable_ReturnsDeterministicBackendUnavailable()
|
||||
{
|
||||
using var backendUnavailableFactory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IScoreEvaluationService>();
|
||||
services.AddSingleton<IScoreEvaluationService, ThrowingScoreEvaluationService>();
|
||||
});
|
||||
});
|
||||
|
||||
using var client = CreateClient(backendUnavailableFactory, "tenant-score-explain-backend");
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/score/explain/sha256:abc123",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
var error = await response.Content.ReadFromJsonAsync<ScoreExplainErrorResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("backend_unavailable", error!.Code);
|
||||
Assert.Equal("sha256:abc123", error.Digest);
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(WebApplicationFactory<StellaOps.Platform.WebService.Options.PlatformServiceOptions> factory, string tenantId)
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "score-test-actor");
|
||||
return client;
|
||||
}
|
||||
|
||||
private sealed class ThrowingScoreEvaluationService : IScoreEvaluationService
|
||||
{
|
||||
public Task<PlatformCacheResult<ScoreEvaluateResponse>> EvaluateAsync(PlatformRequestContext context, ScoreEvaluateRequest request, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<ScoreEvaluateResponse?>> GetByIdAsync(PlatformRequestContext context, string scoreId, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<ScoreExplainResponse?>> GetExplanationAsync(PlatformRequestContext context, string digest, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("backend unavailable");
|
||||
|
||||
public Task<PlatformCacheResult<IReadOnlyList<WeightManifestSummary>>> ListWeightManifestsAsync(PlatformRequestContext context, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<WeightManifestDetail?>> GetWeightManifestAsync(PlatformRequestContext context, string version, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<WeightManifestDetail?>> GetEffectiveWeightManifestAsync(PlatformRequestContext context, DateTimeOffset asOf, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<IReadOnlyList<ScoreHistoryRecord>>> GetHistoryAsync(PlatformRequestContext context, string cveId, string? purl, int limit, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<ScoreReplayResponse?>> GetReplayAsync(PlatformRequestContext context, string scoreId, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<ScoreVerifyResponse>> VerifyReplayAsync(PlatformRequestContext context, ScoreVerifyRequest request, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class StaticScoreEvaluationService : IScoreEvaluationService
|
||||
{
|
||||
private static readonly ScoreExplainResponse Response = new()
|
||||
{
|
||||
ContractVersion = "score.explain.v1",
|
||||
Digest = "sha256:abc123",
|
||||
ScoreId = "score_abc123",
|
||||
FinalScore = 62,
|
||||
Bucket = "Investigate",
|
||||
ComputedAt = DateTimeOffset.Parse("2026-02-26T12:00:00Z"),
|
||||
DeterministicInputHash = "sha256:abc123",
|
||||
ReplayLink = "/api/v1/score/score_abc123/replay",
|
||||
Factors =
|
||||
[
|
||||
new ScoreExplainFactor
|
||||
{
|
||||
Name = "reachability",
|
||||
Weight = 0.25,
|
||||
Value = 1.0,
|
||||
Contribution = 0.25
|
||||
}
|
||||
],
|
||||
Sources =
|
||||
[
|
||||
new ScoreExplainSource
|
||||
{
|
||||
SourceType = "score_history",
|
||||
SourceRef = "score-history:score_abc123",
|
||||
SourceDigest = "sha256:abc123"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
public Task<PlatformCacheResult<ScoreEvaluateResponse>> EvaluateAsync(PlatformRequestContext context, ScoreEvaluateRequest request, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<ScoreEvaluateResponse?>> GetByIdAsync(PlatformRequestContext context, string scoreId, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<ScoreExplainResponse?>> GetExplanationAsync(PlatformRequestContext context, string digest, CancellationToken ct = default)
|
||||
{
|
||||
var normalized = digest.ToLowerInvariant();
|
||||
var value = string.Equals(normalized, "sha256:abc123", StringComparison.Ordinal)
|
||||
? Response
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new PlatformCacheResult<ScoreExplainResponse?>(
|
||||
value,
|
||||
DateTimeOffset.Parse("2026-02-26T12:00:00Z"),
|
||||
Cached: true,
|
||||
CacheTtlSeconds: 300));
|
||||
}
|
||||
|
||||
public Task<PlatformCacheResult<IReadOnlyList<WeightManifestSummary>>> ListWeightManifestsAsync(PlatformRequestContext context, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<WeightManifestDetail?>> GetWeightManifestAsync(PlatformRequestContext context, string version, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<WeightManifestDetail?>> GetEffectiveWeightManifestAsync(PlatformRequestContext context, DateTimeOffset asOf, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<IReadOnlyList<ScoreHistoryRecord>>> GetHistoryAsync(PlatformRequestContext context, string cveId, string? purl, int limit, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<ScoreReplayResponse?>> GetReplayAsync(PlatformRequestContext context, string scoreId, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<PlatformCacheResult<ScoreVerifyResponse>> VerifyReplayAsync(PlatformRequestContext context, ScoreVerifyRequest request, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private static JsonElement GetAnyProperty(JsonElement element, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (element.TryGetProperty(name, out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
throw new KeyNotFoundException($"None of the expected properties [{string.Join(", ", names)}] were found.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user