| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "reflect" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/desktop/internal/workspacestate" |
| 11 | "reasonix/internal/store" |
| 12 | ) |
| 13 | |
| 14 | func TestRestoreBlockedIdentityPreservesPinsWithoutSidecars(t *testing.T) { |
| 15 | app := newSavedTabReconcileTestApp(t) |
| 16 | finishSavedTabMigration(app) |
| 17 | path := filepath.Join(t.TempDir(), "session-id:unverified") |
| 18 | entry := desktopTabEntry{ID: "blocked", Scope: "global", SessionPath: path, Goal: "saved goal", PinnedFiles: []string{"README.md"}} |
| 19 | file := desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID, TabOrder: []string{entry.ID}} |
| 20 | if err := os.MkdirAll(desktopConfigDir(), 0o700); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | if err := os.WriteFile(filepath.Join(desktopConfigDir(), tabsFileName), mustMarshalJSON(t, file), 0o600); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | app.tabsRestored = make(chan struct{}) |
| 27 | app.restoreOrBuildTabs() |
| 28 | tab := app.tabs[entry.ID] |
| 29 | if tab == nil || tab.StartupErr == "" || tab.Ctrl != nil { |
| 30 | t.Fatalf("blocked restore published an unsafe runtime: %+v", tab) |
| 31 | } |
| 32 | if got := persistedDesktopTabEntry(tab); got.SessionPath != path || got.Goal != entry.Goal || !reflect.DeepEqual(got.PinnedFiles, entry.PinnedFiles) { |
| 33 | t.Fatalf("blocked restore lost saved input: %+v", got) |
| 34 | } |
| 35 | artifacts, err := os.ReadDir(filepath.Dir(path)) |
| 36 | if err != nil || len(artifacts) != 0 { |
| 37 | t.Fatalf("blocked restore created artifacts: %v, %v", artifacts, err) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func TestRestorePinnedContextRejectsUnverifiedLocators(t *testing.T) { |
| 42 | for _, path := range []string{"session-id:valid", "session-id:bad/id", filepath.Join(t.TempDir(), "session-id:unverified")} { |
| 43 | t.Run(path, func(t *testing.T) { |
| 44 | tab := &WorkspaceTab{SessionPath: path} |
| 45 | restoreTabPinnedContext(tab, []string{"legacy.md"}) |
| 46 | if got := tab.pendingLegacyPinnedFilesForPersistence(); !reflect.DeepEqual(got, []string{"legacy.md"}) { |
| 47 | t.Fatalf("unverified input was consumed: %v", got) |
| 48 | } |
| 49 | if _, err := os.Stat(store.SessionPinnedContext(path)); err == nil { |
| 50 | t.Fatal("unverified locator created pinned sidecar") |
| 51 | } |
| 52 | }) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestReconcileSavedTabsRejectsPendingOperationIdentityConflicts(t *testing.T) { |
| 57 | for _, pseudo := range []bool{false, true} { |
| 58 | for _, mapping := range []bool{false, true} { |
| 59 | name := "exact" |
| 60 | if pseudo { |
| 61 | name = "pseudo" |
| 62 | } |
| 63 | if mapping { |
| 64 | name += "-mapping" |
| 65 | } |
| 66 | t.Run(name, func(t *testing.T) { |
| 67 | app := newSavedTabReconcileTestApp(t) |
| 68 | root := t.TempDir() |
| 69 | ref, workspaceID := createLegacyCleanupSession(t, app, root, "session-A", true) |
| 70 | op := workspacestate.Operation{ID: "operation", Kind: "import", Lifecycle: workspacestate.Active, WorkspaceID: workspaceID, SessionIDs: []string{"session-B"}} |
| 71 | if mapping { |
| 72 | op.SessionIDs = []string{ref.SessionID} |
| 73 | op.Mapping = &workspacestate.SourceMapping{SessionID: "session-B"} |
| 74 | } |
| 75 | if err := app.workspaceRegistry().BeginOperation(t.Context(), op); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | before, err := app.workspaceRegistry().Load(t.Context()) |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | finishSavedTabMigration(app) |
| 83 | entry := savedProjectTab("conflict", "", op.ID, root, workspaceID) |
| 84 | entry.SessionPath = sessionRoute(ref.SessionID) |
| 85 | if pseudo { |
| 86 | entry.SessionPath = `C:\old\` + entry.SessionPath |
| 87 | } |
| 88 | got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}}) |
| 89 | if changed || len(got.Tabs) != 1 || !got.Tabs[0].restoreBlocked { |
| 90 | t.Fatalf("pending conflict was accepted: changed=%v tabs=%+v", changed, got.Tabs) |
| 91 | } |
| 92 | if got.Tabs[0].SessionID != entry.SessionID || got.Tabs[0].SessionPath != entry.SessionPath || got.Tabs[0].CreateOperationID != op.ID { |
| 93 | t.Fatalf("pending conflict changed saved identity: %+v", got.Tabs[0]) |
| 94 | } |
| 95 | after, err := app.workspaceRegistry().Load(t.Context()) |
| 96 | if err != nil || !reflect.DeepEqual(before, after) { |
| 97 | t.Fatalf("conflict mutated registry: %v", err) |
| 98 | } |
| 99 | }) |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestReconcileSavedTabsAcceptsMatchingPendingOperationIdentity(t *testing.T) { |
| 105 | app := newSavedTabReconcileTestApp(t) |
| 106 | root := t.TempDir() |
| 107 | ref, workspaceID := createLegacyCleanupSession(t, app, root, "session-A", true) |
| 108 | op := workspacestate.Operation{ID: "operation", Kind: "import", Lifecycle: workspacestate.Active, WorkspaceID: workspaceID, SessionIDs: []string{ref.SessionID}} |
| 109 | if err := app.workspaceRegistry().BeginOperation(t.Context(), op); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | finishSavedTabMigration(app) |
| 113 | entry := savedProjectTab("matching", "", op.ID, root, workspaceID) |
| 114 | entry.SessionPath = sessionRoute(ref.SessionID) |
| 115 | got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}}) |
| 116 | if !changed || len(got.Tabs) != 1 || got.Tabs[0].restoreBlocked || got.Tabs[0].SessionID != ref.SessionID || got.Tabs[0].SessionPath != "" { |
| 117 | t.Fatalf("matching pending identity was rejected: changed=%v tabs=%+v", changed, got.Tabs) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func TestHiddenTabPruneRejectsCanonicalIdentityConflict(t *testing.T) { |
| 122 | app := newSavedTabReconcileTestApp(t) |
| 123 | root := t.TempDir() |
| 124 | ref, workspaceID := createLegacyCleanupSession(t, app, root, "session-A", true) |
| 125 | before, err := app.workspaceRegistry().Load(t.Context()) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | for _, path := range []string{"session-id:session-B", "session-id:bad/id", filepath.Join(t.TempDir(), "session-id:session-B"), filepath.Join(t.TempDir(), "legacy.jsonl")} { |
| 130 | t.Run(path, func(t *testing.T) { |
| 131 | tab := &WorkspaceTab{ID: "conflict", Scope: "project", WorkspaceRoot: root, SessionWorkspace: desktopTabWorkspace{ID: workspaceID}, SessionID: ref.SessionID, SessionPath: path} |
| 132 | app.tabs[tab.ID] = tab |
| 133 | for _, save := range []func() error{ |
| 134 | func() error { return app.persistHiddenTabBeforePrune(tab.ID, tab) }, |
| 135 | func() error { return app.saveTabSessionMeta(tab, sessionRoute(ref.SessionID)) }, |
| 136 | } { |
| 137 | var identityErr *sessionLocatorError |
| 138 | if err := save(); !errors.As(err, &identityErr) { |
| 139 | t.Fatalf("conflicting identity accepted: %v", err) |
| 140 | } |
| 141 | } |
| 142 | if tab.SessionID != ref.SessionID || tab.SessionPath != path { |
| 143 | t.Fatal("failed persistence changed tab identity") |
| 144 | } |
| 145 | }) |
| 146 | } |
| 147 | after, err := app.workspaceRegistry().Load(t.Context()) |
| 148 | if err != nil || !reflect.DeepEqual(before, after) { |
| 149 | t.Fatalf("failed prune mutated registry: %v", err) |
| 150 | } |
| 151 | } |
| 152 |