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))); } [Fact] public async Task Wechat_miniapp_login_creates_user_identity_membership_and_session() { await using var context = CreateContext(); var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp"); var service = CreateAuthService( context, new FakeWechatOAuthClient( MiniAppIdentity: new WechatIdentity( "mini-open-id", "union-id", null, null, "session-key", """{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}"""))); var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest( tenantId, "wx-code", null, null)); Assert.Equal(tenantId, result.Tenant.TenantId); Assert.Single(context.Users); Assert.Contains(context.UserIdentities, identity => identity.Provider == "wechat-miniapp" && identity.ProviderSubject == "wx-app-id:mini-open-id" && identity.OpenId == "mini-open-id" && identity.UnionId == "union-id"); Assert.Contains(context.TenantMemberships, membership => membership.TenantId == tenantId && membership.UserId == result.UserId && membership.Status == MembershipStatus.Active); Assert.Single(context.AuthSessions); } [Fact] public async Task Wechat_union_id_reuses_existing_user_across_providers() { await using var context = CreateContext(); var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp"); context.TenantAuthProviders.Add(new TenantAuthProvider { TenantId = tenantId, Provider = "wechat_web", Status = TenantAuthProviderStatus.Testing, ConfigPublic = WechatProviderConfig() }); var user = new User { Id = Guid.NewGuid(), Name = "Existing" }; context.Users.Add(user); context.UserIdentities.Add(new UserIdentity { UserId = user.Id, Provider = "wechat_web", ProviderSubject = "wx-app-id:web-open-id", OpenId = "web-open-id", UnionId = "same-union" }); await context.SaveChangesAsync(); var service = CreateAuthService( context, new FakeWechatOAuthClient( MiniAppIdentity: new WechatIdentity( "mini-open-id", "same-union", null, null, "session-key", """{"openid":"mini-open-id","unionid":"same-union","session_key":"session-key"}"""))); var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest( tenantId, "wx-code", null, null)); Assert.Equal(user.Id, result.UserId); Assert.Single(context.Users); Assert.Equal(2, context.UserIdentities.Count()); } private static TikuDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new TikuDbContext(options); } private static IAuthService CreateAuthService( TikuDbContext context, IWechatOAuthClient? wechatOAuthClient = null) { 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, wechatOAuthClient ?? new FakeWechatOAuthClient()); } private static async Task SeedTenantWithWechatProviderAsync( TikuDbContext context, string provider) { var tenant = new Tenant { Id = Guid.NewGuid(), Slug = Guid.NewGuid().ToString("N"), Name = "Wechat Tenant" }; context.Tenants.Add(tenant); context.TenantAuthProviders.Add(new TenantAuthProvider { TenantId = tenant.Id, Provider = provider, Status = TenantAuthProviderStatus.Testing, ConfigPublic = WechatProviderConfig() }); await context.SaveChangesAsync(); return tenant.Id; } private static JsonElement WechatProviderConfig() { return JsonSerializer.SerializeToElement(new { appId = "wx-app-id", appSecret = "wx-app-secret" }); } 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(); } private sealed class FakeWechatOAuthClient( WechatIdentity? WebIdentity = null, WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient { public Task ExchangeWebCodeAsync( WechatProviderOptions options, string code, CancellationToken cancellationToken = default) { return Task.FromResult(WebIdentity ?? new WechatIdentity( "web-open-id", "union-id", "Wechat User", "https://example.test/avatar.png", null, """{"openid":"web-open-id","unionid":"union-id"}""")); } public Task ExchangeMiniAppCodeAsync( WechatProviderOptions options, string code, CancellationToken cancellationToken = default) { return Task.FromResult(MiniAppIdentity ?? new WechatIdentity( "mini-open-id", "union-id", null, null, "session-key", """{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}""")); } } }