using FluentAssertions; using EonaCat.SecureToken.Core; using EonaCat.SecureToken.Cryptography; using EonaCat.SecureToken.Validation; using Xunit; namespace EonaCat.SecureToken.Tests { public sealed class TokenServiceTests { private static ITokenService CreateService(SigningKeyStore? store = null) => new TokenService(store ?? SigningKeyStore.CreateNew()); // Issue & Validate [Fact] public void Issue_And_Validate_ReturnsSuccess() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-123") .IssuedBy("my-app") .ForAudience("api") .WithRole("admin") .WithClaim("email", "user@example.com")); var result = svc.Validate(token, TokenValidationOptions.AccessToken("my-app", "api")); result.IsSuccess.Should().BeTrue(); var claims = result.UnwrapClaims(); claims.Subject.Should().Be("user-123"); claims.Roles.Should().Contain("admin"); claims.Custom["email"].Should().Be("user@example.com"); } [Fact] public void Validate_TamperedToken_ReturnsInvalidSignature() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create().ForSubject("user-1").IssuedBy("app").ForAudience("api")); // Flip one character in the payload section var parts = token.Split('.'); var corrupted = parts[0] + "." + parts[1].Substring(0, parts[1].Length - 1) + "X" + "." + parts[2]; var result = svc.Validate(corrupted, TokenValidationOptions.AccessToken("app", "api")); result.Should().BeOfType(); } [Fact] public void Validate_ExpiredToken_ReturnsExpired() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1") .IssuedBy("app") .ForAudience("api") .WithLifetime(TimeSpan.FromMilliseconds(1))); Thread.Sleep(50); // let it expire var result = svc.Validate(token, new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, ClockSkew = TimeSpan.Zero, }); result.Should().BeOfType(); } [Fact] public void Validate_WrongAudience_ReturnsWrongAudience() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1") .IssuedBy("app") .ForAudience("service-a")); var result = svc.Validate(token, TokenValidationOptions.AccessToken("app", "service-b")); result.Should().BeOfType(); } [Fact] public void Validate_WrongTokenType_ReturnsWrongTokenType() { var svc = CreateService(); var refreshToken = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1") .IssuedBy("app") .AsRefreshToken()); // Try to use refresh token where access token is expected var result = svc.Validate(refreshToken, TokenValidationOptions.AccessToken("app", "api")); result.Should().BeOfType(); } [Fact] public void Validate_BindingContextMismatch_ReturnsBindingMismatch() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1") .IssuedBy("app") .ForAudience("api") .BoundTo("192.168.1.1")); var result = svc.Validate(token, new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, BindingContext = "10.0.0.1", // different IP }); result.Should().BeOfType(); } [Fact] public void Validate_CorrectBindingContext_ReturnsSuccess() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1") .IssuedBy("app") .ForAudience("api") .BoundTo("192.168.1.1")); var result = svc.Validate(token, new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, BindingContext = "192.168.1.1", }); result.IsSuccess.Should().BeTrue(); } // Key Rotation [Fact] public void KeyRotation_OldTokensRemainValid() { var store = SigningKeyStore.CreateNew(); var svc = CreateService(store); var oldToken = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("api")); // Rotate the signing key store.Rotate(); // Old token must still validate var result = svc.Validate(oldToken, TokenValidationOptions.AccessToken("app", "api")); result.IsSuccess.Should().BeTrue(); // New tokens use the new key var newToken = svc.Issue(TokenDescriptor.Create() .ForSubject("user-2").IssuedBy("app").ForAudience("api")); var claims = svc.Inspect(newToken); claims!.KeyGeneration.Should().Be(2); } [Fact] public void KeyRotation_NewTokensUseNewKey() { var store = SigningKeyStore.CreateNew(); var svc = CreateService(store); store.Rotate(); store.Rotate(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("api")); var claims = svc.Inspect(token); claims!.KeyGeneration.Should().Be(3); var result = svc.Validate(token, TokenValidationOptions.AccessToken("app", "api")); result.IsSuccess.Should().BeTrue(); } // Token Pair [Fact] public void IssueTokenPair_ProducesValidPair() { var svc = CreateService(); var pair = svc.IssueTokenPair("user-1", "app", "api", roles: new[] { "user" }); var accessResult = svc.Validate(pair.AccessToken, TokenValidationOptions.AccessToken("app", "api")); var refreshResult = svc.Validate(pair.RefreshToken, TokenValidationOptions.RefreshToken("app")); accessResult.IsSuccess.Should().BeTrue(); refreshResult.IsSuccess.Should().BeTrue(); } [Fact] public void IssueTokenPair_RefreshToken_CannotBeUsedAsAccessToken() { var svc = CreateService(); var pair = svc.IssueTokenPair("user-1", "app", "api"); // Try to validate refresh token as access token var result = svc.Validate(pair.RefreshToken, TokenValidationOptions.AccessToken("app", "api")); result.Should().BeOfType(); } // Revocation [Fact] public async Task RevocationCheck_RevokedToken_ReturnsRevoked() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("api")); var claims = svc.Inspect(token)!; var revokedIds = new HashSet { claims.TokenId }; var options = new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, RevocationCheck = (id, _) => Task.FromResult(revokedIds.Contains(id)), }; var result = await svc.ValidateAsync(token, options); result.Should().BeOfType(); } // Inspect (no signature check) [Fact] public void Inspect_MalformedToken_ReturnsNull() { var svc = CreateService(); svc.Inspect("not.a.valid.token").Should().BeNull(); } [Fact] public void Inspect_ValidToken_ReturnsClaims() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-42").IssuedBy("app").ForAudience("api")); var claims = svc.Inspect(token); claims.Should().NotBeNull(); claims!.Subject.Should().Be("user-42"); } } }