返回 DeepSeek-Reasonix
web_runtime_test.go
根目录 / internal / cli / web_runtime_test.go
1 package cli
2
3 import (
4 "encoding/json"
5 "net"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strconv"
10 "testing"
11 "time"
12
13 "reasonix/internal/config"
14 )
15
16 func TestServeConfigWithCommandDefaults(t *testing.T) {
17 tests := []struct {
18 name string
19 command string
20 authExplicit bool
21 configured string
22 want string
23 }{
24 {name: "web generates token by default", command: "web", want: "token"},
25 {name: "web overrides configured none by default", command: "web", configured: "none", want: "token"},
26 {name: "web explicit auth wins", command: "web", authExplicit: true, configured: "none", want: "none"},
27 {name: "serve stays config driven", command: "serve", configured: "password", want: "password"},
28 {name: "serve empty stays backward compatible", command: "serve", want: ""},
29 }
30 for _, tt := range tests {
31 t.Run(tt.name, func(t *testing.T) {
32 got := serveConfigWithCommandDefaults(tt.command, tt.authExplicit, config.ServeConfig{AuthMode: tt.configured})
33 if got.AuthMode != tt.want {
34 t.Fatalf("AuthMode = %q, want %q", got.AuthMode, tt.want)
35 }
36 })
37 }
38 }
39
40 func TestListenWebWithPortRetryUsesNextAvailablePort(t *testing.T) {
41 occupied, err := net.Listen("tcp", "127.0.0.1:0")
42 if err != nil {
43 t.Fatal(err)
44 }
45 defer occupied.Close()
46 base := occupied.Addr().(*net.TCPAddr).Port
47 if base == 65535 {
48 t.Skip("ephemeral allocation left no higher port")
49 }
50
51 ln, err := listenWebWithPortRetry(net.JoinHostPort("127.0.0.1", strconv.Itoa(base)))
52 if err != nil {
53 t.Fatal(err)
54 }
55 defer ln.Close()
56 got := ln.Addr().(*net.TCPAddr).Port
57 if got <= base || got > base+webPortRetryLimit+1 {
58 t.Fatalf("bound port = %d, want a higher port near occupied %d", got, base)
59 }
60 }
61
62 func TestListenWebWithPortRetryPreservesEphemeralPort(t *testing.T) {
63 ln, err := listenWebWithPortRetry("127.0.0.1:0")
64 if err != nil {
65 t.Fatal(err)
66 }
67 defer ln.Close()
68 if got := ln.Addr().(*net.TCPAddr).Port; got == 0 {
69 t.Fatal("kernel did not assign an ephemeral port")
70 }
71 }
72
73 func TestValidateWebSessionID(t *testing.T) {
74 for _, valid := range []string{"20260809-122436.032610000-deepseek-v4-flash", "session with space", "a.b-c_d"} {
75 if err := validateWebSessionID(valid); err != nil {
76 t.Errorf("validateWebSessionID(%q) = %v", valid, err)
77 }
78 }
79 for _, invalid := range []string{"", " ", ".", "..", "a/b", `a\b`, "thing.events"} {
80 if err := validateWebSessionID(invalid); err == nil {
81 t.Errorf("validateWebSessionID(%q) succeeded", invalid)
82 }
83 }
84 }
85
86 func TestFreshWebSessionPathKeepsReservedIdentityWithoutMaterializing(t *testing.T) {
87 dir := t.TempDir()
88 got, err := freshWebSessionPath(dir, "reserved-session")
89 if err != nil {
90 t.Fatal(err)
91 }
92 want := filepath.Join(dir, "reserved-session.jsonl")
93 if got != want {
94 t.Fatalf("fresh path = %q, want %q", got, want)
95 }
96 if _, err := os.Stat(got); !os.IsNotExist(err) {
97 t.Fatalf("fresh identity should stay lazy on disk, stat error = %v", err)
98 }
99 if err := os.WriteFile(got, []byte("existing"), 0o600); err != nil {
100 t.Fatal(err)
101 }
102 if _, err := freshWebSessionPath(dir, "reserved-session"); err == nil {
103 t.Fatal("existing transcript was accepted as a fresh Web identity")
104 }
105 }
106
107 func TestWebInstanceRegistryPreservesIndependentInstances(t *testing.T) {
108 dir := filepath.Join(t.TempDir(), "server", "instances")
109 now := time.UnixMilli(1000)
110 alive := map[int]bool{101: true, 202: true}
111 registry := &webInstanceRegistry{
112 dir: dir,
113 now: func() time.Time { now = now.Add(time.Millisecond); return now },
114 heartbeatInterval: 0,
115 processAlive: func(pid int) bool { return alive[pid] },
116 }
117 first, err := registry.register("127.0.0.1:8787", 101)
118 if err != nil {
119 t.Fatal(err)
120 }
121 t.Cleanup(first.Release)
122 second, err := registry.register("127.0.0.1:8788", 202)
123 if err != nil {
124 t.Fatal(err)
125 }
126 t.Cleanup(second.Release)
127 dirInfo, err := os.Stat(dir)
128 if err != nil {
129 t.Fatal(err)
130 }
131 if runtime.GOOS != "windows" && dirInfo.Mode().Perm() != 0o700 {
132 t.Fatalf("registry directory mode = %o, want 700", dirInfo.Mode().Perm())
133 }
134
135 live, err := registry.listLive()
136 if err != nil {
137 t.Fatal(err)
138 }
139 if len(live) != 2 || live[0].Port != 8787 || live[1].Port != 8788 {
140 t.Fatalf("live instances = %+v, want ports 8787 and 8788", live)
141 }
142 for _, reg := range []*webInstanceRegistration{first, second} {
143 info, err := os.Stat(reg.path)
144 if err != nil {
145 t.Fatal(err)
146 }
147 if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
148 t.Fatalf("instance mode = %o, want 600", info.Mode().Perm())
149 }
150 }
151
152 first.Release()
153 live, err = registry.listLive()
154 if err != nil {
155 t.Fatal(err)
156 }
157 if len(live) != 1 || live[0].PID != 202 {
158 t.Fatalf("live after releasing first = %+v, want only second", live)
159 }
160 }
161
162 func TestWebInstanceRegistrySweepsOnlyConfirmedDeadRecords(t *testing.T) {
163 dir := t.TempDir()
164 deadPath := filepath.Join(dir, "dead.json")
165 dead := webInstanceRecord{ServerID: "dead", PID: 303, Host: "127.0.0.1", Port: 8787, StartedAt: 1, HeartbeatAt: 1}
166 data, err := json.Marshal(dead)
167 if err != nil {
168 t.Fatal(err)
169 }
170 if err := os.WriteFile(deadPath, data, 0o600); err != nil {
171 t.Fatal(err)
172 }
173 garbagePath := filepath.Join(dir, "future.json")
174 if err := os.WriteFile(garbagePath, []byte(`{"future_schema":true}`), 0o600); err != nil {
175 t.Fatal(err)
176 }
177 registry := &webInstanceRegistry{
178 dir: dir,
179 processAlive: func(int) bool { return false },
180 }
181 if err := registry.sweepStale(); err != nil {
182 t.Fatal(err)
183 }
184 if _, err := os.Stat(deadPath); !os.IsNotExist(err) {
185 t.Fatalf("dead record still exists: %v", err)
186 }
187 if _, err := os.Stat(garbagePath); err != nil {
188 t.Fatalf("unparseable future record should be preserved: %v", err)
189 }
190 }
191
191 lines GO