using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.AirGap.Controller.Domain; using StellaOps.AirGap.Controller.Options; using StellaOps.AirGap.Controller.Services; using StellaOps.AirGap.Controller.Stores; using StellaOps.AirGap.Importer.Validation; using StellaOps.AirGap.Time.Models; using StellaOps.AirGap.Time.Services; using Xunit; namespace StellaOps.AirGap.Controller.Tests; public class AirGapStartupDiagnosticsHostedServiceTests { [Fact] public async Task Blocks_when_allowlist_missing_for_sealed_state() { var now = DateTimeOffset.UtcNow; var store = new InMemoryAirGapStateStore(); await store.SetAsync(new AirGapState { TenantId = "default", Sealed = true, PolicyHash = "policy-x", TimeAnchor = new TimeAnchor(now, "rough", "rough", "fp", "digest"), StalenessBudget = new StalenessBudget(60, 120) }); var trustDir = CreateTrustMaterial(); var options = BuildOptions(trustDir); options.EgressAllowlist = null; // simulate missing config section var service = CreateService(store, options, now); var ex = await Assert.ThrowsAsync(() => service.StartAsync(CancellationToken.None)); Assert.Contains("egress-allowlist-missing", ex.Message); } [Fact] public async Task Passes_when_materials_present_and_anchor_fresh() { var now = DateTimeOffset.UtcNow; var store = new InMemoryAirGapStateStore(); await store.SetAsync(new AirGapState { TenantId = "default", Sealed = true, PolicyHash = "policy-ok", TimeAnchor = new TimeAnchor(now.AddMinutes(-1), "rough", "rough", "fp", "digest"), StalenessBudget = new StalenessBudget(300, 600) }); var trustDir = CreateTrustMaterial(); var options = BuildOptions(trustDir, new[] { "127.0.0.1/32" }); var service = CreateService(store, options, now); await service.StartAsync(CancellationToken.None); // should not throw } [Fact] public async Task Blocks_when_anchor_is_stale() { var now = DateTimeOffset.UtcNow; var store = new InMemoryAirGapStateStore(); await store.SetAsync(new AirGapState { TenantId = "default", Sealed = true, PolicyHash = "policy-stale", TimeAnchor = new TimeAnchor(now.AddHours(-2), "rough", "rough", "fp", "digest"), StalenessBudget = new StalenessBudget(60, 90) }); var trustDir = CreateTrustMaterial(); var options = BuildOptions(trustDir, new[] { "10.0.0.0/24" }); var service = CreateService(store, options, now); var ex = await Assert.ThrowsAsync(() => service.StartAsync(CancellationToken.None)); Assert.Contains("time-anchor-stale", ex.Message); } [Fact] public async Task Blocks_when_rotation_pending_without_dual_approval() { var now = DateTimeOffset.UtcNow; var store = new InMemoryAirGapStateStore(); await store.SetAsync(new AirGapState { TenantId = "default", Sealed = true, PolicyHash = "policy-rot", TimeAnchor = new TimeAnchor(now, "rough", "rough", "fp", "digest"), StalenessBudget = new StalenessBudget(120, 240) }); var trustDir = CreateTrustMaterial(); var options = BuildOptions(trustDir, new[] { "10.10.0.0/16" }); options.Rotation.PendingKeys["k-new"] = Convert.ToBase64String(new byte[] { 1, 2, 3 }); options.Rotation.ActiveKeys["k-old"] = Convert.ToBase64String(new byte[] { 9, 9, 9 }); options.Rotation.ApproverIds.Add("approver-1"); var service = CreateService(store, options, now); var ex = await Assert.ThrowsAsync(() => service.StartAsync(CancellationToken.None)); Assert.Contains("rotation:rotation-dual-approval-required", ex.Message); } private static AirGapStartupOptions BuildOptions(string trustDir, string[]? allowlist = null) { return new AirGapStartupOptions { TenantId = "default", EgressAllowlist = allowlist, Trust = new TrustMaterialOptions { RootJsonPath = Path.Combine(trustDir, "root.json"), SnapshotJsonPath = Path.Combine(trustDir, "snapshot.json"), TimestampJsonPath = Path.Combine(trustDir, "timestamp.json") } }; } private static AirGapStartupDiagnosticsHostedService CreateService(IAirGapStateStore store, AirGapStartupOptions options, DateTimeOffset now) { return new AirGapStartupDiagnosticsHostedService( store, new StalenessCalculator(), new FixedTimeProvider(now), Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance, new AirGapTelemetry(NullLogger.Instance), new TufMetadataValidator(), new RootRotationPolicy()); } private static string CreateTrustMaterial() { var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "airgap-trust-" + Guid.NewGuid().ToString("N"))).FullName; var expires = DateTimeOffset.UtcNow.AddDays(1).ToString("O"); const string hash = "abc123"; File.WriteAllText(Path.Combine(dir, "root.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\"}}"); File.WriteAllText(Path.Combine(dir, "snapshot.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"meta\":{{\"snapshot\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}"); File.WriteAllText(Path.Combine(dir, "timestamp.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"snapshot\":{{\"meta\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}"); return dir; } private sealed class FixedTimeProvider : TimeProvider { private readonly DateTimeOffset _now; public FixedTimeProvider(DateTimeOffset now) { _now = now; } public override DateTimeOffset GetUtcNow() => _now; } }