partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SnapshotExportImportTests.cs
|
||||
// Sprint: SPRINT_20260208_021_Attestor_snapshot_export_import_for_air_gap
|
||||
// Task: T1 — Unit tests for snapshot export/import
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Model tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class SnapshotModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void SnapshotLevel_values_are_ordered()
|
||||
{
|
||||
((int)SnapshotLevel.LevelA).Should().BeLessThan((int)SnapshotLevel.LevelB);
|
||||
((int)SnapshotLevel.LevelB).Should().BeLessThan((int)SnapshotLevel.LevelC);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifestEntry_properties_roundtrip()
|
||||
{
|
||||
var entry = new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = "attestations/sha256:abc",
|
||||
Digest = "deadbeef",
|
||||
SizeBytes = 1024,
|
||||
Category = "attestation",
|
||||
ContentType = "application/vnd.dsse+json"
|
||||
};
|
||||
|
||||
entry.RelativePath.Should().Be("attestations/sha256:abc");
|
||||
entry.Digest.Should().Be("deadbeef");
|
||||
entry.SizeBytes.Should().Be(1024);
|
||||
entry.Category.Should().Be("attestation");
|
||||
entry.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifestEntry_default_content_type_is_octet_stream()
|
||||
{
|
||||
var entry = new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = "test",
|
||||
Digest = "abc",
|
||||
SizeBytes = 0,
|
||||
Category = "other"
|
||||
};
|
||||
|
||||
entry.ContentType.Should().Be("application/octet-stream");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifest_computed_properties()
|
||||
{
|
||||
var entries = ImmutableArray.Create(
|
||||
new SnapshotManifestEntry { RelativePath = "a", Digest = "d1", SizeBytes = 100, Category = "cat" },
|
||||
new SnapshotManifestEntry { RelativePath = "b", Digest = "d2", SizeBytes = 200, Category = "cat" }
|
||||
);
|
||||
|
||||
var manifest = new SnapshotManifest
|
||||
{
|
||||
ManifestDigest = "abc",
|
||||
Level = SnapshotLevel.LevelB,
|
||||
Entries = entries,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
manifest.TotalSizeBytes.Should().Be(300);
|
||||
manifest.EntryCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifest_empty_entries_gives_zero_totals()
|
||||
{
|
||||
var manifest = new SnapshotManifest
|
||||
{
|
||||
ManifestDigest = "abc",
|
||||
Level = SnapshotLevel.LevelA,
|
||||
Entries = [],
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
manifest.TotalSizeBytes.Should().Be(0);
|
||||
manifest.EntryCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotExportRequest_defaults()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelB };
|
||||
|
||||
request.IncludeTrustRoots.Should().BeTrue();
|
||||
request.IncludePolicies.Should().BeFalse();
|
||||
request.ArtifactDigests.IsDefaultOrEmpty.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotImportRequest_defaults()
|
||||
{
|
||||
var request = new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = new ReadOnlyMemory<byte>([1, 2, 3])
|
||||
};
|
||||
|
||||
request.VerifyIntegrity.Should().BeTrue();
|
||||
request.SkipExisting.Should().BeTrue();
|
||||
request.TargetTenantId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotOperationStatus_has_four_values()
|
||||
{
|
||||
Enum.GetValues<SnapshotOperationStatus>().Should().HaveCount(4);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SnapshotExporter tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class SnapshotExporterTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IOfflineRootStore> _rootStoreMock = new();
|
||||
private readonly Mock<ILogger<SnapshotExporter>> _loggerMock = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
|
||||
private readonly SnapshotExporter _exporter;
|
||||
|
||||
public SnapshotExporterTests()
|
||||
{
|
||||
_exporter = new SnapshotExporter(_rootStoreMock.Object, _loggerMock.Object, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelA_no_artifacts_returns_empty_manifest()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Manifest.Level.Should().Be(SnapshotLevel.LevelA);
|
||||
result.Manifest.EntryCount.Should().Be(0);
|
||||
result.ArchiveContent.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelA_with_artifacts_creates_attestation_entries()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelA,
|
||||
ArtifactDigests = ["sha256:aaa", "sha256:bbb"]
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Manifest.EntryCount.Should().Be(2);
|
||||
result.Manifest.Entries.Should().AllSatisfy(e =>
|
||||
{
|
||||
e.Category.Should().Be("attestation");
|
||||
e.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelB_includes_trust_roots()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
IncludeTrustRoots = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "trust-root")
|
||||
.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelB_without_trust_roots_flag_skips_them()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
IncludeTrustRoots = false
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "trust-root")
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelC_includes_policies()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelC,
|
||||
IncludePolicies = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.Level.Should().Be(SnapshotLevel.LevelC);
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "policy")
|
||||
.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelC_without_policies_flag_skips_them()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelC,
|
||||
IncludePolicies = false,
|
||||
IncludeTrustRoots = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "policy")
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_sets_tenant_and_description_in_manifest()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelA,
|
||||
TenantId = "tenant-42",
|
||||
Description = "Monthly export"
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.TenantId.Should().Be("tenant-42");
|
||||
result.Manifest.Description.Should().Be("Monthly export");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_manifest_digest_is_deterministic()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelA,
|
||||
ArtifactDigests = ["sha256:abc"]
|
||||
};
|
||||
|
||||
var result1 = await _exporter.ExportAsync(request);
|
||||
var result2 = await _exporter.ExportAsync(request);
|
||||
|
||||
result1.Manifest.ManifestDigest.Should().Be(result2.Manifest.ManifestDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_archive_is_valid_json()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
ArtifactDigests = ["sha256:xyz"]
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
var json = Encoding.UTF8.GetString(result.ArchiveContent.Span);
|
||||
var act = () => JsonDocument.Parse(json);
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_records_duration()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_null_request_throws()
|
||||
{
|
||||
var act = () => _exporter.ExportAsync(null!);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseManifestAsync_roundtrips_export_output()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
ArtifactDigests = ["sha256:roundtrip"],
|
||||
TenantId = "tenant-rt",
|
||||
Description = "Roundtrip test"
|
||||
};
|
||||
|
||||
var exported = await _exporter.ExportAsync(request);
|
||||
var parsed = await _exporter.ParseManifestAsync(exported.ArchiveContent);
|
||||
|
||||
parsed.Level.Should().Be(exported.Manifest.Level);
|
||||
parsed.ManifestDigest.Should().Be(exported.Manifest.ManifestDigest);
|
||||
parsed.TenantId.Should().Be(exported.Manifest.TenantId);
|
||||
parsed.Description.Should().Be(exported.Manifest.Description);
|
||||
parsed.EntryCount.Should().Be(exported.Manifest.EntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseManifestAsync_invalid_json_throws()
|
||||
{
|
||||
var garbage = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("not json"));
|
||||
|
||||
var act = () => _exporter.ParseManifestAsync(garbage);
|
||||
|
||||
await act.Should().ThrowAsync<JsonException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelB_with_artifacts_and_trust_roots()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
ArtifactDigests = ["sha256:d1", "sha256:d2"],
|
||||
IncludeTrustRoots = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.EntryCount.Should().Be(4); // 2 attestations + 2 trust roots
|
||||
result.Manifest.Entries.Select(e => e.Category).Distinct()
|
||||
.Should().Contain(["attestation", "trust-root"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_manifest_uses_fixed_timestamp()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.CreatedAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_format_version_defaults_to_1_0_0()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.FormatVersion.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_rootStore_throws()
|
||||
{
|
||||
var act = () => new SnapshotExporter(null!, _loggerMock.Object);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_logger_throws()
|
||||
{
|
||||
var act = () => new SnapshotExporter(_rootStoreMock.Object, null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SnapshotImporter tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class SnapshotImporterTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IOfflineRootStore> _rootStoreMock = new();
|
||||
private readonly Mock<ILogger<SnapshotExporter>> _exporterLoggerMock = new();
|
||||
private readonly Mock<ILogger<SnapshotImporter>> _importerLoggerMock = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
|
||||
private readonly SnapshotExporter _exporter;
|
||||
private readonly SnapshotImporter _importer;
|
||||
|
||||
public SnapshotImporterTests()
|
||||
{
|
||||
_exporter = new SnapshotExporter(_rootStoreMock.Object, _exporterLoggerMock.Object, _timeProvider);
|
||||
_importer = new SnapshotImporter(_rootStoreMock.Object, _importerLoggerMock.Object, _timeProvider);
|
||||
}
|
||||
|
||||
private async Task<ReadOnlyMemory<byte>> ExportArchiveAsync(SnapshotLevel level, string[]? digests = null)
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = level,
|
||||
ArtifactDigests = digests is null ? [] : [.. digests]
|
||||
};
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
return result.ArchiveContent;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_valid_archive_succeeds()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:test"]);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.ImportedCount.Should().BeGreaterThan(0);
|
||||
result.FailedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_preserves_manifest_level()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelC);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
result.Manifest.Level.Should().Be(SnapshotLevel.LevelC);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_invalid_json_returns_failed()
|
||||
{
|
||||
var garbage = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("not json"));
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = garbage
|
||||
});
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Failed);
|
||||
result.Messages.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_null_request_throws()
|
||||
{
|
||||
var act = () => _importer.ImportAsync(null!);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_records_duration()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelA);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateArchiveAsync_valid_archive_returns_success()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:val"]);
|
||||
|
||||
var result = await _importer.ValidateArchiveAsync(archive);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Messages.Should().Contain(m => m.Contains("integrity verified"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateArchiveAsync_invalid_json_returns_failed()
|
||||
{
|
||||
var garbage = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("{bad}"));
|
||||
|
||||
var result = await _importer.ValidateArchiveAsync(garbage);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateArchiveAsync_does_not_import()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:noimport"]);
|
||||
|
||||
var result = await _importer.ValidateArchiveAsync(archive);
|
||||
|
||||
result.ImportedCount.Should().Be(0);
|
||||
result.SkippedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_skip_verify_succeeds_for_valid_archive()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelA, ["sha256:skip"]);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive,
|
||||
VerifyIntegrity = false
|
||||
});
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_export_roundtrip_preserves_entry_count()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:a", "sha256:b"]);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
// 2 attestations + 2 trust roots = 4 entries
|
||||
result.Manifest.EntryCount.Should().Be(4);
|
||||
result.ImportedCount.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_rootStore_throws()
|
||||
{
|
||||
var act = () => new SnapshotImporter(null!, _importerLoggerMock.Object);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_logger_throws()
|
||||
{
|
||||
var act = () => new SnapshotImporter(_rootStoreMock.Object, null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// FakeTimeProvider for deterministic testing
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
file sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
Reference in New Issue
Block a user