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:
master
2025-12-16 10:44:00 +02:00
parent b1f40945b7
commit 4391f35d8a
107 changed files with 10844 additions and 287 deletions

View File

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