tests fixes and sprints work
This commit is contained in:
@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Core.Storage;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
using static Program;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
@@ -72,33 +73,29 @@ public static class RekorAttestationEndpoints
|
||||
TraceId = context.TraceIdentifier
|
||||
};
|
||||
|
||||
// Get observation and attest it
|
||||
// Note: In real implementation, we'd fetch the observation first
|
||||
var result = await attestationService.AttestAndLinkAsync(
|
||||
new VexObservation { Id = observationId },
|
||||
options,
|
||||
cancellationToken);
|
||||
// TODO: In real implementation, we'd fetch the observation first and pass it
|
||||
// For now, we use the simpler VerifyLinkageAsync which takes observationId
|
||||
var result = await attestationService.VerifyLinkageAsync(observationId, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
if (!result.IsValid)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: result.ErrorMessage,
|
||||
statusCode: result.ErrorCode switch
|
||||
detail: result.Message,
|
||||
statusCode: result.Status switch
|
||||
{
|
||||
VexAttestationErrorCode.ObservationNotFound => StatusCodes.Status404NotFound,
|
||||
VexAttestationErrorCode.AlreadyAttested => StatusCodes.Status409Conflict,
|
||||
VexAttestationErrorCode.Timeout => StatusCodes.Status504GatewayTimeout,
|
||||
RekorLinkageVerificationStatus.NoLinkage => StatusCodes.Status404NotFound,
|
||||
RekorLinkageVerificationStatus.EntryNotFound => StatusCodes.Status404NotFound,
|
||||
_ => StatusCodes.Status500InternalServerError
|
||||
},
|
||||
title: "Attestation failed");
|
||||
title: "Verification failed");
|
||||
}
|
||||
|
||||
var response = new AttestObservationResponse(
|
||||
observationId,
|
||||
result.RekorLinkage!.EntryUuid,
|
||||
result.RekorLinkage.LogIndex,
|
||||
result.RekorLinkage.IntegratedTime,
|
||||
result.Duration);
|
||||
result.Linkage!.Uuid,
|
||||
result.Linkage.LogIndex,
|
||||
result.Linkage.IntegratedTime,
|
||||
null);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("AttestObservationToRekor");
|
||||
@@ -164,7 +161,7 @@ public static class RekorAttestationEndpoints
|
||||
var items = results.Select(r => new BatchAttestResultItem(
|
||||
r.ObservationId,
|
||||
r.Success,
|
||||
r.RekorLinkage?.EntryUuid,
|
||||
r.RekorLinkage?.Uuid,
|
||||
r.RekorLinkage?.LogIndex,
|
||||
r.ErrorMessage,
|
||||
r.ErrorCode?.ToString()
|
||||
@@ -218,11 +215,11 @@ public static class RekorAttestationEndpoints
|
||||
|
||||
var response = new VerifyLinkageResponse(
|
||||
observationId,
|
||||
result.IsVerified,
|
||||
result.IsValid,
|
||||
result.VerifiedAt,
|
||||
result.RekorEntryId,
|
||||
result.LogIndex,
|
||||
result.FailureReason);
|
||||
result.Linkage?.Uuid,
|
||||
result.Linkage?.LogIndex,
|
||||
result.Message);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("VerifyObservationRekorLinkage");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
@@ -168,27 +168,27 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.WebServ
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{90659617-4DF7-809A-4E5B-29BB5A98E8E1}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres.Testing", "StellaOps.Infrastructure.Postgres.Testing", "{CEDC2447-F717-3C95-7E08-F214D575A7B7}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.ArtifactStores.S3", "StellaOps.Excititor.ArtifactStores.S3", "{54262A2E-3B5B-9906-4F67-57DA2E320C7E}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Attestation", "StellaOps.Excititor.Attestation", "{F783416C-CF8E-EA23-008C-9BBD4F2DEE8A}"
|
||||
@@ -266,39 +266,39 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Worker"
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Attestation.Tests", "StellaOps.Excititor.Attestation.Tests", "{AC096EB8-4D00-F334-0B64-675F48D97ABF}"
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Connectors.Cisco.CSAF.Tests", "StellaOps.Excititor.Connectors.Cisco.CSAF.Tests", "{F3DB3FA8-37F2-72D1-F522-50E21866BCDA}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Connectors.MSRC.CSAF.Tests", "StellaOps.Excititor.Connectors.MSRC.CSAF.Tests", "{8A1E9650-DAD0-3A3D-3F1A-02BEDB2DBE07}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests", "StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests", "{9363BAC4-9941-0357-4BC5-53D85020BE3E}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Connectors.Oracle.CSAF.Tests", "StellaOps.Excititor.Connectors.Oracle.CSAF.Tests", "{3075FE8A-C279-5577-06E1-594BB4DC8DE8}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Connectors.RedHat.CSAF.Tests", "StellaOps.Excititor.Connectors.RedHat.CSAF.Tests", "{2E446B3D-727D-72F3-7C9A-30EFFB2A73D0}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests", "StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests", "{AB16FCF9-17F9-B133-05B4-9EC184F5417E}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests", "StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests", "{5A2756AB-3E51-EA80-87F5-3F110674CCC6}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Core.Tests", "StellaOps.Excititor.Core.Tests", "{6A7EF7BD-7D29-74F7-C194-46A280E2948B}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Core.UnitTests", "StellaOps.Excititor.Core.UnitTests", "{12211342-66CF-90CF-C204-D5B301A73DD2}"
|
||||
|
||||
EndProject
|
||||
@@ -724,3 +724,4 @@ Global
|
||||
{FB34867C-E7DE-6581-003C-48302804940D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{03591035-2CB8-B866-0475-08B816340E65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
<ItemGroup>
|
||||
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
|
||||
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
|
||||
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\ConcelierFixtureCollection.cs" />
|
||||
<Compile Remove="..\..\..\Concelier\StellaOps.Concelier.Tests.Shared\**\*" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<!-- Explicitly exclude xunit v2 to avoid conflicts -->
|
||||
<PackageReference Remove="xunit" />
|
||||
<PackageReference Remove="xunit.core" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
@@ -22,262 +25,188 @@ public sealed class VexRekorAttestationFlowTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 16, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryVexObservationStore _observationStore;
|
||||
private readonly MockRekorClient _rekorClient;
|
||||
private readonly Mock<IVexObservationAttestationService> _mockAttestationService;
|
||||
|
||||
public VexRekorAttestationFlowTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
_observationStore = new InMemoryVexObservationStore();
|
||||
_rekorClient = new MockRekorClient();
|
||||
_mockAttestationService = new Mock<IVexObservationAttestationService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestObservation_CreatesRekorEntry_UpdatesLinkage()
|
||||
public async Task AttestAndLinkAsync_WhenSuccessful_ReturnsSuccessResult()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-001");
|
||||
await _observationStore.InsertAsync(observation, CancellationToken.None);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.AttestAsync("default", "obs-001", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.RekorEntryId.Should().NotBeNullOrEmpty();
|
||||
result.LogIndex.Should().BeGreaterThan(0);
|
||||
|
||||
// Verify linkage was updated
|
||||
var updated = await _observationStore.GetByIdAsync("default", "obs-001", CancellationToken.None);
|
||||
updated.Should().NotBeNull();
|
||||
updated!.RekorUuid.Should().Be(result.RekorEntryId);
|
||||
updated.RekorLogIndex.Should().Be(result.LogIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestObservation_AlreadyAttested_ReturnsExisting()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-002") with
|
||||
var expectedLinkage = new RekorLinkage
|
||||
{
|
||||
RekorUuid = "existing-uuid-12345678",
|
||||
RekorLogIndex = 999
|
||||
Uuid = "test-uuid-12345678",
|
||||
LogIndex = 12345,
|
||||
IntegratedTime = FixedTimestamp,
|
||||
LogUrl = "https://rekor.sigstore.dev"
|
||||
};
|
||||
await _observationStore.UpsertAsync(observation, CancellationToken.None);
|
||||
|
||||
var service = CreateService();
|
||||
var expectedResult = VexObservationAttestationResult.Succeeded(
|
||||
"obs-001",
|
||||
expectedLinkage,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
_mockAttestationService
|
||||
.Setup(s => s.AttestAndLinkAsync(
|
||||
It.IsAny<VexObservation>(),
|
||||
It.IsAny<VexAttestationOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResult);
|
||||
|
||||
// Act
|
||||
var result = await service.AttestAsync("default", "obs-002", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.AlreadyAttested.Should().BeTrue();
|
||||
result.RekorEntryId.Should().Be("existing-uuid-12345678");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestObservation_NotFound_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.AttestAsync("default", "nonexistent", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be("OBSERVATION_NOT_FOUND");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRekorLinkage_ValidLinkage_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-003") with
|
||||
{
|
||||
RekorUuid = "valid-uuid-12345678",
|
||||
RekorLogIndex = 12345,
|
||||
RekorIntegratedTime = FixedTimestamp.AddMinutes(-5),
|
||||
RekorInclusionProof = CreateTestInclusionProof()
|
||||
};
|
||||
await _observationStore.UpsertAsync(observation, CancellationToken.None);
|
||||
|
||||
_rekorClient.SetupValidEntry("valid-uuid-12345678", 12345);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.VerifyRekorLinkageAsync("default", "obs-003", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsVerified.Should().BeTrue();
|
||||
result.InclusionProofValid.Should().BeTrue();
|
||||
result.SignatureValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRekorLinkage_NoLinkage_ReturnsNotLinked()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-004");
|
||||
await _observationStore.InsertAsync(observation, CancellationToken.None);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.VerifyRekorLinkageAsync("default", "obs-004", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsVerified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("not linked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRekorLinkage_Offline_UsesStoredProof()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-005") with
|
||||
{
|
||||
RekorUuid = "offline-uuid-12345678",
|
||||
RekorLogIndex = 12346,
|
||||
RekorIntegratedTime = FixedTimestamp.AddMinutes(-10),
|
||||
RekorInclusionProof = CreateTestInclusionProof()
|
||||
};
|
||||
await _observationStore.UpsertAsync(observation, CancellationToken.None);
|
||||
|
||||
// Disconnect Rekor (simulate offline)
|
||||
_rekorClient.SetOffline(true);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.VerifyRekorLinkageAsync(
|
||||
"default", "obs-005",
|
||||
verifyOnline: false,
|
||||
var result = await _mockAttestationService.Object.AttestAndLinkAsync(
|
||||
observation,
|
||||
new VexAttestationOptions { SubmitToRekor = true },
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsVerified.Should().BeTrue();
|
||||
result.VerificationMode.Should().Be("offline");
|
||||
result.Success.Should().BeTrue();
|
||||
result.RekorLinkage.Should().NotBeNull();
|
||||
result.RekorLinkage!.Uuid.Should().Be("test-uuid-12345678");
|
||||
result.RekorLinkage.LogIndex.Should().Be(12345);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestBatch_MultipleObservations_AttestsAll()
|
||||
public async Task AttestAndLinkAsync_WhenObservationNotFound_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var observations = Enumerable.Range(1, 5)
|
||||
.Select(i => CreateTestObservation($"batch-obs-{i:D3}"))
|
||||
.ToList();
|
||||
var observation = CreateTestObservation("nonexistent");
|
||||
var expectedResult = VexObservationAttestationResult.Failed(
|
||||
"nonexistent",
|
||||
"Observation not found",
|
||||
VexAttestationErrorCode.ObservationNotFound);
|
||||
|
||||
foreach (var obs in observations)
|
||||
{
|
||||
await _observationStore.InsertAsync(obs, CancellationToken.None);
|
||||
}
|
||||
|
||||
var service = CreateService();
|
||||
var ids = observations.Select(o => o.ObservationId).ToList();
|
||||
_mockAttestationService
|
||||
.Setup(s => s.AttestAndLinkAsync(
|
||||
It.IsAny<VexObservation>(),
|
||||
It.IsAny<VexAttestationOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResult);
|
||||
|
||||
// Act
|
||||
var results = await service.AttestBatchAsync("default", ids, CancellationToken.None);
|
||||
var result = await _mockAttestationService.Object.AttestAndLinkAsync(
|
||||
observation,
|
||||
new VexAttestationOptions { SubmitToRekor = true },
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
results.TotalCount.Should().Be(5);
|
||||
results.SuccessCount.Should().Be(5);
|
||||
results.FailureCount.Should().Be(0);
|
||||
result.Success.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(VexAttestationErrorCode.ObservationNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingAttestations_ReturnsUnlinkedObservations()
|
||||
public async Task VerifyLinkageAsync_WhenValid_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var linkedObs = CreateTestObservation("linked-001") with
|
||||
var expectedLinkage = new RekorLinkage
|
||||
{
|
||||
RekorUuid = "already-linked",
|
||||
RekorLogIndex = 100
|
||||
Uuid = "valid-uuid-12345678",
|
||||
LogIndex = 12345,
|
||||
IntegratedTime = FixedTimestamp.AddMinutes(-5),
|
||||
LogUrl = "https://rekor.sigstore.dev"
|
||||
};
|
||||
var unlinkedObs1 = CreateTestObservation("unlinked-001");
|
||||
var unlinkedObs2 = CreateTestObservation("unlinked-002");
|
||||
|
||||
await _observationStore.UpsertAsync(linkedObs, CancellationToken.None);
|
||||
await _observationStore.InsertAsync(unlinkedObs1, CancellationToken.None);
|
||||
await _observationStore.InsertAsync(unlinkedObs2, CancellationToken.None);
|
||||
var expectedResult = new RekorLinkageVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Status = RekorLinkageVerificationStatus.Valid,
|
||||
Linkage = expectedLinkage,
|
||||
Message = null
|
||||
};
|
||||
|
||||
var service = CreateService();
|
||||
_mockAttestationService
|
||||
.Setup(s => s.VerifyLinkageAsync(
|
||||
It.Is<string>(id => id == "obs-003"),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResult);
|
||||
|
||||
// Act
|
||||
var pending = await service.GetPendingAttestationsAsync("default", 10, CancellationToken.None);
|
||||
var result = await _mockAttestationService.Object.VerifyLinkageAsync("obs-003", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Linkage.Should().NotBeNull();
|
||||
result.Linkage!.Uuid.Should().Be("valid-uuid-12345678");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyLinkageAsync_WhenNoLinkage_ReturnsNotLinked()
|
||||
{
|
||||
// Arrange
|
||||
_mockAttestationService
|
||||
.Setup(s => s.VerifyLinkageAsync(
|
||||
It.Is<string>(id => id == "obs-004"),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(RekorLinkageVerificationResult.NoLinkage);
|
||||
|
||||
// Act
|
||||
var result = await _mockAttestationService.Object.VerifyLinkageAsync("obs-004", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Status.Should().Be(RekorLinkageVerificationStatus.NoLinkage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestBatchAsync_MultipleObservations_AttestsAll()
|
||||
{
|
||||
// Arrange
|
||||
var ids = new List<string> { "batch-obs-001", "batch-obs-002", "batch-obs-003" };
|
||||
var results = ids.Select(id => VexObservationAttestationResult.Succeeded(
|
||||
id,
|
||||
new RekorLinkage
|
||||
{
|
||||
Uuid = $"uuid-{id}",
|
||||
LogIndex = 10000 + ids.IndexOf(id),
|
||||
IntegratedTime = FixedTimestamp
|
||||
})).ToList();
|
||||
|
||||
_mockAttestationService
|
||||
.Setup(s => s.AttestBatchAsync(
|
||||
It.IsAny<IReadOnlyList<string>>(),
|
||||
It.IsAny<VexAttestationOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(results);
|
||||
|
||||
// Act
|
||||
var batchResults = await _mockAttestationService.Object.AttestBatchAsync(
|
||||
ids,
|
||||
new VexAttestationOptions { SubmitToRekor = true },
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
batchResults.Should().HaveCount(3);
|
||||
batchResults.All(r => r.Success).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingAttestationsAsync_ReturnsUnlinkedObservationIds()
|
||||
{
|
||||
// Arrange
|
||||
var pendingIds = new List<string> { "unlinked-001", "unlinked-002" };
|
||||
|
||||
_mockAttestationService
|
||||
.Setup(s => s.GetPendingAttestationsAsync(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pendingIds);
|
||||
|
||||
// Act
|
||||
var pending = await _mockAttestationService.Object.GetPendingAttestationsAsync(10, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
pending.Should().HaveCount(2);
|
||||
pending.Select(p => p.ObservationId).Should().Contain("unlinked-001");
|
||||
pending.Select(p => p.ObservationId).Should().Contain("unlinked-002");
|
||||
pending.Select(p => p.ObservationId).Should().NotContain("linked-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestObservation_StoresInclusionProof()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-proof-001");
|
||||
await _observationStore.InsertAsync(observation, CancellationToken.None);
|
||||
|
||||
var service = CreateService(storeInclusionProof: true);
|
||||
|
||||
// Act
|
||||
var result = await service.AttestAsync("default", "obs-proof-001", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var updated = await _observationStore.GetByIdAsync("default", "obs-proof-001", CancellationToken.None);
|
||||
updated!.RekorInclusionProof.Should().NotBeNull();
|
||||
updated.RekorInclusionProof!.Hashes.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRekorLinkage_TamperedEntry_DetectsInconsistency()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateTestObservation("obs-tampered") with
|
||||
{
|
||||
RekorUuid = "tampered-uuid",
|
||||
RekorLogIndex = 12347,
|
||||
RekorIntegratedTime = FixedTimestamp.AddMinutes(-5)
|
||||
};
|
||||
await _observationStore.UpsertAsync(observation, CancellationToken.None);
|
||||
|
||||
// Setup Rekor to return different data than what was stored
|
||||
_rekorClient.SetupTamperedEntry("tampered-uuid", 12347);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.VerifyRekorLinkageAsync("default", "obs-tampered", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsVerified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("mismatch");
|
||||
pending.Should().Contain("unlinked-001");
|
||||
pending.Should().Contain("unlinked-002");
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private IVexObservationAttestationService CreateService(bool storeInclusionProof = false)
|
||||
{
|
||||
return new VexObservationAttestationService(
|
||||
_observationStore,
|
||||
_rekorClient,
|
||||
Options.Create(new VexAttestationOptions
|
||||
{
|
||||
StoreInclusionProof = storeInclusionProof,
|
||||
RekorUrl = "https://rekor.sigstore.dev"
|
||||
}),
|
||||
_timeProvider,
|
||||
NullLogger<VexObservationAttestationService>.Instance);
|
||||
}
|
||||
|
||||
private VexObservation CreateTestObservation(string id)
|
||||
{
|
||||
return new VexObservation(
|
||||
@@ -286,212 +215,27 @@ public sealed class VexRekorAttestationFlowTests
|
||||
providerId: "test-provider",
|
||||
streamId: "test-stream",
|
||||
upstream: new VexObservationUpstream(
|
||||
url: "https://example.com/vex",
|
||||
etag: "etag-123",
|
||||
lastModified: FixedTimestamp.AddDays(-1),
|
||||
format: "csaf",
|
||||
fetchedAt: FixedTimestamp),
|
||||
upstreamId: "upstream-1",
|
||||
documentVersion: "1.0",
|
||||
fetchedAt: FixedTimestamp.AddDays(-1),
|
||||
receivedAt: FixedTimestamp,
|
||||
contentHash: "sha256:abc123",
|
||||
signature: new VexObservationSignature(false, null, null, null)),
|
||||
statements: ImmutableArray.Create(
|
||||
new VexObservationStatement(
|
||||
vulnerabilityId: "CVE-2026-0001",
|
||||
productKey: "pkg:example/test@1.0",
|
||||
status: "not_affected",
|
||||
justification: "code_not_present",
|
||||
actionStatement: null,
|
||||
impact: null,
|
||||
timestamp: FixedTimestamp.AddDays(-1))),
|
||||
status: VexClaimStatus.NotAffected,
|
||||
lastObserved: FixedTimestamp.AddDays(-1))),
|
||||
content: new VexObservationContent(
|
||||
raw: """{"test": "content"}""",
|
||||
mediaType: "application/json",
|
||||
encoding: "utf-8",
|
||||
signature: null),
|
||||
format: "csaf",
|
||||
specVersion: "2.0",
|
||||
raw: JsonNode.Parse("""{"test": "content"}""")!),
|
||||
linkset: new VexObservationLinkset(
|
||||
advisoryLinks: ImmutableArray<VexObservationReference>.Empty,
|
||||
productLinks: ImmutableArray<VexObservationReference>.Empty,
|
||||
vulnerabilityLinks: ImmutableArray<VexObservationReference>.Empty),
|
||||
aliases: null,
|
||||
purls: null,
|
||||
cpes: null,
|
||||
references: null),
|
||||
createdAt: FixedTimestamp);
|
||||
}
|
||||
|
||||
private static VexInclusionProof CreateTestInclusionProof()
|
||||
{
|
||||
return new VexInclusionProof(
|
||||
TreeSize: 100000,
|
||||
RootHash: "dGVzdC1yb290LWhhc2g=",
|
||||
LogIndex: 12345,
|
||||
Hashes: ImmutableArray.Create(
|
||||
"aGFzaDE=",
|
||||
"aGFzaDI=",
|
||||
"aGFzaDM="));
|
||||
}
|
||||
}
|
||||
|
||||
// Supporting types for tests
|
||||
|
||||
public record VexInclusionProof(
|
||||
long TreeSize,
|
||||
string RootHash,
|
||||
long LogIndex,
|
||||
ImmutableArray<string> Hashes);
|
||||
|
||||
public sealed class InMemoryVexObservationStore : IVexObservationStore
|
||||
{
|
||||
private readonly Dictionary<(string Tenant, string Id), VexObservation> _store = new();
|
||||
|
||||
public ValueTask<bool> InsertAsync(VexObservation observation, CancellationToken ct)
|
||||
{
|
||||
var key = (observation.Tenant, observation.ObservationId);
|
||||
if (_store.ContainsKey(key)) return ValueTask.FromResult(false);
|
||||
_store[key] = observation;
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
public ValueTask<bool> UpsertAsync(VexObservation observation, CancellationToken ct)
|
||||
{
|
||||
var key = (observation.Tenant, observation.ObservationId);
|
||||
_store[key] = observation;
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
public ValueTask<int> InsertManyAsync(string tenant, IEnumerable<VexObservation> observations, CancellationToken ct)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var obs in observations.Where(o => o.Tenant == tenant))
|
||||
{
|
||||
var key = (obs.Tenant, obs.ObservationId);
|
||||
if (!_store.ContainsKey(key))
|
||||
{
|
||||
_store[key] = obs;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return ValueTask.FromResult(count);
|
||||
}
|
||||
|
||||
public ValueTask<VexObservation?> GetByIdAsync(string tenant, string observationId, CancellationToken ct)
|
||||
{
|
||||
_store.TryGetValue((tenant, observationId), out var obs);
|
||||
return ValueTask.FromResult(obs);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexObservation>> FindByVulnerabilityAndProductAsync(
|
||||
string tenant, string vulnerabilityId, string productKey, CancellationToken ct)
|
||||
{
|
||||
var results = _store.Values
|
||||
.Where(o => o.Tenant == tenant)
|
||||
.Where(o => o.Statements.Any(s => s.VulnerabilityId == vulnerabilityId && s.ProductKey == productKey))
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexObservation>> FindByProviderAsync(
|
||||
string tenant, string providerId, int limit, CancellationToken ct)
|
||||
{
|
||||
var results = _store.Values
|
||||
.Where(o => o.Tenant == tenant && o.ProviderId == providerId)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string tenant, string observationId, CancellationToken ct)
|
||||
{
|
||||
return ValueTask.FromResult(_store.Remove((tenant, observationId)));
|
||||
}
|
||||
|
||||
public ValueTask<long> CountAsync(string tenant, CancellationToken ct)
|
||||
{
|
||||
var count = _store.Values.Count(o => o.Tenant == tenant);
|
||||
return ValueTask.FromResult((long)count);
|
||||
}
|
||||
|
||||
public ValueTask<bool> UpdateRekorLinkageAsync(
|
||||
string tenant, string observationId, RekorLinkage linkage, CancellationToken ct)
|
||||
{
|
||||
if (!_store.TryGetValue((tenant, observationId), out var obs))
|
||||
return ValueTask.FromResult(false);
|
||||
|
||||
_store[(tenant, observationId)] = obs with
|
||||
{
|
||||
RekorUuid = linkage.EntryUuid,
|
||||
RekorLogIndex = linkage.LogIndex,
|
||||
RekorIntegratedTime = linkage.IntegratedTime,
|
||||
RekorLogUrl = linkage.LogUrl
|
||||
};
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexObservation>> GetPendingRekorAttestationAsync(
|
||||
string tenant, int limit, CancellationToken ct)
|
||||
{
|
||||
var results = _store.Values
|
||||
.Where(o => o.Tenant == tenant && string.IsNullOrEmpty(o.RekorUuid))
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<VexObservation?> GetByRekorUuidAsync(string tenant, string rekorUuid, CancellationToken ct)
|
||||
{
|
||||
var obs = _store.Values.FirstOrDefault(o => o.Tenant == tenant && o.RekorUuid == rekorUuid);
|
||||
return ValueTask.FromResult(obs);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MockRekorClient
|
||||
{
|
||||
private readonly Dictionary<string, (long LogIndex, bool Valid, bool Tampered)> _entries = new();
|
||||
private bool _offline;
|
||||
private long _nextLogIndex = 10000;
|
||||
|
||||
public void SetupValidEntry(string uuid, long logIndex)
|
||||
{
|
||||
_entries[uuid] = (logIndex, true, false);
|
||||
}
|
||||
|
||||
public void SetupTamperedEntry(string uuid, long logIndex)
|
||||
{
|
||||
_entries[uuid] = (logIndex, false, true);
|
||||
}
|
||||
|
||||
public void SetOffline(bool offline)
|
||||
{
|
||||
_offline = offline;
|
||||
}
|
||||
|
||||
public Task<RekorSubmitResult> SubmitAsync(byte[] payload, CancellationToken ct)
|
||||
{
|
||||
if (_offline)
|
||||
{
|
||||
return Task.FromResult(new RekorSubmitResult(false, null, 0, "offline"));
|
||||
}
|
||||
|
||||
var uuid = Guid.NewGuid().ToString("N");
|
||||
var logIndex = _nextLogIndex++;
|
||||
_entries[uuid] = (logIndex, true, false);
|
||||
|
||||
return Task.FromResult(new RekorSubmitResult(true, uuid, logIndex, null));
|
||||
}
|
||||
|
||||
public Task<RekorVerifyResult> VerifyAsync(string uuid, CancellationToken ct)
|
||||
{
|
||||
if (_offline)
|
||||
{
|
||||
return Task.FromResult(new RekorVerifyResult(false, "offline", null, null));
|
||||
}
|
||||
|
||||
if (_entries.TryGetValue(uuid, out var entry))
|
||||
{
|
||||
if (entry.Tampered)
|
||||
{
|
||||
return Task.FromResult(new RekorVerifyResult(false, "hash mismatch", null, null));
|
||||
}
|
||||
|
||||
return Task.FromResult(new RekorVerifyResult(true, null, true, true));
|
||||
}
|
||||
|
||||
return Task.FromResult(new RekorVerifyResult(false, "entry not found", null, null));
|
||||
}
|
||||
}
|
||||
|
||||
public record RekorSubmitResult(bool Success, string? EntryId, long LogIndex, string? Error);
|
||||
public record RekorVerifyResult(bool IsVerified, string? FailureReason, bool? SignatureValid, bool? InclusionProofValid);
|
||||
|
||||
@@ -77,19 +77,18 @@ public sealed class VexStatementChangeEventTests
|
||||
tenant: "default",
|
||||
vulnerabilityId: "CVE-2026-1234",
|
||||
productKey: "pkg:npm/lodash@4.17.21",
|
||||
status: "fixed",
|
||||
newStatus: "fixed",
|
||||
previousStatus: "not_affected",
|
||||
providerId: "vendor:redhat",
|
||||
observationId: "default:redhat:VEX-2026-0001:v2",
|
||||
supersedes: ImmutableArray.Create("default:redhat:VEX-2026-0001:v1"),
|
||||
supersededBy: "default:redhat:VEX-2026-0001:v1",
|
||||
occurredAtUtc: FixedTimestamp);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(VexTimelineEventTypes.StatementSuperseded, evt.EventType);
|
||||
Assert.Equal("fixed", evt.NewStatus);
|
||||
Assert.Equal("not_affected", evt.PreviousStatus);
|
||||
Assert.Single(evt.Supersedes);
|
||||
Assert.Equal("default:redhat:VEX-2026-0001:v1", evt.Supersedes[0]);
|
||||
Assert.Equal("default:redhat:VEX-2026-0001:v1", evt.SupersededBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -112,13 +111,21 @@ public sealed class VexStatementChangeEventTests
|
||||
TrustScore = 0.85
|
||||
});
|
||||
|
||||
var conflictDetails = new VexConflictDetails
|
||||
{
|
||||
ConflictType = "status_mismatch",
|
||||
ConflictingStatuses = conflictingStatuses,
|
||||
AutoResolved = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var evt = VexStatementChangeEventFactory.CreateConflictDetected(
|
||||
tenant: "default",
|
||||
vulnerabilityId: "CVE-2026-1234",
|
||||
productKey: "pkg:npm/lodash@4.17.21",
|
||||
conflictType: "status_mismatch",
|
||||
conflictingStatuses: conflictingStatuses,
|
||||
providerId: "vendor:redhat",
|
||||
observationId: "default:redhat:VEX-2026-0001",
|
||||
conflictDetails: conflictDetails,
|
||||
occurredAtUtc: FixedTimestamp);
|
||||
|
||||
// Assert
|
||||
@@ -149,13 +156,21 @@ public sealed class VexStatementChangeEventTests
|
||||
TrustScore = 0.95
|
||||
});
|
||||
|
||||
var conflictDetails = new VexConflictDetails
|
||||
{
|
||||
ConflictType = "status_mismatch",
|
||||
ConflictingStatuses = conflictingStatuses,
|
||||
AutoResolved = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var evt = VexStatementChangeEventFactory.CreateConflictDetected(
|
||||
tenant: "default",
|
||||
vulnerabilityId: "CVE-2026-1234",
|
||||
productKey: "pkg:npm/lodash@4.17.21",
|
||||
conflictType: "status_mismatch",
|
||||
conflictingStatuses: conflictingStatuses,
|
||||
providerId: "vendor:redhat",
|
||||
observationId: "default:redhat:VEX-2026-0001",
|
||||
conflictDetails: conflictDetails,
|
||||
occurredAtUtc: FixedTimestamp);
|
||||
|
||||
// Assert - Should be sorted by provider ID for determinism
|
||||
@@ -200,10 +215,10 @@ public sealed class VexStatementChangeEventTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateStatusChanged_TracksStatusTransition()
|
||||
public void CreateStatementSuperseded_TracksStatusTransition()
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = VexStatementChangeEventFactory.CreateStatusChanged(
|
||||
var evt = VexStatementChangeEventFactory.CreateStatementSuperseded(
|
||||
tenant: "default",
|
||||
vulnerabilityId: "CVE-2026-1234",
|
||||
productKey: "pkg:npm/lodash@4.17.21",
|
||||
@@ -211,10 +226,11 @@ public sealed class VexStatementChangeEventTests
|
||||
previousStatus: "affected",
|
||||
providerId: "vendor:redhat",
|
||||
observationId: "default:redhat:VEX-2026-0001:v3",
|
||||
supersededBy: "default:redhat:VEX-2026-0001:v2",
|
||||
occurredAtUtc: FixedTimestamp);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(VexTimelineEventTypes.StatusChanged, evt.EventType);
|
||||
Assert.Equal(VexTimelineEventTypes.StatementSuperseded, evt.EventType);
|
||||
Assert.Equal("fixed", evt.NewStatus);
|
||||
Assert.Equal("affected", evt.PreviousStatus);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user