using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Application.Tenancy; 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 Sms_send_failure_records_failed_code_and_does_not_leave_usable_verification() { await using var context = CreateContext(); var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!")); var smsService = new SmsVerificationService(context, new FailingSmsProvider()); var exception = await Assert.ThrowsAsync(() => smsService.CreateCodeAsync(new SendSmsCodeRequest( seed.TenantId, seed.Phone, SmsPurpose.Login, null, null))); Assert.Equal("sms_provider_send_failed", exception.Code); var verification = Assert.Single(context.SmsVerificationCodes); Assert.Equal(SmsVerificationStatus.Failed, verification.Status); Assert.Equal("failed", verification.Provider); await Assert.ThrowsAsync(() => smsService.VerifyCodeAsync( seed.TenantId, seed.Phone, SmsPurpose.Login, "123456")); } [Fact] public async Task Failed_sms_code_is_never_accepted_even_when_hash_matches() { 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.Failed, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) }); await context.SaveChangesAsync(); var smsService = new SmsVerificationService(context, new FakeSmsProvider()); await Assert.ThrowsAsync(() => smsService.VerifyCodeAsync( seed.TenantId, seed.Phone, SmsPurpose.Login, "123456")); } [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.TenantExternalProviders.Add(new TenantExternalProvider { TenantId = tenantId, Provider = "wechat_web", Capability = TenantExternalProviderCapability.Identity, Status = TenantExternalProviderStatus.Active, SecretRef = "tenant_secrets:identity:wechat_web:default", 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, new FakeSmsProvider()); return new AuthService( context, new PasswordHasher(), smsService, sessionService, wechatOAuthClient ?? new FakeWechatOAuthClient(), new FakeProviderConfigService(context)); } 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.TenantExternalProviders.Add(new TenantExternalProvider { TenantId = tenant.Id, Provider = provider.Replace("-", "_", StringComparison.Ordinal), Capability = TenantExternalProviderCapability.Identity, Status = TenantExternalProviderStatus.Active, SecretRef = $"tenant_secrets:identity:{provider}:default", ConfigPublic = WechatProviderConfig() }); await context.SaveChangesAsync(); return tenant.Id; } private static JsonElement WechatProviderConfig() { return JsonSerializer.SerializeToElement(new { appId = "wx-app-id" }); } 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 FakeProviderConfigService(TikuDbContext context) : ITenantExternalProviderConfigService { public Task GetActiveProviderAsync( Guid tenantId, TenantExternalProviderCapability capability, string? provider = null, CancellationToken cancellationToken = default) { var normalizedProvider = provider?.Replace("-", "_", StringComparison.Ordinal); var item = context.TenantExternalProviders .AsEnumerable() .Where(entity => entity.TenantId == tenantId && entity.Capability == capability && entity.Status == TenantExternalProviderStatus.Active) .Where(entity => string.IsNullOrWhiteSpace(normalizedProvider) || entity.Provider == normalizedProvider) .OrderBy(entity => entity.Priority) .FirstOrDefault() ?? throw new TenantExternalProviderException( "Tenant external provider is not configured.", "tenant_external_provider_not_configured"); return Task.FromResult(new TenantExternalProviderAccount( item.TenantId, item.Capability, item.Provider, item.Status, item.DisplayName, item.ConfigPublic, JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" }), item.SecretRef, item.Priority, item.Metadata)); } public Task> GetProvidersAsync( Guid tenantId, TenantExternalProviderCapability? capability = null, string? provider = null, int? limit = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task UpsertProviderAsync( Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class FakeSmsProvider : ISmsProvider { public Task SendAsync( SmsProviderSendRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new SmsProviderSendResult("fake", "accepted")); } private sealed class FailingSmsProvider : ISmsProvider { public Task SendAsync( SmsProviderSendRequest request, CancellationToken cancellationToken = default) => throw new InvalidOperationException("provider unavailable"); } 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"}""")); } } }