using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Auth; using Tiku.Api.Contracts; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class AuthEndpointTests { [Fact] public async Task Password_login_can_access_current_user_and_tenant() { await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory); using var client = factory.CreateClient(); var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/password", new PasswordLoginDto { TenantId = seed.TenantId, Phone = seed.Phone, Password = "passw0rd!" }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement .GetProperty("tokens") .GetProperty("accessToken") .GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); var meResponse = await client.GetAsync("/api/me"); var tenantResponse = await client.GetAsync("/api/tenants/current"); Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode); Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); Assert.Contains(seed.TenantId.ToString(), await tenantResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Sms_login_can_access_current_user() { await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory); await SeedSmsCodeAsync(factory, seed.TenantId, seed.Phone, "123456"); using var client = factory.CreateClient(); var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/sms", new SmsLoginDto { TenantId = seed.TenantId, Phone = seed.Phone, Code = "123456" }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement .GetProperty("tokens") .GetProperty("accessToken") .GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); var meResponse = await client.GetAsync("/api/me"); Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); } [Fact] public async Task Logout_revokes_access_and_refresh_tokens() { await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory); using var client = factory.CreateClient(); var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/password", new PasswordLoginDto { TenantId = seed.TenantId, Phone = seed.Phone, Password = "passw0rd!" }); var loginJson = await ReadJsonAsync(loginResponse); var tokens = loginJson.RootElement.GetProperty("tokens"); var accessToken = tokens.GetProperty("accessToken").GetString(); var refreshToken = tokens.GetProperty("refreshToken").GetString(); var logoutResponse = await client.PostAsJsonAsync( "/api/auth/logout", new RefreshSessionDto { RefreshToken = refreshToken! }); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); var meResponse = await client.GetAsync("/api/me"); var refreshResponse = await client.PostAsJsonAsync( "/api/auth/refresh", new RefreshSessionDto { RefreshToken = refreshToken! }); Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, refreshResponse.StatusCode); } [Fact] public async Task Wechat_miniapp_login_can_access_current_user() { await using var factory = new ApiTestFactory(new FakeWechatOAuthClient()); var tenantId = Guid.NewGuid(); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Wechat Tenant" }, new TenantAuthProvider { TenantId = tenantId, Provider = "wechat-miniapp", Status = TenantAuthProviderStatus.Testing, ConfigPublic = JsonSerializer.SerializeToElement(new { appId = "wx-app-id", appSecret = "wx-app-secret" }) }); using var client = factory.CreateClient(); var loginResponse = await client.PostAsJsonAsync( "/api/auth/oauth/wechat-miniapp", new OAuthCodeDto { TenantId = tenantId, Code = "wx-code" }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement .GetProperty("tokens") .GetProperty("accessToken") .GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); var meResponse = await client.GetAsync("/api/me"); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Contains(dbContext.UserIdentities, identity => identity.Provider == "wechat-miniapp" && identity.OpenId == "mini-open-id" && identity.UnionId == "union-id"); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync( ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13800000000"; var passwordHash = new PasswordHasher().Hash("passw0rd!"); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Test Tenant" }, new User { Id = userId, Phone = phone, Name = "Test User" }, new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }, new UserIdentity { UserId = userId, Provider = "password", ProviderSubject = phone, Phone = phone, SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); } private static async Task SeedSmsCodeAsync( ApiTestFactory factory, Guid tenantId, string phone, string code) { using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.SmsVerificationCodes.Add(new SmsVerificationCode { TenantId = tenantId, Phone = phone, Purpose = SmsPurpose.Login, CodeHash = SmsCodeHashing.Hash(tenantId, phone, SmsPurpose.Login, code), Status = SmsVerificationStatus.Sent, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) }); await dbContext.SaveChangesAsync(); } private static async Task ReadJsonAsync(HttpResponseMessage response) { var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } private static JsonElement CreateSecretPayload(string passwordHash) { using var document = JsonDocument.Parse( $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); return document.RootElement.Clone(); } private sealed class FakeWechatOAuthClient : IWechatOAuthClient { public Task ExchangeWebCodeAsync( WechatProviderOptions options, string code, CancellationToken cancellationToken = default) { return Task.FromResult(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(new WechatIdentity( "mini-open-id", "union-id", null, null, "session-key", """{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}""")); } } }