298 lines
7.0 KiB
Go
298 lines
7.0 KiB
Go
package nixmsg_test
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 集成测试启动器:在仓库根目录编译 nixmsg,临时目录 + 127.0.0.1:0 + admin init。
|
||
// 不依赖根模块 harness 包(sdk/go 是独立模块,从本目录起测时 go.mod 会挡住 Binary 找根)。
|
||
|
||
type itestServer struct {
|
||
BinPath string
|
||
ConfigPath string
|
||
DataDir string
|
||
Addr string
|
||
HTTPBase string
|
||
AdminHTTPBase string
|
||
AdminPassword string
|
||
MQTTWS string
|
||
cmd *exec.Cmd
|
||
}
|
||
|
||
var (
|
||
itestBinOnce sync.Once
|
||
itestBinPath string
|
||
itestBinErr error
|
||
)
|
||
|
||
func repoRoot(t *testing.T) string {
|
||
t.Helper()
|
||
dir, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for {
|
||
nix := filepath.Join(dir, "cmd", "nixmsg")
|
||
mod := filepath.Join(dir, "go.mod")
|
||
if st, e := os.Stat(nix); e == nil && st.IsDir() {
|
||
if b, e2 := os.ReadFile(mod); e2 == nil && bytes.Contains(b, []byte("module git.asio.asia/nixevol/NixMsg\n")) {
|
||
return dir
|
||
}
|
||
}
|
||
parent := filepath.Dir(dir)
|
||
if parent == dir {
|
||
t.Fatal("找不到仓库根(含 cmd/nixmsg)")
|
||
}
|
||
dir = parent
|
||
}
|
||
}
|
||
|
||
func itestBinary(t *testing.T) string {
|
||
t.Helper()
|
||
itestBinOnce.Do(func() {
|
||
root := ""
|
||
dir, err := os.Getwd()
|
||
if err != nil {
|
||
itestBinErr = err
|
||
return
|
||
}
|
||
for {
|
||
nix := filepath.Join(dir, "cmd", "nixmsg")
|
||
mod := filepath.Join(dir, "go.mod")
|
||
if st, e := os.Stat(nix); e == nil && st.IsDir() {
|
||
if b, e2 := os.ReadFile(mod); e2 == nil && bytes.Contains(b, []byte("module git.asio.asia/nixevol/NixMsg\n")) {
|
||
root = dir
|
||
break
|
||
}
|
||
}
|
||
parent := filepath.Dir(dir)
|
||
if parent == dir {
|
||
itestBinErr = fmt.Errorf("repo root not found")
|
||
return
|
||
}
|
||
dir = parent
|
||
}
|
||
tmp, err := os.MkdirTemp("", "nixmsg-sdk-go-bin-*")
|
||
if err != nil {
|
||
itestBinErr = err
|
||
return
|
||
}
|
||
name := "nixmsg"
|
||
if runtime.GOOS == "windows" {
|
||
name += ".exe"
|
||
}
|
||
out := filepath.Join(tmp, name)
|
||
cmd := exec.Command("go", "build", "-o", out, "./cmd/nixmsg")
|
||
cmd.Dir = root
|
||
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||
if b, e := cmd.CombinedOutput(); e != nil {
|
||
itestBinErr = fmt.Errorf("build nixmsg: %w\n%s", e, b)
|
||
return
|
||
}
|
||
itestBinPath = out
|
||
})
|
||
if itestBinErr != nil {
|
||
t.Fatal(itestBinErr)
|
||
}
|
||
return itestBinPath
|
||
}
|
||
|
||
func startITestServer(t *testing.T) *itestServer {
|
||
t.Helper()
|
||
bin := itestBinary(t)
|
||
dataDir, err := os.MkdirTemp("", "nixmsg-sdk-go-itest-*")
|
||
if err != nil {
|
||
t.Fatal(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)
|
||
t.Fatal(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)
|
||
t.Fatalf("admin init: %v\n%s", initErr, initOut)
|
||
}
|
||
pass := parseAdminPassword(string(initOut))
|
||
if pass == "" {
|
||
_ = os.RemoveAll(dataDir)
|
||
t.Fatalf("admin init 未打印密码:\n%s", initOut)
|
||
}
|
||
|
||
cmd := exec.Command(bin, "serve")
|
||
cmd.Env = append(os.Environ(), "NIXMSG_CONFIG="+cfgPath)
|
||
cmd.Stdout = os.Stderr
|
||
cmd.Stderr = os.Stderr
|
||
if err := cmd.Start(); err != nil {
|
||
_ = os.RemoveAll(dataDir)
|
||
t.Fatal(err)
|
||
}
|
||
s := &itestServer{
|
||
BinPath: bin,
|
||
ConfigPath: cfgPath,
|
||
DataDir: dataDir,
|
||
AdminPassword: pass,
|
||
cmd: cmd,
|
||
}
|
||
addr, err := waitAddrFile(filepath.Join(dataDir, "listen.addr"), 15*time.Second)
|
||
if err != nil {
|
||
_ = s.Stop()
|
||
t.Fatalf("wait listen.addr: %v", err)
|
||
}
|
||
s.Addr = addr
|
||
s.HTTPBase = "http://" + addr
|
||
s.AdminHTTPBase = s.HTTPBase
|
||
s.MQTTWS = "ws://" + addr + "/mqtt"
|
||
t.Cleanup(func() { _ = s.Stop() })
|
||
return s
|
||
}
|
||
|
||
func (s *itestServer) Stop() error {
|
||
if s == nil || s.cmd == nil {
|
||
return nil
|
||
}
|
||
if s.cmd.Process != nil {
|
||
_ = s.cmd.Process.Kill()
|
||
_, _ = s.cmd.Process.Wait()
|
||
}
|
||
s.cmd = nil
|
||
if s.DataDir != "" {
|
||
return os.RemoveAll(s.DataDir)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func parseAdminPassword(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 waitAddrFile(path string, timeout time.Duration) (string, error) {
|
||
deadline := time.Now().Add(timeout)
|
||
var last error
|
||
for time.Now().Before(deadline) {
|
||
b, err := os.ReadFile(path)
|
||
if err == nil {
|
||
addr := strings.TrimSpace(string(b))
|
||
if addr != "" {
|
||
return addr, nil
|
||
}
|
||
last = fmt.Errorf("empty addr")
|
||
} else {
|
||
last = err
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
return "", last
|
||
}
|
||
|
||
type adminHTTP struct {
|
||
base string
|
||
hc *http.Client
|
||
}
|
||
|
||
func newAdminHTTP(t *testing.T, base, password string) *adminHTTP {
|
||
t.Helper()
|
||
jar, err := cookiejar.New(nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
a := &adminHTTP{
|
||
base: strings.TrimRight(base, "/"),
|
||
hc: &http.Client{Timeout: 30 * time.Second, Jar: jar},
|
||
}
|
||
body, _ := json.Marshal(map[string]string{"username": "admin", "password": password})
|
||
res, err := a.do(http.MethodPost, "/api/admin/login", body, "application/json")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer res.Body.Close()
|
||
if res.StatusCode != http.StatusOK {
|
||
raw, _ := io.ReadAll(res.Body)
|
||
t.Fatalf("admin login %d %s", res.StatusCode, raw)
|
||
}
|
||
return a
|
||
}
|
||
|
||
func (a *adminHTTP) do(method, path string, body []byte, ct string) (*http.Response, error) {
|
||
var rdr io.Reader
|
||
if body != nil {
|
||
rdr = bytes.NewReader(body)
|
||
}
|
||
req, err := http.NewRequest(method, a.base+path, rdr)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if ct != "" {
|
||
req.Header.Set("Content-Type", ct)
|
||
}
|
||
switch strings.ToUpper(method) {
|
||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||
req.Header.Set("X-Nixmsg-Request", "1")
|
||
}
|
||
return a.hc.Do(req)
|
||
}
|
||
|
||
func (a *adminHTTP) putRegistration(t *testing.T, enabled bool, code string) {
|
||
t.Helper()
|
||
payload := map[string]any{"enabled": enabled}
|
||
if code != "" {
|
||
payload["code"] = code
|
||
}
|
||
raw, _ := json.Marshal(payload)
|
||
res, err := a.do(http.MethodPut, "/api/admin/registration", raw, "application/json")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer res.Body.Close()
|
||
b, _ := io.ReadAll(res.Body)
|
||
if res.StatusCode != http.StatusOK {
|
||
t.Fatalf("put registration %d %s", res.StatusCode, b)
|
||
}
|
||
}
|
||
|
||
func (a *adminHTTP) kick(t *testing.T, endpointID string) {
|
||
t.Helper()
|
||
res, err := a.do(http.MethodPost, "/api/admin/endpoints/"+endpointID+"/kick", []byte("{}"), "application/json")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer res.Body.Close()
|
||
if res.StatusCode != http.StatusOK {
|
||
b, _ := io.ReadAll(res.Body)
|
||
t.Fatalf("kick %s: %d %s", endpointID, res.StatusCode, b)
|
||
}
|
||
}
|
||
|
||
func delay0() *time.Duration {
|
||
d := time.Duration(0)
|
||
return &d
|
||
}
|