| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "testing" |
| 13 | ) |
| 14 | |
| 15 | // forkTestCapabilities is what a current Serve advertises; the fork token is |
| 16 | // appended only for the serve that actually mounts the fork routes. |
| 17 | const forkTestCapabilities = "execution-v2,session-history-v1,session-identity-v1,session-ownership-v1" |
| 18 | |
| 19 | const forkTestSessionID = "parent-1" |
| 20 | |
| 21 | // forkServeCall is one request the fork serve received. It keeps the fence |
| 22 | // header the bridge attached, which is what proves a command was fenced to the |
| 23 | // session the tab had open. |
| 24 | type forkServeCall struct { |
| 25 | method, path, body, fence string |
| 26 | } |
| 27 | |
| 28 | // forkServe is a Serve stand-in for the remote fork bindings: the token |
| 29 | // handshake with a caller-chosen capability list, the endpoints a remote tab |
| 30 | // needs to reach ready, and the two fork routes under test. Every request but |
| 31 | // the handshake and the event stream is recorded, so a binding that reached a |
| 32 | // route it should not have is visible to the assertions. |
| 33 | type forkServe struct { |
| 34 | t *testing.T |
| 35 | token string |
| 36 | caps string |
| 37 | server *httptest.Server |
| 38 | mu sync.Mutex |
| 39 | calls []forkServeCall |
| 40 | targets string |
| 41 | forkBody string |
| 42 | forkStatus int |
| 43 | dropForkResponse bool |
| 44 | } |
| 45 | |
| 46 | func newForkServe(t *testing.T, forkCapable bool) *forkServe { |
| 47 | t.Helper() |
| 48 | fs := &forkServe{ |
| 49 | t: t, token: "s3cret", caps: forkTestCapabilities, |
| 50 | targets: `{"source":{"hostId":"box","sessionId":"parent-1"},"targets":[],"verifiable":false}`, |
| 51 | forkBody: `{"sessionId":"child-1","turnId":"turn-1","turnNumber":1}`, |
| 52 | } |
| 53 | if forkCapable { |
| 54 | fs.caps += "," + serveCapabilitySessionForkTargetsV1 |
| 55 | } |
| 56 | mux := http.NewServeMux() |
| 57 | mux.HandleFunc("POST /auth/token", func(w http.ResponseWriter, r *http.Request) { |
| 58 | var body struct { |
| 59 | Token string `json:"token"` |
| 60 | } |
| 61 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Token != fs.token { |
| 62 | http.Error(w, "denied", http.StatusUnauthorized) |
| 63 | return |
| 64 | } |
| 65 | http.SetCookie(w, &http.Cookie{Name: "reasonix_token", Value: fs.token, Path: "/", HttpOnly: true}) |
| 66 | w.Header().Set(serveCapabilitiesHeader, fs.caps) |
| 67 | w.WriteHeader(http.StatusNoContent) |
| 68 | }) |
| 69 | mux.HandleFunc("POST /new", func(w http.ResponseWriter, _ *http.Request) { |
| 70 | w.Header().Set("X-Reasonix-Session-ID", forkTestSessionID) |
| 71 | w.WriteHeader(http.StatusNoContent) |
| 72 | }) |
| 73 | mux.HandleFunc("GET /sessions", func(w http.ResponseWriter, _ *http.Request) { |
| 74 | writeTestJSON(w, []serveSessionEntry{ |
| 75 | {Name: "current", Current: true, HostID: "box", SessionID: forkTestSessionID}, |
| 76 | }) |
| 77 | }) |
| 78 | mux.HandleFunc("GET /status", func(w http.ResponseWriter, _ *http.Request) { |
| 79 | _, _ = w.Write([]byte(`{"state":"ready"}`)) |
| 80 | }) |
| 81 | mux.HandleFunc("GET /history", func(w http.ResponseWriter, _ *http.Request) { |
| 82 | _, _ = w.Write([]byte(`[]`)) |
| 83 | }) |
| 84 | mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) { |
| 85 | w.Header().Set("Content-Type", "text/event-stream") |
| 86 | flusher, ok := w.(http.Flusher) |
| 87 | if !ok { |
| 88 | http.Error(w, "no flusher", http.StatusInternalServerError) |
| 89 | return |
| 90 | } |
| 91 | for _, frame := range []string{`{"kind":"session_start"}`, `{"kind":"ready"}`} { |
| 92 | fmt.Fprintf(w, "data: %s\n\n", frame) |
| 93 | } |
| 94 | flusher.Flush() |
| 95 | <-r.Context().Done() |
| 96 | }) |
| 97 | mux.HandleFunc("GET /fork-targets", func(w http.ResponseWriter, _ *http.Request) { |
| 98 | fs.mu.Lock() |
| 99 | payload := fs.targets |
| 100 | fs.mu.Unlock() |
| 101 | w.Header().Set("Content-Type", "application/json") |
| 102 | _, _ = w.Write([]byte(payload)) |
| 103 | }) |
| 104 | mux.HandleFunc("POST /fork-session", func(w http.ResponseWriter, _ *http.Request) { |
| 105 | fs.mu.Lock() |
| 106 | status, payload := fs.forkStatus, fs.forkBody |
| 107 | drop := fs.dropForkResponse |
| 108 | fs.dropForkResponse = false |
| 109 | fs.mu.Unlock() |
| 110 | if drop { |
| 111 | if hijacker, ok := w.(http.Hijacker); ok { |
| 112 | connection, _, _ := hijacker.Hijack() |
| 113 | _ = connection.Close() |
| 114 | return |
| 115 | } |
| 116 | } |
| 117 | if status != 0 { |
| 118 | http.Error(w, payload, status) |
| 119 | return |
| 120 | } |
| 121 | w.Header().Set("Content-Type", "application/json") |
| 122 | _, _ = w.Write([]byte(payload)) |
| 123 | }) |
| 124 | gate := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 125 | if r.URL.Path != "/auth/token" && r.URL.Path != "/events" { |
| 126 | fs.record(r) |
| 127 | } |
| 128 | if r.URL.Path == "/auth/token" { |
| 129 | mux.ServeHTTP(w, r) |
| 130 | return |
| 131 | } |
| 132 | if c, err := r.Cookie("reasonix_token"); err == nil && c.Value == fs.token { |
| 133 | mux.ServeHTTP(w, r) |
| 134 | return |
| 135 | } |
| 136 | http.Error(w, "Unauthorized", http.StatusUnauthorized) |
| 137 | }) |
| 138 | fs.server = httptest.NewServer(gate) |
| 139 | t.Cleanup(fs.server.Close) |
| 140 | return fs |
| 141 | } |
| 142 | |
| 143 | // record keeps one request. Bodies are re-marshaled from their decoded object |
| 144 | // so the recorded form does not depend on the field order the binding wrote. |
| 145 | func (fs *forkServe) record(r *http.Request) { |
| 146 | call := forkServeCall{ |
| 147 | method: r.Method, path: r.URL.Path, |
| 148 | fence: r.Header.Get(expectedSessionIDHeader), |
| 149 | } |
| 150 | if raw, err := io.ReadAll(io.LimitReader(r.Body, 8<<10)); err == nil && len(raw) > 0 { |
| 151 | r.Body = io.NopCloser(bytes.NewReader(raw)) |
| 152 | var decoded any |
| 153 | if json.Unmarshal(raw, &decoded) == nil { |
| 154 | normalized, _ := json.Marshal(decoded) |
| 155 | call.body = string(normalized) |
| 156 | } |
| 157 | } |
| 158 | fs.mu.Lock() |
| 159 | fs.calls = append(fs.calls, call) |
| 160 | fs.mu.Unlock() |
| 161 | } |
| 162 | |
| 163 | func forkRemoteAnchor(a *App, tabID, turnID string, boundary uint64) ForkAnchorView { |
| 164 | a.remoteTabMu.Lock() |
| 165 | defer a.remoteTabMu.Unlock() |
| 166 | return ForkAnchorView{SourceHostID: "box", SourceSessionID: forkTestSessionID, |
| 167 | SessionGeneration: a.remoteTabs[tabID].gen, TurnID: turnID, BoundarySequence: boundary} |
| 168 | } |
| 169 | |
| 170 | func (fs *forkServe) recorded() []forkServeCall { |
| 171 | fs.mu.Lock() |
| 172 | defer fs.mu.Unlock() |
| 173 | return append([]forkServeCall(nil), fs.calls...) |
| 174 | } |
| 175 | |
| 176 | // openForkTab attaches a remote tab to this serve's session and waits for it, |
| 177 | // which is when the handshake capabilities are recorded on the tab. |
| 178 | func openForkTab(t *testing.T, fs *forkServe) (*App, TabMeta) { |
| 179 | t.Helper() |
| 180 | isolateDesktopUserDirs(t) |
| 181 | kernel := &fakeRemoteKernel{ |
| 182 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 183 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 184 | ensureToken: fs.token, |
| 185 | } |
| 186 | seedBridgeTestHost(t, "box") |
| 187 | a := &App{remoteRuntime: kernel} |
| 188 | cleanupRemoteTabPumps(t, a) |
| 189 | return a, openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 190 | } |
| 191 | |
| 192 | // TestForkTargetsCapableRejectsUnknownAndUnadvertisedTabs pins the default: an |
| 193 | // unknown tab, a tab whose handshake has not run, and a serve that advertised |
| 194 | // no fork capability all report false, so no older serve is treated as capable. |
| 195 | func TestForkTargetsCapableRejectsUnknownAndUnadvertisedTabs(t *testing.T) { |
| 196 | fs := newForkServe(t, false) |
| 197 | a, meta := openForkTab(t, fs) |
| 198 | |
| 199 | if a.forkTargetsCapable("missing") { |
| 200 | t.Fatal("unknown tab reported fork-targets capable") |
| 201 | } |
| 202 | a.remoteTabMu.Lock() |
| 203 | a.remoteTabs["pending"] = &remoteTab{id: "pending"} |
| 204 | a.remoteTabMu.Unlock() |
| 205 | if a.forkTargetsCapable("pending") { |
| 206 | t.Fatal("tab without a handshake reported fork-targets capable") |
| 207 | } |
| 208 | if a.forkTargetsCapable(meta.ID) { |
| 209 | t.Fatalf("serve advertising %q reported fork-targets capable", fs.caps) |
| 210 | } |
| 211 | |
| 212 | capable := newForkServe(t, true) |
| 213 | capableApp, capableMeta := openForkTab(t, capable) |
| 214 | if !capableApp.forkTargetsCapable(capableMeta.ID) { |
| 215 | t.Fatalf("serve advertising %q reported fork-targets incapable", capable.caps) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | // assertTabMetaForkTargetsSupported reads one tab exactly as the renderer does, |
| 220 | // through ListTabs, and requires the field to be emitted even when it is false. |
| 221 | func assertTabMetaForkTargetsSupported(t *testing.T, a *App, tabID string, want bool) { |
| 222 | t.Helper() |
| 223 | found := false |
| 224 | for _, meta := range a.ListTabs() { |
| 225 | if meta.ID != tabID { |
| 226 | continue |
| 227 | } |
| 228 | found = true |
| 229 | if meta.ForkTargetsSupported != want { |
| 230 | t.Fatalf("tab %q forkTargetsSupported = %v, want %v", tabID, meta.ForkTargetsSupported, want) |
| 231 | } |
| 232 | raw, err := json.Marshal(meta) |
| 233 | if err != nil { |
| 234 | t.Fatalf("tab %q: marshal meta: %v", tabID, err) |
| 235 | } |
| 236 | if !strings.Contains(string(raw), `"forkTargetsSupported":`) { |
| 237 | t.Fatalf("tab %q meta = %s, want forkTargetsSupported always emitted", tabID, raw) |
| 238 | } |
| 239 | } |
| 240 | if !found { |
| 241 | t.Fatalf("tab %q is missing from ListTabs", tabID) |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // TestRemoteTabMetaForkTargetsSupportedFollowsHandshake pins the renderer's |
| 246 | // view: the tab a capable serve backs reads true, the tab behind a serve that |
| 247 | // never advertised the capability reads false, and a tab whose handshake has |
| 248 | // not run yet reads false too. |
| 249 | func TestRemoteTabMetaForkTargetsSupportedFollowsHandshake(t *testing.T) { |
| 250 | capable, capableMeta := openForkTab(t, newForkServe(t, true)) |
| 251 | unsupported, unsupportedMeta := openForkTab(t, newForkServe(t, false)) |
| 252 | |
| 253 | assertTabMetaForkTargetsSupported(t, capable, capableMeta.ID, true) |
| 254 | assertTabMetaForkTargetsSupported(t, unsupported, unsupportedMeta.ID, false) |
| 255 | |
| 256 | // A restored shell reaches the strip before its handshake, so its meta must |
| 257 | // report false rather than leave the field out. |
| 258 | unsupported.remoteTabMu.Lock() |
| 259 | unsupported.remoteTabs["no-handshake"] = &remoteTab{ |
| 260 | id: "no-handshake", state: "connecting", |
| 261 | ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, |
| 262 | } |
| 263 | unsupported.remoteTabMu.Unlock() |
| 264 | assertTabMetaForkTargetsSupported(t, unsupported, "no-handshake", false) |
| 265 | } |
| 266 | |
| 267 | // TestLocalTabMetaForkTargetsSupportedIsFalse pins that a local tab never reads |
| 268 | // as supported: the local tab builder does not set the field, and the field has |
| 269 | // no omitempty, so a renderer sees false rather than a missing field. |
| 270 | func TestLocalTabMetaForkTargetsSupportedIsFalse(t *testing.T) { |
| 271 | isolateDesktopUserDirs(t) |
| 272 | |
| 273 | app := NewApp() |
| 274 | tab, err := app.EnsureBlankTab("global", "") |
| 275 | if err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | assertTabMetaForkTargetsSupported(t, app, tab.ID, false) |
| 279 | } |
| 280 | |
| 281 | // TestForkTargetsRemoteTabEmptyWithoutCapability pins the empty answer: no |
| 282 | // error the caller must string-match, a slice that marshals as [] rather than |
| 283 | // null, and no request to a route the serve does not have. |
| 284 | func TestForkTargetsRemoteTabEmptyWithoutCapability(t *testing.T) { |
| 285 | fs := newForkServe(t, false) |
| 286 | a, meta := openForkTab(t, fs) |
| 287 | before := len(fs.recorded()) |
| 288 | |
| 289 | for _, tabID := range []string{meta.ID, "missing"} { |
| 290 | view, err := a.ForkTargetsRemoteTab(tabID) |
| 291 | if err != nil { |
| 292 | t.Fatalf("ForkTargetsRemoteTab(%q): %v", tabID, err) |
| 293 | } |
| 294 | if view.Targets == nil { |
| 295 | t.Fatalf("ForkTargetsRemoteTab(%q).Targets is nil; frontend expects []", tabID) |
| 296 | } |
| 297 | encoded, err := json.Marshal(view) |
| 298 | if err != nil { |
| 299 | t.Fatalf("marshal view: %v", err) |
| 300 | } |
| 301 | if !strings.Contains(string(encoded), `"targets":[]`) { |
| 302 | t.Fatalf("ForkTargetsRemoteTab(%q) encoded %s, want an empty array", tabID, encoded) |
| 303 | } |
| 304 | } |
| 305 | if got := fs.recorded()[before:]; len(got) != 0 { |
| 306 | t.Fatalf("incapable serve received %+v, want no request at all", got) |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | // TestForkTargetsRemoteTabDecodesServeTargets pins the read path: the serve's |
| 311 | // targets and verifiability reach the view unchanged. |
| 312 | func TestForkTargetsRemoteTabDecodesServeTargets(t *testing.T) { |
| 313 | fs := newForkServe(t, true) |
| 314 | fs.targets = `{"source":{"hostId":"box","sessionId":"parent-1"},"targets":[{"turnId":"turn-1","boundarySequence":7,"turnNumber":1,"status":"committed","messageId":"m1","available":true},` + |
| 315 | `{"turnId":"turn-2","turnNumber":2,"status":"open","available":false,"reason":"turn_open"}],"verifiable":true}` |
| 316 | a, meta := openForkTab(t, fs) |
| 317 | |
| 318 | view, err := a.ForkTargetsRemoteTab(meta.ID) |
| 319 | if err != nil { |
| 320 | t.Fatalf("ForkTargetsRemoteTab: %v", err) |
| 321 | } |
| 322 | if !view.Verifiable || len(view.Targets) != 2 { |
| 323 | t.Fatalf("view = %+v, want two verifiable targets", view) |
| 324 | } |
| 325 | if view.Targets[0].TurnID != "turn-1" || !view.Targets[0].Available || view.Targets[0].MessageID != "m1" { |
| 326 | t.Fatalf("first target = %+v", view.Targets[0]) |
| 327 | } |
| 328 | if view.Targets[1].Available || view.Targets[1].Reason != "turn_open" { |
| 329 | t.Fatalf("second target = %+v, want the open-turn refusal", view.Targets[1]) |
| 330 | } |
| 331 | fenced := false |
| 332 | for _, call := range fs.recorded() { |
| 333 | if call.path == "/fork-targets" && call.fence == forkTestSessionID { |
| 334 | fenced = true |
| 335 | } |
| 336 | } |
| 337 | if !fenced { |
| 338 | t.Fatal("fork target GET did not carry the expected session id") |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | func TestCreateForkRemoteTabRejectsStaleSourceBeforePosting(t *testing.T) { |
| 343 | fs := newForkServe(t, true) |
| 344 | a, meta := openForkTab(t, fs) |
| 345 | anchor := forkRemoteAnchor(a, meta.ID, "shared-turn", 9) |
| 346 | a.remoteTabMu.Lock() |
| 347 | a.remoteTabs[meta.ID].routing.currentPath = remoteSessionIDRoutePrefix + "source-b" |
| 348 | a.remoteTabMu.Unlock() |
| 349 | before := len(fs.recorded()) |
| 350 | view, err := a.CreateForkRemoteTab(meta.ID, anchor) |
| 351 | if err != nil || view.Reason != "stale_source" { |
| 352 | t.Fatalf("stale remote create = %+v, err=%v", view, err) |
| 353 | } |
| 354 | if got := fs.recorded()[before:]; len(got) != 0 { |
| 355 | t.Fatalf("stale remote create reached Serve: %+v", got) |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | func TestCreateForkRemoteTabReusesOperationAfterLostResponse(t *testing.T) { |
| 360 | fs := newForkServe(t, true) |
| 361 | a, meta := openForkTab(t, fs) |
| 362 | anchor := forkRemoteAnchor(a, meta.ID, "turn-1", 9) |
| 363 | fs.mu.Lock() |
| 364 | fs.dropForkResponse = true |
| 365 | fs.mu.Unlock() |
| 366 | before := len(fs.recorded()) |
| 367 | if _, err := a.CreateForkRemoteTab(meta.ID, anchor); err == nil { |
| 368 | t.Fatal("dropped response unexpectedly succeeded") |
| 369 | } |
| 370 | // Reconstruct Desktop and its remote tab before retrying. The journal is a |
| 371 | // host file, so neither the App instance nor the restored tab id is part of |
| 372 | // the idempotency key. |
| 373 | restarted := &App{remoteRuntime: &fakeRemoteKernel{ |
| 374 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 375 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 376 | ensureToken: fs.token, |
| 377 | }} |
| 378 | cleanupRemoteTabPumps(t, restarted) |
| 379 | restartedMeta := openReadyRemoteTab(t, restarted, RemoteTabOpenOptions{NewSession: true}) |
| 380 | restartedAnchor := forkRemoteAnchor(restarted, restartedMeta.ID, anchor.TurnID, anchor.BoundarySequence) |
| 381 | view, err := restarted.CreateForkRemoteTab(restartedMeta.ID, restartedAnchor) |
| 382 | if err != nil || view.SessionID != "child-1" || view.OperationID == "" { |
| 383 | t.Fatalf("retry = %+v, err=%v", view, err) |
| 384 | } |
| 385 | var operations []string |
| 386 | for _, call := range fs.recorded()[before:] { |
| 387 | if call.path != "/fork-session" { |
| 388 | continue |
| 389 | } |
| 390 | var body map[string]any |
| 391 | if json.Unmarshal([]byte(call.body), &body) == nil { |
| 392 | operations = append(operations, fmt.Sprint(body["operationId"])) |
| 393 | } |
| 394 | } |
| 395 | if len(operations) != 2 || operations[0] == "" || operations[0] != operations[1] { |
| 396 | t.Fatalf("operation ids across unknown-result retry = %v", operations) |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | // TestCreateForkRemoteTabRefusesUnsupportedServe pins that a serve without the |
| 401 | // capability gets the unsupported reason and no fork request at all: falling |
| 402 | // back to /fork would switch the parent session the caller must keep. |
| 403 | func TestCreateForkRemoteTabRefusesUnsupportedServe(t *testing.T) { |
| 404 | fs := newForkServe(t, false) |
| 405 | a, meta := openForkTab(t, fs) |
| 406 | before := len(fs.recorded()) |
| 407 | |
| 408 | view, err := a.CreateForkRemoteTab(meta.ID, forkRemoteAnchor(a, meta.ID, "turn-1", 7)) |
| 409 | if err != nil { |
| 410 | t.Fatalf("CreateForkRemoteTab: %v", err) |
| 411 | } |
| 412 | if view.Opened { |
| 413 | t.Fatal("unsupported serve reported an opened fork") |
| 414 | } |
| 415 | if !strings.Contains(view.Error, serveCapabilitySessionForkTargetsV1) { |
| 416 | t.Fatalf("unsupported error = %q, want the capability named", view.Error) |
| 417 | } |
| 418 | if got := fs.recorded()[before:]; len(got) != 0 { |
| 419 | t.Fatalf("unsupported serve received %+v, want no request at all", got) |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | // TestCreateForkRemoteTabRequiresTurnID pins that a missing turn id is a |
| 424 | // programming error rather than a state a serve could refuse. |
| 425 | func TestCreateForkRemoteTabRequiresTurnID(t *testing.T) { |
| 426 | a := &App{} |
| 427 | if _, err := a.CreateForkRemoteTab("missing", ForkAnchorView{TurnID: " "}); err == nil { |
| 428 | t.Fatal("empty turn id was accepted") |
| 429 | } |
| 430 | } |
| 431 | |
| 432 | // TestCreateForkRemoteTabPostsFencedForkSession pins the create path: one |
| 433 | // fenced POST to /fork-session carrying the turn and operation, a child |
| 434 | // identity in the view, and no request that would rebind the parent session. |
| 435 | func TestCreateForkRemoteTabPostsFencedForkSession(t *testing.T) { |
| 436 | fs := newForkServe(t, true) |
| 437 | a, meta := openForkTab(t, fs) |
| 438 | before := len(fs.recorded()) |
| 439 | |
| 440 | view, err := a.CreateForkRemoteTab(meta.ID, forkRemoteAnchor(a, meta.ID, "turn-4", 9)) |
| 441 | if err != nil { |
| 442 | t.Fatalf("CreateForkRemoteTab: %v", err) |
| 443 | } |
| 444 | if !view.Opened || view.SessionID != "child-1" || view.Error != "" { |
| 445 | t.Fatalf("view = %+v, want the opened child", view) |
| 446 | } |
| 447 | posted := false |
| 448 | for _, call := range fs.recorded()[before:] { |
| 449 | // /new, /resume, /clear, and /fork all move or reload the session the |
| 450 | // tab has open; creating a child must leave that route and lease alone. |
| 451 | switch call.path { |
| 452 | case "/fork-session": |
| 453 | posted = true |
| 454 | if call.method != http.MethodPost { |
| 455 | t.Fatalf("fork-session method = %s", call.method) |
| 456 | } |
| 457 | var body map[string]any |
| 458 | if err := json.Unmarshal([]byte(call.body), &body); err != nil { |
| 459 | t.Fatalf("fork-session body %s: %v", call.body, err) |
| 460 | } |
| 461 | if body["turnId"] != "turn-4" || body["operationId"] == "" || body["sourceSessionId"] != forkTestSessionID || body["boundarySequence"] != float64(9) { |
| 462 | t.Fatalf("fork-session body = %s, want the turn and operation", call.body) |
| 463 | } |
| 464 | if call.fence != forkTestSessionID { |
| 465 | t.Fatalf("fork-session fence = %q, want the parent session id %q", call.fence, forkTestSessionID) |
| 466 | } |
| 467 | case "/fork", "/new", "/resume", "/clear": |
| 468 | t.Fatalf("create fork reached the session-switching route %s", call.path) |
| 469 | } |
| 470 | } |
| 471 | if !posted { |
| 472 | t.Fatalf("no fork-session request: %+v", fs.recorded()[before:]) |
| 473 | } |
| 474 | a.remoteTabMu.Lock() |
| 475 | tab := a.remoteTabs[meta.ID] |
| 476 | path, state := tab.routing.currentPath, tab.state |
| 477 | a.remoteTabMu.Unlock() |
| 478 | if path != remoteSessionIDRoutePrefix+forkTestSessionID || state != "ready" { |
| 479 | t.Fatalf("parent tab moved to path %q state %q, want its original session still ready", path, state) |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | // TestCreateForkRemoteTabSurfacesRefusalReason pins that a refusal status keeps |
| 484 | // the serve's own words instead of a status code. |
| 485 | func TestCreateForkRemoteTabSurfacesRefusalReason(t *testing.T) { |
| 486 | fs := newForkServe(t, true) |
| 487 | a, meta := openForkTab(t, fs) |
| 488 | const reason = `session: turn "turn-2" cannot start a fork (turn_open)` |
| 489 | fs.mu.Lock() |
| 490 | fs.forkStatus, fs.forkBody = http.StatusConflict, `{"code":"fork_unavailable","reason":"turn_open","message":"`+strings.ReplaceAll(reason, `"`, `\"`)+`"}` |
| 491 | fs.mu.Unlock() |
| 492 | |
| 493 | view, err := a.CreateForkRemoteTab(meta.ID, forkRemoteAnchor(a, meta.ID, "turn-2", 9)) |
| 494 | if err != nil { |
| 495 | t.Fatalf("CreateForkRemoteTab: %v", err) |
| 496 | } |
| 497 | if view.Opened || view.Error != reason { |
| 498 | t.Fatalf("view = %+v, want the refusal %q with nothing opened", view, reason) |
| 499 | } |
| 500 | journal, loadErr := loadForkOperations(forkOperationsPath()) |
| 501 | if loadErr != nil || len(journal.Operations) != 0 { |
| 502 | t.Fatalf("explicit remote refusal journal = %+v, err=%v", journal, loadErr) |
| 503 | } |
| 504 | } |
| 505 |