using Microsoft.Extensions.Configuration; using QuestionsApi.Models; using QuestionsApi.Services; using System.IdentityModel.Tokens.Jwt; using Xunit; namespace QuestionsApi.Tests; public sealed class AuthTests { [Theory] [InlineData("valid_user", "person@example.com", "password1", null)] [InlineData("ab", "person@example.com", "password1", "Username")] [InlineData("invalid-user", "person@example.com", "password1", "Username")] [InlineData("valid_user", "not-an-email", "password1", "email")] [InlineData("valid_user", "person@example.com", "short", "Password")] public void NewAccountValidationRejectsInvalidInput( string username, string email, string password, string? expectedError) { var result = AuthValidation.ValidateNewAccount(username, email, password); if (expectedError is null) Assert.Null(result); else Assert.Contains(expectedError, result!, StringComparison.OrdinalIgnoreCase); } [Fact] public void PasswordResetCodesAreSixDigitsAndBoundToTheirRequest() { var service = new PasswordResetCodeService(Configuration(new Dictionary { ["PasswordReset:HmacKey"] = "A-test-only-key-that-is-longer-than-thirty-two-bytes" })); var code = service.GenerateCode(); var hash = service.Hash("request-one", code); Assert.Matches("^[0-9]{6}$", code); Assert.True(service.Verify("request-one", code, hash)); Assert.False(service.Verify("request-two", code, hash)); Assert.False(service.Verify("request-one", "000000", hash)); } [Fact] public void PasswordResetCanRemainUnconfiguredWithoutBreakingAuthentication() { var service = new PasswordResetCodeService(Configuration([])); Assert.False(service.IsConfigured); } [Fact] public void JwtContainsCompatibleIdentityRoleAndAuthVersionClaims() { var service = new JwtTokenService(Configuration(new Dictionary { ["Jwt:Key"] = "A-test-only-jwt-key-that-is-longer-than-thirty-two-bytes", ["Jwt:Issuer"] = "promiscuity", ["Jwt:Audience"] = "promiscuity-auth-api" })); var user = new AuthUser { Id = "507f1f77bcf86cd799439011", Username = "doctor", Role = "SUPER", AuthVersion = 4 }; var generated = service.Generate(user); var token = new JwtSecurityTokenHandler().ReadJwtToken(generated.Token); Assert.Equal("promiscuity", token.Issuer); Assert.Contains("promiscuity-auth-api", token.Audiences); Assert.Equal("4", token.Claims.Single(claim => claim.Type == "auth_ver").Value); Assert.Equal(generated.Jti, token.Id); } [Fact] public void LegacyUsersDefaultToAuthVersionZero() { Assert.Equal(0, new AuthUser().AuthVersion); } [Fact] public void SteamClaimedIdExtractionRejectsUnexpectedHostsAndLengths() { Assert.Equal("76561198000000000", SteamOpenIdService.ExtractSteamId("https://steamcommunity.com/openid/id/76561198000000000")); Assert.Null(SteamOpenIdService.ExtractSteamId("https://example.com/openid/id/76561198000000000")); Assert.Null(SteamOpenIdService.ExtractSteamId("https://steamcommunity.com/openid/id/123")); } [Fact] public void GoogleProviderIsDisabledUntilAllSecureConfigurationExists() { var incomplete = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary { ["Google:ClientId"] = "client-id", ["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback" })); var complete = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary { ["Google:ClientId"] = "client-id", ["Google:ClientSecret"] = "client-secret", ["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback" })); Assert.False(incomplete.IsConfigured); Assert.True(complete.IsConfigured); } [Fact] public void GoogleAuthorizationUsesStateNonceAndPkce() { var provider = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary { ["Google:ClientId"] = "client-id", ["Google:ClientSecret"] = "client-secret", ["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback" })); var url = provider.BuildAuthorizationUrl(new ExternalAuthTransaction { Id = "transaction-state", Nonce = "transaction-nonce", CodeVerifier = "a-long-test-code-verifier" }); Assert.Contains("state=transaction-state", url); Assert.Contains("nonce=transaction-nonce", url); Assert.Contains("code_challenge_method=S256", url); Assert.Contains("scope=openid%20email%20profile", url); Assert.DoesNotContain("client-secret", url); } private static IConfiguration Configuration(Dictionary values) => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); private sealed class TestHttpClientFactory : IHttpClientFactory { public HttpClient CreateClient(string name) => new(); } }