1import {
2 AuthOrchestrationOperateScope,
3 AuthSourceControlWriteScope,
4 EnvironmentAuthorizationError,
5 EnvironmentId,
6 ProjectId,
7 ThreadId,
8 type ScopedThreadRef,
9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import { AsyncResult, Atom } from "effect/reactivity";
12import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
13
14function deferred<T>() {
15 let resolve!: (value: T) => void;
16 const promise = new Promise<T>((complete) => {
17 resolve = complete;
18 });
19 return { promise, resolve };
20}
21
22const resourceQueries = vi.hoisted(() => ({
23 calls: 0,
24 terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null,
25 tabs: null as unknown[] | null,
26}));
27vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({
28 ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()),
29 executeAtomQuery: async (_registry: unknown, atom: string) => {
30 resourceQueries.calls++;
31 const value =
32 atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs };
33 return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) ===
34 null
35 ? { _tag: "Failure", cause: {} }
36 : { _tag: "Success", value };
37 },
38}));
39vi.mock("../connection/hub", () => ({
40 hubConnection: {
41 organizationPermissionAtom: (id: EnvironmentId) => Atom.make(state.organization.has(id)),
42 },
43}));
44vi.mock("../connection/catalog", () => ({
45 environmentCatalog: {
46 stateAtom: (_id: EnvironmentId) => Atom.make(AsyncResult.success({ phase: "offline" })),
47 catalogValueAtom: Atom.make({ isReady: true, entries: new Map() }),
48 },
49}));
50vi.mock("../state/terminal", () => ({
51 terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" },
52}));
53vi.mock("../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } }));
54
55const state = vi.hoisted(() => ({
56 scopes: new Map<string, Set<string>>(),
57 organization: new Set<string>(),
58 threads: [] as {
59 environmentId: EnvironmentId;
60 id: ThreadId;
61 projectId: ProjectId;
62 title: string;
63 worktreePath: string | null;
64 session: { status: "ready" | "stopped" } | null;
65 latestTurn: null;
66 }[],
67 requests: [] as { action: string; environmentId: string; input: { threadId?: string } }[],
68 localEffects: [] as string[],
69 confirm: vi.fn<(message: string) => Promise<boolean>>(),
70 toasts: [] as string[],
71 afterRequest: undefined as ((action: string) => void) | undefined,
72 sessionLookupFails: false,
73}));
74
75vi.mock("react", () => ({
76 useCallback: (callback: unknown) => callback,
77 useMemo: (factory: () => unknown) => factory(),
78 useRef: (current: unknown) => ({ current }),
79}));
80vi.mock("@tanstack/react-router", () => ({
81 useRouter: () => ({ state: { matches: [] }, navigate: async () => {} }),
82}));
83vi.mock("../state/session", () => ({
84 readThreadOrganizationPermission: (id: string) =>
85 state.organization.has(id) || state.scopes.get(id)?.has("orchestration:operate") === true,
86 environmentSession: { sessionStateAtom: "session" },
87 readEnvironmentScope: (environmentId: string, scope: string) =>
88 state.scopes.get(environmentId)?.has(scope) === true,
89}));
90vi.mock("../state/use-atom-command", () => ({
91 useAtomCommand:
92 (action: string) =>
93 async (request: { environmentId: string; input: { threadId?: string } }) => {
94 state.requests.push({ action, ...request });
95 const scope =
96 action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope;
97 if (
98 !state.scopes.get(request.environmentId)?.has(scope) &&
99 !(
100 state.organization.has(request.environmentId) &&
101 [
102 "settle",
103 "unsettle",
104 "snooze",
105 "unsnooze",
106 "pin",
107 "unpin",
108 "reorderPin",
109 "reorderActive",
110 "setAutoSettle",
111 ].includes(action)
112 )
113 ) {
114 return AsyncResult.failure(Cause.fail(new Error("Server denied the request")));
115 }
116 state.afterRequest?.(action);
117 return AsyncResult.success(undefined);
118 },
119}));
120vi.mock("../state/use-atom-query-runner", () => ({
121 useAtomQueryRunner: () => async (environmentId: string) =>
122 state.sessionLookupFails
123 ? AsyncResult.failure(Cause.fail(new Error("Session lookup failed")))
124 : AsyncResult.success({
125 authenticated: true,
126 scopes: [...(state.scopes.get(environmentId) ?? [])],
127 auth: { serverUpdateScope: "environment:maintain" },
128 }),
129}));
130vi.mock("../state/threads", () => ({
131 threadEnvironment: Object.fromEntries(
132 [
133 "archive",
134 "unarchive",
135 "delete",
136 "settle",
137 "unsettle",
138 "pin",
139 "unpin",
140 "reorderPin",
141 "snooze",
142 "unsnooze",
143 "stopSession",
144 ].map((action) => [action, action]),
145 ),
146}));
147vi.mock("../state/vcs", () => ({
148 vcsEnvironment: { removeWorktree: "removeWorktree", refreshStatus: "refreshStatus" },
149}));
150vi.mock("../state/entities", () => ({
151 readEnvironmentSupportsPinning: () => true,
152 readEnvironmentSupportsPinReorder: () => true,
153 readEnvironmentSupportsSettlement: () => true,
154 readEnvironmentSupportsSnooze: () => true,
155 readThreadShell: (ref: ScopedThreadRef) =>
156 state.threads.find(
157 (thread) => thread.environmentId === ref.environmentId && thread.id === ref.threadId,
158 ) ?? null,
159 readThreadShells: () => state.threads,
160 readEnvironmentThreadRefs: (environmentId: EnvironmentId) =>
161 state.threads
162 .filter((thread) => thread.environmentId === environmentId)
163 .map((thread) => ({ environmentId, threadId: thread.id })),
164 readProject: () => ({ workspaceRoot: "/project" }),
165}));
166vi.mock("../components/Sidebar.logic", async (original) => ({
167 ...(await original<typeof import("../components/Sidebar.logic")>()),
168 getFallbackThreadIdAfterDelete: () => null,
169 pinOrderKeyBetween: () => "a",
170}));
171vi.mock("../composerDraftStore", () => ({
172 useComposerDraftStore: (select: (store: unknown) => unknown) =>
173 select({
174 clearDraftThread: () => state.localEffects.push("clear-draft"),
175 clearProjectDraftThreadById: () => state.localEffects.push("clear-project-draft"),
176 }),
177}));
178vi.mock("../terminalUiStateStore", () => ({
179 useTerminalUiStateStore: (select: (store: unknown) => unknown) =>
180 select({
181 clearTerminalUiState: () => state.localEffects.push("clear-terminal-ui"),
182 }),
183}));
184vi.mock("../uiStateStore", () => ({
185 useUiStateStore: (select: (store: unknown) => unknown) =>
186 select({
187 markThreadVisited: () => state.localEffects.push("mark-visited"),
188 }),
189}));
190vi.mock("../lib/archivedThreadsState", () => ({
191 refreshArchivedThreadsForEnvironment: () => state.localEffects.push("refresh-archive"),
192}));
193vi.mock("../lib/composerDraftUploads", () => ({
194 releaseComposerDraftUploads: () => state.localEffects.push("release-uploads"),
195}));
196vi.mock("../localApi", () => ({
197 readLocalApi: () => ({ dialogs: { confirm: state.confirm } }),
198}));
199vi.mock("../threadRoutes", () => ({
200 resolveThreadRouteRef: () => null,
201 buildThreadRouteParams: (ref: ScopedThreadRef) => ref,
202}));
203vi.mock("../components/ui/toast", () => ({
204 stackedThreadToast: (toast: unknown) => toast,
205 toastManager: {
206 add: (toast: { title?: string }) => {
207 if (toast.title) state.toasts.push(toast.title);
208 },
209 },
210}));
211vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => async () => {} }));
212vi.mock("./useSettings", () => ({
213 useClientSettings: (select: (settings: unknown) => unknown) =>
214 select({
215 sidebarThreadSortOrder: "createdAt",
216 confirmThreadDelete: true,
217 confirmThreadUnpin: true,
218 }),
219}));
220
221import { useThreadActions } from "./useThreadActions";
222import { archiveSelectedThreadEntries } from "../components/Sidebar.logic";
223
224const primary = EnvironmentId.make("primary");
225const secondary = EnvironmentId.make("secondary");
226const target = { environmentId: secondary, threadId: ThreadId.make("thread") };
227type ThreadActions = ReturnType<typeof useThreadActions>;
228const operations = [
229 {
230 name: "archive",
231 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.archiveThread(ref),
232 },
233 {
234 name: "unarchive",
235 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unarchiveThread(ref),
236 },
237 {
238 name: "settle",
239 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.settleThread(ref),
240 },
241 {
242 name: "unsettle",
243 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsettleThread(ref),
244 },
245 {
246 name: "snooze",
247 run: (actions: ThreadActions, ref: ScopedThreadRef) =>
248 actions.snoozeThread(ref, "2099-01-01T00:00:00Z"),
249 },
250 {
251 name: "unsnooze",
252 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsnoozeThread(ref),
253 },
254 { name: "pin", run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.pinThread(ref) },
255 {
256 name: "unpin",
257 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unpinThread(ref),
258 },
259 {
260 name: "reorderPin",
261 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.reorderPinnedThread(ref, "b"),
262 },
263] as const;
264
265beforeEach(() => {
266 resourceQueries.calls = 0;
267 resourceQueries.terminals = null;
268 resourceQueries.tabs = null;
269 state.scopes = new Map([
270 [primary, new Set([AuthOrchestrationOperateScope])],
271 [secondary, new Set<string>()],
272 ]);
273 state.threads = [
274 {
275 environmentId: secondary,
276 id: target.threadId,
277 projectId: ProjectId.make("project"),
278 title: "Thread",
279 worktreePath: null,
280 session: null,
281 latestTurn: null,
282 },
283 ];
284 state.requests = [];
285 state.localEffects = [];
286 state.confirm.mockReset().mockResolvedValue(true);
287 state.toasts = [];
288 state.afterRequest = undefined;
289 state.sessionLookupFails = false;
290 state.organization.clear();
291});
292
293describe("thread action permissions", () => {
294 it("stops a bulk archive when its environment loses permission between entries", async () => {
295 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
296 const second = { ...target, threadId: ThreadId.make("second") };
297 state.threads.push({ ...state.threads[0]!, id: second.threadId });
298 state.afterRequest = (action) => {
299 if (action === "archive") state.scopes.get(secondary)!.clear();
300 };
301 const actions = useThreadActions();
302 const entries = [target, second].map((ref) => ({ threadKey: ref.threadId, ref }));
303 const result = await archiveSelectedThreadEntries({
304 entries,
305 archive: (entry, onArchived) => actions.archiveThread(entry.ref, { onArchived }),
306 });
307 expect(state.requests.map((request) => request.input.threadId)).toEqual([target.threadId]);
308 expect(result.archivedThreadKeys).toEqual([target.threadId]);
309 expect(result.mutationFailure?._tag).toBe("Failure");
310 });
311
312 it("rechecks permission after a settle confirmation without dispatching", async () => {
313 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
314 const confirmed = deferred<boolean>();
315 state.confirm.mockReturnValue(confirmed.promise);
316 const pending = useThreadActions().settleThread(target);
317 expect(state.requests).toEqual([]);
318 state.scopes.get(secondary)!.clear();
319
320 confirmed.resolve(true);
321 const result = await pending;
322
323 expect(result._tag).toBe("Failure");
324 expect(state.requests).toEqual([]);
325 expect(state.localEffects).toEqual([]);
326 });
327
328 it.each(operations)("$name requires the target environment's grant", async ({ run }) => {
329 const result = await run(useThreadActions(), target);
330 expect(result._tag).toBe("Failure");
331 expect(state.requests).toEqual([]);
332 expect(state.localEffects).toEqual([]);
333 });
334
335 it.each(operations)("$name rechecks a retained callback after revocation", async ({ run }) => {
336 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
337 const actions = useThreadActions();
338 state.scopes.get(secondary)!.clear();
339 await run(actions, target);
340 expect(state.requests).toEqual([]);
341 });
342
343 it.each(operations)(
344 "$name works after the target gains only task permission",
345 async ({ name, run }) => {
346 const actions = useThreadActions();
347 state.scopes.get(primary)!.clear();
348 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
349 expect((await run(actions, target))._tag).toBe("Success");
350 expect(state.requests).toEqual([
351 expect.objectContaining({ action: name, environmentId: secondary }),
352 ]);
353 },
354 );
355
356 it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)(
357 "%s blocks a forbidden confirmation",
358 async (action) => {
359 await useThreadActions()[action](target);
360 expect(state.confirm).not.toHaveBeenCalled();
361 expect(state.requests).toEqual([]);
362 },
363 );
364
365 it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)(
366 "%s rechecks after the confirmation",
367 async (action) => {
368 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
369 const confirmation = deferred<boolean>();
370 state.confirm.mockReturnValue(confirmation.promise);
371 const result = useThreadActions()[action](target);
372 await vi.waitFor(() => expect(state.confirm).toHaveBeenCalledOnce());
373 state.scopes.get(secondary)!.clear();
374 confirmation.resolve(true);
375 await result;
376 expect(state.requests).toEqual([]);
377 expect(state.localEffects).toEqual([]);
378 },
379 );
380
381 it("identifies the missing task scope when thread deletion is denied", async () => {
382 const result = await useThreadActions().deleteThread(target);
383
384 expect(result._tag).toBe("Failure");
385 if (result._tag !== "Failure") throw new Error("Expected permission denial");
386 const error = Cause.squash<unknown>(result.cause);
387 expect(error).toBeInstanceOf(EnvironmentAuthorizationError);
388 expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope });
389 expect(state.requests).toEqual([]);
390 expect(state.localEffects).toEqual([]);
391 });
392
393 it("deletes an archived thread only with its own environment's grant", async () => {
394 state.threads = [];
395 const actions = useThreadActions();
396 await actions.deleteThread(target);
397 expect(state.requests).toEqual([]);
398 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
399 expect((await actions.deleteThread(target))._tag).toBe("Success");
400 expect(state.requests).toEqual([
401 expect.objectContaining({ action: "delete", environmentId: secondary }),
402 ]);
403 });
404
405 it("stops before delete and local cleanup when permission is revoked during session stop", async () => {
406 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
407 state.threads[0]!.session = { status: "ready" };
408 state.afterRequest = () => state.scopes.get(secondary)!.clear();
409 expect((await useThreadActions().deleteThread(target))._tag).toBe("Failure");
410 expect(state.requests.map((request) => request.action)).toEqual(["stopSession"]);
411 expect(state.localEffects).toEqual([]);
412 });
413
414 it.each([
415 { reason: "without source-control permission", sessionLookupFails: false },
416 { reason: "when the permission lookup fails", sessionLookupFails: true },
417 ])("deletes a worktree thread and keeps its worktree $reason", async ({ sessionLookupFails }) => {
418 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
419 state.threads[0]!.worktreePath = "/worktrees/thread";
420 state.sessionLookupFails = sessionLookupFails;
421 expect((await useThreadActions().deleteThread(target))._tag).toBe("Success");
422 expect(state.confirm).not.toHaveBeenCalled();
423 expect(state.requests.map((request) => request.action)).toEqual(["stopSession", "delete"]);
424 expect(state.localEffects).toContain("clear-terminal-ui");
425 });
426
427 it("does not request worktree removal after its grant is revoked during delete", async () => {
428 state.scopes
429 .get(secondary)!
430 .add(AuthOrchestrationOperateScope)
431 .add(AuthSourceControlWriteScope);
432 state.threads[0]!.worktreePath = "/worktrees/thread";
433 state.afterRequest = () => state.scopes.get(secondary)!.delete(AuthSourceControlWriteScope);
434 const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
435 try {
436 // The thread is already gone, so cleanup reports itself in a toast
437 // instead of failing the deletion.
438 const result = await useThreadActions().deleteThread(target);
439 expect(result._tag).toBe("Success");
440 expect(state.requests.map((request) => request.action)).toEqual(["stopSession", "delete"]);
441 expect(state.toasts).toContain("Failed to delete worktree");
442 expect(state.localEffects).toContain("clear-terminal-ui");
443 } finally {
444 consoleError.mockRestore();
445 }
446 });
447});
448
449it("uses the central grant for organization without authorizing source execution", async () => {
450 resourceQueries.terminals = [];
451 resourceQueries.tabs = [];
452 state.organization.add(secondary);
453 const actions = useThreadActions();
454 expect((await actions.settleThread(target))._tag).toBe("Success");
455 expect(resourceQueries.calls).toBe(0);
456 expect((await actions.deleteThread(target))._tag).toBe("Failure");
457 expect(state.requests.map((request) => request.action)).toEqual(["settle"]);
458 state.organization.clear();
459 expect((await actions.unsettleThread(target))._tag).toBe("Failure");
460 expect(state.requests).toHaveLength(1);
461});