447 lines
23 KiB
PowerShell
447 lines
23 KiB
PowerShell
<#
|
||
账号体系真实验收(在邮件服务器本机执行)
|
||
|
||
为什么必须跑真机:这套东西的价值全在「真的能注册、真的能登录、验证码邮件真的进得了信箱」,
|
||
单元自检(--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
|