Files
wpywmail/server-native-v2/tools/account-acceptance.ps1
T

447 lines
23 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
账号体系真实验收(在邮件服务器本机执行)
为什么必须跑真机:这套东西的价值全在「真的能注册、真的能登录、验证码邮件真的进得了信箱」,
单元自检(--selftest)只能证明存储层与业务层的逻辑,证明不了 HTTP 链路、IMAP 链路、
以及「验证码邮件是否真的投递到了客户端读得到的地方」。
覆盖:
A 策略接口、B 邀请码、C 本机托管地址注册即开通(含死循环回归)、D 会话/资料/审计、
E 真实 IMAP 993 登录 + 收件箱非空、F 忘记密码→读信取码→重置、G 改密踢其他会话、
H 登录失败锁定(423 + retryAfterSeconds)、I 管理员视角、J 停用后不能登录(并清理测试账号)
用法:
powershell -NoProfile -ExecutionPolicy Bypass -File account-acceptance.ps1
退出码 = 失败项数(0 = 全通过)。
#>
param(
[string]$Base = 'http://127.0.0.1:8787',
[string]$ImapHost = '127.0.0.1',
[int]$ImapPort = 993,
[string]$ImapName = 'mail.example.com',
[string]$Report = 'C:\Windows\Temp\wpyw-acct-verify.txt',
[string]$AppSettings = 'C:\Program Files\WpywMail\appsettings.json'
)
$ErrorActionPreference = 'Stop'
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
$script:pass = 0
$script:fail = 0
$script:skip = 0
# 注意:PowerShell 变量名大小写不敏感,这里不能叫 $script:report —— 会和参数 $Report 撞车
$script:logLines = New-Object System.Collections.Generic.List[string]
function Say([string]$line) {
Write-Host $line
$script:logLines.Add($line)
}
function Ok([string]$name, [bool]$cond, [string]$detail = '') {
if ($cond) { $script:pass++; Say ("[PASS] {0}{1}" -f $name, $(if ($detail) { " —— $detail" } else { '' })) }
else { $script:fail++; Say ("[FAIL] {0}{1}" -f $name, $(if ($detail) { " —— $detail" } else { '' })) }
}
function Skip([string]$name, [string]$why = '') {
$script:skip++
Say ("[SKIP] {0}{1}" -f $name, $(if ($why) { " —— $why" } else { '' }))
}
function Invoke-Api {
param([string]$Method, [string]$Path, $Body = $null, [string]$Token = '')
$headers = @{}
if ($Token) { $headers['Authorization'] = "Bearer $Token" }
$params = @{
Uri = ($Base + $Path); Method = $Method; Headers = $headers
UseBasicParsing = $true; TimeoutSec = 30
}
if ($null -ne $Body) {
# 必须按 UTF-8 字节发,不能直接传字符串:控制台是 GBK,中文会变问号
$params['Body'] = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress -Depth 8))
$params['ContentType'] = 'application/json; charset=utf-8'
}
try {
$r = Invoke-WebRequest @params
$json = $null
try { $json = $r.Content | ConvertFrom-Json } catch { }
return @{ Code = [int]$r.StatusCode; Json = $json; Raw = $r.Content }
} catch {
$resp = $_.Exception.Response
$code = 0
$raw = ''
if ($null -ne $resp) {
try { $code = [int]$resp.StatusCode } catch { }
try {
$reader = New-Object IO.StreamReader($resp.GetResponseStream(), [Text.Encoding]::UTF8)
$raw = $reader.ReadToEnd()
$reader.Close()
} catch { }
}
# ⚠ PowerShell 5.1 的坑:非 2xx 响应体常常已经被它的错误格式化逻辑读掉了,
# 这时 GetResponseStream() 读出来是空的 —— 必须回退到 ErrorDetails.Message。
if (-not $raw) {
try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $raw = $_.ErrorDetails.Message } } catch { }
}
$json = $null
if ($raw) { try { $json = $raw | ConvertFrom-Json } catch { } }
return @{ Code = $code; Json = $json; Raw = $raw }
}
}
function Field($obj, [string]$name, $fallback = $null) {
if ($null -eq $obj) { return $fallback }
$p = $obj.PSObject.Properties[$name]
if ($null -ne $p -and $null -ne $p.Value) { return $p.Value }
return $fallback
}
# /api/login 直接把会话对象摊在顶层({token,expiresAt,user}),/api/register 则包在 session 里。
# 两种形状都得认,否则会静默取到空 token,后面全用着已失效的 token 连锁失败(踩过)。
function Get-Token($json) {
$t = [string](Field (Field $json 'session') 'token' '')
if (-not $t) { $t = [string](Field $json 'token' '') }
return $t
}
function Wait-InboxMessage {
param([string]$Token, [string]$SubjectLike, [int]$Seconds = 40)
for ($i = 0; $i -lt $Seconds; $i++) {
$r = Invoke-Api 'GET' '/api/messages?folder=inbox&limit=20' $null $Token
$list = Field $r.Json 'messages'
if ($r.Code -eq 200 -and $null -ne $list) {
foreach ($m in $list) {
$s = [string](Field $m 'subject' '')
if ($s -like $SubjectLike) { return $m }
}
}
Start-Sleep -Seconds 1
}
return $null
}
function Read-ImapUntil {
param($Reader, [string]$Tag, [int]$MaxLines = 500)
$out = New-Object System.Collections.Generic.List[string]
for ($i = 0; $i -lt $MaxLines; $i++) {
$line = $Reader.ReadLine()
if ($null -eq $line) { break }
$out.Add($line)
if ($line.StartsWith($Tag + ' ')) { break }
}
return $out
}
# 真实 IMAP 客户端:连 993(隐式 TLS)→ LOGIN → SELECT INBOX → UID SEARCH ALL
function Test-ImapLogin {
param([string]$Email, [string]$Password)
$res = @{ Ok = $false; Detail = ''; Count = -1; Select = '' }
$tcp = New-Object Net.Sockets.TcpClient
try {
$tcp.Connect($ImapHost, $ImapPort)
$tcp.ReceiveTimeout = 20000
$cb = [Net.Security.RemoteCertificateValidationCallback] { param($a, $b, $c, $d) return $true }
$ssl = New-Object Net.Security.SslStream($tcp.GetStream(), $false, $cb)
$ssl.AuthenticateAsClient($ImapName)
$reader = New-Object IO.StreamReader($ssl, [Text.Encoding]::UTF8)
$writerEncoding = New-Object Text.UTF8Encoding($false)
$writer = New-Object IO.StreamWriter($ssl, $writerEncoding)
$writer.NewLine = "`r`n"
$writer.AutoFlush = $true
$greeting = $reader.ReadLine()
if ($greeting -notmatch '^\* OK') { $res.Detail = "问候语异常: $greeting"; return $res }
$writer.WriteLine("a1 LOGIN $Email $Password")
$login = Read-ImapUntil $reader 'a1'
$loginText = ($login -join ' | ')
if ($loginText -notmatch 'a1 OK') { $res.Detail = "LOGIN 失败: $loginText"; return $res }
$writer.WriteLine('a2 SELECT INBOX')
$sel = Read-ImapUntil $reader 'a2'
$selText = ($sel -join ' ')
if ($selText -notmatch 'a2 OK') { $res.Detail = "SELECT 失败: $selText"; return $res }
$exists = 0
$m = [regex]::Match($selText, '\*\s+(\d+)\s+EXISTS')
if ($m.Success) { $exists = [int]$m.Groups[1].Value }
$writer.WriteLine('a3 UID SEARCH ALL')
$search = Read-ImapUntil $reader 'a3'
$searchText = ($search -join ' ')
$uidCount = -1
$sm = [regex]::Match($searchText, '\*\s+SEARCH([\d\s]*)')
if ($sm.Success) {
$ids = ($sm.Groups[1].Value -split '\s+' | Where-Object { $_ -ne '' })
$uidCount = $ids.Count
}
$writer.WriteLine('a4 LOGOUT')
[void](Read-ImapUntil $reader 'a4')
$res.Ok = $true
$res.Count = $uidCount
$res.Select = "EXISTS=$exists UIDSEARCH=$uidCount"
return $res
} catch {
$res.Detail = $_.Exception.Message
return $res
} finally {
try { $tcp.Close() } catch { }
}
}
function Set-UserActive {
param([string]$Token, [string]$Email, [bool]$Active)
$path = '/api/admin/users/' + [Uri]::EscapeDataString($Email)
return Invoke-Api 'PATCH' $path @{ active = $Active } $Token
}
# ================================================================ 开始
Say ("WpywMail 账号体系真实验收 {0}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))
Say ("目标 API:{0} IMAP:{1}:{2}" -f $Base, $ImapHost, $ImapPort)
Say ''
# 从服务器自己的配置里取管理员口令与邀请码(不硬编码,避免和部署配置不一致)
$cfg = [System.IO.File]::ReadAllText($AppSettings) | ConvertFrom-Json
$invite = $cfg.Accounts.InviteCode
$adminEmail = $cfg.AdminEmail
$adminPassword = $cfg.AdminPassword
$domain = $cfg.Domain
Say ("配置:域名={0} 邀请码长度={1} 管理员={2}" -f $domain, $invite.Length, $adminEmail)
Say ''
$suffix = (Get-Random -Minimum 100000 -Maximum 999999)
$newEmail = "selftest-$suffix@$domain"
$newPassword = "Selftest-Pass-$suffix"
$resetPassword = "Reset-Pass-$suffix"
$lockEmail = "locktest-$suffix@$domain"
$lockPassword = "Locktest-Pass-$suffix"
$tempAccounts = @($newEmail, $lockEmail)
# 管理员先登录:后面「注册配额用尽」时要靠它兜底建号,最后的管理员用例也复用它
$adminLogin = Invoke-Api 'POST' '/api/login' @{ email = $adminEmail; password = $adminPassword }
$adminToken = Get-Token $adminLogin.Json
Ok 'A0 管理员可以登录' ($adminLogin.Code -eq 200 -and $adminToken.Length -gt 20) ("HTTP " + $adminLogin.Code + " " + [string](Field $adminLogin.Json 'error'))
# ---------------------------------------------------------------- A 策略
$ver = Invoke-Api 'GET' '/api/version'
Ok 'A1 /api/version 可达' ($ver.Code -eq 200) ("HTTP " + $ver.Code)
$pol = Invoke-Api 'GET' '/api/auth/policy'
Ok 'A2 策略接口可达' ($pol.Code -eq 200) ("HTTP " + $pol.Code)
Ok 'A3 注册模式 = invite' ((Field $pol.Json 'registration') -eq 'invite') ([string](Field $pol.Json 'registration'))
Ok 'A4 需要邀请码标记' ((Field $pol.Json 'inviteRequired') -eq $true) ([string](Field $pol.Json 'inviteRequired'))
$allowed = Field $pol.Json 'allowedDomains'
Ok 'A5 允许域名包含本机域' ($allowed -contains $domain) ([string]::Join(',', $allowed))
Ok 'A6 密码最短长度 >= 8' (([int](Field $pol.Json 'minPasswordLength' 0)) -ge 8) ([string](Field $pol.Json 'minPasswordLength'))
$note = [string](Field $pol.Json 'verificationNote' '')
Ok 'A7 策略里说明了「本机托管地址免验证」的原因' ($note.Length -gt 10) $note
# ---------------------------------------------------------------- B/C 注册
$badInvite = Invoke-Api 'POST' '/api/register' @{
email = $newEmail; password = $newPassword; displayName = '验收账号'; inviteCode = 'WRONG-CODE'
}
Ok 'B1 邀请码错误被拒(403)' ($badInvite.Code -eq 403) ([string](Field $badInvite.Json 'error'))
$badDomain = Invoke-Api 'POST' '/api/register' @{
email = "nobody-$suffix@example.com"; password = $newPassword; displayName = '外部域'; inviteCode = $invite
}
Ok 'B2 白名单外的域名被拒(400)' ($badDomain.Code -eq 400) ([string](Field $badDomain.Json 'error'))
$weak = Invoke-Api 'POST' '/api/register' @{
email = $newEmail; password = '12345678'; displayName = '弱密码'; inviteCode = $invite
}
Ok 'B3 弱密码被拒(400)' ($weak.Code -eq 400) ([string](Field $weak.Json 'error'))
$reg = Invoke-Api 'POST' '/api/register' @{
email = $newEmail; password = $newPassword; displayName = '验收账号'; inviteCode = $invite
}
$session = Field $reg.Json 'session'
$token = [string](Field $session 'token' '')
if ($reg.Code -eq 429) {
# 一小时内重复跑本脚本会撞到 IP 配额(配额按「真的建出的账号数」计,见 AccountService)。
# 这不是缺陷,但要如实标注,并改用管理员接口建号,让后面的 40 多项用例照常跑完。
Skip 'C1/C2/C3 自助注册链路' '本小时该 IP 的注册配额已用尽(重复运行脚本所致);已改用管理员接口建号继续验收'
$boot = Invoke-Api 'POST' '/api/admin/users' @{ email = $newEmail; password = $newPassword; displayName = '验收账号' } $adminToken
Ok 'C1b 配额用尽时管理员接口可以建号' ($boot.Code -eq 201) ("HTTP " + $boot.Code + " " + [string](Field $boot.Json 'error'))
$boot2 = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
$token = Get-Token $boot2.Json
Ok 'C1c 兜底建的号可以登录' ($boot2.Code -eq 200 -and $token.Length -gt 20) ("HTTP " + $boot2.Code)
} else {
Ok 'C1 本机域注册成功(201)' ($reg.Code -eq 201) ("HTTP " + $reg.Code + " " + [string](Field $reg.Json 'error'))
Ok 'C2 本机域注册不需要邮箱验证(死循环回归)' ((Field $reg.Json 'verificationRequired') -eq $false) ([string](Field $reg.Json 'verificationRequired'))
Ok 'C3 注册直接返回会话 token' ($token.Length -gt 20) ("token 长度 " + $token.Length)
}
$me = Invoke-Api 'GET' '/api/me' $null $token
Ok 'C4 注册后的 token 可访问 /api/me' ($me.Code -eq 200) ("HTTP " + $me.Code)
Ok 'C5 /api/me 返回的账号一致' (([string](Field (Field $me.Json 'user') 'email' '')) -eq $newEmail) ([string](Field (Field $me.Json 'user') 'email' ''))
$login1 = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
$token2 = Get-Token $login1.Json
Ok 'C6 新账号可以正常登录' ($login1.Code -eq 200 -and $token2.Length -gt 20) ("HTTP " + $login1.Code)
if (-not $token) { $token = $token2 }
# ---------------------------------------------------------------- D 会话 / 资料 / 审计
$prof = Invoke-Api 'PATCH' '/api/account/profile' @{ displayName = "验收账号-$suffix" } $token
Ok 'D1 修改显示名成功' ($prof.Code -eq 200) ([string](Field $prof.Json 'error'))
$me2 = Invoke-Api 'GET' '/api/me' $null $token
Ok 'D2 显示名已生效' (([string](Field (Field $me2.Json 'user') 'displayName' '')) -eq "验收账号-$suffix") ([string](Field (Field $me2.Json 'user') 'displayName' ''))
$sess = Invoke-Api 'GET' '/api/account/sessions' $null $token
$sessList = Field $sess.Json 'sessions'
Ok 'D3 会话列表可读且包含当前会话' ($sess.Code -eq 200 -and $null -ne ($sessList | Where-Object { (Field $_ 'current') -eq $true })) ("共 " + @($sessList).Count + " 个会话")
$audit = Invoke-Api 'GET' '/api/account/audit?limit=50' $null $token
$events = Field $audit.Json 'events'
$reasons = @($events | ForEach-Object { [string](Field $_ 'reason' '') })
Ok 'D4 审计里能看到 register 事件' ($reasons -contains 'register') ([string]::Join(',', $reasons))
Ok 'D5 审计里能看到 login-ok 事件' ($reasons -contains 'login-ok') ''
Ok 'D6 审计里能看到 profile-updated 事件' ($reasons -contains 'profile-updated') ''
# ---------------------------------------------------------------- E 真实 IMAP
$imap = Test-ImapLogin $newEmail $newPassword
Ok 'E1 新账号能用真实 IMAP(993 隐式 TLS) 登录' $imap.Ok ([string]$imap.Detail)
Ok 'E2 IMAP SELECT INBOX 成功' ($imap.Ok -and $imap.Select -ne '') ([string]$imap.Select)
# 自己给自己发一封中文邮件,验证本地投递进了这个新信箱
$send = Invoke-Api 'POST' '/api/send' @{
to = $newEmail; subject = "账号验收邮件 $suffix"; text = "这封邮件用来验证新注册账号的信箱能收信。编号 $suffix"
} $token
Ok 'E3 新账号可以发信(入队 202)' ($send.Code -eq 202) ("HTTP " + $send.Code + " " + [string](Field $send.Json 'error'))
$landed = Wait-InboxMessage $token "账号验收邮件*" 40
$landedId = [string](Field $landed 'id' '')
Ok 'E4 自己发的邮件已投递进收件箱' ($null -ne $landed -and $landedId.Length -gt 0) ([string](Field $landed 'subject' '(未收到)'))
$imap2 = Test-ImapLogin $newEmail $newPassword
Ok 'E5 IMAP 收件箱计数 >= 1' ($imap2.Ok -and $imap2.Count -ge 1) ([string]$imap2.Select)
# ---------------------------------------------------------------- F 忘记密码 → 读信取码 → 重置
$forgot = Invoke-Api 'POST' '/api/auth/forgot' @{ email = $newEmail }
Ok 'F1 申请重置密码返回成功' ($forgot.Code -eq 200) ("HTTP " + $forgot.Code + " " + [string](Field $forgot.Json 'error'))
$resetMail = Wait-InboxMessage $token '*重置密码验证码*' 40
$resetId = [string](Field $resetMail 'id' '')
Ok 'F2 重置验证码邮件已投进收件箱' ($resetId.Length -gt 0) ([string](Field $resetMail 'subject' '(未收到)'))
$code = ''
if ($resetId) {
$detail = Invoke-Api 'GET' ("/api/messages/" + [Uri]::EscapeDataString($resetId)) $null $token
$msg = Field $detail.Json 'message'
$body = [string](Field $msg 'text' '')
if (-not $body) { $body = [string](Field $msg 'html' '') }
# ⚠ 必须锚定「验证码:」这个标签:邮件正文里还写着收件人地址(selftest-123456@…),
# 直接抓第一个 6 位数字会抓到地址里的数字,测试自己就成了假失败源。
$cm = [regex]::Match($body, '验证码[::]\s*(\d{6})')
if (-not $cm.Success) {
$all = [regex]::Matches($body, '\b(\d{6})\b')
if ($all.Count -gt 0) { $cm = $all[$all.Count - 1] }
}
if ($cm.Success) {
if ($cm.Groups.Count -gt 1) { $code = $cm.Groups[1].Value } else { $code = $cm.Value }
}
}
Ok 'F3 能从邮件正文里取出 6 位验证码' ($code.Length -eq 6) ("code=" + $(if ($code) { $code } else { '(空)' }))
$badReset = Invoke-Api 'POST' '/api/auth/reset' @{ email = $newEmail; code = '000000'; password = $resetPassword }
Ok 'F4 错误验证码被拒(400)' ($badReset.Code -eq 400) ([string](Field $badReset.Json 'error'))
if ($code.Length -eq 6) {
$goodReset = Invoke-Api 'POST' '/api/auth/reset' @{ email = $newEmail; code = $code; password = $resetPassword }
Ok 'F5 正确验证码重置成功' ($goodReset.Code -eq 200) ("HTTP " + $goodReset.Code + " " + [string](Field $goodReset.Json 'error'))
}
$oldLogin = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
Ok 'F6 重置后旧密码失效(401)' ($oldLogin.Code -eq 401) ("HTTP " + $oldLogin.Code)
$newLogin = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $resetPassword }
$token3 = Get-Token $newLogin.Json
Ok 'F7 重置后新密码可登录' ($newLogin.Code -eq 200 -and $token3.Length -gt 20) ("HTTP " + $newLogin.Code)
$imap3 = Test-ImapLogin $newEmail $resetPassword
Ok 'F8 新密码同样能用 IMAP 登录' $imap3.Ok ([string]$imap3.Detail)
if ($token3) { $token = $token3 }
# ---------------------------------------------------------------- G 改密踢掉其他会话
$loginExtra = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $resetPassword }
$token4 = Get-Token $loginExtra.Json
$before = @(Field (Invoke-Api 'GET' '/api/account/sessions' $null $token).Json 'sessions').Count
$changed = Invoke-Api 'POST' '/api/account/password' @{ currentPassword = $resetPassword; password = $newPassword } $token
$after = @(Field (Invoke-Api 'GET' '/api/account/sessions' $null $token).Json 'sessions').Count
Ok 'G1 修改密码成功' ($changed.Code -eq 200) ("HTTP " + $changed.Code + " " + [string](Field $changed.Json 'error'))
Ok 'G2 改密后其他会话被吊销(当前保留)' ($after -lt $before -and $after -ge 1) ("改前 $before → 改后 $after")
if ($token4) {
$stale = Invoke-Api 'GET' '/api/me' $null $token4
Ok 'G3 被踢掉的那个 token 已失效(401)' ($stale.Code -eq 401) ("HTTP " + $stale.Code)
}
# ---------------------------------------------------------------- H 登录失败锁定
$lockReg = Invoke-Api 'POST' '/api/register' @{
email = $lockEmail; password = $lockPassword; displayName = '锁定验收'; inviteCode = $invite
}
if ($lockReg.Code -eq 429) {
Skip 'H1 第二个测试账号自助注册' '本小时注册配额已用尽(重复运行脚本所致);改用管理员接口建号'
$lockReg = Invoke-Api 'POST' '/api/admin/users' @{ email = $lockEmail; password = $lockPassword; displayName = '锁定验收' } $adminToken
}
Ok 'H1 第二个测试账号建号成功' ($lockReg.Code -eq 201) ("HTTP " + $lockReg.Code + " " + [string](Field $lockReg.Json 'error'))
$maxFail = [int](Field $pol.Json 'maxLoginFailures' 8)
$lastCode = 0
for ($i = 1; $i -le ($maxFail + 1); $i++) {
$r = Invoke-Api 'POST' '/api/login' @{ email = $lockEmail; password = "Wrong-Password-$i" }
$lastCode = $r.Code
}
$lockReply = Invoke-Api 'POST' '/api/login' @{ email = $lockEmail; password = $lockPassword }
$retryAfter = Field $lockReply.Json 'retryAfterSeconds'
Ok 'H2 连续失败后返回 423 锁定' ($lockReply.Code -eq 423) ("HTTP " + $lockReply.Code + " " + [string](Field $lockReply.Json 'error'))
Ok 'H3 锁定响应带 retryAfterSeconds' (($null -ne $retryAfter) -and ([int]$retryAfter -gt 0)) ("retryAfterSeconds=" + [string]$retryAfter + " 原始响应: " + $lockReply.Raw)
Ok 'H4 锁定期间即使密码正确也被挡(不泄露密码对错)' ($lockReply.Code -eq 423) ''
# ---------------------------------------------------------------- I 管理员视角
$users = Invoke-Api 'GET' '/api/admin/users' $null $adminToken
$userList = Field $users.Json 'users'
$mine = $userList | Where-Object { ([string](Field $_ 'email' '')) -eq $newEmail }
Ok 'I2 管理员能看到新注册的账号' ($null -ne $mine) ("共 " + @($userList).Count + " 个账号")
Ok 'I3 新账号在管理员视角是启用状态' ($null -ne $mine -and (Field $mine 'active') -eq $true) ([string](Field $mine 'active'))
$adminAudit = Invoke-Api 'GET' '/api/admin/audit?limit=50' $null $adminToken
$adminEvents = Field $adminAudit.Json 'events'
$adminReasons = @($adminEvents | ForEach-Object { [string](Field $_ 'reason' '') })
Ok 'I4 管理员能看到全站审计' ($adminAudit.Code -eq 200 -and $adminEvents.Count -gt 0) ("共 " + @($adminEvents).Count + " 条,含 " + [string]::Join('/', ($adminReasons | Select-Object -Unique -First 6)))
$nonAdmin = Invoke-Api 'GET' '/api/admin/users' $null $token
Ok 'I5 普通账号访问管理接口被拒(403)' ($nonAdmin.Code -eq 403) ("HTTP " + $nonAdmin.Code)
# ---------------------------------------------------------------- J 停用 + 清理
$deact = Set-UserActive $adminToken $lockEmail $false
Ok 'J1 管理员可停用账号' ($deact.Code -eq 200) ("HTTP " + $deact.Code + " " + [string](Field $deact.Json 'error'))
$deact2 = Set-UserActive $adminToken $newEmail $false
Ok 'J2 清理:停用验收账号一' ($deact2.Code -eq 200) ("HTTP " + $deact2.Code)
$disabled = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
Ok 'J3 被停用的账号不能登录(401,且不暴露账号状态)' ($disabled.Code -eq 401) ("HTTP " + $disabled.Code + " " + [string](Field $disabled.Json 'error'))
$disabledImap = Test-ImapLogin $newEmail $newPassword
Ok 'J4 被停用的账号不能登录 IMAP' (-not $disabledImap.Ok) ([string]$disabledImap.Detail)
# 停用必须是权威状态:不能靠「重新注册」把封禁翻回来(这是真机验收抓出来的洞)
$revive = Invoke-Api 'POST' '/api/register' @{
email = $newEmail; password = "Revive-Pass-$suffix"; displayName = '尝试复活'; inviteCode = $invite
}
Ok 'J5 被停用的账号不能靠重新注册复活(403)' ($revive.Code -eq 403) ("HTTP " + $revive.Code + " " + [string](Field $revive.Json 'error'))
$stillDisabled = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = "Revive-Pass-$suffix" }
Ok 'J6 复活尝试后账号依然进不去(401)' ($stillDisabled.Code -eq 401) ("HTTP " + $stillDisabled.Code)
Say ''
Say ("=== 结果:{0} 项通过,{1} 项失败,{2} 项跳过 ===" -f $script:pass, $script:fail, $script:skip)
Say ''
Say "说明:测试期间创建的两个账号已停用(不是删除,服务器暂无删除账号接口):"
foreach ($a in $tempAccounts) { Say (" - {0}" -f $a) }
try {
$utf8 = New-Object Text.UTF8Encoding($true)
[System.IO.File]::WriteAllLines($Report, $script:logLines, $utf8)
Write-Host ("报告已写入 {0}" -f $Report)
} catch {
Write-Host ("报告写入失败:{0}" -f $_.Exception.Message)
}
exit $script:fail