test: 补 Q2 已有功能集成验收与 F01-F23 对照表

This commit is contained in:
Nixevol
2026-09-30 07:46:50 +08:00
parent 63b09beead
commit 550ed534e4
7 changed files with 875 additions and 0 deletions
+496
View File
@@ -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
}