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.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 { ["k1"] = pub }); trustStore.StagePending(new Dictionary { ["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.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 { ["k1"] = pub }), RootJson: root, SnapshotJson: snapshot, TimestampJson: timestamp, PayloadEntries: new List { 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()); 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(), TrustStore: trustStore, ApproverIds: Array.Empty()); } private sealed class CapturingMonotonicityChecker : IVersionMonotonicityChecker { public List<(BundleVersion Version, string BundleDigest)> RecordedActivations { get; } = new(); public Task 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 Requests { get; } = new(); public Task 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> ListAsync(string tenantId, QuarantineListOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult>(Array.Empty()); public Task RemoveAsync(string tenantId, string quarantineId, string removalReason, CancellationToken cancellationToken = default) => Task.FromResult(false); public Task CleanupExpiredAsync(TimeSpan retentionPeriod, CancellationToken cancellationToken = default) => Task.FromResult(0); } }