| 1 | import * as NodeFS from "node:fs"; |
| 2 | import * as NodeFSP from "node:fs/promises"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; |
| 6 | import * as Cause from "effect/Cause"; |
| 7 | import { AsyncResult } from "effect/reactivity"; |
| 8 | import { isValidElement, type ReactNode } from "react"; |
| 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 10 | |
| 11 | const 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 | |
| 19 | vi.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 | })); |
| 27 | vi.mock("../../components/MaterialListRow", () => ({ MaterialListRow: "MaterialListRow" })); |
| 28 | vi.mock("../../components/MaterialButton", () => ({ MaterialButton: "MaterialButton" })); |
| 29 | vi.mock("../../components/ScreenScrollView", () => ({ ScreenScrollView: "ScrollView" })); |
| 30 | vi.mock("../settings/components/SettingsScreen", () => ({ SettingsScreen: "SettingsScreen" })); |
| 31 | vi.mock("../../components/ThemedSwitch", () => ({ ThemedSwitch: "Switch" })); |
| 32 | vi.mock("react-native", () => ({ |
| 33 | Platform: { OS: "ios" }, |
| 34 | ActivityIndicator: "ActivityIndicator", |
| 35 | Alert: { alert: () => {} }, |
| 36 | Pressable: "Pressable", |
| 37 | ScrollView: "ScrollView", |
| 38 | View: "View", |
| 39 | })); |
| 40 | vi.mock("@react-navigation/native", () => ({ |
| 41 | useNavigation: () => ({ dispatch: () => {} }), |
| 42 | CommonActions: { reset: (input: unknown) => input }, |
| 43 | StackActions: {}, |
| 44 | })); |
| 45 | vi.mock("react-native-safe-area-context", () => ({ |
| 46 | useSafeAreaInsets: () => ({ bottom: 0 }), |
| 47 | })); |
| 48 | vi.mock("../../components/AppSymbol", () => ({ SymbolView: "SymbolView" })); |
| 49 | vi.mock("../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" })); |
| 50 | vi.mock("../../components/EnvironmentMachineSymbol", () => ({ |
| 51 | EnvironmentMachineSymbol: "EnvironmentMachineSymbol", |
| 52 | })); |
| 53 | vi.mock("../../components/ErrorBanner", () => ({ ErrorBanner: "ErrorBanner" })); |
| 54 | vi.mock("../../components/SourceControlIcon", () => ({ SourceControlIcon: "SourceControlIcon" })); |
| 55 | vi.mock("../../lib/uuid", () => ({ uuidv4: () => "project" })); |
| 56 | vi.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 | })); |
| 62 | vi.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 | })); |
| 82 | vi.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 | })); |
| 97 | vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); |
| 98 | vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} })); |
| 99 | vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); |
| 100 | vi.mock("../../state/presentation", () => ({ |
| 101 | useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), |
| 102 | })); |
| 103 | vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); |
| 104 | vi.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 | })); |
| 112 | vi.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 | |
| 132 | import { AddProjectDestinationScreen, AddProjectLocalFolderScreen } from "./AddProjectScreen"; |
| 133 | |
| 134 | function 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 | |
| 149 | function 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 | |
| 162 | function 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 | |
| 168 | describe("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 | |
| 213 | describe("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 | }); |