| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270 |
- package main
- import (
- "net"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
- )
- // TestRedact 覆盖 token 脱敏的各种边界(含多 token、终止符、无 token)。
- func TestRedact(t *testing.T) {
- cases := []struct{ in, want string }{
- {"http://127.0.0.1:3099/?token=abc123", "http://127.0.0.1:3099/?token=***"},
- {"no token here", "no token here"},
- {"a token=x&b=1", "a token=***&b=1"},
- {"token=", "token=***"},
- {"token=a token=b", "token=*** token=***"},
- {"token=abc\"x", "token=***\"x"},
- {"", ""},
- }
- for _, c := range cases {
- if got := Redact(c.in); got != c.want {
- t.Errorf("Redact(%q) = %q, want %q", c.in, got, c.want)
- }
- }
- }
- // TestExtractHTTPURL 覆盖 URL 提取与误报拒绝。
- func TestExtractHTTPURL(t *testing.T) {
- cases := []struct{ in, want string }{
- {"dsh web: http://127.0.0.1:3099/?token=abc", "http://127.0.0.1:3099/?token=abc"},
- {"[dsh out] https://example.com/x", "https://example.com/x"},
- {"no url at all", ""},
- {"httpx not a url", ""},
- {"prefix http://a.b c", "http://a.b"},
- }
- for _, c := range cases {
- if got := extractHTTPURL(c.in); got != c.want {
- t.Errorf("extractHTTPURL(%q) = %q, want %q", c.in, got, c.want)
- }
- }
- }
- func TestShortURL(t *testing.T) {
- if got := shortURL("http://127.0.0.1:3080/?token=x"); got != "http://127.0.0.1:3080/" {
- t.Errorf("shortURL 去除 query 失败: %q", got)
- }
- if got := shortURL("http://127.0.0.1:3080/"); got != "http://127.0.0.1:3080/" {
- t.Errorf("shortURL 无 query 不应改变: %q", got)
- }
- }
- func TestHumanDuration(t *testing.T) {
- cases := []struct {
- d time.Duration
- want string
- }{
- {0, "0 秒"},
- {-time.Second, "0 秒"},
- {45 * time.Second, "45 秒"},
- {90 * time.Second, "1 分钟"},
- {2*time.Hour + 5*time.Minute, "2 小时 5 分"},
- }
- for _, c := range cases {
- if got := humanDuration(c.d); got != c.want {
- t.Errorf("humanDuration(%v) = %q, want %q", c.d, got, c.want)
- }
- }
- }
- // TestConfigNormalized 覆盖越界字段回退默认值。
- func TestConfigNormalized(t *testing.T) {
- c := &Config{WebPort: -1, WebHost: "", UpdateIntervalH: 0, RestartDelaySec: 0}
- n := c.normalized()
- if n.WebPort != 3080 {
- t.Errorf("WebPort 应回退 3080,得到 %d", n.WebPort)
- }
- if n.WebHost != "127.0.0.1" {
- t.Errorf("WebHost 应回退 127.0.0.1,得到 %q", n.WebHost)
- }
- if n.UpdateIntervalH != 24 {
- t.Errorf("UpdateIntervalH 应回退 24,得到 %d", n.UpdateIntervalH)
- }
- if n.RestartDelaySec != 5 {
- t.Errorf("RestartDelaySec 应回退 5,得到 %d", n.RestartDelaySec)
- }
- over := &Config{WebPort: 70000, WebHost: "127.0.0.1", UpdateIntervalH: 1, RestartDelaySec: 1}
- if got := over.normalized().WebPort; got != 3080 {
- t.Errorf("超范围 WebPort 应回退 3080,得到 %d", got)
- }
- }
- // TestIsPortOpen 用真实监听端口验证探测。
- func TestIsPortOpen(t *testing.T) {
- ln, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Skipf("无法创建监听: %v", err)
- }
- port := ln.Addr().(*net.TCPAddr).Port
- if !isPortOpen("127.0.0.1", port) {
- t.Errorf("已监听端口 %d 应判定为 open", port)
- }
- _ = ln.Close()
- time.Sleep(100 * time.Millisecond)
- if isPortOpen("127.0.0.1", port) {
- t.Errorf("已关闭端口 %d 应判定为 closed", port)
- }
- }
- // TestConfigRoundTrip 覆盖配置的首次生成、保存重载、BOM 容忍与损坏回退。
- // 通过 dataDirForced 注入临时目录,避免触碰真实用户配置。
- func TestConfigRoundTrip(t *testing.T) {
- dataDirForced = t.TempDir()
- defer func() { dataDirForced = "" }()
- // 首次加载:生成默认配置
- c := loadConfig()
- if c.WebPort != 3080 || c.AutoUpdate || c.RestartDelaySec != 5 {
- t.Fatalf("默认配置异常: %+v", c)
- }
- if _, err := os.Stat(configPath()); err != nil {
- t.Errorf("首次加载应生成配置文件: %v", err)
- }
- // 保存后重载
- c.WebPort = 3099
- c.AutoUpdate = true
- if err := saveConfig(c); err != nil {
- t.Fatalf("保存失败: %v", err)
- }
- c2 := loadConfig()
- if c2.WebPort != 3099 || !c2.AutoUpdate {
- t.Errorf("重载不一致: %+v", c2)
- }
- // BOM 容忍
- bom := append([]byte{0xEF, 0xBB, 0xBF}, []byte(`{"webPort":1234}`)...)
- if err := os.WriteFile(configPath(), bom, 0o600); err != nil {
- t.Fatal(err)
- }
- if got := loadConfig().WebPort; got != 1234 {
- t.Errorf("BOM 配置应解析为 1234,得到 %d", got)
- }
- // 损坏配置回退默认值
- if err := os.WriteFile(configPath(), []byte(`{ broken`), 0o600); err != nil {
- t.Fatal(err)
- }
- if got := loadConfig().WebPort; got != 3080 {
- t.Errorf("损坏配置应回退 3080,得到 %d", got)
- }
- }
- // TestSupervisorLifecycle 用"立即退出的假命令"验证守护状态机:启动、自动重启、停止。
- func TestSupervisorLifecycle(t *testing.T) {
- cmdExe := os.Getenv("COMSPEC")
- if cmdExe == "" {
- t.Skip("缺少 COMSPEC,跳过守护生命周期测试")
- }
- dir := t.TempDir()
- dataDirForced = dir
- defer func() { dataDirForced = "" }()
- // 假 dsh:立即退出(触发守护重启)
- bat := filepath.Join(dir, "fake-dsh.bat")
- if err := os.WriteFile(bat, []byte("@echo off\r\nexit /b 1\r\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- cfg := defaultConfig()
- cfg.NodePath = cmdExe
- cfg.DshBinJS = bat
- cfg.WebPort = 1 // 无监听端口,避免误判"端口被占用"
- cfg.RestartDelaySec = 1
- log := NewLogger()
- defer log.Close()
- s := NewSupervisor(cfg, log)
- s.Start()
- if !s.isDesired() {
- t.Errorf("Start 后 desired 应为 true")
- }
- time.Sleep(2500 * time.Millisecond)
- if got := s.Status().Restarts; got < 1 {
- t.Errorf("假命令立即退出应至少触发一次自动重启,得到 %d", got)
- }
- s.Stop()
- if s.isDesired() {
- t.Errorf("Stop 后 desired 应为 false")
- }
- time.Sleep(600 * time.Millisecond)
- if s.Status().Running {
- t.Errorf("Stop 后不应处于运行态")
- }
- }
- // TestSetAutoUpdatePersists 验证线程安全 setter 同时更新内存与磁盘。
- func TestSetAutoUpdatePersists(t *testing.T) {
- dataDirForced = t.TempDir()
- defer func() { dataDirForced = "" }()
- log := NewLogger()
- defer log.Close()
- s := NewSupervisor(loadConfig(), log)
- if err := s.SetAutoUpdate(true); err != nil {
- t.Fatalf("SetAutoUpdate 失败: %v", err)
- }
- if !loadConfig().AutoUpdate {
- t.Errorf("配置未持久化到磁盘")
- }
- s.mu.Lock()
- inMemory := s.cfg.AutoUpdate
- s.mu.Unlock()
- if !inMemory {
- t.Errorf("内存配置未更新")
- }
- if err := s.SetAutoUpdate(false); err != nil {
- t.Fatalf("SetAutoUpdate(false) 失败: %v", err)
- }
- if loadConfig().AutoUpdate {
- t.Errorf("关闭后配置仍为 true")
- }
- }
- // TestLoggerRotate 验证日志按天轮转(切换文件),并清理测试产物。
- func TestLoggerRotate(t *testing.T) {
- l := NewLogger()
- defer l.Close()
- base := l.Path()
- l.rotate("19990101")
- rotated := l.Path()
- if rotated == base {
- t.Errorf("rotate 未切换日志路径: %q", rotated)
- }
- if !strings.HasSuffix(rotated, "tray-19990101.log") {
- t.Errorf("轮转文件名不符合预期: %q", rotated)
- }
- t.Cleanup(func() { _ = os.Remove(rotated) })
- }
- // TestBackoff 验证指数退避序列与上限。
- func TestBackoff(t *testing.T) {
- s := NewSupervisor(&Config{RestartDelaySec: 3, WebHost: "127.0.0.1", WebPort: 1}, nil)
- // 无日志时 Logger 为 nil 会 panic,这里只测纯计算:手动构造 failures
- _ = s
- want := []time.Duration{3 * time.Second, 6 * time.Second, 12 * time.Second, 24 * time.Second, 48 * time.Second}
- for i, w := range want {
- s.failures = i
- if got := s.backoff(); got != w {
- t.Errorf("failures=%d backoff=%v, want %v", i, got, w)
- }
- }
- // 上限 2 分钟
- s.failures = 10
- if got := s.backoff(); got != 2*time.Minute {
- t.Errorf("退避上限应为 2 分钟,得到 %v", got)
- }
- // 端口占用温和退避上限 30 秒
- s.failures = 10
- if got := s.portBusyBackoff(); got != 30*time.Second {
- t.Errorf("端口占用退避上限应为 30 秒,得到 %v", got)
- }
- }
|