using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.UnitTests.Auth; public sealed class AuthServiceTests { private static readonly JwtOptions JwtOptions = new() { Issuer = "tiku-unit-tests", Audience = "tiku-api-unit-tests", SigningKey = "unit-test-signing-key-that-is-long-enough" }; [Fact] public void Password_hasher_verifies_own_hash() { var hasher = new PasswordHasher(); var hash = hasher.Hash("passw0rd!"); Assert.True(hasher.Verify("passw0rd!", hash)); Assert.False(hasher.Verify("wrong", hash)); } [Fact] public async Task Password_login_creates_session_and_success_event() { await using var context = CreateContext(); var hasher = new PasswordHasher(); var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!")); var service = CreateAuthService(context); var result = await service.LoginWithPasswordAsync(new PasswordLoginRequest( seed.TenantId, seed.Phone, "passw0rd!", "127.0.0.1", "unit-test")); Assert.Equal(seed.UserId, result.UserId); Assert.False(string.IsNullOrWhiteSpace(result.Tokens.AccessToken)); Assert.False(string.IsNullOrWhiteSpace(result.Tokens.RefreshToken)); Assert.Single(context.AuthSessions); Assert.Contains(context.AuthLoginEvents, entity => entity.Result == AuthLoginResult.Success); } [Fact] public async Task Wrong_password_records_failed_event() { await using var context = CreateContext(); var hasher = new PasswordHasher(); var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!")); var service = CreateAuthService(context); await Assert.ThrowsAsync(() => service.LoginWithPasswordAsync(new PasswordLoginRequest( seed.TenantId, seed.Phone, "wrong", null, null))); Assert.Empty(context.AuthSessions); Assert.Contains(context.AuthLoginEvents, entity => entity.Result == AuthLoginResult.Failed && entity.FailureCode == "invalid_credentials"); } [Fact] public async Task Sms_login_consumes_code_and_creates_session() { await using var context = CreateContext(); var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!")); context.SmsVerificationCodes.Add(new SmsVerificationCode { TenantId = seed.TenantId, Phone = seed.Phone, Purpose = SmsPurpose.Login, CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"), Status = SmsVerificationStatus.Sent, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) }); await context.SaveChangesAsync(); var service = CreateAuthService(context); var result = await service.LoginWithSmsAsync(new SmsLoginRequest( seed.TenantId, seed.Phone, "123456", null, null)); Assert.Equal(seed.UserId, result.UserId); Assert.Single(context.AuthSessions); Assert.Contains(context.SmsVerificationCodes, entity => entity.ConsumedAt is not null); } [Fact] public async Task Invalid_sms_code_records_failed_event() { await using var context = CreateContext(); var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!")); context.SmsVerificationCodes.Add(new SmsVerificationCode { TenantId = seed.TenantId, Phone = seed.Phone, Purpose = SmsPurpose.Login, CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"), Status = SmsVerificationStatus.Sent, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) }); await context.SaveChangesAsync(); var service = CreateAuthService(context); await Assert.ThrowsAsync(() => service.LoginWithSmsAsync(new SmsLoginRequest( seed.TenantId, seed.Phone, "999999", null, null))); Assert.Empty(context.AuthSessions); Assert.Contains(context.AuthLoginEvents, entity => entity.Result == AuthLoginResult.Failed && entity.FailureCode == "invalid_sms_code"); } [Fact] public async Task Revoked_refresh_token_cannot_be_refreshed() { await using var context = CreateContext(); var hasher = new PasswordHasher(); var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!")); var service = CreateAuthService(context); var login = await service.LoginWithPasswordAsync(new PasswordLoginRequest( seed.TenantId, seed.Phone, "passw0rd!", null, null)); await service.LogoutAsync(new LogoutSessionRequest(login.Tokens.RefreshToken)); await Assert.ThrowsAsync(() => service.RefreshAsync(new RefreshSessionRequest( login.Tokens.RefreshToken, null, null))); } private static TikuDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new TikuDbContext(options); } private static IAuthService CreateAuthService(TikuDbContext context) { var tokenService = new TokenService(Options.Create(JwtOptions)); var sessionService = new SessionService(context, tokenService, Options.Create(JwtOptions)); var smsService = new SmsVerificationService(context); return new AuthService( context, new PasswordHasher(), smsService, sessionService); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedUserAsync( TikuDbContext context, string passwordHash) { var tenant = new Tenant { Id = Guid.NewGuid(), Slug = Guid.NewGuid().ToString("N"), Name = "Test Tenant" }; var user = new User { Id = Guid.NewGuid(), Phone = "13800000000", Name = "Test User" }; var membership = new TenantMembership { TenantId = tenant.Id, UserId = user.Id, Role = TenantRole.Student, Status = MembershipStatus.Active }; var identity = new UserIdentity { UserId = user.Id, Provider = "password", ProviderSubject = user.Phone, Phone = user.Phone, SecretPayload = CreateSecretPayload(passwordHash) }; context.Tenants.Add(tenant); context.Users.Add(user); context.TenantMemberships.Add(membership); context.UserIdentities.Add(identity); await context.SaveChangesAsync(); return (tenant.Id, user.Id, user.Phone); } private static JsonElement CreateSecretPayload(string passwordHash) { using var document = JsonDocument.Parse( $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); return document.RootElement.Clone(); } }