Files
StoreWeb/Tests/Store.Users.Tests/UserHttpTests.cs
T
2026-10-29 11:34:35 +08:00

330 lines
16 KiB
C#

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;
/// <summary>真实 MVC 请求管线测试:仅替换认证方案和数据库,不启动业务宿主或访问外部系统。</summary>
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<GlobalExceptionFilter>();
options.Filters.Add<UnifyResultFilter>();
}).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<AuthenticationSchemeOptions, TestAuthenticationHandler>("Test", _ => { });
builder.Services.AddAuthorization();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
builder.Services.AddSingleton(_context.Db);
builder.Services.AddSingleton(_context.Query);
builder.Services.AddSingleton<IOnlineUserStore>(_context.Sessions);
builder.Services.AddTransient<UserManagementService>();
builder.Services.AddTransient<UserFundsService>();
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<ApiResult<UserInfo>>();
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<ApiResult<UserInfo>>();
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<ApiResult>();
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<ApiResult>();
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()
{
_client.DefaultRequestHeaders.Remove("X-Test-User");
_client.DefaultRequestHeaders.Add("X-Test-User", "main");
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<ApiResult>();
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<ApiResult>();
Assert.Equal(ResultCode.ValidationError, result!.Code);
Assert.False(result.Success);
Assert.Contains("Base64", result.Msg);
Assert.Equal(0, await _context.Db.Select<Store.Users.Entities.UserTopUpQrcodeEntity>().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<ApiResult<UserPage<UserInfo>>>();
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<ApiResult<UserPage<UserInfo>>>();
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<ApiResult<UserPage<UserInfo>>>();
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<ApiResult>();
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<ApiResult<UserInfo>>())!.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<ApiResult<UserInfo>>())!.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<ApiResult>())!.Code);
using var reset = await _client.PostAsJsonAsync("/v1/user/ChangePassword", new { userName = "child", newPassword = "reset-password" });
Assert.True((await reset.Content.ReadFromJsonAsync<ApiResult>())!.Success);
Assert.NotNull(await _context.Authentication.Authenticate("child", "reset-password"));
}
[Fact]
public void ApiExplorerAndEndpointMetadata_UsePostForEveryAction()
{
var descriptors = _app.Services.GetRequiredService<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
.OfType<ControllerActionDescriptor>().Where(a => a.ControllerTypeInfo.AsType() == typeof(UserController)).ToArray();
var descriptions = _app.Services.GetRequiredService<IApiDescriptionGroupCollectionProvider>().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<HttpMethodActionConstraint>()).HttpMethods));
Assert.Equal(expected, Assert.Single(Assert.Single(action.EndpointMetadata.OfType<IHttpMethodMetadata>()).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();
}
}
/// <summary>仅在测试宿主注册:请求头模拟已验证身份,生产认证配置不会引用此类。</summary>
internal sealed class TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> 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)));
}
}