sprints work
This commit is contained in:
@@ -14,9 +14,9 @@ namespace StellaOps.Audit.ReplayToken.Tests;
|
||||
/// <summary>
|
||||
/// Security tests for Replay Token generation and verification.
|
||||
/// Implements Model L0 test requirements:
|
||||
/// - Token expiration test: expired replay token → rejected (REPLAY-5100-001)
|
||||
/// - Tamper detection test: modified replay token → rejected (REPLAY-5100-002)
|
||||
/// - Token issuance test: valid request → token generated with correct claims (REPLAY-5100-003)
|
||||
/// Note: REPLAY-5100-001 (token expiration) is BLOCKED - ReplayToken does not currently support expiration.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "ReplayTokenSecurity")]
|
||||
@@ -366,6 +366,249 @@ public sealed class ReplayTokenSecurityTests
|
||||
token1.Equals(token2).Should().BeTrue("value comparison should be case-insensitive");
|
||||
}
|
||||
|
||||
// REPLAY-5100-001: Token expiration tests
|
||||
|
||||
[Fact]
|
||||
public void ExpiredToken_VerifyWithExpiration_ReturnsExpired()
|
||||
{
|
||||
// Arrange
|
||||
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var generator = CreateGenerator(fixedTime);
|
||||
var request = CreateRequest();
|
||||
|
||||
// Generate token with 1 hour expiration
|
||||
var token = generator.GenerateWithExpiration(request, TimeSpan.FromHours(1));
|
||||
|
||||
// Advance time past expiration
|
||||
var futureGenerator = CreateGenerator(fixedTime.AddHours(2));
|
||||
|
||||
// Act
|
||||
var result = futureGenerator.VerifyWithExpiration(token, request);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(ReplayTokenVerificationResult.Expired, "expired token should be rejected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotYetExpiredToken_VerifyWithExpiration_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var generator = CreateGenerator(fixedTime);
|
||||
var request = CreateRequest();
|
||||
|
||||
// Generate token with 1 hour expiration
|
||||
var token = generator.GenerateWithExpiration(request, TimeSpan.FromHours(1));
|
||||
|
||||
// Use same time (not expired)
|
||||
var result = generator.VerifyWithExpiration(token, request);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(ReplayTokenVerificationResult.Valid, "valid, not-yet-expired token should be accepted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpiredToken_IsExpired_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiresAt = now.AddHours(-1); // Already expired
|
||||
var token = new ReplayToken("abc123", now.AddHours(-2), expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
|
||||
|
||||
// Act
|
||||
var isExpired = token.IsExpired(now);
|
||||
|
||||
// Assert
|
||||
isExpired.Should().BeTrue("token with past expiry should be expired");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotYetExpiredToken_IsExpired_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiresAt = now.AddHours(1); // Expires in 1 hour
|
||||
var token = new ReplayToken("abc123", now, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
|
||||
|
||||
// Act
|
||||
var isExpired = token.IsExpired(now);
|
||||
|
||||
// Assert
|
||||
isExpired.Should().BeFalse("token with future expiry should not be expired");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokenWithoutExpiration_IsExpired_ReturnsFalse()
|
||||
{
|
||||
// Arrange - v1.0 token without expiration
|
||||
var token = new ReplayToken("abc123", DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var isExpired = token.IsExpired();
|
||||
|
||||
// Assert
|
||||
isExpired.Should().BeFalse("v1.0 token without expiration should never be expired");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateWithExpiration_UsesDefaultExpiration_WhenNotSpecified()
|
||||
{
|
||||
// Arrange
|
||||
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var generator = CreateGenerator(fixedTime);
|
||||
var request = CreateRequest();
|
||||
|
||||
// Act
|
||||
var token = generator.GenerateWithExpiration(request);
|
||||
|
||||
// Assert
|
||||
token.ExpiresAt.Should().Be(fixedTime + ReplayToken.DefaultExpiration);
|
||||
token.Version.Should().Be(ReplayToken.VersionWithExpiration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateWithExpiration_UsesCustomExpiration()
|
||||
{
|
||||
// Arrange
|
||||
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var generator = CreateGenerator(fixedTime);
|
||||
var request = CreateRequest();
|
||||
var customExpiration = TimeSpan.FromMinutes(30);
|
||||
|
||||
// Act
|
||||
var token = generator.GenerateWithExpiration(request, customExpiration);
|
||||
|
||||
// Assert
|
||||
token.ExpiresAt.Should().Be(fixedTime + customExpiration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokenWithExpiration_CanonicalFormat_IncludesExpiryTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var expiresAt = fixedTime.AddHours(1);
|
||||
var token = new ReplayToken("abc123", fixedTime, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
|
||||
|
||||
// Act
|
||||
var canonical = token.Canonical;
|
||||
|
||||
// Assert
|
||||
canonical.Should().Contain(expiresAt.ToUnixTimeSeconds().ToString());
|
||||
canonical.Split(':').Should().HaveCount(5, "v2.0 format should have 5 parts including expiry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseToken_WithExpiration_RoundTrip_PreservesExpiration()
|
||||
{
|
||||
// Arrange
|
||||
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var generator = CreateGenerator(fixedTime);
|
||||
var request = CreateRequest();
|
||||
var originalToken = generator.GenerateWithExpiration(request, TimeSpan.FromHours(2));
|
||||
|
||||
// Act
|
||||
var parsed = ReplayToken.Parse(originalToken.Canonical);
|
||||
|
||||
// Assert
|
||||
parsed.Value.Should().Be(originalToken.Value);
|
||||
parsed.Version.Should().Be(ReplayToken.VersionWithExpiration);
|
||||
parsed.ExpiresAt.Should().Be(originalToken.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedToken_WithExpiration_VerifyWithExpiration_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var generator = CreateGenerator(fixedTime);
|
||||
var request = CreateRequest();
|
||||
var token = generator.GenerateWithExpiration(request, TimeSpan.FromHours(1));
|
||||
|
||||
// Tamper with the token value
|
||||
var tamperedValue = TamperHash(token.Value);
|
||||
var tamperedToken = new ReplayToken(tamperedValue, token.GeneratedAt, token.ExpiresAt, token.Algorithm, token.Version);
|
||||
|
||||
// Act
|
||||
var result = generator.VerifyWithExpiration(tamperedToken, request);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(ReplayTokenVerificationResult.Invalid, "tampered token should be invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTimeToExpiration_ReturnsRemainingTime()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiresAt = now.AddHours(2);
|
||||
var token = new ReplayToken("abc123", now, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
|
||||
|
||||
// Act
|
||||
var remaining = token.GetTimeToExpiration(now);
|
||||
|
||||
// Assert
|
||||
remaining.Should().NotBeNull();
|
||||
remaining.Value.Should().BeCloseTo(TimeSpan.FromHours(2), TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTimeToExpiration_ExpiredToken_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiresAt = now.AddHours(-1); // Already expired
|
||||
var token = new ReplayToken("abc123", now.AddHours(-2), expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
|
||||
|
||||
// Act
|
||||
var remaining = token.GetTimeToExpiration(now);
|
||||
|
||||
// Assert
|
||||
remaining.Should().BeNull("expired token should have no remaining time");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTimeToExpiration_NoExpiration_ReturnsNull()
|
||||
{
|
||||
// Arrange - v1.0 token without expiration
|
||||
var token = new ReplayToken("abc123", DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var remaining = token.GetTimeToExpiration();
|
||||
|
||||
// Assert
|
||||
remaining.Should().BeNull("v1.0 token without expiration has no remaining time concept");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ValidToken_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = "replay:v1.0:SHA-256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1";
|
||||
|
||||
// Act
|
||||
var success = ReplayToken.TryParse(canonical, out var token);
|
||||
|
||||
// Assert
|
||||
success.Should().BeTrue();
|
||||
token.Should().NotBeNull();
|
||||
token!.Value.Should().Be("abc123def456abc123def456abc123def456abc123def456abc123def456abc1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_InvalidToken_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var invalid = "not-a-valid-token";
|
||||
|
||||
// Act
|
||||
var success = ReplayToken.TryParse(invalid, out var token);
|
||||
|
||||
// Assert
|
||||
success.Should().BeFalse();
|
||||
token.Should().BeNull();
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static Sha256ReplayTokenGenerator CreateGenerator(DateTimeOffset? fixedTime = null)
|
||||
|
||||
Reference in New Issue
Block a user