using StellaOps.AirGap.Controller.Services; using StellaOps.AirGap.Controller.Stores; using StellaOps.AirGap.Time.Models; using StellaOps.AirGap.Time.Services; using Xunit; namespace StellaOps.AirGap.Controller.Tests; public class AirGapStateServiceTests { private readonly AirGapStateService _service; private readonly InMemoryAirGapStateStore _store = new(); private readonly StalenessCalculator _calculator = new(); public AirGapStateServiceTests() { _service = new AirGapStateService(_store, _calculator); } [Fact] public async Task Seal_sets_state_and_computes_staleness() { var now = DateTimeOffset.UtcNow; var anchor = new TimeAnchor(now.AddMinutes(-2), "roughtime", "roughtime", "fp", "digest"); var budget = new StalenessBudget(60, 120); await _service.SealAsync("tenant-a", "policy-1", anchor, budget, now); var status = await _service.GetStatusAsync("tenant-a", now); Assert.True(status.State.Sealed); Assert.Equal("policy-1", status.State.PolicyHash); Assert.Equal("tenant-a", status.State.TenantId); Assert.True(status.Staleness.AgeSeconds > 0); Assert.True(status.Staleness.IsWarning); Assert.Equal(120 - status.Staleness.AgeSeconds, status.Staleness.SecondsRemaining); } [Fact] public async Task Unseal_clears_sealed_flag_and_updates_timestamp() { var now = DateTimeOffset.UtcNow; await _service.SealAsync("default", "hash", TimeAnchor.Unknown, StalenessBudget.Default, now); var later = now.AddMinutes(1); await _service.UnsealAsync("default", later); var status = await _service.GetStatusAsync("default", later); Assert.False(status.State.Sealed); Assert.Equal(later, status.State.LastTransitionAt); } [Fact] public async Task Seal_persists_drift_baseline_seconds() { var now = DateTimeOffset.UtcNow; var anchor = new TimeAnchor(now.AddMinutes(-5), "roughtime", "roughtime", "fp", "digest"); var budget = StalenessBudget.Default; var state = await _service.SealAsync("tenant-drift", "policy-drift", anchor, budget, now); Assert.Equal(300, state.DriftBaselineSeconds); // 5 minutes = 300 seconds } [Fact] public async Task Seal_creates_default_content_budgets_when_not_provided() { var now = DateTimeOffset.UtcNow; var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest"); var budget = new StalenessBudget(120, 240); var state = await _service.SealAsync("tenant-content", "policy-content", anchor, budget, now); Assert.Contains("advisories", state.ContentBudgets.Keys); Assert.Contains("vex", state.ContentBudgets.Keys); Assert.Contains("policy", state.ContentBudgets.Keys); Assert.Equal(budget, state.ContentBudgets["advisories"]); } [Fact] public async Task Seal_uses_provided_content_budgets() { var now = DateTimeOffset.UtcNow; var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest"); var budget = StalenessBudget.Default; var contentBudgets = new Dictionary { { "advisories", new StalenessBudget(30, 60) }, { "vex", new StalenessBudget(60, 120) } }; var state = await _service.SealAsync("tenant-custom", "policy-custom", anchor, budget, now, contentBudgets); Assert.Equal(new StalenessBudget(30, 60), state.ContentBudgets["advisories"]); Assert.Equal(new StalenessBudget(60, 120), state.ContentBudgets["vex"]); Assert.Equal(budget, state.ContentBudgets["policy"]); // Falls back to default } [Fact] public async Task GetStatus_returns_per_content_staleness() { var now = DateTimeOffset.UtcNow; var anchor = new TimeAnchor(now.AddSeconds(-45), "roughtime", "roughtime", "fp", "digest"); var budget = StalenessBudget.Default; var contentBudgets = new Dictionary { { "advisories", new StalenessBudget(30, 60) }, { "vex", new StalenessBudget(60, 120) }, { "policy", new StalenessBudget(100, 200) } }; await _service.SealAsync("tenant-content-status", "policy-content-status", anchor, budget, now, contentBudgets); var status = await _service.GetStatusAsync("tenant-content-status", now); Assert.NotEmpty(status.ContentStaleness); Assert.True(status.ContentStaleness["advisories"].IsWarning); // 45s >= 30s warning Assert.False(status.ContentStaleness["advisories"].IsBreach); // 45s < 60s breach Assert.False(status.ContentStaleness["vex"].IsWarning); // 45s < 60s warning Assert.False(status.ContentStaleness["policy"].IsWarning); // 45s < 100s warning } }