167 lines
12 KiB
C#
167 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));
|
|
await Reject(() => users.Search(new(), false, default));
|
|
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 = "other", 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 == other.Id).FirstAsync()).Points == 30, "转入错误");
|
|
await Reject(() => funds.Change(new() { UserName = "other", 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 = "other", 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 == other.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);
|
|
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 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;
|
|
}
|