using StellaOps.Cryptography; using StellaOps.TestKit; namespace StellaOps.Audit.ReplayToken.Tests; public sealed class ReplayTokenGeneratorTests { [Trait("Category", TestCategories.Unit)] [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 request = new ReplayTokenRequest { FeedManifests = new[] { "sha256:bbb", "sha256:aaa" }, RulesVersion = "rules-v1", RulesHash = "sha256:rules", LatticePolicyVersion = "lattice-v1", LatticePolicyHash = "sha256:lattice", InputHashes = new[] { "sha256:input2", "sha256:input1" }, ScoringConfigVersion = "score-v1", EvidenceHashes = new[] { "sha256:e2", "sha256:e1" }, AdditionalContext = new Dictionary { ["b"] = "2", ["a"] = "1" } }; var token1 = generator.Generate(request); var token2 = generator.Generate(request); Assert.Equal(token1.Value, token2.Value); Assert.Equal(token1.Canonical, token2.Canonical); } [Trait("Category", TestCategories.Unit)] [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 requestA = new ReplayTokenRequest { FeedManifests = new[] { "sha256:aaa", "sha256:bbb" }, InputHashes = new[] { "sha256:input1", "sha256:input2" } }; var requestB = new ReplayTokenRequest { FeedManifests = new[] { "sha256:bbb", "sha256:aaa" }, InputHashes = new[] { "sha256:input2", "sha256:input1" } }; var tokenA = generator.Generate(requestA); var tokenB = generator.Generate(requestB); 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 { ["a"] = "1", ["b"] = "2" } }; var requestB = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" }, AdditionalContext = new Dictionary { ["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 { ["key"] = "1", [" key "] = "2" } }; Assert.Throws(() => 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(() => 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(() => ReplayToken.Parse(canonical)); } private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider { public override DateTimeOffset GetUtcNow() => now; } }