| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/store" |
| 12 | ) |
| 13 | |
| 14 | func TestSessionLocatorSeparatesCanonicalRoutesFromLegacyPaths(t *testing.T) { |
| 15 | legacy := filepath.Join(t.TempDir(), "session-id:real.jsonl") |
| 16 | tests := []struct { |
| 17 | name string |
| 18 | raw string |
| 19 | kind sessionLocatorKind |
| 20 | id string |
| 21 | }{ |
| 22 | {name: "empty", raw: " ", kind: sessionLocatorEmpty}, |
| 23 | {name: "canonical", raw: " session-id:valid-id ", kind: sessionLocatorCanonical, id: "valid-id"}, |
| 24 | {name: "empty canonical id", raw: "session-id:", kind: sessionLocatorInvalid}, |
| 25 | {name: "canonical separator", raw: "session-id:bad/id", kind: sessionLocatorInvalid}, |
| 26 | {name: "canonical reserved name", raw: "session-id:CON", kind: sessionLocatorInvalid}, |
| 27 | {name: "canonical control", raw: "session-id:bad\x01id", kind: sessionLocatorInvalid}, |
| 28 | {name: "canonical windows punctuation", raw: `session-id:bad<id>`, kind: sessionLocatorInvalid}, |
| 29 | {name: "canonical question mark", raw: `session-id:bad?id`, kind: sessionLocatorInvalid}, |
| 30 | {name: "canonical too long", raw: "session-id:" + strings.Repeat("x", 256), kind: sessionLocatorInvalid}, |
| 31 | {name: "legacy colon filename", raw: legacy, kind: sessionLocatorLegacy}, |
| 32 | } |
| 33 | for _, test := range tests { |
| 34 | t.Run(test.name, func(t *testing.T) { |
| 35 | got := classifySessionLocator(test.raw) |
| 36 | if got.kind != test.kind || got.ref.SessionID != test.id { |
| 37 | t.Fatalf("locator = kind:%v id:%q reason:%q", got.kind, got.ref.SessionID, got.reason) |
| 38 | } |
| 39 | }) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestSavedTabRouteCandidateUsesOnlyExactRouteComponents(t *testing.T) { |
| 44 | tests := []struct { |
| 45 | name, raw, kind, id string |
| 46 | }{ |
| 47 | {name: "exact", raw: "session-id:valid-id", kind: "canonical_route", id: "valid-id"}, |
| 48 | {name: "windows drive", raw: `C:\old\session-id:valid-id`, kind: "pseudo_route_path", id: "valid-id"}, |
| 49 | {name: "windows UNC", raw: `\\server\share\session-id:valid-id`, kind: "pseudo_route_path", id: "valid-id"}, |
| 50 | {name: "posix", raw: "/old/session-id:valid-id", kind: "pseudo_route_path", id: "valid-id"}, |
| 51 | {name: "relative", raw: "old/session-id:valid-id"}, |
| 52 | {name: "intermediate component", raw: "/old/session-id:valid-id/history"}, |
| 53 | {name: "transcript", raw: "/old/session-id:valid-id.jsonl"}, |
| 54 | {name: "sidecar", raw: "/old/session-id:valid-id.jsonl.meta"}, |
| 55 | {name: "invalid exact", raw: "session-id:bad/id", kind: "invalid_route"}, |
| 56 | {name: "invalid pseudo", raw: "/old/session-id:bad?id", kind: "invalid_route"}, |
| 57 | } |
| 58 | for _, test := range tests { |
| 59 | t.Run(test.name, func(t *testing.T) { |
| 60 | got := savedTabRouteCandidateForPath(test.raw) |
| 61 | if got.kind != test.kind || got.sessionID != test.id { |
| 62 | t.Fatalf("candidate = kind:%q id:%q", got.kind, got.sessionID) |
| 63 | } |
| 64 | }) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestTabSessionMetaRejectsRouteBeforeCreatingSidecars(t *testing.T) { |
| 69 | isolateDesktopUserDirs(t) |
| 70 | app := NewApp() |
| 71 | t.Cleanup(func() { _ = app.draftStore().Close() }) |
| 72 | tab := &WorkspaceTab{ID: "route", Scope: "global", SessionPath: "session-id:valid-id"} |
| 73 | app.tabs[tab.ID] = tab |
| 74 | |
| 75 | if err := app.saveTabSessionMetaForCurrentSession(tab); err != nil { |
| 76 | t.Fatalf("canonical compatibility route should skip legacy metadata: %v", err) |
| 77 | } |
| 78 | if _, err := os.Stat(store.SessionMeta(filepath.Join(desktopSessionDir(""), "session-id:valid-id"))); !errors.Is(err, os.ErrNotExist) { |
| 79 | t.Fatalf("canonical route produced a metadata sidecar: %v", err) |
| 80 | } |
| 81 | |
| 82 | tab.SessionPath = "session-id:bad/id" |
| 83 | if err := app.saveTabSessionMetaForCurrentSession(tab); err == nil { |
| 84 | t.Fatal("invalid canonical route was silently accepted") |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestCanonicalTabSessionMetaNeverWritesLegacySidecar(t *testing.T) { |
| 89 | isolateDesktopUserDirs(t) |
| 90 | app := NewApp() |
| 91 | t.Cleanup(func() { _ = app.draftStore().Close() }) |
| 92 | tab := &WorkspaceTab{ID: "canonical", Scope: "global", SessionID: "canonical-id", SessionPath: "session-id:canonical-id"} |
| 93 | app.tabs[tab.ID] = tab |
| 94 | |
| 95 | if err := app.saveTabSessionMetaForCurrentSession(tab); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | if err := app.saveTabSessionMeta(tab, "session-id:canonical-id"); err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | if _, err := os.Stat("session-id:canonical-id.meta"); !errors.Is(err, os.ErrNotExist) { |
| 102 | t.Fatalf("canonical tab wrote legacy metadata: %v", err) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestSessionLocatorNeverCanonicalizesRouteAsPath(t *testing.T) { |
| 107 | if got := canonicalTabSessionPath("session-id:valid-id"); got != "" { |
| 108 | t.Fatalf("canonical route became path %q", got) |
| 109 | } |
| 110 | if got := canonicalTabSessionPath("session-id:bad/id"); got != "" { |
| 111 | t.Fatalf("invalid canonical route became path %q", got) |
| 112 | } |
| 113 | if got := sessionRuntimeKey("session-id:valid-id"); got != "session-id:valid-id" { |
| 114 | t.Fatalf("canonical runtime key = %q", got) |
| 115 | } |
| 116 | if got := sessionRuntimeKey("session-id:bad/id"); got != "" { |
| 117 | t.Fatalf("invalid route runtime key = %q", got) |
| 118 | } |
| 119 | if got := sessionRuntimeKey(filepath.Join(t.TempDir(), "not-a-transcript.meta")); got != "" { |
| 120 | t.Fatalf("invalid legacy runtime key = %q", got) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestResolveLegacySessionPathRejectsRoutesBeforePathResolution(t *testing.T) { |
| 125 | dir := t.TempDir() |
| 126 | if _, err := resolveLegacySessionPath("session-id:valid-id", dir); err == nil { |
| 127 | t.Fatal("canonical route was accepted as a legacy path") |
| 128 | } |
| 129 | if _, err := resolveLegacySessionPath("session-id:bad/id", dir); err == nil { |
| 130 | t.Fatal("invalid route was accepted as a legacy path") |
| 131 | } |
| 132 | legacy := filepath.Join(dir, "history.jsonl") |
| 133 | got, err := resolveLegacySessionPath(legacy, dir) |
| 134 | if err != nil || string(got) != legacy { |
| 135 | t.Fatalf("legacy path = %q, %v", got, err) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestLegacyReadersRejectCanonicalAndInvalidRoutes(t *testing.T) { |
| 140 | for _, route := range []string{"session-id:valid-id", "session-id:bad/id"} { |
| 141 | if _, ok := validatedLegacySessionPathForRead(route); ok { |
| 142 | t.Fatalf("route %q was accepted for legacy reads", route) |
| 143 | } |
| 144 | if profile := loadTabSessionProfile(route); profile != defaultTabSessionProfile() { |
| 145 | t.Fatalf("route %q loaded a legacy profile: %+v", route, profile) |
| 146 | } |
| 147 | if got := runningTabSessionGoal(route, "persisted goal"); got != "persisted goal" { |
| 148 | t.Fatalf("route %q read a legacy goal: %q", route, got) |
| 149 | } |
| 150 | if _, _, ok := topicTitleFallbackForOpen("", "topic", route); ok { |
| 151 | t.Fatalf("route %q loaded a legacy title", route) |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | func TestSessionLeaseRejectsRouteBeforeFileAccess(t *testing.T) { |
| 157 | tab := &WorkspaceTab{} |
| 158 | if err := tab.ensureSessionLease("session-id:valid-id"); err != nil { |
| 159 | t.Fatalf("canonical route should use Session Service ownership: %v", err) |
| 160 | } |
| 161 | if tab.sessionLease != nil { |
| 162 | t.Fatal("canonical route acquired a legacy file lease") |
| 163 | } |
| 164 | if err := tab.ensureSessionLease("session-id:bad/id"); err == nil { |
| 165 | t.Fatal("invalid route was silently accepted by the lease boundary") |
| 166 | } |
| 167 | for _, artifact := range []string{"session-id:valid-id.lease.lock", "session-id:valid-id.lease.json"} { |
| 168 | if _, err := os.Lstat(artifact); !errors.Is(err, os.ErrNotExist) { |
| 169 | t.Fatalf("canonical route created %q: %v", artifact, err) |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func TestCanonicalHiddenTabPruneSkipsLegacyMetadataWriter(t *testing.T) { |
| 175 | app := newSavedTabReconcileTestApp(t) |
| 176 | root := t.TempDir() |
| 177 | ref, workspaceID := createLegacyCleanupSession(t, app, root, "hidden-canonical", true) |
| 178 | tab := &WorkspaceTab{ |
| 179 | ID: "hidden", Scope: "project", WorkspaceRoot: root, SessionWorkspace: desktopTabWorkspace{ID: workspaceID}, |
| 180 | SessionID: ref.SessionID, SessionPath: sessionRoute(ref.SessionID), |
| 181 | } |
| 182 | app.tabs[tab.ID] = tab |
| 183 | if err := app.persistHiddenTabBeforePrune(tab.ID, tab); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | for _, artifact := range []string{"session-id:hidden-canonical.meta", "session-id:hidden-canonical.lock"} { |
| 187 | if _, err := os.Lstat(artifact); !errors.Is(err, os.ErrNotExist) { |
| 188 | t.Fatalf("canonical hidden-tab prune created %q: %v", artifact, err) |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func TestCanonicalDetachCloneAndReattachKeepExclusiveIdentity(t *testing.T) { |
| 194 | source := &WorkspaceTab{ID: "source", Scope: "global", SessionID: "canonical-transfer", SessionPath: sessionRoute("canonical-transfer")} |
| 195 | detached := cloneDetachedRuntimeTab(source, sessionRoute("canonical-transfer"), source.currentSessionIdentity()) |
| 196 | if detached == nil || detached.SessionID != "canonical-transfer" || detached.SessionPath != "" { |
| 197 | t.Fatalf("detached identity = id:%q path:%q", detached.SessionID, detached.SessionPath) |
| 198 | } |
| 199 | target := &WorkspaceTab{ID: "target", Scope: "global"} |
| 200 | applyRuntimeTab(target, detached, sessionRoute("canonical-transfer"), context.Background(), nil) |
| 201 | if target.SessionID != "canonical-transfer" || target.SessionPath != "" { |
| 202 | t.Fatalf("reattached identity = id:%q path:%q", target.SessionID, target.SessionPath) |
| 203 | } |
| 204 | } |
| 205 |