280 lines
13 KiB
C#
280 lines
13 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()
|
|
{
|
|
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 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)));
|
|
}
|
|
}
|