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
239 lines
9.4 KiB
C#
239 lines
9.4 KiB
C#
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 sealed class ImportValidatorTests
|
|
{
|
|
[Fact]
|
|
public async Task ValidateAsync_WhenTufInvalid_ShouldFailAndQuarantine()
|
|
{
|
|
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 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\"}}}}";
|
|
var timestamp = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"snapshot\":{\"meta\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
|
|
|
|
using var rsa = RSA.Create(2048);
|
|
var pub = rsa.ExportSubjectPublicKeyInfo();
|
|
|
|
var payload = "bundle-body";
|
|
var payloadType = "application/vnd.stella.bundle";
|
|
var pae = BuildPae(payloadType, payload);
|
|
var sig = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
|
|
|
var envelope = new DsseEnvelope(payloadType, Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)), new[]
|
|
{
|
|
new DsseSignature("k1", Convert.ToBase64String(sig))
|
|
});
|
|
|
|
var trustStore = new TrustStore();
|
|
trustStore.LoadActive(new Dictionary<string, byte[]> { ["k1"] = pub });
|
|
trustStore.StagePending(new Dictionary<string, byte[]> { ["k2"] = pub });
|
|
|
|
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 = 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)
|
|
{
|
|
var parts = new[] { "DSSEv1", payloadType, payload };
|
|
var paeBuilder = new System.Text.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 System.Text.Encoding.UTF8.GetBytes(paeBuilder.ToString());
|
|
}
|
|
|
|
private static string Fingerprint(byte[] pub) => Convert.ToHexString(SHA256.HashData(pub)).ToLowerInvariant();
|
|
|
|
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(
|
|
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);
|
|
}
|
|
}
|