using System.Net.Http.Json; using System.Reflection; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; using Tiku.Api.Controllers; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; namespace Tiku.IntegrationTests.Api; public sealed class AuthPasswordLifecycleTests { [Fact] public async Task Phone_and_password_login_authenticates_without_totp() { await using var factory = new ApiTestFactory(); var seed = await SeedUserAsync(factory); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); using var login = await PostPasswordLoginAsync(client, seed); Assert.Equal("authenticated", login.RootElement.GetProperty("status").GetString()); Assert.Equal(seed.Phone, login.RootElement.GetProperty("user").GetProperty("phone").GetString()); Assert.False(login.RootElement.TryGetProperty("challengeToken", out var challenge) && challenge.ValueKind == JsonValueKind.String); } [Fact] public async Task Forced_password_change_finishes_with_an_authenticated_session() { await using var factory = new ApiTestFactory(); var seed = await SeedUserAsync(factory, true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); using var login = await PostPasswordLoginAsync(client, seed); Assert.Equal("password_change_required", login.RootElement.GetProperty("status").GetString()); var response = await client.PostAsJsonAsync( "/api/tenant/auth/password/change-required", new RequiredPasswordChangeDto { ChallengeToken = login.RootElement.GetProperty("challengeToken").GetString()!, NewPassword = "ChangedPassword2026" }); response.EnsureSuccessStatusCode(); using var changed = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal("authenticated", changed.RootElement.GetProperty("status").GetString()); Assert.False(changed.RootElement.TryGetProperty("challengeToken", out var challenge) && challenge.ValueKind == JsonValueKind.String); } [Theory] [InlineData(nameof(AuthController.LoginWithPassword), "login/password")] [InlineData(nameof(AuthController.SendSmsCode), "sms/send")] [InlineData(nameof(AuthController.LoginWithSms), "login/sms")] [InlineData(nameof(AuthController.LoginWithWechatWeb), "oauth/wechat")] [InlineData(nameof(AuthController.LoginWithWechatMiniApp), "oauth/wechat-miniapp")] [InlineData(nameof(AuthController.ChangeRequiredPassword), "password/change-required")] [InlineData(nameof(AuthController.SendPasswordResetCode), "password/reset/sms/send")] [InlineData(nameof(AuthController.ResetPassword), "password/reset")] [InlineData(nameof(AuthController.ChangePassword), "password/change")] [InlineData(nameof(AuthController.Refresh), "refresh")] [InlineData(nameof(AuthController.Logout), "logout")] [InlineData(nameof(AuthController.LogoutAll), "logout-all")] public void Authentication_routes_match_the_v2_contract(string actionName, string route) { var action = typeof(AuthController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance); var attribute = action?.GetCustomAttribute(); Assert.NotNull(attribute); Assert.Equal(route, attribute.Template); } private static async Task PostPasswordLoginAsync( HttpClient client, (Guid TenantId, string Phone) seed) { var response = await client.PostAsJsonAsync( "/api/tenant/auth/login/password", new PasswordLoginDto { Realm = AuthRealm.Tenant, TenantCode = seed.TenantId.ToString("N"), Identifier = seed.Phone, Password = PasswordTestUserExtensions.TestPassword }); response.EnsureSuccessStatusCode(); return JsonDocument.Parse(await response.Content.ReadAsStringAsync()); } private static async Task<(Guid TenantId, string Phone)> SeedUserAsync( ApiTestFactory factory, bool forcePasswordChange = false) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); const string phone = "13800000000"; await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Password Lifecycle Tenant" }, new User { Id = userId, Phone = phone, Name = "Password Lifecycle User", ForcePasswordChange = forcePasswordChange }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }); return (tenantId, phone); } }