Files
NixMsg/test/accept/accept_test.go
T

579 lines
20 KiB
Go
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.
package accept_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/test/accept"
"git.asio.asia/nixevol/NixMsg/test/harness"
"git.asio.asia/nixevol/NixMsg/test/report"
)
const epPassword = "password1234"
// TestQ2AcceptAndReport 补齐 Q2:对已接线的管理/注册/MQTT 收发做验收,并写 F01–F23 对照表。
func TestQ2AcceptAndReport(t *testing.T) {
items := make(map[string]report.Item, len(report.Features))
for _, f := range report.Features {
items[f.ID] = report.Item{
ID: f.ID,
Status: report.StatusUntested,
Note: "本波未覆盖",
}
}
set := func(id string, st report.Status, note string) {
items[id] = report.Item{ID: id, Status: st, Note: note}
}
runInitHealthz(t, set)
srv, err := harness.Start(harness.Options{})
if err != nil {
t.Fatalf("harness start: %v", err)
}
defer func() { _ = srv.Stop() }()
if srv.AdminPassword == "" {
t.Fatal("admin init 未返回密码")
}
runAdminAuth(t, srv, set)
runRegistration(t, srv, set)
runEndpointCreate(t, srv, set)
runMessagingAccept(t, srv, set)
runRestAccept(t, set)
out := make([]report.Item, 0, len(report.Features))
for _, f := range report.Features {
out = append(out, items[f.ID])
}
results := report.Results{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Items: out,
}
normalized, err := report.Normalize(results)
if err != nil {
t.Fatal(err)
}
var md bytes.Buffer
if werr := report.WriteMarkdown(&md, normalized); werr != nil {
t.Fatal(werr)
}
dir := t.TempDir()
resultsPath := filepath.Join(dir, "q2_results.json")
mdPath := filepath.Join(dir, "ACCEPTANCE.md")
if os.Getenv("NIXMSG_WRITE_ACCEPT_REPORT") == "1" {
root := findModuleRoot(t)
resultsPath = filepath.Join(root, "test", "report", "testdata", "q2_results.json")
mdPath = filepath.Join(root, "test", "accept", "ACCEPTANCE.md")
}
raw, err := json.MarshalIndent(results, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(resultsPath, append(raw, '\n'), 0o644); err != nil {
t.Fatalf("write results: %v", err)
}
if err := os.WriteFile(mdPath, md.Bytes(), 0o644); err != nil {
t.Fatalf("write acceptance md: %v", err)
}
t.Logf("wrote %s and %s", resultsPath, mdPath)
}
func runInitHealthz(t *testing.T, set func(string, report.Status, string)) {
t.Helper()
ls, err := accept.StartWithLogCapture()
if err != nil {
set("F22", report.StatusFail, "admin init/serve 失败: "+err.Error())
t.Errorf("init/serve: %v", err)
return
}
defer func() { _ = ls.Stop() }()
pass := ls.AdminPassword
if pass == "" {
set("F22", report.StatusFail, "admin init 未打印密码")
t.Error("empty admin password")
return
}
hz, err := http.Get(ls.HTTPBase + "/healthz")
if err != nil {
set("F22", report.StatusFail, "/healthz 不可达: "+err.Error())
t.Errorf("healthz: %v", err)
return
}
body, _ := io.ReadAll(hz.Body)
_ = hz.Body.Close()
if hz.StatusCode != http.StatusOK || string(body) != "ok" {
set("F22", report.StatusFail, fmt.Sprintf("/healthz status=%d body=%q", hz.StatusCode, body))
t.Errorf("healthz status=%d body=%q", hz.StatusCode, body)
return
}
rz, err := http.Get(ls.HTTPBase + "/readyz")
if err != nil {
set("F22", report.StatusFail, "/readyz 不可达: "+err.Error())
t.Errorf("readyz: %v", err)
return
}
rbody, _ := io.ReadAll(rz.Body)
_ = rz.Body.Close()
if rz.StatusCode != http.StatusOK || string(rbody) != "ok" {
set("F22", report.StatusFail, fmt.Sprintf("/readyz status=%d body=%q", rz.StatusCode, rbody))
t.Errorf("readyz status=%d body=%q", rz.StatusCode, rbody)
return
}
if strings.Contains(ls.LogBuf.String(), pass) {
set("F22", report.StatusFail, "管理员密码出现在 serve 日志")
t.Errorf("password leaked into serve logs")
return
}
set("F22", report.StatusPass, "已测:空目录 admin init + serve,/healthz 与 /readyz 成功,密码不在 serve 日志;未测:备份恢复、升级迁移、证书重载、Docker 全量、/metrics 抓取")
}
func runAdminAuth(t *testing.T, srv *harness.Server, set func(string, report.Status, string)) {
t.Helper()
code, body, err := accept.ProbeMethod(srv.AdminHTTPBase, http.MethodPost, "/api/admin/login",
[]byte(`{"username":"admin","password":"not-the-password!!"}`))
if err != nil {
set("F17", report.StatusFail, "探测管理登录失败: "+err.Error())
t.Errorf("probe login: %v", err)
return
}
if accept.ClassifyAdminLogin(code) == accept.RouteMissing {
set("F17", report.StatusFail, "管理登录路由未挂(期望已接线)")
t.Errorf("admin login missing: %d %s", code, body)
return
}
client, err := srv.AdminClient()
if err != nil {
set("F17", report.StatusFail, "AdminClient: "+err.Error())
t.Fatal(err)
}
loginBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": srv.AdminPassword,
})
resp, err := client.PostJSON("/api/admin/login", loginBody)
if err != nil {
set("F17", report.StatusFail, "登录请求失败: "+err.Error())
t.Fatal(err)
}
loginRaw, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
set("F17", report.StatusFail, fmt.Sprintf("正确密码登录失败 status=%d body=%s", resp.StatusCode, loginRaw))
t.Errorf("login want 200 got %d %s", resp.StatusCode, loginRaw)
return
}
req, err := http.NewRequest(http.MethodPost, srv.AdminHTTPBase+"/api/admin/logout", strings.NewReader(`{}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
noCSRF, err := client.HTTP.Do(req)
if err != nil {
set("F17", report.StatusFail, "无 CSRF 请求失败: "+err.Error())
t.Fatal(err)
}
noBody, _ := io.ReadAll(noCSRF.Body)
_ = noCSRF.Body.Close()
if noCSRF.StatusCode != http.StatusForbidden {
set("F17", report.StatusFail, fmt.Sprintf("无 CSRF 期望 403 得 %d body=%s", noCSRF.StatusCode, noBody))
t.Errorf("csrf: want 403 got %d %s", noCSRF.StatusCode, noBody)
return
}
okLogout, err := client.PostJSON("/api/admin/logout", []byte(`{}`))
if err != nil {
set("F17", report.StatusFail, "带 CSRF logout 失败: "+err.Error())
t.Fatal(err)
}
_ = okLogout.Body.Close()
if okLogout.StatusCode != http.StatusOK {
set("F17", report.StatusFail, fmt.Sprintf("带 CSRF logout 期望 200 得 %d", okLogout.StatusCode))
t.Errorf("logout with csrf: want 200 got %d", okLogout.StatusCode)
return
}
// 锁定会挡住后续用例:在独立进程上验证,避免污染本 srv 的管理员 IP 锁。
lockSrv, lerr := harness.Start(harness.Options{})
if lerr != nil {
set("F17", report.StatusFail, "锁定探测 harness 启动失败: "+lerr.Error())
t.Fatal(lerr)
}
defer func() { _ = lockSrv.Stop() }()
lockClient, err := harness.NewAdminClient(lockSrv.AdminHTTPBase)
if err != nil {
t.Fatal(err)
}
var locked bool
for i := 0; i < 12; i++ {
r, rerr := lockClient.PostJSON("/api/admin/login", []byte(`{"username":"admin","password":"wrong-password!!"}`))
if rerr != nil {
set("F17", report.StatusFail, "错误密码请求失败: "+rerr.Error())
t.Fatal(rerr)
}
raw, _ := io.ReadAll(r.Body)
_ = r.Body.Close()
if r.StatusCode == http.StatusTooManyRequests {
locked = true
break
}
if r.StatusCode != http.StatusUnauthorized {
set("F17", report.StatusFail, fmt.Sprintf("错误密码第 %d 次期望 401/429 得 %d body=%s", i+1, r.StatusCode, raw))
t.Errorf("bad login %d: %d %s", i, r.StatusCode, raw)
return
}
}
if !locked {
set("F17", report.StatusFail, "错误密码未触发锁定")
t.Error("login lock not triggered")
return
}
set("F17", report.StatusPass, "已测:管理登录、错误密码锁定、无 CSRF 被拒 / 有 CSRF 可通过;管端开通见 F01;管注册见 F23;令牌越权/查记录无正文等未穷尽")
}
func runRegistration(t *testing.T, srv *harness.Server, set func(string, report.Status, string)) {
t.Helper()
code, body, err := accept.ProbeMethod(srv.HTTPBase, http.MethodPost, "/api/client/register",
[]byte(`{"registration_code":"x"}`))
if err != nil {
set("F23", report.StatusFail, "探测注册失败: "+err.Error())
t.Errorf("probe register: %v", err)
return
}
if accept.ClassifyRegister(code) == accept.RouteMissing {
set("F23", report.StatusFail, "注册路由未挂(期望已接线)")
t.Errorf("register missing: %d %s", code, body)
return
}
ac := accept.AdminLogin(t, srv)
code1 := "accept-code-one-aaaa"
accept.EnableRegistration(t, ac, code1)
bad, _, berr := accept.ProbeMethod(srv.HTTPBase, http.MethodPost, "/api/client/register",
[]byte(`{"registration_code":"wrong-code","id":"q2bad001","login_password":"password1234"}`))
if berr != nil {
t.Fatal(berr)
}
if bad != http.StatusUnauthorized && bad != http.StatusForbidden {
set("F23", report.StatusFail, fmt.Sprintf("错码期望 401/403 得 %d", bad))
t.Errorf("wrong code: %d", bad)
return
}
okCode, okBody := accept.RegisterClient(t, srv.HTTPBase, code1, "q2ok0001", epPassword)
if okCode != http.StatusOK {
set("F23", report.StatusFail, fmt.Sprintf("对码注册失败 status=%d body=%s", okCode, trim(okBody)))
t.Errorf("register ok: %d %s", okCode, okBody)
return
}
code2 := "accept-code-two-bbbb"
accept.EnableRegistration(t, ac, code2)
oldBad, _ := accept.RegisterClient(t, srv.HTTPBase, code1, "q2old001", epPassword)
if oldBad == http.StatusOK {
set("F23", report.StatusFail, "换码后旧码仍可注册")
t.Error("old code still works")
return
}
newOK, newBody := accept.RegisterClient(t, srv.HTTPBase, code2, "q2new001", epPassword)
if newOK != http.StatusOK {
set("F23", report.StatusFail, fmt.Sprintf("换码后新码注册失败 status=%d body=%s", newOK, trim(newBody)))
t.Errorf("new code: %d %s", newOK, newBody)
return
}
set("F23", report.StatusPass, "已测:开关、错码、对码、换码;输错锁定未在本用例穷尽")
}
func runEndpointCreate(t *testing.T, srv *harness.Server, set func(string, report.Status, string)) {
t.Helper()
code, body, err := accept.ProbeMethod(srv.AdminHTTPBase, http.MethodPost, "/api/admin/endpoints",
[]byte(`{"id":"q2ep0001","login_password":"password1234"}`))
if err != nil {
set("F01", report.StatusFail, "探测开通端失败: "+err.Error())
t.Errorf("probe endpoints: %v", err)
return
}
if accept.ClassifyCreateEndpoint(code) == accept.RouteMissing {
set("F01", report.StatusFail, "开通端路由未挂(期望已接线)")
t.Errorf("endpoints missing: %d %s", code, body)
return
}
ac := accept.AdminLogin(t, srv)
accept.CreateEndpoint(t, ac, "q2ep0001", epPassword)
if accept.TryMQTTPasswordLogin(t, srv.HTTPBase, "q2ep0001", "wrong-password!!") {
set("F01", report.StatusFail, "错误密码仍能 MQTT 登录")
t.Error("wrong password mqtt accepted")
return
}
if !accept.TryMQTTPasswordLogin(t, srv.HTTPBase, "q2ep0001", epPassword) {
set("F01", report.StatusFail, "正确密码 MQTT 登录失败")
t.Error("good password mqtt rejected")
return
}
set("F01", report.StatusPass, "已测:开通一端、错误密码 MQTT 拒绝、正确密码可连;批量校验/停用/删除转让/同号重开未在本用例穷尽")
_ = code
_ = body
}
func runMessagingAccept(t *testing.T, srv *harness.Server, set func(string, report.Status, string)) {
t.Helper()
ac := accept.AdminLogin(t, srv)
accept.CreateEndpoint(t, ac, "q2alice01", epPassword)
accept.CreateEndpoint(t, ac, "q2bob0001", epPassword)
accept.CreateEndpoint(t, ac, "q2carol01", epPassword)
alice := accept.MQTTLogin(t, srv.HTTPBase, "q2alice01", epPassword)
defer alice.Close()
bob := accept.MQTTLogin(t, srv.HTTPBase, "q2bob0001", epPassword)
defer bob.Close()
delay0 := int64(0)
// F05 / F20:单聊送达 + 裸 MQTT 收发确认
sendResp := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "s1", "id": "q2-dm-1",
"to": map[string]any{"kind": "endpoint", "id": "q2bob0001"},
"body": map[string]any{"enc": "utf8", "data": "hello-bob"},
"delay_ms": delay0,
})
if !sendResp.OK {
set("F05", report.StatusFail, fmt.Sprintf("单聊提交失败: %+v", sendResp))
set("F20", report.StatusFail, "裸 MQTT send 失败")
t.Errorf("send dm: %+v", sendResp)
return
}
msg := bob.WaitType(t, "msg", 8*time.Second)
if msg["id"] != "q2-dm-1" || msg["from"] != "q2alice01" {
set("F05", report.StatusFail, fmt.Sprintf("单聊未正确送达: %v", msg))
t.Errorf("bob msg=%v", msg)
return
}
ack := bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "a1", "from": "q2alice01", "id": "q2-dm-1"})
if !ack.OK {
set("F05", report.StatusFail, fmt.Sprintf("ack 失败: %+v", ack))
set("F20", report.StatusFail, "裸 MQTT ack 失败")
t.Errorf("ack: %+v", ack)
return
}
set("F05", report.StatusPass, "已测:双端在线单聊送达与确认;崩溃续传见 Q3;消息号冲突/密码门/配额未穷尽")
set("F20", report.StatusPass, "已测:裸 MQTT WebSocket 登录、hello、发、收、确认")
set("F02", report.StatusPass, "已测:密码登录后 hello 成功(会话令牌路径可用);顶号/锁定/重置踢线未在本用例穷尽")
set("F21", report.StatusPass, "已测:同一 listen 端口提供 /healthz、管理 API、注册、WebSocket /mqtt;后台分离端口未测")
// F09:离线保留期内送达
keep := true
ttl := int64(86400)
sendOff := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "s2", "id": "q2-off-1",
"to": map[string]any{"kind": "endpoint", "id": "q2carol01"},
"body": map[string]any{"enc": "utf8", "data": "for-carol"},
"delay_ms": delay0,
"offline": map[string]any{"keep": keep, "ttl_seconds": ttl},
})
if !sendOff.OK {
set("F09", report.StatusFail, fmt.Sprintf("离线保留提交失败: %+v", sendOff))
t.Errorf("send offline: %+v", sendOff)
} else {
carol := accept.MQTTLogin(t, srv.HTTPBase, "q2carol01", epPassword)
defer carol.Close()
offMsg := carol.WaitType(t, "msg", 8*time.Second)
if offMsg["id"] != "q2-off-1" {
set("F09", report.StatusFail, fmt.Sprintf("离线保留未送达: %v", offMsg))
t.Errorf("carol offline msg=%v", offMsg)
} else {
carol.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "a2", "from": "q2alice01", "id": "q2-off-1"})
set("F09", report.StatusPass, "已测:选离线保留且接收方稍后上线能送达;超时过期未在本用例拨钟验证")
}
}
// F06:群发发送者收不到
createResp := alice.Request(t, map[string]any{
"v": 1, "type": "group.create", "rid": "g1", "id": "g_q2accept", "name": "Q2",
"members": []map[string]any{{"id": "q2bob0001"}, {"id": "q2carol01"}},
})
if !createResp.OK {
set("F06", report.StatusFail, fmt.Sprintf("建群失败: %+v", createResp))
t.Errorf("group.create: %+v", createResp)
} else {
carol2 := accept.MQTTLogin(t, srv.HTTPBase, "q2carol01", epPassword)
defer carol2.Close()
accept.DrainEvents(t, bob, 400*time.Millisecond)
accept.DrainEvents(t, carol2, 400*time.Millisecond)
accept.DrainEvents(t, alice, 300*time.Millisecond)
grpSend := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "s3", "id": "q2-grp-1",
"to": map[string]any{"kind": "group", "id": "g_q2accept"},
"body": map[string]any{"enc": "utf8", "data": "hi-group"},
"delay_ms": delay0,
})
if !grpSend.OK {
set("F06", report.StatusFail, fmt.Sprintf("群发失败: %+v", grpSend))
t.Errorf("group send: %+v", grpSend)
} else {
bobGrp := bob.WaitType(t, "msg", 8*time.Second)
carolGrp := carol2.WaitType(t, "msg", 8*time.Second)
if bobGrp["id"] != "q2-grp-1" || carolGrp["id"] != "q2-grp-1" {
set("F06", report.StatusFail, fmt.Sprintf("群成员未收到: bob=%v carol=%v", bobGrp, carolGrp))
t.Errorf("bob=%v carol=%v", bobGrp, carolGrp)
} else if got := alice.TryType("msg", 800*time.Millisecond); got != nil {
set("F06", report.StatusFail, fmt.Sprintf("发送者收到自己的群消息: %v", got))
t.Errorf("sender got own msg: %v", got)
} else {
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "a3", "from": "q2alice01", "id": "q2-grp-1"})
carol2.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "a4", "from": "q2alice01", "id": "q2-grp-1"})
set("F06", report.StatusPass, "已测:群成员收到同一份、发送者不收到自己的;入群前不补未单独覆盖")
set("F16", report.StatusPass, "已测:建群并拉成员后可群发;群主权限/退出/解散同号等未穷尽")
}
}
}
// F12:延迟窗口内撤回对方收不到
delayMs := int64(60_000)
sched := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "s4", "id": "q2-rec-1",
"to": map[string]any{"kind": "endpoint", "id": "q2bob0001"},
"body": map[string]any{"enc": "utf8", "data": "will-recall"},
"delay_ms": delayMs,
})
if !sched.OK {
set("F12", report.StatusFail, fmt.Sprintf("延迟发送失败: %+v", sched))
t.Errorf("scheduled send: %+v", sched)
return
}
data, _ := sched.Data.(map[string]any)
if data["state"] != "scheduled" {
set("F12", report.StatusFail, fmt.Sprintf("期望 scheduled 得 %v", data))
t.Errorf("want scheduled got %v", data)
return
}
rec := alice.Request(t, map[string]any{"v": 1, "type": "recall", "rid": "r1", "id": "q2-rec-1"})
if !rec.OK {
set("F12", report.StatusFail, fmt.Sprintf("撤回失败: %+v", rec))
t.Errorf("recall: %+v", rec)
return
}
if got := bob.TryType("msg", 1*time.Second); got != nil {
set("F12", report.StatusFail, fmt.Sprintf("延迟撤回后对方仍收到 msg: %v", got))
t.Errorf("bob got recalled msg: %v", got)
return
}
if got := bob.TryType("revoked", 500*time.Millisecond); got != nil {
set("F12", report.StatusFail, fmt.Sprintf("未推送撤回不应有 revoked: %v", got))
t.Errorf("unexpected revoked: %v", got)
return
}
set("F12", report.StatusPass, "已测:延迟窗口内撤回对方无 msg/revoked")
set("F13", report.StatusPass, "已测:未推送前撤回成功;群部分撤回未覆盖")
// F08:提交成功后杀进程重启,离线保留消息续传(不依赖 Docker)
runCrashResumeForF08(t, set)
}
func runCrashResumeForF08(t *testing.T, set func(string, report.Status, string)) {
t.Helper()
ms, err := accept.StartManaged()
if err != nil {
set("F08", report.StatusFail, "崩溃续传 harness 启动失败: "+err.Error())
t.Errorf("managed start: %v", err)
return
}
defer func() { _ = ms.Cleanup() }()
hs := &harness.Server{
HTTPBase: ms.HTTPBase,
AdminHTTPBase: ms.AdminHTTPBase,
AdminPassword: ms.AdminPassword,
}
ac := accept.AdminLogin(t, hs)
accept.CreateEndpoint(t, ac, "q2crasha1", epPassword)
accept.CreateEndpoint(t, ac, "q2crashb1", epPassword)
alice := accept.MQTTLogin(t, ms.HTTPBase, "q2crasha1", epPassword)
sendOff := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f08s1", "id": "q2-f08-1",
"to": map[string]any{"kind": "endpoint", "id": "q2crashb1"},
"body": map[string]any{"enc": "utf8", "data": "f08-keep"},
"delay_ms": int64(0),
"offline": map[string]any{"keep": true, "ttl_seconds": int64(86400)},
})
if !sendOff.OK {
set("F08", report.StatusFail, fmt.Sprintf("崩溃前提交失败: %+v", sendOff))
t.Errorf("submit: %+v", sendOff)
alice.Close()
return
}
alice.Close()
if err := ms.Kill(); err != nil {
set("F08", report.StatusFail, "杀进程失败: "+err.Error())
t.Errorf("kill: %v", err)
return
}
time.Sleep(200 * time.Millisecond)
if err := ms.Restart(); err != nil {
set("F08", report.StatusFail, "重启失败: "+err.Error())
t.Errorf("restart: %v", err)
return
}
bob := accept.MQTTLogin(t, ms.HTTPBase, "q2crashb1", epPassword)
defer bob.Close()
msg := bob.WaitType(t, "msg", 20*time.Second)
if msg["id"] != "q2-f08-1" {
set("F08", report.StatusFail, fmt.Sprintf("重启后续传失败: %v", msg))
t.Errorf("after restart msg=%v", msg)
return
}
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f08a1", "from": "q2crasha1", "id": "q2-f08-1"})
set("F08", report.StatusPass, "已测:提交成功后杀进程重启,离线保留消息续传;toxiproxy 弱网见 Q3 chaos 测试;应用层去重未单独断言")
}
func findModuleRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod not found")
}
dir = parent
}
}
func trim(s string) string {
s = strings.TrimSpace(s)
if len(s) > 180 {
return s[:180] + "..."
}
return s
}