feat(api): Implement Console Export Client and Models
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled
- Added ConsoleExportClient for managing export requests and responses. - Introduced ConsoleExportRequest and ConsoleExportResponse models. - Implemented methods for creating and retrieving exports with appropriate headers. feat(crypto): Add Software SM2/SM3 Cryptography Provider - Implemented SmSoftCryptoProvider for software-only SM2/SM3 cryptography. - Added support for signing and verification using SM2 algorithm. - Included hashing functionality with SM3 algorithm. - Configured options for loading keys from files and environment gate checks. test(crypto): Add unit tests for SmSoftCryptoProvider - Created comprehensive tests for signing, verifying, and hashing functionalities. - Ensured correct behavior for key management and error handling. feat(api): Enhance Console Export Models - Expanded ConsoleExport models to include detailed status and event types. - Added support for various export formats and notification options. test(time): Implement TimeAnchorPolicyService tests - Developed tests for TimeAnchorPolicyService to validate time anchors. - Covered scenarios for anchor validation, drift calculation, and policy enforcement.
This commit is contained in:
@@ -3,21 +3,91 @@ using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Rfc3161Verifier with real SignedCms verification.
|
||||
/// Per AIRGAP-TIME-57-001: Trusted time-anchor service.
|
||||
/// </summary>
|
||||
public class Rfc3161VerifierTests
|
||||
{
|
||||
private readonly Rfc3161Verifier _verifier = new();
|
||||
|
||||
[Fact]
|
||||
public void StubTokenProducesDeterministicAnchor()
|
||||
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
|
||||
{
|
||||
var tokenBytes = new byte[] { 0x01, 0x02, 0x03 };
|
||||
var verifier = new Rfc3161Verifier();
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa-pkcs1-sha256") };
|
||||
var token = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
var result = verifier.Verify(tokenBytes, trust, out var anchor);
|
||||
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("rfc3161-stub-verified", result.Reason);
|
||||
Assert.Equal("RFC3161", anchor.Format);
|
||||
Assert.Equal("tsa-root", anchor.SignatureFingerprint);
|
||||
Assert.False(string.IsNullOrEmpty(anchor.TokenDigest));
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("rfc3161-trust-roots-required", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTokenEmpty()
|
||||
{
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
|
||||
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("rfc3161-token-empty", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenInvalidAsn1Structure()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03 }; // Invalid ASN.1
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("rfc3161-", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ProducesTokenDigest()
|
||||
{
|
||||
var token = new byte[] { 0x30, 0x00 }; // Empty SEQUENCE (minimal valid ASN.1)
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out _);
|
||||
|
||||
// Should fail on CMS decode but attempt was made
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("rfc3161-", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_HandlesExceptionsGracefully()
|
||||
{
|
||||
// Create bytes that might cause internal exceptions
|
||||
var token = new byte[256];
|
||||
new Random(42).NextBytes(token);
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
// Should not throw, should return failure result
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("rfc3161-", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReportsDecodeErrorForMalformedCms()
|
||||
{
|
||||
// Create something that looks like CMS but isn't valid
|
||||
var token = new byte[] { 0x30, 0x82, 0x00, 0x10, 0x06, 0x09 };
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out _);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
// Should report either decode or error
|
||||
Assert.True(result.Reason?.Contains("rfc3161-") ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,148 @@ using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for RoughtimeVerifier with real Ed25519 signature verification.
|
||||
/// Per AIRGAP-TIME-57-001: Trusted time-anchor service.
|
||||
/// </summary>
|
||||
public class RoughtimeVerifierTests
|
||||
{
|
||||
private readonly RoughtimeVerifier _verifier = new();
|
||||
|
||||
[Fact]
|
||||
public void StubTokenProducesDeterministicAnchor()
|
||||
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
|
||||
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("roughtime-trust-roots-required", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTokenEmpty()
|
||||
{
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
|
||||
|
||||
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("roughtime-token-empty", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTokenTooShort()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03 };
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("roughtime-message-too-short", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenInvalidTagCount()
|
||||
{
|
||||
// Create a minimal wire format with invalid tag count
|
||||
var token = new byte[8];
|
||||
// Set num_tags to 0 (invalid)
|
||||
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)0);
|
||||
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("roughtime-invalid-tag-count", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenNonEd25519Algorithm()
|
||||
{
|
||||
// Create a minimal valid-looking wire format
|
||||
var token = CreateMinimalRoughtimeToken();
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "rsa") }; // Wrong algorithm
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
// Should fail either on parsing or signature verification
|
||||
Assert.Contains("roughtime-", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenKeyLengthWrong()
|
||||
{
|
||||
var token = CreateMinimalRoughtimeToken();
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[16], "ed25519") }; // Wrong key length
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("roughtime-", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ProducesTokenDigest()
|
||||
{
|
||||
var token = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
|
||||
|
||||
var verifier = new RoughtimeVerifier();
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[] { 0x10, 0x20 }, "ed25519") };
|
||||
var result = _verifier.Verify(token, trust, out _);
|
||||
|
||||
var result = verifier.Verify(token, trust, out var anchor);
|
||||
// Even on failure, we should get a deterministic result
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("roughtime-stub-verified", result.Reason);
|
||||
Assert.Equal("Roughtime", anchor.Format);
|
||||
Assert.Equal("root1", anchor.SignatureFingerprint);
|
||||
/// <summary>
|
||||
/// Creates a minimal Roughtime wire format token for testing parsing paths.
|
||||
/// Note: This will fail signature verification but tests the parsing logic.
|
||||
/// </summary>
|
||||
private static byte[] CreateMinimalRoughtimeToken()
|
||||
{
|
||||
// Roughtime wire format:
|
||||
// [num_tags:u32] [offsets:u32[n-1]] [tags:u32[n]] [values...]
|
||||
// We'll create 2 tags: SIG and SREP
|
||||
|
||||
const uint TagSig = 0x00474953; // "SIG\0"
|
||||
const uint TagSrep = 0x50455253; // "SREP"
|
||||
|
||||
var sigValue = new byte[64]; // Ed25519 signature
|
||||
var srepValue = CreateMinimalSrep();
|
||||
|
||||
// Header: num_tags=2, offset[0]=64 (sig length), tags=[SIG, SREP]
|
||||
var headerSize = 4 + 4 + 8; // num_tags + 1 offset + 2 tags = 16 bytes
|
||||
var token = new byte[headerSize + sigValue.Length + srepValue.Length];
|
||||
|
||||
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)2); // num_tags = 2
|
||||
BitConverter.TryWriteBytes(token.AsSpan(4, 4), (uint)64); // offset[0] = 64 (sig length)
|
||||
BitConverter.TryWriteBytes(token.AsSpan(8, 4), TagSig);
|
||||
BitConverter.TryWriteBytes(token.AsSpan(12, 4), TagSrep);
|
||||
sigValue.CopyTo(token.AsSpan(16));
|
||||
srepValue.CopyTo(token.AsSpan(16 + 64));
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private static byte[] CreateMinimalSrep()
|
||||
{
|
||||
// SREP with MIDP tag containing 8-byte timestamp
|
||||
const uint TagMidp = 0x5044494D; // "MIDP"
|
||||
|
||||
// Header: num_tags=1, tags=[MIDP]
|
||||
var headerSize = 4 + 4; // num_tags + 1 tag = 8 bytes
|
||||
var srepValue = new byte[headerSize + 8]; // + 8 bytes for MIDP value
|
||||
|
||||
BitConverter.TryWriteBytes(srepValue.AsSpan(0, 4), (uint)1); // num_tags = 1
|
||||
BitConverter.TryWriteBytes(srepValue.AsSpan(4, 4), TagMidp);
|
||||
// MIDP value: microseconds since Unix epoch (example: 2025-01-01 00:00:00 UTC)
|
||||
BitConverter.TryWriteBytes(srepValue.AsSpan(8, 8), 1735689600000000L);
|
||||
|
||||
return srepValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for TimeAnchorPolicyService.
|
||||
/// Per AIRGAP-TIME-57-001: Time-anchor policy enforcement.
|
||||
/// </summary>
|
||||
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<string, AirGapOptions.StalenessOptions>()
|
||||
};
|
||||
_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<TimeAnchorPolicyService>.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user