partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SnapshotExportImportTests.cs
|
||||
// Sprint: SPRINT_20260208_021_Attestor_snapshot_export_import_for_air_gap
|
||||
// Task: T1 — Unit tests for snapshot export/import
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Model tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class SnapshotModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void SnapshotLevel_values_are_ordered()
|
||||
{
|
||||
((int)SnapshotLevel.LevelA).Should().BeLessThan((int)SnapshotLevel.LevelB);
|
||||
((int)SnapshotLevel.LevelB).Should().BeLessThan((int)SnapshotLevel.LevelC);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifestEntry_properties_roundtrip()
|
||||
{
|
||||
var entry = new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = "attestations/sha256:abc",
|
||||
Digest = "deadbeef",
|
||||
SizeBytes = 1024,
|
||||
Category = "attestation",
|
||||
ContentType = "application/vnd.dsse+json"
|
||||
};
|
||||
|
||||
entry.RelativePath.Should().Be("attestations/sha256:abc");
|
||||
entry.Digest.Should().Be("deadbeef");
|
||||
entry.SizeBytes.Should().Be(1024);
|
||||
entry.Category.Should().Be("attestation");
|
||||
entry.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifestEntry_default_content_type_is_octet_stream()
|
||||
{
|
||||
var entry = new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = "test",
|
||||
Digest = "abc",
|
||||
SizeBytes = 0,
|
||||
Category = "other"
|
||||
};
|
||||
|
||||
entry.ContentType.Should().Be("application/octet-stream");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifest_computed_properties()
|
||||
{
|
||||
var entries = ImmutableArray.Create(
|
||||
new SnapshotManifestEntry { RelativePath = "a", Digest = "d1", SizeBytes = 100, Category = "cat" },
|
||||
new SnapshotManifestEntry { RelativePath = "b", Digest = "d2", SizeBytes = 200, Category = "cat" }
|
||||
);
|
||||
|
||||
var manifest = new SnapshotManifest
|
||||
{
|
||||
ManifestDigest = "abc",
|
||||
Level = SnapshotLevel.LevelB,
|
||||
Entries = entries,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
manifest.TotalSizeBytes.Should().Be(300);
|
||||
manifest.EntryCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifest_empty_entries_gives_zero_totals()
|
||||
{
|
||||
var manifest = new SnapshotManifest
|
||||
{
|
||||
ManifestDigest = "abc",
|
||||
Level = SnapshotLevel.LevelA,
|
||||
Entries = [],
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
manifest.TotalSizeBytes.Should().Be(0);
|
||||
manifest.EntryCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotExportRequest_defaults()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelB };
|
||||
|
||||
request.IncludeTrustRoots.Should().BeTrue();
|
||||
request.IncludePolicies.Should().BeFalse();
|
||||
request.ArtifactDigests.IsDefaultOrEmpty.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotImportRequest_defaults()
|
||||
{
|
||||
var request = new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = new ReadOnlyMemory<byte>([1, 2, 3])
|
||||
};
|
||||
|
||||
request.VerifyIntegrity.Should().BeTrue();
|
||||
request.SkipExisting.Should().BeTrue();
|
||||
request.TargetTenantId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotOperationStatus_has_four_values()
|
||||
{
|
||||
Enum.GetValues<SnapshotOperationStatus>().Should().HaveCount(4);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SnapshotExporter tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class SnapshotExporterTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IOfflineRootStore> _rootStoreMock = new();
|
||||
private readonly Mock<ILogger<SnapshotExporter>> _loggerMock = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
|
||||
private readonly SnapshotExporter _exporter;
|
||||
|
||||
public SnapshotExporterTests()
|
||||
{
|
||||
_exporter = new SnapshotExporter(_rootStoreMock.Object, _loggerMock.Object, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelA_no_artifacts_returns_empty_manifest()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Manifest.Level.Should().Be(SnapshotLevel.LevelA);
|
||||
result.Manifest.EntryCount.Should().Be(0);
|
||||
result.ArchiveContent.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelA_with_artifacts_creates_attestation_entries()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelA,
|
||||
ArtifactDigests = ["sha256:aaa", "sha256:bbb"]
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Manifest.EntryCount.Should().Be(2);
|
||||
result.Manifest.Entries.Should().AllSatisfy(e =>
|
||||
{
|
||||
e.Category.Should().Be("attestation");
|
||||
e.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelB_includes_trust_roots()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
IncludeTrustRoots = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "trust-root")
|
||||
.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelB_without_trust_roots_flag_skips_them()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
IncludeTrustRoots = false
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "trust-root")
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelC_includes_policies()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelC,
|
||||
IncludePolicies = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.Level.Should().Be(SnapshotLevel.LevelC);
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "policy")
|
||||
.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelC_without_policies_flag_skips_them()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelC,
|
||||
IncludePolicies = false,
|
||||
IncludeTrustRoots = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "policy")
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_sets_tenant_and_description_in_manifest()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelA,
|
||||
TenantId = "tenant-42",
|
||||
Description = "Monthly export"
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.TenantId.Should().Be("tenant-42");
|
||||
result.Manifest.Description.Should().Be("Monthly export");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_manifest_digest_is_deterministic()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelA,
|
||||
ArtifactDigests = ["sha256:abc"]
|
||||
};
|
||||
|
||||
var result1 = await _exporter.ExportAsync(request);
|
||||
var result2 = await _exporter.ExportAsync(request);
|
||||
|
||||
result1.Manifest.ManifestDigest.Should().Be(result2.Manifest.ManifestDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_archive_is_valid_json()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
ArtifactDigests = ["sha256:xyz"]
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
var json = Encoding.UTF8.GetString(result.ArchiveContent.Span);
|
||||
var act = () => JsonDocument.Parse(json);
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_records_duration()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_null_request_throws()
|
||||
{
|
||||
var act = () => _exporter.ExportAsync(null!);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseManifestAsync_roundtrips_export_output()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
ArtifactDigests = ["sha256:roundtrip"],
|
||||
TenantId = "tenant-rt",
|
||||
Description = "Roundtrip test"
|
||||
};
|
||||
|
||||
var exported = await _exporter.ExportAsync(request);
|
||||
var parsed = await _exporter.ParseManifestAsync(exported.ArchiveContent);
|
||||
|
||||
parsed.Level.Should().Be(exported.Manifest.Level);
|
||||
parsed.ManifestDigest.Should().Be(exported.Manifest.ManifestDigest);
|
||||
parsed.TenantId.Should().Be(exported.Manifest.TenantId);
|
||||
parsed.Description.Should().Be(exported.Manifest.Description);
|
||||
parsed.EntryCount.Should().Be(exported.Manifest.EntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseManifestAsync_invalid_json_throws()
|
||||
{
|
||||
var garbage = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("not json"));
|
||||
|
||||
var act = () => _exporter.ParseManifestAsync(garbage);
|
||||
|
||||
await act.Should().ThrowAsync<JsonException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelB_with_artifacts_and_trust_roots()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
ArtifactDigests = ["sha256:d1", "sha256:d2"],
|
||||
IncludeTrustRoots = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.EntryCount.Should().Be(4); // 2 attestations + 2 trust roots
|
||||
result.Manifest.Entries.Select(e => e.Category).Distinct()
|
||||
.Should().Contain(["attestation", "trust-root"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_manifest_uses_fixed_timestamp()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.CreatedAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_format_version_defaults_to_1_0_0()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.FormatVersion.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_rootStore_throws()
|
||||
{
|
||||
var act = () => new SnapshotExporter(null!, _loggerMock.Object);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_logger_throws()
|
||||
{
|
||||
var act = () => new SnapshotExporter(_rootStoreMock.Object, null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SnapshotImporter tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class SnapshotImporterTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IOfflineRootStore> _rootStoreMock = new();
|
||||
private readonly Mock<ILogger<SnapshotExporter>> _exporterLoggerMock = new();
|
||||
private readonly Mock<ILogger<SnapshotImporter>> _importerLoggerMock = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
|
||||
private readonly SnapshotExporter _exporter;
|
||||
private readonly SnapshotImporter _importer;
|
||||
|
||||
public SnapshotImporterTests()
|
||||
{
|
||||
_exporter = new SnapshotExporter(_rootStoreMock.Object, _exporterLoggerMock.Object, _timeProvider);
|
||||
_importer = new SnapshotImporter(_rootStoreMock.Object, _importerLoggerMock.Object, _timeProvider);
|
||||
}
|
||||
|
||||
private async Task<ReadOnlyMemory<byte>> ExportArchiveAsync(SnapshotLevel level, string[]? digests = null)
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = level,
|
||||
ArtifactDigests = digests is null ? [] : [.. digests]
|
||||
};
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
return result.ArchiveContent;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_valid_archive_succeeds()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:test"]);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.ImportedCount.Should().BeGreaterThan(0);
|
||||
result.FailedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_preserves_manifest_level()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelC);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
result.Manifest.Level.Should().Be(SnapshotLevel.LevelC);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_invalid_json_returns_failed()
|
||||
{
|
||||
var garbage = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("not json"));
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = garbage
|
||||
});
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Failed);
|
||||
result.Messages.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_null_request_throws()
|
||||
{
|
||||
var act = () => _importer.ImportAsync(null!);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_records_duration()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelA);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateArchiveAsync_valid_archive_returns_success()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:val"]);
|
||||
|
||||
var result = await _importer.ValidateArchiveAsync(archive);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Messages.Should().Contain(m => m.Contains("integrity verified"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateArchiveAsync_invalid_json_returns_failed()
|
||||
{
|
||||
var garbage = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("{bad}"));
|
||||
|
||||
var result = await _importer.ValidateArchiveAsync(garbage);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateArchiveAsync_does_not_import()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:noimport"]);
|
||||
|
||||
var result = await _importer.ValidateArchiveAsync(archive);
|
||||
|
||||
result.ImportedCount.Should().Be(0);
|
||||
result.SkippedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_skip_verify_succeeds_for_valid_archive()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelA, ["sha256:skip"]);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive,
|
||||
VerifyIntegrity = false
|
||||
});
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_export_roundtrip_preserves_entry_count()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:a", "sha256:b"]);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
// 2 attestations + 2 trust roots = 4 entries
|
||||
result.Manifest.EntryCount.Should().Be(4);
|
||||
result.ImportedCount.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_rootStore_throws()
|
||||
{
|
||||
var act = () => new SnapshotImporter(null!, _importerLoggerMock.Object);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_logger_throws()
|
||||
{
|
||||
var act = () => new SnapshotImporter(_rootStoreMock.Object, null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// FakeTimeProvider for deterministic testing
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
file sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SchemaIsolationServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_018_Attestor_postgresql_persistence_layer
|
||||
// Task: T1 — Tests for schema isolation, RLS scaffolding, temporal table management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Tests;
|
||||
|
||||
internal sealed class TestSchemaIsolationMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeSchemaTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
|
||||
public class SchemaIsolationServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestSchemaIsolationMeterFactory _meterFactory = new();
|
||||
private readonly FakeSchemaTimeProvider _timeProvider = new();
|
||||
private readonly SchemaIsolationService _service;
|
||||
|
||||
public SchemaIsolationServiceTests()
|
||||
{
|
||||
_service = new SchemaIsolationService(_timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetAssignment
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(AttestorSchema.ProofChain, "proofchain")]
|
||||
[InlineData(AttestorSchema.Attestor, "attestor")]
|
||||
[InlineData(AttestorSchema.Verdict, "verdict")]
|
||||
[InlineData(AttestorSchema.Watchlist, "watchlist")]
|
||||
[InlineData(AttestorSchema.Audit, "audit")]
|
||||
public void GetAssignment_returns_correct_schema_name(AttestorSchema schema, string expectedName)
|
||||
{
|
||||
var assignment = _service.GetAssignment(schema);
|
||||
|
||||
assignment.SchemaName.Should().Be(expectedName);
|
||||
assignment.Schema.Should().Be(schema);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAssignment_ProofChain_has_six_tables()
|
||||
{
|
||||
var assignment = _service.GetAssignment(AttestorSchema.ProofChain);
|
||||
|
||||
assignment.Tables.Should().HaveCount(6);
|
||||
assignment.Tables.Should().Contain("sbom_entries");
|
||||
assignment.Tables.Should().Contain("dsse_envelopes");
|
||||
assignment.Tables.Should().Contain("spines");
|
||||
assignment.Tables.Should().Contain("trust_anchors");
|
||||
assignment.Tables.Should().Contain("rekor_entries");
|
||||
assignment.Tables.Should().Contain("audit_log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAssignment_Verdict_has_tables()
|
||||
{
|
||||
var assignment = _service.GetAssignment(AttestorSchema.Verdict);
|
||||
|
||||
assignment.Tables.Should().Contain("verdict_ledger");
|
||||
assignment.Tables.Should().Contain("verdict_policies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAssignment_invalid_value_throws()
|
||||
{
|
||||
var act = () => _service.GetAssignment((AttestorSchema)999);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetAllAssignments
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetAllAssignments_returns_all_five_schemas()
|
||||
{
|
||||
var all = _service.GetAllAssignments();
|
||||
|
||||
all.Should().HaveCount(5);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.ProofChain);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Attestor);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Verdict);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Watchlist);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllAssignments_every_assignment_has_at_least_one_table()
|
||||
{
|
||||
var all = _service.GetAllAssignments();
|
||||
|
||||
foreach (var a in all)
|
||||
{
|
||||
a.Tables.Should().NotBeEmpty($"schema {a.Schema} should have at least one table");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GenerateProvisioningSql
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(AttestorSchema.ProofChain, "proofchain")]
|
||||
[InlineData(AttestorSchema.Attestor, "attestor")]
|
||||
[InlineData(AttestorSchema.Verdict, "verdict")]
|
||||
[InlineData(AttestorSchema.Watchlist, "watchlist")]
|
||||
[InlineData(AttestorSchema.Audit, "audit")]
|
||||
public void GenerateProvisioningSql_generates_create_schema(AttestorSchema schema, string schemaName)
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(schema);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Schema.Should().Be(schema);
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains($"CREATE SCHEMA IF NOT EXISTS {schemaName}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_includes_grant_statement()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("GRANT USAGE"));
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("stellaops_app"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_includes_default_privileges()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("ALTER DEFAULT PRIVILEGES"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_includes_comment()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.Audit);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("COMMENT ON SCHEMA"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_records_timestamp()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.Timestamp.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_produces_four_statements()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.GeneratedStatements.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetRlsPolicies
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetRlsPolicies_Verdict_returns_policies()
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(AttestorSchema.Verdict);
|
||||
|
||||
policies.Should().NotBeEmpty();
|
||||
policies.Should().OnlyContain(p => p.Schema == AttestorSchema.Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRlsPolicies_ProofChain_returns_empty()
|
||||
{
|
||||
// ProofChain does not have tenant isolation (shared read-only data)
|
||||
var policies = _service.GetRlsPolicies(AttestorSchema.ProofChain);
|
||||
|
||||
policies.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRlsPolicies_all_have_tenant_column()
|
||||
{
|
||||
foreach (var schema in Enum.GetValues<AttestorSchema>())
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(schema);
|
||||
policies.Should().OnlyContain(p => p.TenantColumn == "tenant_id");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RlsPolicyDefinition_UsingExpression_computed_correctly()
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(AttestorSchema.Verdict);
|
||||
|
||||
var policy = policies.First();
|
||||
policy.UsingExpression.Should().Contain("tenant_id");
|
||||
policy.UsingExpression.Should().Contain("current_setting");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GenerateRlsSql
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_Verdict_generates_enable_and_policy()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Verdict);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("ENABLE ROW LEVEL SECURITY"));
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("CREATE POLICY"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_Verdict_includes_force_rls()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Verdict);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("FORCE ROW LEVEL SECURITY"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_ProofChain_returns_empty_statements()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.GeneratedStatements.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_Watchlist_generates_multiple_policies()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Watchlist);
|
||||
|
||||
var policyStatements = result.GeneratedStatements.Where(s => s.Contains("CREATE POLICY")).ToList();
|
||||
policyStatements.Should().HaveCountGreaterThan(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_uses_permissive_mode()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Verdict);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("AS PERMISSIVE"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetTemporalTables
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_returns_three_configs()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
tables.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_verdict_ledger_has_seven_year_retention()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
var verdict = tables.First(t => t.TableName.Contains("verdict_ledger"));
|
||||
verdict.Retention.Should().Be(TemporalRetention.SevenYears);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_noise_ledger_has_seven_year_retention()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
var noise = tables.First(t => t.TableName.Contains("noise_ledger"));
|
||||
noise.Retention.Should().Be(TemporalRetention.SevenYears);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_watchlist_has_one_year_retention()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
var watchlist = tables.First(t => t.TableName.Contains("watched_identities"));
|
||||
watchlist.Retention.Should().Be(TemporalRetention.OneYear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_all_have_history_table_names()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
tables.Should().OnlyContain(t => t.HistoryTableName.Contains("_history"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GenerateTemporalTableSql
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_generates_alter_table_for_period_columns()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("sys_period_start") && s.Contains("sys_period_end"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_creates_history_table()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("CREATE TABLE IF NOT EXISTS") && s.Contains("_history"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_creates_trigger_function()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("CREATE OR REPLACE FUNCTION") && s.Contains("RETURNS TRIGGER"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_attaches_trigger()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("CREATE TRIGGER") && s.Contains("BEFORE UPDATE OR DELETE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_includes_retention_comment()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("retention:"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_produces_five_statements()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_null_config_throws()
|
||||
{
|
||||
var act = () => _service.GenerateTemporalTableSql(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetSummary
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_returns_complete_summary()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
summary.Assignments.Should().HaveCount(5);
|
||||
summary.RlsPolicies.Should().NotBeEmpty();
|
||||
summary.TemporalTables.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_ProvisionedCount_reflects_isProvisioned_flags()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
// Default IsProvisioned is false for all assignments
|
||||
summary.ProvisionedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_RlsEnabledCount_counts_non_disabled_policies()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
// All RLS policies are Permissive (not Disabled)
|
||||
summary.RlsEnabledCount.Should().Be(summary.RlsPolicies.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_records_timestamp()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
summary.ComputedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Null-time-provider fallback
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_time_provider_uses_system_default()
|
||||
{
|
||||
using var mf = new TestSchemaIsolationMeterFactory();
|
||||
var svc = new SchemaIsolationService(null, mf);
|
||||
|
||||
var result = svc.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
|
||||
result.Timestamp.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_factory_throws()
|
||||
{
|
||||
var act = () => new SchemaIsolationService(_timeProvider, null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Cross-schema consistency checks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RlsPolicies_only_reference_schemas_with_assignments()
|
||||
{
|
||||
var assignedSchemas = _service.GetAllAssignments().Select(a => a.Schema).ToHashSet();
|
||||
|
||||
foreach (var schema in Enum.GetValues<AttestorSchema>())
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(schema);
|
||||
foreach (var p in policies)
|
||||
{
|
||||
assignedSchemas.Should().Contain(p.Schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemporalTables_only_reference_schemas_with_assignments()
|
||||
{
|
||||
var assignedSchemas = _service.GetAllAssignments().Select(a => a.Schema).ToHashSet();
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
foreach (var t in tables)
|
||||
{
|
||||
assignedSchemas.Should().Contain(t.Schema);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_provisioning_sql_for_same_schema()
|
||||
{
|
||||
var result1 = _service.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
var result2 = _service.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
|
||||
result1.GeneratedStatements.Should().BeEquivalentTo(result2.GeneratedStatements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_rls_sql_for_same_schema()
|
||||
{
|
||||
var result1 = _service.GenerateRlsSql(AttestorSchema.Watchlist);
|
||||
var result2 = _service.GenerateRlsSql(AttestorSchema.Watchlist);
|
||||
|
||||
result1.GeneratedStatements.Should().BeEquivalentTo(result2.GeneratedStatements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_temporal_sql_for_same_config()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
var result1 = _service.GenerateTemporalTableSql(config);
|
||||
var result2 = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result1.GeneratedStatements.Should().BeEquivalentTo(result2.GeneratedStatements);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NoiseLedgerServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_017_Attestor_noise_ledger
|
||||
// Task: T1 — Tests for NoiseLedgerService
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Audit;
|
||||
|
||||
internal sealed class TestNoiseLedgerMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options) { var m = new Meter(options); _meters.Add(m); return m; }
|
||||
public void Dispose() { foreach (var m in _meters) m.Dispose(); }
|
||||
}
|
||||
|
||||
internal sealed class FakeNoiseLedgerTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
public void SetUtcNow(DateTimeOffset value) => _now = value;
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
public sealed class NoiseLedgerServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestNoiseLedgerMeterFactory _meterFactory = new();
|
||||
private readonly FakeNoiseLedgerTimeProvider _timeProvider = new();
|
||||
private readonly NoiseLedgerService _sut;
|
||||
|
||||
public NoiseLedgerServiceTests()
|
||||
{
|
||||
_sut = new NoiseLedgerService(_timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
private static RecordSuppressionRequest CreateRequest(
|
||||
string findingId = "CVE-2026-1234",
|
||||
SuppressionCategory category = SuppressionCategory.VexOverride,
|
||||
FindingSeverity severity = FindingSeverity.High,
|
||||
string componentRef = "pkg:npm/lodash@4.17.21",
|
||||
string justification = "VEX states not_affected",
|
||||
string suppressedBy = "security-team",
|
||||
DateTimeOffset? expiresAt = null,
|
||||
string? tenantId = null) => new()
|
||||
{
|
||||
FindingId = findingId,
|
||||
Category = category,
|
||||
Severity = severity,
|
||||
ComponentRef = componentRef,
|
||||
Justification = justification,
|
||||
SuppressedBy = suppressedBy,
|
||||
ExpiresAt = expiresAt,
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record: basic
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_ValidRequest_ReturnsEntryWithDigest()
|
||||
{
|
||||
var result = await _sut.RecordAsync(CreateRequest());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.EntryDigest.Should().StartWith("sha256:");
|
||||
result.Deduplicated.Should().BeFalse();
|
||||
result.Entry.FindingId.Should().Be("CVE-2026-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_SetsTimestampFromProvider()
|
||||
{
|
||||
var expected = new DateTimeOffset(2026, 6, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(expected);
|
||||
|
||||
var result = await _sut.RecordAsync(CreateRequest());
|
||||
|
||||
result.Entry.SuppressedAt.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_RecordsAllFields()
|
||||
{
|
||||
var result = await _sut.RecordAsync(CreateRequest(
|
||||
tenantId: "acme",
|
||||
expiresAt: new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
result.Entry.Category.Should().Be(SuppressionCategory.VexOverride);
|
||||
result.Entry.Severity.Should().Be(FindingSeverity.High);
|
||||
result.Entry.ComponentRef.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
result.Entry.Justification.Should().Be("VEX states not_affected");
|
||||
result.Entry.SuppressedBy.Should().Be("security-team");
|
||||
result.Entry.TenantId.Should().Be("acme");
|
||||
result.Entry.ExpiresAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_WithEvidenceDigest_RecordsIt()
|
||||
{
|
||||
var request = CreateRequest() with { EvidenceDigest = "sha256:evidence123" };
|
||||
var result = await _sut.RecordAsync(request);
|
||||
|
||||
result.Entry.EvidenceDigest.Should().Be("sha256:evidence123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_WithCorrelationId_RecordsIt()
|
||||
{
|
||||
var request = CreateRequest() with { CorrelationId = "scan-run-42" };
|
||||
var result = await _sut.RecordAsync(request);
|
||||
|
||||
result.Entry.CorrelationId.Should().Be("scan-run-42");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record: deduplication
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_DuplicateRequest_ReturnsDeduplicated()
|
||||
{
|
||||
var request = CreateRequest();
|
||||
var first = await _sut.RecordAsync(request);
|
||||
var second = await _sut.RecordAsync(request);
|
||||
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.EntryDigest.Should().Be(first.EntryDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_DifferentFinding_ProducesDifferentDigest()
|
||||
{
|
||||
var r1 = await _sut.RecordAsync(CreateRequest(findingId: "CVE-2026-0001"));
|
||||
var r2 = await _sut.RecordAsync(CreateRequest(findingId: "CVE-2026-0002"));
|
||||
|
||||
r1.EntryDigest.Should().NotBe(r2.EntryDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_DifferentCategory_ProducesDifferentDigest()
|
||||
{
|
||||
var r1 = await _sut.RecordAsync(CreateRequest(category: SuppressionCategory.VexOverride));
|
||||
var r2 = await _sut.RecordAsync(CreateRequest(category: SuppressionCategory.PolicyRule));
|
||||
|
||||
r1.EntryDigest.Should().NotBe(r2.EntryDigest);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record: validation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_NullRequest_Throws()
|
||||
{
|
||||
var act = () => _sut.RecordAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmptyFindingId_Throws()
|
||||
{
|
||||
var act = () => _sut.RecordAsync(CreateRequest(findingId: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>().WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmptyComponentRef_Throws()
|
||||
{
|
||||
var act = () => _sut.RecordAsync(CreateRequest(componentRef: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>().WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmptyJustification_Throws()
|
||||
{
|
||||
var act = () => _sut.RecordAsync(CreateRequest(justification: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>().WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmptySuppressedBy_Throws()
|
||||
{
|
||||
var act = () => _sut.RecordAsync(CreateRequest(suppressedBy: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>().WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var act = () => _sut.RecordAsync(CreateRequest(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetByDigest
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_Existing_ReturnsEntry()
|
||||
{
|
||||
var recorded = await _sut.RecordAsync(CreateRequest());
|
||||
var entry = await _sut.GetByDigestAsync(recorded.EntryDigest);
|
||||
|
||||
entry.Should().NotBeNull();
|
||||
entry!.FindingId.Should().Be("CVE-2026-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_Unknown_ReturnsNull()
|
||||
{
|
||||
var entry = await _sut.GetByDigestAsync("sha256:nonexistent");
|
||||
entry.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_NullDigest_Throws()
|
||||
{
|
||||
var act = () => _sut.GetByDigestAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Query
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByFindingId_FiltersCorrectly()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "CVE-1"));
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "CVE-2"));
|
||||
|
||||
var results = await _sut.QueryAsync(new NoiseLedgerQuery { FindingId = "CVE-1" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].FindingId.Should().Be("CVE-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByCategory_FiltersCorrectly()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(category: SuppressionCategory.VexOverride));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "CVE-other",
|
||||
category: SuppressionCategory.FalsePositive));
|
||||
|
||||
var results = await _sut.QueryAsync(
|
||||
new NoiseLedgerQuery { Category = SuppressionCategory.FalsePositive });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Category.Should().Be(SuppressionCategory.FalsePositive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_BySeverity_FiltersCorrectly()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(severity: FindingSeverity.High));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "CVE-low", severity: FindingSeverity.Low));
|
||||
|
||||
var results = await _sut.QueryAsync(
|
||||
new NoiseLedgerQuery { Severity = FindingSeverity.Low });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Severity.Should().Be(FindingSeverity.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByComponentRef_FiltersCorrectly()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(componentRef: "pkg:npm/a@1"));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "CVE-b", componentRef: "pkg:npm/b@2"));
|
||||
|
||||
var results = await _sut.QueryAsync(
|
||||
new NoiseLedgerQuery { ComponentRef = "pkg:npm/b@2" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ComponentRef.Should().Be("pkg:npm/b@2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ActiveOnly_ExcludesExpired()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(now);
|
||||
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "expired",
|
||||
expiresAt: new DateTimeOffset(2026, 6, 14, 0, 0, 0, TimeSpan.Zero)));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "active",
|
||||
expiresAt: new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var results = await _sut.QueryAsync(new NoiseLedgerQuery { ActiveOnly = true });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].FindingId.Should().Be("active");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_NoFilters_ReturnsAll()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "a"));
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "b"));
|
||||
|
||||
var results = await _sut.QueryAsync(new NoiseLedgerQuery());
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_RespectsLimit()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "a"));
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "b"));
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "c"));
|
||||
|
||||
var results = await _sut.QueryAsync(new NoiseLedgerQuery { Limit = 2 });
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_NullQuery_Throws()
|
||||
{
|
||||
var act = () => _sut.QueryAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var act = () => _sut.QueryAsync(new NoiseLedgerQuery(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Statistics
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_EmptyLedger_ReturnsZeros()
|
||||
{
|
||||
var stats = await _sut.GetStatisticsAsync();
|
||||
|
||||
stats.TotalCount.Should().Be(0);
|
||||
stats.ActiveCount.Should().Be(0);
|
||||
stats.ExpiredCount.Should().Be(0);
|
||||
stats.ByCategoryCount.Should().BeEmpty();
|
||||
stats.BySeverityCount.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_CountsByCategory()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "a", category: SuppressionCategory.VexOverride));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "b", category: SuppressionCategory.VexOverride));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "c", category: SuppressionCategory.PolicyRule));
|
||||
|
||||
var stats = await _sut.GetStatisticsAsync();
|
||||
|
||||
stats.TotalCount.Should().Be(3);
|
||||
stats.ByCategoryCount[SuppressionCategory.VexOverride].Should().Be(2);
|
||||
stats.ByCategoryCount[SuppressionCategory.PolicyRule].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_CountsBySeverity()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "a", severity: FindingSeverity.Critical));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "b", severity: FindingSeverity.Low));
|
||||
|
||||
var stats = await _sut.GetStatisticsAsync();
|
||||
|
||||
stats.BySeverityCount[FindingSeverity.Critical].Should().Be(1);
|
||||
stats.BySeverityCount[FindingSeverity.Low].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_TracksActiveAndExpired()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(now);
|
||||
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "expired",
|
||||
expiresAt: new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "active",
|
||||
expiresAt: new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero)));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "no-expiry")); // No expiration = active
|
||||
|
||||
var stats = await _sut.GetStatisticsAsync();
|
||||
|
||||
stats.ActiveCount.Should().Be(2);
|
||||
stats.ExpiredCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var act = () => _sut.GetStatisticsAsync(null, cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// IsExpired
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void NoiseLedgerEntry_IsExpired_ReturnsTrueWhenPastExpiration()
|
||||
{
|
||||
var entry = new NoiseLedgerEntry
|
||||
{
|
||||
EntryDigest = "sha256:test",
|
||||
FindingId = "CVE-1",
|
||||
Category = SuppressionCategory.VexOverride,
|
||||
Severity = FindingSeverity.High,
|
||||
ComponentRef = "pkg:test",
|
||||
Justification = "test",
|
||||
SuppressedBy = "user",
|
||||
SuppressedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
entry.IsExpired(new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero))
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoiseLedgerEntry_IsExpired_ReturnsFalseWithNoExpiration()
|
||||
{
|
||||
var entry = new NoiseLedgerEntry
|
||||
{
|
||||
EntryDigest = "sha256:test",
|
||||
FindingId = "CVE-1",
|
||||
Category = SuppressionCategory.VexOverride,
|
||||
Severity = FindingSeverity.High,
|
||||
ComponentRef = "pkg:test",
|
||||
Justification = "test",
|
||||
SuppressedBy = "user",
|
||||
SuppressedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
entry.IsExpired(DateTimeOffset.UtcNow).Should().BeFalse();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Constructor
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_Throws()
|
||||
{
|
||||
var act = () => new NoiseLedgerService(null, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_Succeeds()
|
||||
{
|
||||
using var factory = new TestNoiseLedgerMeterFactory();
|
||||
var sut = new NoiseLedgerService(null, factory);
|
||||
sut.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Determinism
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_SameInputs_ProducesSameDigest()
|
||||
{
|
||||
var r1 = await _sut.RecordAsync(CreateRequest());
|
||||
|
||||
using var factory2 = new TestNoiseLedgerMeterFactory();
|
||||
var sut2 = new NoiseLedgerService(_timeProvider, factory2);
|
||||
var r2 = await sut2.RecordAsync(CreateRequest());
|
||||
|
||||
r1.EntryDigest.Should().Be(r2.EntryDigest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryContentAddressedStoreTests.cs
|
||||
// Sprint: SPRINT_20260208_005_Attestor_cas_for_sbom_vex_attestation_artifacts
|
||||
// Task: T1 — Deterministic tests for unified CAS
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Attestor.ProofChain.Cas;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Cas;
|
||||
|
||||
public sealed class InMemoryContentAddressedStoreTests : IDisposable
|
||||
{
|
||||
private readonly CasFakeTimeProvider _time = new(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly CasTestMeterFactory _meterFactory = new();
|
||||
private readonly InMemoryContentAddressedStore _store;
|
||||
|
||||
public InMemoryContentAddressedStoreTests()
|
||||
{
|
||||
_store = new InMemoryContentAddressedStore(
|
||||
_time,
|
||||
NullLogger<InMemoryContentAddressedStore>.Instance,
|
||||
_meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meterFactory.Dispose();
|
||||
}
|
||||
|
||||
// ── Put ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Put_NewArtifact_ReturnsStoredWithDigest()
|
||||
{
|
||||
var result = await _store.PutAsync(MakePutRequest("hello world"));
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Deduplicated.Should().BeFalse();
|
||||
result.Artifact.Digest.Should().StartWith("sha256:");
|
||||
result.Artifact.ArtifactType.Should().Be(CasArtifactType.Sbom);
|
||||
result.Artifact.MediaType.Should().Be("application/spdx+json");
|
||||
result.Artifact.SizeBytes.Should().Be(Encoding.UTF8.GetByteCount("hello world"));
|
||||
result.Artifact.CreatedAt.Should().Be(_time.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_SameContentTwice_Deduplicates()
|
||||
{
|
||||
var first = await _store.PutAsync(MakePutRequest("same content"));
|
||||
var second = await _store.PutAsync(MakePutRequest("same content"));
|
||||
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.Artifact.Digest.Should().Be(first.Artifact.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_DifferentContent_DifferentDigests()
|
||||
{
|
||||
var a = await _store.PutAsync(MakePutRequest("content A"));
|
||||
var b = await _store.PutAsync(MakePutRequest("content B"));
|
||||
|
||||
b.Artifact.Digest.Should().NotBe(a.Artifact.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_NullRequest_Throws()
|
||||
{
|
||||
var act = () => _store.PutAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_EmptyMediaType_Throws()
|
||||
{
|
||||
var req = new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("data"),
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = ""
|
||||
};
|
||||
var act = () => _store.PutAsync(req);
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_WithTags_PreservesTags()
|
||||
{
|
||||
var tags = ImmutableDictionary<string, string>.Empty
|
||||
.Add("component", "libc")
|
||||
.Add("version", "2.36");
|
||||
|
||||
var req = MakePutRequest("tagged content") with { Tags = tags };
|
||||
var result = await _store.PutAsync(req);
|
||||
|
||||
result.Artifact.Tags.Should().HaveCount(2);
|
||||
result.Artifact.Tags["component"].Should().Be("libc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_WithRelatedDigests_PreservesRelations()
|
||||
{
|
||||
var related = ImmutableArray.Create("sha256:parent1", "sha256:parent2");
|
||||
var req = MakePutRequest("child content") with { RelatedDigests = related };
|
||||
var result = await _store.PutAsync(req);
|
||||
|
||||
result.Artifact.RelatedDigests.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
// ── Get ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Get_ExistingArtifact_ReturnsContentAndMetadata()
|
||||
{
|
||||
var put = await _store.PutAsync(MakePutRequest("retrieve me"));
|
||||
var get = await _store.GetAsync(put.Artifact.Digest);
|
||||
|
||||
get.Should().NotBeNull();
|
||||
get!.Artifact.Digest.Should().Be(put.Artifact.Digest);
|
||||
Encoding.UTF8.GetString(get.Content.Span).Should().Be("retrieve me");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_NonExistent_ReturnsNull()
|
||||
{
|
||||
var result = await _store.GetAsync("sha256:0000000000000000000000000000000000000000000000000000000000000000");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Exists ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Exists_StoredArtifact_ReturnsTrue()
|
||||
{
|
||||
var put = await _store.PutAsync(MakePutRequest("exists"));
|
||||
var exists = await _store.ExistsAsync(put.Artifact.Digest);
|
||||
exists.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exists_NotStored_ReturnsFalse()
|
||||
{
|
||||
var exists = await _store.ExistsAsync("sha256:aaaa");
|
||||
exists.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ExistingArtifact_RemovesAndReturnsTrue()
|
||||
{
|
||||
var put = await _store.PutAsync(MakePutRequest("delete me"));
|
||||
var deleted = await _store.DeleteAsync(put.Artifact.Digest);
|
||||
deleted.Should().BeTrue();
|
||||
|
||||
var after = await _store.GetAsync(put.Artifact.Digest);
|
||||
after.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_NonExistent_ReturnsFalse()
|
||||
{
|
||||
var result = await _store.DeleteAsync("sha256:nonexistent");
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByArtifactType_ReturnsMatchingOnly()
|
||||
{
|
||||
await _store.PutAsync(MakePutRequest("sbom1", CasArtifactType.Sbom));
|
||||
await _store.PutAsync(MakePutRequest("vex1", CasArtifactType.Vex, "application/csaf+json"));
|
||||
|
||||
var sboms = await _store.ListAsync(new CasQuery { ArtifactType = CasArtifactType.Sbom });
|
||||
sboms.Should().HaveCount(1);
|
||||
sboms[0].ArtifactType.Should().Be(CasArtifactType.Sbom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByMediaType_ReturnsMatchingOnly()
|
||||
{
|
||||
await _store.PutAsync(MakePutRequest("spdx", CasArtifactType.Sbom, "application/spdx+json"));
|
||||
await _store.PutAsync(MakePutRequest("cdx", CasArtifactType.Sbom, "application/vnd.cyclonedx+json"));
|
||||
|
||||
var spdx = await _store.ListAsync(new CasQuery { MediaType = "application/spdx+json" });
|
||||
spdx.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByTag_ReturnsMatchingOnly()
|
||||
{
|
||||
var tagged = MakePutRequest("tagged") with
|
||||
{
|
||||
Tags = ImmutableDictionary<string, string>.Empty.Add("env", "prod")
|
||||
};
|
||||
await _store.PutAsync(tagged);
|
||||
await _store.PutAsync(MakePutRequest("untagged"));
|
||||
|
||||
var results = await _store.ListAsync(new CasQuery { TagKey = "env", TagValue = "prod" });
|
||||
results.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_PaginationRespected()
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
await _store.PutAsync(MakePutRequest($"item {i}"));
|
||||
|
||||
var page1 = await _store.ListAsync(new CasQuery { Limit = 2, Offset = 0 });
|
||||
var page2 = await _store.ListAsync(new CasQuery { Limit = 2, Offset = 2 });
|
||||
|
||||
page1.Should().HaveCount(2);
|
||||
page2.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
// ── Statistics ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatistics_ReturnsCorrectCounts()
|
||||
{
|
||||
await _store.PutAsync(MakePutRequest("sbom1", CasArtifactType.Sbom));
|
||||
await _store.PutAsync(MakePutRequest("vex1", CasArtifactType.Vex, "application/csaf+json"));
|
||||
// Dedup
|
||||
await _store.PutAsync(MakePutRequest("sbom1", CasArtifactType.Sbom));
|
||||
|
||||
var stats = await _store.GetStatisticsAsync();
|
||||
stats.TotalArtifacts.Should().Be(2);
|
||||
stats.DedupCount.Should().Be(1);
|
||||
stats.TypeCounts[CasArtifactType.Sbom].Should().Be(1);
|
||||
stats.TypeCounts[CasArtifactType.Vex].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatistics_TotalBytes_MatchesStoredContent()
|
||||
{
|
||||
await _store.PutAsync(MakePutRequest("short"));
|
||||
await _store.PutAsync(MakePutRequest("a longer piece of content here"));
|
||||
|
||||
var stats = await _store.GetStatisticsAsync();
|
||||
stats.TotalBytes.Should().Be(
|
||||
Encoding.UTF8.GetByteCount("short") +
|
||||
Encoding.UTF8.GetByteCount("a longer piece of content here"));
|
||||
}
|
||||
|
||||
// ── Digest determinism ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_SameContent_SameDigest()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("deterministic");
|
||||
var a = InMemoryContentAddressedStore.ComputeDigest(content);
|
||||
var b = InMemoryContentAddressedStore.ComputeDigest(content);
|
||||
b.Should().Be(a);
|
||||
a.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_DifferentContent_DifferentDigest()
|
||||
{
|
||||
var a = InMemoryContentAddressedStore.ComputeDigest(Encoding.UTF8.GetBytes("alpha"));
|
||||
var b = InMemoryContentAddressedStore.ComputeDigest(Encoding.UTF8.GetBytes("beta"));
|
||||
b.Should().NotBe(a);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static CasPutRequest MakePutRequest(
|
||||
string content,
|
||||
CasArtifactType type = CasArtifactType.Sbom,
|
||||
string mediaType = "application/spdx+json")
|
||||
=> new()
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes(content),
|
||||
ArtifactType = type,
|
||||
MediaType = mediaType
|
||||
};
|
||||
|
||||
// ── Test infrastructure ───────────────────────────────────────────────
|
||||
|
||||
private sealed class CasTestMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
_meters.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CasFakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public CasFakeTimeProvider(DateTimeOffset startTime) => _utcNow = startTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ObjectStorageTests.cs
|
||||
// Sprint: SPRINT_20260208_019_Attestor_s3_minio_gcs_object_storage_for_tiles
|
||||
// Task: T1 — Tests for object storage providers and CAS bridge
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Cas;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Cas;
|
||||
|
||||
internal sealed class TestObjectStorageMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeObjectStorageTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IObjectStorageProvider"/> for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryObjectStorageProvider : IObjectStorageProvider
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, (byte[] Content, string ContentType, ImmutableDictionary<string, string> Metadata)> _blobs = new();
|
||||
private readonly bool _enforceWriteOnce;
|
||||
|
||||
public InMemoryObjectStorageProvider(bool enforceWriteOnce = false)
|
||||
{
|
||||
_enforceWriteOnce = enforceWriteOnce;
|
||||
}
|
||||
|
||||
public ObjectStorageProviderKind Kind => ObjectStorageProviderKind.S3Compatible;
|
||||
|
||||
public Task<BlobPutResult> PutAsync(BlobPutRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_enforceWriteOnce && _blobs.ContainsKey(request.Key))
|
||||
{
|
||||
return Task.FromResult(new BlobPutResult
|
||||
{
|
||||
Key = request.Key,
|
||||
SizeBytes = _blobs[request.Key].Content.Length,
|
||||
AlreadyExisted = true
|
||||
});
|
||||
}
|
||||
|
||||
_blobs[request.Key] = (request.Content.ToArray(), request.ContentType, request.Metadata);
|
||||
return Task.FromResult(new BlobPutResult
|
||||
{
|
||||
Key = request.Key,
|
||||
SizeBytes = request.Content.Length,
|
||||
AlreadyExisted = false
|
||||
});
|
||||
}
|
||||
|
||||
public Task<BlobGetResult?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_blobs.TryGetValue(key, out var blob))
|
||||
return Task.FromResult<BlobGetResult?>(null);
|
||||
|
||||
return Task.FromResult<BlobGetResult?>(new BlobGetResult
|
||||
{
|
||||
Key = key,
|
||||
Content = new ReadOnlyMemory<byte>(blob.Content),
|
||||
ContentType = blob.ContentType,
|
||||
Metadata = blob.Metadata,
|
||||
SizeBytes = blob.Content.Length
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string key, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_blobs.ContainsKey(key));
|
||||
|
||||
public Task<bool> DeleteAsync(string key, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_blobs.TryRemove(key, out _));
|
||||
|
||||
public Task<BlobListResult> ListAsync(BlobListQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = _blobs.Keys.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(query.KeyPrefix))
|
||||
results = results.Where(k => k.StartsWith(query.KeyPrefix, StringComparison.Ordinal));
|
||||
|
||||
var offset = 0;
|
||||
if (!string.IsNullOrEmpty(query.ContinuationToken) && int.TryParse(query.ContinuationToken, out var parsed))
|
||||
offset = parsed;
|
||||
|
||||
var page = results.OrderBy(k => k).Skip(offset).Take(query.Limit + 1).ToList();
|
||||
var hasMore = page.Count > query.Limit;
|
||||
|
||||
return Task.FromResult(new BlobListResult
|
||||
{
|
||||
Blobs = page.Take(query.Limit).Select(k => new BlobReference
|
||||
{
|
||||
Key = k,
|
||||
SizeBytes = _blobs[k].Content.Length
|
||||
}).ToImmutableArray(),
|
||||
ContinuationToken = hasMore ? (offset + query.Limit).ToString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ObjectStorageContentAddressedStore Tests
|
||||
// =============================================================================
|
||||
|
||||
public class ObjectStorageContentAddressedStoreTests : IDisposable
|
||||
{
|
||||
private readonly TestObjectStorageMeterFactory _meterFactory = new();
|
||||
private readonly FakeObjectStorageTimeProvider _timeProvider = new();
|
||||
private readonly InMemoryObjectStorageProvider _provider = new();
|
||||
private readonly ObjectStorageContentAddressedStore _store;
|
||||
|
||||
public ObjectStorageContentAddressedStoreTests()
|
||||
{
|
||||
_store = new ObjectStorageContentAddressedStore(_provider, _timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
// ── PutAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_stores_content_and_returns_digest()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("hello tiles");
|
||||
var result = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = content,
|
||||
ArtifactType = CasArtifactType.ProofBundle,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
result.Deduplicated.Should().BeFalse();
|
||||
result.Artifact.Digest.Should().StartWith("sha256:");
|
||||
result.Artifact.SizeBytes.Should().Be(content.Length);
|
||||
result.Artifact.ArtifactType.Should().Be(CasArtifactType.ProofBundle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_same_content_is_deduplicated()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("duplicate content");
|
||||
var req = new CasPutRequest
|
||||
{
|
||||
Content = content,
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
};
|
||||
|
||||
var first = await _store.PutAsync(req);
|
||||
var second = await _store.PutAsync(req);
|
||||
|
||||
first.Deduplicated.Should().BeFalse();
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
first.Artifact.Digest.Should().Be(second.Artifact.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_null_request_throws()
|
||||
{
|
||||
var act = () => _store.PutAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_empty_media_type_throws()
|
||||
{
|
||||
var act = () => _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = new byte[] { 1 },
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_preserves_tags()
|
||||
{
|
||||
var tags = new Dictionary<string, string> { ["env"] = "prod" }.ToImmutableDictionary();
|
||||
var result = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("tagged"),
|
||||
ArtifactType = CasArtifactType.Attestation,
|
||||
MediaType = "application/json",
|
||||
Tags = tags,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
result.Artifact.Tags.Should().ContainKey("env");
|
||||
result.Artifact.Tags["env"].Should().Be("prod");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_preserves_related_digests()
|
||||
{
|
||||
var related = ImmutableArray.Create("sha256:aaaa", "sha256:bbbb");
|
||||
var result = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("related"),
|
||||
ArtifactType = CasArtifactType.Vex,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = related
|
||||
});
|
||||
|
||||
result.Artifact.RelatedDigests.Should().BeEquivalentTo(related);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_records_timestamp()
|
||||
{
|
||||
var result = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("timestamped"),
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
result.Artifact.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
// ── GetAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_retrieves_stored_content()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("retrievable");
|
||||
var put = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = content,
|
||||
ArtifactType = CasArtifactType.ProofBundle,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
var result = await _store.GetAsync(put.Artifact.Digest);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Content.ToArray().Should().BeEquivalentTo(content);
|
||||
result.Artifact.Digest.Should().Be(put.Artifact.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_missing_digest_returns_null()
|
||||
{
|
||||
var result = await _store.GetAsync("sha256:nonexistent");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_null_digest_throws()
|
||||
{
|
||||
var act = () => _store.GetAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_empty_digest_throws()
|
||||
{
|
||||
var act = () => _store.GetAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── ExistsAsync ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_returns_true_for_stored()
|
||||
{
|
||||
var put = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("exists"),
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
(await _store.ExistsAsync(put.Artifact.Digest)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_returns_false_for_missing()
|
||||
{
|
||||
(await _store.ExistsAsync("sha256:missing")).Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── DeleteAsync ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_removes_stored_blob()
|
||||
{
|
||||
var put = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("deletable"),
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
(await _store.DeleteAsync(put.Artifact.Digest)).Should().BeTrue();
|
||||
(await _store.ExistsAsync(put.Artifact.Digest)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_returns_false_for_missing()
|
||||
{
|
||||
(await _store.DeleteAsync("sha256:nonexistent")).Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── ListAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_returns_stored_artifacts()
|
||||
{
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("list-item-1"),
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("list-item-2"),
|
||||
ArtifactType = CasArtifactType.Vex,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
var results = await _store.ListAsync(new CasQuery { Limit = 100 });
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_filters_by_artifact_type()
|
||||
{
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("sbom-content"),
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("vex-content"),
|
||||
ArtifactType = CasArtifactType.Vex,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
var results = await _store.ListAsync(new CasQuery
|
||||
{
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
Limit = 100
|
||||
});
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ArtifactType.Should().Be(CasArtifactType.Sbom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_respects_limit()
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes($"item-{i}"),
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
}
|
||||
|
||||
var results = await _store.ListAsync(new CasQuery { Limit = 2 });
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
// ── GetStatisticsAsync ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_returns_accurate_counts()
|
||||
{
|
||||
var content1 = Encoding.UTF8.GetBytes("stat-1");
|
||||
var content2 = Encoding.UTF8.GetBytes("stat-2");
|
||||
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = content1,
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = content2,
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
var stats = await _store.GetStatisticsAsync();
|
||||
stats.TotalArtifacts.Should().Be(2);
|
||||
stats.TotalBytes.Should().Be(content1.Length + content2.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_tracks_dedup_count()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("dedup-stat");
|
||||
var req = new CasPutRequest
|
||||
{
|
||||
Content = content,
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
};
|
||||
|
||||
await _store.PutAsync(req);
|
||||
await _store.PutAsync(req); // dedup
|
||||
|
||||
var stats = await _store.GetStatisticsAsync();
|
||||
stats.DedupCount.Should().Be(1);
|
||||
}
|
||||
|
||||
// ── Constructor validation ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_provider_throws()
|
||||
{
|
||||
var act = () => new ObjectStorageContentAddressedStore(null!, _timeProvider, _meterFactory);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_factory_throws()
|
||||
{
|
||||
var act = () => new ObjectStorageContentAddressedStore(_provider, _timeProvider, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_time_provider_uses_system()
|
||||
{
|
||||
using var mf = new TestObjectStorageMeterFactory();
|
||||
var store = new ObjectStorageContentAddressedStore(_provider, null, mf);
|
||||
store.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// ── Determinism ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Deterministic_digest_for_same_content()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("deterministic");
|
||||
var req = new CasPutRequest
|
||||
{
|
||||
Content = content,
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
};
|
||||
|
||||
var r1 = await _store.PutAsync(req);
|
||||
var digest1 = r1.Artifact.Digest;
|
||||
|
||||
// Compute independently
|
||||
var digest2 = ObjectStorageContentAddressedStore.ComputeDigest(content);
|
||||
|
||||
digest1.Should().Be(digest2);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FileSystemObjectStorageProvider Tests
|
||||
// =============================================================================
|
||||
|
||||
public class FileSystemObjectStorageProviderTests : IDisposable
|
||||
{
|
||||
private readonly TestObjectStorageMeterFactory _meterFactory = new();
|
||||
private readonly string _tempRoot;
|
||||
private readonly FileSystemObjectStorageProvider _provider;
|
||||
|
||||
public FileSystemObjectStorageProviderTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-fs-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
|
||||
_provider = new FileSystemObjectStorageProvider(
|
||||
new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem,
|
||||
RootPath = _tempRoot
|
||||
},
|
||||
_meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meterFactory.Dispose();
|
||||
try { Directory.Delete(_tempRoot, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kind_is_filesystem()
|
||||
{
|
||||
_provider.Kind.Should().Be(ObjectStorageProviderKind.FileSystem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_stores_and_retrieves()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("fs-test");
|
||||
var result = await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "test/blob1",
|
||||
Content = content,
|
||||
ContentType = "text/plain"
|
||||
});
|
||||
|
||||
result.Key.Should().Be("test/blob1");
|
||||
result.SizeBytes.Should().Be(content.Length);
|
||||
result.AlreadyExisted.Should().BeFalse();
|
||||
|
||||
var get = await _provider.GetAsync("test/blob1");
|
||||
get.Should().NotBeNull();
|
||||
get!.Content.ToArray().Should().BeEquivalentTo(content);
|
||||
get.ContentType.Should().Be("text/plain");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_write_once_returns_already_existed()
|
||||
{
|
||||
var provider = new FileSystemObjectStorageProvider(
|
||||
new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem,
|
||||
RootPath = _tempRoot,
|
||||
EnforceWriteOnce = true
|
||||
},
|
||||
_meterFactory);
|
||||
|
||||
var content = Encoding.UTF8.GetBytes("worm");
|
||||
await provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "worm/blob",
|
||||
Content = content,
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
var second = await provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "worm/blob",
|
||||
Content = Encoding.UTF8.GetBytes("different"),
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
second.AlreadyExisted.Should().BeTrue();
|
||||
|
||||
// Original content preserved
|
||||
var get = await provider.GetAsync("worm/blob");
|
||||
Encoding.UTF8.GetString(get!.Content.ToArray()).Should().Be("worm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_returns_true_for_stored()
|
||||
{
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "exists-check",
|
||||
Content = new byte[] { 1, 2, 3 },
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
(await _provider.ExistsAsync("exists-check")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_returns_false_for_missing()
|
||||
{
|
||||
(await _provider.ExistsAsync("nope")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_removes_blob_and_metadata()
|
||||
{
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "delete-me",
|
||||
Content = new byte[] { 1 },
|
||||
ContentType = "text/plain"
|
||||
});
|
||||
|
||||
(await _provider.DeleteAsync("delete-me")).Should().BeTrue();
|
||||
(await _provider.ExistsAsync("delete-me")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_returns_false_for_missing()
|
||||
{
|
||||
(await _provider.DeleteAsync("nothing")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_with_write_once_returns_false()
|
||||
{
|
||||
var provider = new FileSystemObjectStorageProvider(
|
||||
new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem,
|
||||
RootPath = _tempRoot,
|
||||
EnforceWriteOnce = true
|
||||
},
|
||||
_meterFactory);
|
||||
|
||||
await provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "worm-no-delete",
|
||||
Content = new byte[] { 1 },
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
(await provider.DeleteAsync("worm-no-delete")).Should().BeFalse();
|
||||
(await provider.ExistsAsync("worm-no-delete")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_returns_stored_blobs()
|
||||
{
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "list/a",
|
||||
Content = new byte[] { 1 },
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "list/b",
|
||||
Content = new byte[] { 2, 3 },
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
var result = await _provider.ListAsync(new BlobListQuery
|
||||
{
|
||||
KeyPrefix = "list/",
|
||||
Limit = 100
|
||||
});
|
||||
|
||||
result.Blobs.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_empty_directory_returns_empty()
|
||||
{
|
||||
var result = await _provider.ListAsync(new BlobListQuery
|
||||
{
|
||||
KeyPrefix = "nonexistent/",
|
||||
Limit = 100
|
||||
});
|
||||
|
||||
result.Blobs.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_preserves_metadata()
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["origin"] = "scanner",
|
||||
["version"] = "2.0"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "meta/test",
|
||||
Content = new byte[] { 42 },
|
||||
ContentType = "application/json",
|
||||
Metadata = metadata
|
||||
});
|
||||
|
||||
var result = await _provider.GetAsync("meta/test");
|
||||
result.Should().NotBeNull();
|
||||
result!.Metadata.Should().ContainKey("origin");
|
||||
result.Metadata["origin"].Should().Be("scanner");
|
||||
result.Metadata["version"].Should().Be("2.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_config_throws()
|
||||
{
|
||||
var act = () => new FileSystemObjectStorageProvider(null!, _meterFactory);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_empty_root_path_throws()
|
||||
{
|
||||
var act = () => new FileSystemObjectStorageProvider(
|
||||
new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem,
|
||||
RootPath = ""
|
||||
},
|
||||
_meterFactory);
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_factory_throws()
|
||||
{
|
||||
var act = () => new FileSystemObjectStorageProvider(
|
||||
new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem,
|
||||
RootPath = _tempRoot
|
||||
},
|
||||
null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ObjectStorageModels Tests
|
||||
// =============================================================================
|
||||
|
||||
public class ObjectStorageModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ObjectStorageConfig_default_values()
|
||||
{
|
||||
var config = new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem
|
||||
};
|
||||
|
||||
config.Prefix.Should().BeEmpty();
|
||||
config.BucketName.Should().BeEmpty();
|
||||
config.EndpointUrl.Should().BeEmpty();
|
||||
config.Region.Should().BeEmpty();
|
||||
config.RootPath.Should().BeEmpty();
|
||||
config.EnforceWriteOnce.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlobPutRequest_default_content_type()
|
||||
{
|
||||
var req = new BlobPutRequest
|
||||
{
|
||||
Key = "test",
|
||||
Content = new byte[] { 1 }
|
||||
};
|
||||
|
||||
req.ContentType.Should().Be("application/octet-stream");
|
||||
req.Metadata.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlobGetResult_default_values()
|
||||
{
|
||||
var result = new BlobGetResult
|
||||
{
|
||||
Key = "k",
|
||||
Content = new byte[] { 1 },
|
||||
SizeBytes = 1
|
||||
};
|
||||
|
||||
result.ContentType.Should().Be("application/octet-stream");
|
||||
result.Metadata.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlobListQuery_default_values()
|
||||
{
|
||||
var query = new BlobListQuery();
|
||||
|
||||
query.KeyPrefix.Should().BeEmpty();
|
||||
query.Limit.Should().Be(100);
|
||||
query.ContinuationToken.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectStorageProviderKind_has_three_values()
|
||||
{
|
||||
Enum.GetValues<ObjectStorageProviderKind>().Should().HaveCount(3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Compliance;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Compliance;
|
||||
|
||||
public sealed class ComplianceReportGeneratorTests : IDisposable
|
||||
{
|
||||
private readonly TestComplianceMeterFactory _meterFactory = new();
|
||||
private readonly ComplianceReportGenerator _sut;
|
||||
|
||||
public ComplianceReportGeneratorTests()
|
||||
{
|
||||
_sut = new ComplianceReportGenerator(TimeProvider.System, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
private static ImmutableHashSet<EvidenceArtifactType> AllEvidence() =>
|
||||
ImmutableHashSet.Create(
|
||||
EvidenceArtifactType.Sbom,
|
||||
EvidenceArtifactType.VexStatement,
|
||||
EvidenceArtifactType.SignedAttestation,
|
||||
EvidenceArtifactType.TransparencyLogEntry,
|
||||
EvidenceArtifactType.VerificationReceipt,
|
||||
EvidenceArtifactType.ProofBundle,
|
||||
EvidenceArtifactType.ReachabilityAnalysis,
|
||||
EvidenceArtifactType.PolicyEvaluation,
|
||||
EvidenceArtifactType.ProvenanceAttestation,
|
||||
EvidenceArtifactType.IncidentReport);
|
||||
|
||||
private static ImmutableHashSet<EvidenceArtifactType> NoEvidence() =>
|
||||
ImmutableHashSet<EvidenceArtifactType>.Empty;
|
||||
|
||||
// --- Supported Frameworks ---
|
||||
|
||||
[Fact]
|
||||
public void SupportedFrameworks_Contains_AllFour()
|
||||
{
|
||||
_sut.SupportedFrameworks.Should().HaveCount(4);
|
||||
_sut.SupportedFrameworks.Should().Contain(RegulatoryFramework.Nis2);
|
||||
_sut.SupportedFrameworks.Should().Contain(RegulatoryFramework.Dora);
|
||||
_sut.SupportedFrameworks.Should().Contain(RegulatoryFramework.Iso27001);
|
||||
_sut.SupportedFrameworks.Should().Contain(RegulatoryFramework.EuCra);
|
||||
}
|
||||
|
||||
// --- GetControls ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegulatoryFramework.Nis2, 5)]
|
||||
[InlineData(RegulatoryFramework.Dora, 5)]
|
||||
[InlineData(RegulatoryFramework.Iso27001, 6)]
|
||||
[InlineData(RegulatoryFramework.EuCra, 4)]
|
||||
public void GetControls_ReturnsExpectedCount(RegulatoryFramework framework, int expected)
|
||||
{
|
||||
var controls = _sut.GetControls(framework);
|
||||
controls.Length.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegulatoryFramework.Nis2, "NIS2-Art21.2d")]
|
||||
[InlineData(RegulatoryFramework.Dora, "DORA-Art6.1")]
|
||||
[InlineData(RegulatoryFramework.Iso27001, "ISO27001-A.8.28")]
|
||||
[InlineData(RegulatoryFramework.EuCra, "CRA-AnnexI.2.1")]
|
||||
public void GetControls_ContainsExpectedControlId(RegulatoryFramework framework, string expectedControlId)
|
||||
{
|
||||
var controls = _sut.GetControls(framework);
|
||||
controls.Should().Contain(c => c.ControlId == expectedControlId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetControls_AllControlsHaveFrameworkSet()
|
||||
{
|
||||
foreach (var framework in _sut.SupportedFrameworks)
|
||||
{
|
||||
var controls = _sut.GetControls(framework);
|
||||
foreach (var control in controls)
|
||||
control.Framework.Should().Be(framework);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetControls_AllControlsHaveRequiredFields()
|
||||
{
|
||||
foreach (var framework in _sut.SupportedFrameworks)
|
||||
{
|
||||
var controls = _sut.GetControls(framework);
|
||||
foreach (var control in controls)
|
||||
{
|
||||
control.ControlId.Should().NotBeNullOrWhiteSpace();
|
||||
control.Title.Should().NotBeNullOrWhiteSpace();
|
||||
control.Description.Should().NotBeNullOrWhiteSpace();
|
||||
control.Category.Should().NotBeNullOrWhiteSpace();
|
||||
control.SatisfiedBy.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - Full Evidence ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegulatoryFramework.Nis2)]
|
||||
[InlineData(RegulatoryFramework.Dora)]
|
||||
[InlineData(RegulatoryFramework.Iso27001)]
|
||||
[InlineData(RegulatoryFramework.EuCra)]
|
||||
public async Task GenerateReportAsync_AllEvidence_FullCompliance(RegulatoryFramework framework)
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
framework, "sha256:abc123", AllEvidence());
|
||||
|
||||
report.MeetsMinimumCompliance.Should().BeTrue();
|
||||
report.MandatoryGapCount.Should().Be(0);
|
||||
report.CompliancePercentage.Should().Be(1.0);
|
||||
report.SatisfiedCount.Should().Be(report.TotalControls);
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - No Evidence ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegulatoryFramework.Nis2)]
|
||||
[InlineData(RegulatoryFramework.Dora)]
|
||||
[InlineData(RegulatoryFramework.Iso27001)]
|
||||
[InlineData(RegulatoryFramework.EuCra)]
|
||||
public async Task GenerateReportAsync_NoEvidence_ZeroCompliance(RegulatoryFramework framework)
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
framework, "sha256:abc123", NoEvidence());
|
||||
|
||||
report.CompliancePercentage.Should().Be(0.0);
|
||||
report.SatisfiedCount.Should().Be(0);
|
||||
report.MeetsMinimumCompliance.Should().BeFalse();
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - Partial Evidence ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_PartialEvidence_PartialCompliance()
|
||||
{
|
||||
var partial = ImmutableHashSet.Create(EvidenceArtifactType.Sbom);
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", partial);
|
||||
|
||||
report.CompliancePercentage.Should().BeGreaterThan(0.0);
|
||||
report.CompliancePercentage.Should().BeLessThan(1.0);
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - Subject and Metadata ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_RecordsSubjectRef()
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:subject123", AllEvidence());
|
||||
|
||||
report.SubjectRef.Should().Be("sha256:subject123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_RecordsFramework()
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Dora, "sha256:abc", AllEvidence());
|
||||
|
||||
report.Framework.Should().Be(RegulatoryFramework.Dora);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_SetsGeneratedAt()
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", AllEvidence());
|
||||
|
||||
report.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - Artifact Refs ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_WithArtifactRefs_IncludesInResult()
|
||||
{
|
||||
var refs = ImmutableDictionary<EvidenceArtifactType, ImmutableArray<string>>.Empty
|
||||
.Add(EvidenceArtifactType.Sbom, ["sha256:sbom-ref-1"]);
|
||||
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc",
|
||||
ImmutableHashSet.Create(EvidenceArtifactType.Sbom),
|
||||
refs);
|
||||
|
||||
var sbomControls = report.Controls
|
||||
.Where(c => c.IsSatisfied && c.SatisfyingArtifacts.Contains("sha256:sbom-ref-1"))
|
||||
.ToList();
|
||||
|
||||
sbomControls.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - Gap Descriptions ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_UnsatisfiedControl_HasGapDescription()
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", NoEvidence());
|
||||
|
||||
var unsatisfied = report.Controls.Where(c => !c.IsSatisfied).ToList();
|
||||
unsatisfied.Should().NotBeEmpty();
|
||||
foreach (var control in unsatisfied)
|
||||
control.GapDescription.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_SatisfiedControl_NoGapDescription()
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", AllEvidence());
|
||||
|
||||
var satisfied = report.Controls.Where(c => c.IsSatisfied).ToList();
|
||||
satisfied.Should().NotBeEmpty();
|
||||
foreach (var control in satisfied)
|
||||
control.GapDescription.Should().BeNull();
|
||||
}
|
||||
|
||||
// --- Null Protection ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_NullSubjectRef_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, null!, AllEvidence());
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_NullEvidence_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", null!);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_CancellationToken_Respected()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", AllEvidence(), ct: cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// --- Determinism ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_Deterministic()
|
||||
{
|
||||
var evidence = AllEvidence();
|
||||
var r1 = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", evidence);
|
||||
var r2 = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", evidence);
|
||||
|
||||
r1.TotalControls.Should().Be(r2.TotalControls);
|
||||
r1.SatisfiedCount.Should().Be(r2.SatisfiedCount);
|
||||
r1.CompliancePercentage.Should().Be(r2.CompliancePercentage);
|
||||
r1.MeetsMinimumCompliance.Should().Be(r2.MeetsMinimumCompliance);
|
||||
}
|
||||
|
||||
// --- Constructor Validation ---
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => new ComplianceReportGenerator(TimeProvider.System, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_UsesSystem()
|
||||
{
|
||||
var sut = new ComplianceReportGenerator(null, _meterFactory);
|
||||
sut.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// --- Mandatory vs Optional Controls ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_OptionalControlsMissing_StillMeetsMinimum()
|
||||
{
|
||||
// DORA has one non-mandatory control (DORA-Art11) — provide evidence for all mandatory ones
|
||||
var evidence = ImmutableHashSet.Create(
|
||||
EvidenceArtifactType.PolicyEvaluation,
|
||||
EvidenceArtifactType.SignedAttestation,
|
||||
EvidenceArtifactType.VerificationReceipt,
|
||||
EvidenceArtifactType.IncidentReport,
|
||||
EvidenceArtifactType.VexStatement,
|
||||
EvidenceArtifactType.Sbom,
|
||||
EvidenceArtifactType.ProvenanceAttestation,
|
||||
EvidenceArtifactType.ReachabilityAnalysis,
|
||||
EvidenceArtifactType.ProofBundle);
|
||||
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Dora, "sha256:abc", evidence);
|
||||
|
||||
report.MeetsMinimumCompliance.Should().BeTrue();
|
||||
}
|
||||
|
||||
// --- NIS2 Specific Controls ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("NIS2-Art21.2d", "Supply Chain Security")]
|
||||
[InlineData("NIS2-Art21.2e", "Supply Chain Security")]
|
||||
[InlineData("NIS2-Art21.2a", "Risk Management")]
|
||||
[InlineData("NIS2-Art21.2g", "Risk Management")]
|
||||
[InlineData("NIS2-Art23", "Incident Management")]
|
||||
public void Nis2Controls_HaveExpectedCategory(string controlId, string expectedCategory)
|
||||
{
|
||||
var controls = _sut.GetControls(RegulatoryFramework.Nis2);
|
||||
var control = controls.First(c => c.ControlId == controlId);
|
||||
control.Category.Should().Be(expectedCategory);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestComplianceMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly ConcurrentBag<Meter> _meters = [];
|
||||
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var meter in _meters)
|
||||
meter.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexFindingsServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_023_Attestor_vex_findings_api_with_proof_artifacts
|
||||
// Task: T1 — Tests for VEX findings API with proof artifacts
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Findings;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Findings;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Model tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class VexFindingsModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProofArtifactKind_has_six_values()
|
||||
{
|
||||
Enum.GetValues<ProofArtifactKind>().Should().HaveCount(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFindingStatus_has_four_values()
|
||||
{
|
||||
Enum.GetValues<VexFindingStatus>().Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofArtifact_default_content_type()
|
||||
{
|
||||
var artifact = new ProofArtifact
|
||||
{
|
||||
Kind = ProofArtifactKind.DsseSignature,
|
||||
Digest = "sha256:abc",
|
||||
Payload = new ReadOnlyMemory<byte>([1, 2]),
|
||||
ProducedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
artifact.ContentType.Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFinding_HasSignatureProof_true_when_dsse_present()
|
||||
{
|
||||
var finding = MakeFinding("f1", ProofArtifactKind.DsseSignature);
|
||||
finding.HasSignatureProof.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFinding_HasSignatureProof_false_when_no_dsse()
|
||||
{
|
||||
var finding = MakeFinding("f1", ProofArtifactKind.RekorReceipt);
|
||||
finding.HasSignatureProof.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFinding_HasRekorReceipt_true_when_present()
|
||||
{
|
||||
var finding = MakeFinding("f1", ProofArtifactKind.RekorReceipt);
|
||||
finding.HasRekorReceipt.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFinding_HasRekorReceipt_false_when_absent()
|
||||
{
|
||||
var finding = MakeFinding("f1", ProofArtifactKind.MerkleProof);
|
||||
finding.HasRekorReceipt.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFindingQuery_defaults()
|
||||
{
|
||||
var query = new VexFindingQuery();
|
||||
query.Limit.Should().Be(100);
|
||||
query.Offset.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFindingQueryResult_HasMore()
|
||||
{
|
||||
var result = new VexFindingQueryResult
|
||||
{
|
||||
Findings = ImmutableArray.Create(MakeFinding("f1", ProofArtifactKind.DsseSignature)),
|
||||
TotalCount = 10,
|
||||
Offset = 0
|
||||
};
|
||||
|
||||
result.HasMore.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFindingQueryResult_HasMore_false_when_all_returned()
|
||||
{
|
||||
var result = new VexFindingQueryResult
|
||||
{
|
||||
Findings = ImmutableArray.Create(MakeFinding("f1", ProofArtifactKind.DsseSignature)),
|
||||
TotalCount = 1,
|
||||
Offset = 0
|
||||
};
|
||||
|
||||
result.HasMore.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static VexFinding MakeFinding(string id, ProofArtifactKind proofKind)
|
||||
{
|
||||
return new VexFinding
|
||||
{
|
||||
FindingId = id,
|
||||
VulnerabilityId = "CVE-2025-0001",
|
||||
ComponentPurl = "pkg:npm/test@1.0",
|
||||
Status = VexFindingStatus.NotAffected,
|
||||
ProofArtifacts = ImmutableArray.Create(new ProofArtifact
|
||||
{
|
||||
Kind = proofKind,
|
||||
Digest = $"sha256:{id}",
|
||||
Payload = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("proof")),
|
||||
ProducedAt = DateTimeOffset.UtcNow
|
||||
}),
|
||||
DeterminedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Service tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class VexFindingsServiceTests
|
||||
{
|
||||
private readonly VexFindingsService _service;
|
||||
|
||||
public VexFindingsServiceTests()
|
||||
{
|
||||
var meterFactory = new TestFindingsMeterFactory();
|
||||
_service = new VexFindingsService(meterFactory);
|
||||
}
|
||||
|
||||
// ── UpsertAsync ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_stores_and_returns_finding()
|
||||
{
|
||||
var finding = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0");
|
||||
|
||||
var result = await _service.UpsertAsync(finding);
|
||||
|
||||
result.FindingId.Should().NotBeNullOrWhiteSpace();
|
||||
result.VulnerabilityId.Should().Be("CVE-2025-0001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_generates_deterministic_id_when_empty()
|
||||
{
|
||||
var finding1 = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0") with { FindingId = "" };
|
||||
var finding2 = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0") with { FindingId = "" };
|
||||
|
||||
var r1 = await _service.UpsertAsync(finding1);
|
||||
var r2 = await _service.UpsertAsync(finding2);
|
||||
|
||||
r1.FindingId.Should().Be(r2.FindingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_preserves_explicit_id()
|
||||
{
|
||||
var finding = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0") with { FindingId = "custom-id" };
|
||||
|
||||
var result = await _service.UpsertAsync(finding);
|
||||
|
||||
result.FindingId.Should().Be("custom-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_overwrites_on_same_id()
|
||||
{
|
||||
var v1 = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0") with
|
||||
{
|
||||
FindingId = "dup",
|
||||
Status = VexFindingStatus.UnderInvestigation
|
||||
};
|
||||
var v2 = v1 with { Status = VexFindingStatus.Fixed };
|
||||
|
||||
await _service.UpsertAsync(v1);
|
||||
await _service.UpsertAsync(v2);
|
||||
|
||||
var stored = await _service.GetByIdAsync("dup");
|
||||
stored!.Status.Should().Be(VexFindingStatus.Fixed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_null_throws()
|
||||
{
|
||||
var act = () => _service.UpsertAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── GetByIdAsync ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_returns_stored_finding()
|
||||
{
|
||||
var finding = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0") with { FindingId = "get-test" };
|
||||
await _service.UpsertAsync(finding);
|
||||
|
||||
var result = await _service.GetByIdAsync("get-test");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.VulnerabilityId.Should().Be("CVE-2025-0001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_returns_null_for_missing()
|
||||
{
|
||||
var result = await _service.GetByIdAsync("nonexistent");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_empty_id_throws()
|
||||
{
|
||||
var act = () => _service.GetByIdAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── QueryAsync ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_returns_all_when_no_filter()
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with { FindingId = "q1" });
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0002", "pkg:npm/b@2") with { FindingId = "q2" });
|
||||
|
||||
var result = await _service.QueryAsync(new VexFindingQuery());
|
||||
|
||||
result.Findings.Should().HaveCount(2);
|
||||
result.TotalCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_filters_by_vulnerability_id()
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with { FindingId = "fv1" });
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0002", "pkg:npm/b@2") with { FindingId = "fv2" });
|
||||
|
||||
var result = await _service.QueryAsync(new VexFindingQuery { VulnerabilityId = "CVE-2025-0001" });
|
||||
|
||||
result.Findings.Should().HaveCount(1);
|
||||
result.Findings[0].VulnerabilityId.Should().Be("CVE-2025-0001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_filters_by_component_prefix()
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0001", "pkg:npm/foo@1") with { FindingId = "fc1" });
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0002", "pkg:maven/bar@2") with { FindingId = "fc2" });
|
||||
|
||||
var result = await _service.QueryAsync(new VexFindingQuery { ComponentPurlPrefix = "pkg:npm/" });
|
||||
|
||||
result.Findings.Should().HaveCount(1);
|
||||
result.Findings[0].ComponentPurl.Should().StartWith("pkg:npm/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_filters_by_status()
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with
|
||||
{
|
||||
FindingId = "fs1",
|
||||
Status = VexFindingStatus.Affected
|
||||
});
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0002", "pkg:npm/b@2") with
|
||||
{
|
||||
FindingId = "fs2",
|
||||
Status = VexFindingStatus.NotAffected
|
||||
});
|
||||
|
||||
var result = await _service.QueryAsync(new VexFindingQuery { Status = VexFindingStatus.Affected });
|
||||
|
||||
result.Findings.Should().HaveCount(1);
|
||||
result.Findings[0].Status.Should().Be(VexFindingStatus.Affected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_pagination()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding($"CVE-2025-{i:D4}", $"pkg:npm/lib{i}@1") with
|
||||
{
|
||||
FindingId = $"pg{i}"
|
||||
});
|
||||
}
|
||||
|
||||
var page1 = await _service.QueryAsync(new VexFindingQuery { Limit = 2, Offset = 0 });
|
||||
var page2 = await _service.QueryAsync(new VexFindingQuery { Limit = 2, Offset = 2 });
|
||||
|
||||
page1.Findings.Should().HaveCount(2);
|
||||
page1.HasMore.Should().BeTrue();
|
||||
page2.Findings.Should().HaveCount(2);
|
||||
page2.HasMore.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_deterministic_ordering()
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0002", "pkg:npm/b@1") with { FindingId = "od1" });
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with { FindingId = "od2" });
|
||||
|
||||
var result = await _service.QueryAsync(new VexFindingQuery());
|
||||
|
||||
result.Findings[0].VulnerabilityId.Should().Be("CVE-2025-0001");
|
||||
result.Findings[1].VulnerabilityId.Should().Be("CVE-2025-0002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_null_throws()
|
||||
{
|
||||
var act = () => _service.QueryAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── ResolveProofsAsync ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveProofsAsync_merges_new_proofs()
|
||||
{
|
||||
var original = CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with { FindingId = "rp1" };
|
||||
await _service.UpsertAsync(original);
|
||||
|
||||
var additionalProof = new ProofArtifact
|
||||
{
|
||||
Kind = ProofArtifactKind.MerkleProof,
|
||||
Digest = "sha256:merkle",
|
||||
Payload = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("merkle-proof")),
|
||||
ProducedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var withNewProof = original with
|
||||
{
|
||||
ProofArtifacts = ImmutableArray.Create(additionalProof)
|
||||
};
|
||||
|
||||
var resolved = await _service.ResolveProofsAsync(withNewProof);
|
||||
|
||||
resolved.ProofArtifacts.Length.Should().Be(2); // original DSSE + new Merkle
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveProofsAsync_deduplicates_by_digest()
|
||||
{
|
||||
var original = CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with { FindingId = "rp2" };
|
||||
await _service.UpsertAsync(original);
|
||||
|
||||
// Same digest as original
|
||||
var duplicate = original with { ProofArtifacts = original.ProofArtifacts };
|
||||
|
||||
var resolved = await _service.ResolveProofsAsync(duplicate);
|
||||
|
||||
resolved.ProofArtifacts.Length.Should().Be(1); // no duplicate added
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveProofsAsync_returns_input_when_not_in_store()
|
||||
{
|
||||
var finding = CreateFinding("CVE-2025-0099", "pkg:npm/new@1") with { FindingId = "notfound" };
|
||||
|
||||
var resolved = await _service.ResolveProofsAsync(finding);
|
||||
|
||||
resolved.Should().Be(finding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveProofsAsync_null_throws()
|
||||
{
|
||||
var act = () => _service.ResolveProofsAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── ComputeFindingId ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeFindingId_is_deterministic()
|
||||
{
|
||||
var id1 = VexFindingsService.ComputeFindingId("CVE-2025-0001", "pkg:npm/test@1.0");
|
||||
var id2 = VexFindingsService.ComputeFindingId("CVE-2025-0001", "pkg:npm/test@1.0");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
id1.Should().StartWith("finding:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFindingId_differs_for_different_inputs()
|
||||
{
|
||||
var id1 = VexFindingsService.ComputeFindingId("CVE-2025-0001", "pkg:npm/a@1");
|
||||
var id2 = VexFindingsService.ComputeFindingId("CVE-2025-0002", "pkg:npm/a@1");
|
||||
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_throws()
|
||||
{
|
||||
var act = () => new VexFindingsService(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private static VexFinding CreateFinding(string vulnId, string purl) => new()
|
||||
{
|
||||
FindingId = $"finding-{vulnId}-{purl}",
|
||||
VulnerabilityId = vulnId,
|
||||
ComponentPurl = purl,
|
||||
Status = VexFindingStatus.NotAffected,
|
||||
Justification = "vulnerable_code_not_in_execute_path",
|
||||
ProofArtifacts = ImmutableArray.Create(new ProofArtifact
|
||||
{
|
||||
Kind = ProofArtifactKind.DsseSignature,
|
||||
Digest = $"sha256:{vulnId}:{purl}",
|
||||
Payload = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes($"dsse-{vulnId}")),
|
||||
ProducedAt = DateTimeOffset.UtcNow
|
||||
}),
|
||||
DeterminedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Test meter factory
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
file sealed class TestFindingsMeterFactory : IMeterFactory
|
||||
{
|
||||
public Meter Create(MeterOptions options) => new(options);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryFingerprintStoreTests.cs
|
||||
// Sprint: SPRINT_20260208_004_Attestor_binary_fingerprint_store_and_trust_scoring
|
||||
// Task: T1 — Deterministic tests for fingerprint store
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Attestor.ProofChain.FingerprintStore;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.FingerprintStore;
|
||||
|
||||
public sealed class BinaryFingerprintStoreTests : IDisposable
|
||||
{
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly TestMeterFactory _meterFactory = new();
|
||||
private readonly BinaryFingerprintStore _store;
|
||||
|
||||
public BinaryFingerprintStoreTests()
|
||||
{
|
||||
_store = new BinaryFingerprintStore(
|
||||
_time,
|
||||
NullLogger<BinaryFingerprintStore>.Instance,
|
||||
_meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meterFactory.Dispose();
|
||||
}
|
||||
|
||||
// ── Registration ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Register_NewFingerprint_ReturnsRecordWithContentAddressedId()
|
||||
{
|
||||
var reg = CreateRegistration();
|
||||
var record = await _store.RegisterAsync(reg);
|
||||
|
||||
record.Should().NotBeNull();
|
||||
record.FingerprintId.Should().StartWith("fp:");
|
||||
record.Format.Should().Be("elf");
|
||||
record.Architecture.Should().Be("x86_64");
|
||||
record.FileSha256.Should().Be("abc123");
|
||||
record.CreatedAt.Should().Be(_time.GetUtcNow());
|
||||
record.TrustScore.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_SameInputTwice_ReturnsExistingIdempotently()
|
||||
{
|
||||
var reg = CreateRegistration();
|
||||
var first = await _store.RegisterAsync(reg);
|
||||
var second = await _store.RegisterAsync(reg);
|
||||
|
||||
second.FingerprintId.Should().Be(first.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_DifferentSections_ProducesDifferentIds()
|
||||
{
|
||||
var reg1 = CreateRegistration();
|
||||
var reg2 = CreateRegistration(sectionHashes: ImmutableDictionary<string, string>.Empty
|
||||
.Add(".text", "different_hash"));
|
||||
|
||||
var r1 = await _store.RegisterAsync(reg1);
|
||||
var r2 = await _store.RegisterAsync(reg2);
|
||||
|
||||
r2.FingerprintId.Should().NotBe(r1.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_NullInput_Throws()
|
||||
{
|
||||
var act = () => _store.RegisterAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_EmptyFormat_Throws()
|
||||
{
|
||||
var reg = CreateRegistration(format: "");
|
||||
var act = () => _store.RegisterAsync(reg);
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── Lookup ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_ExistingRecord_Returns()
|
||||
{
|
||||
var reg = CreateRegistration();
|
||||
var created = await _store.RegisterAsync(reg);
|
||||
|
||||
var found = await _store.GetByIdAsync(created.FingerprintId);
|
||||
found.Should().NotBeNull();
|
||||
found!.FingerprintId.Should().Be(created.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_NonExistent_ReturnsNull()
|
||||
{
|
||||
var found = await _store.GetByIdAsync("fp:nonexistent");
|
||||
found.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFileSha256_ExistingRecord_Returns()
|
||||
{
|
||||
var reg = CreateRegistration();
|
||||
var created = await _store.RegisterAsync(reg);
|
||||
|
||||
var found = await _store.GetByFileSha256Async("abc123");
|
||||
found.Should().NotBeNull();
|
||||
found!.FingerprintId.Should().Be(created.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFileSha256_NonExistent_ReturnsNull()
|
||||
{
|
||||
var found = await _store.GetByFileSha256Async("nonexistent_sha");
|
||||
found.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Section-hash matching ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task FindBySectionHashes_ExactMatch_ReturnsSimilarity1()
|
||||
{
|
||||
var sections = DefaultSectionHashes();
|
||||
var reg = CreateRegistration(sectionHashes: sections);
|
||||
await _store.RegisterAsync(reg);
|
||||
|
||||
var result = await _store.FindBySectionHashesAsync(sections);
|
||||
result.Should().NotBeNull();
|
||||
result!.Found.Should().BeTrue();
|
||||
result.SectionSimilarity.Should().Be(1.0);
|
||||
result.MatchedSections.Should().HaveCount(2);
|
||||
result.DifferingSections.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindBySectionHashes_PartialMatch_ReturnsPartialSimilarity()
|
||||
{
|
||||
var stored = DefaultSectionHashes();
|
||||
await _store.RegisterAsync(CreateRegistration(sectionHashes: stored));
|
||||
|
||||
var query = ImmutableDictionary<string, string>.Empty
|
||||
.Add(".text", "texthash123") // matches
|
||||
.Add(".rodata", "different"); // does not match
|
||||
|
||||
var result = await _store.FindBySectionHashesAsync(query, minSimilarity: 0.3);
|
||||
result.Should().NotBeNull();
|
||||
result!.SectionSimilarity.Should().Be(0.5); // 1 of 2 match
|
||||
result.MatchedSections.Should().Contain(".text");
|
||||
result.DifferingSections.Should().Contain(".rodata");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindBySectionHashes_BelowMinSimilarity_ReturnsNull()
|
||||
{
|
||||
var stored = DefaultSectionHashes();
|
||||
await _store.RegisterAsync(CreateRegistration(sectionHashes: stored));
|
||||
|
||||
var query = ImmutableDictionary<string, string>.Empty
|
||||
.Add(".text", "completely_different")
|
||||
.Add(".rodata", "also_different");
|
||||
|
||||
var result = await _store.FindBySectionHashesAsync(query, minSimilarity: 0.8);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindBySectionHashes_EmptyQuery_ReturnsNull()
|
||||
{
|
||||
await _store.RegisterAsync(CreateRegistration());
|
||||
var result = await _store.FindBySectionHashesAsync(ImmutableDictionary<string, string>.Empty);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Trust scoring ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeTrustScore_WithBuildIdAndPurl_HigherScore()
|
||||
{
|
||||
var reg = CreateRegistration(
|
||||
buildId: "gnu-build-id-123",
|
||||
packagePurl: "pkg:deb/debian/libc6@2.36",
|
||||
evidenceDigests: ["sha256:ev1", "sha256:ev2"]);
|
||||
var created = await _store.RegisterAsync(reg);
|
||||
|
||||
var breakdown = await _store.ComputeTrustScoreAsync(created.FingerprintId);
|
||||
breakdown.Score.Should().BeGreaterThan(0.3);
|
||||
breakdown.BuildIdScore.Should().BeGreaterThan(0);
|
||||
breakdown.ProvenanceScore.Should().BeGreaterThan(0);
|
||||
breakdown.EvidenceScore.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeTrustScore_MinimalRecord_LowerScore()
|
||||
{
|
||||
var reg = CreateRegistration(
|
||||
sectionHashes: ImmutableDictionary<string, string>.Empty.Add(".debug", "x"));
|
||||
var created = await _store.RegisterAsync(reg);
|
||||
|
||||
var breakdown = await _store.ComputeTrustScoreAsync(created.FingerprintId);
|
||||
breakdown.Score.Should().BeLessThan(0.3);
|
||||
breakdown.GoldenBonus.Should().Be(0);
|
||||
breakdown.BuildIdScore.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeTrustScore_NonExistent_Throws()
|
||||
{
|
||||
var act = () => _store.ComputeTrustScoreAsync("fp:nonexistent");
|
||||
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_Components_DeterministicWithSameInputs()
|
||||
{
|
||||
var sections = DefaultSectionHashes();
|
||||
var a = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, "build123", ["e1", "e2"], "pkg:deb/test@1", true);
|
||||
var b = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, "build123", ["e1", "e2"], "pkg:deb/test@1", true);
|
||||
|
||||
b.Score.Should().Be(a.Score);
|
||||
b.GoldenBonus.Should().Be(a.GoldenBonus);
|
||||
b.BuildIdScore.Should().Be(a.BuildIdScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_GoldenRecord_HasGoldenBonus()
|
||||
{
|
||||
var sections = DefaultSectionHashes();
|
||||
var nonGolden = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, null, [], null, false);
|
||||
var golden = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, null, [], null, true);
|
||||
|
||||
golden.GoldenBonus.Should().BeGreaterThan(0);
|
||||
golden.Score.Should().BeGreaterThan(nonGolden.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_ScoreCappedAtPoint99()
|
||||
{
|
||||
// Maximise all signals
|
||||
var sections = ImmutableDictionary<string, string>.Empty
|
||||
.Add(".text", "a").Add(".rodata", "b").Add(".data", "c").Add(".bss", "d");
|
||||
var breakdown = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, "build-id", ["e1", "e2", "e3", "e4", "e5"], "pkg:deb/x@1", true);
|
||||
|
||||
breakdown.Score.Should().BeLessOrEqualTo(0.99);
|
||||
}
|
||||
|
||||
// ── Golden set management ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGoldenSet_NewSet_ReturnsSet()
|
||||
{
|
||||
var gs = await _store.CreateGoldenSetAsync("baseline-v1", "Debian 12 baseline");
|
||||
gs.Name.Should().Be("baseline-v1");
|
||||
gs.Description.Should().Be("Debian 12 baseline");
|
||||
gs.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddToGoldenSet_ValidFingerprint_MarksAsGolden()
|
||||
{
|
||||
await _store.CreateGoldenSetAsync("baseline-v1");
|
||||
var reg = CreateRegistration();
|
||||
var created = await _store.RegisterAsync(reg);
|
||||
|
||||
var updated = await _store.AddToGoldenSetAsync(created.FingerprintId, "baseline-v1");
|
||||
updated.IsGolden.Should().BeTrue();
|
||||
updated.GoldenSetName.Should().Be("baseline-v1");
|
||||
updated.TrustScore.Should().BeGreaterThan(created.TrustScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddToGoldenSet_NonExistentSet_Throws()
|
||||
{
|
||||
var created = await _store.RegisterAsync(CreateRegistration());
|
||||
var act = () => _store.AddToGoldenSetAsync(created.FingerprintId, "nonexistent");
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFromGoldenSet_GoldenRecord_RemovesGoldenFlag()
|
||||
{
|
||||
await _store.CreateGoldenSetAsync("baseline-v1");
|
||||
var created = await _store.RegisterAsync(CreateRegistration());
|
||||
await _store.AddToGoldenSetAsync(created.FingerprintId, "baseline-v1");
|
||||
|
||||
var removed = await _store.RemoveFromGoldenSetAsync(created.FingerprintId);
|
||||
removed.IsGolden.Should().BeFalse();
|
||||
removed.GoldenSetName.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGoldenSetMembers_ReturnsOnlyGoldenRecords()
|
||||
{
|
||||
await _store.CreateGoldenSetAsync("baseline-v1");
|
||||
var reg1 = CreateRegistration(fileSha256: "sha1");
|
||||
var reg2 = CreateRegistration(fileSha256: "sha2",
|
||||
sectionHashes: ImmutableDictionary<string, string>.Empty.Add(".text", "other"));
|
||||
var r1 = await _store.RegisterAsync(reg1);
|
||||
await _store.RegisterAsync(reg2);
|
||||
await _store.AddToGoldenSetAsync(r1.FingerprintId, "baseline-v1");
|
||||
|
||||
var members = await _store.GetGoldenSetMembersAsync("baseline-v1");
|
||||
members.Should().HaveCount(1);
|
||||
members[0].FingerprintId.Should().Be(r1.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListGoldenSets_ReturnsAllSets()
|
||||
{
|
||||
await _store.CreateGoldenSetAsync("set-a");
|
||||
await _store.CreateGoldenSetAsync("set-b");
|
||||
|
||||
var sets = await _store.ListGoldenSetsAsync();
|
||||
sets.Should().HaveCount(2);
|
||||
sets.Select(s => s.Name).Should().BeEquivalentTo(["set-a", "set-b"]);
|
||||
}
|
||||
|
||||
// ── List and query ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByFormat_ReturnsMatchingOnly()
|
||||
{
|
||||
await _store.RegisterAsync(CreateRegistration(format: "elf"));
|
||||
await _store.RegisterAsync(CreateRegistration(format: "pe", fileSha256: "pe_sha",
|
||||
sectionHashes: ImmutableDictionary<string, string>.Empty.Add(".text", "pe_hash")));
|
||||
|
||||
var elfOnly = await _store.ListAsync(new FingerprintQuery { Format = "elf" });
|
||||
elfOnly.Should().HaveCount(1);
|
||||
elfOnly[0].Format.Should().Be("elf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByMinTrustScore_ExcludesLowScored()
|
||||
{
|
||||
// High trust: build ID + PURL + evidence + key sections
|
||||
await _store.RegisterAsync(CreateRegistration(
|
||||
buildId: "bid",
|
||||
packagePurl: "pkg:deb/test@1",
|
||||
evidenceDigests: ["e1", "e2", "e3"]));
|
||||
|
||||
// Low trust: no build ID, no PURL, no evidence, non-key sections
|
||||
await _store.RegisterAsync(CreateRegistration(
|
||||
fileSha256: "low_sha",
|
||||
sectionHashes: ImmutableDictionary<string, string>.Empty.Add(".debug", "x")));
|
||||
|
||||
var highOnly = await _store.ListAsync(new FingerprintQuery { MinTrustScore = 0.3 });
|
||||
highOnly.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ExistingRecord_RemovesAndReturnsTrue()
|
||||
{
|
||||
var created = await _store.RegisterAsync(CreateRegistration());
|
||||
var deleted = await _store.DeleteAsync(created.FingerprintId);
|
||||
deleted.Should().BeTrue();
|
||||
|
||||
var found = await _store.GetByIdAsync(created.FingerprintId);
|
||||
found.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_NonExistent_ReturnsFalse()
|
||||
{
|
||||
var deleted = await _store.DeleteAsync("fp:nonexistent");
|
||||
deleted.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── Content-addressed ID determinism ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeFingerprintId_SameInput_SameOutput()
|
||||
{
|
||||
var sections = DefaultSectionHashes();
|
||||
var a = BinaryFingerprintStore.ComputeFingerprintId("elf", "x86_64", sections);
|
||||
var b = BinaryFingerprintStore.ComputeFingerprintId("elf", "x86_64", sections);
|
||||
b.Should().Be(a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFingerprintId_DifferentInput_DifferentOutput()
|
||||
{
|
||||
var sections = DefaultSectionHashes();
|
||||
var a = BinaryFingerprintStore.ComputeFingerprintId("elf", "x86_64", sections);
|
||||
var b = BinaryFingerprintStore.ComputeFingerprintId("pe", "x86_64", sections);
|
||||
b.Should().NotBe(a);
|
||||
}
|
||||
|
||||
// ── Section similarity ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SectionSimilarity_IdenticalSections_Returns1()
|
||||
{
|
||||
var s = DefaultSectionHashes();
|
||||
var (similarity, matched, differing) = BinaryFingerprintStore.ComputeSectionSimilarity(s, s);
|
||||
similarity.Should().Be(1.0);
|
||||
matched.Should().HaveCount(2);
|
||||
differing.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SectionSimilarity_NoOverlap_Returns0()
|
||||
{
|
||||
var a = ImmutableDictionary<string, string>.Empty.Add(".text", "aaa");
|
||||
var b = ImmutableDictionary<string, string>.Empty.Add(".text", "bbb");
|
||||
var (similarity, matched, differing) = BinaryFingerprintStore.ComputeSectionSimilarity(a, b);
|
||||
similarity.Should().Be(0.0);
|
||||
matched.Should().BeEmpty();
|
||||
differing.Should().Contain(".text");
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static ImmutableDictionary<string, string> DefaultSectionHashes() =>
|
||||
ImmutableDictionary<string, string>.Empty
|
||||
.Add(".text", "texthash123")
|
||||
.Add(".rodata", "rodatahash456");
|
||||
|
||||
private static FingerprintRegistration CreateRegistration(
|
||||
string format = "elf",
|
||||
string architecture = "x86_64",
|
||||
string fileSha256 = "abc123",
|
||||
string? buildId = null,
|
||||
ImmutableDictionary<string, string>? sectionHashes = null,
|
||||
string? packagePurl = null,
|
||||
string? packageVersion = null,
|
||||
ImmutableArray<string>? evidenceDigests = null) =>
|
||||
new()
|
||||
{
|
||||
Format = format,
|
||||
Architecture = architecture,
|
||||
FileSha256 = fileSha256,
|
||||
BuildId = buildId,
|
||||
SectionHashes = sectionHashes ?? DefaultSectionHashes(),
|
||||
PackagePurl = packagePurl,
|
||||
PackageVersion = packageVersion,
|
||||
EvidenceDigests = evidenceDigests ?? []
|
||||
};
|
||||
|
||||
// ── Minimal IMeterFactory + FakeTimeProvider for tests ────────────────
|
||||
|
||||
private sealed class TestMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
_meters.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal fake time provider for deterministic tests.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset startTime) => _utcNow = startTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Graph;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Graph;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SubgraphVisualizationService"/>.
|
||||
/// </summary>
|
||||
public sealed class SubgraphVisualizationServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2025, 7, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly SubgraphVisualizationService _service = new();
|
||||
|
||||
private static ProofGraphSubgraph CreateSubgraph(
|
||||
string rootId = "root-1",
|
||||
int maxDepth = 5,
|
||||
ProofGraphNode[]? nodes = null,
|
||||
ProofGraphEdge[]? edges = null)
|
||||
{
|
||||
return new ProofGraphSubgraph
|
||||
{
|
||||
RootNodeId = rootId,
|
||||
MaxDepth = maxDepth,
|
||||
Nodes = nodes ?? [],
|
||||
Edges = edges ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static ProofGraphNode CreateNode(
|
||||
string id,
|
||||
ProofGraphNodeType type = ProofGraphNodeType.Artifact,
|
||||
string digest = "sha256:abc123") => new()
|
||||
{
|
||||
Id = id,
|
||||
Type = type,
|
||||
ContentDigest = digest,
|
||||
CreatedAt = FixedTime
|
||||
};
|
||||
|
||||
private static ProofGraphEdge CreateEdge(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
ProofGraphEdgeType type = ProofGraphEdgeType.DescribedBy) => new()
|
||||
{
|
||||
Id = $"{sourceId}->{type}->{targetId}",
|
||||
SourceId = sourceId,
|
||||
TargetId = targetId,
|
||||
Type = type,
|
||||
CreatedAt = FixedTime
|
||||
};
|
||||
|
||||
// --- Basic rendering ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_EmptySubgraph_ReturnsEmptyResult()
|
||||
{
|
||||
var subgraph = CreateSubgraph();
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
result.NodeCount.Should().Be(0);
|
||||
result.EdgeCount.Should().Be(0);
|
||||
result.RootNodeId.Should().Be("root-1");
|
||||
result.Format.Should().Be(SubgraphRenderFormat.Json);
|
||||
result.GeneratedAt.Should().Be(FixedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_SingleNode_ReturnsCorrectVisualization()
|
||||
{
|
||||
var subgraph = CreateSubgraph(
|
||||
rootId: "n1",
|
||||
nodes: [CreateNode("n1")]);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
result.NodeCount.Should().Be(1);
|
||||
result.Nodes[0].Id.Should().Be("n1");
|
||||
result.Nodes[0].IsRoot.Should().BeTrue();
|
||||
result.Nodes[0].Depth.Should().Be(0);
|
||||
result.Nodes[0].Type.Should().Be("Artifact");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_MultipleNodes_ComputesDepths()
|
||||
{
|
||||
var nodes = new[]
|
||||
{
|
||||
CreateNode("root", ProofGraphNodeType.Artifact),
|
||||
CreateNode("child1", ProofGraphNodeType.SbomDocument),
|
||||
CreateNode("child2", ProofGraphNodeType.VexStatement),
|
||||
CreateNode("grandchild", ProofGraphNodeType.InTotoStatement)
|
||||
};
|
||||
var edges = new[]
|
||||
{
|
||||
CreateEdge("root", "child1"),
|
||||
CreateEdge("root", "child2"),
|
||||
CreateEdge("child1", "grandchild")
|
||||
};
|
||||
|
||||
var subgraph = CreateSubgraph("root", nodes: nodes, edges: edges);
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
result.NodeCount.Should().Be(4);
|
||||
result.EdgeCount.Should().Be(3);
|
||||
|
||||
var rootViz = result.Nodes.First(n => n.Id == "root");
|
||||
rootViz.Depth.Should().Be(0);
|
||||
rootViz.IsRoot.Should().BeTrue();
|
||||
|
||||
var child1Viz = result.Nodes.First(n => n.Id == "child1");
|
||||
child1Viz.Depth.Should().Be(1);
|
||||
child1Viz.IsRoot.Should().BeFalse();
|
||||
|
||||
var grandchildViz = result.Nodes.First(n => n.Id == "grandchild");
|
||||
grandchildViz.Depth.Should().Be(2);
|
||||
}
|
||||
|
||||
// --- Mermaid format ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Mermaid_ContainsGraphDirective()
|
||||
{
|
||||
var subgraph = CreateSubgraph("n1", nodes: [CreateNode("n1")]);
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
|
||||
result.Format.Should().Be(SubgraphRenderFormat.Mermaid);
|
||||
result.Content.Should().Contain("graph TD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Mermaid_ContainsNodeAndEdge()
|
||||
{
|
||||
var nodes = new[] { CreateNode("n1"), CreateNode("n2", ProofGraphNodeType.SbomDocument) };
|
||||
var edges = new[] { CreateEdge("n1", "n2") };
|
||||
var subgraph = CreateSubgraph("n1", nodes: nodes, edges: edges);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
|
||||
result.Content.Should().Contain("n1");
|
||||
result.Content.Should().Contain("n2");
|
||||
result.Content.Should().Contain("described by");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Mermaid_ContainsClassDefinitions()
|
||||
{
|
||||
var subgraph = CreateSubgraph("n1", nodes: [CreateNode("n1")]);
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
|
||||
result.Content.Should().Contain("classDef artifact");
|
||||
result.Content.Should().Contain("classDef sbom");
|
||||
result.Content.Should().Contain("classDef attestation");
|
||||
}
|
||||
|
||||
// --- DOT format ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Dot_ContainsDigraphDirective()
|
||||
{
|
||||
var subgraph = CreateSubgraph("n1", nodes: [CreateNode("n1")]);
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Dot, FixedTime);
|
||||
|
||||
result.Format.Should().Be(SubgraphRenderFormat.Dot);
|
||||
result.Content.Should().Contain("digraph proof_subgraph");
|
||||
result.Content.Should().Contain("rankdir=TB");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Dot_ContainsNodeColors()
|
||||
{
|
||||
var nodes = new[]
|
||||
{
|
||||
CreateNode("n1", ProofGraphNodeType.Artifact),
|
||||
CreateNode("n2", ProofGraphNodeType.VexStatement)
|
||||
};
|
||||
var subgraph = CreateSubgraph("n1", nodes: nodes);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Dot, FixedTime);
|
||||
|
||||
result.Content.Should().Contain("#4CAF50"); // Artifact green
|
||||
result.Content.Should().Contain("#9C27B0"); // VEX purple
|
||||
}
|
||||
|
||||
// --- JSON format ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Json_IsValidJson()
|
||||
{
|
||||
var nodes = new[] { CreateNode("n1"), CreateNode("n2") };
|
||||
var edges = new[] { CreateEdge("n1", "n2") };
|
||||
var subgraph = CreateSubgraph("n1", nodes: nodes, edges: edges);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
var act = () => JsonDocument.Parse(result.Content);
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
// --- Edge labels ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(ProofGraphEdgeType.DescribedBy, "described by")]
|
||||
[InlineData(ProofGraphEdgeType.AttestedBy, "attested by")]
|
||||
[InlineData(ProofGraphEdgeType.HasVex, "has VEX")]
|
||||
[InlineData(ProofGraphEdgeType.SignedBy, "signed by")]
|
||||
[InlineData(ProofGraphEdgeType.ChainsTo, "chains to")]
|
||||
public async Task Render_EdgeTypes_HaveCorrectLabels(ProofGraphEdgeType edgeType, string expectedLabel)
|
||||
{
|
||||
var nodes = new[] { CreateNode("n1"), CreateNode("n2") };
|
||||
var edges = new[] { CreateEdge("n1", "n2", edgeType) };
|
||||
var subgraph = CreateSubgraph("n1", nodes: nodes, edges: edges);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
result.Edges[0].Label.Should().Be(expectedLabel);
|
||||
}
|
||||
|
||||
// --- Node types ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(ProofGraphNodeType.Artifact, "Artifact")]
|
||||
[InlineData(ProofGraphNodeType.SbomDocument, "SbomDocument")]
|
||||
[InlineData(ProofGraphNodeType.VexStatement, "VexStatement")]
|
||||
[InlineData(ProofGraphNodeType.SigningKey, "SigningKey")]
|
||||
public async Task Render_NodeTypes_PreservedInVisualization(ProofGraphNodeType nodeType, string expectedType)
|
||||
{
|
||||
var subgraph = CreateSubgraph("n1", nodes: [CreateNode("n1", nodeType)]);
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
result.Nodes[0].Type.Should().Be(expectedType);
|
||||
}
|
||||
|
||||
// --- Content digest truncation ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_LongDigest_TruncatedInLabel()
|
||||
{
|
||||
var node = CreateNode("n1", digest: "sha256:abcdef1234567890abcdef1234567890");
|
||||
var subgraph = CreateSubgraph("n1", nodes: [node]);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
|
||||
// Label should contain truncated digest
|
||||
result.Nodes[0].Label.Should().Contain("...");
|
||||
// Full digest should still be in ContentDigest
|
||||
result.Nodes[0].ContentDigest.Should().Be("sha256:abcdef1234567890abcdef1234567890");
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_CancelledToken_ThrowsOperationCancelled()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
var act = () => _service.RenderAsync(CreateSubgraph(), SubgraphRenderFormat.Json, FixedTime, cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// --- Null argument ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_NullSubgraph_Throws()
|
||||
{
|
||||
var act = () => _service.RenderAsync(null!, SubgraphRenderFormat.Json, FixedTime);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// --- Determinism ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_SameInput_ProducesSameOutput()
|
||||
{
|
||||
var nodes = new[] { CreateNode("n1"), CreateNode("n2") };
|
||||
var edges = new[] { CreateEdge("n1", "n2") };
|
||||
var subgraph = CreateSubgraph("n1", nodes: nodes, edges: edges);
|
||||
|
||||
var r1 = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
var r2 = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
|
||||
r1.Content.Should().Be(r2.Content);
|
||||
}
|
||||
|
||||
// --- All three formats produce output ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(SubgraphRenderFormat.Mermaid)]
|
||||
[InlineData(SubgraphRenderFormat.Dot)]
|
||||
[InlineData(SubgraphRenderFormat.Json)]
|
||||
public async Task Render_AllFormats_ProduceNonEmptyContent(SubgraphRenderFormat format)
|
||||
{
|
||||
var subgraph = CreateSubgraph("n1", nodes: [CreateNode("n1")]);
|
||||
var result = await _service.RenderAsync(subgraph, format, FixedTime);
|
||||
|
||||
result.Content.Should().NotBeNullOrWhiteSpace();
|
||||
result.Format.Should().Be(format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Cas;
|
||||
using StellaOps.Attestor.ProofChain.Idempotency;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Idempotency;
|
||||
|
||||
public sealed class IdempotentIngestServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestIdempotencyMeterFactory _meterFactory = new();
|
||||
private readonly InMemoryContentAddressedStore _store;
|
||||
private readonly IdempotentIngestService _sut;
|
||||
|
||||
public IdempotentIngestServiceTests()
|
||||
{
|
||||
_store = new InMemoryContentAddressedStore(
|
||||
TimeProvider.System,
|
||||
new Microsoft.Extensions.Logging.Abstractions.NullLogger<InMemoryContentAddressedStore>(),
|
||||
_meterFactory);
|
||||
_sut = new IdempotentIngestService(_store, TimeProvider.System, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
private static byte[] SbomBytes(string content = "test-sbom") =>
|
||||
Encoding.UTF8.GetBytes(content);
|
||||
|
||||
private static byte[] JsonAttestationBytes(string payload = "test") =>
|
||||
Encoding.UTF8.GetBytes($"{{\"payload\":\"{payload}\"}}");
|
||||
|
||||
private static string ComputeExpectedDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
// --- SBOM Ingest Tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_FirstSubmission_ReturnsNotDeduplicated()
|
||||
{
|
||||
var bytes = SbomBytes();
|
||||
var result = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/spdx+json"
|
||||
});
|
||||
|
||||
result.Deduplicated.Should().BeFalse();
|
||||
result.Digest.Should().Be(ComputeExpectedDigest(bytes));
|
||||
result.SbomEntryId.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_DuplicateSubmission_ReturnsDeduplicated()
|
||||
{
|
||||
var bytes = SbomBytes();
|
||||
var request = new SbomIngestRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/spdx+json"
|
||||
};
|
||||
|
||||
var first = await _sut.IngestSbomAsync(request);
|
||||
var second = await _sut.IngestSbomAsync(request);
|
||||
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.Digest.Should().Be(first.Digest);
|
||||
second.SbomEntryId.Digest.Should().Be(first.SbomEntryId.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_DifferentContent_DifferentDigest()
|
||||
{
|
||||
var result1 = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes("sbom-a"),
|
||||
MediaType = "application/spdx+json"
|
||||
});
|
||||
|
||||
var result2 = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes("sbom-b"),
|
||||
MediaType = "application/spdx+json"
|
||||
});
|
||||
|
||||
result1.Digest.Should().NotBe(result2.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_WithTags_StoresTags()
|
||||
{
|
||||
var tags = ImmutableDictionary<string, string>.Empty
|
||||
.Add("purl", "pkg:npm/test@1.0");
|
||||
var result = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes(),
|
||||
MediaType = "application/spdx+json",
|
||||
Tags = tags
|
||||
});
|
||||
|
||||
result.Artifact.Tags.Should().ContainKey("purl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_WithIdempotencyKey_ReturnsSameOnRetry()
|
||||
{
|
||||
var bytes = SbomBytes("idem-sbom");
|
||||
var first = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/spdx+json",
|
||||
IdempotencyKey = "key-001"
|
||||
});
|
||||
|
||||
// Second call with same key — returns deduplicated result
|
||||
var second = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes("different-content"),
|
||||
MediaType = "application/spdx+json",
|
||||
IdempotencyKey = "key-001"
|
||||
});
|
||||
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.Digest.Should().Be(first.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_EmptyContent_ThrowsArgument()
|
||||
{
|
||||
var act = () => _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = ReadOnlyMemory<byte>.Empty,
|
||||
MediaType = "application/spdx+json"
|
||||
});
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*Content*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_EmptyMediaType_ThrowsArgument()
|
||||
{
|
||||
var act = () => _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes(),
|
||||
MediaType = ""
|
||||
});
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*MediaType*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_NullRequest_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.IngestSbomAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_CancellationToken_Respected()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes(),
|
||||
MediaType = "application/spdx+json"
|
||||
}, cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_ArtifactType_IsSbom()
|
||||
{
|
||||
var result = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes(),
|
||||
MediaType = "application/spdx+json"
|
||||
});
|
||||
|
||||
result.Artifact.ArtifactType.Should().Be(CasArtifactType.Sbom);
|
||||
}
|
||||
|
||||
// --- Attestation Verify Tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_FirstSubmission_NoCacheHit()
|
||||
{
|
||||
var result = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
result.CacheHit.Should().BeFalse();
|
||||
result.Digest.Should().NotBeNullOrEmpty();
|
||||
result.Checks.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_DuplicateSubmission_CacheHit()
|
||||
{
|
||||
var bytes = JsonAttestationBytes();
|
||||
var request = new AttestationVerifyRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
};
|
||||
|
||||
var first = await _sut.VerifyAttestationAsync(request);
|
||||
var second = await _sut.VerifyAttestationAsync(request);
|
||||
|
||||
second.CacheHit.Should().BeTrue();
|
||||
second.Digest.Should().Be(first.Digest);
|
||||
second.Verified.Should().Be(first.Verified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_JsonContent_PassesStructureCheck()
|
||||
{
|
||||
var result = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
result.Verified.Should().BeTrue();
|
||||
result.Checks.Should().Contain(c => c.Check == "json_structure" && c.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_NonJsonContent_FailsStructureCheck()
|
||||
{
|
||||
var result = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("not-json-content"),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
result.Verified.Should().BeFalse();
|
||||
result.Checks.Should().Contain(c => c.Check == "json_structure" && !c.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_ChecksIncludeContentPresent()
|
||||
{
|
||||
var result = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
result.Checks.Should().Contain(c => c.Check == "content_present" && c.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_ChecksIncludeDigestFormat()
|
||||
{
|
||||
var result = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
result.Checks.Should().Contain(c => c.Check == "digest_format" && c.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_WithIdempotencyKey_CachesResult()
|
||||
{
|
||||
var bytes = JsonAttestationBytes("idem-test");
|
||||
var first = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/vnd.dsse.envelope+json",
|
||||
IdempotencyKey = "attest-key-001"
|
||||
});
|
||||
|
||||
// Different content, same key → should return cached result
|
||||
var second = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes("different"),
|
||||
MediaType = "application/vnd.dsse.envelope+json",
|
||||
IdempotencyKey = "attest-key-001"
|
||||
});
|
||||
|
||||
second.CacheHit.Should().BeTrue();
|
||||
second.Digest.Should().Be(first.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_NullRequest_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.VerifyAttestationAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_EmptyContent_ThrowsArgument()
|
||||
{
|
||||
var act = () => _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = ReadOnlyMemory<byte>.Empty,
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*Content*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_CancellationToken_Respected()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
}, cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_Deterministic()
|
||||
{
|
||||
var bytes = JsonAttestationBytes("deterministic-test");
|
||||
var request = new AttestationVerifyRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
};
|
||||
|
||||
// Create separate services with separate caches
|
||||
var store2 = new InMemoryContentAddressedStore(
|
||||
TimeProvider.System,
|
||||
new Microsoft.Extensions.Logging.Abstractions.NullLogger<InMemoryContentAddressedStore>(),
|
||||
_meterFactory);
|
||||
var sut2 = new IdempotentIngestService(store2, TimeProvider.System, _meterFactory);
|
||||
|
||||
var result1 = await _sut.VerifyAttestationAsync(request);
|
||||
var result2 = await sut2.VerifyAttestationAsync(request);
|
||||
|
||||
result1.Digest.Should().Be(result2.Digest);
|
||||
result1.Verified.Should().Be(result2.Verified);
|
||||
result1.Checks.Length.Should().Be(result2.Checks.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_SummaryReflectsOutcome()
|
||||
{
|
||||
var passing = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
passing.Summary.Should().Contain("passed");
|
||||
}
|
||||
|
||||
// --- Idempotency Key Lookup Tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task LookupIdempotencyKeyAsync_UnknownKey_ReturnsNull()
|
||||
{
|
||||
var result = await _sut.LookupIdempotencyKeyAsync("nonexistent");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LookupIdempotencyKeyAsync_AfterIngest_ReturnsEntry()
|
||||
{
|
||||
await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes(),
|
||||
MediaType = "application/spdx+json",
|
||||
IdempotencyKey = "lookup-test"
|
||||
});
|
||||
|
||||
var entry = await _sut.LookupIdempotencyKeyAsync("lookup-test");
|
||||
entry.Should().NotBeNull();
|
||||
entry!.Key.Should().Be("lookup-test");
|
||||
entry.OperationType.Should().Be("sbom-ingest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LookupIdempotencyKeyAsync_AfterVerify_ReturnsEntry()
|
||||
{
|
||||
await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json",
|
||||
IdempotencyKey = "verify-lookup"
|
||||
});
|
||||
|
||||
var entry = await _sut.LookupIdempotencyKeyAsync("verify-lookup");
|
||||
entry.Should().NotBeNull();
|
||||
entry!.OperationType.Should().Be("attest-verify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LookupIdempotencyKeyAsync_NullKey_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.LookupIdempotencyKeyAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// --- Constructor Validation ---
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullStore_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => new IdempotentIngestService(null!, TimeProvider.System, _meterFactory);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => new IdempotentIngestService(_store, TimeProvider.System, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_UsesSystem()
|
||||
{
|
||||
var sut = new IdempotentIngestService(_store, null, _meterFactory);
|
||||
sut.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestIdempotencyMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly ConcurrentBag<Meter> _meters = [];
|
||||
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var meter in _meters)
|
||||
meter.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LinkCaptureServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_015_Attestor_in_toto_link_attestation_capture
|
||||
// Task: T1 — Tests for LinkCaptureService
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.LinkCapture;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.LinkCapture;
|
||||
|
||||
internal sealed class TestLinkCaptureMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options) { var m = new Meter(options); _meters.Add(m); return m; }
|
||||
public void Dispose() { foreach (var m in _meters) m.Dispose(); }
|
||||
}
|
||||
|
||||
public sealed class LinkCaptureServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestLinkCaptureMeterFactory _meterFactory = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly LinkCaptureService _sut;
|
||||
|
||||
public LinkCaptureServiceTests()
|
||||
{
|
||||
_sut = new LinkCaptureService(_timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
private static LinkCaptureRequest CreateRequest(
|
||||
string step = "build",
|
||||
string functionary = "ci-bot",
|
||||
string[]? command = null,
|
||||
CapturedMaterial[]? materials = null,
|
||||
CapturedProduct[]? products = null,
|
||||
string? pipelineId = null,
|
||||
string? stepId = null) => new()
|
||||
{
|
||||
StepName = step,
|
||||
Functionary = functionary,
|
||||
Command = (command ?? ["make", "build"]).ToImmutableArray(),
|
||||
Materials = (materials ?? []).ToImmutableArray(),
|
||||
Products = (products ?? []).ToImmutableArray(),
|
||||
PipelineId = pipelineId,
|
||||
StepId = stepId
|
||||
};
|
||||
|
||||
private static CapturedMaterial CreateMaterial(string uri = "src/main.c", string digest = "abc123") =>
|
||||
new()
|
||||
{
|
||||
Uri = uri,
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = digest }
|
||||
};
|
||||
|
||||
private static CapturedProduct CreateProduct(string uri = "bin/app", string digest = "def456") =>
|
||||
new()
|
||||
{
|
||||
Uri = uri,
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = digest }
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Capture: basic
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_ValidRequest_ReturnsRecordWithDigest()
|
||||
{
|
||||
var result = await _sut.CaptureAsync(CreateRequest());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.LinkDigest.Should().StartWith("sha256:");
|
||||
result.Deduplicated.Should().BeFalse();
|
||||
result.LinkRecord.StepName.Should().Be("build");
|
||||
result.LinkRecord.Functionary.Should().Be("ci-bot");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_SetsTimestampFromProvider()
|
||||
{
|
||||
var expected = new DateTimeOffset(2026, 6, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(expected);
|
||||
|
||||
var result = await _sut.CaptureAsync(CreateRequest());
|
||||
|
||||
result.LinkRecord.CapturedAt.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_WithMaterialsAndProducts_RecordsAll()
|
||||
{
|
||||
var materials = new[] { CreateMaterial("a.c", "1"), CreateMaterial("b.c", "2") };
|
||||
var products = new[] { CreateProduct("app", "3") };
|
||||
var request = CreateRequest(materials: materials, products: products);
|
||||
|
||||
var result = await _sut.CaptureAsync(request);
|
||||
|
||||
result.LinkRecord.Materials.Should().HaveCount(2);
|
||||
result.LinkRecord.Products.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_WithEnvironment_RecordsContext()
|
||||
{
|
||||
var request = CreateRequest() with
|
||||
{
|
||||
Environment = new CapturedEnvironment
|
||||
{
|
||||
Hostname = "ci-node-1",
|
||||
OperatingSystem = "linux"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _sut.CaptureAsync(request);
|
||||
|
||||
result.LinkRecord.Environment.Should().NotBeNull();
|
||||
result.LinkRecord.Environment!.Hostname.Should().Be("ci-node-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_WithByproducts_RecordsByproducts()
|
||||
{
|
||||
var request = CreateRequest() with
|
||||
{
|
||||
Byproducts = new Dictionary<string, string> { ["log"] = "build output" }
|
||||
.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
var result = await _sut.CaptureAsync(request);
|
||||
|
||||
result.LinkRecord.Byproducts.Should().ContainKey("log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_WithPipelineAndStepId_RecordsIds()
|
||||
{
|
||||
var result = await _sut.CaptureAsync(
|
||||
CreateRequest(pipelineId: "pipe-42", stepId: "job-7"));
|
||||
|
||||
result.LinkRecord.PipelineId.Should().Be("pipe-42");
|
||||
result.LinkRecord.StepId.Should().Be("job-7");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Capture: deduplication
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_DuplicateRequest_ReturnsDeduplicated()
|
||||
{
|
||||
var request = CreateRequest();
|
||||
var first = await _sut.CaptureAsync(request);
|
||||
var second = await _sut.CaptureAsync(request);
|
||||
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.LinkDigest.Should().Be(first.LinkDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_DifferentStep_ProducesDifferentDigest()
|
||||
{
|
||||
var r1 = await _sut.CaptureAsync(CreateRequest(step: "build"));
|
||||
var r2 = await _sut.CaptureAsync(CreateRequest(step: "test"));
|
||||
|
||||
r1.LinkDigest.Should().NotBe(r2.LinkDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_DifferentFunctionary_ProducesDifferentDigest()
|
||||
{
|
||||
var r1 = await _sut.CaptureAsync(CreateRequest(functionary: "alice"));
|
||||
var r2 = await _sut.CaptureAsync(CreateRequest(functionary: "bob"));
|
||||
|
||||
r1.LinkDigest.Should().NotBe(r2.LinkDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_DifferentMaterials_ProducesDifferentDigest()
|
||||
{
|
||||
var r1 = await _sut.CaptureAsync(CreateRequest(
|
||||
materials: [CreateMaterial("a.c", "111")]));
|
||||
var r2 = await _sut.CaptureAsync(CreateRequest(
|
||||
materials: [CreateMaterial("b.c", "222")]));
|
||||
|
||||
r1.LinkDigest.Should().NotBe(r2.LinkDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_DigestIsDeterministic()
|
||||
{
|
||||
var materials = new[] { CreateMaterial("z.c", "z"), CreateMaterial("a.c", "a") };
|
||||
var materialsReversed = new[] { CreateMaterial("a.c", "a"), CreateMaterial("z.c", "z") };
|
||||
|
||||
var r1 = await _sut.CaptureAsync(CreateRequest(materials: materials));
|
||||
|
||||
// New service instance to ensure no state leakage
|
||||
using var factory2 = new TestLinkCaptureMeterFactory();
|
||||
var sut2 = new LinkCaptureService(_timeProvider, factory2);
|
||||
var r2 = await sut2.CaptureAsync(CreateRequest(materials: materialsReversed));
|
||||
|
||||
r1.LinkDigest.Should().Be(r2.LinkDigest, "materials order should not affect digest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_EnvironmentDoesNotAffectDigest()
|
||||
{
|
||||
var req1 = CreateRequest() with
|
||||
{
|
||||
Environment = new CapturedEnvironment { Hostname = "node-1" }
|
||||
};
|
||||
var req2 = CreateRequest() with
|
||||
{
|
||||
Environment = new CapturedEnvironment { Hostname = "node-2" }
|
||||
};
|
||||
|
||||
var r1 = await _sut.CaptureAsync(req1);
|
||||
|
||||
using var factory2 = new TestLinkCaptureMeterFactory();
|
||||
var sut2 = new LinkCaptureService(_timeProvider, factory2);
|
||||
var r2 = await sut2.CaptureAsync(req2);
|
||||
|
||||
r1.LinkDigest.Should().Be(r2.LinkDigest,
|
||||
"environment should be excluded from canonical hash");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Capture: validation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_NullRequest_Throws()
|
||||
{
|
||||
var act = () => _sut.CaptureAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_EmptyStepName_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => _sut.CaptureAsync(CreateRequest(step: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_EmptyFunctionary_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => _sut.CaptureAsync(CreateRequest(functionary: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.CaptureAsync(CreateRequest(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetByDigest
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_ExistingDigest_ReturnsRecord()
|
||||
{
|
||||
var capture = await _sut.CaptureAsync(CreateRequest());
|
||||
var record = await _sut.GetByDigestAsync(capture.LinkDigest);
|
||||
|
||||
record.Should().NotBeNull();
|
||||
record!.Digest.Should().Be(capture.LinkDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_UnknownDigest_ReturnsNull()
|
||||
{
|
||||
var record = await _sut.GetByDigestAsync("sha256:nonexistent");
|
||||
record.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_NullDigest_Throws()
|
||||
{
|
||||
var act = () => _sut.GetByDigestAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.GetByDigestAsync("sha256:abc", cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Query
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByStepName_FiltersCorrectly()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(step: "build"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "test"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "package"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery { StepName = "build" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].StepName.Should().Be("build");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByFunctionary_FiltersCorrectly()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(functionary: "alice"));
|
||||
await _sut.CaptureAsync(CreateRequest(functionary: "bob"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery { Functionary = "bob" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Functionary.Should().Be("bob");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByPipelineId_FiltersCorrectly()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(pipelineId: "pipe-1"));
|
||||
await _sut.CaptureAsync(CreateRequest(pipelineId: "pipe-2"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "other"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery { PipelineId = "pipe-1" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].PipelineId.Should().Be("pipe-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_CaseInsensitiveStepFilter()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(step: "Build"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery { StepName = "build" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_EmptyStore_ReturnsEmpty()
|
||||
{
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery());
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_NoFilters_ReturnsAll()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(step: "a", functionary: "x"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "b", functionary: "y"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery());
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_RespectsLimit()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(step: "a", functionary: "x"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "b", functionary: "y"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "c", functionary: "z"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery { Limit = 2 });
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_OrdersByDescendingTimestamp()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "first", functionary: "a"));
|
||||
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "second", functionary: "b"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery());
|
||||
|
||||
results[0].StepName.Should().Be("second");
|
||||
results[1].StepName.Should().Be("first");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_NullQuery_Throws()
|
||||
{
|
||||
var act = () => _sut.QueryAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.QueryAsync(new LinkCaptureQuery(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Constructor
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_Throws()
|
||||
{
|
||||
var act = () => new LinkCaptureService(null, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_UsesSystemDefault()
|
||||
{
|
||||
using var factory = new TestLinkCaptureMeterFactory();
|
||||
var sut = new LinkCaptureService(null, factory);
|
||||
|
||||
sut.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake TimeProvider for test control of timestamps.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public void SetUtcNow(DateTimeOffset value) => _now = value;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="EvidenceCoverageScorer"/>.
|
||||
/// </summary>
|
||||
public sealed class EvidenceCoverageScorerTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2025, 7, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed class CoverageScorerTestMeterFactory : IMeterFactory
|
||||
{
|
||||
public Meter Create(MeterOptions options) => new(options);
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private static EvidenceCoverageScorer CreateScorer(
|
||||
EvidenceCoveragePolicy? policy = null,
|
||||
Func<string, bool>? resolver = null)
|
||||
{
|
||||
return new EvidenceCoverageScorer(
|
||||
policy ?? new EvidenceCoveragePolicy(),
|
||||
resolver ?? (_ => true),
|
||||
new CoverageScorerTestMeterFactory());
|
||||
}
|
||||
|
||||
private static DimensionEvidenceInput Input(EvidenceDimension dim, params string[] ids) => new()
|
||||
{
|
||||
Dimension = dim,
|
||||
EvidenceIds = [..ids]
|
||||
};
|
||||
|
||||
// --- Full coverage (all resolvable) ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_AllDimensionsFullyResolvable_ReturnsGreen()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1", "r2"),
|
||||
Input(EvidenceDimension.BinaryAnalysis, "b1"),
|
||||
Input(EvidenceDimension.SbomCompleteness, "s1", "s2", "s3"),
|
||||
Input(EvidenceDimension.VexCoverage, "v1"),
|
||||
Input(EvidenceDimension.Provenance, "p1")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
result.OverallScore.Should().Be(1.0);
|
||||
result.CoveragePercentage.Should().Be(100.0);
|
||||
result.CoverageLevel.Should().Be(CoverageLevel.Green);
|
||||
result.MeetsAiGatingThreshold.Should().BeTrue();
|
||||
result.SubjectRef.Should().Be("pkg:test@1.0");
|
||||
result.EvaluatedAt.Should().Be(FixedTime);
|
||||
result.Dimensions.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
// --- No evidence at all ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_NoEvidenceProvided_ReturnsRedWithZeroScore()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var inputs = new List<DimensionEvidenceInput>();
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
result.OverallScore.Should().Be(0.0);
|
||||
result.CoverageLevel.Should().Be(CoverageLevel.Red);
|
||||
result.MeetsAiGatingThreshold.Should().BeFalse();
|
||||
}
|
||||
|
||||
// --- Partial coverage ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_PartialResolvable_ReturnsCorrectScore()
|
||||
{
|
||||
// Resolver returns true only for "good-*" IDs
|
||||
var scorer = CreateScorer(resolver: id => id.StartsWith("good", StringComparison.Ordinal));
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "good-1", "bad-1"), // 0.5
|
||||
Input(EvidenceDimension.BinaryAnalysis, "good-1", "good-2"), // 1.0
|
||||
Input(EvidenceDimension.SbomCompleteness, "bad-1", "bad-2"), // 0.0
|
||||
Input(EvidenceDimension.VexCoverage, "good-1"), // 1.0
|
||||
Input(EvidenceDimension.Provenance, "good-1") // 1.0
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
// Weighted: (0.5*0.25 + 1.0*0.20 + 0.0*0.25 + 1.0*0.20 + 1.0*0.10) / 1.0
|
||||
// = (0.125 + 0.20 + 0.0 + 0.20 + 0.10) / 1.0 = 0.625
|
||||
result.OverallScore.Should().BeApproximately(0.625, 0.001);
|
||||
result.CoverageLevel.Should().Be(CoverageLevel.Yellow);
|
||||
result.MeetsAiGatingThreshold.Should().BeFalse();
|
||||
}
|
||||
|
||||
// --- Per-dimension breakdown ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_DimensionResultsIncludeCorrectCounts()
|
||||
{
|
||||
var resolver = (string id) => id != "unresolvable";
|
||||
var scorer = CreateScorer(resolver: resolver);
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "a", "b", "unresolvable")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
var reachDim = result.Dimensions.First(d => d.Dimension == EvidenceDimension.Reachability);
|
||||
reachDim.EvidenceCount.Should().Be(3);
|
||||
reachDim.ResolvableCount.Should().Be(2);
|
||||
reachDim.Score.Should().BeApproximately(2.0 / 3.0, 0.001);
|
||||
reachDim.Reason.Should().Contain("2 of 3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_MissingDimension_GetsZeroScore()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
var binaryDim = result.Dimensions.First(d => d.Dimension == EvidenceDimension.BinaryAnalysis);
|
||||
binaryDim.Score.Should().Be(0.0);
|
||||
binaryDim.EvidenceCount.Should().Be(0);
|
||||
binaryDim.Reason.Should().Contain("No evidence");
|
||||
}
|
||||
|
||||
// --- Gating threshold ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_ExactlyAtThreshold_MeetsGating()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy { AiGatingThreshold = 1.0 };
|
||||
var scorer = CreateScorer(policy: policy);
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1"),
|
||||
Input(EvidenceDimension.BinaryAnalysis, "b1"),
|
||||
Input(EvidenceDimension.SbomCompleteness, "s1"),
|
||||
Input(EvidenceDimension.VexCoverage, "v1"),
|
||||
Input(EvidenceDimension.Provenance, "p1")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
result.MeetsAiGatingThreshold.Should().BeTrue();
|
||||
scorer.MeetsGatingThreshold(result).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_BelowThreshold_FailsGating()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy { AiGatingThreshold = 0.99 };
|
||||
var scorer = CreateScorer(policy: policy, resolver: _ => false);
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
result.MeetsAiGatingThreshold.Should().BeFalse();
|
||||
}
|
||||
|
||||
// --- Coverage levels ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_CustomThresholds_CorrectLevel()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy
|
||||
{
|
||||
GreenThreshold = 0.90,
|
||||
YellowThreshold = 0.60,
|
||||
// Only use reachability for simplicity
|
||||
ReachabilityWeight = 1.0,
|
||||
BinaryAnalysisWeight = 0.0,
|
||||
SbomCompletenessWeight = 0.0,
|
||||
VexCoverageWeight = 0.0,
|
||||
ProvenanceWeight = 0.0
|
||||
};
|
||||
|
||||
var scorer = CreateScorer(policy: policy);
|
||||
|
||||
// 7 of 10 resolvable = 0.70 → Yellow
|
||||
var resolver70 = (string id) => int.TryParse(id, out var n) && n <= 7;
|
||||
var scorer70 = new EvidenceCoverageScorer(policy, resolver70, new CoverageScorerTestMeterFactory());
|
||||
var inputs = Enumerable.Range(1, 10).Select(i => i.ToString()).ToArray();
|
||||
var result = await scorer70.ComputeCoverageAsync("test", [Input(EvidenceDimension.Reachability, inputs)], FixedTime);
|
||||
result.CoverageLevel.Should().Be(CoverageLevel.Yellow);
|
||||
}
|
||||
|
||||
// --- Policy validation ---
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeWeight_Throws()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy { ReachabilityWeight = -0.1 };
|
||||
var act = () => CreateScorer(policy: policy);
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*non-negative*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidGatingThreshold_Throws()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy { AiGatingThreshold = 1.5 };
|
||||
var act = () => CreateScorer(policy: policy);
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*gating*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_GreenBelowYellow_Throws()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy { GreenThreshold = 0.40, YellowThreshold = 0.60 };
|
||||
var act = () => CreateScorer(policy: policy);
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*Green*yellow*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullPolicy_Throws()
|
||||
{
|
||||
var act = () => new EvidenceCoverageScorer(null!, _ => true, new CoverageScorerTestMeterFactory());
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullResolver_Throws()
|
||||
{
|
||||
var act = () => new EvidenceCoverageScorer(new EvidenceCoveragePolicy(), null!, new CoverageScorerTestMeterFactory());
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_Throws()
|
||||
{
|
||||
var act = () => new EvidenceCoverageScorer(new EvidenceCoveragePolicy(), _ => true, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_CancelledToken_ThrowsOperationCancelled()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
var act = () => scorer.ComputeCoverageAsync("test", [], FixedTime, cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// --- Null arguments ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_NullSubjectRef_Throws()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var act = () => scorer.ComputeCoverageAsync(null!, [], FixedTime);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_NullInputs_Throws()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var act = () => scorer.ComputeCoverageAsync("test", null!, FixedTime);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeetsGatingThreshold_NullResult_Throws()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var act = () => scorer.MeetsGatingThreshold(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// --- Determinism ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_SameInputs_ProduceSameResult()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1", "r2"),
|
||||
Input(EvidenceDimension.BinaryAnalysis, "b1")
|
||||
};
|
||||
|
||||
var r1 = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
var r2 = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
r1.OverallScore.Should().Be(r2.OverallScore);
|
||||
r1.CoverageLevel.Should().Be(r2.CoverageLevel);
|
||||
r1.MeetsAiGatingThreshold.Should().Be(r2.MeetsAiGatingThreshold);
|
||||
}
|
||||
|
||||
// --- Default policy ---
|
||||
|
||||
[Fact]
|
||||
public void DefaultPolicy_HasExpectedDefaults()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy();
|
||||
|
||||
policy.ReachabilityWeight.Should().Be(0.25);
|
||||
policy.BinaryAnalysisWeight.Should().Be(0.20);
|
||||
policy.SbomCompletenessWeight.Should().Be(0.25);
|
||||
policy.VexCoverageWeight.Should().Be(0.20);
|
||||
policy.ProvenanceWeight.Should().Be(0.10);
|
||||
policy.AiGatingThreshold.Should().Be(0.80);
|
||||
policy.GreenThreshold.Should().Be(0.80);
|
||||
policy.YellowThreshold.Should().Be(0.50);
|
||||
}
|
||||
|
||||
// --- All dimensions fully covered with reason text ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_FullyCovered_DimensionReasonSaysAll()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("test", inputs, FixedTime);
|
||||
var reachDim = result.Dimensions.First(d => d.Dimension == EvidenceDimension.Reachability);
|
||||
reachDim.Reason.Should().Contain("All 1 evidence items resolvable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
using StellaOps.Attestor.ProofChain.Receipts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Receipts;
|
||||
|
||||
public sealed class FieldOwnershipValidatorTests
|
||||
{
|
||||
private readonly FieldOwnershipValidator _sut = new();
|
||||
|
||||
private static VerificationReceipt CreateFullReceipt() => new()
|
||||
{
|
||||
ProofBundleId = new ProofBundleId("abc123"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor-001"),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks =
|
||||
[
|
||||
new VerificationCheck
|
||||
{
|
||||
Check = "signature",
|
||||
Status = VerificationResult.Pass,
|
||||
KeyId = "key-1",
|
||||
Expected = "sha256:aaa",
|
||||
Actual = "sha256:aaa",
|
||||
LogIndex = 42,
|
||||
Details = "Signature valid"
|
||||
}
|
||||
],
|
||||
ToolDigests = new Dictionary<string, string> { ["verifier"] = "sha256:bbb" }
|
||||
};
|
||||
|
||||
private static VerificationReceipt CreateMinimalReceipt() => new()
|
||||
{
|
||||
ProofBundleId = new ProofBundleId("min-123"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor-min"),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks =
|
||||
[
|
||||
new VerificationCheck
|
||||
{
|
||||
Check = "basic",
|
||||
Status = VerificationResult.Pass
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// --- ReceiptOwnershipMap Tests ---
|
||||
|
||||
[Fact]
|
||||
public void ReceiptOwnershipMap_ReturnsDefaultMap()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
map.Should().NotBeNull();
|
||||
map.DocumentType.Should().Be("VerificationReceipt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptOwnershipMap_ContainsExpectedEntries()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
map.Entries.Should().HaveCountGreaterOrEqualTo(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptOwnershipMap_HasTopLevelFields()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
var topLevel = map.Entries
|
||||
.Where(e => !e.FieldPath.StartsWith("checks[]"))
|
||||
.Select(e => e.FieldPath)
|
||||
.ToList();
|
||||
|
||||
topLevel.Should().Contain("proofBundleId");
|
||||
topLevel.Should().Contain("verifiedAt");
|
||||
topLevel.Should().Contain("verifierVersion");
|
||||
topLevel.Should().Contain("anchorId");
|
||||
topLevel.Should().Contain("result");
|
||||
topLevel.Should().Contain("checks");
|
||||
topLevel.Should().Contain("toolDigests");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptOwnershipMap_HasCheckFields()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
var checkFields = map.Entries
|
||||
.Where(e => e.FieldPath.StartsWith("checks[]"))
|
||||
.Select(e => e.FieldPath)
|
||||
.ToList();
|
||||
|
||||
checkFields.Should().Contain("checks[].check");
|
||||
checkFields.Should().Contain("checks[].status");
|
||||
checkFields.Should().Contain("checks[].keyId");
|
||||
checkFields.Should().Contain("checks[].logIndex");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("proofBundleId", OwnerModule.Core)]
|
||||
[InlineData("verifiedAt", OwnerModule.Core)]
|
||||
[InlineData("verifierVersion", OwnerModule.Core)]
|
||||
[InlineData("anchorId", OwnerModule.Verification)]
|
||||
[InlineData("result", OwnerModule.Verification)]
|
||||
[InlineData("checks", OwnerModule.Verification)]
|
||||
[InlineData("toolDigests", OwnerModule.Core)]
|
||||
public void ReceiptOwnershipMap_CorrectOwnerAssignment(string fieldPath, OwnerModule expectedOwner)
|
||||
{
|
||||
var entry = _sut.ReceiptOwnershipMap.Entries
|
||||
.First(e => e.FieldPath == fieldPath);
|
||||
entry.Owner.Should().Be(expectedOwner);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("checks[].keyId", OwnerModule.Signing)]
|
||||
[InlineData("checks[].logIndex", OwnerModule.Rekor)]
|
||||
[InlineData("checks[].check", OwnerModule.Verification)]
|
||||
[InlineData("checks[].status", OwnerModule.Verification)]
|
||||
public void ReceiptOwnershipMap_CheckFieldOwners(string fieldPath, OwnerModule expectedOwner)
|
||||
{
|
||||
var entry = _sut.ReceiptOwnershipMap.Entries
|
||||
.First(e => e.FieldPath == fieldPath);
|
||||
entry.Owner.Should().Be(expectedOwner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptOwnershipMap_AllEntriesHaveDescriptions()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
foreach (var entry in map.Entries)
|
||||
{
|
||||
entry.Description.Should().NotBeNullOrWhiteSpace(
|
||||
$"Entry '{entry.FieldPath}' should have a description");
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateReceiptOwnershipAsync Tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_FullReceipt_IsValid()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.DocumentType.Should().Be("VerificationReceipt");
|
||||
result.MissingRequiredCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_FullReceipt_PopulatesAllFields()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.TotalFields.Should().BeGreaterThan(0);
|
||||
result.PopulatedCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_MinimalReceipt_IsValid()
|
||||
{
|
||||
var receipt = CreateMinimalReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.MissingRequiredCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_MinimalReceipt_OptionalFieldsNotPopulated()
|
||||
{
|
||||
var receipt = CreateMinimalReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
// ToolDigests is optional and not set
|
||||
var toolDigests = result.Fields.FirstOrDefault(f => f.FieldPath == "toolDigests");
|
||||
toolDigests.Should().NotBeNull();
|
||||
toolDigests!.IsPopulated.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_RecordsValidatedAt()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(receipt, now);
|
||||
|
||||
result.ValidatedAt.Should().Be(now);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_EmptyChecks_MissingRequired()
|
||||
{
|
||||
var receipt = new VerificationReceipt
|
||||
{
|
||||
ProofBundleId = new ProofBundleId("abc"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor"),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks = []
|
||||
};
|
||||
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.MissingRequiredCount.Should().BeGreaterThan(0);
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_MultipleChecks_GeneratesFieldsForEach()
|
||||
{
|
||||
var receipt = new VerificationReceipt
|
||||
{
|
||||
ProofBundleId = new ProofBundleId("abc"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor"),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks =
|
||||
[
|
||||
new VerificationCheck { Check = "sig", Status = VerificationResult.Pass },
|
||||
new VerificationCheck { Check = "digest", Status = VerificationResult.Pass }
|
||||
]
|
||||
};
|
||||
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
var checkFields = result.Fields
|
||||
.Where(f => f.FieldPath == "checks[].check")
|
||||
.ToList();
|
||||
checkFields.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_AllOwnershipIsValid()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.ValidCount.Should().Be(result.TotalFields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_NullReceipt_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.ValidateReceiptOwnershipAsync(null!, DateTimeOffset.UtcNow);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_CancellationToken_Respected()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow, cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_Deterministic()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var result1 = await _sut.ValidateReceiptOwnershipAsync(receipt, now);
|
||||
var result2 = await _sut.ValidateReceiptOwnershipAsync(receipt, now);
|
||||
|
||||
result1.TotalFields.Should().Be(result2.TotalFields);
|
||||
result1.PopulatedCount.Should().Be(result2.PopulatedCount);
|
||||
result1.ValidCount.Should().Be(result2.ValidCount);
|
||||
result1.MissingRequiredCount.Should().Be(result2.MissingRequiredCount);
|
||||
result1.IsValid.Should().Be(result2.IsValid);
|
||||
}
|
||||
|
||||
// --- DefaultReceiptMap Static Tests ---
|
||||
|
||||
[Fact]
|
||||
public void DefaultReceiptMap_SchemaVersion_IsSet()
|
||||
{
|
||||
var map = FieldOwnershipValidator.DefaultReceiptMap;
|
||||
// SchemaVersion defaults to "1.0"
|
||||
map.SchemaVersion.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultReceiptMap_RequiredFields_AreMarked()
|
||||
{
|
||||
var map = FieldOwnershipValidator.DefaultReceiptMap;
|
||||
var requiredTopLevel = map.Entries
|
||||
.Where(e => e.IsRequired && !e.FieldPath.StartsWith("checks[]"))
|
||||
.Select(e => e.FieldPath)
|
||||
.ToList();
|
||||
|
||||
requiredTopLevel.Should().Contain("proofBundleId");
|
||||
requiredTopLevel.Should().Contain("verifiedAt");
|
||||
requiredTopLevel.Should().Contain("verifierVersion");
|
||||
requiredTopLevel.Should().Contain("anchorId");
|
||||
requiredTopLevel.Should().Contain("result");
|
||||
requiredTopLevel.Should().Contain("checks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultReceiptMap_OptionalFields_AreMarked()
|
||||
{
|
||||
var map = FieldOwnershipValidator.DefaultReceiptMap;
|
||||
var optionalTopLevel = map.Entries
|
||||
.Where(e => !e.IsRequired && !e.FieldPath.StartsWith("checks[]"))
|
||||
.Select(e => e.FieldPath)
|
||||
.ToList();
|
||||
|
||||
optionalTopLevel.Should().Contain("toolDigests");
|
||||
}
|
||||
|
||||
// --- FieldOwnershipValidationResult Computed Properties ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidationResult_ComputedProperties_AreCorrect()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.TotalFields.Should().Be(result.Fields.Length);
|
||||
result.PopulatedCount.Should().Be(
|
||||
result.Fields.Count(f => f.IsPopulated));
|
||||
result.ValidCount.Should().Be(
|
||||
result.Fields.Count(f => f.OwnershipValid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReceiptSidebarServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_024_Attestor_vex_receipt_sidebar
|
||||
// Task: T1 — Tests for receipt sidebar models and service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
using StellaOps.Attestor.ProofChain.Receipts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Receipts;
|
||||
|
||||
file sealed class TestSidebarMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
_meters.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model tests ────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class ReceiptSidebarModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReceiptVerificationStatus_has_four_values()
|
||||
{
|
||||
Enum.GetValues<ReceiptVerificationStatus>().Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptCheckDetail_roundtrips_properties()
|
||||
{
|
||||
var detail = new ReceiptCheckDetail
|
||||
{
|
||||
Name = "dsse-signature",
|
||||
Passed = true,
|
||||
KeyId = "key-1",
|
||||
LogIndex = 42,
|
||||
Detail = "Signature valid"
|
||||
};
|
||||
|
||||
detail.Name.Should().Be("dsse-signature");
|
||||
detail.Passed.Should().BeTrue();
|
||||
detail.KeyId.Should().Be("key-1");
|
||||
detail.LogIndex.Should().Be(42);
|
||||
detail.Detail.Should().Be("Signature valid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptCheckDetail_optional_properties_default_to_null()
|
||||
{
|
||||
var detail = new ReceiptCheckDetail { Name = "basic", Passed = false };
|
||||
detail.KeyId.Should().BeNull();
|
||||
detail.LogIndex.Should().BeNull();
|
||||
detail.Detail.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptSidebarDetail_computes_check_counts()
|
||||
{
|
||||
var detail = CreateSidebarDetail([
|
||||
new ReceiptCheckDetail { Name = "a", Passed = true },
|
||||
new ReceiptCheckDetail { Name = "b", Passed = false },
|
||||
new ReceiptCheckDetail { Name = "c", Passed = true }
|
||||
]);
|
||||
|
||||
detail.TotalChecks.Should().Be(3);
|
||||
detail.PassedChecks.Should().Be(2);
|
||||
detail.FailedChecks.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptSidebarDetail_empty_checks_returns_zero_counts()
|
||||
{
|
||||
var detail = CreateSidebarDetail([]);
|
||||
|
||||
detail.TotalChecks.Should().Be(0);
|
||||
detail.PassedChecks.Should().Be(0);
|
||||
detail.FailedChecks.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexReceiptSidebarContext_defaults()
|
||||
{
|
||||
var detail = CreateSidebarDetail([]);
|
||||
var ctx = new VexReceiptSidebarContext { Receipt = detail };
|
||||
|
||||
ctx.Decision.Should().BeNull();
|
||||
ctx.Justification.Should().BeNull();
|
||||
ctx.EvidenceRefs.Should().BeEmpty();
|
||||
ctx.FindingId.Should().BeNull();
|
||||
ctx.VulnerabilityId.Should().BeNull();
|
||||
ctx.ComponentPurl.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexReceiptSidebarContext_roundtrips_full()
|
||||
{
|
||||
var detail = CreateSidebarDetail([]);
|
||||
var ctx = new VexReceiptSidebarContext
|
||||
{
|
||||
Receipt = detail,
|
||||
Decision = "not_affected",
|
||||
Justification = "component_not_present",
|
||||
EvidenceRefs = ["ref-1", "ref-2"],
|
||||
FindingId = "finding:abc",
|
||||
VulnerabilityId = "CVE-2025-0001",
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.21"
|
||||
};
|
||||
|
||||
ctx.Decision.Should().Be("not_affected");
|
||||
ctx.Justification.Should().Be("component_not_present");
|
||||
ctx.EvidenceRefs.Should().HaveCount(2);
|
||||
ctx.FindingId.Should().Be("finding:abc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptSidebarRequest_defaults()
|
||||
{
|
||||
var req = new ReceiptSidebarRequest { BundleId = "sha256:abc" };
|
||||
req.IncludeChecks.Should().BeTrue();
|
||||
req.IncludeToolDigests.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static ReceiptSidebarDetail CreateSidebarDetail(ImmutableArray<ReceiptCheckDetail> checks) => new()
|
||||
{
|
||||
BundleId = "sha256:test",
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = Guid.Empty.ToString(),
|
||||
VerificationStatus = ReceiptVerificationStatus.Verified,
|
||||
Checks = checks
|
||||
};
|
||||
}
|
||||
|
||||
// ── Service tests ──────────────────────────────────────────────────────
|
||||
|
||||
public sealed class ReceiptSidebarServiceTests : IDisposable
|
||||
{
|
||||
private static readonly Guid AnchorGuid = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private readonly TestSidebarMeterFactory _meterFactory = new();
|
||||
private readonly ReceiptSidebarService _sut;
|
||||
|
||||
public ReceiptSidebarServiceTests()
|
||||
{
|
||||
_sut = new ReceiptSidebarService(_meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
// ── FormatReceipt ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_maps_bundle_id()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:abc123");
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.BundleId.Should().Contain("abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_maps_anchor_id()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x");
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.AnchorId.Should().Be(AnchorGuid.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_maps_verifier_version()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x");
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.VerifierVersion.Should().Be("2.1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_all_pass_returns_verified()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
MakeCheck("dsse-signature", VerificationResult.Pass),
|
||||
MakeCheck("rekor-inclusion", VerificationResult.Pass)
|
||||
]);
|
||||
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.VerificationStatus.Should().Be(ReceiptVerificationStatus.Verified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_mixed_returns_partially_verified()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
MakeCheck("dsse-signature", VerificationResult.Pass),
|
||||
MakeCheck("policy-check", VerificationResult.Fail)
|
||||
]);
|
||||
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.VerificationStatus.Should().Be(ReceiptVerificationStatus.PartiallyVerified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_all_fail_returns_failed()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
MakeCheck("sig", VerificationResult.Fail),
|
||||
MakeCheck("hash", VerificationResult.Fail)
|
||||
]);
|
||||
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.VerificationStatus.Should().Be(ReceiptVerificationStatus.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_no_checks_returns_unverified()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", []);
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.VerificationStatus.Should().Be(ReceiptVerificationStatus.Unverified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_sets_dsse_verified_when_dsse_check_passes()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
MakeCheck("dsse-envelope-signature", VerificationResult.Pass)
|
||||
]);
|
||||
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.DsseVerified.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_dsse_not_verified_when_dsse_check_fails()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
MakeCheck("dsse-envelope-signature", VerificationResult.Fail)
|
||||
]);
|
||||
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.DsseVerified.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_sets_rekor_verified_when_rekor_check_passes()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
MakeCheck("rekor-inclusion-proof", VerificationResult.Pass, logIndex: 100)
|
||||
]);
|
||||
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.RekorInclusionVerified.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_rekor_not_verified_when_absent()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
MakeCheck("basic-hash", VerificationResult.Pass)
|
||||
]);
|
||||
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.RekorInclusionVerified.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_maps_check_details()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
MakeCheck("sig-check", VerificationResult.Pass, keyId: "key-1", details: "Valid signature")
|
||||
]);
|
||||
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.Checks.Should().ContainSingle();
|
||||
|
||||
var check = detail.Checks[0];
|
||||
check.Name.Should().Be("sig-check");
|
||||
check.Passed.Should().BeTrue();
|
||||
check.KeyId.Should().Be("key-1");
|
||||
check.Detail.Should().Be("Valid signature");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_formats_expected_actual_when_no_details()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
new VerificationCheck
|
||||
{
|
||||
Check = "digest-match",
|
||||
Status = VerificationResult.Fail,
|
||||
Expected = "sha256:aaa",
|
||||
Actual = "sha256:bbb"
|
||||
}
|
||||
]);
|
||||
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.Checks[0].Detail.Should().Contain("Expected: sha256:aaa");
|
||||
detail.Checks[0].Detail.Should().Contain("Actual: sha256:bbb");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_maps_tool_digests()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", toolDigests: new Dictionary<string, string>
|
||||
{
|
||||
["verifier"] = "sha256:vvv",
|
||||
["scanner"] = "sha256:sss"
|
||||
});
|
||||
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.ToolDigests.Should().NotBeNull();
|
||||
detail.ToolDigests!.Should().HaveCount(2);
|
||||
detail.ToolDigests["verifier"].Should().Be("sha256:vvv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_null_tool_digests_stays_null()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x");
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
detail.ToolDigests.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatReceipt_throws_on_null()
|
||||
{
|
||||
var act = () => _sut.FormatReceipt(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── GetDetailAsync ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailAsync_returns_null_for_unknown_bundle()
|
||||
{
|
||||
var request = new ReceiptSidebarRequest { BundleId = "sha256:unknown" };
|
||||
var result = await _sut.GetDetailAsync(request);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailAsync_returns_detail_for_registered_receipt()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:abc");
|
||||
_sut.Register(receipt);
|
||||
|
||||
var request = new ReceiptSidebarRequest { BundleId = receipt.ProofBundleId.ToString() };
|
||||
var result = await _sut.GetDetailAsync(request);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.VerifierVersion.Should().Be("2.1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailAsync_excludes_checks_when_requested()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:abc", [
|
||||
MakeCheck("sig", VerificationResult.Pass)
|
||||
]);
|
||||
_sut.Register(receipt);
|
||||
|
||||
var request = new ReceiptSidebarRequest
|
||||
{
|
||||
BundleId = receipt.ProofBundleId.ToString(),
|
||||
IncludeChecks = false
|
||||
};
|
||||
var result = await _sut.GetDetailAsync(request);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Checks.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailAsync_excludes_tool_digests_when_not_requested()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:abc", toolDigests: new Dictionary<string, string>
|
||||
{
|
||||
["tool"] = "sha256:ttt"
|
||||
});
|
||||
_sut.Register(receipt);
|
||||
|
||||
var request = new ReceiptSidebarRequest
|
||||
{
|
||||
BundleId = receipt.ProofBundleId.ToString(),
|
||||
IncludeToolDigests = false
|
||||
};
|
||||
var result = await _sut.GetDetailAsync(request);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.ToolDigests.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailAsync_throws_on_null_request()
|
||||
{
|
||||
var act = () => _sut.GetDetailAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── GetContextAsync ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetContextAsync_returns_null_for_unknown_bundle()
|
||||
{
|
||||
var result = await _sut.GetContextAsync("sha256:nope");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContextAsync_returns_registered_context()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:ctx");
|
||||
var detail = _sut.FormatReceipt(receipt);
|
||||
var ctx = new VexReceiptSidebarContext
|
||||
{
|
||||
Receipt = detail,
|
||||
Decision = "not_affected",
|
||||
VulnerabilityId = "CVE-2025-0001"
|
||||
};
|
||||
_sut.RegisterContext(receipt.ProofBundleId.ToString(), ctx);
|
||||
|
||||
var result = await _sut.GetContextAsync(receipt.ProofBundleId.ToString());
|
||||
result.Should().NotBeNull();
|
||||
result!.Decision.Should().Be("not_affected");
|
||||
result.VulnerabilityId.Should().Be("CVE-2025-0001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContextAsync_falls_back_to_receipt_only_context()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:fallback");
|
||||
_sut.Register(receipt);
|
||||
|
||||
var result = await _sut.GetContextAsync(receipt.ProofBundleId.ToString());
|
||||
result.Should().NotBeNull();
|
||||
result!.Decision.Should().BeNull();
|
||||
result.Receipt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContextAsync_throws_on_null_or_empty()
|
||||
{
|
||||
var act1 = () => _sut.GetContextAsync(null!);
|
||||
var act2 = () => _sut.GetContextAsync("");
|
||||
var act3 = () => _sut.GetContextAsync(" ");
|
||||
|
||||
await act1.Should().ThrowAsync<ArgumentException>();
|
||||
await act2.Should().ThrowAsync<ArgumentException>();
|
||||
await act3.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── DeriveVerificationStatus (internal, tested via FormatReceipt) ──
|
||||
|
||||
[Fact]
|
||||
public void DeriveVerificationStatus_handles_single_pass()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
MakeCheck("only", VerificationResult.Pass)
|
||||
]);
|
||||
|
||||
var status = ReceiptSidebarService.DeriveVerificationStatus(receipt);
|
||||
status.Should().Be(ReceiptVerificationStatus.Verified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveVerificationStatus_handles_single_fail()
|
||||
{
|
||||
var receipt = CreateReceipt("sha256:x", [
|
||||
MakeCheck("only", VerificationResult.Fail)
|
||||
]);
|
||||
|
||||
var status = ReceiptSidebarService.DeriveVerificationStatus(receipt);
|
||||
status.Should().Be(ReceiptVerificationStatus.Failed);
|
||||
}
|
||||
|
||||
// ── Register ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Register_throws_on_null()
|
||||
{
|
||||
var act = () => _sut.Register(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterContext_throws_on_null_or_empty_bundleId()
|
||||
{
|
||||
var detail = _sut.FormatReceipt(CreateReceipt("sha256:x", []));
|
||||
var ctx = new VexReceiptSidebarContext { Receipt = detail };
|
||||
|
||||
var act1 = () => _sut.RegisterContext(null!, ctx);
|
||||
var act2 = () => _sut.RegisterContext("", ctx);
|
||||
var act3 = () => _sut.RegisterContext(" ", ctx);
|
||||
|
||||
act1.Should().Throw<ArgumentException>();
|
||||
act2.Should().Throw<ArgumentException>();
|
||||
act3.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private static VerificationReceipt CreateReceipt(
|
||||
string digest,
|
||||
List<VerificationCheck>? checks = null,
|
||||
IReadOnlyDictionary<string, string>? toolDigests = null) => new()
|
||||
{
|
||||
ProofBundleId = new ProofBundleId(digest),
|
||||
VerifiedAt = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero),
|
||||
VerifierVersion = "2.1.0",
|
||||
AnchorId = new TrustAnchorId(AnchorGuid),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks = checks ?? [],
|
||||
ToolDigests = toolDigests
|
||||
};
|
||||
|
||||
private static VerificationCheck MakeCheck(
|
||||
string name,
|
||||
VerificationResult status,
|
||||
string? keyId = null,
|
||||
long? logIndex = null,
|
||||
string? details = null) => new()
|
||||
{
|
||||
Check = name,
|
||||
Status = status,
|
||||
KeyId = keyId,
|
||||
LogIndex = logIndex,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Rekor;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Rekor;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DsseEnvelopeSizeGuard"/>.
|
||||
/// </summary>
|
||||
public sealed class DsseEnvelopeSizeGuardTests
|
||||
{
|
||||
private sealed class SizeGuardTestMeterFactory : IMeterFactory
|
||||
{
|
||||
public Meter Create(MeterOptions options) => new(options);
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private static DsseEnvelopeSizeGuard CreateGuard(DsseEnvelopeSizePolicy? policy = null)
|
||||
=> new(policy, new SizeGuardTestMeterFactory());
|
||||
|
||||
private static DsseEnvelope CreateEnvelope(int payloadSizeBytes)
|
||||
{
|
||||
// Create a DSSE envelope with a payload of the specified approximate size.
|
||||
// The payload is Base64-encoded, so we need fewer raw bytes.
|
||||
var rawBytes = new byte[(payloadSizeBytes * 3) / 4];
|
||||
Array.Fill(rawBytes, (byte)'A');
|
||||
var payload = Convert.ToBase64String(rawBytes);
|
||||
|
||||
return new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = payload,
|
||||
Signatures =
|
||||
[
|
||||
new DsseSignature
|
||||
{
|
||||
KeyId = "test-key-id",
|
||||
Sig = "dGVzdC1zaWduYXR1cmU="
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// --- Full envelope (under soft limit) ---
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_SmallEnvelope_ReturnsFullEnvelopeMode()
|
||||
{
|
||||
var guard = CreateGuard();
|
||||
var envelope = CreateEnvelope(100);
|
||||
|
||||
var result = await guard.ValidateAsync(envelope);
|
||||
|
||||
result.Mode.Should().Be(EnvelopeSubmissionMode.FullEnvelope);
|
||||
result.IsAccepted.Should().BeTrue();
|
||||
result.PayloadDigest.Should().BeNull();
|
||||
result.ChunkManifest.Should().BeNull();
|
||||
result.RejectionReason.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_ExactlySoftLimit_ReturnsFullEnvelope()
|
||||
{
|
||||
// With a 200 byte soft limit, a small envelope should pass
|
||||
var policy = new DsseEnvelopeSizePolicy { SoftLimitBytes = 100_000, HardLimitBytes = 200_000 };
|
||||
var guard = CreateGuard(policy);
|
||||
var envelope = CreateEnvelope(100);
|
||||
|
||||
var result = await guard.ValidateAsync(envelope);
|
||||
|
||||
result.Mode.Should().Be(EnvelopeSubmissionMode.FullEnvelope);
|
||||
}
|
||||
|
||||
// --- Hash-only fallback ---
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_OverSoftLimit_HashOnlyEnabled_ReturnsHashOnlyMode()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy
|
||||
{
|
||||
SoftLimitBytes = 100,
|
||||
HardLimitBytes = 1_000_000,
|
||||
EnableHashOnlyFallback = true
|
||||
};
|
||||
var guard = CreateGuard(policy);
|
||||
var envelope = CreateEnvelope(1000);
|
||||
|
||||
var result = await guard.ValidateAsync(envelope);
|
||||
|
||||
result.Mode.Should().Be(EnvelopeSubmissionMode.HashOnly);
|
||||
result.IsAccepted.Should().BeTrue();
|
||||
result.PayloadDigest.Should().StartWith("sha256:");
|
||||
result.PayloadDigest!.Length.Should().Be(71); // "sha256:" + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_HashOnlyDigest_IsDeterministic()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy
|
||||
{
|
||||
SoftLimitBytes = 100,
|
||||
HardLimitBytes = 1_000_000,
|
||||
EnableHashOnlyFallback = true
|
||||
};
|
||||
var guard = CreateGuard(policy);
|
||||
var envelope = CreateEnvelope(1000);
|
||||
|
||||
var result1 = await guard.ValidateAsync(envelope);
|
||||
var result2 = await guard.ValidateAsync(envelope);
|
||||
|
||||
result1.PayloadDigest.Should().Be(result2.PayloadDigest);
|
||||
}
|
||||
|
||||
// --- Chunked mode ---
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_OverSoftLimit_ChunkingEnabled_ReturnsChunkedMode()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy
|
||||
{
|
||||
SoftLimitBytes = 100,
|
||||
HardLimitBytes = 1_000_000,
|
||||
ChunkSizeBytes = 512,
|
||||
EnableChunking = true,
|
||||
EnableHashOnlyFallback = true
|
||||
};
|
||||
var guard = CreateGuard(policy);
|
||||
var envelope = CreateEnvelope(2000);
|
||||
|
||||
var result = await guard.ValidateAsync(envelope);
|
||||
|
||||
result.Mode.Should().Be(EnvelopeSubmissionMode.Chunked);
|
||||
result.IsAccepted.Should().BeTrue();
|
||||
result.ChunkManifest.Should().NotBeNull();
|
||||
result.ChunkManifest!.ChunkCount.Should().BeGreaterThan(1);
|
||||
result.ChunkManifest.OriginalDigest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_Chunked_ManifestHasCorrectChunkCount()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy
|
||||
{
|
||||
SoftLimitBytes = 100,
|
||||
HardLimitBytes = 1_000_000,
|
||||
ChunkSizeBytes = 256,
|
||||
EnableChunking = true
|
||||
};
|
||||
var guard = CreateGuard(policy);
|
||||
var envelope = CreateEnvelope(1000);
|
||||
|
||||
var result = await guard.ValidateAsync(envelope);
|
||||
var manifest = result.ChunkManifest!;
|
||||
|
||||
// Verify chunks cover the entire envelope
|
||||
var totalChunkSize = manifest.Chunks.Sum(c => c.SizeBytes);
|
||||
totalChunkSize.Should().Be((int)manifest.TotalSizeBytes);
|
||||
|
||||
// Verify chunk indices are sequential
|
||||
for (int i = 0; i < manifest.ChunkCount; i++)
|
||||
{
|
||||
manifest.Chunks[i].Index.Should().Be(i);
|
||||
manifest.Chunks[i].Digest.Should().StartWith("sha256:");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_Chunked_ChunkingTakesPriorityOverHashOnly()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy
|
||||
{
|
||||
SoftLimitBytes = 100,
|
||||
HardLimitBytes = 1_000_000,
|
||||
EnableChunking = true,
|
||||
EnableHashOnlyFallback = true
|
||||
};
|
||||
var guard = CreateGuard(policy);
|
||||
var envelope = CreateEnvelope(1000);
|
||||
|
||||
var result = await guard.ValidateAsync(envelope);
|
||||
|
||||
// Chunking takes priority when both are enabled
|
||||
result.Mode.Should().Be(EnvelopeSubmissionMode.Chunked);
|
||||
}
|
||||
|
||||
// --- Hard limit rejection ---
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_OverHardLimit_ReturnsRejected()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy
|
||||
{
|
||||
SoftLimitBytes = 100,
|
||||
HardLimitBytes = 500,
|
||||
EnableHashOnlyFallback = true
|
||||
};
|
||||
var guard = CreateGuard(policy);
|
||||
var envelope = CreateEnvelope(2000);
|
||||
|
||||
var result = await guard.ValidateAsync(envelope);
|
||||
|
||||
result.Mode.Should().Be(EnvelopeSubmissionMode.Rejected);
|
||||
result.IsAccepted.Should().BeFalse();
|
||||
result.RejectionReason.Should().Contain("hard limit");
|
||||
}
|
||||
|
||||
// --- Both fallbacks disabled ---
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_OverSoftLimit_NoFallback_ReturnsRejected()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy
|
||||
{
|
||||
SoftLimitBytes = 100,
|
||||
HardLimitBytes = 1_000_000,
|
||||
EnableHashOnlyFallback = false,
|
||||
EnableChunking = false
|
||||
};
|
||||
var guard = CreateGuard(policy);
|
||||
var envelope = CreateEnvelope(1000);
|
||||
|
||||
var result = await guard.ValidateAsync(envelope);
|
||||
|
||||
result.Mode.Should().Be(EnvelopeSubmissionMode.Rejected);
|
||||
result.RejectionReason.Should().Contain("fallback modes are disabled");
|
||||
}
|
||||
|
||||
// --- Raw bytes validation ---
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_RawBytes_UnderSoftLimit_ReturnsFullEnvelope()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy { SoftLimitBytes = 1000, HardLimitBytes = 2000 };
|
||||
var guard = CreateGuard(policy);
|
||||
var bytes = Encoding.UTF8.GetBytes("{\"payload\":\"test\"}");
|
||||
|
||||
var result = await guard.ValidateAsync(new ReadOnlyMemory<byte>(bytes));
|
||||
|
||||
result.Mode.Should().Be(EnvelopeSubmissionMode.FullEnvelope);
|
||||
result.EnvelopeSizeBytes.Should().Be(bytes.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_RawBytes_Empty_ReturnsRejected()
|
||||
{
|
||||
var guard = CreateGuard();
|
||||
|
||||
var result = await guard.ValidateAsync(ReadOnlyMemory<byte>.Empty);
|
||||
|
||||
result.Mode.Should().Be(EnvelopeSubmissionMode.Rejected);
|
||||
result.RejectionReason.Should().Contain("empty");
|
||||
}
|
||||
|
||||
// --- Policy validation ---
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeSoftLimit_Throws()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy { SoftLimitBytes = -1 };
|
||||
|
||||
var act = () => CreateGuard(policy);
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*SoftLimitBytes*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_HardLimitLessThanSoftLimit_Throws()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy { SoftLimitBytes = 1000, HardLimitBytes = 500 };
|
||||
|
||||
var act = () => CreateGuard(policy);
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*HardLimitBytes*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeChunkSize_Throws()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy { ChunkSizeBytes = 0 };
|
||||
|
||||
var act = () => CreateGuard(policy);
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*ChunkSizeBytes*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultPolicy_HasReasonableDefaults()
|
||||
{
|
||||
var guard = CreateGuard();
|
||||
|
||||
guard.Policy.SoftLimitBytes.Should().Be(102_400);
|
||||
guard.Policy.HardLimitBytes.Should().Be(1_048_576);
|
||||
guard.Policy.ChunkSizeBytes.Should().Be(65_536);
|
||||
guard.Policy.EnableHashOnlyFallback.Should().BeTrue();
|
||||
guard.Policy.EnableChunking.Should().BeFalse();
|
||||
guard.Policy.HashAlgorithm.Should().Be("SHA-256");
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_Envelope_CancelledToken_Throws()
|
||||
{
|
||||
var guard = CreateGuard();
|
||||
var envelope = CreateEnvelope(100);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(
|
||||
() => guard.ValidateAsync(envelope, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_RawBytes_CancelledToken_Throws()
|
||||
{
|
||||
var guard = CreateGuard();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(
|
||||
() => guard.ValidateAsync(new ReadOnlyMemory<byte>(new byte[] { 1, 2, 3 }), cts.Token));
|
||||
}
|
||||
|
||||
// --- Digest determinism ---
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_SameInput_ProducesSameOutput()
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes("deterministic test data");
|
||||
|
||||
var digest1 = DsseEnvelopeSizeGuard.ComputeDigest(data);
|
||||
var digest2 = DsseEnvelopeSizeGuard.ComputeDigest(data);
|
||||
|
||||
digest1.Should().Be(digest2);
|
||||
digest1.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_DifferentInput_ProducesDifferentOutput()
|
||||
{
|
||||
var data1 = Encoding.UTF8.GetBytes("data one");
|
||||
var data2 = Encoding.UTF8.GetBytes("data two");
|
||||
|
||||
var digest1 = DsseEnvelopeSizeGuard.ComputeDigest(data1);
|
||||
var digest2 = DsseEnvelopeSizeGuard.ComputeDigest(data2);
|
||||
|
||||
digest1.Should().NotBe(digest2);
|
||||
}
|
||||
|
||||
// --- Chunk manifest determinism ---
|
||||
|
||||
[Fact]
|
||||
public void BuildChunkManifest_SameInput_ProducesSameManifest()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy { SoftLimitBytes = 100, HardLimitBytes = 1_000_000, ChunkSizeBytes = 256 };
|
||||
var guard = CreateGuard(policy);
|
||||
var data = new byte[1000];
|
||||
Array.Fill(data, (byte)0x42);
|
||||
|
||||
var manifest1 = guard.BuildChunkManifest(data);
|
||||
var manifest2 = guard.BuildChunkManifest(data);
|
||||
|
||||
manifest1.Should().Be(manifest2);
|
||||
}
|
||||
|
||||
// --- Size tracking ---
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_ReportsCorrectEnvelopeSize()
|
||||
{
|
||||
var policy = new DsseEnvelopeSizePolicy { SoftLimitBytes = 100, HardLimitBytes = 1_000_000 };
|
||||
var guard = CreateGuard(policy);
|
||||
var envelope = CreateEnvelope(1000);
|
||||
|
||||
var result = await guard.ValidateAsync(envelope);
|
||||
|
||||
result.EnvelopeSizeBytes.Should().BeGreaterThan(0);
|
||||
result.Policy.Should().Be(policy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using StellaOps.Attestor.ProofChain.Rekor;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Rekor;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ReachMapBuilder"/> and <see cref="ReachMapPredicate"/>.
|
||||
/// </summary>
|
||||
public sealed class ReachMapBuilderTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2025, 7, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static ReachMapBuilder CreateMinimalBuilder() => new ReachMapBuilder()
|
||||
.WithScanId("scan-001")
|
||||
.WithArtifactRef("pkg:docker/myapp@sha256:abc123")
|
||||
.WithAnalyzer("stella-reach", "2.0.0", 0.95, "full")
|
||||
.WithGeneratedAt(FixedTime);
|
||||
|
||||
private static ReachMapNode CreateNode(string id, string state = "reachable", bool isEntry = false, bool isSink = false) => new()
|
||||
{
|
||||
NodeId = id,
|
||||
QualifiedName = $"com.example.{id}",
|
||||
Module = "main",
|
||||
ReachabilityState = state,
|
||||
IsEntryPoint = isEntry,
|
||||
IsSink = isSink
|
||||
};
|
||||
|
||||
private static ReachMapEdge CreateEdge(string source, string target) => new()
|
||||
{
|
||||
SourceNodeId = source,
|
||||
TargetNodeId = target,
|
||||
CallType = "direct"
|
||||
};
|
||||
|
||||
private static ReachMapFinding CreateFinding(string vulnId, bool isReachable, string? witnessId = null) => new()
|
||||
{
|
||||
VulnId = vulnId,
|
||||
IsReachable = isReachable,
|
||||
ConfidenceScore = 0.9,
|
||||
WitnessId = witnessId
|
||||
};
|
||||
|
||||
// --- Basic build ---
|
||||
|
||||
[Fact]
|
||||
public void Build_MinimalConfig_ProducesValidPredicate()
|
||||
{
|
||||
var predicate = CreateMinimalBuilder().Build();
|
||||
|
||||
predicate.ScanId.Should().Be("scan-001");
|
||||
predicate.ArtifactRef.Should().Be("pkg:docker/myapp@sha256:abc123");
|
||||
predicate.GraphDigest.Should().StartWith("sha256:");
|
||||
predicate.Nodes.Should().BeEmpty();
|
||||
predicate.Edges.Should().BeEmpty();
|
||||
predicate.Findings.Should().BeEmpty();
|
||||
predicate.Analysis.Analyzer.Should().Be("stella-reach");
|
||||
predicate.Analysis.AnalyzerVersion.Should().Be("2.0.0");
|
||||
predicate.Analysis.Confidence.Should().Be(0.95);
|
||||
predicate.Analysis.Completeness.Should().Be("full");
|
||||
predicate.Analysis.GeneratedAt.Should().Be(FixedTime);
|
||||
predicate.SchemaVersion.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithNodesAndEdges_ProducesCorrectSummary()
|
||||
{
|
||||
var predicate = CreateMinimalBuilder()
|
||||
.AddNode(CreateNode("entry1", isEntry: true))
|
||||
.AddNode(CreateNode("middle1"))
|
||||
.AddNode(CreateNode("sink1", "reachable", isSink: true))
|
||||
.AddEdge(CreateEdge("entry1", "middle1"))
|
||||
.AddEdge(CreateEdge("middle1", "sink1"))
|
||||
.AddFinding(CreateFinding("CVE-2024-0001", true))
|
||||
.AddFinding(CreateFinding("CVE-2024-0002", false))
|
||||
.Build();
|
||||
|
||||
predicate.Summary.TotalNodes.Should().Be(3);
|
||||
predicate.Summary.TotalEdges.Should().Be(2);
|
||||
predicate.Summary.EntryPointCount.Should().Be(1);
|
||||
predicate.Summary.SinkCount.Should().Be(1);
|
||||
predicate.Summary.ReachableCount.Should().Be(1);
|
||||
predicate.Summary.UnreachableCount.Should().Be(1);
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
[Fact]
|
||||
public void Build_MissingScanId_Throws()
|
||||
{
|
||||
var builder = new ReachMapBuilder()
|
||||
.WithArtifactRef("ref")
|
||||
.WithAnalyzer("a", "1.0", 0.9, "full")
|
||||
.WithGeneratedAt(FixedTime);
|
||||
|
||||
var act = () => builder.Build();
|
||||
|
||||
act.Should().Throw<InvalidOperationException>().WithMessage("*ScanId*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MissingArtifactRef_Throws()
|
||||
{
|
||||
var builder = new ReachMapBuilder()
|
||||
.WithScanId("scan-001")
|
||||
.WithAnalyzer("a", "1.0", 0.9, "full")
|
||||
.WithGeneratedAt(FixedTime);
|
||||
|
||||
var act = () => builder.Build();
|
||||
|
||||
act.Should().Throw<InvalidOperationException>().WithMessage("*ArtifactRef*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MissingAnalyzer_Throws()
|
||||
{
|
||||
var builder = new ReachMapBuilder()
|
||||
.WithScanId("scan-001")
|
||||
.WithArtifactRef("ref")
|
||||
.WithGeneratedAt(FixedTime);
|
||||
|
||||
var act = () => builder.Build();
|
||||
|
||||
act.Should().Throw<InvalidOperationException>().WithMessage("*Analyzer*");
|
||||
}
|
||||
|
||||
// --- Graph digest determinism ---
|
||||
|
||||
[Fact]
|
||||
public void Build_SameInputs_ProduceSameDigest()
|
||||
{
|
||||
var p1 = CreateMinimalBuilder()
|
||||
.AddNode(CreateNode("a"))
|
||||
.AddNode(CreateNode("b"))
|
||||
.AddEdge(CreateEdge("a", "b"))
|
||||
.AddFinding(CreateFinding("CVE-001", true))
|
||||
.Build();
|
||||
|
||||
var p2 = CreateMinimalBuilder()
|
||||
.AddNode(CreateNode("a"))
|
||||
.AddNode(CreateNode("b"))
|
||||
.AddEdge(CreateEdge("a", "b"))
|
||||
.AddFinding(CreateFinding("CVE-001", true))
|
||||
.Build();
|
||||
|
||||
p1.GraphDigest.Should().Be(p2.GraphDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DifferentOrder_ProduceSameDigest()
|
||||
{
|
||||
// Nodes added in different order should produce same digest (sorted internally)
|
||||
var p1 = CreateMinimalBuilder()
|
||||
.AddNode(CreateNode("a"))
|
||||
.AddNode(CreateNode("b"))
|
||||
.Build();
|
||||
|
||||
var p2 = CreateMinimalBuilder()
|
||||
.AddNode(CreateNode("b"))
|
||||
.AddNode(CreateNode("a"))
|
||||
.Build();
|
||||
|
||||
p1.GraphDigest.Should().Be(p2.GraphDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DifferentContent_ProduceDifferentDigest()
|
||||
{
|
||||
var p1 = CreateMinimalBuilder()
|
||||
.AddNode(CreateNode("a", "reachable"))
|
||||
.Build();
|
||||
|
||||
var p2 = CreateMinimalBuilder()
|
||||
.AddNode(CreateNode("a", "unreachable"))
|
||||
.Build();
|
||||
|
||||
p1.GraphDigest.Should().NotBe(p2.GraphDigest);
|
||||
}
|
||||
|
||||
// --- Witness aggregation ---
|
||||
|
||||
[Fact]
|
||||
public void Build_FindingsWithWitnessIds_AggregatesWitnesses()
|
||||
{
|
||||
var predicate = CreateMinimalBuilder()
|
||||
.AddFinding(CreateFinding("CVE-001", true, "w-001"))
|
||||
.AddFinding(CreateFinding("CVE-002", false, "w-002"))
|
||||
.Build();
|
||||
|
||||
predicate.AggregatedWitnessIds.Should().BeEquivalentTo(["w-001", "w-002"]);
|
||||
predicate.Summary.AggregatedWitnessCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DuplicateWitnessIds_AreDeduped()
|
||||
{
|
||||
var predicate = CreateMinimalBuilder()
|
||||
.AddFinding(CreateFinding("CVE-001", true, "w-001"))
|
||||
.AddFinding(CreateFinding("CVE-002", false, "w-001"))
|
||||
.Build();
|
||||
|
||||
predicate.AggregatedWitnessIds.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ExplicitWitnessId_IsIncluded()
|
||||
{
|
||||
var predicate = CreateMinimalBuilder()
|
||||
.AddWitnessId("explicit-witness")
|
||||
.Build();
|
||||
|
||||
predicate.AggregatedWitnessIds.Should().Contain("explicit-witness");
|
||||
}
|
||||
|
||||
// --- AddNodes/AddEdges/AddFindings bulk ---
|
||||
|
||||
[Fact]
|
||||
public void AddNodes_Bulk_AddsAllNodes()
|
||||
{
|
||||
var nodes = new[] { CreateNode("a"), CreateNode("b"), CreateNode("c") };
|
||||
var predicate = CreateMinimalBuilder()
|
||||
.AddNodes(nodes)
|
||||
.Build();
|
||||
|
||||
predicate.Nodes.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEdges_Bulk_AddsAllEdges()
|
||||
{
|
||||
var edges = new[] { CreateEdge("a", "b"), CreateEdge("b", "c") };
|
||||
var predicate = CreateMinimalBuilder()
|
||||
.AddEdges(edges)
|
||||
.Build();
|
||||
|
||||
predicate.Edges.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFindings_Bulk_AddsAllFindings()
|
||||
{
|
||||
var findings = new[] { CreateFinding("CVE-001", true), CreateFinding("CVE-002", false) };
|
||||
var predicate = CreateMinimalBuilder()
|
||||
.AddFindings(findings)
|
||||
.Build();
|
||||
|
||||
predicate.Findings.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
// --- CAS URI ---
|
||||
|
||||
[Fact]
|
||||
public void Build_WithGraphCasUri_IsIncluded()
|
||||
{
|
||||
var predicate = CreateMinimalBuilder()
|
||||
.WithGraphCasUri("cas://sha256:abc123")
|
||||
.Build();
|
||||
|
||||
predicate.GraphCasUri.Should().Be("cas://sha256:abc123");
|
||||
}
|
||||
|
||||
// --- Statement integration ---
|
||||
|
||||
[Fact]
|
||||
public void ReachMapStatement_HasCorrectPredicateType()
|
||||
{
|
||||
var predicate = CreateMinimalBuilder().Build();
|
||||
var statement = new ReachMapStatement
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
new Subject
|
||||
{
|
||||
Name = "myapp",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
|
||||
}
|
||||
],
|
||||
Predicate = predicate
|
||||
};
|
||||
|
||||
statement.PredicateType.Should().Be("reach-map.stella/v1");
|
||||
statement.Type.Should().Be("https://in-toto.io/Statement/v1");
|
||||
}
|
||||
|
||||
// --- Null argument protection ---
|
||||
|
||||
[Fact]
|
||||
public void WithScanId_Null_Throws()
|
||||
{
|
||||
var act = () => CreateMinimalBuilder().WithScanId(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithArtifactRef_Null_Throws()
|
||||
{
|
||||
var act = () => CreateMinimalBuilder().WithArtifactRef(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNode_Null_Throws()
|
||||
{
|
||||
var act = () => CreateMinimalBuilder().AddNode(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEdge_Null_Throws()
|
||||
{
|
||||
var act = () => CreateMinimalBuilder().AddEdge(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFinding_Null_Throws()
|
||||
{
|
||||
var act = () => CreateMinimalBuilder().AddFinding(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScoreReplayServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_020_Attestor_score_replay_and_verification
|
||||
// Task: T1 — Tests for score replay, comparison, and attestation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Replay;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Replay;
|
||||
|
||||
internal sealed class TestScoreReplayMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeScoreReplayTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
|
||||
public class ScoreReplayServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestScoreReplayMeterFactory _meterFactory = new();
|
||||
private readonly FakeScoreReplayTimeProvider _timeProvider = new();
|
||||
private readonly ScoreReplayService _service;
|
||||
|
||||
public ScoreReplayServiceTests()
|
||||
{
|
||||
_service = new ScoreReplayService(_timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
private static ScoreReplayRequest CreateRequest(
|
||||
decimal originalScore = 0.75m,
|
||||
string verdictId = "verdict-001",
|
||||
Dictionary<string, string>? inputs = null) => new()
|
||||
{
|
||||
VerdictId = verdictId,
|
||||
OriginalScore = originalScore,
|
||||
ScoringInputs = (inputs ?? new Dictionary<string, string>
|
||||
{
|
||||
["coverage"] = "0.75",
|
||||
["severity"] = "0.5",
|
||||
["confidence"] = "1.0"
|
||||
}).ToImmutableDictionary()
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// ReplayAsync
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_produces_result_with_digest()
|
||||
{
|
||||
var result = await _service.ReplayAsync(CreateRequest());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.ReplayDigest.Should().StartWith("sha256:");
|
||||
result.VerdictId.Should().Be("verdict-001");
|
||||
result.ReplayedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_matching_score_returns_Matched()
|
||||
{
|
||||
// Compute the expected replay score for known inputs
|
||||
var inputs = new Dictionary<string, string>
|
||||
{
|
||||
["val1"] = "0.5",
|
||||
["val2"] = "0.5"
|
||||
};
|
||||
var expectedScore = ScoreReplayService.ComputeScore(inputs.ToImmutableDictionary());
|
||||
|
||||
var result = await _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "v-match",
|
||||
OriginalScore = expectedScore,
|
||||
ScoringInputs = inputs.ToImmutableDictionary()
|
||||
});
|
||||
|
||||
result.Status.Should().Be(ScoreReplayStatus.Matched);
|
||||
result.Divergence.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_diverged_score_returns_Diverged()
|
||||
{
|
||||
var result = await _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "v-diverge",
|
||||
OriginalScore = 0.99m,
|
||||
ScoringInputs = new Dictionary<string, string>
|
||||
{
|
||||
["x"] = "0.1"
|
||||
}.ToImmutableDictionary()
|
||||
});
|
||||
|
||||
result.Status.Should().Be(ScoreReplayStatus.Diverged);
|
||||
result.Divergence.Should().BeGreaterThan(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_records_duration()
|
||||
{
|
||||
var result = await _service.ReplayAsync(CreateRequest());
|
||||
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_computes_determinism_hash()
|
||||
{
|
||||
var result = await _service.ReplayAsync(CreateRequest());
|
||||
|
||||
result.DeterminismHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_determinism_hash_matches_when_original_is_same()
|
||||
{
|
||||
var inputs = new Dictionary<string, string>
|
||||
{
|
||||
["a"] = "1.0"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var hash = ScoreReplayService.ComputeDeterminismHash(inputs);
|
||||
|
||||
var result = await _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "v-hash",
|
||||
OriginalScore = 0.5m,
|
||||
ScoringInputs = inputs,
|
||||
OriginalDeterminismHash = hash
|
||||
});
|
||||
|
||||
result.DeterminismHashMatches.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_determinism_hash_mismatch_when_original_differs()
|
||||
{
|
||||
var result = await _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "v-mismatch",
|
||||
OriginalScore = 0.5m,
|
||||
ScoringInputs = new Dictionary<string, string>
|
||||
{
|
||||
["a"] = "1.0"
|
||||
}.ToImmutableDictionary(),
|
||||
OriginalDeterminismHash = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
});
|
||||
|
||||
result.DeterminismHashMatches.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_null_original_hash_always_matches()
|
||||
{
|
||||
var result = await _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "v-null-hash",
|
||||
OriginalScore = 0.5m,
|
||||
ScoringInputs = new Dictionary<string, string>
|
||||
{
|
||||
["a"] = "1.0"
|
||||
}.ToImmutableDictionary(),
|
||||
OriginalDeterminismHash = null
|
||||
});
|
||||
|
||||
result.DeterminismHashMatches.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_null_request_throws()
|
||||
{
|
||||
var act = () => _service.ReplayAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_empty_verdict_id_throws()
|
||||
{
|
||||
var act = () => _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "",
|
||||
OriginalScore = 0.5m,
|
||||
ScoringInputs = ImmutableDictionary<string, string>.Empty
|
||||
});
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_cancellation_throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _service.ReplayAsync(CreateRequest(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayAsync_empty_inputs_returns_zero_score()
|
||||
{
|
||||
var result = await _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "v-empty",
|
||||
OriginalScore = 0m,
|
||||
ScoringInputs = ImmutableDictionary<string, string>.Empty
|
||||
});
|
||||
|
||||
result.ReplayedScore.Should().Be(0m);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// CompareAsync
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CompareAsync_identical_results_is_deterministic()
|
||||
{
|
||||
var inputs = new Dictionary<string, string>
|
||||
{
|
||||
["val"] = "0.5"
|
||||
}.ToImmutableDictionary();
|
||||
var expectedScore = ScoreReplayService.ComputeScore(inputs);
|
||||
|
||||
var reqA = new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "v-compare",
|
||||
OriginalScore = expectedScore,
|
||||
ScoringInputs = inputs
|
||||
};
|
||||
|
||||
var resultA = await _service.ReplayAsync(reqA);
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var resultB = await _service.ReplayAsync(reqA with { VerdictId = "v-compare" });
|
||||
|
||||
// Both have same inputs → same replayed score and determinism hash
|
||||
var comparison = await _service.CompareAsync(resultA, resultB);
|
||||
|
||||
comparison.Divergence.Should().Be(0m);
|
||||
comparison.IsDeterministic.Should().BeTrue();
|
||||
comparison.DifferenceDetails.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompareAsync_divergent_results_reports_differences()
|
||||
{
|
||||
var resultA = await _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "v-a",
|
||||
OriginalScore = 0.5m,
|
||||
ScoringInputs = new Dictionary<string, string>
|
||||
{
|
||||
["val"] = "0.3"
|
||||
}.ToImmutableDictionary()
|
||||
});
|
||||
|
||||
var resultB = await _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "v-b",
|
||||
OriginalScore = 0.5m,
|
||||
ScoringInputs = new Dictionary<string, string>
|
||||
{
|
||||
["val"] = "0.9"
|
||||
}.ToImmutableDictionary()
|
||||
});
|
||||
|
||||
var comparison = await _service.CompareAsync(resultA, resultB);
|
||||
|
||||
comparison.IsDeterministic.Should().BeFalse();
|
||||
comparison.Divergence.Should().BeGreaterThan(0m);
|
||||
comparison.DifferenceDetails.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompareAsync_null_resultA_throws()
|
||||
{
|
||||
var result = await _service.ReplayAsync(CreateRequest());
|
||||
var act = () => _service.CompareAsync(null!, result);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompareAsync_null_resultB_throws()
|
||||
{
|
||||
var result = await _service.ReplayAsync(CreateRequest());
|
||||
var act = () => _service.CompareAsync(result, null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// CreateAttestationAsync
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_produces_attestation_with_payload()
|
||||
{
|
||||
var replay = await _service.ReplayAsync(CreateRequest());
|
||||
var attestation = await _service.CreateAttestationAsync(replay);
|
||||
|
||||
attestation.AttestationDigest.Should().StartWith("sha256:");
|
||||
attestation.PayloadType.Should().Be("application/vnd.stella.score+json");
|
||||
attestation.Payload.Length.Should().BeGreaterThan(0);
|
||||
attestation.ReplayResult.Should().Be(replay);
|
||||
attestation.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_payload_is_valid_json()
|
||||
{
|
||||
var replay = await _service.ReplayAsync(CreateRequest());
|
||||
var attestation = await _service.CreateAttestationAsync(replay);
|
||||
|
||||
var payloadStr = Encoding.UTF8.GetString(attestation.Payload.ToArray());
|
||||
var act = () => System.Text.Json.JsonDocument.Parse(payloadStr);
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_null_result_throws()
|
||||
{
|
||||
var act = () => _service.CreateAttestationAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_signing_key_id_is_null_by_default()
|
||||
{
|
||||
var replay = await _service.ReplayAsync(CreateRequest());
|
||||
var attestation = await _service.CreateAttestationAsync(replay);
|
||||
|
||||
attestation.SigningKeyId.Should().BeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetByDigestAsync
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_returns_stored_result()
|
||||
{
|
||||
var replay = await _service.ReplayAsync(CreateRequest());
|
||||
var retrieved = await _service.GetByDigestAsync(replay.ReplayDigest);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.ReplayDigest.Should().Be(replay.ReplayDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_returns_null_for_missing()
|
||||
{
|
||||
var result = await _service.GetByDigestAsync("sha256:nonexistent");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_null_digest_throws()
|
||||
{
|
||||
var act = () => _service.GetByDigestAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// QueryAsync
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_no_filter_returns_all()
|
||||
{
|
||||
await _service.ReplayAsync(CreateRequest(verdictId: "q1"));
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.ReplayAsync(CreateRequest(verdictId: "q2"));
|
||||
|
||||
var results = await _service.QueryAsync(new ScoreReplayQuery());
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_filters_by_verdict_id()
|
||||
{
|
||||
await _service.ReplayAsync(CreateRequest(verdictId: "target"));
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.ReplayAsync(CreateRequest(verdictId: "other"));
|
||||
|
||||
var results = await _service.QueryAsync(new ScoreReplayQuery { VerdictId = "target" });
|
||||
results.Should().HaveCount(1);
|
||||
results[0].VerdictId.Should().Be("target");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_filters_by_status()
|
||||
{
|
||||
// Create a matched result
|
||||
var inputs = new Dictionary<string, string> { ["val"] = "0.5" }.ToImmutableDictionary();
|
||||
var expectedScore = ScoreReplayService.ComputeScore(inputs);
|
||||
await _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "matched",
|
||||
OriginalScore = expectedScore,
|
||||
ScoringInputs = inputs
|
||||
});
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
|
||||
// Create a diverged result
|
||||
await _service.ReplayAsync(new ScoreReplayRequest
|
||||
{
|
||||
VerdictId = "diverged",
|
||||
OriginalScore = 0.99m,
|
||||
ScoringInputs = new Dictionary<string, string> { ["val"] = "0.1" }.ToImmutableDictionary()
|
||||
});
|
||||
|
||||
var matched = await _service.QueryAsync(new ScoreReplayQuery { Status = ScoreReplayStatus.Matched });
|
||||
matched.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_respects_limit()
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.ReplayAsync(CreateRequest(verdictId: $"limited-{i}"));
|
||||
}
|
||||
|
||||
var results = await _service.QueryAsync(new ScoreReplayQuery { Limit = 2 });
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_null_query_throws()
|
||||
{
|
||||
var act = () => _service.QueryAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// ComputeScore (deterministic)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ComputeScore_empty_inputs_returns_zero()
|
||||
{
|
||||
var score = ScoreReplayService.ComputeScore(ImmutableDictionary<string, string>.Empty);
|
||||
score.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeScore_non_numeric_inputs_ignored()
|
||||
{
|
||||
var score = ScoreReplayService.ComputeScore(
|
||||
new Dictionary<string, string> { ["text"] = "hello" }.ToImmutableDictionary());
|
||||
score.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeScore_deterministic_for_same_inputs()
|
||||
{
|
||||
var inputs = new Dictionary<string, string>
|
||||
{
|
||||
["a"] = "0.5",
|
||||
["b"] = "0.8"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var s1 = ScoreReplayService.ComputeScore(inputs);
|
||||
var s2 = ScoreReplayService.ComputeScore(inputs);
|
||||
|
||||
s1.Should().Be(s2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeScore_clamped_to_0_1()
|
||||
{
|
||||
var score = ScoreReplayService.ComputeScore(
|
||||
new Dictionary<string, string> { ["val"] = "0.5" }.ToImmutableDictionary());
|
||||
|
||||
score.Should().BeGreaterOrEqualTo(0m);
|
||||
score.Should().BeLessOrEqualTo(1m);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// ComputeDeterminismHash (deterministic)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ComputeDeterminismHash_same_inputs_same_hash()
|
||||
{
|
||||
var inputs = new Dictionary<string, string>
|
||||
{
|
||||
["x"] = "1",
|
||||
["y"] = "2"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
var h1 = ScoreReplayService.ComputeDeterminismHash(inputs);
|
||||
var h2 = ScoreReplayService.ComputeDeterminismHash(inputs);
|
||||
|
||||
h1.Should().Be(h2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDeterminismHash_different_inputs_different_hash()
|
||||
{
|
||||
var h1 = ScoreReplayService.ComputeDeterminismHash(
|
||||
new Dictionary<string, string> { ["a"] = "1" }.ToImmutableDictionary());
|
||||
var h2 = ScoreReplayService.ComputeDeterminismHash(
|
||||
new Dictionary<string, string> { ["a"] = "2" }.ToImmutableDictionary());
|
||||
|
||||
h1.Should().NotBe(h2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Constructor validation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_factory_throws()
|
||||
{
|
||||
var act = () => new ScoreReplayService(_timeProvider, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_time_provider_uses_system()
|
||||
{
|
||||
using var mf = new TestScoreReplayMeterFactory();
|
||||
var svc = new ScoreReplayService(null, mf);
|
||||
svc.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExceptionSigningServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_008_Attestor_dsse_signed_exception_objects_with_recheck_policy
|
||||
// Task: T1 - Unit Tests
|
||||
// Description: Tests for ExceptionSigningService and DSSE-signed exception objects.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Attestor.ProofChain.Json;
|
||||
using StellaOps.Attestor.ProofChain.Services;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Services;
|
||||
|
||||
public sealed class ExceptionSigningServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 2, 8, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task SignExceptionAsync_ReturnsValidEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var (service, signer, _) = CreateService();
|
||||
var exception = CreateTestException();
|
||||
var subject = CreateTestSubject();
|
||||
var recheckPolicy = CreateDefaultRecheckPolicy();
|
||||
|
||||
signer.SignStatementAsync(
|
||||
Arg.Any<DsseSignedExceptionStatement>(),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
// Act
|
||||
var result = await service.SignExceptionAsync(
|
||||
exception,
|
||||
subject,
|
||||
recheckPolicy);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Envelope.Should().NotBeNull();
|
||||
result.Statement.Should().NotBeNull();
|
||||
result.ExceptionContentId.Should().StartWith("sha256:");
|
||||
result.Statement.PredicateType.Should().Be(DsseSignedExceptionStatement.PredicateTypeUri);
|
||||
result.Statement.Predicate.Exception.ExceptionId.Should().Be(exception.ExceptionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExceptionAsync_SetsSignedAtToCurrentTime()
|
||||
{
|
||||
// Arrange
|
||||
var (service, signer, _) = CreateService();
|
||||
var exception = CreateTestException();
|
||||
var subject = CreateTestSubject();
|
||||
var recheckPolicy = CreateDefaultRecheckPolicy();
|
||||
|
||||
DsseSignedExceptionStatement? capturedStatement = null;
|
||||
signer.SignStatementAsync(
|
||||
Arg.Do<DsseSignedExceptionStatement>(s => capturedStatement = s),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
// Act
|
||||
await service.SignExceptionAsync(exception, subject, recheckPolicy);
|
||||
|
||||
// Assert
|
||||
capturedStatement.Should().NotBeNull();
|
||||
capturedStatement!.Predicate.SignedAt.Should().Be(FixedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExceptionAsync_CalculatesNextRecheckDate()
|
||||
{
|
||||
// Arrange
|
||||
var (service, signer, _) = CreateService();
|
||||
var exception = CreateTestException();
|
||||
var subject = CreateTestSubject();
|
||||
var recheckPolicy = new ExceptionRecheckPolicy
|
||||
{
|
||||
RecheckIntervalDays = 30,
|
||||
AutoRecheckEnabled = true
|
||||
};
|
||||
|
||||
DsseSignedExceptionStatement? capturedStatement = null;
|
||||
signer.SignStatementAsync(
|
||||
Arg.Do<DsseSignedExceptionStatement>(s => capturedStatement = s),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
// Act
|
||||
await service.SignExceptionAsync(exception, subject, recheckPolicy);
|
||||
|
||||
// Assert
|
||||
capturedStatement.Should().NotBeNull();
|
||||
capturedStatement!.Predicate.RecheckPolicy.NextRecheckAt.Should().Be(FixedTime.AddDays(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExceptionAsync_WithApprover_SetsActiveStatus()
|
||||
{
|
||||
// Arrange
|
||||
var (service, signer, _) = CreateService();
|
||||
var exception = CreateTestException() with { ApprovedBy = "admin@example.com" };
|
||||
var subject = CreateTestSubject();
|
||||
var recheckPolicy = CreateDefaultRecheckPolicy();
|
||||
|
||||
DsseSignedExceptionStatement? capturedStatement = null;
|
||||
signer.SignStatementAsync(
|
||||
Arg.Do<DsseSignedExceptionStatement>(s => capturedStatement = s),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
// Act
|
||||
await service.SignExceptionAsync(exception, subject, recheckPolicy);
|
||||
|
||||
// Assert
|
||||
capturedStatement.Should().NotBeNull();
|
||||
capturedStatement!.Predicate.Status.Should().Be(ExceptionStatus.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExceptionAsync_WithoutApprover_SetsPendingApprovalStatus()
|
||||
{
|
||||
// Arrange
|
||||
var (service, signer, _) = CreateService();
|
||||
var exception = CreateTestException() with { ApprovedBy = null };
|
||||
var subject = CreateTestSubject();
|
||||
var recheckPolicy = CreateDefaultRecheckPolicy();
|
||||
|
||||
DsseSignedExceptionStatement? capturedStatement = null;
|
||||
signer.SignStatementAsync(
|
||||
Arg.Do<DsseSignedExceptionStatement>(s => capturedStatement = s),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
// Act
|
||||
await service.SignExceptionAsync(exception, subject, recheckPolicy);
|
||||
|
||||
// Assert
|
||||
capturedStatement.Should().NotBeNull();
|
||||
capturedStatement!.Predicate.Status.Should().Be(ExceptionStatus.PendingApproval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExceptionAsync_ExpiredException_SetsExpiredStatus()
|
||||
{
|
||||
// Arrange
|
||||
var (service, signer, _) = CreateService();
|
||||
var exception = CreateTestException() with
|
||||
{
|
||||
ApprovedBy = "admin@example.com",
|
||||
ExpiresAt = FixedTime.AddDays(-1) // Expired yesterday
|
||||
};
|
||||
var subject = CreateTestSubject();
|
||||
var recheckPolicy = CreateDefaultRecheckPolicy();
|
||||
|
||||
DsseSignedExceptionStatement? capturedStatement = null;
|
||||
signer.SignStatementAsync(
|
||||
Arg.Do<DsseSignedExceptionStatement>(s => capturedStatement = s),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
// Act
|
||||
await service.SignExceptionAsync(exception, subject, recheckPolicy);
|
||||
|
||||
// Assert
|
||||
capturedStatement.Should().NotBeNull();
|
||||
capturedStatement!.Predicate.Status.Should().Be(ExceptionStatus.Expired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckRecheckRequired_ActiveException_ReturnsNoAction()
|
||||
{
|
||||
// Arrange
|
||||
var (service, _, _) = CreateService();
|
||||
var statement = CreateTestStatement(
|
||||
expiresAt: FixedTime.AddDays(60),
|
||||
status: ExceptionStatus.Active,
|
||||
nextRecheckAt: FixedTime.AddDays(30));
|
||||
|
||||
// Act
|
||||
var result = service.CheckRecheckRequired(statement);
|
||||
|
||||
// Assert
|
||||
result.RecheckRequired.Should().BeFalse();
|
||||
result.IsExpired.Should().BeFalse();
|
||||
result.ExpiringWithinWarningWindow.Should().BeFalse();
|
||||
result.RecommendedAction.Should().Be(RecheckAction.None);
|
||||
result.DaysUntilExpiry.Should().Be(60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckRecheckRequired_ExpiredWithinWarningWindow_ReturnsRenewalRecommended()
|
||||
{
|
||||
// Arrange
|
||||
var (service, _, _) = CreateService();
|
||||
var statement = CreateTestStatement(
|
||||
expiresAt: FixedTime.AddDays(5), // Within 7-day warning window
|
||||
status: ExceptionStatus.Active,
|
||||
nextRecheckAt: FixedTime.AddDays(30));
|
||||
|
||||
// Act
|
||||
var result = service.CheckRecheckRequired(statement);
|
||||
|
||||
// Assert
|
||||
result.RecheckRequired.Should().BeFalse();
|
||||
result.IsExpired.Should().BeFalse();
|
||||
result.ExpiringWithinWarningWindow.Should().BeTrue();
|
||||
result.RecommendedAction.Should().Be(RecheckAction.RenewalRecommended);
|
||||
result.DaysUntilExpiry.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckRecheckRequired_RecheckDue_ReturnsRecheckDue()
|
||||
{
|
||||
// Arrange
|
||||
var (service, _, _) = CreateService();
|
||||
var statement = CreateTestStatement(
|
||||
expiresAt: FixedTime.AddDays(60),
|
||||
status: ExceptionStatus.Active,
|
||||
nextRecheckAt: FixedTime.AddDays(-1)); // Recheck was due yesterday
|
||||
|
||||
// Act
|
||||
var result = service.CheckRecheckRequired(statement);
|
||||
|
||||
// Assert
|
||||
result.RecheckRequired.Should().BeTrue();
|
||||
result.IsExpired.Should().BeFalse();
|
||||
result.RecommendedAction.Should().Be(RecheckAction.RecheckDue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckRecheckRequired_Expired_ReturnsRenewalRequired()
|
||||
{
|
||||
// Arrange
|
||||
var (service, _, _) = CreateService();
|
||||
var statement = CreateTestStatement(
|
||||
expiresAt: FixedTime.AddDays(-1), // Expired yesterday
|
||||
status: ExceptionStatus.Active,
|
||||
nextRecheckAt: FixedTime.AddDays(30));
|
||||
|
||||
// Act
|
||||
var result = service.CheckRecheckRequired(statement);
|
||||
|
||||
// Assert
|
||||
result.RecheckRequired.Should().BeTrue();
|
||||
result.IsExpired.Should().BeTrue();
|
||||
result.RecommendedAction.Should().Be(RecheckAction.RenewalRequired);
|
||||
result.DaysUntilExpiry.Should().Be(-1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckRecheckRequired_Revoked_ReturnsRevokedAction()
|
||||
{
|
||||
// Arrange
|
||||
var (service, _, _) = CreateService();
|
||||
var statement = CreateTestStatement(
|
||||
expiresAt: FixedTime.AddDays(60),
|
||||
status: ExceptionStatus.Revoked,
|
||||
nextRecheckAt: FixedTime.AddDays(30));
|
||||
|
||||
// Act
|
||||
var result = service.CheckRecheckRequired(statement);
|
||||
|
||||
// Assert
|
||||
result.RecheckRequired.Should().BeFalse();
|
||||
result.IsExpired.Should().BeFalse();
|
||||
result.RecommendedAction.Should().Be(RecheckAction.Revoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExceptionAsync_ContentIdIsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var (service1, signer1, _) = CreateService();
|
||||
var (service2, signer2, _) = CreateService();
|
||||
|
||||
var exception = CreateTestException();
|
||||
var subject = CreateTestSubject();
|
||||
var recheckPolicy = CreateDefaultRecheckPolicy();
|
||||
|
||||
signer1.SignStatementAsync(
|
||||
Arg.Any<DsseSignedExceptionStatement>(),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
signer2.SignStatementAsync(
|
||||
Arg.Any<DsseSignedExceptionStatement>(),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
// Act
|
||||
var result1 = await service1.SignExceptionAsync(exception, subject, recheckPolicy);
|
||||
var result2 = await service2.SignExceptionAsync(exception, subject, recheckPolicy);
|
||||
|
||||
// Assert
|
||||
result1.ExceptionContentId.Should().Be(result2.ExceptionContentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExceptionAsync_DifferentExceptions_ProduceDifferentContentIds()
|
||||
{
|
||||
// Arrange
|
||||
var (service, signer, _) = CreateService();
|
||||
var exception1 = CreateTestException() with { ExceptionId = "exc-001" };
|
||||
var exception2 = CreateTestException() with { ExceptionId = "exc-002" };
|
||||
var subject = CreateTestSubject();
|
||||
var recheckPolicy = CreateDefaultRecheckPolicy();
|
||||
|
||||
signer.SignStatementAsync(
|
||||
Arg.Any<DsseSignedExceptionStatement>(),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
// Act
|
||||
var result1 = await service.SignExceptionAsync(exception1, subject, recheckPolicy);
|
||||
var result2 = await service.SignExceptionAsync(exception2, subject, recheckPolicy);
|
||||
|
||||
// Assert
|
||||
result1.ExceptionContentId.Should().NotBe(result2.ExceptionContentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExceptionAsync_WithRenewalChain_SetsRenewsExceptionId()
|
||||
{
|
||||
// Arrange
|
||||
var (service, signer, _) = CreateService();
|
||||
var exception = CreateTestException();
|
||||
var subject = CreateTestSubject();
|
||||
var recheckPolicy = CreateDefaultRecheckPolicy();
|
||||
var previousExceptionId = "sha256:abc123";
|
||||
|
||||
DsseSignedExceptionStatement? capturedStatement = null;
|
||||
signer.SignStatementAsync(
|
||||
Arg.Do<DsseSignedExceptionStatement>(s => capturedStatement = s),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
// Act
|
||||
await service.SignExceptionAsync(
|
||||
exception,
|
||||
subject,
|
||||
recheckPolicy,
|
||||
renewsExceptionId: previousExceptionId);
|
||||
|
||||
// Assert
|
||||
capturedStatement.Should().NotBeNull();
|
||||
capturedStatement!.Predicate.RenewsExceptionId.Should().Be(previousExceptionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExceptionAsync_WithEnvironments_SetsEnvironments()
|
||||
{
|
||||
// Arrange
|
||||
var (service, signer, _) = CreateService();
|
||||
var exception = CreateTestException();
|
||||
var subject = CreateTestSubject();
|
||||
var recheckPolicy = CreateDefaultRecheckPolicy();
|
||||
var environments = new[] { "prod", "staging" };
|
||||
|
||||
DsseSignedExceptionStatement? capturedStatement = null;
|
||||
signer.SignStatementAsync(
|
||||
Arg.Do<DsseSignedExceptionStatement>(s => capturedStatement = s),
|
||||
SigningKeyProfile.Exception,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(CreateTestEnvelope());
|
||||
|
||||
// Act
|
||||
await service.SignExceptionAsync(
|
||||
exception,
|
||||
subject,
|
||||
recheckPolicy,
|
||||
environments: environments);
|
||||
|
||||
// Assert
|
||||
capturedStatement.Should().NotBeNull();
|
||||
capturedStatement!.Predicate.Environments.Should().BeEquivalentTo(environments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullSigner_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalizer = Substitute.For<IJsonCanonicalizer>();
|
||||
var timeProvider = TimeProvider.System;
|
||||
|
||||
// Act & Assert
|
||||
var act = () => new ExceptionSigningService(null!, canonicalizer, timeProvider);
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("signer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullCanonicalizer_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var signer = Substitute.For<IProofChainSigner>();
|
||||
var timeProvider = TimeProvider.System;
|
||||
|
||||
// Act & Assert
|
||||
var act = () => new ExceptionSigningService(signer, null!, timeProvider);
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("canonicalizer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var signer = Substitute.For<IProofChainSigner>();
|
||||
var canonicalizer = Substitute.For<IJsonCanonicalizer>();
|
||||
|
||||
// Act & Assert
|
||||
var act = () => new ExceptionSigningService(signer, canonicalizer, null!);
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("timeProvider");
|
||||
}
|
||||
|
||||
// --- Helper Methods ---
|
||||
|
||||
private (ExceptionSigningService Service, IProofChainSigner Signer, IJsonCanonicalizer Canonicalizer) CreateService()
|
||||
{
|
||||
var signer = Substitute.For<IProofChainSigner>();
|
||||
var canonicalizer = new Rfc8785JsonCanonicalizer();
|
||||
var timeProvider = new FakeTimeProvider(FixedTime);
|
||||
|
||||
var service = new ExceptionSigningService(signer, canonicalizer, timeProvider);
|
||||
return (service, signer, canonicalizer);
|
||||
}
|
||||
|
||||
private static BudgetExceptionEntry CreateTestException() => new()
|
||||
{
|
||||
ExceptionId = "exc-test-001",
|
||||
CoveredReasons = new[] { "R001", "R002" },
|
||||
CoveredTiers = new[] { "critical" },
|
||||
ExpiresAt = FixedTime.AddDays(90),
|
||||
Justification = "Test exception for unit testing",
|
||||
ApprovedBy = "admin@example.com"
|
||||
};
|
||||
|
||||
private static Subject CreateTestSubject() => new()
|
||||
{
|
||||
Name = "registry.example.com/app:v1.0.0",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "abc123def456"
|
||||
}
|
||||
};
|
||||
|
||||
private static ExceptionRecheckPolicy CreateDefaultRecheckPolicy() => new()
|
||||
{
|
||||
RecheckIntervalDays = 30,
|
||||
AutoRecheckEnabled = true,
|
||||
RequiresReapprovalOnExpiry = true
|
||||
};
|
||||
|
||||
private static DsseEnvelope CreateTestEnvelope() => new()
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("{}"u8.ToArray()),
|
||||
Signatures = new List<DsseSignature>
|
||||
{
|
||||
new() { KeyId = "test-key", Sig = "signature123" }
|
||||
}
|
||||
};
|
||||
|
||||
private DsseSignedExceptionStatement CreateTestStatement(
|
||||
DateTimeOffset? expiresAt,
|
||||
ExceptionStatus status,
|
||||
DateTimeOffset? nextRecheckAt) => new()
|
||||
{
|
||||
Subject = new[] { CreateTestSubject() },
|
||||
Predicate = new DsseSignedExceptionPayload
|
||||
{
|
||||
Exception = CreateTestException() with { ExpiresAt = expiresAt },
|
||||
ExceptionContentId = "sha256:test123",
|
||||
SignedAt = FixedTime,
|
||||
RecheckPolicy = new ExceptionRecheckPolicy
|
||||
{
|
||||
RecheckIntervalDays = 30,
|
||||
AutoRecheckEnabled = true,
|
||||
NextRecheckAt = nextRecheckAt
|
||||
},
|
||||
Status = status
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for deterministic testing.
|
||||
/// </summary>
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnknownsTriageScorerTests.cs
|
||||
// Sprint: SPRINT_20260208_022_Attestor_unknowns_five_dimensional_triage_scoring
|
||||
// Task: T1 — Tests for five-dimensional triage scoring
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Services;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Services;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Model tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class TriageScoringModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void TriageBand_has_three_values()
|
||||
{
|
||||
Enum.GetValues<TriageBand>().Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageBand_ordering()
|
||||
{
|
||||
((int)TriageBand.Hot).Should().BeLessThan((int)TriageBand.Warm);
|
||||
((int)TriageBand.Warm).Should().BeLessThan((int)TriageBand.Cold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageDimensionWeights_defaults_sum_to_1()
|
||||
{
|
||||
var w = TriageDimensionWeights.Default;
|
||||
var sum = w.P + w.E + w.U + w.C + w.S;
|
||||
sum.Should().BeApproximately(1.0, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageDimensionWeights_default_values()
|
||||
{
|
||||
var w = TriageDimensionWeights.Default;
|
||||
w.P.Should().Be(0.30);
|
||||
w.E.Should().Be(0.25);
|
||||
w.U.Should().Be(0.20);
|
||||
w.C.Should().Be(0.15);
|
||||
w.S.Should().Be(0.10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageBandThresholds_default_values()
|
||||
{
|
||||
var t = TriageBandThresholds.Default;
|
||||
t.HotThreshold.Should().Be(0.70);
|
||||
t.WarmThreshold.Should().Be(0.40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageScore_roundtrip()
|
||||
{
|
||||
var score = new TriageScore
|
||||
{
|
||||
Probability = 0.9,
|
||||
Exposure = 0.8,
|
||||
Uncertainty = 0.7,
|
||||
Consequence = 0.6,
|
||||
SignalFreshness = 0.5
|
||||
};
|
||||
|
||||
score.Probability.Should().Be(0.9);
|
||||
score.Exposure.Should().Be(0.8);
|
||||
score.Uncertainty.Should().Be(0.7);
|
||||
score.Consequence.Should().Be(0.6);
|
||||
score.SignalFreshness.Should().Be(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageScoringResult_computed_band_counts()
|
||||
{
|
||||
var items = ImmutableArray.Create(
|
||||
MakeScoredItem("pkg:a", "R1", 0.9, TriageBand.Hot),
|
||||
MakeScoredItem("pkg:b", "R2", 0.5, TriageBand.Warm),
|
||||
MakeScoredItem("pkg:c", "R3", 0.1, TriageBand.Cold),
|
||||
MakeScoredItem("pkg:d", "R4", 0.8, TriageBand.Hot)
|
||||
);
|
||||
|
||||
var result = new TriageScoringResult
|
||||
{
|
||||
Items = items,
|
||||
Weights = TriageDimensionWeights.Default,
|
||||
Thresholds = TriageBandThresholds.Default
|
||||
};
|
||||
|
||||
result.HotCount.Should().Be(2);
|
||||
result.WarmCount.Should().Be(1);
|
||||
result.ColdCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageScoringResult_empty_items_gives_zero_counts()
|
||||
{
|
||||
var result = new TriageScoringResult
|
||||
{
|
||||
Items = [],
|
||||
Weights = TriageDimensionWeights.Default,
|
||||
Thresholds = TriageBandThresholds.Default
|
||||
};
|
||||
|
||||
result.HotCount.Should().Be(0);
|
||||
result.WarmCount.Should().Be(0);
|
||||
result.ColdCount.Should().Be(0);
|
||||
}
|
||||
|
||||
private static TriageScoredItem MakeScoredItem(string purl, string reason, double composite, TriageBand band)
|
||||
{
|
||||
return new TriageScoredItem
|
||||
{
|
||||
Unknown = new UnknownItem(purl, null, reason, null),
|
||||
Score = new TriageScore
|
||||
{
|
||||
Probability = composite,
|
||||
Exposure = composite,
|
||||
Uncertainty = composite,
|
||||
Consequence = composite,
|
||||
SignalFreshness = composite
|
||||
},
|
||||
CompositeScore = composite,
|
||||
Band = band
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Scorer tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class UnknownsTriageScorerTests
|
||||
{
|
||||
private readonly UnknownsTriageScorer _scorer;
|
||||
|
||||
public UnknownsTriageScorerTests()
|
||||
{
|
||||
var meterFactory = new TestTriageMeterFactory();
|
||||
_scorer = new UnknownsTriageScorer(meterFactory);
|
||||
}
|
||||
|
||||
// ── ComputeComposite ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeComposite_all_ones_returns_1()
|
||||
{
|
||||
var score = AllDimensions(1.0);
|
||||
_scorer.ComputeComposite(score).Should().BeApproximately(1.0, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeComposite_all_zeros_returns_0()
|
||||
{
|
||||
var score = AllDimensions(0.0);
|
||||
_scorer.ComputeComposite(score).Should().BeApproximately(0.0, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeComposite_mixed_with_default_weights()
|
||||
{
|
||||
var score = new TriageScore
|
||||
{
|
||||
Probability = 0.8, // weight 0.30
|
||||
Exposure = 0.6, // weight 0.25
|
||||
Uncertainty = 0.4, // weight 0.20
|
||||
Consequence = 0.2, // weight 0.15
|
||||
SignalFreshness = 0.0 // weight 0.10
|
||||
};
|
||||
|
||||
// Expected: (0.8*0.3 + 0.6*0.25 + 0.4*0.2 + 0.2*0.15 + 0.0*0.1) / 1.0
|
||||
// = (0.24 + 0.15 + 0.08 + 0.03 + 0.00) = 0.50
|
||||
_scorer.ComputeComposite(score).Should().BeApproximately(0.50, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeComposite_custom_weights()
|
||||
{
|
||||
var score = AllDimensions(1.0);
|
||||
var weights = new TriageDimensionWeights { P = 1, E = 0, U = 0, C = 0, S = 0 };
|
||||
|
||||
_scorer.ComputeComposite(score, weights).Should().BeApproximately(1.0, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeComposite_equal_weights_gives_average()
|
||||
{
|
||||
var score = new TriageScore
|
||||
{
|
||||
Probability = 1.0,
|
||||
Exposure = 0.5,
|
||||
Uncertainty = 0.0,
|
||||
Consequence = 0.5,
|
||||
SignalFreshness = 0.0
|
||||
};
|
||||
var weights = new TriageDimensionWeights { P = 1, E = 1, U = 1, C = 1, S = 1 };
|
||||
|
||||
// Average of (1.0, 0.5, 0.0, 0.5, 0.0) = 2.0 / 5 = 0.4
|
||||
_scorer.ComputeComposite(score, weights).Should().BeApproximately(0.4, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeComposite_clamps_input_values()
|
||||
{
|
||||
var score = new TriageScore
|
||||
{
|
||||
Probability = 2.0, // should clamp to 1.0
|
||||
Exposure = -0.5, // should clamp to 0.0
|
||||
Uncertainty = 0.5,
|
||||
Consequence = 0.5,
|
||||
SignalFreshness = 0.5
|
||||
};
|
||||
|
||||
var result = _scorer.ComputeComposite(score);
|
||||
result.Should().BeGreaterOrEqualTo(0.0).And.BeLessOrEqualTo(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeComposite_zero_total_weight_returns_0()
|
||||
{
|
||||
var score = AllDimensions(1.0);
|
||||
var weights = new TriageDimensionWeights { P = 0, E = 0, U = 0, C = 0, S = 0 };
|
||||
|
||||
_scorer.ComputeComposite(score, weights).Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeComposite_null_score_throws()
|
||||
{
|
||||
var act = () => _scorer.ComputeComposite(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── Classify ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Classify_hot_at_threshold()
|
||||
{
|
||||
_scorer.Classify(0.70).Should().Be(TriageBand.Hot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_hot_above_threshold()
|
||||
{
|
||||
_scorer.Classify(0.95).Should().Be(TriageBand.Hot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_warm_at_threshold()
|
||||
{
|
||||
_scorer.Classify(0.40).Should().Be(TriageBand.Warm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_warm_between_thresholds()
|
||||
{
|
||||
_scorer.Classify(0.55).Should().Be(TriageBand.Warm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_cold_below_warm()
|
||||
{
|
||||
_scorer.Classify(0.20).Should().Be(TriageBand.Cold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_cold_at_zero()
|
||||
{
|
||||
_scorer.Classify(0.0).Should().Be(TriageBand.Cold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_custom_thresholds()
|
||||
{
|
||||
var thresholds = new TriageBandThresholds { HotThreshold = 0.90, WarmThreshold = 0.50 };
|
||||
|
||||
_scorer.Classify(0.89, thresholds).Should().Be(TriageBand.Warm);
|
||||
_scorer.Classify(0.90, thresholds).Should().Be(TriageBand.Hot);
|
||||
_scorer.Classify(0.49, thresholds).Should().Be(TriageBand.Cold);
|
||||
}
|
||||
|
||||
// ── Score (batch) ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Score_empty_unknowns_returns_empty_result()
|
||||
{
|
||||
var request = new TriageScoringRequest
|
||||
{
|
||||
Unknowns = [],
|
||||
Scores = new Dictionary<(string, string), TriageScore>()
|
||||
};
|
||||
|
||||
var result = _scorer.Score(request);
|
||||
|
||||
result.Items.Should().BeEmpty();
|
||||
result.HotCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Score_classifies_unknowns_correctly()
|
||||
{
|
||||
var unknowns = new[]
|
||||
{
|
||||
new UnknownItem("pkg:npm/hot-lib@1.0", null, "no-sbom", null),
|
||||
new UnknownItem("pkg:npm/cold-lib@1.0", null, "no-vulndb", null),
|
||||
new UnknownItem("pkg:npm/warm-lib@1.0", null, "no-sbom", null),
|
||||
};
|
||||
|
||||
var scores = new Dictionary<(string, string), TriageScore>
|
||||
{
|
||||
[("pkg:npm/hot-lib@1.0", "no-sbom")] = AllDimensions(0.9),
|
||||
[("pkg:npm/cold-lib@1.0", "no-vulndb")] = AllDimensions(0.1),
|
||||
[("pkg:npm/warm-lib@1.0", "no-sbom")] = AllDimensions(0.5),
|
||||
};
|
||||
|
||||
var result = _scorer.Score(new TriageScoringRequest
|
||||
{
|
||||
Unknowns = unknowns,
|
||||
Scores = scores
|
||||
});
|
||||
|
||||
result.HotCount.Should().Be(1);
|
||||
result.WarmCount.Should().Be(1);
|
||||
result.ColdCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Score_orders_by_composite_descending()
|
||||
{
|
||||
var unknowns = new[]
|
||||
{
|
||||
new UnknownItem("pkg:a", null, "R1", null),
|
||||
new UnknownItem("pkg:b", null, "R2", null),
|
||||
new UnknownItem("pkg:c", null, "R3", null),
|
||||
};
|
||||
|
||||
var scores = new Dictionary<(string, string), TriageScore>
|
||||
{
|
||||
[("pkg:a", "R1")] = AllDimensions(0.3),
|
||||
[("pkg:b", "R2")] = AllDimensions(0.9),
|
||||
[("pkg:c", "R3")] = AllDimensions(0.6),
|
||||
};
|
||||
|
||||
var result = _scorer.Score(new TriageScoringRequest
|
||||
{
|
||||
Unknowns = unknowns,
|
||||
Scores = scores
|
||||
});
|
||||
|
||||
result.Items[0].Unknown.PackageUrl.Should().Be("pkg:b");
|
||||
result.Items[1].Unknown.PackageUrl.Should().Be("pkg:c");
|
||||
result.Items[2].Unknown.PackageUrl.Should().Be("pkg:a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Score_missing_score_defaults_to_cold()
|
||||
{
|
||||
var unknowns = new[]
|
||||
{
|
||||
new UnknownItem("pkg:missing", null, "R1", null),
|
||||
};
|
||||
|
||||
var result = _scorer.Score(new TriageScoringRequest
|
||||
{
|
||||
Unknowns = unknowns,
|
||||
Scores = new Dictionary<(string, string), TriageScore>() // no score for this item
|
||||
});
|
||||
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.Items[0].CompositeScore.Should().Be(0.0);
|
||||
result.Items[0].Band.Should().Be(TriageBand.Cold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Score_custom_weights_affect_composite()
|
||||
{
|
||||
var unknowns = new[]
|
||||
{
|
||||
new UnknownItem("pkg:test", null, "R1", null),
|
||||
};
|
||||
|
||||
var score = new TriageScore
|
||||
{
|
||||
Probability = 1.0,
|
||||
Exposure = 0.0,
|
||||
Uncertainty = 0.0,
|
||||
Consequence = 0.0,
|
||||
SignalFreshness = 0.0
|
||||
};
|
||||
|
||||
var scores = new Dictionary<(string, string), TriageScore>
|
||||
{
|
||||
[("pkg:test", "R1")] = score
|
||||
};
|
||||
|
||||
// With all weight on P (=1.0) → composite = 1.0
|
||||
var result = _scorer.Score(new TriageScoringRequest
|
||||
{
|
||||
Unknowns = unknowns,
|
||||
Scores = scores,
|
||||
Weights = new TriageDimensionWeights { P = 1, E = 0, U = 0, C = 0, S = 0 }
|
||||
});
|
||||
|
||||
result.Items[0].CompositeScore.Should().BeApproximately(1.0, 1e-10);
|
||||
result.Items[0].Band.Should().Be(TriageBand.Hot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Score_is_deterministic()
|
||||
{
|
||||
var unknowns = new[]
|
||||
{
|
||||
new UnknownItem("pkg:a", null, "R1", null),
|
||||
new UnknownItem("pkg:b", null, "R2", null),
|
||||
};
|
||||
|
||||
var scores = new Dictionary<(string, string), TriageScore>
|
||||
{
|
||||
[("pkg:a", "R1")] = AllDimensions(0.5),
|
||||
[("pkg:b", "R2")] = AllDimensions(0.8),
|
||||
};
|
||||
|
||||
var request = new TriageScoringRequest { Unknowns = unknowns, Scores = scores };
|
||||
|
||||
var result1 = _scorer.Score(request);
|
||||
var result2 = _scorer.Score(request);
|
||||
|
||||
result1.Items.Length.Should().Be(result2.Items.Length);
|
||||
for (int i = 0; i < result1.Items.Length; i++)
|
||||
{
|
||||
result1.Items[i].CompositeScore.Should().Be(result2.Items[i].CompositeScore);
|
||||
result1.Items[i].Band.Should().Be(result2.Items[i].Band);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Score_null_request_throws()
|
||||
{
|
||||
var act = () => _scorer.Score(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Score_preserves_weights_and_thresholds_in_result()
|
||||
{
|
||||
var weights = new TriageDimensionWeights { P = 0.5, E = 0.2, U = 0.1, C = 0.1, S = 0.1 };
|
||||
var thresholds = new TriageBandThresholds { HotThreshold = 0.85, WarmThreshold = 0.50 };
|
||||
|
||||
var result = _scorer.Score(new TriageScoringRequest
|
||||
{
|
||||
Unknowns = [],
|
||||
Scores = new Dictionary<(string, string), TriageScore>(),
|
||||
Weights = weights,
|
||||
Thresholds = thresholds
|
||||
});
|
||||
|
||||
result.Weights.Should().Be(weights);
|
||||
result.Thresholds.Should().Be(thresholds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_throws()
|
||||
{
|
||||
var act = () => new UnknownsTriageScorer(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private static TriageScore AllDimensions(double value) => new()
|
||||
{
|
||||
Probability = value,
|
||||
Exposure = value,
|
||||
Uncertainty = value,
|
||||
Consequence = value,
|
||||
SignalFreshness = value
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Test meter factory
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
file sealed class TestTriageMeterFactory : IMeterFactory
|
||||
{
|
||||
public Meter Create(MeterOptions options) => new(options);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleRotationServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_016_Attestor_monthly_bundle_rotation_and_re_signing
|
||||
// Task: T1 — Tests for BundleRotationService
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Signing;
|
||||
|
||||
internal sealed class TestRotationMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options) { var m = new Meter(options); _meters.Add(m); return m; }
|
||||
public void Dispose() { foreach (var m in _meters) m.Dispose(); }
|
||||
}
|
||||
|
||||
internal sealed class FakeRotationTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
public void SetUtcNow(DateTimeOffset value) => _now = value;
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub key store that tracks known key IDs (returns true for TryGetVerificationKey
|
||||
/// but does not provide real key material since the rotation service only checks presence).
|
||||
/// </summary>
|
||||
internal sealed class StubKeyStore : IProofChainKeyStore
|
||||
{
|
||||
private readonly HashSet<string> _knownKeyIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void AddKey(string keyId) => _knownKeyIds.Add(keyId);
|
||||
|
||||
public bool TryGetSigningKey(SigningKeyProfile profile, out EnvelopeKey key)
|
||||
{
|
||||
key = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetVerificationKey(string keyId, out EnvelopeKey key)
|
||||
{
|
||||
key = default!;
|
||||
return _knownKeyIds.Contains(keyId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BundleRotationServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestRotationMeterFactory _meterFactory = new();
|
||||
private readonly FakeRotationTimeProvider _timeProvider = new();
|
||||
private readonly StubKeyStore _keyStore = new();
|
||||
private readonly BundleRotationService _sut;
|
||||
|
||||
public BundleRotationServiceTests()
|
||||
{
|
||||
_keyStore.AddKey("old-key-1");
|
||||
_keyStore.AddKey("new-key-1");
|
||||
_sut = new BundleRotationService(_keyStore, _timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
private static BundleRotationRequest CreateRequest(
|
||||
string rotationId = "rot-001",
|
||||
string oldKeyId = "old-key-1",
|
||||
string newKeyId = "new-key-1",
|
||||
string[]? bundles = null,
|
||||
RotationCadence cadence = RotationCadence.Monthly) => new()
|
||||
{
|
||||
RotationId = rotationId,
|
||||
Transition = new KeyTransition
|
||||
{
|
||||
OldKeyId = oldKeyId,
|
||||
NewKeyId = newKeyId,
|
||||
NewKeyAlgorithm = "ECDSA-P256",
|
||||
EffectiveAt = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
},
|
||||
BundleDigests = (bundles ?? ["sha256:aaa", "sha256:bbb"]).ToImmutableArray(),
|
||||
Cadence = cadence
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Rotate: basic
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_ValidRequest_ReturnsCompletedResult()
|
||||
{
|
||||
var result = await _sut.RotateAsync(CreateRequest());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.RotationId.Should().Be("rot-001");
|
||||
result.OverallStatus.Should().Be(RotationStatus.Completed);
|
||||
result.Entries.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_AllBundlesReSigned_SuccessCountMatches()
|
||||
{
|
||||
var result = await _sut.RotateAsync(CreateRequest());
|
||||
|
||||
result.SuccessCount.Should().Be(2);
|
||||
result.FailureCount.Should().Be(0);
|
||||
result.SkippedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_ProducesNewDigests()
|
||||
{
|
||||
var result = await _sut.RotateAsync(CreateRequest());
|
||||
|
||||
foreach (var entry in result.Entries)
|
||||
{
|
||||
entry.NewDigest.Should().NotBeNull();
|
||||
entry.NewDigest.Should().StartWith("sha256:");
|
||||
entry.NewDigest.Should().NotBe(entry.OriginalDigest);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_RecordsTransition()
|
||||
{
|
||||
var result = await _sut.RotateAsync(CreateRequest());
|
||||
|
||||
result.Transition.OldKeyId.Should().Be("old-key-1");
|
||||
result.Transition.NewKeyId.Should().Be("new-key-1");
|
||||
result.Transition.NewKeyAlgorithm.Should().Be("ECDSA-P256");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_SetsTimestamps()
|
||||
{
|
||||
var expected = new DateTimeOffset(2026, 7, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(expected);
|
||||
|
||||
var result = await _sut.RotateAsync(CreateRequest());
|
||||
|
||||
result.StartedAt.Should().Be(expected);
|
||||
result.CompletedAt.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_RecordsCadence()
|
||||
{
|
||||
var request = CreateRequest(cadence: RotationCadence.Quarterly);
|
||||
var result = await _sut.RotateAsync(request);
|
||||
|
||||
// Cadence is stored in the request, result references the transition
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Rotate: key validation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_OldKeyMissing_AllBundlesFail()
|
||||
{
|
||||
var result = await _sut.RotateAsync(
|
||||
CreateRequest(oldKeyId: "nonexistent-old"));
|
||||
|
||||
result.OverallStatus.Should().Be(RotationStatus.Failed);
|
||||
result.Entries.Should().AllSatisfy(e =>
|
||||
{
|
||||
e.Status.Should().Be(RotationStatus.Failed);
|
||||
e.ErrorMessage.Should().Contain("nonexistent-old");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_NewKeyMissing_AllBundlesFail()
|
||||
{
|
||||
var result = await _sut.RotateAsync(
|
||||
CreateRequest(newKeyId: "nonexistent-new"));
|
||||
|
||||
result.OverallStatus.Should().Be(RotationStatus.Failed);
|
||||
result.Entries.Should().AllSatisfy(e =>
|
||||
{
|
||||
e.Status.Should().Be(RotationStatus.Failed);
|
||||
e.ErrorMessage.Should().Contain("nonexistent-new");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_EmptyBundleDigest_EntryFails()
|
||||
{
|
||||
var result = await _sut.RotateAsync(
|
||||
CreateRequest(bundles: ["sha256:valid", " "]));
|
||||
|
||||
result.Entries[0].Status.Should().Be(RotationStatus.ReSigned);
|
||||
result.Entries[1].Status.Should().Be(RotationStatus.Failed);
|
||||
result.Entries[1].ErrorMessage.Should().Contain("Empty");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Rotate: argument validation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_NullRequest_Throws()
|
||||
{
|
||||
var act = () => _sut.RotateAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_EmptyRotationId_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => _sut.RotateAsync(CreateRequest(rotationId: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_EmptyBundles_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => _sut.RotateAsync(CreateRequest(bundles: []));
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_EmptyOldKeyId_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => _sut.RotateAsync(CreateRequest(oldKeyId: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_EmptyNewKeyId_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => _sut.RotateAsync(CreateRequest(newKeyId: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.RotateAsync(CreateRequest(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Transition attestation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task GetTransitionAttestationAsync_AfterRotation_ReturnsAttestation()
|
||||
{
|
||||
await _sut.RotateAsync(CreateRequest());
|
||||
|
||||
var attestation = await _sut.GetTransitionAttestationAsync("rot-001");
|
||||
|
||||
attestation.Should().NotBeNull();
|
||||
attestation!.RotationId.Should().Be("rot-001");
|
||||
attestation.AttestationId.Should().Be("attest-rot-001");
|
||||
attestation.BundlesProcessed.Should().Be(2);
|
||||
attestation.BundlesSucceeded.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTransitionAttestationAsync_HasResultDigest()
|
||||
{
|
||||
await _sut.RotateAsync(CreateRequest());
|
||||
|
||||
var attestation = await _sut.GetTransitionAttestationAsync("rot-001");
|
||||
|
||||
attestation!.ResultDigest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTransitionAttestationAsync_RecordsTransition()
|
||||
{
|
||||
await _sut.RotateAsync(CreateRequest());
|
||||
|
||||
var attestation = await _sut.GetTransitionAttestationAsync("rot-001");
|
||||
|
||||
attestation!.Transition.OldKeyId.Should().Be("old-key-1");
|
||||
attestation.Transition.NewKeyId.Should().Be("new-key-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTransitionAttestationAsync_UnknownRotation_ReturnsNull()
|
||||
{
|
||||
var attestation = await _sut.GetTransitionAttestationAsync("nonexistent");
|
||||
attestation.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTransitionAttestationAsync_NullRotationId_Throws()
|
||||
{
|
||||
var act = () => _sut.GetTransitionAttestationAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTransitionAttestationAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.GetTransitionAttestationAsync("rot-001", cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Query history
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QueryHistoryAsync_NoRotations_ReturnsEmpty()
|
||||
{
|
||||
var results = await _sut.QueryHistoryAsync(new RotationHistoryQuery());
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryHistoryAsync_AfterRotation_ReturnsResults()
|
||||
{
|
||||
await _sut.RotateAsync(CreateRequest());
|
||||
|
||||
var results = await _sut.QueryHistoryAsync(new RotationHistoryQuery());
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].RotationId.Should().Be("rot-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryHistoryAsync_FilterByKeyId_FiltersCorrectly()
|
||||
{
|
||||
await _sut.RotateAsync(CreateRequest(rotationId: "r1"));
|
||||
|
||||
_keyStore.AddKey("old-key-2");
|
||||
_keyStore.AddKey("new-key-2");
|
||||
await _sut.RotateAsync(CreateRequest(
|
||||
rotationId: "r2", oldKeyId: "old-key-2", newKeyId: "new-key-2"));
|
||||
|
||||
var results = await _sut.QueryHistoryAsync(
|
||||
new RotationHistoryQuery { KeyId = "old-key-2" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].RotationId.Should().Be("r2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryHistoryAsync_FilterByStatus_FiltersCorrectly()
|
||||
{
|
||||
await _sut.RotateAsync(CreateRequest(rotationId: "r1"));
|
||||
|
||||
// Create a failed rotation
|
||||
await _sut.RotateAsync(CreateRequest(
|
||||
rotationId: "r2", oldKeyId: "missing-key"));
|
||||
|
||||
var results = await _sut.QueryHistoryAsync(
|
||||
new RotationHistoryQuery { Status = RotationStatus.Failed });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].RotationId.Should().Be("r2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryHistoryAsync_RespectsLimit()
|
||||
{
|
||||
await _sut.RotateAsync(CreateRequest(rotationId: "r1"));
|
||||
await _sut.RotateAsync(CreateRequest(rotationId: "r2"));
|
||||
await _sut.RotateAsync(CreateRequest(rotationId: "r3"));
|
||||
|
||||
var results = await _sut.QueryHistoryAsync(
|
||||
new RotationHistoryQuery { Limit = 2 });
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryHistoryAsync_NullQuery_Throws()
|
||||
{
|
||||
var act = () => _sut.QueryHistoryAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryHistoryAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.QueryHistoryAsync(new RotationHistoryQuery(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// ComputeNextRotationDate
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextRotationDate_Monthly_AddsOneMonth()
|
||||
{
|
||||
var baseDate = new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var next = _sut.ComputeNextRotationDate(RotationCadence.Monthly, baseDate);
|
||||
|
||||
next.Should().Be(new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextRotationDate_Quarterly_AddsThreeMonths()
|
||||
{
|
||||
var baseDate = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var next = _sut.ComputeNextRotationDate(RotationCadence.Quarterly, baseDate);
|
||||
|
||||
next.Should().Be(new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextRotationDate_OnDemand_ReturnsBaseDate()
|
||||
{
|
||||
var baseDate = new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero);
|
||||
var next = _sut.ComputeNextRotationDate(RotationCadence.OnDemand, baseDate);
|
||||
|
||||
next.Should().Be(baseDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeNextRotationDate_NoLastRotation_UsesCurrentTime()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(now);
|
||||
|
||||
var next = _sut.ComputeNextRotationDate(RotationCadence.Monthly, null);
|
||||
|
||||
next.Should().Be(new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Determinism
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_SameInputs_ProducesSameDigests()
|
||||
{
|
||||
var r1 = await _sut.RotateAsync(CreateRequest(rotationId: "det-1"));
|
||||
|
||||
using var factory2 = new TestRotationMeterFactory();
|
||||
var sut2 = new BundleRotationService(_keyStore, _timeProvider, factory2);
|
||||
var r2 = await sut2.RotateAsync(CreateRequest(rotationId: "det-2"));
|
||||
|
||||
// New digests should be the same since they're computed from same inputs
|
||||
for (int i = 0; i < r1.Entries.Length; i++)
|
||||
{
|
||||
r1.Entries[i].NewDigest.Should().Be(r2.Entries[i].NewDigest,
|
||||
"same bundle+key should produce same re-signed digest");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Constructor
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullKeyStore_Throws()
|
||||
{
|
||||
var act = () => new BundleRotationService(null!, null, _meterFactory);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_Throws()
|
||||
{
|
||||
var act = () => new BundleRotationService(_keyStore, null, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_Succeeds()
|
||||
{
|
||||
using var factory = new TestRotationMeterFactory();
|
||||
var sut = new BundleRotationService(_keyStore, null, factory);
|
||||
sut.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ProofChain.Signing.DefaultCryptoProfileResolver"/>.
|
||||
/// </summary>
|
||||
public sealed class DefaultCryptoProfileResolverTests
|
||||
{
|
||||
private sealed class CryptoTestMeterFactory : IMeterFactory
|
||||
{
|
||||
public Meter Create(MeterOptions options) => new(options);
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private static DefaultCryptoProfileResolver CreateResolver(
|
||||
CryptoSovereignRegion region = CryptoSovereignRegion.International)
|
||||
=> new(region, new CryptoTestMeterFactory());
|
||||
|
||||
// --- Region-based resolution ---
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_International_ReturnsEd25519()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.International);
|
||||
|
||||
var binding = await resolver.ResolveAsync(SigningKeyProfile.Evidence);
|
||||
|
||||
binding.AlgorithmProfile.Should().Be(CryptoAlgorithmProfile.Ed25519);
|
||||
binding.AlgorithmId.Should().Be("ED25519");
|
||||
binding.Region.Should().Be(CryptoSovereignRegion.International);
|
||||
binding.KeyProfile.Should().Be(SigningKeyProfile.Evidence);
|
||||
binding.RequiresQualifiedTimestamp.Should().BeFalse();
|
||||
binding.RequiresHsm.Should().BeFalse();
|
||||
binding.MinimumCadesLevel.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_EuEidas_ReturnsEidasRsaWithTimestamp()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.EuEidas);
|
||||
|
||||
var binding = await resolver.ResolveAsync(SigningKeyProfile.Authority);
|
||||
|
||||
binding.AlgorithmProfile.Should().Be(CryptoAlgorithmProfile.EidasRsaSha256);
|
||||
binding.AlgorithmId.Should().Be("eIDAS-RSA-SHA256");
|
||||
binding.Region.Should().Be(CryptoSovereignRegion.EuEidas);
|
||||
binding.RequiresQualifiedTimestamp.Should().BeTrue();
|
||||
binding.MinimumCadesLevel.Should().Be(CadesLevel.CadesT);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_UsFips_ReturnsEcdsaP256WithHsm()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.UsFips);
|
||||
|
||||
var binding = await resolver.ResolveAsync(SigningKeyProfile.Reasoning);
|
||||
|
||||
binding.AlgorithmProfile.Should().Be(CryptoAlgorithmProfile.EcdsaP256);
|
||||
binding.AlgorithmId.Should().Be("ES256");
|
||||
binding.RequiresHsm.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_RuGost_ReturnsGost2012_256()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.RuGost);
|
||||
|
||||
var binding = await resolver.ResolveAsync(SigningKeyProfile.VexVerdict);
|
||||
|
||||
binding.AlgorithmProfile.Should().Be(CryptoAlgorithmProfile.Gost2012_256);
|
||||
binding.AlgorithmId.Should().Be("GOST-R34.10-2012-256");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_CnSm_ReturnsSm2()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.CnSm);
|
||||
|
||||
var binding = await resolver.ResolveAsync(SigningKeyProfile.Generator);
|
||||
|
||||
binding.AlgorithmProfile.Should().Be(CryptoAlgorithmProfile.Sm2);
|
||||
binding.AlgorithmId.Should().Be("SM2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_PostQuantum_ReturnsDilithium3()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.PostQuantum);
|
||||
|
||||
var binding = await resolver.ResolveAsync(SigningKeyProfile.Exception);
|
||||
|
||||
binding.AlgorithmProfile.Should().Be(CryptoAlgorithmProfile.Dilithium3);
|
||||
binding.AlgorithmId.Should().Be("DILITHIUM3");
|
||||
}
|
||||
|
||||
// --- Explicit region override ---
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_WithExplicitRegion_OverridesActiveRegion()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.International);
|
||||
|
||||
var binding = await resolver.ResolveAsync(SigningKeyProfile.Evidence, CryptoSovereignRegion.PostQuantum);
|
||||
|
||||
binding.AlgorithmProfile.Should().Be(CryptoAlgorithmProfile.Dilithium3);
|
||||
binding.Region.Should().Be(CryptoSovereignRegion.PostQuantum);
|
||||
}
|
||||
|
||||
// --- All key profiles resolve for all regions ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(SigningKeyProfile.Evidence)]
|
||||
[InlineData(SigningKeyProfile.Reasoning)]
|
||||
[InlineData(SigningKeyProfile.VexVerdict)]
|
||||
[InlineData(SigningKeyProfile.Authority)]
|
||||
[InlineData(SigningKeyProfile.Generator)]
|
||||
[InlineData(SigningKeyProfile.Exception)]
|
||||
public async Task Resolve_AllKeyProfiles_SucceedForInternational(SigningKeyProfile profile)
|
||||
{
|
||||
var resolver = CreateResolver();
|
||||
|
||||
var binding = await resolver.ResolveAsync(profile);
|
||||
|
||||
binding.KeyProfile.Should().Be(profile);
|
||||
binding.AlgorithmId.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
// --- ActiveRegion property ---
|
||||
|
||||
[Fact]
|
||||
public void ActiveRegion_ReturnsConfiguredRegion()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.EuEidas);
|
||||
|
||||
resolver.ActiveRegion.Should().Be(CryptoSovereignRegion.EuEidas);
|
||||
}
|
||||
|
||||
// --- Policy access ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(CryptoSovereignRegion.International)]
|
||||
[InlineData(CryptoSovereignRegion.EuEidas)]
|
||||
[InlineData(CryptoSovereignRegion.UsFips)]
|
||||
[InlineData(CryptoSovereignRegion.RuGost)]
|
||||
[InlineData(CryptoSovereignRegion.CnSm)]
|
||||
[InlineData(CryptoSovereignRegion.PostQuantum)]
|
||||
public void GetPolicy_AllRegions_ReturnValidPolicy(CryptoSovereignRegion region)
|
||||
{
|
||||
var resolver = CreateResolver();
|
||||
|
||||
var policy = resolver.GetPolicy(region);
|
||||
|
||||
policy.Region.Should().Be(region);
|
||||
policy.AllowedAlgorithms.Should().NotBeEmpty();
|
||||
policy.AllowedAlgorithms.Should().Contain(policy.DefaultAlgorithm);
|
||||
policy.Description.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPolicy_EuEidas_HasQualifiedTimestampRequirement()
|
||||
{
|
||||
var resolver = CreateResolver();
|
||||
|
||||
var policy = resolver.GetPolicy(CryptoSovereignRegion.EuEidas);
|
||||
|
||||
policy.RequiresQualifiedTimestamp.Should().BeTrue();
|
||||
policy.MinimumCadesLevel.Should().Be(CadesLevel.CadesT);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPolicy_UsFips_HasHsmRequirement()
|
||||
{
|
||||
var resolver = CreateResolver();
|
||||
|
||||
var policy = resolver.GetPolicy(CryptoSovereignRegion.UsFips);
|
||||
|
||||
policy.RequiresHsm.Should().BeTrue();
|
||||
}
|
||||
|
||||
// --- Algorithm ID mapping ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(CryptoAlgorithmProfile.Ed25519, "ED25519")]
|
||||
[InlineData(CryptoAlgorithmProfile.EcdsaP256, "ES256")]
|
||||
[InlineData(CryptoAlgorithmProfile.EcdsaP384, "ES384")]
|
||||
[InlineData(CryptoAlgorithmProfile.RsaPss, "PS256")]
|
||||
[InlineData(CryptoAlgorithmProfile.Gost2012_256, "GOST-R34.10-2012-256")]
|
||||
[InlineData(CryptoAlgorithmProfile.Gost2012_512, "GOST-R34.10-2012-512")]
|
||||
[InlineData(CryptoAlgorithmProfile.Sm2, "SM2")]
|
||||
[InlineData(CryptoAlgorithmProfile.Dilithium3, "DILITHIUM3")]
|
||||
[InlineData(CryptoAlgorithmProfile.Falcon512, "FALCON512")]
|
||||
[InlineData(CryptoAlgorithmProfile.EidasRsaSha256, "eIDAS-RSA-SHA256")]
|
||||
[InlineData(CryptoAlgorithmProfile.EidasEcdsaSha256, "eIDAS-ECDSA-SHA256")]
|
||||
public void MapAlgorithmId_AllProfiles_ReturnCorrectId(CryptoAlgorithmProfile profile, string expectedId)
|
||||
{
|
||||
var result = DefaultCryptoProfileResolver.MapAlgorithmId(profile);
|
||||
|
||||
result.Should().Be(expectedId);
|
||||
}
|
||||
|
||||
// --- Qualified timestamp validation ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateQualifiedTimestamp_NonEidasRegion_ReturnsNotQualified()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.International);
|
||||
var timestampBytes = new byte[] { 0x30, 0x03, 0x01, 0x01, 0xFF };
|
||||
var signedData = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
var result = await resolver.ValidateQualifiedTimestampAsync(timestampBytes, signedData);
|
||||
|
||||
result.IsQualified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("International");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateQualifiedTimestamp_EuEidas_EmptyTimestamp_ReturnsFailure()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.EuEidas);
|
||||
var signedData = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
var result = await resolver.ValidateQualifiedTimestampAsync(ReadOnlyMemory<byte>.Empty, signedData);
|
||||
|
||||
result.IsQualified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateQualifiedTimestamp_EuEidas_EmptySignedData_ReturnsFailure()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.EuEidas);
|
||||
var timestampBytes = new byte[] { 0x30, 0x03, 0x01, 0x01, 0xFF };
|
||||
|
||||
var result = await resolver.ValidateQualifiedTimestampAsync(timestampBytes, ReadOnlyMemory<byte>.Empty);
|
||||
|
||||
result.IsQualified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("Signed data");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateQualifiedTimestamp_EuEidas_InvalidAsn1_ReturnsFailure()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.EuEidas);
|
||||
var timestampBytes = new byte[] { 0xFF, 0x03, 0x01, 0x01, 0xFF }; // Not ASN.1 SEQUENCE
|
||||
var signedData = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
var result = await resolver.ValidateQualifiedTimestampAsync(timestampBytes, signedData);
|
||||
|
||||
result.IsQualified.Should().BeFalse();
|
||||
result.FailureReason.Should().Contain("ASN.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateQualifiedTimestamp_EuEidas_ValidStructure_ReturnsQualified()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.EuEidas);
|
||||
var timestampBytes = new byte[] { 0x30, 0x03, 0x01, 0x01, 0xFF }; // Valid ASN.1 SEQUENCE tag
|
||||
var signedData = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
var result = await resolver.ValidateQualifiedTimestampAsync(timestampBytes, signedData);
|
||||
|
||||
result.IsQualified.Should().BeTrue();
|
||||
result.AchievedCadesLevel.Should().Be(CadesLevel.CadesT);
|
||||
result.PolicyOid.Should().Be("0.4.0.2023.1.1");
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_CancelledToken_ThrowsOperationCanceled()
|
||||
{
|
||||
var resolver = CreateResolver();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(
|
||||
() => resolver.ResolveAsync(SigningKeyProfile.Evidence, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateQualifiedTimestamp_CancelledToken_ThrowsOperationCanceled()
|
||||
{
|
||||
var resolver = CreateResolver(CryptoSovereignRegion.EuEidas);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(
|
||||
() => resolver.ValidateQualifiedTimestampAsync(new byte[] { 0x30 }, new byte[] { 0x01 }, cts.Token));
|
||||
}
|
||||
|
||||
// --- Determinism ---
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_SameInputs_ProduceIdenticalBindings()
|
||||
{
|
||||
var resolver1 = CreateResolver(CryptoSovereignRegion.PostQuantum);
|
||||
var resolver2 = CreateResolver(CryptoSovereignRegion.PostQuantum);
|
||||
|
||||
var binding1 = await resolver1.ResolveAsync(SigningKeyProfile.Evidence);
|
||||
var binding2 = await resolver2.ResolveAsync(SigningKeyProfile.Evidence);
|
||||
|
||||
binding1.Should().Be(binding2);
|
||||
}
|
||||
|
||||
// --- Policy consistency ---
|
||||
|
||||
[Fact]
|
||||
public void AllPolicies_DefaultAlgorithm_IsInAllowedList()
|
||||
{
|
||||
var resolver = CreateResolver();
|
||||
|
||||
foreach (var region in Enum.GetValues<CryptoSovereignRegion>())
|
||||
{
|
||||
var policy = resolver.GetPolicy(region);
|
||||
policy.AllowedAlgorithms.Should().Contain(policy.DefaultAlgorithm,
|
||||
because: $"region {region} default algorithm must be in allowed list");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllPolicies_AllowedAlgorithms_AreNotEmpty()
|
||||
{
|
||||
var resolver = CreateResolver();
|
||||
|
||||
foreach (var region in Enum.GetValues<CryptoSovereignRegion>())
|
||||
{
|
||||
var policy = resolver.GetPolicy(region);
|
||||
policy.AllowedAlgorithms.Should().NotBeEmpty(
|
||||
because: $"region {region} must have at least one allowed algorithm");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user