using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.AirGap.Time.Models; using StellaOps.AirGap.Time.Services; using StellaOps.AirGap.Time.Stores; namespace StellaOps.AirGap.Time.Tests; /// /// Tests for TimeAnchorPolicyService. /// Per AIRGAP-TIME-57-001: Time-anchor policy enforcement. /// public class TimeAnchorPolicyServiceTests { private readonly TimeProvider _fixedTimeProvider; private readonly InMemoryTimeAnchorStore _store; private readonly StalenessCalculator _calculator; private readonly TimeTelemetry _telemetry; private readonly TimeStatusService _statusService; private readonly AirGapOptions _airGapOptions; public TimeAnchorPolicyServiceTests() { _fixedTimeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero)); _store = new InMemoryTimeAnchorStore(); _calculator = new StalenessCalculator(); _telemetry = new TimeTelemetry(); _airGapOptions = new AirGapOptions { Staleness = new AirGapOptions.StalenessOptions { WarningSeconds = 3600, BreachSeconds = 7200 }, ContentBudgets = new Dictionary() }; _statusService = new TimeStatusService(_store, _calculator, _telemetry, Options.Create(_airGapOptions)); } private TimeAnchorPolicyService CreateService(TimeAnchorPolicyOptions? options = null) { return new TimeAnchorPolicyService( _statusService, Options.Create(options ?? new TimeAnchorPolicyOptions()), NullLogger.Instance, _fixedTimeProvider); } [Fact] public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenNoAnchor() { var service = CreateService(); var result = await service.ValidateTimeAnchorAsync("tenant-1"); Assert.False(result.Allowed); Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode); Assert.NotNull(result.Remediation); } [Fact] public async Task ValidateTimeAnchorAsync_ReturnsSuccess_WhenAnchorValid() { var service = CreateService(); var anchor = new TimeAnchor( _fixedTimeProvider.GetUtcNow().AddMinutes(-30), "test-source", "Roughtime", "fingerprint", "digest123"); var budget = new StalenessBudget(3600, 7200); await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None); var result = await service.ValidateTimeAnchorAsync("tenant-1"); Assert.True(result.Allowed); Assert.Null(result.ErrorCode); Assert.NotNull(result.Staleness); Assert.False(result.Staleness.IsBreach); } [Fact] public async Task ValidateTimeAnchorAsync_ReturnsWarning_WhenAnchorStale() { var service = CreateService(); var anchor = new TimeAnchor( _fixedTimeProvider.GetUtcNow().AddSeconds(-5000), // Past warning threshold "test-source", "Roughtime", "fingerprint", "digest123"); var budget = new StalenessBudget(3600, 7200); await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None); var result = await service.ValidateTimeAnchorAsync("tenant-1"); Assert.True(result.Allowed); // Allowed but with warning Assert.NotNull(result.Staleness); Assert.True(result.Staleness.IsWarning); Assert.Contains("warning", result.Reason, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenAnchorBreached() { var service = CreateService(); var anchor = new TimeAnchor( _fixedTimeProvider.GetUtcNow().AddSeconds(-8000), // Past breach threshold "test-source", "Roughtime", "fingerprint", "digest123"); var budget = new StalenessBudget(3600, 7200); await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None); var result = await service.ValidateTimeAnchorAsync("tenant-1"); Assert.False(result.Allowed); Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorBreached, result.ErrorCode); Assert.NotNull(result.Staleness); Assert.True(result.Staleness.IsBreach); } [Fact] public async Task EnforceBundleImportPolicyAsync_AllowsImport_WhenAnchorValid() { var service = CreateService(); var anchor = new TimeAnchor( _fixedTimeProvider.GetUtcNow().AddMinutes(-30), "test-source", "Roughtime", "fingerprint", "digest123"); var budget = new StalenessBudget(3600, 7200); await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None); var result = await service.EnforceBundleImportPolicyAsync( "tenant-1", "bundle-123", _fixedTimeProvider.GetUtcNow().AddMinutes(-15)); Assert.True(result.Allowed); } [Fact] public async Task EnforceBundleImportPolicyAsync_BlocksImport_WhenDriftExceeded() { var options = new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 }; // 1 hour max var service = CreateService(options); var anchor = new TimeAnchor( _fixedTimeProvider.GetUtcNow().AddMinutes(-30), "test-source", "Roughtime", "fingerprint", "digest123"); var budget = new StalenessBudget(86400, 172800); // Large budget await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None); var bundleTimestamp = _fixedTimeProvider.GetUtcNow().AddDays(-2); // 2 days ago var result = await service.EnforceBundleImportPolicyAsync( "tenant-1", "bundle-123", bundleTimestamp); Assert.False(result.Allowed); Assert.Equal(TimeAnchorPolicyErrorCodes.DriftExceeded, result.ErrorCode); } [Fact] public async Task EnforceOperationPolicyAsync_BlocksStrictOperations_WhenNoAnchor() { var options = new TimeAnchorPolicyOptions { StrictOperations = new[] { "attestation.sign" } }; var service = CreateService(options); var result = await service.EnforceOperationPolicyAsync("tenant-1", "attestation.sign"); Assert.False(result.Allowed); Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode); } [Fact] public async Task EnforceOperationPolicyAsync_AllowsNonStrictOperations_InNonStrictMode() { var options = new TimeAnchorPolicyOptions { StrictEnforcement = false, StrictOperations = new[] { "attestation.sign" } }; var service = CreateService(options); var result = await service.EnforceOperationPolicyAsync("tenant-1", "some.other.operation"); Assert.True(result.Allowed); } [Fact] public async Task CalculateDriftAsync_ReturnsNoDrift_WhenNoAnchor() { var service = CreateService(); var result = await service.CalculateDriftAsync("tenant-1", _fixedTimeProvider.GetUtcNow()); Assert.False(result.HasAnchor); Assert.Equal(TimeSpan.Zero, result.Drift); Assert.Null(result.AnchorTime); } [Fact] public async Task CalculateDriftAsync_ReturnsDrift_WhenAnchorExists() { var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 }); var anchorTime = _fixedTimeProvider.GetUtcNow().AddMinutes(-30); var anchor = new TimeAnchor(anchorTime, "test", "Roughtime", "fp", "digest"); var budget = new StalenessBudget(3600, 7200); await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None); var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(15); var result = await service.CalculateDriftAsync("tenant-1", targetTime); Assert.True(result.HasAnchor); Assert.Equal(anchorTime, result.AnchorTime); Assert.Equal(45, (int)result.Drift.TotalMinutes); // 30 min + 15 min Assert.False(result.DriftExceedsThreshold); } [Fact] public async Task CalculateDriftAsync_DetectsExcessiveDrift() { var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 60 }); // 1 minute max var anchor = new TimeAnchor( _fixedTimeProvider.GetUtcNow(), "test", "Roughtime", "fp", "digest"); var budget = new StalenessBudget(3600, 7200); await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None); var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(5); // 5 minutes drift var result = await service.CalculateDriftAsync("tenant-1", targetTime); Assert.True(result.HasAnchor); Assert.True(result.DriftExceedsThreshold); } private sealed class FakeTimeProvider : TimeProvider { private readonly DateTimeOffset _now; public FakeTimeProvider(DateTimeOffset now) => _now = now; public override DateTimeOffset GetUtcNow() => _now; } }