返回 DeepSeek-Reasonix
session_identity_test.go
根目录 / internal / serve / session_identity_test.go
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
553 lines GO