| 1 | package serve |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "reasonix/internal/agent" |
| 16 | "reasonix/internal/config" |
| 17 | "reasonix/internal/control" |
| 18 | "reasonix/internal/event" |
| 19 | "reasonix/internal/session" |
| 20 | "reasonix/internal/store" |
| 21 | ) |
| 22 | |
| 23 | func TestSessionsDeduplicatesMigratedLegacySource(t *testing.T) { |
| 24 | legacyDir := t.TempDir() |
| 25 | v4Root := filepath.Join(t.TempDir(), "sessions-v4") |
| 26 | service, err := session.NewService("serve-test", session.NewFilesystemPersistence(v4Root)) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard) |
| 31 | ctrl := control.New(control.Options{Executor: exec, SessionDir: legacyDir, SessionService: service, ExclusiveSession: true}) |
| 32 | if _, err := ctrl.BindFreshSession(t.Context(), "current"); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-target"}) |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | if err := service.Close(t.Context(), target.Ref()); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | legacy := filepath.Join(legacyDir, "old.jsonl") |
| 43 | if err := os.WriteFile(legacy, []byte(`{"role":"user","content":"old"}`+"\n"), 0o600); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | mapping := session.MigrationMapping{ |
| 47 | SchemaVersion: session.SchemaVersion, |
| 48 | Entries: []session.MigrationEntry{{SourcePath: agent.CanonicalSessionPath(legacy), TargetID: "canonical-target"}}, |
| 49 | } |
| 50 | data, err := json.Marshal(mapping) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | if err := os.WriteFile(filepath.Join(v4Root, "migration-map.json"), data, 0o600); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | t.Cleanup(ctrl.Close) |
| 58 | t.Cleanup(func() { _ = service.CloseAll(context.Background()) }) |
| 59 | // Service-backed controllers park their runtime in the idle cache after |
| 60 | // close, so the lifecycle fixture's writer-retire wait does not apply; |
| 61 | // these listing tests only need the HTTP surface. |
| 62 | srv := New(ctrl, NewBroadcaster(), config.ServeConfig{}) |
| 63 | t.Cleanup(srv.Close) |
| 64 | recorder := httptest.NewRecorder() |
| 65 | srv.sessions(recorder, httptest.NewRequest(http.MethodGet, "/sessions", nil)) |
| 66 | var rows []sessionListEntry |
| 67 | if err := json.Unmarshal(recorder.Body.Bytes(), &rows); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | for _, row := range rows { |
| 71 | if row.Name == "old" || agent.CanonicalSessionPath(row.Path) == agent.CanonicalSessionPath(legacy) { |
| 72 | t.Fatalf("migrated legacy row was not deduplicated: %+v", rows) |
| 73 | } |
| 74 | } |
| 75 | found := false |
| 76 | for _, row := range rows { |
| 77 | if row.SessionID == "canonical-target" { |
| 78 | found = true |
| 79 | } |
| 80 | } |
| 81 | if !found { |
| 82 | t.Fatalf("canonical target missing from sessions: %+v", rows) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestDeleteSessionDeletesCanonicalIdentity(t *testing.T) { |
| 87 | legacyDir := t.TempDir() |
| 88 | v4Root := filepath.Join(t.TempDir(), "sessions-v4") |
| 89 | service, err := session.NewService("serve-test", session.NewFilesystemPersistence(v4Root)) |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard) |
| 94 | ctrl := control.New(control.Options{Executor: exec, SessionDir: legacyDir, SessionService: service, ExclusiveSession: true}) |
| 95 | current, err := ctrl.BindFreshSession(t.Context(), "current") |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-target"}) |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | if err := service.Close(t.Context(), target.Ref()); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | t.Cleanup(ctrl.Close) |
| 107 | t.Cleanup(func() { _ = service.CloseAll(context.Background()) }) |
| 108 | srv := httptest.NewServer(newLifecycleTestServer(t, ctrl, NewBroadcaster(), config.ServeConfig{}).Handler()) |
| 109 | defer srv.Close() |
| 110 | post := func(body string) int { |
| 111 | resp, err := http.Post(srv.URL+"/delete-session", "application/json", strings.NewReader(body)) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | defer resp.Body.Close() |
| 116 | return resp.StatusCode |
| 117 | } |
| 118 | if got := post(`{"name":"` + current.SessionID + `","sessionId":"` + current.SessionID + `"}`); got != http.StatusConflict { |
| 119 | t.Fatalf("active canonical delete status = %d, want 409", got) |
| 120 | } |
| 121 | if got := post(`{"name":"canonical-target","sessionId":"canonical-target"}`); got != http.StatusNoContent { |
| 122 | t.Fatalf("canonical delete status = %d, want 204", got) |
| 123 | } |
| 124 | if _, err := os.Stat(filepath.Join(v4Root, "canonical-target")); !os.IsNotExist(err) { |
| 125 | t.Fatalf("canonical session still exists or stat failed unexpectedly: %v", err) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | func newExclusiveSessionServe(t *testing.T) (*Server, *control.Controller, *session.Service, session.SessionRef) { |
| 130 | t.Helper() |
| 131 | return newExclusiveSessionServeWithOptions(t, nil) |
| 132 | } |
| 133 | |
| 134 | // newExclusiveSessionServeWithOptions lets a test shape the foreground |
| 135 | // controller (for example install a blocking Runner so a turn can be held open) |
| 136 | // before the exclusive identity is bound. |
| 137 | func newExclusiveSessionServeWithOptions(t *testing.T, configure func(*control.Options)) (*Server, *control.Controller, *session.Service, session.SessionRef) { |
| 138 | t.Helper() |
| 139 | root := filepath.Join(t.TempDir(), "sessions-v4") |
| 140 | service, err := session.NewService("serve-test", session.NewFilesystemPersistence(root)) |
| 141 | if err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | t.Cleanup(func() { |
| 145 | if err := service.Shutdown(context.Background()); err != nil { |
| 146 | t.Errorf("shutdown session service: %v", err) |
| 147 | } |
| 148 | }) |
| 149 | exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard) |
| 150 | opts := control.Options{ |
| 151 | Executor: exec, SessionDir: t.TempDir(), SessionService: service, ExclusiveSession: true, |
| 152 | } |
| 153 | if configure != nil { |
| 154 | configure(&opts) |
| 155 | } |
| 156 | ctrl := control.New(opts) |
| 157 | ref, err := ctrl.BindFreshSession(t.Context(), "current") |
| 158 | if err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | t.Cleanup(ctrl.Close) |
| 162 | t.Cleanup(func() { _ = service.CloseAll(context.Background()) }) |
| 163 | bc := NewBroadcaster() |
| 164 | srv := New(ctrl, bc, config.ServeConfig{}) |
| 165 | // Production serve hosts register a frame tag per controller; the takeover |
| 166 | // tests assert its identity, so wire the sink the CLI server would. |
| 167 | tag := newSessionTagSink(bc) |
| 168 | if id, bound := ctrl.SessionRef(); bound { |
| 169 | tag.SetIdentity("", id.SessionID) |
| 170 | } |
| 171 | srv.RegisterSessionTag(ctrl, tag) |
| 172 | return srv, ctrl, service, ref |
| 173 | } |
| 174 | |
| 175 | func TestExclusiveV3SessionsAndResumeUseImmutableIdentity(t *testing.T) { |
| 176 | srv, ctrl, service, current := newExclusiveSessionServe(t) |
| 177 | target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "target"}) |
| 178 | if err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | if _, err := target.Session().Flush(t.Context()); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | if err := service.Close(t.Context(), target.Ref()); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | |
| 188 | list := httptest.NewRecorder() |
| 189 | srv.sessions(list, httptest.NewRequest(http.MethodGet, "/sessions", nil)) |
| 190 | var rows []sessionListEntry |
| 191 | if err := json.Unmarshal(list.Body.Bytes(), &rows); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | foundCurrent, foundTarget := false, false |
| 195 | for _, row := range rows { |
| 196 | if row.SessionID == current.SessionID && row.HostID == current.HostID && row.Current && row.Path == "" { |
| 197 | foundCurrent = true |
| 198 | } |
| 199 | if row.SessionID == "target" && row.HostID == current.HostID && row.Path == "" { |
| 200 | foundTarget = true |
| 201 | } |
| 202 | } |
| 203 | if !foundCurrent || !foundTarget { |
| 204 | t.Fatalf("v3 rows = %+v", rows) |
| 205 | } |
| 206 | |
| 207 | resume := httptest.NewRecorder() |
| 208 | req := httptest.NewRequest(http.MethodPost, "/resume", strings.NewReader(`{"hostId":"serve-test","sessionId":"target"}`)) |
| 209 | srv.resume(resume, req) |
| 210 | if resume.Code != http.StatusNoContent { |
| 211 | t.Fatalf("resume status = %d: %s", resume.Code, resume.Body.String()) |
| 212 | } |
| 213 | if got := resume.Header().Get(sessionIDHeader); got != "target" { |
| 214 | t.Fatalf("resume session id = %q", got) |
| 215 | } |
| 216 | if got, ok := ctrl.SessionRef(); !ok || got.SessionID != "target" || ctrl.SessionPath() != "" { |
| 217 | t.Fatalf("controller identity = %+v, bound=%v path=%q", got, ok, ctrl.SessionPath()) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func TestSessionsReportsFinalizingExclusiveRuntimeAsRunning(t *testing.T) { |
| 222 | srv, ctrl, service, ref := newExclusiveSessionServe(t) |
| 223 | runtime, ok := service.Runtime(ref) |
| 224 | if !ok { |
| 225 | t.Fatal("current runtime is not published") |
| 226 | } |
| 227 | generation := ctrl.ExecutionGeneration() |
| 228 | runtime.NoteExecution(generation, session.RuntimeFinalizing, "terminal_commit") |
| 229 | defer runtime.NoteExecution(generation, session.RuntimeIdle, "") |
| 230 | |
| 231 | list := httptest.NewRecorder() |
| 232 | srv.sessions(list, httptest.NewRequest(http.MethodGet, "/sessions", nil)) |
| 233 | var rows []sessionListEntry |
| 234 | if err := json.Unmarshal(list.Body.Bytes(), &rows); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | for _, row := range rows { |
| 238 | if row.SessionID == ref.SessionID { |
| 239 | if !row.Running { |
| 240 | t.Fatalf("finalizing session row = %+v, want running", row) |
| 241 | } |
| 242 | return |
| 243 | } |
| 244 | } |
| 245 | t.Fatalf("session %q missing from rows %+v", ref.SessionID, rows) |
| 246 | } |
| 247 | |
| 248 | func TestExclusiveV3MissingResumeDoesNotCreateOrReplaceCurrent(t *testing.T) { |
| 249 | srv, ctrl, service, current := newExclusiveSessionServe(t) |
| 250 | resume := httptest.NewRecorder() |
| 251 | req := httptest.NewRequest(http.MethodPost, "/resume", strings.NewReader(`{"hostId":"serve-test","sessionId":"missing"}`)) |
| 252 | srv.resume(resume, req) |
| 253 | if resume.Code != http.StatusConflict { |
| 254 | t.Fatalf("resume status = %d, want 409", resume.Code) |
| 255 | } |
| 256 | if got, ok := ctrl.SessionRef(); !ok || got != current { |
| 257 | t.Fatalf("current identity changed to %+v, bound=%v", got, ok) |
| 258 | } |
| 259 | if _, err := service.Query().Snapshot(t.Context(), session.SessionRef{HostID: "serve-test", SessionID: "missing"}); err == nil { |
| 260 | t.Fatal("missing Open created a session") |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func TestExclusiveV3ResumeNameFallbackUsesCanonicalIdentity(t *testing.T) { |
| 265 | srv, ctrl, service, current := newExclusiveSessionServe(t) |
| 266 | target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "named-target"}) |
| 267 | if err != nil { |
| 268 | t.Fatal(err) |
| 269 | } |
| 270 | if err := service.Close(t.Context(), target.Ref()); err != nil { |
| 271 | t.Fatal(err) |
| 272 | } |
| 273 | |
| 274 | resume := httptest.NewRecorder() |
| 275 | req := httptest.NewRequest(http.MethodPost, "/resume", strings.NewReader(`{"name":"named-target"}`)) |
| 276 | srv.resume(resume, req) |
| 277 | if resume.Code != http.StatusNoContent { |
| 278 | t.Fatalf("name fallback status = %d: %s", resume.Code, resume.Body.String()) |
| 279 | } |
| 280 | if got := resume.Header().Get(sessionIDHeader); got != "named-target" { |
| 281 | t.Fatalf("name fallback session id = %q", got) |
| 282 | } |
| 283 | if got, ok := ctrl.SessionRef(); !ok || got.HostID != current.HostID || got.SessionID != "named-target" { |
| 284 | t.Fatalf("controller identity after name fallback = %+v, bound=%v", got, ok) |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | func TestExclusiveV3RotationAndMutationFenceReturnSessionID(t *testing.T) { |
| 289 | srv, ctrl, _, current := newExclusiveSessionServe(t) |
| 290 | stale := httptest.NewRequest(http.MethodPost, "/cancel", nil) |
| 291 | stale.Header.Set(expectedSessionIDHeader, "stale") |
| 292 | if err := srv.expectedSessionErrorLocked(stale); err == nil { |
| 293 | t.Fatal("stale immutable identity passed mutation fence") |
| 294 | } |
| 295 | matching := httptest.NewRequest(http.MethodPost, "/cancel", nil) |
| 296 | matching.Header.Set(expectedSessionIDHeader, current.SessionID) |
| 297 | if err := srv.expectedSessionErrorLocked(matching); err != nil { |
| 298 | t.Fatalf("matching identity rejected: %v", err) |
| 299 | } |
| 300 | |
| 301 | rotate := httptest.NewRecorder() |
| 302 | srv.newSession(rotate, httptest.NewRequest(http.MethodPost, "/new", nil)) |
| 303 | if rotate.Code != http.StatusNoContent { |
| 304 | t.Fatalf("new status = %d: %s", rotate.Code, rotate.Body.String()) |
| 305 | } |
| 306 | ref, ok := ctrl.SessionRef() |
| 307 | if !ok || ref.SessionID == current.SessionID || ref.SessionID == "" { |
| 308 | t.Fatalf("rotated identity = %+v, bound=%v", ref, ok) |
| 309 | } |
| 310 | if got := rotate.Header().Get(sessionIDHeader); got != ref.SessionID { |
| 311 | t.Fatalf("new response session id = %q, want %q", got, ref.SessionID) |
| 312 | } |
| 313 | if got := rotate.Header().Get(sessionPathHeader); got != "" { |
| 314 | t.Fatalf("exclusive rotation exposed legacy path %q", got) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | // The persistence list caps one page at 100 rows ordered by the random |
| 319 | // session id, so a workspace with more canonical sessions hid an arbitrary |
| 320 | // subset — including a session another runtime had just taken over. The |
| 321 | // handler must follow NextCursor and surface every row in one response. |
| 322 | func TestSessionsListsBeyondFirstHundredCanonicalSessions(t *testing.T) { |
| 323 | v4Root := filepath.Join(robustTempDir(t), "sessions-v4") |
| 324 | service, err := session.NewService("serve-test", session.NewFilesystemPersistence(v4Root)) |
| 325 | if err != nil { |
| 326 | t.Fatal(err) |
| 327 | } |
| 328 | exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard) |
| 329 | ctrl := control.New(control.Options{Executor: exec, SessionDir: robustTempDir(t), SessionService: service, ExclusiveSession: true}) |
| 330 | const total = 103 |
| 331 | for i := range total { |
| 332 | created, err := service.Create(t.Context(), session.CreateOptions{SessionID: fmt.Sprintf("s%03d", i)}) |
| 333 | if err != nil { |
| 334 | t.Fatal(err) |
| 335 | } |
| 336 | if err := service.Close(t.Context(), created.Ref()); err != nil { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | } |
| 340 | // A catalog full of pending metadata keeps the query's background rebuild |
| 341 | // queue busy, which races the lifecycle fixture's writer-retire check. |
| 342 | // Drive the queue to quiescence before the handler under test runs. |
| 343 | deadline := time.Now().Add(30 * time.Second) |
| 344 | for time.Now().Before(deadline) { |
| 345 | page, err := service.Query().List(t.Context(), "", 100) |
| 346 | if err != nil { |
| 347 | t.Fatal(err) |
| 348 | } |
| 349 | pending := 0 |
| 350 | for _, info := range page.Sessions { |
| 351 | if info.MetadataStatus != session.MetadataReady { |
| 352 | pending++ |
| 353 | } |
| 354 | } |
| 355 | if pending == 0 { |
| 356 | break |
| 357 | } |
| 358 | time.Sleep(50 * time.Millisecond) |
| 359 | } |
| 360 | t.Cleanup(ctrl.Close) |
| 361 | t.Cleanup(func() { _ = service.CloseAll(context.Background()) }) |
| 362 | // Service-backed controllers park their runtime in the idle cache after |
| 363 | // close, so the lifecycle fixture's writer-retire wait does not apply; |
| 364 | // these listing tests only need the HTTP surface. |
| 365 | srv := New(ctrl, NewBroadcaster(), config.ServeConfig{}) |
| 366 | t.Cleanup(srv.Close) |
| 367 | recorder := httptest.NewRecorder() |
| 368 | srv.sessions(recorder, httptest.NewRequest(http.MethodGet, "/sessions", nil)) |
| 369 | var rows []sessionListEntry |
| 370 | if err := json.Unmarshal(recorder.Body.Bytes(), &rows); err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | seen := make(map[string]bool, len(rows)) |
| 374 | for _, row := range rows { |
| 375 | seen[row.SessionID] = true |
| 376 | } |
| 377 | for i := range total { |
| 378 | if !seen[fmt.Sprintf("s%03d", i)] { |
| 379 | t.Fatalf("session s%03d missing from the list (%d rows returned)", i, len(rows)) |
| 380 | } |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | // migratedDeleteFixture hosts an exclusive foreground beside a legacy session |
| 385 | // dir and a v4 root so tests can stage migration maps and drive |
| 386 | // /delete-session against canonical rows. |
| 387 | type migratedDeleteFixture struct { |
| 388 | t *testing.T |
| 389 | srv *Server |
| 390 | url string |
| 391 | legacyDir string |
| 392 | v4Root string |
| 393 | service *session.Service |
| 394 | } |
| 395 | |
| 396 | func newMigratedDeleteFixture(t *testing.T, canonicalIDs ...string) *migratedDeleteFixture { |
| 397 | t.Helper() |
| 398 | legacyDir := t.TempDir() |
| 399 | v4Root := filepath.Join(t.TempDir(), "sessions-v4") |
| 400 | service, err := session.NewService("serve-test", session.NewFilesystemPersistence(v4Root)) |
| 401 | if err != nil { |
| 402 | t.Fatal(err) |
| 403 | } |
| 404 | exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard) |
| 405 | ctrl := control.New(control.Options{Executor: exec, SessionDir: legacyDir, SessionService: service, ExclusiveSession: true}) |
| 406 | if _, err := ctrl.BindFreshSession(t.Context(), "current"); err != nil { |
| 407 | t.Fatal(err) |
| 408 | } |
| 409 | for _, id := range canonicalIDs { |
| 410 | created, err := service.Create(t.Context(), session.CreateOptions{SessionID: id}) |
| 411 | if err != nil { |
| 412 | t.Fatal(err) |
| 413 | } |
| 414 | if err := service.Close(t.Context(), created.Ref()); err != nil { |
| 415 | t.Fatal(err) |
| 416 | } |
| 417 | } |
| 418 | t.Cleanup(ctrl.Close) |
| 419 | t.Cleanup(func() { _ = service.CloseAll(context.Background()) }) |
| 420 | srv := newLifecycleTestServer(t, ctrl, NewBroadcaster(), config.ServeConfig{}) |
| 421 | ts := httptest.NewServer(srv.Handler()) |
| 422 | t.Cleanup(ts.Close) |
| 423 | return &migratedDeleteFixture{t: t, srv: srv, url: ts.URL, legacyDir: legacyDir, v4Root: v4Root, service: service} |
| 424 | } |
| 425 | |
| 426 | // writeLegacy stages a legacy transcript plus one sidecar so a removal can be |
| 427 | // checked against the whole legacy file set, not just the .jsonl. |
| 428 | func (f *migratedDeleteFixture) writeLegacy(dir, name string) string { |
| 429 | f.t.Helper() |
| 430 | path := filepath.Join(dir, name+".jsonl") |
| 431 | if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o600); err != nil { |
| 432 | f.t.Fatal(err) |
| 433 | } |
| 434 | if err := os.WriteFile(store.SessionMeta(path), []byte(`{}`), 0o600); err != nil { |
| 435 | f.t.Fatal(err) |
| 436 | } |
| 437 | return path |
| 438 | } |
| 439 | |
| 440 | func (f *migratedDeleteFixture) writeMigrationMap(entries ...session.MigrationEntry) { |
| 441 | f.t.Helper() |
| 442 | data, err := json.Marshal(session.MigrationMapping{SchemaVersion: session.SchemaVersion, Entries: entries}) |
| 443 | if err != nil { |
| 444 | f.t.Fatal(err) |
| 445 | } |
| 446 | if err := os.WriteFile(filepath.Join(f.v4Root, "migration-map.json"), data, 0o600); err != nil { |
| 447 | f.t.Fatal(err) |
| 448 | } |
| 449 | } |
| 450 | |
| 451 | func (f *migratedDeleteFixture) deleteCanonical(id string) int { |
| 452 | f.t.Helper() |
| 453 | resp, err := http.Post(f.url+"/delete-session", "application/json", strings.NewReader(`{"name":"`+id+`","sessionId":"`+id+`"}`)) |
| 454 | if err != nil { |
| 455 | f.t.Fatal(err) |
| 456 | } |
| 457 | defer resp.Body.Close() |
| 458 | return resp.StatusCode |
| 459 | } |
| 460 | |
| 461 | func (f *migratedDeleteFixture) canonicalExists(id string) bool { |
| 462 | _, err := os.Stat(filepath.Join(f.v4Root, id)) |
| 463 | return err == nil |
| 464 | } |
| 465 | |
| 466 | func fileExists(t *testing.T, path string) bool { |
| 467 | t.Helper() |
| 468 | _, err := os.Stat(path) |
| 469 | if err != nil && !os.IsNotExist(err) { |
| 470 | t.Fatalf("stat %s: %v", path, err) |
| 471 | } |
| 472 | return err == nil |
| 473 | } |
| 474 | |
| 475 | // One legacy head can migrate into several canonical sessions; the listing |
| 476 | // keeps such a source visible as its own row. Deleting one of those targets |
| 477 | // must therefore leave the source (and the sibling target) untouched, or the |
| 478 | // delete removes a transcript the user still sees as a distinct session. |
| 479 | func TestDeleteCanonicalSessionKeepsSharedMigrationSource(t *testing.T) { |
| 480 | f := newMigratedDeleteFixture(t, "target-a", "target-b") |
| 481 | source := f.writeLegacy(f.legacyDir, "shared") |
| 482 | f.writeMigrationMap( |
| 483 | session.MigrationEntry{SourcePath: agent.CanonicalSessionPath(source), TargetID: "target-a"}, |
| 484 | session.MigrationEntry{SourcePath: agent.CanonicalSessionPath(source), TargetID: "target-b"}, |
| 485 | ) |
| 486 | if got := f.deleteCanonical("target-a"); got != http.StatusNoContent { |
| 487 | t.Fatalf("delete status = %d, want 204", got) |
| 488 | } |
| 489 | if f.canonicalExists("target-a") { |
| 490 | t.Fatal("canonical target-a survived its delete") |
| 491 | } |
| 492 | if !f.canonicalExists("target-b") { |
| 493 | t.Fatal("sibling canonical target-b was removed") |
| 494 | } |
| 495 | if !fileExists(t, source) || !fileExists(t, store.SessionMeta(source)) { |
| 496 | t.Fatal("shared legacy source was deleted while target-b still references it") |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | // Once the deleted row was the source's only live target the source would |
| 501 | // resurface in /sessions as a fresh legacy row; the whole legacy file set goes |
| 502 | // with the row. |
| 503 | func TestDeleteCanonicalSessionRemovesSoleMigrationSource(t *testing.T) { |
| 504 | f := newMigratedDeleteFixture(t, "target-a") |
| 505 | source := f.writeLegacy(f.legacyDir, "sole") |
| 506 | f.writeMigrationMap(session.MigrationEntry{SourcePath: agent.CanonicalSessionPath(source), TargetID: "target-a"}) |
| 507 | if got := f.deleteCanonical("target-a"); got != http.StatusNoContent { |
| 508 | t.Fatalf("delete status = %d, want 204", got) |
| 509 | } |
| 510 | if f.canonicalExists("target-a") { |
| 511 | t.Fatal("canonical target-a survived its delete") |
| 512 | } |
| 513 | if fileExists(t, source) || fileExists(t, store.SessionMeta(source)) { |
| 514 | t.Fatal("sole-target legacy source (or its sidecar) survived the canonical delete") |
| 515 | } |
| 516 | } |
| 517 | |
| 518 | // A migration map is data, not authority: a recorded source outside the |
| 519 | // session dir is never removed, while the canonical delete itself still lands. |
| 520 | func TestDeleteCanonicalSessionLeavesMigrationSourceOutsideSessionDir(t *testing.T) { |
| 521 | f := newMigratedDeleteFixture(t, "target-a") |
| 522 | outside := f.writeLegacy(t.TempDir(), "elsewhere") |
| 523 | f.writeMigrationMap(session.MigrationEntry{SourcePath: agent.CanonicalSessionPath(outside), TargetID: "target-a"}) |
| 524 | if got := f.deleteCanonical("target-a"); got != http.StatusNoContent { |
| 525 | t.Fatalf("delete status = %d, want 204", got) |
| 526 | } |
| 527 | if f.canonicalExists("target-a") { |
| 528 | t.Fatal("canonical target-a survived its delete") |
| 529 | } |
| 530 | if !fileExists(t, outside) || !fileExists(t, store.SessionMeta(outside)) { |
| 531 | t.Fatal("transcript outside the session dir was removed on the strength of a migration map") |
| 532 | } |
| 533 | } |
| 534 | |
| 535 | // The source obeys the same ownership gate as a named legacy delete. A source a |
| 536 | // local window is writing refuses the request before anything is removed, so |
| 537 | // the row is not deleted only to resurrect its source. |
| 538 | func TestDeleteCanonicalSessionRefusesMirroredMigrationSource(t *testing.T) { |
| 539 | f := newMigratedDeleteFixture(t, "target-a") |
| 540 | source := f.writeLegacy(f.legacyDir, "mirrored") |
| 541 | f.writeMigrationMap(session.MigrationEntry{SourcePath: agent.CanonicalSessionPath(source), TargetID: "target-a"}) |
| 542 | f.srv.markMirrored(mirroredSession{path: source, mirrorID: "local-writer", phase: mirrorPhaseExternal}) |
| 543 | if got := f.deleteCanonical("target-a"); got != http.StatusConflict { |
| 544 | t.Fatalf("delete status = %d, want 409", got) |
| 545 | } |
| 546 | if !f.canonicalExists("target-a") { |
| 547 | t.Fatal("canonical row was deleted although its mirrored source refused") |
| 548 | } |
| 549 | if !fileExists(t, source) { |
| 550 | t.Fatal("mirrored legacy source was removed") |
| 551 | } |
| 552 | } |
| 553 |