Files
StoreWeb/Tests/UserManagementSmoke/Program.cs
T
2026-10-23 19:28:17 +08:00

169 lines
12 KiB
C#

using System.ComponentModel.DataAnnotations;
using System.Reflection;
using System.Security.Claims;
using System.Text.Json;
using FreeSql;
using MAX.Framework.Commons;
using MAX.Framework.Security;
using Store.Users.Contracts;
using Store.Users.Contracts.Dtos;
using Store.Users.Entities;
using Store.Users.Services;
using YourNamespace;
UserRoutingChecks.Run();
// 独立临时 SQLite 数据库,绝不连接开发或生产数据库。
var path = Path.Combine(Path.GetTempPath(), $"store-users-{Guid.NewGuid():N}.db");
try
{
using var db = new FreeSqlBuilder().UseMappingPriority(FreeSql.Internal.MappingPriorityType.Attribute, FreeSql.Internal.MappingPriorityType.Aop, FreeSql.Internal.MappingPriorityType.FluentApi).UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path}").Build();
db.CodeFirst.ConfigEntity<UserEntity>(e => e.Name("users"));
db.CodeFirst.ConfigEntity<UserPointsRecordEntity>(e => e.Name("points"));
db.CodeFirst.ConfigEntity<UserPaymentRecordEntity>(e => e.Name("payments"));
db.CodeFirst.ConfigEntity<ShopEntity>(e => e.Name("shops"));
db.CodeFirst.ConfigEntity<OEMEntity>(e => e.Name("oems"));
db.CodeFirst.ConfigEntity<UserTopUpMessageEntity>(e => e.Name("messages"));
db.CodeFirst.ConfigEntity<UserTopUpQrcodeEntity>(e => e.Name("qrcodes"));
db.CodeFirst.SyncStructure(typeof(UserEntity), typeof(UserPointsRecordEntity), typeof(UserPaymentRecordEntity), typeof(ShopEntity), typeof(OEMEntity), typeof(UserTopUpMessageEntity), typeof(UserTopUpQrcodeEntity));
var admin = Seed("admin", 1);
var main = Seed("main", 0);
var other = Seed("other", 0);
var actor = new Actor { UserName = admin.UserName };
var sessions = new Sessions();
var repositoryType = typeof(UserManagementService).Assembly.GetType("Store.Users.Repositories.UserRepository")!;
var repository = Activator.CreateInstance(repositoryType, db)!;
var queryType = typeof(UserManagementService).Assembly.GetType("Store.Users.Services.UserQueryService")!;
var query = (IUserQueryService)Activator.CreateInstance(queryType, repository)!;
var authType = typeof(UserManagementService).Assembly.GetType("Store.Users.Services.UserAuthenticationService")!;
var auth = (IUserAuthenticationService)Activator.CreateInstance(authType, db, query)!;
var users = new UserManagementService(db, actor, query, sessions);
var funds = new UserFundsService(db, actor);
Assert(await auth.Authenticate("main", "wrong") == null, "错误密码被接受");
Assert(await auth.Authenticate(" main ", "legacy-password") != null, "旧密码登录失败");
var stored = await db.Select<UserEntity>().Where(u => u.Id == main.Id).FirstAsync();
Assert(stored.Password == "legacy-password" && UserPassword.Verify(stored, "legacy-password"), "登录不应修改已有密码");
Assert(await auth.Authenticate("main", "legacy-password") != null, "重复登录失败");
var created = await users.Create(new() { UserName = "newmain", Password = "new-password" }, false, default);
Assert(created.GroupId == 3 && created.ShopPointsPerMonth == 30 && created.Points == 0, "主账号默认值错误");
Assert(!JsonSerializer.Serialize(created).Contains("Password", StringComparison.OrdinalIgnoreCase), "响应包含密码字段");
actor.UserName = main.UserName;
var sub = await users.Create(new() { UserName = "child", Password = "child-password", Authority = "积分用户" }, true, default);
Assert(sub.ParentUserName == main.UserName && sub.OemId == main.OemId, "子账号归属或OEM继承错误");
Assert(sub.PostagePerOrder == 2.2m && sub.RefundPostagePerOrder == 2.5m, "继承费用下限错误");
await Reject(() => users.Create(new() { UserName = "bad", Password = "password", Authority = "超级权限" }, true, default));
await Reject(() => users.Create(new() { UserName = "bad-admin", Password = "password", SaLevel = 1 }, true, default));
await Reject(() => users.Update(sub.Id, new() { ShopPointsPerMonth = 30 }, true, default));
await Reject(() => users.GetUser(other.UserName, default));
Assert((await users.Search(new(), false, default)).Total == 2, "主账号列表范围错误");
await Reject(() => users.Update(other.Id, new() { Notes = "tampered" }, true, default));
await users.Update(sub.Id, new() { Notes = "" }, true, default);
Assert((await users.Search(new(), true, default)).Total == 1, "子账号查询范围错误");
await users.ChangePassword(new() { OldPassword = "legacy-password", NewPassword = "changed-password" }, default);
Assert(sessions.Names.Contains("main"), "修改密码未注销会话");
Assert(await auth.Authenticate("main", "legacy-password") == null, "旧密码仍能登录");
Assert(await auth.Authenticate("main", "changed-password") != null, "新密码登录失败");
await Reject(() => funds.Change(new() { UserName = "other", Amount = 1, Operation = "充值", RequestId = "forbidden" }, true, default));
actor.UserName = "admin";
var recharge = new UserFundsRequest { UserName = "main", Amount = 100, Operation = "充值", RequestId = "points-1" };
await funds.Change(recharge, true, default);
await Reject(() => funds.Change(recharge, true, default));
Assert((await db.Select<UserEntity>().Where(u => u.Id == main.Id).FirstAsync()).Points == 100, "重复请求造成重复充值");
actor.UserName = "main";
await funds.Change(new() { UserName = "child", Amount = 30, Operation = "转移", RequestId = "transfer-1" }, true, default);
Assert((await db.Select<UserEntity>().Where(u => u.Id == main.Id).FirstAsync()).Points == 70, "转出错误");
Assert((await db.Select<UserEntity>().Where(u => u.Id == sub.Id).FirstAsync()).Points == 30, "转入错误");
await Reject(() => funds.Change(new() { UserName = "child", Amount = 1000, Operation = "转移", RequestId = "overdraft" }, true, default));
Assert(await db.Select<UserPointsRecordEntity>().Where(r => r.OutTradeNo == "user:overdraft").CountAsync() == 0, "失败转账产生流水");
actor.UserName = "admin";
await funds.Change(new() { UserName = "main", Amount = 25.5m, Operation = "充值", RequestId = "balance-1" }, false, default);
await funds.Change(new() { UserName = "main", Amount = 5m, Operation = "消费", RequestId = "balance-2" }, false, default);
Assert((await db.Select<UserEntity>().Where(u => u.Id == main.Id).FirstAsync()).Balance == 20.5m, "余额变更错误");
await Reject(() => users.Delete(main.Id, default));
await Reject(() => users.Delete(admin.Id, default));
await users.Delete(created.Id, default);
Assert(!await db.Select<UserEntity>().Where(u => u.Id == created.Id).AnyAsync(), "空账户删除失败");
await Reject(() => users.Search(new() { PageSize = 201 }, false, default));
await Reject(() => funds.Change(new() { UserName = "main", Amount = 0.001m, Operation = "充值", RequestId = "precision" }, false, default));
Assert((await users.Points("main", new() { Operation = "充值" }, default)).Total == 1, "积分流水过滤错误");
Assert((await users.Balance("main", new() { PaymentType = 1 }, default)).Total == 1, "余额流水过滤错误");
await Reject(() => users.Points("main", new() { StartTime = DateTime.Today, EndTime = DateTime.Today.AddDays(-1) }, default));
using (var cancelled = new CancellationTokenSource())
{
cancelled.Cancel();
bool cancellationObserved = false;
try { await auth.Authenticate("main", "changed-password", cancelled.Token); }
catch (OperationCanceledException) { cancellationObserved = true; }
Assert(cancellationObserved, "认证未响应取消请求");
}
// 强制第二条转账流水写入失败,验证已更新的双方积分和第一条流水一起回滚。
actor.UserName = "main";
await db.Ado.ExecuteNonQueryAsync("CREATE TRIGGER reject_credit BEFORE INSERT ON points WHEN NEW.option_type = '转入' BEGIN SELECT RAISE(ABORT, 'test rollback'); END");
bool failed = false;
try { await funds.Change(new() { UserName = "child", Amount = 10, Operation = "转移", RequestId = "rollback" }, true, default); }
catch (Exception ex) when (ex.Message.Contains("test rollback")) { failed = true; }
Assert(failed, "测试故障未触发");
Assert((await db.Select<UserEntity>().Where(u => u.Id == main.Id).FirstAsync()).Points == 70, "转账失败未回滚转出账户");
Assert((await db.Select<UserEntity>().Where(u => u.Id == sub.Id).FirstAsync()).Points == 30, "转账失败未回滚转入账户");
Assert(await db.Select<UserPointsRecordEntity>().Where(r => r.OutTradeNo == "user:rollback").CountAsync() == 0, "转账失败未回滚流水");
await db.Ado.ExecuteNonQueryAsync("DROP TRIGGER reject_credit");
var messageId = await users.AddTopUpMessage(new() { Message = "银行充值待核实" }, default);
actor.UserName = "other";
await Reject(() => users.UpdateTopUpMessage(new() { Id = messageId, Result = "已处理" }, default));
actor.UserName = "other";
Assert((await users.TopUpMessages(new(), default)).Total == 0, "充值备注越权泄漏");
actor.UserName = "admin";
await users.UpdateTopUpMessage(new() { Id = messageId, Result = "已处理" }, default);
Assert((await users.TopUpMessages(new(), default)).Items.Single().Result == "已处理", "备注处理结果未保存");
await users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "aGVsbG8=" }, default);
await users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "d29ybGQ=" }, default);
Assert(await users.GetTopUpQrcode("agent", default) == "d29ybGQ=", "二维码更新失败");
Assert(await db.Select<UserTopUpQrcodeEntity>().CountAsync() == 1, "二维码产生重复编码");
await Reject(() => users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "invalid!" }, default));
actor.UserName = "child";
await Reject(() => users.Create(new() { UserName = "grandchild", Password = "password" }, true, default));
actor.UserName = "admin";
var shopOwner = Seed("shop-owner", 0);
await db.Insert(new ShopEntity { UserName = shopOwner.UserName, ShopId = "test-shop" }).ExecuteAffrowsAsync();
await Reject(() => users.Delete(shopOwner.Id, default));
Console.WriteLine("PASS: 用户认证、登录不修改密码、权限隔离、主子账号、计费继承、资金事务、重复请求、超扣回滚、删除限制、参数边界。");
UserEntity Seed(string name, short level)
{
var u = new UserEntity { UserName = name, NickName = name, Password = "legacy-password", ParentUserName = "", GroupId = 3,
Mobile = "", CompanyName = "", Notes = "", CardId = "", Authority = "积分用户", ForbiddenPage = "", OemId = "",
SaLevel = level, Balance = 0, Points = 0, ShopPointsPerMonth = 30, PostagePerOrder = 1, RefundPostagePerOrder = 1 };
u.Id = (int)db.Insert(u).ExecuteIdentity();
return u;
}
}
finally { if (File.Exists(path)) File.Delete(path); }
static void Assert(bool condition, string message) { if (!condition) throw new Exception(message); }
static async Task Reject(Func<Task> action)
{
try { await action(); }
catch (AppException) { return; }
catch (ValidationException) { return; }
throw new Exception("操作应被拒绝");
}
sealed class Actor : ICurrentUser
{
public bool IsAuthenticated => true;
public string? UserName { get; set; }
public string? NickName => UserName;
public IEnumerable<Claim> Claims => [];
public string? GetClaimValue(string type) => null;
public T? GetClaimValue<T>(string type) => default;
}
sealed class Sessions : IOnlineUserStore
{
public HashSet<string> Names { get; } = [];
public void SignOutSession(string name, string jti) => Names.Add(name);
public void SignOut(string name) => Names.Add(name);
public void SignIn(OnlineUserInfo info) { }
public bool IsTokenActive(string name, string jti) => false;
public IReadOnlyList<OnlineUserInfo> GetOnlineUsers() => [];
public int Count => 0;
}