using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Auth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class AuthRecoveryAndDeviceEndpointTests { [Fact] public async Task Password_reset_send_does_not_reveal_account_existence() { var provider = new CapturingSmsProvider(); await using var factory = new ApiTestFactory(smsProvider: provider); var seed = await SeedUserAsync(factory); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); var existing = await client.PostAsJsonAsync( "/api/tenant/auth/password/reset/sms/send", new PasswordResetSmsSendDto { TenantCode = seed.TenantId.ToString("N"), Phone = seed.Phone, DeviceId = "known-device" }); var missing = await client.PostAsJsonAsync( "/api/tenant/auth/password/reset/sms/send", new PasswordResetSmsSendDto { TenantCode = seed.TenantId.ToString("N"), Phone = "13999999999", DeviceId = "unknown-device" }); Assert.Equal(HttpStatusCode.Accepted, existing.StatusCode); Assert.Equal(HttpStatusCode.Accepted, missing.StatusCode); Assert.Equal(1, provider.SendCount); Assert.DoesNotContain(provider.Code!, await existing.Content.ReadAsStringAsync(), StringComparison.Ordinal); Assert.DoesNotContain(provider.Code!, await missing.Content.ReadAsStringAsync(), StringComparison.Ordinal); } [Fact] public async Task Password_reset_consumes_reset_code_and_revokes_existing_sessions() { var provider = new CapturingSmsProvider(); await using var factory = new ApiTestFactory(smsProvider: provider); var seed = await SeedUserAsync(factory); using var client = factory.CreateClient(); var oldTokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone); var send = await client.PostAsJsonAsync( "/api/tenant/auth/password/reset/sms/send", new PasswordResetSmsSendDto { TenantCode = seed.TenantId.ToString("N"), Phone = seed.Phone, DeviceId = "reset-device" }); Assert.Equal(HttpStatusCode.Accepted, send.StatusCode); Assert.NotNull(provider.Code); var resetRequest = new PasswordResetDto { TenantCode = seed.TenantId.ToString("N"), Phone = seed.Phone, Code = provider.Code!, NewPassword = "ResetPassword2026" }; var reset = await client.PostAsJsonAsync("/api/tenant/auth/password/reset", resetRequest); Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode); client.UseAccessToken(oldTokens); Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/tenant/me")).StatusCode); Assert.Equal( HttpStatusCode.Unauthorized, (await PostPasswordLoginAsync(client, seed, PasswordTestUserExtensions.TestPassword)).StatusCode); Assert.Equal( HttpStatusCode.OK, (await PostPasswordLoginAsync(client, seed, resetRequest.NewPassword)).StatusCode); Assert.Equal( HttpStatusCode.Unauthorized, (await client.PostAsJsonAsync("/api/tenant/auth/password/reset", resetRequest)).StatusCode); } [Fact] public async Task Authenticated_password_change_rotates_to_a_new_session() { await using var factory = new ApiTestFactory(); var seed = await SeedUserAsync(factory); using var client = factory.CreateClient(); var oldTokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone); client.UseAccessToken(oldTokens); var changed = await client.PostAsJsonAsync( "/api/tenant/auth/password/change", new AuthenticatedPasswordChangeDto { CurrentPassword = PasswordTestUserExtensions.TestPassword, NewPassword = "ChangedPassword2026" }); Assert.Equal(HttpStatusCode.OK, changed.StatusCode); using var body = JsonDocument.Parse(await changed.Content.ReadAsStringAsync()); var accessToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("accessToken") .GetString(); var refreshToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("refreshToken") .GetString(); Assert.False(string.IsNullOrWhiteSpace(accessToken)); Assert.False(string.IsNullOrWhiteSpace(refreshToken)); client.UseAccessToken(oldTokens); Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/tenant/me")).StatusCode); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/tenant/me")).StatusCode); Assert.Equal( HttpStatusCode.Unauthorized, (await PostPasswordLoginAsync(client, seed, PasswordTestUserExtensions.TestPassword)).StatusCode); Assert.Equal( HttpStatusCode.OK, (await PostPasswordLoginAsync(client, seed, "ChangedPassword2026")).StatusCode); } [Fact] public async Task Device_sessions_are_scoped_and_only_other_owned_families_can_be_revoked() { await using var factory = new ApiTestFactory(); var seed = await SeedUserAsync(factory); var other = await SeedUserAsync(factory); using var firstClient = factory.CreateClient(); using var secondClient = factory.CreateClient(); using var otherClient = factory.CreateClient(); _ = await firstClient.LoginAsTenantAsync(seed.TenantId, seed.Phone); var secondTokens = await secondClient.LoginAsTenantAsync(seed.TenantId, seed.Phone); var otherTokens = await otherClient.LoginAsTenantAsync(other.TenantId, other.Phone); secondClient.UseAccessToken(secondTokens); otherClient.UseAccessToken(otherTokens); using var sessions = JsonDocument.Parse( await (await secondClient.GetAsync("/api/tenant/me/sessions")).Content.ReadAsStringAsync()); var items = sessions.RootElement.EnumerateArray().ToArray(); Assert.Equal(2, items.Length); var currentFamily = items.Single(item => item.GetProperty("isCurrent").GetBoolean()) .GetProperty("sessionFamilyId").GetGuid(); var otherOwnedFamily = items.Single(item => !item.GetProperty("isCurrent").GetBoolean()) .GetProperty("sessionFamilyId").GetGuid(); Assert.Equal( HttpStatusCode.Conflict, (await secondClient.DeleteAsync($"/api/tenant/me/sessions/{currentFamily}")).StatusCode); Assert.Equal( HttpStatusCode.NotFound, (await otherClient.DeleteAsync($"/api/tenant/me/sessions/{otherOwnedFamily}")).StatusCode); Assert.Equal( HttpStatusCode.NoContent, (await secondClient.DeleteAsync($"/api/tenant/me/sessions/{otherOwnedFamily}")).StatusCode); using var remaining = JsonDocument.Parse( await (await secondClient.GetAsync("/api/tenant/me/sessions")).Content.ReadAsStringAsync()); Assert.Single(remaining.RootElement.EnumerateArray()); } [Fact] public async Task Tenant_administrator_reset_requires_same_tenant_and_forces_password_change() { await using var factory = new ApiTestFactory(); var admin = await SeedUserAsync(factory); var target = await SeedMemberAsync(factory, admin.TenantId, TenantRole.Teacher); var crossTenantTarget = await SeedUserAsync(factory); using var targetClient = factory.CreateClient(); var targetTokens = await targetClient.LoginAsTenantAsync(target.TenantId, target.Phone); using var adminClient = factory.CreateClient(); adminClient.UseAccessToken(await adminClient.LoginAsTenantAsync(admin.TenantId, admin.Phone)); var reset = await adminClient.PostAsJsonAsync( $"/api/tenant/members/{target.UserId}/password-reset", new AdministrativePasswordResetDto { TemporaryPassword = "TemporaryPassword2026", Reason = "Account recovery verification" }); var crossTenant = await adminClient.PostAsJsonAsync( $"/api/tenant/members/{crossTenantTarget.UserId}/password-reset", new AdministrativePasswordResetDto { TemporaryPassword = "TemporaryPassword2026", Reason = "Must not cross tenant boundary" }); Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode); Assert.Equal(HttpStatusCode.NotFound, crossTenant.StatusCode); targetClient.UseAccessToken(targetTokens); Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/tenant/me")).StatusCode); var temporaryLogin = await PostPasswordLoginAsync(targetClient, target, "TemporaryPassword2026"); Assert.Equal(HttpStatusCode.OK, temporaryLogin.StatusCode); Assert.Contains("password_change_required", await temporaryLogin.Content.ReadAsStringAsync(), StringComparison.Ordinal); using var scope = factory.CreateSystemScope("Verify administrative password reset"); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId) .Select(item => item.ForcePasswordChange).SingleAsync()); Assert.True(await dbContext.AuditLogs.AnyAsync(item => item.TenantId == admin.TenantId && item.ActorUserId == admin.UserId && item.Action == "auth.password.reset_by_administrator" && item.TargetId == target.UserId.ToString())); } private static Task PostPasswordLoginAsync( HttpClient client, UserSeed seed, string password) { return client.PostAsJsonAsync( "/api/tenant/auth/login/password", new PasswordLoginDto { Realm = AuthRealm.Tenant, TenantCode = seed.TenantId.ToString("N"), Identifier = seed.Phone, Password = password }); } private static async Task SeedUserAsync(ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var user = new User { Id = Guid.NewGuid(), Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Recovery test user" }.WithTestPassword(); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Recovery test tenant", Status = TenantStatus.Active }, user, new TenantMembership { TenantId = tenantId, UserId = user.Id, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }); return new UserSeed(tenantId, user.Id, user.Phone!); } private static async Task SeedMemberAsync( ApiTestFactory factory, Guid tenantId, TenantRole role) { var user = new User { Id = Guid.NewGuid(), Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Administrative reset target" }.WithTestPassword(); await factory.SeedAsync( user, new TenantMembership { TenantId = tenantId, UserId = user.Id, Role = role, Status = MembershipStatus.Active }); return new UserSeed(tenantId, user.Id, user.Phone!); } private sealed record UserSeed(Guid TenantId, Guid UserId, string Phone); private sealed class CapturingSmsProvider : ISmsProvider { public int SendCount { get; private set; } public string? Code { get; private set; } public Task SendAsync( SmsProviderSendRequest request, CancellationToken cancellationToken = default) { SendCount++; Code = request.Code; return Task.FromResult(new SmsProviderSendResult("test", "sent", "reset-message-id")); } } }