增加Shop API
This commit is contained in:
1 parent
3769193696
commit
5d017740c6
83 files changed
+4451
-696
No files matched your search
@@ -1,77 +1,164 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using MAX.Framework.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Security.Claims;
|
||||
using MAX.Framework.Security;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
public sealed class PersistentSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TokensSurviveRestart_AndLogoutOnlyRevokesCurrentSession()
|
||||
private sealed class Clock : TimeProvider
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"sessions-{Guid.NewGuid():N}.db");
|
||||
var options = Options.Create(new JwtOptions());
|
||||
TokenResult first;
|
||||
TokenResult second;
|
||||
string firstJti;
|
||||
public DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
private static IFreeSql Open(string path)
|
||||
{
|
||||
var db = new FreeSql.FreeSqlBuilder()
|
||||
.UseMappingPriority(FreeSql.Internal.MappingPriorityType.Attribute,
|
||||
FreeSql.Internal.MappingPriorityType.Aop, FreeSql.Internal.MappingPriorityType.FluentApi)
|
||||
.UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path};Pooling=false").Build();
|
||||
db.CodeFirst.ConfigEntity<UserToken>(e => e.Name("tokens"));
|
||||
db.CodeFirst.ConfigEntity<TokenAccount>(e => e.Name("users"));
|
||||
db.CodeFirst.SyncStructure<UserToken>();
|
||||
db.Ado.ExecuteNonQuery("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, user_name TEXT, nick_name TEXT)");
|
||||
db.Ado.ExecuteNonQuery("INSERT OR IGNORE INTO users VALUES (1, 'same', 'Nickname')");
|
||||
return db;
|
||||
}
|
||||
private static TokenResult Issue(TokenService service, string? device = null) =>
|
||||
service.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "192.0.2.10", device, "Device");
|
||||
|
||||
[Fact]
|
||||
public async Task BearerHandler_EnforcesDatabaseTokensAndReturns401And403()
|
||||
{
|
||||
using var db = Open(":memory:");
|
||||
var builder = Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Services.AddSingleton<IFreeSql>(db);
|
||||
builder.Services.AddStoreHubTokenAuth(builder.Configuration);
|
||||
builder.Services.AddAuthorization(
|
||||
options => options.AddPolicy("deny", policy => policy.RequireClaim("missing")));
|
||||
await using var app = builder.Build();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapGet("/private", () => "ok").RequireAuthorization();
|
||||
app.MapGet("/forbidden", () => "ok").RequireAuthorization("deny");
|
||||
await app.StartAsync();
|
||||
using var client = app.GetTestClient();
|
||||
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await client.GetAsync("/private")).StatusCode);
|
||||
var service = app.Services.GetRequiredService<ITokenService>();
|
||||
var token = Issue((TokenService)service);
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", token.Token);
|
||||
Assert.Equal("ok", await client.GetStringAsync("/private"));
|
||||
Assert.Equal(System.Net.HttpStatusCode.Forbidden, (await client.GetAsync("/forbidden")).StatusCode);
|
||||
var principal = await service.ValidateTokenAsync(token.Token);
|
||||
var store = app.Services.GetRequiredService<IOnlineUserStore>();
|
||||
store.SignOutSession("same", principal!.FindFirst(JwtClaimTypes.Jti)!.Value);
|
||||
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await client.GetAsync("/private")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokensSurviveRestart_AndLogoutInvalidatesWarmCache()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"tokens-{Guid.NewGuid():N}.db");
|
||||
var clock = new Clock();
|
||||
var options = Options.Create(new TokenAuthOptions());
|
||||
try
|
||||
{
|
||||
TokenResult first, second;
|
||||
using (var db = Open(path))
|
||||
using (var cache = new MemoryCache(new MemoryCacheOptions()))
|
||||
{
|
||||
db.CodeFirst.SyncStructure<OnlineUserInfo>();
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(options, store);
|
||||
first = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "192.0.2.10");
|
||||
second = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "2001:db8::1");
|
||||
firstJti = (await tokens.ValidateTokenAsync(first.Token))!.FindFirst(JwtClaimTypes.Jti)!.Value;
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
Assert.Equal(2, store.Count);
|
||||
var service = new TokenService(db, options, cache, clock);
|
||||
first = Issue(service); second = Issue(service);
|
||||
Assert.Equal(32, Convert.FromBase64String(first.Token).Length);
|
||||
Assert.NotEqual(first.Token, second.Token);
|
||||
Assert.All(db.Select<UserToken>().ToList(), x => Assert.Equal(64, x.TokenHash.Length));
|
||||
}
|
||||
// 新 ORM、新存储和 TokenService,模拟进程重启后的数据库恢复。
|
||||
using (var db = Open(path))
|
||||
using (var cache = new MemoryCache(new MemoryCacheOptions()))
|
||||
{
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(options, store);
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(first.Token));
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
Assert.Contains(store.GetOnlineUsers(), x => x.LoginIp == "2001:db8::1");
|
||||
store.SignOutSession("other", firstJti);
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(first.Token));
|
||||
store.SignOutSession("same", firstJti);
|
||||
Assert.Null(await tokens.ValidateTokenAsync(first.Token));
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
var service = new TokenService(db, options, cache, clock);
|
||||
var store = new OnlineUserStore(db, service);
|
||||
var principal = await service.ValidateTokenAsync(first.Token);
|
||||
Assert.Equal("Nickname", principal!.FindFirst(JwtClaimTypes.NickName)!.Value);
|
||||
Assert.NotNull(await service.ValidateTokenAsync(second.Token));
|
||||
var id = principal.FindFirst(JwtClaimTypes.Jti)!.Value;
|
||||
store.SignOutSession("other", id);
|
||||
Assert.NotNull(await service.ValidateTokenAsync(first.Token));
|
||||
store.SignOutSession("same", id);
|
||||
Assert.Null(await service.ValidateTokenAsync(first.Token));
|
||||
Assert.NotNull(await service.ValidateTokenAsync(second.Token));
|
||||
Assert.Single(store.GetOnlineUsers());
|
||||
Assert.NotNull(db.Select<OnlineUserInfo>().Where(x => x.Jti == firstJti).First().RevokedAt);
|
||||
}
|
||||
using (var db = Open(path))
|
||||
{
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(options, store);
|
||||
Assert.Null(await tokens.ValidateTokenAsync(first.Token));
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
store.SignOut("same");
|
||||
Assert.Null(await tokens.ValidateTokenAsync(second.Token));
|
||||
Assert.Null(await service.ValidateTokenAsync(second.Token));
|
||||
Assert.All(db.Select<UserToken>().ToList(), t => Assert.True(t.Revoked));
|
||||
}
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredSessionsAreRejected_EvenWithinJwtClockTolerance()
|
||||
public async Task SlidingRenewal_IsThrottled_AndCappedByAbsoluteExpiry()
|
||||
{
|
||||
using var db = Open(":memory:");
|
||||
db.CodeFirst.SyncStructure<OnlineUserInfo>();
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(Options.Create(new JwtOptions()), store);
|
||||
var token = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "user")]);
|
||||
db.Update<OnlineUserInfo>().Set(x => x.ExpiresAt, DateTime.UtcNow.AddSeconds(-1))
|
||||
.Where(x => x.UserName == "user").ExecuteAffrows();
|
||||
Assert.Null(await tokens.ValidateTokenAsync(token.Token));
|
||||
Assert.Empty(store.GetOnlineUsers());
|
||||
Assert.Equal(1, db.Select<OnlineUserInfo>().Count());
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var clock = new Clock();
|
||||
var service = new TokenService(db, Options.Create(new TokenAuthOptions { IdleDays = 2, AbsoluteMaxDays = 3 }), cache, clock);
|
||||
var token = Issue(service);
|
||||
var initial = db.Select<UserToken>().First();
|
||||
clock.Now = clock.Now.AddMinutes(9);
|
||||
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
||||
Assert.Equal(initial.ExpiresAt, db.Select<UserToken>().First().ExpiresAt);
|
||||
clock.Now = clock.Now.AddMinutes(1);
|
||||
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
||||
Assert.Equal(clock.Now.UtcDateTime.AddDays(2), db.Select<UserToken>().First().ExpiresAt);
|
||||
clock.Now = clock.Now.AddDays(1);
|
||||
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
||||
Assert.Equal(initial.CreatedAt.AddDays(3), db.Select<UserToken>().First().ExpiresAt);
|
||||
clock.Now = new DateTimeOffset(initial.CreatedAt.AddDays(3), TimeSpan.Zero);
|
||||
Assert.Null(await service.ValidateTokenAsync(token.Token));
|
||||
}
|
||||
|
||||
private static IFreeSql Open(string path) => new FreeSql.FreeSqlBuilder()
|
||||
.UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path};Pooling=false")
|
||||
.Build();
|
||||
[Theory]
|
||||
[InlineData(0, 3)]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(2, 2)]
|
||||
public async Task DeviceLimit_EvictsOldestAndClearsCache(int limit, int expected)
|
||||
{
|
||||
using var db = Open(":memory:");
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new TokenService(db, Options.Create(new TokenAuthOptions { MaxDevicesPerUser = limit }), cache, new Clock());
|
||||
var first = Issue(service, "a");
|
||||
Assert.NotNull(await service.ValidateTokenAsync(first.Token));
|
||||
Issue(service, "b"); var last = Issue(service, "c");
|
||||
Assert.Equal(expected, db.Select<UserToken>().Where(t => !t.Revoked).Count());
|
||||
Assert.NotNull(await service.ValidateTokenAsync(last.Token));
|
||||
Assert.Equal(limit == 0, await service.ValidateTokenAsync(first.Token) != null);
|
||||
var replacement = Issue(service, "c");
|
||||
Assert.Null(await service.ValidateTokenAsync(last.Token));
|
||||
Assert.NotNull(await service.ValidateTokenAsync(replacement.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CacheDisabled_ObservesExternalRevocation_AndRejectsIdleExpiry()
|
||||
{
|
||||
using var db = Open(":memory:");
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var clock = new Clock();
|
||||
var service = new TokenService(db, Options.Create(new TokenAuthOptions { CacheSeconds = 0 }), cache, clock);
|
||||
var token = Issue(service);
|
||||
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
||||
db.Update<UserToken>().Set(t => t.Revoked, true).Where(t => t.Id > 0).ExecuteAffrows();
|
||||
Assert.Null(await service.ValidateTokenAsync(token.Token));
|
||||
var second = Issue(service);
|
||||
clock.Now = clock.Now.AddDays(7);
|
||||
Assert.Null(await service.ValidateTokenAsync(second.Token));
|
||||
Assert.Null(await service.ValidateTokenAsync("malformed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using Asp.Versioning;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Conventions;
|
||||
using MAX.Framework.Dtos;
|
||||
using MAX.Framework.Security;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Store.Shops.Controllers;
|
||||
using Store.Shops.Entities;
|
||||
using Store.Shops.Services;
|
||||
using Store.Users.Contracts;
|
||||
|
||||
namespace Store.Shops.Tests;
|
||||
|
||||
public sealed class ShopHttpTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ShopTestContext 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();
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Conventions.Add(new ApiRoutePrefixConvention());
|
||||
options.Filters.Add<GlobalExceptionFilter>();
|
||||
options.Filters.Add<UnifyResultFilter>();
|
||||
}).AddApplicationPart(typeof(ShopController).Assembly).ConfigureApiBehaviorOptions(options =>
|
||||
{
|
||||
options.InvalidModelStateResponseFactory = _ =>
|
||||
new ObjectResult(ApiResult.Fail("参数错误", 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, ShopTestAuthentication>("Test", _ => { });
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
||||
builder.Services.AddSingleton(context.Db);
|
||||
builder.Services.AddSingleton<IUserQueryService>(context);
|
||||
builder.Services.AddSingleton(context.Configuration);
|
||||
builder.Services.AddSingleton(context.Gateway);
|
||||
builder.Services.AddTransient<ShopManagementService>();
|
||||
builder.Services.AddTransient<ShopService>();
|
||||
app = builder.Build();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
await app.StartAsync();
|
||||
client = app.GetTestClient();
|
||||
client.DefaultRequestHeaders.Add("X-Test-User", "owner");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GetPddClients")]
|
||||
[InlineData("GetPddShopClients")]
|
||||
[InlineData("GetPddShopToken")]
|
||||
[InlineData("GetAllPddShops")]
|
||||
[InlineData("AddWeidianShop")]
|
||||
[InlineData("UpdateWeidianShop")]
|
||||
[InlineData("UpdateWeidianShopLogin")]
|
||||
[InlineData("DeleteWeidianShop")]
|
||||
public async Task ActionsOnlyAcceptPost(string action)
|
||||
{
|
||||
using var response = await client.GetAsync("/api/v1/shop/" + action);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Contains("POST", response.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActionsBindDtoAndReturnSingleApiResultEnvelope()
|
||||
{
|
||||
using var create = await client.PostAsJsonAsync("/api/v1/shop/AddWeidianShop", new { loginName = "login", shopId = "shop", notes = "old" });
|
||||
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
|
||||
var json = await create.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(200, json.GetProperty("code").GetInt32());
|
||||
Assert.Equal(JsonValueKind.Null, json.GetProperty("data").ValueKind);
|
||||
using var update = await client.PostAsJsonAsync("/api/v1/shop/UpdateWeidianShop", new { loginName = "login", shopId = "shop", notes = "new" });
|
||||
Assert.Equal(200, (await update.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
|
||||
using var cookie = await client.PostAsJsonAsync("/api/v1/shop/UpdateWeidianShopLogin", new { shopId = "shop", cookie = "session" });
|
||||
Assert.Equal(200, (await cookie.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
|
||||
var row = Assert.Single(await context.Db.Select<WeidianShopEntity>().ToListAsync());
|
||||
Assert.Equal("new", row.Notes);
|
||||
Assert.Equal("session", row.Cookie);
|
||||
Assert.Equal("owner", row.UserName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidationAndBusinessErrorsReturnHttp200WithFailureCode()
|
||||
{
|
||||
using var invalid = await client.PostAsJsonAsync("/api/v1/shop/AddWeidianShop", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, invalid.StatusCode);
|
||||
Assert.Equal(ResultCode.ValidationError, (await invalid.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
|
||||
using var denied = await client.PostAsJsonAsync("/api/v1/shop/GetAllPddShops", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, denied.StatusCode);
|
||||
Assert.Equal(ResultCode.BusinessError, (await denied.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CredentialResponseIsWrappedAndNotCached_AnonymousCannotRead()
|
||||
{
|
||||
using var response = await client.PostAsJsonAsync("/api/v1/shop/GetPddClients", new { });
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(200, result.GetProperty("code").GetInt32());
|
||||
Assert.Equal("test-client", result.GetProperty("data")[0].GetProperty("clientId").GetString());
|
||||
Assert.True(response.Headers.CacheControl!.NoStore);
|
||||
client.DefaultRequestHeaders.Remove("X-Test-User");
|
||||
using var anonymous = await client.PostAsJsonAsync("/api/v1/shop/GetPddClients", new { });
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
client.Dispose();
|
||||
await app.DisposeAsync();
|
||||
context.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ShopTestAuthentication(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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using MAX.Framework.Commons;
|
||||
using Store.Shops.Dtos;
|
||||
using Store.Shops.Entities;
|
||||
|
||||
namespace Store.Shops.Tests;
|
||||
|
||||
public sealed class ShopManagementTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateThenImportAgain_UpdatesWithoutDuplicating_AndNullPreservesFields()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
await c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Notes = "keep", Cookie = "old" }, true);
|
||||
await c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Cookie = "new" }, true);
|
||||
var row = Assert.Single(await c.Db.Select<WeidianShopEntity>().ToListAsync());
|
||||
Assert.Equal("keep", row.Notes);
|
||||
Assert.Equal("new", row.Cookie);
|
||||
Assert.Equal("owner", row.UserName);
|
||||
await c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Notes = "", NewShopId = "new-id" }, false);
|
||||
row = await c.Db.Select<WeidianShopEntity>().FirstAsync();
|
||||
Assert.Equal("", row.Notes);
|
||||
Assert.Equal("new-id", row.ShopId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongOwnerCannotUpdateCookieOrDelete_EvenWithSameShopId()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
await c.Db.Insert(new WeidianShopEntity { LoginName = "login", ShopId = "shop", UserName = "other", Cookie = "secret" }).ExecuteAffrowsAsync();
|
||||
await Assert.ThrowsAsync<AppException>(() => c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "attack" }));
|
||||
await Assert.ThrowsAsync<AppException>(() => c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Notes = "attack" }, false));
|
||||
await Assert.ThrowsAsync<AppException>(() => c.Service.DeleteWeidian(new() { ShopId = "shop" }));
|
||||
Assert.Equal("secret", (await c.Db.Select<WeidianShopEntity>().FirstAsync()).Cookie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignedAccountMatchesWholeName_AndCannotReassignOrDelete()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
await c.Db.Insert(new WeidianShopEntity { LoginName = "login", ShopId = "shop", UserName = "other", SubUserName = "coowner,owner2" }).ExecuteAffrowsAsync();
|
||||
await Assert.ThrowsAsync<AppException>(() => c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "attack" }));
|
||||
await c.Db.Update<WeidianShopEntity>().Where(s => s.ShopId == "shop").Set(s => s.SubUserName, "first,owner,last").ExecuteAffrowsAsync();
|
||||
await c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "allowed" });
|
||||
await Assert.ThrowsAsync<AppException>(() => c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", SubUserName = "new" }, false));
|
||||
await Assert.ThrowsAsync<AppException>(() => c.Service.DeleteWeidian(new() { ShopId = "shop" }));
|
||||
Assert.Equal("allowed", (await c.Db.Select<WeidianShopEntity>().FirstAsync()).Cookie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CookieUpdateOnlyChangesAuthorizedRows_AndOwnerCanDelete()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
await c.Db.Insert(new[] {
|
||||
new WeidianShopEntity { ShopId = "shop", UserName = "owner", WdToken = "keep" },
|
||||
new WeidianShopEntity { ShopId = "shop", UserName = "other", Cookie = "secret" }
|
||||
}).ExecuteAffrowsAsync();
|
||||
await c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "new", LoginStatus = "online" });
|
||||
Assert.Equal("keep", (await c.Db.Select<WeidianShopEntity>().Where(s => s.UserName == "owner").FirstAsync()).WdToken);
|
||||
await c.Service.DeleteWeidian(new() { ShopId = "shop" });
|
||||
Assert.Equal("secret", Assert.Single(await c.Db.Select<WeidianShopEntity>().ToListAsync()).Cookie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AmbiguousLoginIsRejectedWithoutChangingEitherShop()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
await c.Service.SaveWeidian(new() { LoginName = "same", ShopId = "one" }, true);
|
||||
await c.Service.SaveWeidian(new() { LoginName = "same", ShopId = "two" }, true);
|
||||
await Assert.ThrowsAsync<AppException>(() => c.Service.SaveWeidian(new() { LoginName = "same", Notes = "bad" }, false));
|
||||
Assert.All(await c.Db.Select<WeidianShopEntity>().ToListAsync(), s => Assert.Equal("", s.Notes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidationAndAnonymousRequestsDoNotWrite()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
await Assert.ThrowsAsync<ValidationException>(() => c.Service.SaveWeidian(new(), true));
|
||||
await Assert.ThrowsAsync<ValidationException>(() => c.Service.UpdateWeidianCookie(new() { ShopId = "shop", WdToken = "only" }));
|
||||
await Assert.ThrowsAsync<ValidationException>(() => c.Service.DeleteWeidian(new()));
|
||||
c.UserName = null;
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => c.Service.SaveWeidian(new() { LoginName = "x" }, true));
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => c.Service.GetPddClients());
|
||||
Assert.Equal(0, await c.Db.Select<WeidianShopEntity>().CountAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 50)]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(1, 501)]
|
||||
[InlineData(int.MaxValue, 500)]
|
||||
public async Task InvalidPaginationIsRejected(int page, int size)
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
await Assert.ThrowsAsync<ValidationException>(() => c.Service.GetAdminShops(new() { Page = page, PageSize = size }));
|
||||
await Assert.ThrowsAsync<ValidationException>(() => c.Service.GetShopClientInfo(new() { Page = page, PageSize = size }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientInfoScopesCountAndRowsBeforePagination()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
await c.Db.Insert(new[] {
|
||||
new PddShopEntity { ShopId = "one", UserName = "owner" },
|
||||
new PddShopEntity { ShopId = "two", UserName = "other", SubUserName = "owner" },
|
||||
new PddShopEntity { ShopId = "three", UserName = "other", SubUserName = "owner2" }
|
||||
}).ExecuteAffrowsAsync();
|
||||
foreach (var id in new[] { "one", "two", "three" })
|
||||
await c.Db.Insert(new ShopApiAuthEntity { ShopId = id, AppKey = id, AppSecret = "secret" }).ExecuteAffrowsAsync();
|
||||
var page = await c.Service.GetShopClientInfo(new() { PageSize = 1 });
|
||||
Assert.Equal(2, page.Total);
|
||||
Assert.Equal("two", Assert.Single(page.Items).ShopId);
|
||||
Assert.Empty((await c.Service.GetShopClientInfo(new() { ShopIds = ["three"] })).Items);
|
||||
c.UserName = "admin";
|
||||
Assert.Empty((await c.Service.GetShopClientInfo(new())).Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminListResetsOldMonthOnceAndKeepsCurrentQuota()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
await Assert.ThrowsAsync<AppException>(() => c.Service.GetAdminShops(new()));
|
||||
c.UserName = "admin";
|
||||
await c.Db.Insert(new PddShopEntity { ShopId = "old", ShopAccount = "test", LoginPass = "password", Cookie = "secret" }).ExecuteAffrowsAsync();
|
||||
await c.Db.Insert(new ShopProductQuotaEntity { ShopId = "old", AddProductMonth = DateTime.Now.Month % 12 + 1, AddProductRemaining = 2 }).ExecuteAffrowsAsync();
|
||||
var page = await c.Service.GetAdminShops(new() { SearchName = "test" });
|
||||
Assert.Equal(1, page.Total);
|
||||
Assert.Equal(300, Assert.Single(page.Items).AddProductRemaining);
|
||||
var json = JsonSerializer.Serialize(page);
|
||||
Assert.DoesNotContain("LoginPass", json);
|
||||
Assert.DoesNotContain("Cookie", json);
|
||||
await c.Db.Update<ShopProductQuotaEntity>().Where(s => s.ShopId == "old").Set(s => s.AddProductRemaining, 17).ExecuteAffrowsAsync();
|
||||
Assert.Equal(17, Assert.Single((await c.Service.GetAdminShops(new())).Items).AddProductRemaining);
|
||||
Assert.Equal(17, (await c.Db.Select<PddShopEntity>().FirstAsync()).AddProductRemaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuotaAndShopUpdatesRollbackTogether()
|
||||
{
|
||||
using var c = new ShopTestContext { UserName = "admin" };
|
||||
await c.Db.Insert(new PddShopEntity { ShopId = "shop" }).ExecuteAffrowsAsync();
|
||||
await c.Db.Insert(new ShopProductQuotaEntity { ShopId = "shop", AddProductMonth = 0, AddProductRemaining = 7 }).ExecuteAffrowsAsync();
|
||||
c.Db.Ado.ExecuteNonQuery("CREATE TRIGGER reject_shop_update BEFORE UPDATE ON pdd_shops BEGIN SELECT RAISE(ABORT, 'test failure'); END");
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => c.Service.GetAdminShops(new()));
|
||||
var quota = await c.Db.Select<ShopProductQuotaEntity>().FirstAsync();
|
||||
Assert.Equal(0, quota.AddProductMonth);
|
||||
Assert.Equal(7, quota.AddProductRemaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenRequiresShopAccess_AndRetriesStructuredFailure()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
await Assert.ThrowsAsync<AppException>(() => c.Service.GetPddToken(new() { MallId = "mall" }));
|
||||
Assert.Equal(0, c.Handler.Calls);
|
||||
await c.Db.Insert(new PddShopEntity { MallId = "mall&x=1", UserName = "owner" }).ExecuteAffrowsAsync();
|
||||
c.Handler.Responses.Enqueue("""{"code":1,"msg":"failed"}""");
|
||||
var token = await c.Service.GetPddToken(new() { MallId = "mall&x=1" });
|
||||
Assert.Equal("test-token", token.GetProperty("accessToken").GetString());
|
||||
Assert.Equal(2, c.Handler.Calls);
|
||||
Assert.Contains("mall%26x%3D1", c.Handler.LastUri!.AbsoluteUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenMalformedResponsesFailWithoutLeakingUpstreamContent()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
c.Handler.Responses.Enqueue("private upstream error");
|
||||
c.Handler.Responses.Enqueue("""{"code":3}""");
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => c.Gateway.GetToken("mall", default));
|
||||
Assert.DoesNotContain("private", error.Message);
|
||||
Assert.Equal(2, c.Handler.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PddClientsComeFromConfiguration()
|
||||
{
|
||||
using var c = new ShopTestContext();
|
||||
Assert.Equal("test-client", Assert.Single(await c.Service.GetPddClients()).ClientId);
|
||||
c.Configuration["Shops:PddClients:0:ClientSecret"] = "";
|
||||
await Assert.ThrowsAsync<AppException>(() => c.Service.GetPddClients());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using FreeSql;
|
||||
using MAX.Framework.Security;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Store.Shops.Entities;
|
||||
using Store.Shops.Services;
|
||||
using Store.Users.Contracts;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Contracts.Enums;
|
||||
|
||||
namespace Store.Shops.Tests;
|
||||
|
||||
internal sealed class ShopTestContext : IDisposable, ICurrentUser, IUserQueryService
|
||||
{
|
||||
private readonly string path = Path.Combine(Path.GetTempPath(), $"store-shops-{Guid.NewGuid():N}.db");
|
||||
public IFreeSql Db { get; }
|
||||
public string? UserName { get; set; } = "owner";
|
||||
public string? NickName => UserName;
|
||||
public bool IsAuthenticated => UserName != null;
|
||||
public IEnumerable<Claim> Claims => [];
|
||||
public string? GetClaimValue(string type) => null;
|
||||
public T? GetClaimValue<T>(string type) => default;
|
||||
public Task<UserInfo> GetUser(string name) => Task.FromResult(new UserInfo
|
||||
{
|
||||
UserName = name, SaLevel = name == "admin" ? UserAdminLevel.SuperAdmin : UserAdminLevel.Normal
|
||||
});
|
||||
public StubHttp Handler { get; } = new();
|
||||
public PddTokenClient Gateway { get; }
|
||||
public ShopManagementService Service { get; }
|
||||
public IConfiguration Configuration { get; } = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Shops:PddClients:0:ClientId"] = "test-client",
|
||||
["Shops:PddClients:0:ClientSecret"] = "test-secret",
|
||||
["Shops:PddClients:0:RedirectUri"] = "https://example.test/callback"
|
||||
}).Build();
|
||||
|
||||
public ShopTestContext()
|
||||
{
|
||||
Db = new FreeSqlBuilder().UseMappingPriority(FreeSql.Internal.MappingPriorityType.Attribute,
|
||||
FreeSql.Internal.MappingPriorityType.Aop, FreeSql.Internal.MappingPriorityType.FluentApi)
|
||||
.UseConnectionString(DataType.Sqlite, $"Data Source={path};Pooling=False").Build();
|
||||
Db.CodeFirst.ConfigEntity<PddShopEntity>(e => e.Name("pdd_shops"));
|
||||
Db.CodeFirst.ConfigEntity<WeidianShopEntity>(e => e.Name("weidian_shops"));
|
||||
Db.CodeFirst.ConfigEntity<ShopApiAuthEntity>(e => e.Name("shop_auth"));
|
||||
Db.CodeFirst.ConfigEntity<ShopProductQuotaEntity>(e => e.Name("shop_quota"));
|
||||
Db.CodeFirst.SyncStructure(typeof(PddShopEntity), typeof(WeidianShopEntity), typeof(ShopApiAuthEntity), typeof(ShopProductQuotaEntity));
|
||||
Gateway = new(new HttpClient(Handler));
|
||||
Service = new(Db, this, this, Configuration, Gateway);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Gateway.Dispose();
|
||||
Db.Dispose();
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StubHttp : HttpMessageHandler
|
||||
{
|
||||
public Queue<string> Responses { get; } = new();
|
||||
public int Calls { get; private set; }
|
||||
public Uri? LastUri { get; private set; }
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
Calls++;
|
||||
LastUri = request.RequestUri;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(Responses.Count == 0 ? """{"code":0,"data":{"accessToken":"test-token"}}""" : Responses.Dequeue())
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Modules/Store.Shops/Store.Shops.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FreeSql.Provider.Sqlite" Version="3.5.311" />
|
||||
</ItemGroup>
|
||||
<ItemGroup><Using Include="Xunit" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -12,27 +12,30 @@ namespace Store.Users.Tests;
|
||||
public sealed class DevelopmentTokenTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Development", true, "test-fixed-token", "admin")]
|
||||
[InlineData("Development", true, "incorrect-token", "")]
|
||||
[InlineData("Development", false, "test-fixed-token", "")]
|
||||
[InlineData("Production", true, "test-fixed-token", "")]
|
||||
public async Task FixedToken_OnlyAuthenticatesWhenEnabledInDevelopment(
|
||||
string environment, bool enabled, string token, string expectedUser)
|
||||
[InlineData("Development", true, "test-fixed-token", "test-fixed-token", "admin", "admin")]
|
||||
[InlineData("Development", true, "incorrect-token", "test-fixed-token", "admin", "")]
|
||||
[InlineData("Development", true, "TEST-FIXED-TOKEN", "test-fixed-token", "admin", "")]
|
||||
[InlineData("Development", false, "test-fixed-token", "test-fixed-token", "admin", "")]
|
||||
[InlineData("Production", true, "test-fixed-token", "test-fixed-token", "admin", "")]
|
||||
[InlineData("Staging", true, "test-fixed-token", "test-fixed-token", "admin", "")]
|
||||
[InlineData("Development", true, "test-fixed-token", "", "admin", "")]
|
||||
[InlineData("Development", true, "test-fixed-token", "test-fixed-token", "", "")]
|
||||
public async Task FixedToken_OnlyAuthenticatesWhenConfiguredAndEnabledInDevelopment(
|
||||
string environment, bool enabled, string token, string configuredToken, string userName, string expectedUser)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = environment });
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:DevelopmentToken:Enabled"] = enabled.ToString(),
|
||||
["Jwt:DevelopmentToken:Token"] = "test-fixed-token",
|
||||
["Jwt:DevelopmentToken:UserName"] = "admin"
|
||||
["TokenAuth:DevelopmentToken:Enabled"] = enabled.ToString(),
|
||||
["TokenAuth:DevelopmentToken:Token"] = configuredToken,
|
||||
["TokenAuth:DevelopmentToken:UserName"] = userName
|
||||
});
|
||||
using var db = new FreeSql.FreeSqlBuilder()
|
||||
.UseConnectionString(FreeSql.DataType.Sqlite, "Data Source=:memory:").Build();
|
||||
db.CodeFirst.SyncStructure<OnlineUserInfo>();
|
||||
builder.Services.AddSingleton<IFreeSql>(db);
|
||||
builder.Services.AddStoreHubJwt(builder.Configuration);
|
||||
builder.Services.AddStoreHubTokenAuth(builder.Configuration);
|
||||
await using var app = builder.Build();
|
||||
app.UseAuthentication();
|
||||
app.MapGet("/identity", (ICurrentUser user) =>
|
||||
@@ -42,7 +45,7 @@ public sealed class DevelopmentTokenTests
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", token);
|
||||
|
||||
Assert.Equal(expectedUser, await client.GetStringAsync("/identity"));
|
||||
// 同一凭据不依赖首次请求创建的会话。
|
||||
// 固定测试凭据不创建数据库会话,重复请求的结果保持一致。
|
||||
Assert.Equal(expectedUser, await client.GetStringAsync("/identity"));
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj --filter FullyQuali
|
||||
| `UserFundsTests.cs` | 积分和余额充值、累计金额、审计字段、重复请求、双账户积分转移、向自己转移、超扣、非管理员调整、非法金额/类型、流水故障回滚及原请求重试 |
|
||||
| `UserRecordTests.cs` | 账户隔离、操作类型/店铺/订单/时间组合过滤、时间包含边界、分页排序、排除已删除记录、无效日期范围 |
|
||||
| `UserTopUpTests.cs` | 充值备注身份与可见范围、处理权限、处理备注不自动充值、二维码更新或插入、Data URI、损坏数据、管理员限制 |
|
||||
| `UserHttpTests.cs` | TestServer 真实 MVC 请求,12 个默认 POST 操作拒绝 GET/PUT/DELETE,7 个 GET 操作拒绝 POST;未登录 401、JSON 模型验证、有效 POST 创建、认证身份读取、ApiExplorer 与端点方法一致 |
|
||||
| `UserHttpTests.cs` | TestServer 真实 MVC 请求,12 个写入操作拒绝 GET/PUT/DELETE,7 个查询操作拒绝 GET,所有操作采用 POST;未登录 401、JSON 模型验证、有效 POST 创建、认证身份读取、ApiExplorer 与端点方法一致 |
|
||||
|
||||
使用 xUnit,Theory 每组参数分别统计,失败时输出具体测试名称。测试项目已添加到解决方案的 Tests 分组。
|
||||
|
||||
@@ -33,8 +33,8 @@ dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj --filter FullyQuali
|
||||
|
||||
测试不会读取 `Store.API/appsettings.json`、访问业务 MySQL、调用第三方服务或修改业务数据。每个测试拥有独立数据库,时间过滤采用固定时间,身份通过测试上下文或仅在 TestServer 注册的测试认证方案提供。HTTP 测试接入真实 UnifyResultFilter、GlobalExceptionFilter 及与宿主一致的模型验证响应,验证数据只包装一次,无数据操作、业务异常和参数错误均返回 HTTP 200 的 ApiResult。路由拒绝与测试认证挑战仍保留原始 405/401,此测试宿主未装配全局状态码中间件;测试认证不验证 JWT 签名。
|
||||
|
||||
SQLite 能验证数据持久化和异常回滚,但不能替代 MySQL InnoDB 并发行锁、隔离级别和迁移脚本验证。原有 `UserManagementSmoke` 和 `ShopRenewSmoke` 冒烟入口仍可单独运行。
|
||||
SQLite 能验证数据持久化和异常回滚,但不能替代 MySQL InnoDB 并发行锁、隔离级别和迁移脚本验证。旧版 `UserManagementSmoke` 和 `ShopRenewSmoke` 尚未适配当前接口,本轮验证使用上述 xUnit 项目。
|
||||
|
||||
User DTO 仅声明 Required。RequestValidatorTests 验证非空规则、错误集合和这一模块约定,同时确认公共工具仍支持其他模块的完整 DataAnnotations。UserServiceValidationTests 验证服务层的长度、范围和 ID 判断;原有金额、精度、格式、日期等用例继续通过服务入口验证。HTTP 测试分别验证 DTO 非空拦截与服务手动参数检查,都按系统约定返回 HTTP 200 和 ValidationError。
|
||||
User DTO 使用 DataAnnotations 和 IValidatableObject 声明非空、长度、范围、精度和跨字段规则。RequestValidatorTests 验证这些规则;UserServiceValidationTests 确认内部服务调用同样执行验证,HTTP 测试验证模型错误返回 HTTP 200 和 ValidationError。
|
||||
|
||||
UserAccessTests 覆盖统一管理范围:管理员跨主账号修改/创建子账号/重置密码,主账号修改本人和直属子账号、重置子账号密码、处理子账号充值备注,非授权账户拒绝操作;普通主账号不能接管管理员账户,也不能充值、扣款或改计费。店铺续费通过真实 MediatR 事务管道测试代操作权限、目标账户扣款与实际操作者审计。HTTP 测试覆盖资料修改与密码重置接口。
|
||||
@@ -5,7 +5,7 @@ using Store.Users.Dtos;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
/// <summary>验证公共工具,以及 User DTO 仅保留 Required 的模块约定。</summary>
|
||||
/// <summary>验证服务入口使用的 DTO 注解和跨字段规则。</summary>
|
||||
public sealed class RequestValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -41,48 +41,39 @@ public sealed class RequestValidatorTests
|
||||
public void TryValidate_ReturnsAllMissingRequiredFields()
|
||||
{
|
||||
Assert.False(RequestValidator.TryValidate(new UserFundsRequest(), out var errors));
|
||||
Assert.Equal(3, errors.Count);
|
||||
Assert.Equal(4, errors.Count);
|
||||
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.Amount)));
|
||||
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.UserName)));
|
||||
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.RequestId)));
|
||||
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.Operation)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(UserFundsRequest))]
|
||||
[InlineData(typeof(UserEditRequest))]
|
||||
[InlineData(typeof(UserCreateRequest))]
|
||||
[InlineData(typeof(ChangePasswordRequest))]
|
||||
[InlineData(typeof(UserSearchRequest))]
|
||||
[InlineData(typeof(UserRecordSearchRequest))]
|
||||
[InlineData(typeof(UserTopUpMessageRequest))]
|
||||
[InlineData(typeof(UserTopUpResultRequest))]
|
||||
[InlineData(typeof(UserTopUpQrcodeRequest))]
|
||||
[InlineData(typeof(UserNameRequest))]
|
||||
[InlineData(typeof(UserLoginRequest))]
|
||||
[InlineData(typeof(UserTopUpQrcodeQuery))]
|
||||
[InlineData(typeof(ShopRenewRequest))]
|
||||
public void UserDtos_DeclareOnlyRequiredValidation(Type type)
|
||||
public static TheoryData<object> InvalidRequests => new()
|
||||
{
|
||||
Assert.False(typeof(IValidatableObject).IsAssignableFrom(type));
|
||||
foreach (var property in type.GetProperties())
|
||||
Assert.All(property.GetCustomAttributes(typeof(ValidationAttribute), true), attribute => Assert.IsType<RequiredAttribute>(attribute));
|
||||
}
|
||||
new UserFundsRequest { UserName = new string('u', 21), RequestId = "id", Amount = 1, Operation = "充值" },
|
||||
new UserFundsRequest { UserName = "user", RequestId = "id", Amount = -1, Operation = "充值" },
|
||||
new UserFundsRequest { UserName = "user", RequestId = "id", Amount = 1.001m, Operation = "充值" },
|
||||
new UserTopUpQrcodeRequest { AgentCode = "agent", QrcodeBase64 = "invalid!" },
|
||||
new UserRecordSearchRequest { Page = 0 },
|
||||
new UserRecordSearchRequest { StartTime = new(2025, 2, 1), EndTime = new(2025, 1, 1) },
|
||||
new ShopRenewRequest { ShopId = "shop", RenewDuration = 0 },
|
||||
new ShopRenewRequest { ShopId = "shop", RenewDuration = 121 },
|
||||
new UserCreateRequest { UserName = "user", Password = "123" },
|
||||
new UserCreateRequest { UserName = "user", Password = "password", ShopPointsPerMonth = 1 }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void UserDto_DoesNotCheckNonEmptyParameterValues()
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRequests))]
|
||||
public void InvalidDto_IsRejectedAtValidationEntry(object request)
|
||||
{
|
||||
// 这些值非空但不合法:DTO 应通过,服务负责拒绝。
|
||||
RequestValidator.Validate(new UserFundsRequest { UserName = new string('u', 21), RequestId = "id", Amount = -1, Operation = "非法操作" });
|
||||
RequestValidator.Validate(new UserTopUpQrcodeRequest { AgentCode = "agent", QrcodeBase64 = "invalid!" });
|
||||
RequestValidator.Validate(new UserRecordSearchRequest { Page = 0, StartTime = new(2025, 2, 1), EndTime = new(2025, 1, 1) });
|
||||
RequestValidator.Validate(new ShopRenewRequest { ShopId = "shop", RenewDuration = 0 });
|
||||
RequestValidator.Validate(new UserCreateRequest { UserName = "user", Password = "123", ShopPointsPerMonth = 1 });
|
||||
Assert.Throws<ValidationException>(() => RequestValidator.Validate(request));
|
||||
Assert.False(RequestValidator.TryValidate(request, out var errors));
|
||||
Assert.NotEmpty(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameworkValidator_StillSupportsOtherModulesAnnotationsAndCrossFieldRules()
|
||||
{
|
||||
// User 的约定不限制公共工具支持标准 DataAnnotations 的能力。
|
||||
Assert.Throws<ValidationException>(() => RequestValidator.Validate(new OtherModuleRequest { Name = "toolong" }));
|
||||
Assert.False(RequestValidator.TryValidate(new OtherModuleRequest { Name = "ok", Count = 0 }, out _));
|
||||
var request = new OtherModuleRequest { Name = "ok", Count = 2, Start = 2, End = 1 };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Store.Users.Contracts.Enums;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.DependencyInjection;
|
||||
using MAX.Framework.Mediation;
|
||||
@@ -25,7 +26,7 @@ public sealed class UserAccessTests
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = actor;
|
||||
var target = await context.Read(targetName);
|
||||
await context.Users.Update(target.Id, new() { Notes = "updated" }, subRoute, default);
|
||||
await context.Users.Update(new() { Id = target.Id, Notes = "updated" }, subRoute, default);
|
||||
Assert.Equal("updated", (await context.Read(targetName)).Notes);
|
||||
Assert.Contains(targetName, context.Sessions.SignedOut);
|
||||
}
|
||||
@@ -39,7 +40,7 @@ public sealed class UserAccessTests
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = actor;
|
||||
var target = await context.Read(targetName);
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Update(target.Id, new() { Notes = "bad" }, false, default));
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Update(new() { Id = target.Id, Notes = "bad" }, false, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
Assert.Equal("", (await context.Read(targetName)).Notes);
|
||||
}
|
||||
@@ -65,8 +66,7 @@ public sealed class UserAccessTests
|
||||
UserName = "new-child", Password = "password", ParentUserName = "other"
|
||||
}, true, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(context.Main.Id,
|
||||
new() { SaLevel = 1 }, false, default))).Code);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(new() { Id = context.Main.Id, SaLevel = UserAdminLevel.SuperAdmin }, false, default))).Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -125,8 +125,7 @@ public sealed class UserAccessTests
|
||||
{
|
||||
UserName = privilegedChild.UserName, NewPassword = "takeover-password"
|
||||
}, default))).Code);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(privilegedChild.Id,
|
||||
new() { Notes = "takeover" }, true, default))).Code);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(new() { Id = privilegedChild.Id, Notes = "takeover" }, true, default))).Code);
|
||||
Assert.True(Store.Users.Services.UserPassword.Verify(await context.Read(privilegedChild.UserName), "legacy-password"));
|
||||
}
|
||||
|
||||
@@ -143,8 +142,7 @@ public sealed class UserAccessTests
|
||||
UserName = name, Amount = 10m, Operation = "充值", RequestId = Guid.NewGuid().ToString("N")
|
||||
}, points, default))).Code);
|
||||
var target = await context.Read(name);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(target.Id,
|
||||
new() { ShopPointsPerMonth = 45 }, false, default))).Code);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(new() { Id = target.Id, ShopPointsPerMonth = 45 }, false, default))).Code);
|
||||
}
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(new()
|
||||
{
|
||||
@@ -164,6 +162,8 @@ public sealed class UserAccessTests
|
||||
await context.Db.Update<UserEntity>().Set(u => u.Points, 100m).Where(u => u.Id == context.Child.Id).ExecuteAffrowsAsync();
|
||||
await context.Db.Insert(new ShopEntity { ShopId = "shop", ShopAccount = "shop-account", UserName = "child" }).ExecuteAffrowsAsync();
|
||||
var services = new ServiceCollection();
|
||||
context.CurrentUser.UserName = actor;
|
||||
services.AddSingleton<MAX.Framework.Security.ICurrentUser>(context.CurrentUser);
|
||||
services.AddSingleton(context.Db);
|
||||
services.AddSingleton<UnitOfWorkExecutionContext>();
|
||||
services.AddMediatRAttributedBehaviors(typeof(ShopRenewCommand).Assembly);
|
||||
@@ -171,7 +171,7 @@ public sealed class UserAccessTests
|
||||
typeof(ShopRenewCommand).Assembly.GetType("Store.Users.Commands.ShopRenewCommandHandler", true)!);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var mediator = provider.GetRequiredService<IMediator>();
|
||||
var command = new ShopRenewCommand("child", "shop", 1, OperatorName: actor);
|
||||
var command = new ShopRenewCommand(new() { UserName = "child", ShopId = "shop", RenewDuration = 1 });
|
||||
if (!allowed)
|
||||
{
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => mediator.Send(command))).Code);
|
||||
|
||||
@@ -215,6 +215,8 @@ public sealed class UserHttpTests : IAsyncLifetime
|
||||
[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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Store.Users.Contracts.Enums;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using MAX.Framework.Commons;
|
||||
@@ -55,7 +56,7 @@ public sealed class UserManagementTests
|
||||
var info = await context.Users.Create(new() { UserName = "created", Password = "new-password" }, false, default);
|
||||
var stored = await context.Read("created");
|
||||
Assert.True(info.IsMainUser);
|
||||
Assert.Equal((byte)3, info.GroupId);
|
||||
Assert.Equal(UserGroup.MainAccount, info.GroupId);
|
||||
Assert.Equal(30, info.ShopPointsPerMonth);
|
||||
Assert.Equal(0, info.Points);
|
||||
Assert.Equal(0m, info.Balance);
|
||||
@@ -98,11 +99,11 @@ public sealed class UserManagementTests
|
||||
context.CurrentUser.UserName = "main";
|
||||
var info = await context.Users.Create(new()
|
||||
{
|
||||
UserName = "created", Password = "new-password", GroupId = groupId, Authority = "订单"
|
||||
UserName = "created", Password = "new-password", GroupId = (UserGroup)groupId, Authority = "订单"
|
||||
}, true, default);
|
||||
Assert.Equal("main", info.ParentUserName);
|
||||
Assert.Equal(groupId, info.GroupId);
|
||||
Assert.Equal((short)0, info.SaLevel);
|
||||
Assert.Equal((UserGroup)groupId, info.GroupId);
|
||||
Assert.Equal(UserAdminLevel.Normal, info.SaLevel);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -114,7 +115,7 @@ public sealed class UserManagementTests
|
||||
context.CurrentUser.UserName = "main";
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Create(new()
|
||||
{
|
||||
UserName = "created", Password = "new-password", GroupId = groupId
|
||||
UserName = "created", Password = "new-password", GroupId = (UserGroup)groupId
|
||||
}, true, default));
|
||||
Assert.False(await context.Db.Select<UserEntity>().Where(u => u.UserName == "created").AnyAsync());
|
||||
}
|
||||
@@ -126,9 +127,9 @@ public sealed class UserManagementTests
|
||||
context.CurrentUser.UserName = "main";
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Create(new()
|
||||
{
|
||||
UserName = "created", Password = "new-password", GroupId = 3
|
||||
UserName = "created", Password = "new-password", GroupId = UserGroup.MainAccount
|
||||
}, true, default));
|
||||
Assert.Contains("子账号分组", error.Message);
|
||||
Assert.Contains("子账号不能使用主号分组", error.Message);
|
||||
}
|
||||
[Fact]
|
||||
public async Task ChildAccount_CannotCreateAnotherChild()
|
||||
@@ -145,15 +146,16 @@ public sealed class UserManagementTests
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
foreach (var request in new UserEditRequest[]
|
||||
foreach (var request in new UserUpdateRequest[]
|
||||
{
|
||||
new() { Authority = "不存在的权限" }, new() { SaLevel = 1 }, new() { ShopPointsPerMonth = 30 }
|
||||
new() { Authority = "不存在的权限" }, new() { SaLevel = UserAdminLevel.SuperAdmin }, new() { ShopPointsPerMonth = 30 }
|
||||
})
|
||||
{
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Update(context.Child.Id, request, true, default));
|
||||
request.Id = context.Child.Id;
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Update(request, true, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
}
|
||||
Assert.Equal((short)0, (await context.Read("child")).SaLevel);
|
||||
Assert.Equal(UserAdminLevel.Normal, (await context.Read("child")).SaLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -162,7 +164,7 @@ public sealed class UserManagementTests
|
||||
using var context = new UserTestContext();
|
||||
await context.Db.Update<UserEntity>().Set(u => u.Notes, "old note").Set(u => u.Mobile, "13800000000")
|
||||
.Set(u => u.Points, 42m).Set(u => u.Balance, 11m).Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
|
||||
var info = await context.Users.Update(context.Main.Id, new() { Notes = "", NickName = " New Name " }, false, default);
|
||||
var info = await context.Users.Update(new() { Id = context.Main.Id, Notes = "", NickName = " New Name " }, false, default);
|
||||
Assert.Equal("", info.Notes);
|
||||
Assert.Equal("13800000000", info.Mobile);
|
||||
Assert.Equal("New Name", info.NickName);
|
||||
@@ -197,7 +199,7 @@ public sealed class UserManagementTests
|
||||
context.Seed("child-two", parent: "main");
|
||||
context.Seed("foreign-child", parent: "other");
|
||||
context.CurrentUser.UserName = "main";
|
||||
var page = await context.Users.Search(new() { Keyword = "child", GroupId = 4, PageSize = 1 }, true, default);
|
||||
var page = await context.Users.Search(new() { Keyword = "child", GroupId = UserGroup.CustomerService, PageSize = 1 }, true, default);
|
||||
Assert.Equal(2, page.Total);
|
||||
Assert.Equal("child-two", Assert.Single(page.Items).UserName);
|
||||
var second = await context.Users.Search(new() { Page = 2, PageSize = 1 }, true, default);
|
||||
@@ -269,8 +271,16 @@ public sealed class UserManagementTests
|
||||
Assert.Contains("other", context.Sessions.SignedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyOwnAccount_CanBeDeleted_AndSessionIsInvalidated()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Users.Delete(context.Admin.Id, default);
|
||||
Assert.Null(await context.Read("admin"));
|
||||
Assert.Contains("admin", context.Sessions.SignedOut);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("self")]
|
||||
[InlineData("points")]
|
||||
[InlineData("balance")]
|
||||
[InlineData("children")]
|
||||
@@ -279,7 +289,6 @@ public sealed class UserManagementTests
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var target = context.Other;
|
||||
if (condition == "self") target = context.Admin;
|
||||
if (condition == "children") target = context.Main;
|
||||
if (condition == "points") await context.Db.Update<UserEntity>().Set(u => u.Points, 1m).Where(u => u.Id == target.Id).ExecuteAffrowsAsync();
|
||||
if (condition == "balance") await context.Db.Update<UserEntity>().Set(u => u.Balance, 1m).Where(u => u.Id == target.Id).ExecuteAffrowsAsync();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Store.Users.Contracts.Enums;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
/// <summary>确认从 DTO 移出的长度和范围规则仍在服务写入前生效。</summary>
|
||||
/// <summary>确认服务入口执行 DTO 的长度和范围校验,拒绝无效写入。</summary>
|
||||
public sealed class UserServiceValidationTests
|
||||
{
|
||||
[Theory]
|
||||
@@ -13,7 +14,7 @@ public sealed class UserServiceValidationTests
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Delete(id, default));
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Update(id, new(), false, default));
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Update(new() { Id = id }, false, default));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -35,7 +36,7 @@ public sealed class UserServiceValidationTests
|
||||
public async Task InvalidEdit_IsRejectedBeforePersistence(string field)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var request = new UserEditRequest();
|
||||
var request = new UserUpdateRequest { Id = context.Main.Id };
|
||||
switch (field)
|
||||
{
|
||||
case "nickname": request.NickName = new string('x', 51); break;
|
||||
@@ -47,14 +48,14 @@ public sealed class UserServiceValidationTests
|
||||
case "forbidden": request.ForbiddenPage = new string('x', 1001); break;
|
||||
case "oem": request.OemId = new string('x', 51); break;
|
||||
case "group": request.GroupId = 0; break;
|
||||
case "level": request.SaLevel = 2; break;
|
||||
case "level": request.SaLevel = (UserAdminLevel)2; break;
|
||||
case "monthly": request.ShopPointsPerMonth = 29; break;
|
||||
case "postage": request.PostagePerOrder = 2.1m; break;
|
||||
case "refund": request.RefundPostagePerOrder = 2.4m; break;
|
||||
case "gift": request.GiftOemAmountPerOrder = -1m; break;
|
||||
case "logistics": request.SyncLogisticsPerOrder = -1m; break;
|
||||
}
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Update(context.Main.Id, request, false, default));
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Update(request, false, default));
|
||||
Assert.Empty(context.Sessions.SignedOut);
|
||||
Assert.Equal("main", (await context.Read("main")).NickName);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Store.Users.Contracts.Enums;
|
||||
using FreeSql;
|
||||
using MAX.Framework.Security;
|
||||
using Store.Users.Contracts;
|
||||
@@ -64,7 +65,7 @@ internal sealed class UserTestContext : IDisposable
|
||||
var user = new UserEntity
|
||||
{
|
||||
UserName = name, NickName = name, Password = "legacy-password", ParentUserName = parent,
|
||||
GroupId = string.IsNullOrEmpty(parent) ? (byte)3 : (byte)4, SaLevel = level,
|
||||
GroupId = string.IsNullOrEmpty(parent) ? UserGroup.MainAccount : UserGroup.CustomerService, SaLevel = (UserAdminLevel)level,
|
||||
Mobile = "", CompanyName = "", Notes = "", CardId = "", Authority = "积分用户,订单",
|
||||
ForbiddenPage = "", OemId = "", Balance = 0, Points = 0, ShopPointsPerMonth = 30,
|
||||
PostagePerOrder = 1m, RefundPostagePerOrder = 1m
|
||||
|
||||
Reference in new issue
Block a user