using Asp.Versioning; using System.Net; using System.Net.Http.Json; using System.Security.Claims; using System.Text.Encodings.Web; using System.Text.Json; using MAX.Framework.Commons; using MAX.Framework.Dtos; using Microsoft.AspNetCore.Mvc; using MAX.Framework.DependencyInjection; using MAX.Framework.Security; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Store.Users.Contracts.Dtos; using Store.Users.Controllers; using Store.Users.Services; namespace Store.Users.Tests; /// 真实 MVC 请求管线测试:仅替换认证方案和数据库,不启动业务宿主或访问外部系统。 public sealed class UserHttpTests : IAsyncLifetime { private readonly UserTestContext _context = new(); private WebApplication _app = null!; private HttpClient _client = null!; public async Task InitializeAsync() { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" }); builder.WebHost.UseTestServer(); builder.Logging.ClearProviders(); // 与宿主一致:正常数据自动包装,显式 ApiResult 不重复包装,业务异常及模型验证返回 HTTP 200。 builder.Services.AddControllers(options => { options.Filters.Add(); options.Filters.Add(); }).AddApplicationPart(typeof(UserController).Assembly).ConfigureApiBehaviorOptions(options => { options.InvalidModelStateResponseFactory = context => new ObjectResult(ApiResult.Fail( string.Join("; ", context.ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage)), ResultCode.ValidationError)) { StatusCode = 200 }; }); builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(1, 0); options.ApiVersionReader = new UrlSegmentApiVersionReader(); }).AddMvc(); builder.Services.AddAuthentication("Test").AddScheme("Test", _ => { }); builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddSingleton(_context.Db); builder.Services.AddSingleton(_context.Query); builder.Services.AddSingleton(_context.Sessions); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddMediatRAttributedBehaviors(typeof(UserController).Assembly); _app = builder.Build(); _app.UseAuthentication(); _app.UseAuthorization(); _app.MapControllers(); await _app.StartAsync(); _client = _app.GetTestClient(); _client.DefaultRequestHeaders.Add("X-Test-User", "admin"); } [Theory] [InlineData("AddUser")] [InlineData("AddSubUser")] [InlineData("UpdateUser")] [InlineData("UpdateSubUser")] [InlineData("DelUser")] [InlineData("ChangePassword")] [InlineData("UpdateUserPoints")] [InlineData("UpdateUserBalance")] [InlineData("AddUserTopUpMsg")] [InlineData("UpdateUserTopUpMsg")] [InlineData("UpdateTopUpQrcode")] [InlineData("ShopRenew")] public async Task UnmarkedActions_RejectGetPutAndDelete(string action) { foreach (var method in new[] { HttpMethod.Get, HttpMethod.Put, HttpMethod.Delete }) { using var request = new HttpRequestMessage(method, $"/v1/user/{action}"); using var response = await _client.SendAsync(request); Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); Assert.Contains("POST", response.Content.Headers.Allow); } } [Theory] [InlineData("GetUser")] [InlineData("GetAllUser")] [InlineData("GetSubUser")] [InlineData("GetUserPoints")] [InlineData("GetUserBalance")] [InlineData("GetUserTopUpMsg")] [InlineData("GetTopUpQrcode")] public async Task QueryActions_RejectGet(string action) { using var response = await _client.GetAsync($"/v1/user/{action}"); Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); Assert.Contains("POST", response.Content.Headers.Allow); } [Fact] public async Task UnauthenticatedRequests_AreChallengedBeforeReadingOrWriting() { _client.DefaultRequestHeaders.Remove("X-Test-User"); using var read = await _client.PostAsJsonAsync("/v1/user/GetUser", new { }); using var write = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName = "created", password = "new-password" }); Assert.Equal(HttpStatusCode.Unauthorized, read.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, write.StatusCode); Assert.Null(await _context.Read("created")); } [Fact] public async Task DefaultPost_CreatesAccountFromJsonBody_WithoutExposingPassword() { using var response = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName = "created", password = "new-password" }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); Assert.DoesNotContain("password", content, StringComparison.OrdinalIgnoreCase); var result = await response.Content.ReadFromJsonAsync>(); Assert.True(result!.Success); var info = result.Data; Assert.Equal("created", info!.UserName); Assert.NotNull(await _context.Read("created")); } [Fact] public async Task Post_ReadsCurrentUserFromRequestIdentity() { _client.DefaultRequestHeaders.Remove("X-Test-User"); _client.DefaultRequestHeaders.Add("X-Test-User", "main"); using var response = await _client.PostAsJsonAsync("/v1/user/GetUser", new { }); var result = await response.Content.ReadFromJsonAsync>(); Assert.True(result!.Success); var info = result.Data; Assert.Equal("main", info!.UserName); } [Fact] public async Task InvalidPasswordLength_IsRejectedByServiceBeforeCreatingAccount() { // 遵循宿主验证约定:HTTP 200 + 业务错误码,不返回默认的 HTTP 400。 using var response = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName = "created", password = "123" }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); Assert.Equal(ResultCode.ValidationError, result!.Code); Assert.False(result.Success); Assert.Null(await _context.Read("created")); } [Theory] [InlineData(null, "password")] [InlineData("", "password")] [InlineData("created", " ")] public async Task MissingRequiredInput_IsRejectedByDtoBeforeCreatingAccount(string? userName, string password) { using var response = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName, password }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); Assert.Equal(ResultCode.ValidationError, result!.Code); Assert.False(result.Success); Assert.Null(await _context.Read("created")); } [Theory] [InlineData("DelUser")] [InlineData("ChangePassword")] [InlineData("UpdateUserTopUpMsg")] [InlineData("UpdateTopUpQrcode")] public async Task CommandsWithoutData_ReturnHttp200AndSingleApiResult(string action) { string url = $"/v1/user/{action}"; object body = new { }; switch (action) { case "DelUser": url += $"?id={_context.Other.Id}"; break; case "ChangePassword": body = new { oldPassword = "legacy-password", newPassword = "new-password" }; break; case "UpdateUserTopUpMsg": var id = await _context.Users.AddTopUpMessage(new() { Message = "测试充值备注" }, default); body = new { id, result = "已处理" }; break; case "UpdateTopUpQrcode": body = new { agentCode = "agent", qrcodeBase64 = "aGVsbG8=" }; break; } using var response = await _client.PostAsJsonAsync(url, body); Assert.Equal(HttpStatusCode.OK, response.StatusCode); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var result = document.RootElement; Assert.Equal(ResultCode.Success, result.GetProperty("code").GetInt32()); Assert.True(result.GetProperty("success").GetBoolean()); Assert.Equal("操作成功", result.GetProperty("msg").GetString()); // data 必须为 null,不能变成包含另一层 code/data 的 ApiResult。 Assert.Equal(JsonValueKind.Null, result.GetProperty("data").ValueKind); } [Fact] public async Task BusinessFailure_ReturnsHttp200WithFailureApiResult() { using var response = await _client.PostAsJsonAsync("/v1/user/ChangePassword", new { oldPassword = "wrong-password", newPassword = "new-password" }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); Assert.Equal(ResultCode.BusinessError, result!.Code); Assert.False(result.Success); Assert.Equal("原密码错误", result.Msg); Assert.Null(result.Data); Assert.Empty(_context.Sessions.SignedOut); } [Fact] public async Task InvalidQrcode_IsRejectedByServiceWithValidationApiResult() { using var response = await _client.PostAsJsonAsync("/v1/user/UpdateTopUpQrcode", new { agentCode = "agent", qrcodeBase64 = "invalid!" }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); Assert.Equal(ResultCode.ValidationError, result!.Code); Assert.False(result.Success); Assert.Contains("Base64", result.Msg); Assert.Equal(0, await _context.Db.Select().CountAsync()); } [Fact] public async Task GetSubUser_UsesAdminOrMainAccountScope() { _context.Seed("foreign-child", parent: "other"); using var allResponse = await _client.PostAsJsonAsync("/v1/user/GetSubUser", new { }); var all = await allResponse.Content.ReadFromJsonAsync>>(); Assert.Equal(HttpStatusCode.OK, allResponse.StatusCode); Assert.True(all!.Success); Assert.Equal(2, all.Data!.Total); using var filteredResponse = await _client.PostAsJsonAsync("/v1/user/GetSubUser", new { parentUserName = "other" }); var filtered = await filteredResponse.Content.ReadFromJsonAsync>>(); Assert.True(filtered!.Success); Assert.Equal("foreign-child", Assert.Single(filtered.Data!.Items).UserName); _client.DefaultRequestHeaders.Remove("X-Test-User"); _client.DefaultRequestHeaders.Add("X-Test-User", "main"); using var ownResponse = await _client.PostAsJsonAsync("/v1/user/GetSubUser", new { }); var own = await ownResponse.Content.ReadFromJsonAsync>>(); Assert.True(own!.Success); Assert.Equal("child", Assert.Single(own.Data!.Items).UserName); using var forbiddenResponse = await _client.PostAsJsonAsync("/v1/user/GetSubUser", new { parentUserName = "other" }); Assert.Equal(HttpStatusCode.OK, forbiddenResponse.StatusCode); var forbidden = await forbiddenResponse.Content.ReadFromJsonAsync(); Assert.Equal(ResultCode.Forbidden, forbidden!.Code); } [Fact] public async Task UpdateEndpoints_ApplySharedAccountManagementScope() { using var adminUpdate = await _client.PostAsJsonAsync("/v1/user/UpdateSubUser", new { id = _context.Child.Id, notes = "admin-update" }); Assert.True((await adminUpdate.Content.ReadFromJsonAsync>())!.Success); _client.DefaultRequestHeaders.Remove("X-Test-User"); _client.DefaultRequestHeaders.Add("X-Test-User", "main"); foreach (var id in new[] { _context.Main.Id, _context.Child.Id }) { using var update = await _client.PostAsJsonAsync("/v1/user/UpdateUser", new { id, notes = "main-update" }); Assert.Equal(HttpStatusCode.OK, update.StatusCode); Assert.True((await update.Content.ReadFromJsonAsync>())!.Success); } using var forbidden = await _client.PostAsJsonAsync("/v1/user/UpdateUser", new { id = _context.Other.Id, notes = "bad" }); Assert.Equal(ResultCode.Forbidden, (await forbidden.Content.ReadFromJsonAsync())!.Code); using var reset = await _client.PostAsJsonAsync("/v1/user/ChangePassword", new { userName = "child", newPassword = "reset-password" }); Assert.True((await reset.Content.ReadFromJsonAsync())!.Success); Assert.NotNull(await _context.Authentication.Authenticate("child", "reset-password")); } [Fact] public void ApiExplorerAndEndpointMetadata_UsePostForEveryAction() { var descriptors = _app.Services.GetRequiredService().ActionDescriptors.Items .OfType().Where(a => a.ControllerTypeInfo.AsType() == typeof(UserController)).ToArray(); var descriptions = _app.Services.GetRequiredService().ApiDescriptionGroups.Items .SelectMany(g => g.Items).Where(d => d.ActionDescriptor is ControllerActionDescriptor a && a.ControllerTypeInfo.AsType() == typeof(UserController)).ToArray(); Assert.Equal(19, descriptors.Length); Assert.Equal(descriptors.Length, descriptions.Length); foreach (var action in descriptors) { var expected = "POST"; Assert.Equal(expected, Assert.Single(Assert.Single(action.ActionConstraints!.OfType()).HttpMethods)); Assert.Equal(expected, Assert.Single(Assert.Single(action.EndpointMetadata.OfType()).HttpMethods)); Assert.Equal(expected, Assert.Single(descriptions, d => d.ActionDescriptor.Id == action.Id).HttpMethod); } } public async Task DisposeAsync() { _client?.Dispose(); if (_app != null) await _app.DisposeAsync(); _context.Dispose(); } } /// 仅在测试宿主注册:请求头模拟已验证身份,生产认证配置不会引用此类。 internal sealed class TestAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) { protected override Task HandleAuthenticateAsync() { if (!Request.Headers.TryGetValue("X-Test-User", out var name)) return Task.FromResult(AuthenticateResult.NoResult()); var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.UserName, name.ToString())], Scheme.Name)); return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name))); } }