1import * as NodeFS from "node:fs";
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts";
6import * as Cause from "effect/Cause";
7import { AsyncResult } from "effect/reactivity";
8import { isValidElement, type ReactNode } from "react";
9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
10
11const state = vi.hoisted(() => ({
12 scopes: new Set<string>(),
13 otherScopes: new Set<string>(),
14 baseDirectory: "",
15 projects: [] as string[],
16 createRequests: [] as { environmentId: string; workspaceRoot: string }[],
17}));
18
19vi.mock("react", async (importOriginal) => ({
20 ...(await importOriginal<typeof import("react")>()),
21 useCallback: (callback: unknown) => callback,
22 useMemo: (factory: () => unknown) => factory(),
23 useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}],
24 useRef: (current: unknown) => ({ current }),
25 useEffect: () => {},
26}));
27vi.mock("../../components/MaterialListRow", () => ({ MaterialListRow: "MaterialListRow" }));
28vi.mock("../../components/MaterialButton", () => ({ MaterialButton: "MaterialButton" }));
29vi.mock("../../components/ScreenScrollView", () => ({ ScreenScrollView: "ScrollView" }));
30vi.mock("../settings/components/SettingsScreen", () => ({ SettingsScreen: "SettingsScreen" }));
31vi.mock("../../components/ThemedSwitch", () => ({ ThemedSwitch: "Switch" }));
32vi.mock("react-native", () => ({
33 Platform: { OS: "ios" },
34 ActivityIndicator: "ActivityIndicator",
35 Alert: { alert: () => {} },
36 Pressable: "Pressable",
37 ScrollView: "ScrollView",
38 View: "View",
39}));
40vi.mock("@react-navigation/native", () => ({
41 useNavigation: () => ({ dispatch: () => {} }),
42 CommonActions: { reset: (input: unknown) => input },
43 StackActions: {},
44}));
45vi.mock("react-native-safe-area-context", () => ({
46 useSafeAreaInsets: () => ({ bottom: 0 }),
47}));
48vi.mock("../../components/AppSymbol", () => ({ SymbolView: "SymbolView" }));
49vi.mock("../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" }));
50vi.mock("../../components/EnvironmentMachineSymbol", () => ({
51 EnvironmentMachineSymbol: "EnvironmentMachineSymbol",
52}));
53vi.mock("../../components/ErrorBanner", () => ({ ErrorBanner: "ErrorBanner" }));
54vi.mock("../../components/SourceControlIcon", () => ({ SourceControlIcon: "SourceControlIcon" }));
55vi.mock("../../lib/uuid", () => ({ uuidv4: () => "project" }));
56vi.mock("../../state/session", () => ({
57 useEnvironmentScope: (environmentId: string, scope: string) =>
58 (environmentId === "environment" ? state.scopes : state.otherScopes).has(scope),
59 readEnvironmentScope: (environmentId: string, scope: string) =>
60 (environmentId === "environment" ? state.scopes : state.otherScopes).has(scope),
61}));
62vi.mock("../../state/entities", () => ({
63 useProjects: () => [],
64 useServerConfigs: () =>
65 new Map([
66 [
67 "environment",
68 {
69 environment: { platform: { os: "linux" }, capabilities: {} },
70 settings: { addProjectBaseDirectory: state.baseDirectory },
71 },
72 ],
73 [
74 "other-environment",
75 {
76 environment: { platform: { os: "linux" }, capabilities: {} },
77 settings: { addProjectBaseDirectory: state.baseDirectory },
78 },
79 ],
80 ]),
81}));
82vi.mock("../../state/use-remote-environment-registry", () => ({
83 useRemoteEnvironmentRuntime: () => ({ connectionState: "connected" }),
84 useRemoteConnectionStatus: () => ({
85 connectedEnvironments: [
86 { environmentId: "environment", connectionState: "connected" },
87 { environmentId: "other-environment", connectionState: "connected" },
88 ],
89 }),
90 useSavedRemoteConnections: () => ({
91 savedConnectionsById: {
92 connection: { environmentId: "environment", environmentLabel: "Environment" },
93 other: { environmentId: "other-environment", environmentLabel: "Other environment" },
94 },
95 }),
96}));
97vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
98vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} }));
99vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) }));
100vi.mock("../../state/presentation", () => ({
101 useEnvironmentPresentation: () => ({ isReady: true, presentation: null }),
102}));
103vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} }));
104vi.mock("../../state/sourceControl", () => ({
105 sourceControlEnvironment: {
106 cloneRepository: async ({ input }: { input: { destinationPath: string } }) => {
107 NodeFS.mkdirSync(input.destinationPath);
108 return AsyncResult.success({ cwd: input.destinationPath });
109 },
110 },
111}));
112vi.mock("../../state/projects", () => ({
113 projectEnvironment: {
114 create: async ({
115 environmentId,
116 input,
117 }: {
118 environmentId: string;
119 input: { workspaceRoot: string };
120 }) => {
121 state.createRequests.push({ environmentId, workspaceRoot: input.workspaceRoot });
122 const scopes = environmentId === "environment" ? state.scopes : state.otherScopes;
123 if (!scopes.has(AuthOrchestrationOperateScope)) {
124 return AsyncResult.failure(Cause.fail(new Error("Project creation denied")));
125 }
126 state.projects.push(input.workspaceRoot);
127 return AsyncResult.success(undefined);
128 },
129 },
130}));
131
132import { AddProjectDestinationScreen, AddProjectLocalFolderScreen } from "./AddProjectScreen";
133
134function findAction(node: ReactNode, label: string): (() => unknown) | null {
135 if (Array.isArray(node)) {
136 for (const child of node) {
137 const action = findAction(child, label);
138 if (action) return action;
139 }
140 return null;
141 }
142 if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) {
143 return null;
144 }
145 if (node.props.label === label) return node.props.onPress ?? null;
146 return findAction(node.props.children, label);
147}
148
149function cloneAction() {
150 const action = findAction(
151 AddProjectDestinationScreen({
152 environmentId: "environment",
153 remoteUrl: "https://example.com/repo.git",
154 repositoryName: "repo",
155 }),
156 "Clone project",
157 );
158 if (!action) throw new Error("Clone action missing");
159 return action;
160}
161
162function localProjectAction(environmentId = "environment") {
163 const action = findAction(AddProjectLocalFolderScreen({ environmentId }), "Add project");
164 if (!action) throw new Error("Add project action missing");
165 return action;
166}
167
168describe("clone project permissions", () => {
169 beforeEach(async () => {
170 state.baseDirectory = await NodeFSP.mkdtemp(
171 NodePath.join(NodeOS.tmpdir(), "t3-clone-permissions-"),
172 );
173 state.scopes = new Set([AuthSourceControlWriteScope]);
174 state.otherScopes = new Set();
175 state.projects = [];
176 state.createRequests = [];
177 });
178
179 afterEach(async () => {
180 await NodeFSP.rm(state.baseDirectory, { recursive: true, force: true });
181 });
182
183 it("does not leave a clone on disk when project creation is denied", async () => {
184 await cloneAction()();
185
186 expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false);
187 expect(state.projects).toEqual([]);
188 });
189
190 it("clones and registers the project when both permissions are granted", async () => {
191 state.scopes.add(AuthOrchestrationOperateScope);
192 await cloneAction()();
193
194 const destination = NodePath.join(state.baseDirectory, "repo");
195 expect(NodeFS.existsSync(destination)).toBe(true);
196 expect(state.projects).toEqual([destination]);
197 });
198
199 it.each([AuthSourceControlWriteScope, AuthOrchestrationOperateScope])(
200 "rechecks %s before a retained clone action creates a directory",
201 async (scope) => {
202 state.scopes.add(AuthOrchestrationOperateScope);
203 const submit = cloneAction();
204 state.scopes.delete(scope);
205 await submit();
206
207 expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false);
208 expect(state.projects).toEqual([]);
209 },
210 );
211});
212
213describe("local project permissions", () => {
214 beforeEach(() => {
215 state.baseDirectory = "/workspace/project";
216 state.scopes = new Set();
217 state.otherScopes = new Set();
218 state.projects = [];
219 state.createRequests = [];
220 });
221
222 it.each([false, true])(
223 "does not dispatch project creation without the current grant (revoked: %s)",
224 async (revokeBeforeSubmit) => {
225 if (revokeBeforeSubmit) state.scopes.add(AuthOrchestrationOperateScope);
226 const submit = localProjectAction();
227 state.scopes.delete(AuthOrchestrationOperateScope);
228
229 await submit();
230
231 expect(state.createRequests).toEqual([]);
232 expect(state.projects).toEqual([]);
233 },
234 );
235
236 it("adds a typed local path with only task-operation permission", async () => {
237 state.scopes.add(AuthOrchestrationOperateScope);
238
239 await localProjectAction()();
240
241 expect(state.createRequests).toEqual([
242 { environmentId: "environment", workspaceRoot: state.baseDirectory },
243 ]);
244 expect(state.projects).toEqual([state.baseDirectory]);
245 });
246
247 it("uses a grant added while the local-folder form is open", async () => {
248 const submit = localProjectAction();
249 state.scopes.add(AuthOrchestrationOperateScope);
250
251 await submit();
252
253 expect(state.projects).toEqual([state.baseDirectory]);
254 });
255
256 it.each([false, true])(
257 "uses the selected environment's grant (allowed: %s)",
258 async (allowSelectedEnvironment) => {
259 (allowSelectedEnvironment ? state.otherScopes : state.scopes).add(
260 AuthOrchestrationOperateScope,
261 );
262
263 await localProjectAction("other-environment")();
264
265 expect(state.createRequests).toEqual(
266 allowSelectedEnvironment
267 ? [{ environmentId: "other-environment", workspaceRoot: state.baseDirectory }]
268 : [],
269 );
270 expect(state.projects).toEqual(allowSelectedEnvironment ? [state.baseDirectory] : []);
271 },
272 );
273});