semi implemented and features implemented save checkpoint

This commit is contained in:
master
2026-02-08 18:00:49 +02:00
parent 04360dff63
commit 1bf6bbf395
20895 changed files with 716795 additions and 64 deletions

View File

@@ -0,0 +1,35 @@
using FluentAssertions;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Scanner.Evidence.Tests;
public sealed class AttestationIdempotencyKeyTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FromDsseEnvelope_SamePayload_ReturnsSameKey()
{
var payload = "{\"payloadType\":\"verdict.stella/v1\",\"payload\":\"e30=\"}"u8.ToArray();
var key1 = AttestationIdempotencyKey.FromDsseEnvelope(payload);
var key2 = AttestationIdempotencyKey.FromDsseEnvelope(payload);
key1.Should().Be(key2);
key1.Should().StartWith("sha256:");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ToOciTag_ProducesStableSafeTag()
{
const string key = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
var tag = AttestationIdempotencyKey.ToOciTag(key);
tag.Should().StartWith("verdict-");
tag.Should().NotContain(":");
tag.Length.Should().BeLessThanOrEqualTo(128);
}
}

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Evidence.Tests/StellaOps.Scanner.Evidence.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Implement idempotent verdict attestation submission (idempotency key + dedupe + retry classification + tests). |

View File

@@ -54,14 +54,65 @@ public sealed class OciArtifactPusherTests
Assert.True(annotations.TryGetProperty("org.opencontainers.image.created", out _));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PushAsync_ExistingTag_ReturnsAlreadyExistsWithoutManifestPut()
{
var handler = new TestRegistryHandler
{
ManifestAlreadyExists = true,
ExistingManifestDigest = "sha256:existingmanifestdigest"
};
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance);
var request = new OciArtifactPushRequest
{
Reference = "registry.example/stellaops/delta:demo",
ArtifactType = OciMediaTypes.DeltaVerdictPredicate,
Tag = "verdict-fixed-tag",
Layers =
[
new OciLayerContent { Content = new byte[] { 0x01, 0x02 }, MediaType = OciMediaTypes.DsseEnvelope }
]
};
var result = await pusher.PushAsync(request);
Assert.True(result.Success);
Assert.True(result.AlreadyExists);
Assert.Equal("sha256:existingmanifestdigest", result.ManifestDigest);
Assert.Equal(0, handler.ManifestPutCount);
}
private sealed class TestRegistryHandler : HttpMessageHandler
{
public bool ManifestAlreadyExists { get; set; }
public string ExistingManifestDigest { get; set; } = "sha256:existing";
public int ManifestPutCount { get; private set; }
public byte[]? ManifestBytes { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.Contains("/manifests/", StringComparison.Ordinal))
{
if (!ManifestAlreadyExists)
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Headers.TryAddWithoutValidation("Docker-Content-Digest", ExistingManifestDigest);
return response;
}
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
@@ -81,6 +132,7 @@ public sealed class OciArtifactPusherTests
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
{
ManifestPutCount++;
ManifestBytes = request.Content is null ? null : await request.Content.ReadAsByteArrayAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.Created);
}

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Storage.Oci.Tests/StellaOps.Scanner.Storage.Oci.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Implement idempotent verdict attestation submission (idempotency key + dedupe + retry classification + tests). |

View File

@@ -188,6 +188,41 @@ public sealed class VerdictOciPublisherTests
annotations.GetProperty(OciAnnotations.StellaGraphRevisionId).GetString());
Assert.Equal("sha256:proof_bundle_value",
annotations.GetProperty(OciAnnotations.StellaProofBundleDigest).GetString());
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaIdempotencyKey, out var idempotencyKey));
Assert.StartsWith("sha256:", idempotencyKey.GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PushAsync_UsesStableIdempotencyTag()
{
var handler = new TestRegistryHandler();
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var request = new VerdictOciPublishRequest
{
Reference = "registry.example/stellaops/app",
ImageDigest = "sha256:image123",
DsseEnvelopeBytes = "{\"payload\":\"aGVsbG8=\"}"u8.ToArray(),
SbomDigest = "sha256:sbom",
FeedsDigest = "sha256:feeds",
PolicyDigest = "sha256:policy",
Decision = "pass"
};
var result = await verdictPublisher.PushAsync(request);
Assert.True(result.Success);
Assert.NotNull(handler.ManifestPutPath);
Assert.Contains("/manifests/verdict-", handler.ManifestPutPath, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
@@ -304,12 +339,18 @@ public sealed class VerdictOciPublisherTests
private sealed class TestRegistryHandler : HttpMessageHandler
{
public string? ManifestPutPath { get; private set; }
public byte[]? ManifestBytes { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.Contains("/manifests/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
@@ -329,6 +370,7 @@ public sealed class VerdictOciPublisherTests
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
{
ManifestPutPath = path;
ManifestBytes = request.Content is null ? null : await request.Content.ReadAsByteArrayAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.Created);
}

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/StellaOps.Scanner.Worker.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Implement idempotent verdict attestation submission (idempotency key + dedupe + retry classification + tests). |

View File

@@ -0,0 +1,179 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Scanner.Storage.Oci;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class VerdictPushStageExecutorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_PushSuccess_StoresManifestAndIdempotencyKey()
{
var publisher = new Mock<IVerdictOciPublisher>(MockBehavior.Strict);
publisher
.Setup(p => p.PushAsync(It.IsAny<VerdictOciPublishRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OciArtifactPushResult
{
Success = true,
ManifestDigest = "sha256:manifest-a",
ManifestReference = "registry.example/app@sha256:manifest-a",
LayerDigests = Array.Empty<string>()
});
var options = CreateOptions(maxRetries: 0);
var executor = new VerdictPushStageExecutor(
publisher.Object,
new StaticOptionsMonitor<ScannerWorkerOptions>(options),
NullLogger<VerdictPushStageExecutor>.Instance);
var context = CreateContext("job-a", "scan-a");
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictDsseEnvelope, "{\"payload\":\"e30=\"}"u8.ToArray());
await executor.ExecuteAsync(context, TestContext.Current.CancellationToken);
Assert.True(context.Analysis.TryGet<string>(VerdictPushAnalysisKeys.VerdictManifestDigest, out var digest));
Assert.Equal("sha256:manifest-a", digest);
Assert.True(context.Analysis.TryGet<string>(VerdictPushAnalysisKeys.VerdictIdempotencyKey, out var idempotencyKey));
Assert.StartsWith("sha256:", idempotencyKey);
publisher.Verify(
p => p.PushAsync(
It.Is<VerdictOciPublishRequest>(r => !string.IsNullOrWhiteSpace(r.IdempotencyKey)),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_TransientFailure_RetriesAndSucceeds()
{
var publisher = new Mock<IVerdictOciPublisher>(MockBehavior.Strict);
publisher
.SetupSequence(p => p.PushAsync(It.IsAny<VerdictOciPublishRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(OciArtifactPushResult.Failed("503 Service Unavailable"))
.ReturnsAsync(new OciArtifactPushResult
{
Success = true,
ManifestDigest = "sha256:manifest-b",
ManifestReference = "registry.example/app@sha256:manifest-b",
LayerDigests = Array.Empty<string>()
});
var options = CreateOptions(maxRetries: 1);
var executor = new VerdictPushStageExecutor(
publisher.Object,
new StaticOptionsMonitor<ScannerWorkerOptions>(options),
NullLogger<VerdictPushStageExecutor>.Instance);
var context = CreateContext("job-b", "scan-b");
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictDsseEnvelope, "{\"payload\":\"Zm9v\"}"u8.ToArray());
await executor.ExecuteAsync(context, TestContext.Current.CancellationToken);
Assert.True(context.Analysis.TryGet<string>(VerdictPushAnalysisKeys.VerdictManifestDigest, out var digest));
Assert.Equal("sha256:manifest-b", digest);
publisher.Verify(p => p.PushAsync(It.IsAny<VerdictOciPublishRequest>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_SameEnvelope_SecondCallUsesReceiptCache()
{
var publisher = new Mock<IVerdictOciPublisher>(MockBehavior.Strict);
publisher
.Setup(p => p.PushAsync(It.IsAny<VerdictOciPublishRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OciArtifactPushResult
{
Success = true,
ManifestDigest = "sha256:manifest-c",
ManifestReference = "registry.example/app@sha256:manifest-c",
LayerDigests = Array.Empty<string>()
});
var options = CreateOptions(maxRetries: 0);
var executor = new VerdictPushStageExecutor(
publisher.Object,
new StaticOptionsMonitor<ScannerWorkerOptions>(options),
NullLogger<VerdictPushStageExecutor>.Instance);
var envelope = "{\"payload\":\"YmFy\"}"u8.ToArray();
var first = CreateContext("job-c1", "scan-c1");
first.Analysis.Set(VerdictPushAnalysisKeys.VerdictDsseEnvelope, envelope);
await executor.ExecuteAsync(first, TestContext.Current.CancellationToken);
var second = CreateContext("job-c2", "scan-c2");
second.Analysis.Set(VerdictPushAnalysisKeys.VerdictDsseEnvelope, envelope);
await executor.ExecuteAsync(second, TestContext.Current.CancellationToken);
Assert.True(second.Analysis.TryGet<string>(VerdictPushAnalysisKeys.VerdictManifestDigest, out var digest));
Assert.Equal("sha256:manifest-c", digest);
publisher.Verify(p => p.PushAsync(It.IsAny<VerdictOciPublishRequest>(), It.IsAny<CancellationToken>()), Times.Once);
}
private static ScannerWorkerOptions CreateOptions(int maxRetries)
{
var options = new ScannerWorkerOptions();
options.VerdictPush.MaxRetries = maxRetries;
return options;
}
private static ScanJobContext CreateContext(string jobId, string scanId)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[VerdictPushMetadataKeys.Enabled] = "true",
[VerdictPushMetadataKeys.RegistryReference] = "registry.example/team/app",
[VerdictPushMetadataKeys.Decision] = "pass",
["image.digest"] = "sha256:image"
};
var now = new DateTimeOffset(2026, 2, 8, 12, 0, 0, TimeSpan.Zero);
var timeProvider = new FixedTimeProvider(now);
var lease = new StubLease(jobId, scanId, now, metadata);
return new ScanJobContext(lease, timeProvider, now, TestContext.Current.CancellationToken);
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
private sealed class StubLease : IScanJobLease
{
private readonly IReadOnlyDictionary<string, string> _metadata;
public StubLease(string jobId, string scanId, DateTimeOffset now, IReadOnlyDictionary<string, string> metadata)
{
JobId = jobId;
ScanId = scanId;
_metadata = metadata;
EnqueuedAtUtc = now.AddMinutes(-1);
LeasedAtUtc = now;
}
public string JobId { get; }
public string ScanId { get; }
public int Attempt { get; } = 1;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration { get; } = TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata => _metadata;
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}