using System.Text.Json;
namespace WpywMail.Native;
///
/// 账号体系自检:注册(含验证码)、登录失败锁定、密码重置、会话管理、资料与审计。
///
/// 这些用例**不经过 HTTP**,直接打 AccountService + 存储层,所以跑得快、
/// 也能覆盖「两套存储后端行为一致」这一点(json 与 sqlite 各跑一遍)。
/// 验证码是随机的、只能从邮件正文里拿;这里为了可测,直接从存储里读回
/// 哈希校验逻辑无法反推明文 —— 所以改为**注入式**:测试里用一个已知码替换哈希。
///
public static partial class SelfTest
{
private static void TestAccounts()
{
foreach (var provider in new[] { "json", "sqlite" })
{
var config = new AppConfig
{
Domain = "wpy.email",
Hostname = "mail.example.com",
AdminEmail = "accounts@wpy.email",
AdminPassword = "selftest-password-1234",
DataDirectory = Path.Combine(Path.GetTempPath(), $"wpyw-acct-{provider}-{Guid.NewGuid().ToString("N")[..8]}"),
Dkim = new DkimConfig { Enabled = false },
Storage = new StorageConfig { Provider = provider },
};
Directory.CreateDirectory(config.DataDirectory);
IMailStore? store = null;
try
{
config.Accounts = new AccountsConfig
{
Registration = "invite",
InviteCode = "TEST-INVITE",
// 两个域各司其职:wpy.email 由本机托管(免验证路径),mail.example.net 托管在别处(走验证码路径)
AllowedDomains = ["wpy.email", "mail.example.net"],
RequireEmailVerification = true,
MinPasswordLength = 12,
CodeMinutes = 30,
MaxCodeAttempts = 3,
MaxLoginFailures = 3,
LockoutMinutes = 15,
RegisterPerHourPerIp = 5,
ResendPerHourPerEmail = 5,
};
store = Program.CreateStore(config);
var accounts = new AccountService(config, store);
// ---- 密码策略
Check($"[{provider}] 密码太短被拒", !accounts.CheckPassword("short", "a@wpy.email").Ok, "");
Check($"[{provider}] 纯数字密码被拒", !accounts.CheckPassword("123456789012", "a@wpy.email").Ok, "");
Check($"[{provider}] 与邮箱相同的密码被拒", !accounts.CheckPassword("a@wpy.email", "a@wpy.email").Ok, "");
Check($"[{provider}] 合格密码被接受", accounts.CheckPassword("Str0ng-Pass-2026", "a@wpy.email").Ok, "");
// ---- 域名白名单
Check($"[{provider}] 域名校验:允许 wpy.email",
accounts.Register(new RegisterRequest("new1@wpy.email", "Str0ng-Pass-2026", "新人", "TEST-INVITE"), "10.0.0.1", "test").Ok, "");
var badDomain = accounts.Register(new RegisterRequest("someone@example.com", "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.2", "test");
Check($"[{provider}] 域名校验:拒绝外部域", !badDomain.Ok && badDomain.Status == 400, badDomain.Error);
// ---- 邀请码
var badInvite = accounts.Register(new RegisterRequest("new2@wpy.email", "Str0ng-Pass-2026", "", "WRONG"), "10.0.0.3", "test");
Check($"[{provider}] 邀请码错误被拒", !badInvite.Ok && badInvite.Status == 403, badInvite.Error);
// ---- 弱密码
var weak = accounts.Register(new RegisterRequest("new3@wpy.email", "123", "", "TEST-INVITE"), "10.0.0.4", "test");
Check($"[{provider}] 注册时弱密码被拒", !weak.Ok && weak.Status == 400, weak.Error);
// ---- 【设计决定】本机托管的地址必须免邮箱验证直接开通
// 验证码邮件只能投进「这个」信箱,而它在验证通过前登录不了(IMAP/Webmail/API 全进不去)
// → 要验证就是死循环。这类地址的授权凭据是管理员发放的邀请码。
var hostedEmail = $"hosted{provider}@wpy.email";
var hostedReg = accounts.Register(new RegisterRequest(hostedEmail, "Str0ng-Pass-2026", "本机地址", "TEST-INVITE"), "10.0.0.21", "test");
Check($"[{provider}] 本机托管地址免邮箱验证直接开通",
hostedReg.Ok && !hostedReg.VerificationRequired, hostedReg.Error);
Check($"[{provider}] 本机托管地址开通后立即可登录",
store.Authenticate(hostedEmail, "Str0ng-Pass-2026") is not null, "");
Check($"[{provider}] 免验证注册不会残留验证码记录",
store.FindVerificationCode(hostedEmail, "register") is null, "");
Check($"[{provider}] 托管判定:本机域=true / 外部域=false",
accounts.IsHostedHere("a@wpy.email") && !accounts.IsHostedHere("a@mail.example.net"), "");
// ---- 注册 → 验证码 → 激活(外部托管的邮箱:验证码才有意义)
var email = $"reg{provider}@mail.example.net";
var register = accounts.Register(new RegisterRequest(email, "Str0ng-Pass-2026", "注册用户", "TEST-INVITE"), "10.0.0.5", "test");
Check($"[{provider}] 注册返回需要邮箱验证", register.Ok && register.VerificationRequired, register.Error);
// ★ 关键回归:曾经写成「验证通过才建号」,结果本地投递看不到收件人、验证码邮件根本送不到。
// 正确做法是注册时就把信箱行建出来并置为未激活。
var pending = store.FindUserAnyState(email);
Check($"[{provider}] 注册后立即建号且处于未激活状态", pending is { Active: false },
pending is null ? "(无用户行)" : $"active={pending.Active}");
Check($"[{provider}] 未激活的信箱对本地投递可见", store.IsLocalAddress(email), "");
Check($"[{provider}] 未激活期间任何密码都不能登录(凭据只存在验证码记录里)",
store.Authenticate(email, "Str0ng-Pass-2026") is null, "");
var code = ReadCodeFromOutbox(store, email);
Check($"[{provider}] 验证码邮件已入队且能取出 6 位码", code is { Length: 6 }, code ?? "(空)");
// ★ 端到端:这封验证码邮件必须真的能投进那个「未激活」信箱。
// 原设计(验证通过才建号)就是死在这里 —— 本地投递找不到收件人,用户永远收不到码。
var codeRaw = ReadQueuedRaw(store, email);
var landed = codeRaw is null ? null : store.DeliverLocal(email, codeRaw, "postmaster@wpy.email");
Check($"[{provider}] 验证码邮件真的投进了未激活信箱",
landed is not null && landed.OwnerEmail.Equals(email, StringComparison.OrdinalIgnoreCase),
landed is null ? "本地投递返回 null(收件人不可见)" : $"owner={landed.OwnerEmail} folder={landed.Folder} 未读={landed.Unread}");
var wrong = accounts.VerifyRegistration(new VerifyCodeRequest(email, "000000"), "10.0.0.5", "test");
Check($"[{provider}] 错误验证码被拒", !wrong.Ok, wrong.Error);
var verified = accounts.VerifyRegistration(new VerifyCodeRequest(email, code!), "10.0.0.5", "test");
Check($"[{provider}] 正确验证码激活账号", verified.Ok && verified.Session is not null, verified.Error);
var created = store.FindUser(email);
Check($"[{provider}] 激活后用户存在且可认证",
created is not null && store.Authenticate(email, "Str0ng-Pass-2026") is not null, "");
Check($"[{provider}] 激活后验证码被清除", store.FindVerificationCode(email, "register") is null, "");
var reuse = accounts.VerifyRegistration(new VerifyCodeRequest(email, code!), "10.0.0.5", "test");
Check($"[{provider}] 验证码不能用第二次", !reuse.Ok, reuse.Error);
// ---- 未完成验证的注册可以重来;抢注者无法凭自己提交的密码进去
var pendEmail = $"pending{provider}@mail.example.net";
accounts.Register(new RegisterRequest(pendEmail, "First-Pass-2026", "先注册", "TEST-INVITE"), "10.0.0.7", "test");
var again = accounts.Register(new RegisterRequest(pendEmail, "Second-Pass-2026", "后注册", "TEST-INVITE"), "10.0.0.8", "test");
Check($"[{provider}] 未激活的注册允许重来(不返回 409)", again.Ok && again.VerificationRequired, again.Error);
Check($"[{provider}] 重来期间两个密码都不能登录(未激活就没有可用凭据)",
store.Authenticate(pendEmail, "First-Pass-2026") is null && store.Authenticate(pendEmail, "Second-Pass-2026") is null, "");
var pendCode = ReadCodeFromOutbox(store, pendEmail);
var pendVerified = accounts.VerifyRegistration(new VerifyCodeRequest(pendEmail, pendCode!), "10.0.0.8", "test");
Check($"[{provider}] 验证后生效的是读得到验证码那一方提交的密码",
pendVerified.Ok
&& store.Authenticate(pendEmail, "Second-Pass-2026") is not null
&& store.Authenticate(pendEmail, "First-Pass-2026") is null, pendVerified.Error);
// ---- 登录失败时的措辞:卡在验证的人要被告知出路;被停用的人不能被探测出来
var pendHint = accounts.LoginFailureHint(pendEmail);
Check($"[{provider}] 已激活账号的失败提示是通用 401",
pendHint.Status == 401 && !pendHint.PendingVerification, pendHint.AuditReason);
var hintPending = accounts.LoginFailureHint($"pending2{provider}@mail.example.net");
Check($"[{provider}] 不存在的账号失败提示是通用 401(不暴露存在性)",
hintPending.Status == 401 && hintPending.AuditReason == "unknown user", hintPending.AuditReason);
accounts.Register(new RegisterRequest($"pending2{provider}@mail.example.net", "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.13", "test");
var hintWait = accounts.LoginFailureHint($"pending2{provider}@mail.example.net");
Check($"[{provider}] 卡在邮箱验证的账号会被告知出路(403)",
hintWait.Status == 403 && hintWait.PendingVerification, $"{hintWait.Status} {hintWait.AuditReason}");
// ---- 管理员停用是权威状态:不能靠重新注册翻回来
var disabled = $"disabled{provider}@wpy.email";
store.CreateUser(disabled, "Str0ng-Pass-2026", "停用测试");
store.SetLastLogin(disabled);
store.SetUserActive(disabled, false);
var reReg = accounts.Register(new RegisterRequest(disabled, "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.14", "test");
Check($"[{provider}] 被停用的账号不能靠重新注册复活(403)",
!reReg.Ok && reReg.Status == 403, $"{reReg.Status} {reReg.Error}");
Check($"[{provider}] 被停用的账号登录提示不暴露状态(401)",
accounts.LoginFailureHint(disabled).Status == 401, "");
Check($"[{provider}] 停用状态没有被注册流程改掉", store.FindUser(disabled) is null, "");
// ---- 重复注册(已激活的账号必须被挡)
var dup = accounts.Register(new RegisterRequest(email, "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.6", "test");
Check($"[{provider}] 已激活账号重复注册被拒(409)", !dup.Ok && dup.Status == 409, dup.Error);
// ---- 登录失败锁定
var lockedEmail = "lock@wpy.email";
store.CreateUser(lockedEmail, "Str0ng-Pass-2026", "锁定测试");
for (var i = 0; i < config.Accounts.MaxLoginFailures; i++)
accounts.Record(lockedEmail, "10.0.0.9", "login-failed", false, "test");
Check($"[{provider}] 连续失败后进入锁定", accounts.LockRemainingSeconds(lockedEmail) > 0,
$"剩余 {accounts.LockRemainingSeconds(lockedEmail)}s");
var fresh = "fresh@wpy.email";
store.CreateUser(fresh, "Str0ng-Pass-2026", "未锁定");
Check($"[{provider}] 未失败过的账号不锁定", accounts.LockRemainingSeconds(fresh) == 0, "");
// ---- 审计可查
var events = store.ListAuthEvents(lockedEmail, null, "login-failed", 10);
Check($"[{provider}] 审计记录了登录失败", events.Count == config.Accounts.MaxLoginFailures, $"{events.Count} 条");
Check($"[{provider}] 按 IP 统计失败次数", store.CountAuthEvents(null, "10.0.0.9", "login-failed", false, 60) == config.Accounts.MaxLoginFailures, "");
// ---- 会话管理
var sessions = store.ListSessions(fresh);
Check($"[{provider}] 新建用户初始无会话", sessions.Count == 0, $"{sessions.Count} 个");
var s1 = store.CreateSession(fresh, 30);
var s2 = store.CreateSession(fresh, 30);
var s3 = store.CreateSession(fresh, 30);
Check($"[{provider}] 三次登录产生三个会话", store.ListSessions(fresh).Count == 3, "");
var revoked = store.RemoveSessions(fresh, s2.Token);
Check($"[{provider}] 退出其他设备保留当前(吊销 2 个)", revoked == 2 && store.ListSessions(fresh).Count == 1, $"吊销 {revoked} 个");
Check($"[{provider}] 保留的正是当前 token", store.GetSession(s2.Token) is not null && store.GetSession(s1.Token) is null, "");
var all = store.RemoveSessions(fresh, null);
Check($"[{provider}] 全部吊销", all == 1 && store.ListSessions(fresh).Count == 0, $"吊销 {all} 个");
Check($"[{provider}] 会话视图标记 current", sessions.Count == 0, "");
// ---- 资料
accounts.UpdateProfile(fresh, "新名字");
var renamed = store.FindUser(fresh);
Check($"[{provider}] 显示名更新生效", renamed?.DisplayName == "新名字", renamed?.DisplayName ?? "(null)");
// ---- 密码重置
var resetEmail = "reset@wpy.email";
store.CreateUser(resetEmail, "Str0ng-Pass-2026", "重置测试");
var keep = store.CreateSession(resetEmail, 30);
var (requested, reqError) = accounts.RequestReset(resetEmail, "10.0.0.10");
Check($"[{provider}] 申请重置成功", requested, reqError);
var resetCode = ReadCodeFromOutbox(store, resetEmail);
Check($"[{provider}] 重置验证码邮件已入队", resetCode is { Length: 6 }, resetCode ?? "(空)");
var weakReset = accounts.ResetPassword(new ResetPasswordRequest(resetEmail, resetCode!, "123"), "10.0.0.10");
Check($"[{provider}] 重置时弱密码被拒", !weakReset.Ok && weakReset.Status == 400, weakReset.Error);
var badCode = accounts.ResetPassword(new ResetPasswordRequest(resetEmail, "999999", "New-Str0ng-2026"), "10.0.0.10");
Check($"[{provider}] 重置时错误验证码被拒", !badCode.Ok, badCode.Error);
var good = accounts.ResetPassword(new ResetPasswordRequest(resetEmail, resetCode!, "New-Str0ng-2026"), "10.0.0.10");
Check($"[{provider}] 正确验证码重置成功", good.Ok, good.Error);
Check($"[{provider}] 新密码可用", store.Authenticate(resetEmail, "New-Str0ng-2026") is not null, "");
Check($"[{provider}] 旧密码失效", store.Authenticate(resetEmail, "Str0ng-Pass-2026") is null, "");
Check($"[{provider}] 重置后旧会话被吊销", store.GetSession(keep.Token) is null, "");
// ---- 不暴露账号是否存在
var unknown = accounts.RequestReset("nobody@wpy.email", "10.0.0.11");
Check($"[{provider}] 对不存在的邮箱申请重置也返回成功(防枚举)", unknown.Ok, unknown.Error);
// ---- 关闭注册
config.Accounts.Registration = "closed";
var closed = accounts.Register(new RegisterRequest("closed@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), "10.0.0.12", "test");
Check($"[{provider}] 关闭注册后拒绝(403)", !closed.Ok && closed.Status == 403, closed.Error);
// ---- 限流
config.Accounts.Registration = "invite";
config.Accounts.RegisterPerHourPerIp = 2;
// ★ 真机验收抓到的坑:失败尝试(填错邀请码 / 密码太短)不能吃掉严格配额,
// 否则正常用户表单填错几次就被挡一小时,NAT 下还会连累同 IP 的其他人。
var failIp = "10.9.9.10";
for (var i = 0; i < 6; i++)
accounts.Register(new RegisterRequest($"quota{i}-{provider}@wpy.email", "123", "", "TEST-INVITE"), failIp, "test");
var quota1 = accounts.Register(new RegisterRequest($"quota-a-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), failIp, "test");
var quota2 = accounts.Register(new RegisterRequest($"quota-b-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), failIp, "test");
Check($"[{provider}] 表单填错 6 次后仍能正常建号(失败不吃严格配额)",
quota1.Ok && quota2.Ok, $"{quota1.Status}/{quota2.Status}");
var quota3 = accounts.Register(new RegisterRequest($"quota-c-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), failIp, "test");
Check($"[{provider}] 成功建号达到配额后照样限流(429)",
!quota3.Ok && quota3.Status == 429, $"{quota3.Status} {quota3.Error}");
var ip = "10.9.9.9";
var r1 = accounts.Register(new RegisterRequest($"rate1-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), ip, "test");
var r2 = accounts.Register(new RegisterRequest($"rate2-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), ip, "test");
var r3 = accounts.Register(new RegisterRequest($"rate3-{provider}@wpy.email", "Str0ng-Pass-2026", "", "TEST-INVITE"), ip, "test");
Check($"[{provider}] 同 IP 超过每小时上限后被限流(429)",
r1.Ok && r2.Ok && !r3.Ok && r3.Status == 429, r3.Error);
}
catch (Exception ex)
{
Fail($"[{provider}] 账号体系自检", $"抛出异常:{ex.Message}");
}
finally
{
try { store?.Dispose(); } catch { }
try { Directory.Delete(config.DataDirectory, true); } catch { }
}
}
}
/// 从出站队列里把那封验证码邮件的原文取出来,再解析出 6 位验证码。
private static string? ReadCodeFromOutbox(IMailStore store, string email)
{
try
{
var raw = ReadQueuedRaw(store, email);
if (raw is null) return null;
var match = System.Text.RegularExpressions.Regex.Match(Mime.Parse(raw).Text ?? "", @"\b(\d{6})\b");
return match.Success ? match.Groups[1].Value : null;
}
catch { return null; }
}
/// 取该收件人最近一封出站邮件的原始字节(用于把「投递」也纳入自检)。
private static byte[]? ReadQueuedRaw(IMailStore store, string email)
{
try
{
var items = new List();
foreach (var user in store.AllUsers()) items.AddRange(store.ListQueue(user.Email));
var hit = items
.Where(q => q.Recipients.Any(r => r.Equals(email, StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(q => q.CreatedAt)
.FirstOrDefault();
if (hit is null) return null;
var message = store.GetById(hit.MessageId);
return message is null ? null : store.ReadRaw(message.RawPath);
}
catch { return null; }
}
}