579 lines
20 KiB
Go
579 lines
20 KiB
Go
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
|
||
}
|