| 1 | import { |
| 2 | AuthOrchestrationOperateScope, |
| 3 | AuthSourceControlWriteScope, |
| 4 | EnvironmentId, |
| 5 | ThreadId, |
| 6 | SourceControlProviderKind, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import * as Cause from "effect/Cause"; |
| 9 | import { AsyncResult } from "effect/reactivity"; |
| 10 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 11 | |
| 12 | const state = vi.hoisted(() => ({ |
| 13 | scopes: new Map<string, Set<string>>(), |
| 14 | requests: [] as { action: string; environmentId: string | null }[], |
| 15 | })); |
| 16 | |
| 17 | vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); |
| 18 | vi.mock("@effect/atom-react", () => ({ |
| 19 | useAtomValue: () => ({ operation: null, error: null, isRunning: false }), |
| 20 | })); |
| 21 | vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); |
| 22 | vi.mock("./session", () => ({ |
| 23 | useEnvironmentScope: (environmentId: string, scope: string) => |
| 24 | state.scopes.get(environmentId)?.has(scope) === true, |
| 25 | readEnvironmentScope: (environmentId: string, scope: string) => |
| 26 | state.scopes.get(environmentId)?.has(scope) === true, |
| 27 | })); |
| 28 | vi.mock("./query", () => ({ useEnvironmentQuery: () => ({ refresh: () => {} }) })); |
| 29 | vi.mock("./use-atom-command", () => ({ |
| 30 | useAtomCommand: |
| 31 | (command: { action: string; environmentId?: string }) => |
| 32 | async (input: { environmentId?: string }) => { |
| 33 | state.requests.push({ |
| 34 | action: command.action, |
| 35 | environmentId: input.environmentId ?? command.environmentId ?? null, |
| 36 | }); |
| 37 | return AsyncResult.success({}); |
| 38 | }, |
| 39 | })); |
| 40 | vi.mock("./vcs", () => ({ |
| 41 | vcsEnvironment: { init: { action: "init" }, pull: { action: "pull" }, status: () => "status" }, |
| 42 | vcsActionManager: { |
| 43 | stateAtom: () => "state", |
| 44 | resetError: () => {}, |
| 45 | track: (_registry: unknown, _target: unknown, _input: unknown, execute: () => unknown) => |
| 46 | execute(), |
| 47 | runStackedAction: (scope: { environmentId: string }) => ({ action: "stack", ...scope }), |
| 48 | }, |
| 49 | })); |
| 50 | vi.mock("./sourceControl", () => ({ |
| 51 | sourceControlEnvironment: { publishRepository: { action: "publish" } }, |
| 52 | })); |
| 53 | vi.mock("./git", () => ({ |
| 54 | gitEnvironment: { preparePullRequestThread: { action: "prepare" } }, |
| 55 | })); |
| 56 | |
| 57 | import { |
| 58 | useGitStackedAction, |
| 59 | usePreparePullRequestThreadAction, |
| 60 | useSourceControlPublishRepositoryAction, |
| 61 | useVcsInitAction, |
| 62 | useVcsPullAction, |
| 63 | } from "./sourceControlActions"; |
| 64 | |
| 65 | const primary = EnvironmentId.make("primary"); |
| 66 | const secondary = EnvironmentId.make("secondary"); |
| 67 | const scope = { environmentId: secondary, cwd: "/repo" }; |
| 68 | const threadId = ThreadId.make("thread"); |
| 69 | const cases = [ |
| 70 | { |
| 71 | name: "init", |
| 72 | create: () => { |
| 73 | const action = useVcsInitAction(scope); |
| 74 | return { isAllowed: action.isAllowed, run: () => action.run() }; |
| 75 | }, |
| 76 | }, |
| 77 | { |
| 78 | name: "pull", |
| 79 | create: () => { |
| 80 | const action = useVcsPullAction(scope); |
| 81 | return { isAllowed: action.isAllowed, run: () => action.run() }; |
| 82 | }, |
| 83 | }, |
| 84 | { |
| 85 | name: "stack", |
| 86 | create: () => { |
| 87 | const action = useGitStackedAction(scope); |
| 88 | return { |
| 89 | isAllowed: action.isAllowed, |
| 90 | run: () => action.run({ actionId: "action", action: "commit" }), |
| 91 | }; |
| 92 | }, |
| 93 | }, |
| 94 | { |
| 95 | name: "publish", |
| 96 | create: () => { |
| 97 | const action = useSourceControlPublishRepositoryAction(scope); |
| 98 | return { |
| 99 | isAllowed: action.isAllowed, |
| 100 | run: () => |
| 101 | action.run({ |
| 102 | provider: SourceControlProviderKind.make("github"), |
| 103 | repository: "owner/repo", |
| 104 | visibility: "private", |
| 105 | remoteName: "origin", |
| 106 | protocol: "ssh", |
| 107 | }), |
| 108 | }; |
| 109 | }, |
| 110 | }, |
| 111 | { |
| 112 | name: "prepare", |
| 113 | create: () => { |
| 114 | const action = usePreparePullRequestThreadAction(scope); |
| 115 | return { |
| 116 | isAllowed: action.isAllowed, |
| 117 | run: () => action.run({ reference: "1", mode: "local" }), |
| 118 | }; |
| 119 | }, |
| 120 | }, |
| 121 | ] as const; |
| 122 | |
| 123 | beforeEach(() => { |
| 124 | state.scopes = new Map([ |
| 125 | [primary, new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope])], |
| 126 | [secondary, new Set<string>()], |
| 127 | ]); |
| 128 | state.requests = []; |
| 129 | }); |
| 130 | |
| 131 | describe("source control callback grants", () => { |
| 132 | it.each(cases)("$name uses the selected environment's permission", async ({ create }) => { |
| 133 | const action = create(); |
| 134 | expect(action.isAllowed).toBe(false); |
| 135 | expect((await action.run())._tag).toBe("Failure"); |
| 136 | expect(state.requests).toEqual([]); |
| 137 | }); |
| 138 | |
| 139 | it.each(cases)("$name rejects a retained callback after revocation", async ({ create }) => { |
| 140 | state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); |
| 141 | const action = create(); |
| 142 | expect(action.isAllowed).toBe(true); |
| 143 | state.scopes.get(secondary)!.clear(); |
| 144 | expect((await action.run())._tag).toBe("Failure"); |
| 145 | expect(state.requests).toEqual([]); |
| 146 | expect(create().isAllowed).toBe(false); |
| 147 | }); |
| 148 | |
| 149 | it.each(cases)("$name accepts a retained callback after a grant", async ({ name, create }) => { |
| 150 | const action = create(); |
| 151 | expect(action.isAllowed).toBe(false); |
| 152 | state.scopes.get(primary)!.clear(); |
| 153 | state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); |
| 154 | expect((await action.run())._tag).toBe("Success"); |
| 155 | expect(state.requests).toEqual([{ action: name, environmentId: secondary }]); |
| 156 | expect(create().isAllowed).toBe(true); |
| 157 | }); |
| 158 | }); |
| 159 | |
| 160 | describe("pull request worktree attachment permission", () => { |
| 161 | it.each([ |
| 162 | ["local", threadId, false, true], |
| 163 | ["worktree", undefined, false, true], |
| 164 | ["worktree", threadId, false, false], |
| 165 | ["worktree", threadId, true, true], |
| 166 | ] as const)( |
| 167 | "mode %s / thread %s / task grant %s", |
| 168 | async (mode, attachedThreadId, operate, allowed) => { |
| 169 | state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); |
| 170 | if (operate) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); |
| 171 | const result = await usePreparePullRequestThreadAction(scope).run({ |
| 172 | reference: "1", |
| 173 | mode, |
| 174 | ...(attachedThreadId !== undefined ? { threadId: attachedThreadId } : {}), |
| 175 | }); |
| 176 | expect(result._tag).toBe(allowed ? "Success" : "Failure"); |
| 177 | expect(state.requests).toHaveLength(allowed ? 1 : 0); |
| 178 | if (result._tag === "Failure") { |
| 179 | expect(Cause.squash(result.cause)).toMatchObject({ |
| 180 | requiredScope: AuthOrchestrationOperateScope, |
| 181 | }); |
| 182 | } |
| 183 | }, |
| 184 | ); |
| 185 | |
| 186 | it.each([false, true])( |
| 187 | "reads the fresh task grant before attaching a worktree: %s", |
| 188 | async (allowed) => { |
| 189 | state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); |
| 190 | if (!allowed) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); |
| 191 | const action = usePreparePullRequestThreadAction(scope); |
| 192 | if (allowed) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); |
| 193 | else state.scopes.get(secondary)!.delete(AuthOrchestrationOperateScope); |
| 194 | const result = await action.run({ reference: "1", mode: "worktree", threadId }); |
| 195 | expect(result._tag).toBe(allowed ? "Success" : "Failure"); |
| 196 | expect(state.requests).toHaveLength(allowed ? 1 : 0); |
| 197 | }, |
| 198 | ); |
| 199 | |
| 200 | it("still requires source-control permission when the task grant is present", async () => { |
| 201 | state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); |
| 202 | const result = await usePreparePullRequestThreadAction(scope).run({ |
| 203 | reference: "1", |
| 204 | mode: "worktree", |
| 205 | threadId, |
| 206 | }); |
| 207 | expect(result._tag).toBe("Failure"); |
| 208 | expect(state.requests).toEqual([]); |
| 209 | }); |
| 210 | }); |