Files
NixMsg/test/accept/accept_test.go

497 lines
18 KiB
Go
Raw Permalink 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"
)
// 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
}