stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -0,0 +1,77 @@
using FluentAssertions;
using StellaOps.Cryptography;
using StellaOps.TestKit;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed class ReplayTokenGeneratorNormalizationTests
{
[Trait("Category", TestCategories.Unit)]
[Trait("Intent", "Safety")]
[Fact]
public void Generate_TrimsListValuesAndIgnoresWhitespaceEntries()
{
var generator = CreateGenerator();
var requestA = new ReplayTokenRequest
{
FeedManifests = ["sha256:aaa", "sha256:bbb"],
InputHashes = ["sha256:input1"],
EvidenceHashes = ["sha256:evidence1"]
};
var requestB = new ReplayTokenRequest
{
FeedManifests = [" sha256:bbb ", " ", "\t", "sha256:aaa"],
InputHashes = [" sha256:input1 "],
EvidenceHashes = ["sha256:evidence1", " "]
};
var tokenA = generator.Generate(requestA);
var tokenB = generator.Generate(requestB);
tokenB.Value.Should().Be(tokenA.Value);
tokenB.Canonical.Should().Be(tokenA.Canonical);
}
[Trait("Category", TestCategories.Unit)]
[Trait("Intent", "Safety")]
[Fact]
public void Generate_TrimsAdditionalContextValues()
{
var generator = CreateGenerator();
var requestA = new ReplayTokenRequest
{
InputHashes = ["sha256:input1"],
AdditionalContext = new Dictionary<string, string>
{
["key"] = "value"
}
};
var requestB = new ReplayTokenRequest
{
InputHashes = ["sha256:input1"],
AdditionalContext = new Dictionary<string, string>
{
["key"] = " value "
}
};
var tokenA = generator.Generate(requestA);
var tokenB = generator.Generate(requestB);
tokenB.Value.Should().Be(tokenA.Value);
}
private static Sha256ReplayTokenGenerator CreateGenerator()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
return new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
}
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
}

View File

@@ -0,0 +1,57 @@
using StellaOps.TestKit;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenGeneratorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Generate_IgnoresAdditionalContextOrdering()
{
var generator = CreateGenerator();
var requestA = new ReplayTokenRequest
{
InputHashes = new[] { "sha256:input" },
AdditionalContext = new Dictionary<string, string>
{
["a"] = "1",
["b"] = "2"
}
};
var requestB = new ReplayTokenRequest
{
InputHashes = new[] { "sha256:input" },
AdditionalContext = new Dictionary<string, string>
{
["b"] = "2",
["a"] = "1"
}
};
var tokenA = generator.Generate(requestA);
var tokenB = generator.Generate(requestB);
Assert.Equal(tokenA.Value, tokenB.Value);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Generate_DuplicateAdditionalContextKeys_Throws()
{
var generator = CreateGenerator();
var request = new ReplayTokenRequest
{
InputHashes = new[] { "sha256:input" },
AdditionalContext = new Dictionary<string, string>
{
["key"] = "1",
[" key "] = "2"
}
};
Assert.Throws<ArgumentException>(() => generator.Generate(request));
}
}

View File

@@ -0,0 +1,33 @@
using StellaOps.TestKit;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenGeneratorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GenerateWithExpiration_UsesDistinctCanonicalVersion()
{
var generator = CreateGenerator();
var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
var v1Token = generator.Generate(request);
var v2Token = generator.GenerateWithExpiration(request, TimeSpan.FromMinutes(5));
Assert.NotEqual(v1Token.Value, v2Token.Value);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void GenerateWithExpiration_NonPositiveExpiration_Throws(int seconds)
{
var generator = CreateGenerator();
var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
var expiration = TimeSpan.FromSeconds(seconds);
Assert.Throws<ArgumentOutOfRangeException>(() => generator.GenerateWithExpiration(request, expiration));
}
}

View File

@@ -0,0 +1,18 @@
using StellaOps.Cryptography;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenGeneratorTests
{
private static Sha256ReplayTokenGenerator CreateGenerator(DateTimeOffset? now = null)
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(now ?? new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
return new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
}
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
}

View File

@@ -0,0 +1,29 @@
using StellaOps.TestKit;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenGeneratorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ReplayToken_Parse_RoundTripsCanonical()
{
var token = new ReplayToken("0123456789abcdef", DateTimeOffset.UnixEpoch);
var parsed = ReplayToken.Parse(token.Canonical);
Assert.Equal(token.Value, parsed.Value);
Assert.Equal(token.Algorithm, parsed.Algorithm);
Assert.Equal(token.Version, parsed.Version);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("")]
[InlineData("replay")]
[InlineData("replay:v1.0:SHA-256")]
[InlineData("other:v1.0:SHA-256:abc")]
public void ReplayToken_Parse_Invalid_Throws(string canonical)
{
Assert.ThrowsAny<Exception>(() => ReplayToken.Parse(canonical));
}
}

View File

@@ -0,0 +1,31 @@
using StellaOps.TestKit;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenGeneratorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_MatchingInputs_ReturnsTrue()
{
var generator = CreateGenerator();
var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
var token = generator.Generate(request);
Assert.True(generator.Verify(token, request));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_DifferentInputs_ReturnsFalse()
{
var generator = CreateGenerator();
var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
var different = new ReplayTokenRequest { InputHashes = new[] { "sha256:other" } };
var token = generator.Generate(request);
Assert.False(generator.Verify(token, different));
}
}

View File

@@ -1,19 +1,14 @@
using StellaOps.Cryptography;
using StellaOps.TestKit;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed class ReplayTokenGeneratorTests
public sealed partial class ReplayTokenGeneratorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Generate_SameInputs_ReturnsSameValue()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var fixedNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var timeProvider = new FixedTimeProvider(fixedNow);
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var generator = CreateGenerator(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var request = new ReplayTokenRequest
{
@@ -40,13 +35,10 @@ public sealed class ReplayTokenGeneratorTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Generate_IgnoresArrayOrdering()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var generator = CreateGenerator();
var requestA = new ReplayTokenRequest
{
@@ -65,151 +57,4 @@ public sealed class ReplayTokenGeneratorTests
Assert.Equal(tokenA.Value, tokenB.Value);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_MatchingInputs_ReturnsTrue()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
var token = generator.Generate(request);
Assert.True(generator.Verify(token, request));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_DifferentInputs_ReturnsFalse()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
var different = new ReplayTokenRequest { InputHashes = new[] { "sha256:other" } };
var token = generator.Generate(request);
Assert.False(generator.Verify(token, different));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Generate_IgnoresAdditionalContextOrdering()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var requestA = new ReplayTokenRequest
{
InputHashes = new[] { "sha256:input" },
AdditionalContext = new Dictionary<string, string>
{
["a"] = "1",
["b"] = "2"
}
};
var requestB = new ReplayTokenRequest
{
InputHashes = new[] { "sha256:input" },
AdditionalContext = new Dictionary<string, string>
{
["b"] = "2",
["a"] = "1"
}
};
var tokenA = generator.Generate(requestA);
var tokenB = generator.Generate(requestB);
Assert.Equal(tokenA.Value, tokenB.Value);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Generate_DuplicateAdditionalContextKeys_Throws()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var request = new ReplayTokenRequest
{
InputHashes = new[] { "sha256:input" },
AdditionalContext = new Dictionary<string, string>
{
["key"] = "1",
[" key "] = "2"
}
};
Assert.Throws<ArgumentException>(() => generator.Generate(request));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GenerateWithExpiration_UsesDistinctCanonicalVersion()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
var v1Token = generator.Generate(request);
var v2Token = generator.GenerateWithExpiration(request, TimeSpan.FromMinutes(5));
Assert.NotEqual(v1Token.Value, v2Token.Value);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void GenerateWithExpiration_NonPositiveExpiration_Throws(int seconds)
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
var expiration = TimeSpan.FromSeconds(seconds);
Assert.Throws<ArgumentOutOfRangeException>(() => generator.GenerateWithExpiration(request, expiration));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ReplayToken_Parse_RoundTripsCanonical()
{
var token = new ReplayToken("0123456789abcdef", DateTimeOffset.UnixEpoch);
var parsed = ReplayToken.Parse(token.Canonical);
Assert.Equal(token.Value, parsed.Value);
Assert.Equal(token.Algorithm, parsed.Algorithm);
Assert.Equal(token.Version, parsed.Version);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("")]
[InlineData("replay")]
[InlineData("replay:v1.0:SHA-256")]
[InlineData("other:v1.0:SHA-256:abc")]
public void ReplayToken_Parse_Invalid_Throws(string canonical)
{
Assert.ThrowsAny<Exception>(() => ReplayToken.Parse(canonical));
}
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
}

View File

@@ -0,0 +1,48 @@
using FluentAssertions;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenSecurityTests
{
[Fact]
public void GenerateWithExpiration_UsesDefaultExpiration_WhenNotSpecified()
{
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
var token = generator.GenerateWithExpiration(request);
token.ExpiresAt.Should().Be(fixedTime + ReplayToken.DefaultExpiration);
token.Version.Should().Be(ReplayToken.VersionWithExpiration);
}
[Fact]
public void GenerateWithExpiration_UsesCustomExpiration()
{
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
var customExpiration = TimeSpan.FromMinutes(30);
var token = generator.GenerateWithExpiration(request, customExpiration);
token.ExpiresAt.Should().Be(fixedTime + customExpiration);
}
[Fact]
public void TamperedToken_WithExpiration_VerifyWithExpiration_ReturnsInvalid()
{
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));
var tamperedValue = TamperHash(token.Value);
var tamperedToken = new ReplayToken(tamperedValue, token.GeneratedAt, token.ExpiresAt, token.Algorithm, token.Version);
var result = generator.VerifyWithExpiration(tamperedToken, request);
result.Should().Be(ReplayTokenVerificationResult.Invalid, "tampered token should be invalid");
}
}

View File

@@ -0,0 +1,34 @@
using FluentAssertions;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenSecurityTests
{
[Fact]
public void TokenWithExpiration_CanonicalFormat_IncludesExpiryTimestamp()
{
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);
var canonical = token.Canonical;
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()
{
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));
var parsed = ReplayToken.Parse(originalToken.Canonical);
parsed.Value.Should().Be(originalToken.Value);
parsed.Version.Should().Be(ReplayToken.VersionWithExpiration);
parsed.ExpiresAt.Should().Be(originalToken.ExpiresAt);
}
}

View File

@@ -0,0 +1,41 @@
using FluentAssertions;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenSecurityTests
{
[Fact]
public void GetTimeToExpiration_ReturnsRemainingTime()
{
var now = DateTimeOffset.UtcNow;
var expiresAt = now.AddHours(2);
var token = new ReplayToken("abc123", now, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
var remaining = token.GetTimeToExpiration(now);
remaining.Should().NotBeNull();
remaining.Value.Should().BeCloseTo(TimeSpan.FromHours(2), TimeSpan.FromSeconds(1));
}
[Fact]
public void GetTimeToExpiration_ExpiredToken_ReturnsNull()
{
var now = DateTimeOffset.UtcNow;
var expiresAt = now.AddHours(-1);
var token = new ReplayToken("abc123", now.AddHours(-2), expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
var remaining = token.GetTimeToExpiration(now);
remaining.Should().BeNull("expired token should have no remaining time");
}
[Fact]
public void GetTimeToExpiration_NoExpiration_ReturnsNull()
{
var token = new ReplayToken("abc123", DateTimeOffset.UtcNow);
var remaining = token.GetTimeToExpiration();
remaining.Should().BeNull("v1.0 token without expiration has no remaining time concept");
}
}

View File

@@ -0,0 +1,70 @@
using FluentAssertions;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenSecurityTests
{
[Fact]
public void ExpiredToken_VerifyWithExpiration_ReturnsExpired()
{
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));
var futureGenerator = CreateGenerator(fixedTime.AddHours(2));
var result = futureGenerator.VerifyWithExpiration(token, request);
result.Should().Be(ReplayTokenVerificationResult.Expired, "expired token should be rejected");
}
[Fact]
public void NotYetExpiredToken_VerifyWithExpiration_ReturnsValid()
{
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));
var result = generator.VerifyWithExpiration(token, request);
result.Should().Be(ReplayTokenVerificationResult.Valid, "valid, not-yet-expired token should be accepted");
}
[Fact]
public void ExpiredToken_IsExpired_ReturnsTrue()
{
var now = DateTimeOffset.UtcNow;
var expiresAt = now.AddHours(-1);
var token = new ReplayToken("abc123", now.AddHours(-2), expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
var isExpired = token.IsExpired(now);
isExpired.Should().BeTrue("token with past expiry should be expired");
}
[Fact]
public void NotYetExpiredToken_IsExpired_ReturnsFalse()
{
var now = DateTimeOffset.UtcNow;
var expiresAt = now.AddHours(1);
var token = new ReplayToken("abc123", now, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
var isExpired = token.IsExpired(now);
isExpired.Should().BeFalse("token with future expiry should not be expired");
}
[Fact]
public void TokenWithoutExpiration_IsExpired_ReturnsFalse()
{
var token = new ReplayToken("abc123", DateTimeOffset.UtcNow);
var isExpired = token.IsExpired();
isExpired.Should().BeFalse("v1.0 token without expiration should never be expired");
}
}

View File

@@ -0,0 +1,53 @@
using FluentAssertions;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenSecurityTests
{
[Fact]
public void GenerateToken_ValidRequest_HasCorrectAlgorithm()
{
var generator = CreateGenerator();
var request = CreateRequest();
var token = generator.Generate(request);
token.Algorithm.Should().Be(ReplayToken.DefaultAlgorithm);
}
[Fact]
public void GenerateToken_ValidRequest_HasCorrectVersion()
{
var generator = CreateGenerator();
var request = CreateRequest();
var token = generator.Generate(request);
token.Version.Should().Be(ReplayToken.DefaultVersion);
}
[Fact]
public void GenerateToken_ValidRequest_HasValidSha256Format()
{
var generator = CreateGenerator();
var request = CreateRequest();
var token = generator.Generate(request);
token.Value.Should().NotBeNullOrEmpty();
token.Value.Length.Should().Be(64, "SHA-256 hex should be 64 characters");
token.Value.Should().MatchRegex("^[0-9a-f]{64}$", "should be lowercase hex");
}
[Fact]
public void GenerateToken_ValidRequest_HasCorrectTimestamp()
{
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
var token = generator.Generate(request);
token.GeneratedAt.Should().Be(fixedTime);
}
}

View File

@@ -0,0 +1,76 @@
using FluentAssertions;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenSecurityTests
{
[Fact]
public void GenerateToken_ValidRequest_CanonicalFormatIsCorrect()
{
var generator = CreateGenerator();
var request = CreateRequest();
var token = generator.Generate(request);
token.Canonical.Should().StartWith("replay:v");
token.Canonical.Should().Contain(":SHA-256:");
token.Canonical.Split(':').Should().HaveCount(4);
}
[Fact]
public void GenerateToken_EmptyRequest_StillGeneratesValidToken()
{
var generator = CreateGenerator();
var emptyRequest = new ReplayTokenRequest();
var token = generator.Generate(emptyRequest);
token.Should().NotBeNull();
token.Value.Should().NotBeNullOrEmpty();
}
[Fact]
public void GenerateToken_NullInputs_HandledGracefully()
{
var generator = CreateGenerator();
var request = new ReplayTokenRequest
{
FeedManifests = null!,
RulesVersion = null!,
InputHashes = null!
};
var token = generator.Generate(request);
token.Should().NotBeNull();
token.Value.Length.Should().Be(64);
}
[Fact]
public void GenerateToken_DeterministicAcrossMultipleCalls()
{
var fixedTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
var tokens = Enumerable.Range(0, 10)
.Select(_ => generator.Generate(request))
.ToList();
tokens.Select(t => t.Value).Distinct().Should().HaveCount(1,
"same request should produce same token value");
}
[Fact]
public void GenerateToken_DifferentRequests_ProduceDifferentTokens()
{
var generator = CreateGenerator();
var request1 = new ReplayTokenRequest { InputHashes = ["sha256:input1"] };
var request2 = new ReplayTokenRequest { InputHashes = ["sha256:input2"] };
var token1 = generator.Generate(request1);
var token2 = generator.Generate(request2);
token1.Value.Should().NotBe(token2.Value, "different requests should produce different tokens");
}
}

View File

@@ -0,0 +1,57 @@
using FluentAssertions;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenSecurityTests
{
[Fact]
public void ParseToken_RoundTrip_PreservesValues()
{
var generator = CreateGenerator();
var request = CreateRequest();
var originalToken = generator.Generate(request);
var parsed = ReplayToken.Parse(originalToken.Canonical);
parsed.Value.Should().Be(originalToken.Value);
parsed.Algorithm.Should().Be(originalToken.Algorithm);
parsed.Version.Should().Be(originalToken.Version);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("invalid")]
[InlineData("replay:")]
[InlineData("replay:v1.0")]
[InlineData("replay:v1.0:SHA-256")]
[InlineData("notreplay:v1.0:SHA-256:abc")]
public void ParseToken_InvalidFormat_ThrowsException(string invalidCanonical)
{
var act = () => ReplayToken.Parse(invalidCanonical);
act.Should().Throw<Exception>();
}
[Fact]
public void Token_Equality_BasedOnValue()
{
var value = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
var token1 = new ReplayToken(value, DateTimeOffset.UtcNow);
var token2 = new ReplayToken(value, DateTimeOffset.UtcNow.AddHours(1));
token1.Equals(token2).Should().BeTrue("equality should be based on value only");
token1.GetHashCode().Should().Be(token2.GetHashCode());
}
[Fact]
public void Token_Equality_CaseInsensitive()
{
var value1 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
var value2 = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
var token1 = new ReplayToken(value1, DateTimeOffset.UtcNow);
var token2 = new ReplayToken(value2, DateTimeOffset.UtcNow);
token1.Equals(token2).Should().BeTrue("value comparison should be case-insensitive");
}
}

View File

@@ -0,0 +1,73 @@
using FluentAssertions;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenSecurityTests
{
[Fact]
public void TamperedRequest_AddedField_VerificationFails()
{
var generator = CreateGenerator();
var originalRequest = CreateRequest();
var token = generator.Generate(originalRequest);
var tamperedRequest = new ReplayTokenRequest
{
FeedManifests = originalRequest.FeedManifests,
RulesVersion = originalRequest.RulesVersion,
RulesHash = originalRequest.RulesHash,
InputHashes = originalRequest.InputHashes,
AdditionalContext = new Dictionary<string, string> { ["malicious"] = "data" }
};
var isValid = generator.Verify(token, tamperedRequest);
isValid.Should().BeFalse("request with added field should fail verification");
}
[Fact]
public void TamperedRequest_RemovedField_VerificationFails()
{
var generator = CreateGenerator();
var originalRequest = new ReplayTokenRequest
{
FeedManifests = ["sha256:feed1", "sha256:feed2"],
RulesVersion = "v1",
RulesHash = "sha256:rules",
InputHashes = ["sha256:input1"]
};
var token = generator.Generate(originalRequest);
var tamperedRequest = new ReplayTokenRequest
{
FeedManifests = ["sha256:feed1"],
RulesVersion = "v1",
RulesHash = "sha256:rules",
InputHashes = ["sha256:input1"]
};
var isValid = generator.Verify(token, tamperedRequest);
isValid.Should().BeFalse("request with removed field should fail verification");
}
[Fact]
public void TamperedRequest_ModifiedValue_VerificationFails()
{
var generator = CreateGenerator();
var originalRequest = CreateRequest();
var token = generator.Generate(originalRequest);
var tamperedRequest = new ReplayTokenRequest
{
FeedManifests = originalRequest.FeedManifests,
RulesVersion = "v2",
RulesHash = originalRequest.RulesHash,
InputHashes = originalRequest.InputHashes
};
var isValid = generator.Verify(token, tamperedRequest);
isValid.Should().BeFalse("request with modified value should fail verification");
}
}

View File

@@ -0,0 +1,52 @@
using FluentAssertions;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenSecurityTests
{
[Fact]
public void TamperedToken_ModifiedValue_VerificationFails()
{
var generator = CreateGenerator();
var request = CreateRequest();
var token = generator.Generate(request);
var tamperedValue = TamperHash(token.Value);
var tamperedToken = new ReplayToken(tamperedValue, token.GeneratedAt, token.Algorithm, token.Version);
var isValid = generator.Verify(tamperedToken, request);
isValid.Should().BeFalse("tampered token value should fail verification");
}
[Fact]
public void TamperedToken_SingleBitFlip_VerificationFails()
{
var generator = CreateGenerator();
var request = CreateRequest();
var token = generator.Generate(request);
var chars = token.Value.ToCharArray();
chars[0] = chars[0] == 'a' ? 'b' : 'a';
var tamperedValue = new string(chars);
var tamperedToken = new ReplayToken(tamperedValue, token.GeneratedAt, token.Algorithm, token.Version);
var isValid = generator.Verify(tamperedToken, request);
isValid.Should().BeFalse("single-bit tampered token should fail verification");
}
[Fact]
public void TamperedToken_ModifiedAlgorithm_ParsedCorrectlyButVerificationFails()
{
var generator = CreateGenerator();
var request = CreateRequest();
var token = generator.Generate(request);
var tamperedToken = new ReplayToken(token.Value, token.GeneratedAt, "SHA-512", token.Version);
var isValid = generator.Verify(tamperedToken, request);
isValid.Should().BeTrue("algorithm claim doesn't affect verification (value-based)");
}
}

View File

@@ -0,0 +1,29 @@
using FluentAssertions;
namespace StellaOps.Audit.ReplayToken.Tests;
public sealed partial class ReplayTokenSecurityTests
{
[Fact]
public void TryParse_ValidToken_ReturnsTrue()
{
var canonical = "replay:v1.0:SHA-256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1";
var success = ReplayToken.TryParse(canonical, out var token);
success.Should().BeTrue();
token.Should().NotBeNull();
token!.Value.Should().Be("abc123def456abc123def456abc123def456abc123def456abc123def456abc1");
}
[Fact]
public void TryParse_InvalidToken_ReturnsFalse()
{
var invalid = "not-a-valid-token";
var success = ReplayToken.TryParse(invalid, out var token);
success.Should().BeFalse();
token.Should().BeNull();
}
}

View File

@@ -5,612 +5,21 @@
// Description: Model L0 security tests for Replay Token
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Cryptography;
using Xunit;
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)
/// - 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)
/// </summary>
[Trait("Category", "Unit")]
[Trait("Category", "ReplayTokenSecurity")]
public sealed class ReplayTokenSecurityTests
public sealed partial class ReplayTokenSecurityTests
{
// REPLAY-5100-002: Tamper detection tests
[Fact]
public void TamperedToken_ModifiedValue_VerificationFails()
{
// Arrange
var generator = CreateGenerator();
var request = CreateRequest();
var token = generator.Generate(request);
// Tamper with the token value
var tamperedValue = TamperHash(token.Value);
var tamperedToken = new ReplayToken(tamperedValue, token.GeneratedAt, token.Algorithm, token.Version);
// Act
var isValid = generator.Verify(tamperedToken, request);
// Assert
isValid.Should().BeFalse("tampered token value should fail verification");
}
[Fact]
public void TamperedToken_SingleBitFlip_VerificationFails()
{
// Arrange
var generator = CreateGenerator();
var request = CreateRequest();
var token = generator.Generate(request);
// Flip a single bit in the token value
var chars = token.Value.ToCharArray();
chars[0] = chars[0] == 'a' ? 'b' : 'a';
var tamperedValue = new string(chars);
var tamperedToken = new ReplayToken(tamperedValue, token.GeneratedAt, token.Algorithm, token.Version);
// Act
var isValid = generator.Verify(tamperedToken, request);
// Assert
isValid.Should().BeFalse("single-bit tampered token should fail verification");
}
[Fact]
public void TamperedToken_ModifiedAlgorithm_ParsedCorrectlyButVerificationFails()
{
// Arrange
var generator = CreateGenerator();
var request = CreateRequest();
var token = generator.Generate(request);
// Create token with different algorithm claim (but same value)
var tamperedToken = new ReplayToken(token.Value, token.GeneratedAt, "SHA-512", token.Version);
// Act - Verification should still work based on computed hash
var isValid = generator.Verify(tamperedToken, request);
// Assert
isValid.Should().BeTrue("algorithm claim doesn't affect verification (value-based)");
}
[Fact]
public void TamperedRequest_AddedField_VerificationFails()
{
// Arrange
var generator = CreateGenerator();
var originalRequest = CreateRequest();
var token = generator.Generate(originalRequest);
var tamperedRequest = new ReplayTokenRequest
{
FeedManifests = originalRequest.FeedManifests,
RulesVersion = originalRequest.RulesVersion,
RulesHash = originalRequest.RulesHash,
InputHashes = originalRequest.InputHashes,
// Add extra field
AdditionalContext = new Dictionary<string, string> { ["malicious"] = "data" }
};
// Act
var isValid = generator.Verify(token, tamperedRequest);
// Assert
isValid.Should().BeFalse("request with added field should fail verification");
}
[Fact]
public void TamperedRequest_RemovedField_VerificationFails()
{
// Arrange
var generator = CreateGenerator();
var originalRequest = new ReplayTokenRequest
{
FeedManifests = ["sha256:feed1", "sha256:feed2"],
RulesVersion = "v1",
RulesHash = "sha256:rules",
InputHashes = ["sha256:input1"]
};
var token = generator.Generate(originalRequest);
var tamperedRequest = new ReplayTokenRequest
{
FeedManifests = ["sha256:feed1"], // Removed one
RulesVersion = "v1",
RulesHash = "sha256:rules",
InputHashes = ["sha256:input1"]
};
// Act
var isValid = generator.Verify(token, tamperedRequest);
// Assert
isValid.Should().BeFalse("request with removed field should fail verification");
}
[Fact]
public void TamperedRequest_ModifiedValue_VerificationFails()
{
// Arrange
var generator = CreateGenerator();
var originalRequest = CreateRequest();
var token = generator.Generate(originalRequest);
var tamperedRequest = new ReplayTokenRequest
{
FeedManifests = originalRequest.FeedManifests,
RulesVersion = "v2", // Changed version
RulesHash = originalRequest.RulesHash,
InputHashes = originalRequest.InputHashes
};
// Act
var isValid = generator.Verify(token, tamperedRequest);
// Assert
isValid.Should().BeFalse("request with modified value should fail verification");
}
// REPLAY-5100-003: Token issuance tests
[Fact]
public void GenerateToken_ValidRequest_HasCorrectAlgorithm()
{
// Arrange
var generator = CreateGenerator();
var request = CreateRequest();
// Act
var token = generator.Generate(request);
// Assert
token.Algorithm.Should().Be(ReplayToken.DefaultAlgorithm);
}
[Fact]
public void GenerateToken_ValidRequest_HasCorrectVersion()
{
// Arrange
var generator = CreateGenerator();
var request = CreateRequest();
// Act
var token = generator.Generate(request);
// Assert
token.Version.Should().Be(ReplayToken.DefaultVersion);
}
[Fact]
public void GenerateToken_ValidRequest_HasValidSha256Format()
{
// Arrange
var generator = CreateGenerator();
var request = CreateRequest();
// Act
var token = generator.Generate(request);
// Assert
token.Value.Should().NotBeNullOrEmpty();
token.Value.Length.Should().Be(64, "SHA-256 hex should be 64 characters");
token.Value.Should().MatchRegex("^[0-9a-f]{64}$", "should be lowercase hex");
}
[Fact]
public void GenerateToken_ValidRequest_HasCorrectTimestamp()
{
// Arrange
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
// Act
var token = generator.Generate(request);
// Assert
token.GeneratedAt.Should().Be(fixedTime);
}
[Fact]
public void GenerateToken_ValidRequest_CanonicalFormatIsCorrect()
{
// Arrange
var generator = CreateGenerator();
var request = CreateRequest();
// Act
var token = generator.Generate(request);
// Assert
token.Canonical.Should().StartWith("replay:v");
token.Canonical.Should().Contain(":SHA-256:");
token.Canonical.Split(':').Should().HaveCount(4);
}
[Fact]
public void GenerateToken_EmptyRequest_StillGeneratesValidToken()
{
// Arrange
var generator = CreateGenerator();
var emptyRequest = new ReplayTokenRequest();
// Act
var token = generator.Generate(emptyRequest);
// Assert
token.Should().NotBeNull();
token.Value.Should().NotBeNullOrEmpty();
}
[Fact]
public void GenerateToken_NullInputs_HandledGracefully()
{
// Arrange
var generator = CreateGenerator();
var request = new ReplayTokenRequest
{
FeedManifests = null!,
RulesVersion = null!,
InputHashes = null!
};
// Act
var token = generator.Generate(request);
// Assert
token.Should().NotBeNull();
token.Value.Length.Should().Be(64);
}
[Fact]
public void GenerateToken_DeterministicAcrossMultipleCalls()
{
// Arrange
var fixedTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
// Act
var tokens = Enumerable.Range(0, 10)
.Select(_ => generator.Generate(request))
.ToList();
// Assert
tokens.Select(t => t.Value).Distinct().Should().HaveCount(1,
"same request should produce same token value");
}
[Fact]
public void GenerateToken_DifferentRequests_ProduceDifferentTokens()
{
// Arrange
var generator = CreateGenerator();
var request1 = new ReplayTokenRequest { InputHashes = ["sha256:input1"] };
var request2 = new ReplayTokenRequest { InputHashes = ["sha256:input2"] };
// Act
var token1 = generator.Generate(request1);
var token2 = generator.Generate(request2);
// Assert
token1.Value.Should().NotBe(token2.Value,
"different requests should produce different tokens");
}
[Fact]
public void ParseToken_RoundTrip_PreservesValues()
{
// Arrange
var generator = CreateGenerator();
var request = CreateRequest();
var originalToken = generator.Generate(request);
// Act
var parsed = ReplayToken.Parse(originalToken.Canonical);
// Assert
parsed.Value.Should().Be(originalToken.Value);
parsed.Algorithm.Should().Be(originalToken.Algorithm);
parsed.Version.Should().Be(originalToken.Version);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("invalid")]
[InlineData("replay:")]
[InlineData("replay:v1.0")]
[InlineData("replay:v1.0:SHA-256")]
[InlineData("notreplay:v1.0:SHA-256:abc")]
public void ParseToken_InvalidFormat_ThrowsException(string invalidCanonical)
{
// Act
var act = () => ReplayToken.Parse(invalidCanonical);
// Assert
act.Should().Throw<Exception>();
}
[Fact]
public void Token_Equality_BasedOnValue()
{
// Arrange
var value = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
var token1 = new ReplayToken(value, DateTimeOffset.UtcNow);
var token2 = new ReplayToken(value, DateTimeOffset.UtcNow.AddHours(1)); // Different time
// Act & Assert
token1.Equals(token2).Should().BeTrue("equality should be based on value only");
token1.GetHashCode().Should().Be(token2.GetHashCode());
}
[Fact]
public void Token_Equality_CaseInsensitive()
{
// Arrange
var value1 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
var value2 = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
var token1 = new ReplayToken(value1, DateTimeOffset.UtcNow);
var token2 = new ReplayToken(value2, DateTimeOffset.UtcNow);
// Act & Assert
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)
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
@@ -635,7 +44,6 @@ public sealed class ReplayTokenSecurityTests
private static string TamperHash(string hash)
{
// Create a completely different hash value
var chars = hash.ToCharArray();
for (int i = 0; i < chars.Length; i++)
{

View File

@@ -1,11 +1,12 @@
# Audit ReplayToken Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0074-M | DONE | Revalidated 2026-01-06. |
| AUDIT-0074-T | DONE | Revalidated 2026-01-06. |
| AUDIT-0074-A | DONE | Waived (test project; revalidated 2026-01-06). |
| REMED-05 | DONE | File split <= 100 lines with helpers extracted; `dotnet test src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj` passed 2026-02-03 (60 tests). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0371-A | DONE | Waived (test project; revalidated 2026-01-07). |
| AUDIT-TESTGAP-CORELIB-INTEROP-0001 | DONE | Added ToolManager unit tests + skip gating (2026-01-13). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-08 | DONE | Added stubbed ToolManager unit tests for deterministic path/process checks. |

View File

@@ -0,0 +1,96 @@
using FluentAssertions;
using StellaOps.Interop;
using InteropToolResult = StellaOps.Interop.ToolResult;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace StellaOps.Interop.Tests;
public sealed class ToolManagerUnitTests
{
[Fact]
public async Task RunAsync_ReturnsFailure_WhenResolverReturnsNull()
{
var resolver = new StubPathResolver(null);
var runner = new StubProcessRunner(InteropToolResult.Ok("ok", string.Empty, 0));
var manager = new ToolManager("work", pathResolver: resolver, processRunner: runner);
var result = await manager.RunAsync("missing-tool", "--version", CancellationToken.None);
result.Success.Should().BeFalse();
result.Error.Should().Be("Tool not found: missing-tool");
runner.Calls.Should().Be(0);
}
[Fact]
public async Task RunAsync_DelegatesToRunner()
{
var expected = InteropToolResult.Ok("out", "err", 0);
var resolver = new StubPathResolver("/tmp/tool");
var runner = new StubProcessRunner(expected);
var manager = new ToolManager("work", pathResolver: resolver, processRunner: runner);
var result = await manager.RunAsync("stub-tool", "--help", CancellationToken.None);
result.Should().Be(expected);
runner.Calls.Should().Be(1);
runner.LastTool.Should().Be("stub-tool");
runner.LastToolPath.Should().Be("/tmp/tool");
}
[Fact]
public async Task VerifyToolAsync_ThrowsWhenRunnerFails()
{
var failure = InteropToolResult.Failed("boom");
var resolver = new StubPathResolver("/tmp/tool");
var runner = new StubProcessRunner(failure);
var manager = new ToolManager("work", pathResolver: resolver, processRunner: runner);
var act = () => manager.VerifyToolAsync("stub-tool", "--help", CancellationToken.None);
var thrown = await act.Should().ThrowAsync<ToolExecutionException>();
thrown.Which.Result.Should().Be(failure);
}
private sealed class StubPathResolver : IToolPathResolver
{
private readonly string? _toolPath;
public StubPathResolver(string? toolPath)
{
_toolPath = toolPath;
}
public string? ResolveToolPath(string tool) => _toolPath;
}
private sealed class StubProcessRunner : IToolProcessRunner
{
private readonly InteropToolResult _result;
public StubProcessRunner(InteropToolResult result)
{
_result = result;
}
public int Calls { get; private set; }
public string? LastTool { get; private set; }
public string? LastToolPath { get; private set; }
public Task<InteropToolResult> RunAsync(
string tool,
string toolPath,
string args,
string workingDirectory,
CancellationToken ct = default)
{
Calls++;
LastTool = tool;
LastToolPath = toolPath;
return Task.FromResult(_result);
}
}
}