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,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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user