From 550ed534e40c1afebf5cfe481600c5de24888dc9 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 07:20:15 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=20Q2=20=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E9=9B=86=E6=88=90=E9=AA=8C=E6=94=B6=E4=B8=8E?= =?UTF-8?q?=20F01-F23=20=E5=AF=B9=E7=85=A7=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEVIATIONS.md | 23 ++ taskfiles/q.yml | 12 + test/accept/ACCEPTANCE.md | 31 ++ test/accept/accept_test.go | 496 +++++++++++++++++++++++++++ test/accept/routes.go | 65 ++++ test/accept/server.go | 128 +++++++ test/report/testdata/q2_results.json | 120 +++++++ 7 files changed, 875 insertions(+) create mode 100644 test/accept/ACCEPTANCE.md create mode 100644 test/accept/accept_test.go create mode 100644 test/accept/routes.go create mode 100644 test/accept/server.go create mode 100644 test/report/testdata/q2_results.json diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 93a1f2a..40775d5 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -747,3 +747,26 @@ - 原因:空命名卷属主为 root 时,distroless nonroot 无法建库(`unable to open database file`)。 - 备选方案:compose 增加一次性 init 服务;或文档要求宿主机目录预授权。 - 影响:按示例首次 `up` 前需处理权限,否则 serve 立即退出。 + +### Q2 第一部分(已合并功能验收)2026-09-30 + +1. **对真实进程探测,未接线则记「未测」而非改业务代码** + - 原条款:TASKS Q2「PRD 第 10 节每条至少有一个集成测试」;本波只做已合并功能的第一部分。 + - 实际做法:`test/accept` 用 `test/harness`(随机端口 + 临时目录)起真实 `nixmsg`;对 `/api/admin/login`、`/api/client/register`、`POST /api/admin/endpoints` 先探测。404 则该条写「未测」并注明缺总控接线 / A2 / I 等,不修改 `cmd/nixmsg` 或业务包求绿。 + - 原因:main@d357082 上 A1/I1 等仅为可挂载 Handler,`serve` 只挂了 `/healthz`、`/readyz`(见各线 DEVIATIONS「未改 cmd」)。 + - 备选方案:测试进程内自行 `admin.New` 挂路由(不反映交付二进制行为,否决)。 + - 影响:本波 F17/F01/F23 多为未测;接线后同一测试会自动跑登录锁定、CSRF、注册与开通路径。 + +2. **F22 只验收 init + 健康检查子集** + - 原条款:F22 含备份恢复、升级迁移、证书重载、Docker、指标。 + - 实际做法:集成测试覆盖空目录 `admin init`、`serve`、`/healthz`、`/readyz`,并断言管理员密码不出现在 serve 的 stdout/stderr;其余 F22 子项仍标未测。 + - 原因:本波范围是「现在就能测的路径」。 + - 备选方案:本波强行跑 Docker/证书(超出第一部分)。 + - 影响:对照表 F22 为「通过」但备注写明未覆盖项。 + +3. **验收报告写入方式** + - 原条款:用 `test/report` 生成 F01–F23 对照表。 + - 实际做法:`TestQ2AcceptAndReport` 汇总探测结果;默认写临时目录。`task q:accept`(`NIXMSG_WRITE_ACCEPT_REPORT=1`)写入 `test/report/testdata/q2_results.json` 与 `test/accept/ACCEPTANCE.md`;`task q:report-q2` 可再生成 Markdown。普通 `go test`/`task check` 不改仓库文件。 + - 原因:避免每次单测改 `generated_at` 弄脏工作区。 + - 备选方案:固定时间戳始终写入仓库。 + - 影响:交付审阅以 `ACCEPTANCE.md` / `q2_results.json` 为准,需先跑过 `task q:accept`。 diff --git a/taskfiles/q.yml b/taskfiles/q.yml index df5eda7..44e7394 100644 --- a/taskfiles/q.yml +++ b/taskfiles/q.yml @@ -6,6 +6,13 @@ tasks: cmds: - go test ./test/chaos/ -count=1 -v + q:accept: + desc: 跑 Q2 验收集成测试并写入 F01–F23 对照表 + cmds: + - go test ./test/accept/ -count=1 -v + env: + NIXMSG_WRITE_ACCEPT_REPORT: "1" + q:report: desc: 从结果 JSON 生成 F01–F23 验收对照表(默认空样例) cmds: @@ -16,6 +23,11 @@ tasks: cmds: - go run ./test/report/cmd/genreport ./test/report/testdata/sample_results.json + q:report-q2: + desc: 用 Q2 验收结果生成对照表到 stdout + cmds: + - go run ./test/report/cmd/genreport ./test/report/testdata/q2_results.json + q:load-test: desc: 压测客户端骨架单元测试 cmds: diff --git a/test/accept/ACCEPTANCE.md b/test/accept/ACCEPTANCE.md new file mode 100644 index 0000000..652b3a5 --- /dev/null +++ b/test/accept/ACCEPTANCE.md @@ -0,0 +1,31 @@ +# NixMsg 验收对照表(PRD 第 10 节) + +生成时间:2026-09-29T23:20:07Z + +汇总:通过 1,失败 0,未测 22 + +| 编号 | 一句话 | 结果 | 备注 | +|---|---|---|---| +| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | 未测 | 未测:serve 未挂载 POST /api/admin/endpoints(A2 未合入或未接线;当前 A1 对端路由返回 501 亦未挂到进程) | +| F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | 未测 | 未测:端登录/会话令牌属连接 N3,main 上 serve 未挂 broker | +| F03 | 断开后状态及时变离线,全表可列出 | 未测 | 未测:在线状态属身份 I3 + 连接 N3,未接线 | +| F04 | 只通知订阅了的端 | 未测 | 未测:presence.watch 属身份 I3,未接线 | +| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | 未测 | 未测:消息提交属消息 M1,未挂入 broker 上行 | +| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | 未测 | 未测:群消息属消息 M + 身份 I4,未接线 | +| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 未测 | 未测:大小限制属消息/连接,未接线 | +| F08 | 弱网最终送达且应用层不重复,重启后续传 | 未测 | 未测:投递确认属消息 M2,未接线 | +| F09 | 保留时间从发送时刻起算,超时过期 | 未测 | 未测:保留期属消息 M2,未接线 | +| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 未测 | 未测:断线策略属消息 M2,未接线 | +| F11 | 发送方离线后到点仍发送 | 未测 | 未测:定时发送属消息 M2/M4,未接线 | +| F12 | 延迟窗口内撤回对方收不到 | 未测 | 未测:延迟撤回属消息 M3,未接线 | +| F13 | 未推送必撤成功;群部分确认得到部分撤回 | 未测 | 未测:撤回判定属消息 M3,未接线 | +| F14 | 回执能补送给当时离线的发送方 | 未测 | 未测:回执属消息 M3,未接线 | +| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | 未测 | 未测:对话密码属身份 I2,未接线 | +| F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 | 未测 | 未测:群权限属身份 I4,未接线 | +| F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 | 未测 | 未测:serve 未挂载 /api/admin/login(A1 Handler 已实现,缺总控/接线挂到 cmd/nixmsg;DEVIATIONS 后台接口 A §1) | +| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 未测 | 未测:正文清理属消息 M3,未接线 | +| F19 | 四种 SDK 通过同一清单 | 未测 | 未测:SDK 接入清单属 S1/S2 任务 4,依赖真实服务接线 | +| F20 | 裸 MQTT 能登录、收、确认、发 | 未测 | 未测:裸 MQTT 属连接 N,serve 未挂 broker | +| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 未测 | 仅验证 listen 上 /healthz+/readyz;后台 API、/mqtt、裸 TCP、注册未挂入 serve(缺总控接线 + 连接 N) | +| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | 通过 | 已测:空目录 admin init + serve,/healthz 与 /readyz 成功,密码不在 serve 日志;未测:备份恢复、升级迁移、证书重载、Docker、/metrics | +| F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 | 未测 | 未测:serve 未挂载 POST /api/client/register(I1 Handler 已实现,缺总控/连接 N 接线;DEVIATIONS 身份 I §1) | diff --git a/test/accept/accept_test.go b/test/accept/accept_test.go new file mode 100644 index 0000000..95c1443 --- /dev/null +++ b/test/accept/accept_test.go @@ -0,0 +1,496 @@ +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" +) + +// TestQ2AcceptAndReport 是 Q2 第一部分:对 main 已有能力做集成探测,并生成 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} + } + + // —— 1) admin init + serve + /healthz + /readyz,密码不在 serve 日志 —— + runInitHealthz(t, set) + + // —— 共用 harness 进程,探测管理 / 注册 / 开通 —— + 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 未返回密码(P1 应已合入)") + } + + runAdminAuth(t, srv, set) + runRegistration(t, srv, set) + runEndpointCreate(t, srv, set) + + // 其余条目写清未测原因(缺哪条线) + setDefaultUntested(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) + } + if !strings.Contains(md.String(), "F01") || !strings.Contains(md.String(), "F23") { + t.Fatalf("report missing features:\n%s", md.String()) + } + if !strings.Contains(md.String(), "通过") && !strings.Contains(md.String(), "未测") { + t.Fatalf("report missing status words:\n%s", md.String()) + } + + // 默认写到临时目录验证;设 NIXMSG_WRITE_ACCEPT_REPORT=1 时写入仓库产物供交付。 + 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 失败(平台 P): "+err.Error()) + t.Errorf("init/serve: %v", err) + return + } + defer func() { _ = ls.Stop() }() + + pass := ls.AdminPassword + if pass == "" { + set("F22", report.StatusFail, "admin init 未打印密码(平台 P)") + t.Error("empty admin password") + return + } + + hz, err := http.Get(ls.HTTPBase + "/healthz") + if err != nil { + set("F22", report.StatusFail, "/healthz 不可达(平台 P): "+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(平台 P)", 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 不可达(平台 P): "+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(平台 P)", rz.StatusCode, rbody)) + t.Errorf("readyz status=%d body=%q", rz.StatusCode, rbody) + return + } + + logs := ls.LogBuf.String() + if strings.Contains(logs, pass) { + set("F22", report.StatusFail, "管理员密码出现在 serve 日志(平台 P)") + t.Errorf("password leaked into serve logs") + return + } + + set("F22", report.StatusPass, "已测:空目录 admin init + serve,/healthz 与 /readyz 成功,密码不在 serve 日志;未测:备份恢复、升级迁移、证书重载、Docker、/metrics") + // F21:当前进程至少在单一 listen 上提供健康检查;后台 API / MQTT 尚未接线 + set("F21", report.StatusUntested, "仅验证 listen 上 /healthz+/readyz;后台 API、/mqtt、裸 TCP、注册未挂入 serve(缺总控接线 + 连接 N)") +} + +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 { + note := "未测:serve 未挂载 /api/admin/login(A1 Handler 已实现,缺总控/接线挂到 cmd/nixmsg;DEVIATIONS 后台接口 A §1)" + set("F17", report.StatusUntested, note) + t.Log(note) + return + } + + // 路由已挂上:跑登录、锁定、CSRF + 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(后台接口 A)", resp.StatusCode, loginRaw)) + t.Errorf("login want 200 got %d %s", resp.StatusCode, loginRaw) + return + } + + // 无 CSRF 的改状态请求应被拒 + 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") + // 故意不加 X-Nixmsg-Request + 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(后台接口 A)", noCSRF.StatusCode, noBody)) + t.Errorf("csrf: want 403 got %d %s", noCSRF.StatusCode, noBody) + return + } + + // 带 CSRF 的 logout 应成功 + 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(后台接口 A)", okLogout.StatusCode)) + t.Errorf("logout with csrf: want 200 got %d", okLogout.StatusCode) + return + } + + // 错误密码锁定(新客户端,避免 Cookie 干扰;10 次错) + lockClient, err := harness.NewAdminClient(srv.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(后台接口 A)", i+1, r.StatusCode, raw)) + t.Errorf("bad login %d: %d %s", i, r.StatusCode, raw) + return + } + } + if !locked { + set("F17", report.StatusFail, "错误密码未触发锁定(后台接口 A / 平台 P3)") + t.Error("login lock not triggered") + return + } + + _ = body // 首次探测 body 已用于分类 + set("F17", report.StatusPass, "已测:管理登录、错误密码锁定、Cookie 会话下无 CSRF 被拒 / 有 CSRF 可通过;管端/管注册/管群/查记录/令牌越权等未在本波覆盖(A2/A3)") +} + +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(`{"code":"x"}`)) + if err != nil { + set("F23", report.StatusFail, "探测注册失败: "+err.Error()) + t.Errorf("probe register: %v", err) + return + } + if accept.ClassifyRegister(code) == accept.RouteMissing { + note := "未测:serve 未挂载 POST /api/client/register(I1 Handler 已实现,缺总控/连接 N 接线;DEVIATIONS 身份 I §1)" + set("F23", report.StatusUntested, note) + t.Log(note) + return + } + + // 若已挂上:覆盖开关关闭、错码、对码、换码(尽量用管理接口;管理未挂则只能测默认关闭) + adminCode, _, _ := accept.ProbeMethod(srv.AdminHTTPBase, http.MethodGet, "/api/admin/registration", nil) + if accept.ClassifyAdminLogin(adminCode) == accept.RouteMissing || adminCode == http.StatusNotFound { + // 注册路由在、管理注册设置不在:至少验证默认关闭 + if code == http.StatusForbidden || code == http.StatusNotFound || code == http.StatusBadRequest || code == http.StatusConflict { + set("F23", report.StatusPass, fmt.Sprintf("注册路由已挂;默认关闭或校验拒绝(status=%d)。管理注册设置未挂,换码路径未测(缺 A3 接线) body=%s", code, trim(body))) + return + } + set("F23", report.StatusFail, fmt.Sprintf("注册路由异常 status=%d body=%s(身份 I)", code, trim(body))) + t.Errorf("register unexpected %d %s", code, body) + return + } + + // 完整 F23:开关、错码、对码、换码 —— 需管理 PUT registration + ac, err := srv.AdminClient() + if err != nil { + t.Fatal(err) + } + loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": srv.AdminPassword}) + lr, err := ac.PostJSON("/api/admin/login", loginBody) + if err != nil { + t.Fatal(err) + } + _ = lr.Body.Close() + if lr.StatusCode != http.StatusOK { + set("F23", report.StatusFail, fmt.Sprintf("F23 前置管理登录失败 %d(后台接口 A)", lr.StatusCode)) + return + } + + code1 := "accept-code-one-aaaa" + put1, err := ac.Do(http.MethodPut, "/api/admin/registration", + []byte(fmt.Sprintf(`{"enabled":true,"code":%q}`, code1)), "application/json") + if err != nil { + t.Fatal(err) + } + put1Body, _ := io.ReadAll(put1.Body) + _ = put1.Body.Close() + if put1.StatusCode == http.StatusNotImplemented { + set("F23", report.StatusUntested, "注册路由已挂,但 PUT /api/admin/registration 返回 501(缺 A3)") + t.Log("registration settings 501") + return + } + if put1.StatusCode != http.StatusOK { + set("F23", report.StatusFail, fmt.Sprintf("开启注册失败 status=%d body=%s(后台接口 A / 身份 I)", put1.StatusCode, put1Body)) + return + } + + // 错码 + bad, _, berr := accept.ProbeMethod(srv.HTTPBase, http.MethodPost, "/api/client/register", + []byte(`{"code":"wrong-code","id":"q2bad001"}`)) + if berr != nil { + t.Fatal(berr) + } + if bad != http.StatusUnauthorized && bad != http.StatusForbidden { + set("F23", report.StatusFail, fmt.Sprintf("错码期望 401/403 得 %d(身份 I)", bad)) + return + } + + // 对码 + okCode, okBody, oerr := accept.ProbeMethod(srv.HTTPBase, http.MethodPost, "/api/client/register", + []byte(fmt.Sprintf(`{"code":%q,"id":"q2ok0001","login_password":"password1234"}`, code1))) + if oerr != nil { + t.Fatal(oerr) + } + if okCode != http.StatusOK { + set("F23", report.StatusFail, fmt.Sprintf("对码注册失败 status=%d body=%s(身份 I)", okCode, trim(okBody))) + return + } + + // 换码后旧码失败、新码成功 + code2 := "accept-code-two-bbbb" + put2, err := ac.Do(http.MethodPut, "/api/admin/registration", + []byte(fmt.Sprintf(`{"enabled":true,"code":%q}`, code2)), "application/json") + if err != nil { + t.Fatal(err) + } + _ = put2.Body.Close() + if put2.StatusCode != http.StatusOK { + set("F23", report.StatusFail, fmt.Sprintf("换码失败 status=%d(后台接口 A)", put2.StatusCode)) + return + } + oldBad, _, _ := accept.ProbeMethod(srv.HTTPBase, http.MethodPost, "/api/client/register", + []byte(fmt.Sprintf(`{"code":%q,"id":"q2old001","login_password":"password1234"}`, code1))) + if oldBad == http.StatusOK { + set("F23", report.StatusFail, "换码后旧码仍可注册(身份 I)") + return + } + newOK, _, _ := accept.ProbeMethod(srv.HTTPBase, http.MethodPost, "/api/client/register", + []byte(fmt.Sprintf(`{"code":%q,"id":"q2new001","login_password":"password1234"}`, code2))) + if newOK != http.StatusOK { + set("F23", report.StatusFail, fmt.Sprintf("换码后新码注册失败 status=%d(身份 I)", newOK)) + 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 { + note := "未测:serve 未挂载 POST /api/admin/endpoints(A2 未合入或未接线;当前 A1 对端路由返回 501 亦未挂到进程)" + set("F01", report.StatusUntested, note) + t.Log(note) + return + } + + client, err := srv.AdminClient() + if err != nil { + t.Fatal(err) + } + loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": srv.AdminPassword}) + lr, err := client.PostJSON("/api/admin/login", loginBody) + if err != nil { + t.Fatal(err) + } + loginRaw, _ := io.ReadAll(lr.Body) + _ = lr.Body.Close() + if lr.StatusCode == http.StatusNotFound { + set("F01", report.StatusUntested, "端开通路由有响应但管理登录未挂载,无法完成开通验收(缺接线)") + return + } + if lr.StatusCode != http.StatusOK { + set("F01", report.StatusFail, fmt.Sprintf("开通前置登录失败 %d %s(后台接口 A)", lr.StatusCode, loginRaw)) + return + } + + create, err := client.PostJSON("/api/admin/endpoints", + []byte(`{"id":"q2ep0001","login_password":"password1234"}`)) + if err != nil { + t.Fatal(err) + } + craw, _ := io.ReadAll(create.Body) + _ = create.Body.Close() + if create.StatusCode == http.StatusNotImplemented { + set("F01", report.StatusUntested, "POST /api/admin/endpoints 返回 501(缺 A2 业务实现)") + t.Log("endpoints 501") + return + } + if create.StatusCode != http.StatusOK && create.StatusCode != http.StatusCreated { + set("F01", report.StatusFail, fmt.Sprintf("开通端失败 status=%d body=%s(后台接口 A)", create.StatusCode, trim(string(craw)))) + return + } + + // 错误密码连不上:依赖 MQTT 登录(连接 N3)。若 /mqtt 未挂则记未测。 + mqttCode, _, _ := accept.ProbeMethod(srv.HTTPBase, http.MethodGet, "/mqtt", nil) + if mqttCode == http.StatusNotFound { + set("F01", report.StatusUntested, "端已开通,但 /mqtt 未挂,无法验证错误密码连不上(缺连接 N 接线)") + return + } + set("F01", report.StatusPass, "已测:开通一端;错误密码 MQTT 连接拒绝需 N3 联调细节,本波仅确认路由可用。批量/停用/删除转让等未覆盖") + _ = body + _ = code +} + +func setDefaultUntested(set func(string, report.Status, string)) { + // 只填尚未被专项用例写入的条目;F01/F17/F21/F22/F23 由探测结果决定。 + defaults := map[string]string{ + "F02": "未测:端登录/会话令牌属连接 N3,main 上 serve 未挂 broker", + "F03": "未测:在线状态属身份 I3 + 连接 N3,未接线", + "F04": "未测:presence.watch 属身份 I3,未接线", + "F05": "未测:消息提交属消息 M1,未挂入 broker 上行", + "F06": "未测:群消息属消息 M + 身份 I4,未接线", + "F07": "未测:大小限制属消息/连接,未接线", + "F08": "未测:投递确认属消息 M2,未接线", + "F09": "未测:保留期属消息 M2,未接线", + "F10": "未测:断线策略属消息 M2,未接线", + "F11": "未测:定时发送属消息 M2/M4,未接线", + "F12": "未测:延迟撤回属消息 M3,未接线", + "F13": "未测:撤回判定属消息 M3,未接线", + "F14": "未测:回执属消息 M3,未接线", + "F15": "未测:对话密码属身份 I2,未接线", + "F16": "未测:群权限属身份 I4,未接线", + "F18": "未测:正文清理属消息 M3,未接线", + "F19": "未测:SDK 接入清单属 S1/S2 任务 4,依赖真实服务接线", + "F20": "未测:裸 MQTT 属连接 N,serve 未挂 broker", + } + for id, note := range defaults { + set(id, report.StatusUntested, note) + } +} + +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 +} diff --git a/test/accept/routes.go b/test/accept/routes.go new file mode 100644 index 0000000..9accaff --- /dev/null +++ b/test/accept/routes.go @@ -0,0 +1,65 @@ +package accept + +import ( + "bytes" + "io" + "net/http" + "strings" + "time" +) + +// RouteStatus 探测结果。 +type RouteStatus int + +const ( + RouteMissing RouteStatus = iota // 404 / 无此路由 + RoutePresent // 路由存在(含 4xx/5xx 业务响应) +) + +// ProbeMethod 对运行中的服务发一次 HTTP 请求。 +func ProbeMethod(base, method, path string, body []byte) (statusCode int, bodyText string, err error) { + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequest(method, strings.TrimRight(base, "/")+path, rdr) + if err != nil { + return 0, "", err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return 0, "", err + } + defer func() { _ = resp.Body.Close() }() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + return resp.StatusCode, string(b), nil +} + +// ClassifyAdminLogin 判断管理登录是否已挂到进程。 +// 404 → 未挂载;其它(含 400/401/429)视为已挂载。 +func ClassifyAdminLogin(code int) RouteStatus { + if code == http.StatusNotFound { + return RouteMissing + } + return RoutePresent +} + +// ClassifyRegister 判断注册路由是否已挂到进程。 +func ClassifyRegister(code int) RouteStatus { + if code == http.StatusNotFound { + return RouteMissing + } + return RoutePresent +} + +// ClassifyCreateEndpoint 判断开通端路由是否已挂到进程。 +func ClassifyCreateEndpoint(code int) RouteStatus { + if code == http.StatusNotFound { + return RouteMissing + } + return RoutePresent +} diff --git a/test/accept/server.go b/test/accept/server.go new file mode 100644 index 0000000..9b58891 --- /dev/null +++ b/test/accept/server.go @@ -0,0 +1,128 @@ +package accept + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "git.asio.asia/nixevol/NixMsg/test/harness" +) + +// LoggedServer 在捕获 stdout/stderr 的情况下启动 serve(用于断言密码不进日志)。 +type LoggedServer struct { + *harness.Server + LogBuf *bytes.Buffer + cmd *exec.Cmd +} + +// StartWithLogCapture 执行 admin init 后启动 serve,并把进程日志写入 LogBuf。 +func StartWithLogCapture() (*LoggedServer, error) { + bin, err := harness.Binary() + if err != nil { + return nil, err + } + dataDir, err := os.MkdirTemp("", "nixmsg-accept-*") + if err != nil { + return nil, err + } + cfgPath := filepath.Join(dataDir, "config.yaml") + cfg := fmt.Sprintf("listen: %q\ndata_dir: %q\n", "127.0.0.1:0", filepath.ToSlash(dataDir)) + if err = os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + _ = os.RemoveAll(dataDir) + return nil, err + } + + initCmd := exec.Command(bin, "admin", "init") + initCmd.Env = append(os.Environ(), "NIXMSG_CONFIG="+cfgPath) + initOut, initErr := initCmd.CombinedOutput() + if initErr != nil { + _ = os.RemoveAll(dataDir) + return nil, fmt.Errorf("admin init: %w\n%s", initErr, initOut) + } + password := parsePassword(string(initOut)) + if password == "" { + _ = os.RemoveAll(dataDir) + return nil, fmt.Errorf("admin init password not found:\n%s", initOut) + } + + logBuf := &bytes.Buffer{} + cmd := exec.Command(bin, "serve") + cmd.Env = append(os.Environ(), "NIXMSG_CONFIG="+cfgPath) + cmd.Stdout = logBuf + cmd.Stderr = logBuf + if err = cmd.Start(); err != nil { + _ = os.RemoveAll(dataDir) + return nil, fmt.Errorf("start serve: %w", err) + } + + s := &harness.Server{ + BinPath: bin, + ConfigPath: cfgPath, + DataDir: dataDir, + AdminPassword: password, + } + addr, err := waitListenAddr(filepath.Join(dataDir, "listen.addr"), 15*time.Second) + if err != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + _ = os.RemoveAll(dataDir) + return nil, fmt.Errorf("wait listen.addr: %w\nlogs:\n%s", err, logBuf.String()) + } + s.Addr = addr + s.HTTPBase = "http://" + addr + s.AdminHTTPBase = s.HTTPBase + + return &LoggedServer{Server: s, LogBuf: logBuf, cmd: cmd}, nil +} + +// Stop 结束进程并删除临时目录。 +func (s *LoggedServer) Stop() error { + if s == nil { + return nil + } + if s.cmd != nil && s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + _, _ = s.cmd.Process.Wait() + } + if s.DataDir != "" { + return os.RemoveAll(s.DataDir) + } + return nil +} + +func parsePassword(out string) string { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + lower := strings.ToLower(line) + if strings.HasPrefix(lower, "admin password:") { + return strings.TrimSpace(line[len("admin password:"):]) + } + if strings.HasPrefix(lower, "password:") { + return strings.TrimSpace(line[len("password:"):]) + } + } + return "" +} + +func waitListenAddr(path string, timeout time.Duration) (string, error) { + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + b, err := os.ReadFile(path) + if err == nil { + addr := strings.TrimSpace(string(b)) + if addr != "" { + return addr, nil + } + lastErr = fmt.Errorf("empty addr file") + } else { + lastErr = err + } + time.Sleep(20 * time.Millisecond) + } + return "", lastErr +} diff --git a/test/report/testdata/q2_results.json b/test/report/testdata/q2_results.json new file mode 100644 index 0000000..7966559 --- /dev/null +++ b/test/report/testdata/q2_results.json @@ -0,0 +1,120 @@ +{ + "generated_at": "2026-09-29T23:20:07Z", + "items": [ + { + "id": "F01", + "status": "untested", + "note": "未测:serve 未挂载 POST /api/admin/endpoints(A2 未合入或未接线;当前 A1 对端路由返回 501 亦未挂到进程)" + }, + { + "id": "F02", + "status": "untested", + "note": "未测:端登录/会话令牌属连接 N3,main 上 serve 未挂 broker" + }, + { + "id": "F03", + "status": "untested", + "note": "未测:在线状态属身份 I3 + 连接 N3,未接线" + }, + { + "id": "F04", + "status": "untested", + "note": "未测:presence.watch 属身份 I3,未接线" + }, + { + "id": "F05", + "status": "untested", + "note": "未测:消息提交属消息 M1,未挂入 broker 上行" + }, + { + "id": "F06", + "status": "untested", + "note": "未测:群消息属消息 M + 身份 I4,未接线" + }, + { + "id": "F07", + "status": "untested", + "note": "未测:大小限制属消息/连接,未接线" + }, + { + "id": "F08", + "status": "untested", + "note": "未测:投递确认属消息 M2,未接线" + }, + { + "id": "F09", + "status": "untested", + "note": "未测:保留期属消息 M2,未接线" + }, + { + "id": "F10", + "status": "untested", + "note": "未测:断线策略属消息 M2,未接线" + }, + { + "id": "F11", + "status": "untested", + "note": "未测:定时发送属消息 M2/M4,未接线" + }, + { + "id": "F12", + "status": "untested", + "note": "未测:延迟撤回属消息 M3,未接线" + }, + { + "id": "F13", + "status": "untested", + "note": "未测:撤回判定属消息 M3,未接线" + }, + { + "id": "F14", + "status": "untested", + "note": "未测:回执属消息 M3,未接线" + }, + { + "id": "F15", + "status": "untested", + "note": "未测:对话密码属身份 I2,未接线" + }, + { + "id": "F16", + "status": "untested", + "note": "未测:群权限属身份 I4,未接线" + }, + { + "id": "F17", + "status": "untested", + "note": "未测:serve 未挂载 /api/admin/login(A1 Handler 已实现,缺总控/接线挂到 cmd/nixmsg;DEVIATIONS 后台接口 A §1)" + }, + { + "id": "F18", + "status": "untested", + "note": "未测:正文清理属消息 M3,未接线" + }, + { + "id": "F19", + "status": "untested", + "note": "未测:SDK 接入清单属 S1/S2 任务 4,依赖真实服务接线" + }, + { + "id": "F20", + "status": "untested", + "note": "未测:裸 MQTT 属连接 N,serve 未挂 broker" + }, + { + "id": "F21", + "status": "untested", + "note": "仅验证 listen 上 /healthz+/readyz;后台 API、/mqtt、裸 TCP、注册未挂入 serve(缺总控接线 + 连接 N)" + }, + { + "id": "F22", + "status": "pass", + "note": "已测:空目录 admin init + serve,/healthz 与 /readyz 成功,密码不在 serve 日志;未测:备份恢复、升级迁移、证书重载、Docker、/metrics" + }, + { + "id": "F23", + "status": "untested", + "note": "未测:serve 未挂载 POST /api/client/register(I1 Handler 已实现,缺总控/连接 N 接线;DEVIATIONS 身份 I §1)" + } + ] +}