返回 DeepSeek-Reasonix
remote_serve_fake_test.go
根目录 / desktop / remote_serve_fake_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "sync"
10 "testing"
11 "time"
12
13 "reasonix/internal/config"
14 )
15
16 // fakeServe is a minimal Serve stand-in for bridge tests: token handshake,
17 // session enter, an SSE feed that emits two frames then holds, and recorded
18 // command endpoints for the proxy bindings.
19 type fakeServe struct {
20 t *testing.T
21 token string
22 server *httptest.Server
23
24 mu sync.Mutex
25 newCalled int
26 newSessionPath string
27 resumePath string
28 resumeSessionID string
29 cookieOnNew bool
30 sessions []serveSessionEntry
31 calls []string // "METHOD /path body" per command request
32 expectedPaths []string // foreground command fence headers
33 failNext string // non-empty ⇒ next command endpoint replies 409 with this text
34 failEnter string // non-empty ⇒ next /new or /resume replies 409
35 enterDelay time.Duration
36 newStarted, newRelease chan struct{}
37 resumeStarted chan string
38 resumeRelease chan struct{}
39 failHistory bool // /history replies 500 when set
40 historyBody string
41 historyStarted, historyRelease chan struct{}
42 failSessions bool // /sessions replies 500 when set
43 sessionsFailCount int // /sessions replies 500 this many times, then recovers
44 resumeDropCount int // /resume commits the switch but drops the connection unanswered this many times
45 sessionsStarted chan struct{}
46 sessionsRelease chan struct{}
47 eventsConns int // /events connections opened
48 eventsQuery string
49 eventFrames []string
50 eventFeed <-chan string
51 eventsStatus int // non-zero makes /events fail before opening
52 eventsFailCount int // refuse this many /events opens with 503, then serve normally
53 eventsCloseEarly bool // return immediately after the initial 200 frames
54 statusPayload string
55 statusAfterCancel string
56 }
57
58 func (fs *fakeServe) eventsCount() int { fs.mu.Lock(); defer fs.mu.Unlock(); return fs.eventsConns }
59
60 func (fs *fakeServe) recorded() []string {
61 fs.mu.Lock()
62 defer fs.mu.Unlock()
63 out := make([]string, len(fs.calls))
64 copy(out, fs.calls)
65 return out
66 }
67
68 func (fs *fakeServe) recordedExpectedPaths() []string {
69 fs.mu.Lock()
70 defer fs.mu.Unlock()
71 return append([]string(nil), fs.expectedPaths...)
72 }
73
74 func (fs *fakeServe) record(method, path, body string) {
75 fs.mu.Lock()
76 fs.calls = append(fs.calls, method+" "+path+" "+body)
77 fs.mu.Unlock()
78 }
79
80 // newFakeServe builds a stand-in for the workspace Serve's HTTP surface. The
81 // mux is wrapped in a token-mode gate that mirrors the real authGate's
82 // contract: POST /auth/token is matched on the EXACT path (a "//auth/token"
83 // double slash — what naive base+path joins produce from EnsureServer's
84 // trailing-slash LocalURL — is denied with 401 before routing), and every
85 // other path requires the session cookie the bootstrap installs. A bare mux
86 // cannot catch this: it 301-redirects unclean paths and Go's client follows
87 // preserving POST, so a double-slash request would silently succeed here
88 // while the real Serve rejects it.
89 func newFakeServe(t *testing.T, token string, sessions []serveSessionEntry) *fakeServe {
90 t.Helper()
91 fs := &fakeServe{t: t, token: token, sessions: sessions}
92 mux := http.NewServeMux()
93 mux.HandleFunc("POST /auth/token", func(w http.ResponseWriter, r *http.Request) {
94 var body struct {
95 Token string `json:"token"`
96 }
97 if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Token != fs.token {
98 http.Error(w, "denied", http.StatusUnauthorized)
99 return
100 }
101 http.SetCookie(w, &http.Cookie{Name: "reasonix_token", Value: fs.token, Path: "/", HttpOnly: true})
102 w.Header().Set(serveCapabilitiesHeader, "permission-presets-v1,present-files-v1,execution-v2,session-history-v1,session-identity-v1,session-ownership-v1")
103 w.WriteHeader(http.StatusNoContent)
104 })
105 mux.HandleFunc("POST /new", func(w http.ResponseWriter, r *http.Request) {
106 fs.mu.Lock()
107 fs.newCalled++
108 _, cookieErr := r.Cookie("reasonix_token")
109 fs.cookieOnNew = cookieErr == nil
110 fail := fs.failEnter
111 fs.failEnter = ""
112 enterDelay := fs.enterDelay
113 newSessionPath := fs.newSessionPath
114 newStarted, newRelease := fs.newStarted, fs.newRelease
115 if fail != "" {
116 fs.mu.Unlock()
117 http.Error(w, fail, http.StatusConflict)
118 return
119 }
120 // The serve abandons the current session on /new: no file, not listed.
121 for i := range fs.sessions {
122 fs.sessions[i].Current = false
123 }
124 fs.mu.Unlock()
125 if newStarted != nil {
126 select {
127 case newStarted <- struct{}{}:
128 default:
129 }
130 }
131 if newRelease != nil {
132 select {
133 case <-newRelease:
134 case <-r.Context().Done():
135 return
136 }
137 }
138 if enterDelay > 0 {
139 time.Sleep(enterDelay)
140 }
141 if newSessionPath != "" {
142 w.Header().Set("X-Reasonix-Session-Path", newSessionPath)
143 }
144 w.WriteHeader(http.StatusNoContent)
145 })
146 mux.HandleFunc("POST /resume", func(w http.ResponseWriter, r *http.Request) {
147 var body struct {
148 Path string `json:"path"`
149 SessionID string `json:"sessionId"`
150 }
151 // Mirrors the real handler: a canonical row carries only sessionId, so
152 // requiring a path here would hide every identity-route regression.
153 if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" && body.SessionID == "" {
154 http.Error(w, "missing path or sessionId", http.StatusBadRequest)
155 return
156 }
157 fs.mu.Lock()
158 fail := fs.failEnter
159 fs.failEnter = ""
160 enterDelay := fs.enterDelay
161 resumeStarted, resumeRelease := fs.resumeStarted, fs.resumeRelease
162 drop := fs.takeFault(&fs.resumeDropCount)
163 if fail != "" {
164 fs.mu.Unlock()
165 http.Error(w, fail, http.StatusConflict)
166 return
167 }
168 fs.resumePath, fs.resumeSessionID = body.Path, body.SessionID
169 for i := range fs.sessions {
170 fs.sessions[i].Current = body.SessionID != "" && fs.sessions[i].SessionID == body.SessionID ||
171 body.SessionID == "" && fs.sessions[i].Path == body.Path
172 }
173 fs.mu.Unlock()
174 if body.SessionID != "" {
175 w.Header().Set("X-Reasonix-Session-ID", body.SessionID)
176 }
177 if drop {
178 // Serve committed the switch; only the response is lost.
179 dropHTTPConnection(w)
180 return
181 }
182 if resumeStarted != nil {
183 select {
184 case resumeStarted <- body.Path:
185 default:
186 }
187 }
188 if resumeRelease != nil {
189 select {
190 case <-resumeRelease:
191 case <-r.Context().Done():
192 return
193 }
194 }
195 if enterDelay > 0 {
196 time.Sleep(enterDelay)
197 }
198 w.WriteHeader(http.StatusNoContent)
199 })
200 mux.HandleFunc("GET /sessions", func(w http.ResponseWriter, r *http.Request) {
201 fs.record(r.Method, "/sessions", "")
202 fs.mu.Lock()
203 fail := fs.failSessions || fs.takeFault(&fs.sessionsFailCount)
204 started, release := fs.sessionsStarted, fs.sessionsRelease
205 sessions := append([]serveSessionEntry(nil), fs.sessions...)
206 fs.mu.Unlock()
207 if started != nil {
208 select {
209 case started <- struct{}{}:
210 default:
211 }
212 }
213 if release != nil {
214 select {
215 case <-release:
216 case <-r.Context().Done():
217 return
218 }
219 }
220 if fail {
221 http.Error(w, "sessions unavailable", http.StatusInternalServerError)
222 return
223 }
224 writeTestJSON(w, sessions)
225 })
226 mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) {
227 fs.mu.Lock()
228 fs.eventsConns++
229 fs.eventsQuery = r.URL.RawQuery
230 eventsStatus := fs.eventsStatus
231 if fs.takeFault(&fs.eventsFailCount) && eventsStatus == 0 {
232 eventsStatus = http.StatusServiceUnavailable
233 }
234 closeEarly := fs.eventsCloseEarly
235 frames := append([]string(nil), fs.eventFrames...)
236 feed := fs.eventFeed
237 fs.mu.Unlock()
238 if eventsStatus != 0 {
239 http.Error(w, "event stream unavailable", eventsStatus)
240 return
241 }
242 w.Header().Set("Content-Type", "text/event-stream")
243 flusher, ok := w.(http.Flusher)
244 if !ok {
245 http.Error(w, "no flusher", http.StatusInternalServerError)
246 return
247 }
248 if len(frames) == 0 {
249 frames = []string{`{"kind":"session_start"}`, `{"kind":"ready"}`}
250 }
251 for _, frame := range frames {
252 fmt.Fprintf(w, "data: %s\n\n", frame)
253 }
254 flusher.Flush()
255 if closeEarly {
256 return
257 }
258 if feed == nil {
259 <-r.Context().Done()
260 return
261 }
262 for {
263 select {
264 case frame := <-feed:
265 fmt.Fprintf(w, "data: %s\n\n", frame)
266 flusher.Flush()
267 case <-r.Context().Done():
268 return
269 }
270 }
271 })
272 command := func(path string) http.HandlerFunc {
273 return func(w http.ResponseWriter, r *http.Request) {
274 data, _ := io.ReadAll(io.LimitReader(r.Body, 4<<10))
275 fs.record(r.Method, path, string(data))
276 fs.mu.Lock()
277 fs.expectedPaths = append(fs.expectedPaths, r.Header.Get(expectedSessionPathHeader))
278 fail := fs.failNext
279 fs.failNext = ""
280 if path == "/cancel" && fs.statusAfterCancel != "" {
281 fs.statusPayload = fs.statusAfterCancel
282 }
283 fs.mu.Unlock()
284 if fail != "" {
285 http.Error(w, fail, http.StatusConflict)
286 return
287 }
288 if path == "/composer-profile" {
289 w.Header().Set("Content-Type", "application/json")
290 _, _ = io.WriteString(w, `{"drainedApprovalIDs":["approval-1"]}`)
291 return
292 }
293 if path == "/permission/preset" {
294 w.Header().Set("Content-Type", "application/json")
295 _, _ = io.WriteString(w, `{"snapshot":{"sessionId":"remote-session","generation":1,"revision":8,"preset":"workspace-write","workspaceRoot":"/workspace","grants":[],"capabilities":{"backend":"seatbelt","enforcement":"full","supportedPresets":["read-only","workspace-write","danger-full-access"]}}}`)
296 return
297 }
298 w.WriteHeader(http.StatusNoContent)
299 }
300 }
301 for _, path := range []string{"/submit", "/cancel", "/approve", "/plan-decision", "/answer", "/extension-form", "/rewind", "/goal", "/goal/edit", "/goal/pause", "/goal/resume", "/jobs/cancel", "/inbox/items", "/permission/preset", "/composer-profile", "/delete-session", "/model", "/effort", "/quality-floor", "/plan", "/compact", "/fork", "/summarize", "/forget", "/clear"} {
302 mux.HandleFunc("POST "+path, command(path))
303 }
304 snapshot := func(path, payload string) {
305 mux.HandleFunc("GET "+path, func(w http.ResponseWriter, r *http.Request) {
306 fs.record(r.Method, path, "")
307 responsePayload := payload
308 if path == "/history" {
309 fs.mu.Lock()
310 fail := fs.failHistory
311 if fs.historyBody != "" {
312 responsePayload = fs.historyBody
313 }
314 started, release := fs.historyStarted, fs.historyRelease
315 fs.mu.Unlock()
316 if started != nil {
317 started <- struct{}{}
318 }
319 if release != nil {
320 select {
321 case <-release:
322 case <-r.Context().Done():
323 return
324 }
325 }
326 if fail {
327 http.Error(w, "gone", http.StatusInternalServerError)
328 return
329 }
330 }
331 w.Header().Set("Content-Type", "application/json")
332 _, _ = w.Write([]byte(responsePayload))
333 })
334 }
335 snapshot("/history", `[{"role":"user","content":"hi"}]`)
336 snapshot("/context", `{"used":10}`)
337 snapshot("/todos", `[]`)
338 snapshot("/checkpoints", `[{"turn":1}]`)
339 snapshot("/models", `{"current":"remote/chat","label":"chat","models":[{"ref":"remote/chat","provider":"remote","model":"chat","active":true}]}`)
340 snapshot("/commands", `[{"name":"remote-review","description":"Review remotely","kind":"custom","group":"skills"}]`)
341 snapshot("/pending-prompts", `[{"kind":"approval_request","approval":{"id":"approval-1","tool":"bash"}}]`)
342 snapshot("/permission", `{"sessionId":"remote-session","generation":1,"revision":7,"preset":"workspace-write","workspaceRoot":"/workspace","grants":[],"capabilities":{"backend":"seatbelt","enforcement":"full","supportedPresets":["read-only","workspace-write","danger-full-access"]}}`)
343 mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) {
344 fs.record(r.Method, "/status", "")
345 fs.mu.Lock()
346 payload := fs.statusPayload
347 fs.mu.Unlock()
348 if payload == "" {
349 payload = `{"state":"ready"}`
350 }
351 w.Header().Set("Content-Type", "application/json")
352 _, _ = w.Write([]byte(payload))
353 })
354 snapshot("/branches", `{"branches":[]}`)
355 snapshot("/skills", `[]`)
356 gate := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
357 if r.URL.Path == "/auth/token" {
358 mux.ServeHTTP(w, r)
359 return
360 }
361 if c, err := r.Cookie("reasonix_token"); err == nil && c.Value == fs.token {
362 mux.ServeHTTP(w, r)
363 return
364 }
365 http.Error(w, "Unauthorized", http.StatusUnauthorized)
366 })
367 fs.server = httptest.NewServer(gate)
368 t.Cleanup(fs.server.Close)
369 return fs
370 }
371
372 func (fs *fakeServe) snapshot() (newCalled int, resumePath string, cookieOnNew bool) {
373 fs.mu.Lock()
374 defer fs.mu.Unlock()
375 return fs.newCalled, fs.resumePath, fs.cookieOnNew
376 }
377
378 func writeTestJSON(w http.ResponseWriter, v any) {
379 w.Header().Set("Content-Type", "application/json")
380 _ = json.NewEncoder(w).Encode(v)
381 }
382
383 func TestRemoteHostFixtureIsolatesCanonicalWorkspaceRegistry(t *testing.T) {
384 isolateDesktopUserDirs(t)
385 prior := NewApp()
386 if _, err := prior.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil {
387 t.Fatal(err)
388 }
389 seedBridgeTestHost(t, "box")
390 fresh := NewApp()
391 if _, err := fresh.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil {
392 t.Fatalf("remote fixture reused another home's canonical registry: %v", err)
393 }
394 }
395
396 func seedBridgeTestHost(t *testing.T, hostID string) {
397 t.Helper()
398 // A new config home also needs its own canonical registry: global workspace
399 // identity contains that home, while REASONIX_STATE_HOME otherwise survives.
400 home := isolateDesktopUserDirs(t)
401 t.Setenv("REASONIX_HOME", home)
402 if err := editUserConfig(func(c *config.Config) error {
403 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: hostID, Host: "127.0.0.1", Port: 22, User: "dev"})
404 }); err != nil {
405 t.Fatal(err)
406 }
407 }
408
409 func waitForRemoteEventCount(t *testing.T, log *eventLog, prefix string, want int) {
410 t.Helper()
411 deadline := time.Now().Add(3 * time.Second)
412 for {
413 if got := log.count(prefix); got >= want {
414 return
415 }
416 if time.Now().After(deadline) {
417 t.Fatalf("event count for %q = %d, want >= %d (events: %v)", prefix, log.count(prefix), want, log.recorded())
418 }
419 time.Sleep(10 * time.Millisecond)
420 }
421 }
422
423 // cleanupRemoteTabPumps cancels every open tab's SSE pump and waits for all
424 // bridge tasks to return. Waiting matters on Windows: an async resume can
425 // publish its final tab snapshot after the assertion succeeds, racing the
426 // temporary user directory's cleanup.
427 func cleanupRemoteTabPumps(t *testing.T, a *App) {
428 t.Helper()
429 t.Cleanup(func() {
430 a.remoteTabMu.Lock()
431 for _, tab := range a.remoteTabs {
432 if tab.cancel != nil {
433 tab.cancel()
434 }
435 }
436 a.remoteTabMu.Unlock()
437 done := make(chan struct{})
438 go func() { a.remoteTabTasks.Wait(); close(done) }()
439 select {
440 case <-done:
441 case <-time.After(5 * time.Second):
442 t.Error("remote tab tasks did not stop after pump cancellation")
443 }
444 })
445 }
446
447 // openReadyRemoteTab opens a tab against the fake serve and waits for ready.
448 func openReadyRemoteTab(t *testing.T, a *App, opts RemoteTabOpenOptions) TabMeta {
449 t.Helper()
450 meta, err := a.OpenRemoteProjectTab("box", "~/app", opts)
451 if err != nil {
452 t.Fatal(err)
453 }
454 waitForTabState(t, a, meta.ID, "ready")
455 return meta
456 }
457
457 lines GO