partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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() { }
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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");
}
}

View File

@@ -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));
}
}

View File

@@ -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
};
}

View File

@@ -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);
}
}

View File

@@ -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>();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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() { }
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}
}