Refactor SurfaceCacheValidator to simplify oldest entry calculation
Add global using for Xunit in test project Enhance ImportValidatorTests with async validation and quarantine checks Implement FileSystemQuarantineServiceTests for quarantine functionality Add integration tests for ImportValidator to check monotonicity Create BundleVersionTests to validate version parsing and comparison logic Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
This commit is contained in:
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -1,22 +1,61 @@
|
||||
using System.Security.Cryptography;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Quarantine;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class ImportValidatorTests
|
||||
public sealed class ImportValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void FailsWhenTufInvalid()
|
||||
public async Task ValidateAsync_WhenTufInvalid_ShouldFailAndQuarantine()
|
||||
{
|
||||
var request = BuildRequest(rootJson: "{}", snapshotJson: "{}", timestampJson: "{}");
|
||||
var result = new ImportValidator().Validate(request);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.StartsWith("tuf:", result.Reason);
|
||||
var quarantine = new CapturingQuarantineService();
|
||||
var monotonicity = new CapturingMonotonicityChecker();
|
||||
|
||||
var validator = new ImportValidator(
|
||||
new DsseVerifier(),
|
||||
new TufMetadataValidator(),
|
||||
new MerkleRootCalculator(),
|
||||
new RootRotationPolicy(),
|
||||
monotonicity,
|
||||
quarantine,
|
||||
NullLogger<ImportValidator>.Instance);
|
||||
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
|
||||
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
|
||||
|
||||
try
|
||||
{
|
||||
var request = BuildRequest(bundlePath, rootJson: "{}", snapshotJson: "{}", timestampJson: "{}");
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().StartWith("tuf:");
|
||||
|
||||
quarantine.Requests.Should().HaveCount(1);
|
||||
quarantine.Requests[0].TenantId.Should().Be("tenant-a");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SucceedsWhenAllChecksPass()
|
||||
public async Task ValidateAsync_WhenAllChecksPass_ShouldSucceedAndRecordActivation()
|
||||
{
|
||||
var root = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\"}";
|
||||
var snapshot = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"meta\":{\"snapshot\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
|
||||
@@ -39,20 +78,66 @@ public class ImportValidatorTests
|
||||
trustStore.LoadActive(new Dictionary<string, byte[]> { ["k1"] = pub });
|
||||
trustStore.StagePending(new Dictionary<string, byte[]> { ["k2"] = pub });
|
||||
|
||||
var request = new ImportValidationRequest(
|
||||
envelope,
|
||||
new TrustRootConfig("/tmp/root.json", new[] { Fingerprint(pub) }, new[] { "rsassa-pss-sha256" }, null, null, new Dictionary<string, byte[]> { ["k1"] = pub }),
|
||||
root,
|
||||
snapshot,
|
||||
timestamp,
|
||||
new List<NamedStream> { new("a.txt", new MemoryStream("data"u8.ToArray())) },
|
||||
trustStore,
|
||||
new[] { "approver-1", "approver-2" });
|
||||
var quarantine = new CapturingQuarantineService();
|
||||
var monotonicity = new CapturingMonotonicityChecker();
|
||||
|
||||
var result = new ImportValidator().Validate(request);
|
||||
var validator = new ImportValidator(
|
||||
new DsseVerifier(),
|
||||
new TufMetadataValidator(),
|
||||
new MerkleRootCalculator(),
|
||||
new RootRotationPolicy(),
|
||||
monotonicity,
|
||||
quarantine,
|
||||
NullLogger<ImportValidator>.Instance);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("import-validated", result.Reason);
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
|
||||
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
|
||||
|
||||
try
|
||||
{
|
||||
var request = new ImportValidationRequest(
|
||||
TenantId: "tenant-a",
|
||||
BundleType: "offline-kit",
|
||||
BundleDigest: "sha256:bundle",
|
||||
BundlePath: bundlePath,
|
||||
ManifestJson: "{\"version\":\"1.0.0\"}",
|
||||
ManifestVersion: "1.0.0",
|
||||
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
|
||||
ForceActivate: false,
|
||||
ForceActivateReason: null,
|
||||
Envelope: envelope,
|
||||
TrustRoots: new TrustRootConfig("/tmp/root.json", new[] { Fingerprint(pub) }, new[] { "rsassa-pss-sha256" }, null, null, new Dictionary<string, byte[]> { ["k1"] = pub }),
|
||||
RootJson: root,
|
||||
SnapshotJson: snapshot,
|
||||
TimestampJson: timestamp,
|
||||
PayloadEntries: new List<NamedStream> { new("a.txt", new MemoryStream("data"u8.ToArray())) },
|
||||
TrustStore: trustStore,
|
||||
ApproverIds: new[] { "approver-1", "approver-2" });
|
||||
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Reason.Should().Be("import-validated");
|
||||
|
||||
monotonicity.RecordedActivations.Should().HaveCount(1);
|
||||
monotonicity.RecordedActivations[0].BundleDigest.Should().Be("sha256:bundle");
|
||||
monotonicity.RecordedActivations[0].Version.SemVer.Should().Be("1.0.0");
|
||||
|
||||
quarantine.Requests.Should().BeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, string payload)
|
||||
@@ -74,19 +159,80 @@ public class ImportValidatorTests
|
||||
|
||||
private static string Fingerprint(byte[] pub) => Convert.ToHexString(SHA256.HashData(pub)).ToLowerInvariant();
|
||||
|
||||
private static ImportValidationRequest BuildRequest(string rootJson, string snapshotJson, string timestampJson)
|
||||
private static ImportValidationRequest BuildRequest(string bundlePath, string rootJson, string snapshotJson, string timestampJson)
|
||||
{
|
||||
var envelope = new DsseEnvelope("text/plain", Convert.ToBase64String("hi"u8), Array.Empty<DsseSignature>());
|
||||
var trustRoot = TrustRootConfig.Empty("/tmp");
|
||||
var trustStore = new TrustStore();
|
||||
return new ImportValidationRequest(
|
||||
envelope,
|
||||
trustRoot,
|
||||
rootJson,
|
||||
snapshotJson,
|
||||
timestampJson,
|
||||
Array.Empty<NamedStream>(),
|
||||
trustStore,
|
||||
Array.Empty<string>());
|
||||
TenantId: "tenant-a",
|
||||
BundleType: "offline-kit",
|
||||
BundleDigest: "sha256:bundle",
|
||||
BundlePath: bundlePath,
|
||||
ManifestJson: null,
|
||||
ManifestVersion: "1.0.0",
|
||||
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
|
||||
ForceActivate: false,
|
||||
ForceActivateReason: null,
|
||||
Envelope: envelope,
|
||||
TrustRoots: trustRoot,
|
||||
RootJson: rootJson,
|
||||
SnapshotJson: snapshotJson,
|
||||
TimestampJson: timestampJson,
|
||||
PayloadEntries: Array.Empty<NamedStream>(),
|
||||
TrustStore: trustStore,
|
||||
ApproverIds: Array.Empty<string>());
|
||||
}
|
||||
|
||||
private sealed class CapturingMonotonicityChecker : IVersionMonotonicityChecker
|
||||
{
|
||||
public List<(BundleVersion Version, string BundleDigest)> RecordedActivations { get; } = new();
|
||||
|
||||
public Task<MonotonicityCheckResult> CheckAsync(string tenantId, string bundleType, BundleVersion incomingVersion, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new MonotonicityCheckResult(
|
||||
IsMonotonic: true,
|
||||
CurrentVersion: null,
|
||||
CurrentBundleDigest: null,
|
||||
CurrentActivatedAt: null,
|
||||
ReasonCode: "FIRST_ACTIVATION"));
|
||||
}
|
||||
|
||||
public Task RecordActivationAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion version,
|
||||
string bundleDigest,
|
||||
bool wasForceActivated = false,
|
||||
string? forceActivateReason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RecordedActivations.Add((version, bundleDigest));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingQuarantineService : IQuarantineService
|
||||
{
|
||||
public List<QuarantineRequest> Requests { get; } = new();
|
||||
|
||||
public Task<QuarantineResult> QuarantineAsync(QuarantineRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(new QuarantineResult(
|
||||
Success: true,
|
||||
QuarantineId: "test",
|
||||
QuarantinePath: "(memory)",
|
||||
QuarantinedAt: DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<QuarantineEntry>> ListAsync(string tenantId, QuarantineListOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<QuarantineEntry>>(Array.Empty<QuarantineEntry>());
|
||||
|
||||
public Task<bool> RemoveAsync(string tenantId, string quarantineId, string removalReason, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<int> CleanupExpiredAsync(TimeSpan retentionPeriod, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Importer.Quarantine;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Quarantine;
|
||||
|
||||
public sealed class FileSystemQuarantineServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QuarantineAsync_ShouldCreateExpectedFiles_AndListAsyncShouldReturnEntry()
|
||||
{
|
||||
var root = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var bundlePath = Path.Combine(root, "bundle.tar.zst");
|
||||
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
|
||||
|
||||
var options = Options.Create(new QuarantineOptions
|
||||
{
|
||||
QuarantineRoot = Path.Combine(root, "quarantine"),
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
MaxQuarantineSizeBytes = 1024 * 1024,
|
||||
EnableAutomaticCleanup = true
|
||||
});
|
||||
|
||||
var svc = new FileSystemQuarantineService(
|
||||
options,
|
||||
NullLogger<FileSystemQuarantineService>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var result = await svc.QuarantineAsync(new QuarantineRequest(
|
||||
TenantId: "tenant-a",
|
||||
BundlePath: bundlePath,
|
||||
ManifestJson: "{\"version\":\"1.0.0\"}",
|
||||
ReasonCode: "dsse:invalid",
|
||||
ReasonMessage: "dsse:invalid",
|
||||
VerificationLog: new[] { "tuf:ok", "dsse:invalid" },
|
||||
Metadata: new Dictionary<string, string> { ["k"] = "v" }));
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
Directory.Exists(result.QuarantinePath).Should().BeTrue();
|
||||
|
||||
File.Exists(Path.Combine(result.QuarantinePath, "bundle.tar.zst")).Should().BeTrue();
|
||||
File.Exists(Path.Combine(result.QuarantinePath, "manifest.json")).Should().BeTrue();
|
||||
File.Exists(Path.Combine(result.QuarantinePath, "verification.log")).Should().BeTrue();
|
||||
File.Exists(Path.Combine(result.QuarantinePath, "failure-reason.txt")).Should().BeTrue();
|
||||
File.Exists(Path.Combine(result.QuarantinePath, "quarantine.json")).Should().BeTrue();
|
||||
|
||||
var listed = await svc.ListAsync("tenant-a");
|
||||
listed.Should().ContainSingle(e => e.QuarantineId == result.QuarantineId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDeleteDirectory(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_ShouldMoveToRemovedFolder()
|
||||
{
|
||||
var root = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var bundlePath = Path.Combine(root, "bundle.tar.zst");
|
||||
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
|
||||
|
||||
var quarantineRoot = Path.Combine(root, "quarantine");
|
||||
var options = Options.Create(new QuarantineOptions { QuarantineRoot = quarantineRoot, MaxQuarantineSizeBytes = 1024 * 1024 });
|
||||
var svc = new FileSystemQuarantineService(options, NullLogger<FileSystemQuarantineService>.Instance, TimeProvider.System);
|
||||
|
||||
var result = await svc.QuarantineAsync(new QuarantineRequest(
|
||||
TenantId: "tenant-a",
|
||||
BundlePath: bundlePath,
|
||||
ManifestJson: null,
|
||||
ReasonCode: "tuf:invalid",
|
||||
ReasonMessage: "tuf:invalid",
|
||||
VerificationLog: new[] { "tuf:invalid" }));
|
||||
|
||||
var removed = await svc.RemoveAsync("tenant-a", result.QuarantineId, "investigated");
|
||||
removed.Should().BeTrue();
|
||||
|
||||
Directory.Exists(result.QuarantinePath).Should().BeFalse();
|
||||
Directory.Exists(Path.Combine(quarantineRoot, "tenant-a", ".removed", result.QuarantineId)).Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDeleteDirectory(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupExpiredAsync_ShouldDeleteOldEntries()
|
||||
{
|
||||
var root = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var bundlePath = Path.Combine(root, "bundle.tar.zst");
|
||||
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
|
||||
|
||||
var quarantineRoot = Path.Combine(root, "quarantine");
|
||||
var options = Options.Create(new QuarantineOptions { QuarantineRoot = quarantineRoot, MaxQuarantineSizeBytes = 1024 * 1024 });
|
||||
var svc = new FileSystemQuarantineService(options, NullLogger<FileSystemQuarantineService>.Instance, TimeProvider.System);
|
||||
|
||||
var result = await svc.QuarantineAsync(new QuarantineRequest(
|
||||
TenantId: "tenant-a",
|
||||
BundlePath: bundlePath,
|
||||
ManifestJson: null,
|
||||
ReasonCode: "tuf:invalid",
|
||||
ReasonMessage: "tuf:invalid",
|
||||
VerificationLog: new[] { "tuf:invalid" }));
|
||||
|
||||
var jsonPath = Path.Combine(result.QuarantinePath, "quarantine.json");
|
||||
var json = await File.ReadAllTextAsync(jsonPath);
|
||||
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
var entry = JsonSerializer.Deserialize<QuarantineEntry>(json, jsonOptions);
|
||||
entry.Should().NotBeNull();
|
||||
|
||||
var oldEntry = entry! with { QuarantinedAt = DateTimeOffset.Parse("1900-01-01T00:00:00Z") };
|
||||
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(oldEntry, jsonOptions));
|
||||
|
||||
var removed = await svc.CleanupExpiredAsync(TimeSpan.FromDays(30));
|
||||
removed.Should().BeGreaterThanOrEqualTo(1);
|
||||
Directory.Exists(result.QuarantinePath).Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDeleteDirectory(root);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
private static void SafeDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Quarantine;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Validation;
|
||||
|
||||
public sealed class ImportValidatorIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenNonMonotonic_ShouldFailAndQuarantine()
|
||||
{
|
||||
var quarantine = new CapturingQuarantineService();
|
||||
var monotonicity = new FixedMonotonicityChecker(isMonotonic: false);
|
||||
|
||||
var validator = new ImportValidator(
|
||||
new DsseVerifier(),
|
||||
new TufMetadataValidator(),
|
||||
new MerkleRootCalculator(),
|
||||
new RootRotationPolicy(),
|
||||
monotonicity,
|
||||
quarantine,
|
||||
NullLogger<ImportValidator>.Instance);
|
||||
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
|
||||
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
|
||||
|
||||
try
|
||||
{
|
||||
var (envelope, trustRoots) = CreateValidDsse();
|
||||
|
||||
var trustStore = new TrustStore();
|
||||
trustStore.LoadActive(new Dictionary<string, byte[]>());
|
||||
trustStore.StagePending(new Dictionary<string, byte[]> { ["pending-key"] = new byte[] { 1, 2, 3 } });
|
||||
|
||||
var request = new ImportValidationRequest(
|
||||
TenantId: "tenant-a",
|
||||
BundleType: "offline-kit",
|
||||
BundleDigest: "sha256:bundle",
|
||||
BundlePath: bundlePath,
|
||||
ManifestJson: "{\"version\":\"1.0.0\"}",
|
||||
ManifestVersion: "1.0.0",
|
||||
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
|
||||
ForceActivate: false,
|
||||
ForceActivateReason: null,
|
||||
Envelope: envelope,
|
||||
TrustRoots: trustRoots,
|
||||
RootJson: """
|
||||
{"version":1,"expiresUtc":"2025-12-31T00:00:00Z"}
|
||||
""",
|
||||
SnapshotJson: """
|
||||
{"version":1,"expiresUtc":"2025-12-31T00:00:00Z","meta":{"snapshot":{"hashes":{"sha256":"abc"}}}}
|
||||
""",
|
||||
TimestampJson: """
|
||||
{"version":1,"expiresUtc":"2025-12-31T00:00:00Z","snapshot":{"meta":{"hashes":{"sha256":"abc"}}}}
|
||||
""",
|
||||
PayloadEntries: new[] { new NamedStream("payload.txt", new MemoryStream(Encoding.UTF8.GetBytes("hello"))) },
|
||||
TrustStore: trustStore,
|
||||
ApproverIds: new[] { "approver-a", "approver-b" });
|
||||
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().Contain("version-non-monotonic");
|
||||
|
||||
quarantine.Requests.Should().HaveCount(1);
|
||||
quarantine.Requests[0].TenantId.Should().Be("tenant-a");
|
||||
quarantine.Requests[0].ReasonCode.Should().Contain("version-non-monotonic");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (DsseEnvelope envelope, TrustRootConfig trustRoots) CreateValidDsse()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var publicKey = rsa.ExportSubjectPublicKeyInfo();
|
||||
|
||||
var fingerprint = Convert.ToHexString(SHA256.HashData(publicKey)).ToLowerInvariant();
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"hello\":\"world\"}");
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
var pae = BuildPae(payloadType, payloadBytes);
|
||||
var signature = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
PayloadType: payloadType,
|
||||
Payload: payloadBase64,
|
||||
Signatures: new[] { new DsseSignature("key-1", Convert.ToBase64String(signature)) });
|
||||
|
||||
var trustRoots = new TrustRootConfig(
|
||||
RootBundlePath: "(memory)",
|
||||
TrustedKeyFingerprints: new[] { fingerprint },
|
||||
AllowedSignatureAlgorithms: new[] { "rsa-pss-sha256" },
|
||||
NotBeforeUtc: null,
|
||||
NotAfterUtc: null,
|
||||
PublicKeys: new Dictionary<string, byte[]> { ["key-1"] = publicKey });
|
||||
|
||||
return (envelope, trustRoots);
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, byte[] payloadBytes)
|
||||
{
|
||||
const string paePrefix = "DSSEv1";
|
||||
var payload = Encoding.UTF8.GetString(payloadBytes);
|
||||
|
||||
var parts = new[]
|
||||
{
|
||||
paePrefix,
|
||||
payloadType,
|
||||
payload
|
||||
};
|
||||
|
||||
var paeBuilder = new StringBuilder();
|
||||
paeBuilder.Append("PAE:");
|
||||
paeBuilder.Append(parts.Length);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part.Length);
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetBytes(paeBuilder.ToString());
|
||||
}
|
||||
|
||||
private sealed class FixedMonotonicityChecker : IVersionMonotonicityChecker
|
||||
{
|
||||
private readonly bool _isMonotonic;
|
||||
|
||||
public FixedMonotonicityChecker(bool isMonotonic)
|
||||
{
|
||||
_isMonotonic = isMonotonic;
|
||||
}
|
||||
|
||||
public Task<MonotonicityCheckResult> CheckAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion incomingVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new MonotonicityCheckResult(
|
||||
IsMonotonic: _isMonotonic,
|
||||
CurrentVersion: new BundleVersion(2, 0, 0, DateTimeOffset.Parse("2025-12-14T00:00:00Z")),
|
||||
CurrentBundleDigest: "sha256:current",
|
||||
CurrentActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
|
||||
ReasonCode: _isMonotonic ? "MONOTONIC_OK" : "VERSION_NON_MONOTONIC"));
|
||||
}
|
||||
|
||||
public Task RecordActivationAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion version,
|
||||
string bundleDigest,
|
||||
bool wasForceActivated = false,
|
||||
string? forceActivateReason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingQuarantineService : IQuarantineService
|
||||
{
|
||||
public List<QuarantineRequest> Requests { get; } = new();
|
||||
|
||||
public Task<QuarantineResult> QuarantineAsync(QuarantineRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(new QuarantineResult(
|
||||
Success: true,
|
||||
QuarantineId: "test",
|
||||
QuarantinePath: "(memory)",
|
||||
QuarantinedAt: DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<QuarantineEntry>> ListAsync(string tenantId, QuarantineListOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<QuarantineEntry>>(Array.Empty<QuarantineEntry>());
|
||||
|
||||
public Task<bool> RemoveAsync(string tenantId, string quarantineId, string removalReason, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<int> CleanupExpiredAsync(TimeSpan retentionPeriod, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Versioning;
|
||||
|
||||
public sealed class BundleVersionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_ShouldParseSemVer()
|
||||
{
|
||||
var createdAt = new DateTimeOffset(2025, 12, 14, 0, 0, 0, TimeSpan.Zero);
|
||||
var version = BundleVersion.Parse("1.2.3", createdAt);
|
||||
|
||||
version.Major.Should().Be(1);
|
||||
version.Minor.Should().Be(2);
|
||||
version.Patch.Should().Be(3);
|
||||
version.Prerelease.Should().BeNull();
|
||||
version.CreatedAt.Should().Be(createdAt);
|
||||
version.SemVer.Should().Be("1.2.3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ShouldParsePrerelease()
|
||||
{
|
||||
var createdAt = new DateTimeOffset(2025, 12, 14, 0, 0, 0, TimeSpan.Zero);
|
||||
var version = BundleVersion.Parse("1.2.3-edge.1", createdAt);
|
||||
|
||||
version.SemVer.Should().Be("1.2.3-edge.1");
|
||||
version.Prerelease.Should().Be("edge.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNewerThan_ShouldCompareMajorMinorPatch()
|
||||
{
|
||||
var a = new BundleVersion(1, 2, 3, DateTimeOffset.UnixEpoch);
|
||||
var b = new BundleVersion(2, 0, 0, DateTimeOffset.UnixEpoch);
|
||||
b.IsNewerThan(a).Should().BeTrue();
|
||||
a.IsNewerThan(b).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNewerThan_ShouldTreatReleaseAsNewerThanPrerelease()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 12, 14, 0, 0, 0, TimeSpan.Zero);
|
||||
var prerelease = new BundleVersion(1, 2, 3, now, "alpha");
|
||||
var release = new BundleVersion(1, 2, 3, now, null);
|
||||
|
||||
release.IsNewerThan(prerelease).Should().BeTrue();
|
||||
prerelease.IsNewerThan(release).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNewerThan_ShouldOrderPrereleaseIdentifiers()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 12, 14, 0, 0, 0, TimeSpan.Zero);
|
||||
var alpha = new BundleVersion(1, 2, 3, now, "alpha");
|
||||
var beta = new BundleVersion(1, 2, 3, now, "beta");
|
||||
var rc1 = new BundleVersion(1, 2, 3, now, "rc.1");
|
||||
var rc2 = new BundleVersion(1, 2, 3, now, "rc.2");
|
||||
|
||||
beta.IsNewerThan(alpha).Should().BeTrue();
|
||||
rc1.IsNewerThan(beta).Should().BeTrue();
|
||||
rc2.IsNewerThan(rc1).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNewerThan_ShouldUseCreatedAtAsTiebreaker()
|
||||
{
|
||||
var earlier = new DateTimeOffset(2025, 12, 14, 0, 0, 0, TimeSpan.Zero);
|
||||
var later = earlier.AddMinutes(1);
|
||||
|
||||
var a = new BundleVersion(1, 2, 3, earlier, "edge");
|
||||
var b = new BundleVersion(1, 2, 3, later, "edge");
|
||||
|
||||
b.IsNewerThan(a).Should().BeTrue();
|
||||
a.IsNewerThan(b).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Versioning;
|
||||
|
||||
public sealed class VersionMonotonicityCheckerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckAsync_WhenNoCurrent_ShouldBeFirstActivation()
|
||||
{
|
||||
var store = new InMemoryBundleVersionStore();
|
||||
var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-14T00:00:00Z")));
|
||||
|
||||
var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-14T00:00:00Z"));
|
||||
var result = await checker.CheckAsync("tenant-a", "offline-kit", incoming);
|
||||
|
||||
result.IsMonotonic.Should().BeTrue();
|
||||
result.ReasonCode.Should().Be("FIRST_ACTIVATION");
|
||||
result.CurrentVersion.Should().BeNull();
|
||||
result.CurrentBundleDigest.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_WhenOlder_ShouldBeNonMonotonic()
|
||||
{
|
||||
var store = new InMemoryBundleVersionStore();
|
||||
await store.UpsertAsync(new BundleVersionRecord(
|
||||
TenantId: "tenant-a",
|
||||
BundleType: "offline-kit",
|
||||
VersionString: "2.0.0",
|
||||
Major: 2,
|
||||
Minor: 0,
|
||||
Patch: 0,
|
||||
Prerelease: null,
|
||||
BundleCreatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
|
||||
BundleDigest: "sha256:current",
|
||||
ActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
|
||||
WasForceActivated: false,
|
||||
ForceActivateReason: null));
|
||||
|
||||
var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-14T00:00:00Z")));
|
||||
var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-14T00:00:00Z"));
|
||||
|
||||
var result = await checker.CheckAsync("tenant-a", "offline-kit", incoming);
|
||||
|
||||
result.IsMonotonic.Should().BeFalse();
|
||||
result.ReasonCode.Should().Be("VERSION_NON_MONOTONIC");
|
||||
result.CurrentVersion.Should().NotBeNull();
|
||||
result.CurrentVersion!.SemVer.Should().Be("2.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordActivationAsync_WhenNonMonotonicWithoutForce_ShouldThrow()
|
||||
{
|
||||
var store = new InMemoryBundleVersionStore();
|
||||
await store.UpsertAsync(new BundleVersionRecord(
|
||||
TenantId: "tenant-a",
|
||||
BundleType: "offline-kit",
|
||||
VersionString: "2.0.0",
|
||||
Major: 2,
|
||||
Minor: 0,
|
||||
Patch: 0,
|
||||
Prerelease: null,
|
||||
BundleCreatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
|
||||
BundleDigest: "sha256:current",
|
||||
ActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
|
||||
WasForceActivated: false,
|
||||
ForceActivateReason: null));
|
||||
|
||||
var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-15T00:00:00Z")));
|
||||
var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-15T00:00:00Z"));
|
||||
|
||||
var act = () => checker.RecordActivationAsync("tenant-a", "offline-kit", incoming, "sha256:new");
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordActivationAsync_WhenForced_ShouldWriteForceFields()
|
||||
{
|
||||
var store = new InMemoryBundleVersionStore();
|
||||
await store.UpsertAsync(new BundleVersionRecord(
|
||||
TenantId: "tenant-a",
|
||||
BundleType: "offline-kit",
|
||||
VersionString: "2.0.0",
|
||||
Major: 2,
|
||||
Minor: 0,
|
||||
Patch: 0,
|
||||
Prerelease: null,
|
||||
BundleCreatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
|
||||
BundleDigest: "sha256:current",
|
||||
ActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
|
||||
WasForceActivated: false,
|
||||
ForceActivateReason: null));
|
||||
|
||||
var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-15T00:00:00Z")));
|
||||
var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-15T00:00:00Z"));
|
||||
|
||||
await checker.RecordActivationAsync(
|
||||
"tenant-a",
|
||||
"offline-kit",
|
||||
incoming,
|
||||
"sha256:new",
|
||||
wasForceActivated: true,
|
||||
forceActivateReason: "manual rollback permitted");
|
||||
|
||||
var current = await store.GetCurrentAsync("tenant-a", "offline-kit");
|
||||
current.Should().NotBeNull();
|
||||
current!.WasForceActivated.Should().BeTrue();
|
||||
current.ForceActivateReason.Should().Be("manual rollback permitted");
|
||||
current.BundleDigest.Should().Be("sha256:new");
|
||||
}
|
||||
|
||||
private sealed class InMemoryBundleVersionStore : IBundleVersionStore
|
||||
{
|
||||
private BundleVersionRecord? _current;
|
||||
private readonly List<BundleVersionRecord> _history = new();
|
||||
|
||||
public Task<BundleVersionRecord?> GetCurrentAsync(string tenantId, string bundleType, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(_current is not null &&
|
||||
_current.TenantId.Equals(tenantId, StringComparison.Ordinal) &&
|
||||
_current.BundleType.Equals(bundleType, StringComparison.Ordinal)
|
||||
? _current
|
||||
: null);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(BundleVersionRecord record, CancellationToken ct = default)
|
||||
{
|
||||
_current = record;
|
||||
_history.Insert(0, record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BundleVersionRecord>> GetHistoryAsync(string tenantId, string bundleType, int limit = 10, CancellationToken ct = default)
|
||||
{
|
||||
var items = _history
|
||||
.Where(r => r.TenantId.Equals(tenantId, StringComparison.Ordinal) && r.BundleType.Equals(bundleType, StringComparison.Ordinal))
|
||||
.Take(limit)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<BundleVersionRecord>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user