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]) } out = report.Reconcile(out) 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 }