stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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++)
|
||||
{
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user