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