Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions apps/mobile/src/features/projects/AddProjectScreen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import * as NodeFS from "node:fs";
import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { isValidElement, type ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const state = vi.hoisted(() => ({
scopes: new Set<string>(),
baseDirectory: "",
projects: [] as string[],
}));

vi.mock("react", async (importOriginal) => ({
...(await importOriginal<typeof import("react")>()),
useCallback: (callback: unknown) => callback,
useMemo: (factory: () => unknown) => factory(),
useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}],
useRef: (current: unknown) => ({ current }),
useEffect: () => {},
}));
vi.mock("react-native", () => ({
ActivityIndicator: "ActivityIndicator",
Alert: { alert: () => {} },
Pressable: "Pressable",
ScrollView: "ScrollView",
View: "View",
}));
vi.mock("@react-navigation/native", () => ({
useNavigation: () => ({ dispatch: () => {} }),
CommonActions: { reset: (input: unknown) => input },
StackActions: {},
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ bottom: 0 }),
}));
vi.mock("../../components/AppSymbol", () => ({ SymbolView: "SymbolView" }));
vi.mock("../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" }));
vi.mock("../../components/EnvironmentMachineSymbol", () => ({
EnvironmentMachineSymbol: "EnvironmentMachineSymbol",
}));
vi.mock("../../components/ErrorBanner", () => ({ ErrorBanner: "ErrorBanner" }));
vi.mock("../../components/SourceControlIcon", () => ({ SourceControlIcon: "SourceControlIcon" }));
vi.mock("../../lib/uuid", () => ({ uuidv4: () => "project" }));
vi.mock("../../state/session", () => ({
useEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.has(scope),
readEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.has(scope),
}));
vi.mock("../../state/entities", () => ({
useProjects: () => [],
useServerConfigs: () =>
new Map([
[
"environment",
{
environment: { platform: { os: "linux" } },
settings: { addProjectBaseDirectory: state.baseDirectory },
},
],
]),
}));
vi.mock("../../state/use-remote-environment-registry", () => ({
useRemoteEnvironmentRuntime: () => ({ connectionState: "connected" }),
useRemoteConnectionStatus: () => ({
connectedEnvironments: [{ environmentId: "environment", connectionState: "connected" }],
}),
useSavedRemoteConnections: () => ({
savedConnectionsById: {
connection: { environmentId: "environment", environmentLabel: "Environment" },
},
}),
}));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} }));
vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) }));
vi.mock("../../state/presentation", () => ({
useEnvironmentPresentation: () => ({ isReady: true, presentation: null }),
}));
vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} }));
vi.mock("../../state/sourceControl", () => ({
sourceControlEnvironment: {
cloneRepository: async ({ input }: { input: { destinationPath: string } }) => {
NodeFS.mkdirSync(input.destinationPath);
return AsyncResult.success({ cwd: input.destinationPath });
},
},
}));
vi.mock("../../state/projects", () => ({
projectEnvironment: {
create: async ({ input }: { input: { workspaceRoot: string } }) => {
if (!state.scopes.has(AuthOrchestrationOperateScope)) {
return AsyncResult.failure(Cause.fail(new Error("Project creation denied")));
}
state.projects.push(input.workspaceRoot);
return AsyncResult.success(undefined);
},
},
}));

import { AddProjectDestinationScreen } from "./AddProjectScreen";

function findCloneAction(node: ReactNode): (() => unknown) | null {
if (Array.isArray(node)) {
for (const child of node) {
const action = findCloneAction(child);
if (action) return action;
}
return null;
}
if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) {
return null;
}
if (node.props.label === "Clone project") return node.props.onPress ?? null;
return findCloneAction(node.props.children);
}

function cloneAction() {
const action = findCloneAction(
AddProjectDestinationScreen({
environmentId: "environment",
remoteUrl: "https://example.com/repo.git",
repositoryName: "repo",
}),
);
if (!action) throw new Error("Clone action missing");
return action;
}

describe("clone project permissions", () => {
beforeEach(async () => {
state.baseDirectory = await NodeFSP.mkdtemp(
NodePath.join(NodeOS.tmpdir(), "t3-clone-permissions-"),
);
state.scopes = new Set([AuthSourceControlWriteScope]);
state.projects = [];
});

afterEach(async () => {
await NodeFSP.rm(state.baseDirectory, { recursive: true, force: true });
});

it("does not leave a clone on disk when project creation is denied", async () => {
await cloneAction()();

expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false);
expect(state.projects).toEqual([]);
});

it("clones and registers the project when both permissions are granted", async () => {
state.scopes.add(AuthOrchestrationOperateScope);
await cloneAction()();

const destination = NodePath.join(state.baseDirectory, "repo");
expect(NodeFS.existsSync(destination)).toBe(true);
expect(state.projects).toEqual([destination]);
});

it.each([AuthSourceControlWriteScope, AuthOrchestrationOperateScope])(
"rechecks %s before a retained clone action creates a directory",
async (scope) => {
state.scopes.add(AuthOrchestrationOperateScope);
const submit = cloneAction();
state.scopes.delete(scope);
await submit();

expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false);
expect(state.projects).toEqual([]);
},
);
});
57 changes: 45 additions & 12 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
isWindowsPlatform,
} from "@t3tools/client-runtime/state/projects";
import {
AuthOrchestrationOperateScope,
AuthSourceControlWriteScope,
CommandId,
type EnvironmentId,
type EnvironmentMachineKind,
Expand All @@ -53,6 +55,7 @@ import { useProjects, useServerConfigs } from "../../state/entities";
import { filesystemEnvironment } from "../../state/filesystem";
import { projectEnvironment } from "../../state/projects";
import { useEnvironmentQuery } from "../../state/query";
import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
import { sourceControlEnvironment } from "../../state/sourceControl";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
Expand Down Expand Up @@ -464,6 +467,15 @@ export function AddProjectSourceScreen() {
const navigation = useNavigation();
const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
useSelectedEnvironment();
const canWriteSourceControl = useEnvironmentScope(
selectedEnvironment?.environmentId ?? null,
AuthSourceControlWriteScope,
);
const canCreateProject = useEnvironmentScope(
selectedEnvironment?.environmentId ?? null,
AuthOrchestrationOperateScope,
);
const canCloneProject = canWriteSourceControl && canCreateProject;
const discoveryState = useEnvironmentQuery(
selectedEnvironment === null
? null
Expand Down Expand Up @@ -554,11 +566,13 @@ export function AddProjectSourceScreen() {
key={candidate}
source={candidate}
selectedEnvironmentId={selectedEnvironment.environmentId}
ready={readiness[candidate].ready}
ready={canCloneProject && readiness[candidate].ready}
hint={
readiness[candidate].ready
? addProjectRemoteSourcePathHint(candidate)
: (readiness[candidate].hint ?? "")
!canCloneProject
? "This connection cannot clone projects."
: readiness[candidate].ready
? addProjectRemoteSourcePathHint(candidate)
: (readiness[candidate].hint ?? "")
}
isFirst={false}
/>
Expand Down Expand Up @@ -908,6 +922,15 @@ export function AddProjectDestinationScreen(props: {
reportFailure: false,
});
const environment = useEnvironmentFromParam(props.environmentId);
const canWriteSourceControl = useEnvironmentScope(
environment?.environmentId ?? null,
AuthSourceControlWriteScope,
);
const canCreateProject = useEnvironmentScope(
environment?.environmentId ?? null,
AuthOrchestrationOperateScope,
);
const canCloneProject = canWriteSourceControl && canCreateProject;
const createProject = useCreateProject(environment);
const remoteUrl = stringParam(props.remoteUrl);
const repositoryTitle = stringParam(props.repositoryTitle);
Expand All @@ -924,7 +947,16 @@ export function AddProjectDestinationScreen(props: {
const [error, setError] = useState<string | null>(null);

const submitPath = useCallback(async () => {
if (!environment || !remoteUrl || isBrowseNavigating || isSubmitting) return;
if (
!environment ||
!readEnvironmentScope(environment.environmentId, AuthSourceControlWriteScope) ||
!readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) ||
!remoteUrl ||
isBrowseNavigating ||
isSubmitting
) {
return;
}
setError(null);
const resolved = resolveAddProjectPath({
rawPath: pathInput,
Expand Down Expand Up @@ -976,17 +1008,18 @@ export function AddProjectDestinationScreen(props: {
) : null}
{environment ? (
<>
<ProjectPathInput
value={pathInput}
onChangeText={setPathInput}
onSubmit={() => void submitPath()}
/>
<ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} />
<PrimaryActionButton
label="Clone project"
disabled={isBrowseNavigating || isSubmitting || !remoteUrl}
onPress={() => void submitPath()}
disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl}
onPress={submitPath}
loading={isSubmitting}
/>
{!canCloneProject ? (
<Text className="text-sm text-foreground-muted">
This connection cannot clone projects.
</Text>
) : null}
<FolderBrowser
environment={environment}
navigateToBrowsePath={navigateToBrowsePath}
Expand Down
32 changes: 24 additions & 8 deletions apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
import { AuthSourceControlWriteScope, resolveEnvironmentMachineKind } from "@t3tools/contracts";
import { LegendList } from "@legendapp/list/react-native";
import {
isAtomCommandInterrupted,
Expand Down Expand Up @@ -27,6 +27,7 @@ import { ThemedSwitch } from "../../components/ThemedSwitch";
import { cn } from "../../lib/cn";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { useServerConfigs } from "../../state/entities";
import { useEnvironmentScope } from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";
import { vcsEnvironment } from "../../state/vcs";
import {
Expand Down Expand Up @@ -203,6 +204,10 @@ export function NewTaskEnvironmentPickerRouteScreen() {

export function NewTaskBranchPickerRouteScreen() {
const flow = useNewTaskFlow();
const canWriteSourceControl = useEnvironmentScope(
flow.selectedProject?.environmentId ?? null,
AuthSourceControlWriteScope,
);
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
Expand Down Expand Up @@ -250,19 +255,19 @@ export function NewTaskBranchPickerRouteScreen() {

const selectBranch = useCallback(
async (branch: VcsRef) => {
if (selectingBranchNameRef.current !== null) {
const needsCheckout = shouldCheckoutNewTaskBranch({
branchIsCurrent: branch.current,
branchWorktreePath: branch.worktreePath,
workspaceMode: flow.workspaceMode,
});
if (selectingBranchNameRef.current !== null || (needsCheckout && !canWriteSourceControl)) {
return;
}
selectingBranchNameRef.current = branch.name;
void Haptics.selectionAsync();

try {
let selectedBranch = branch;
const needsCheckout = shouldCheckoutNewTaskBranch({
branchIsCurrent: branch.current,
branchWorktreePath: branch.worktreePath,
workspaceMode: flow.workspaceMode,
});
if (needsCheckout && flow.selectedProject) {
setSwitchingBranchName(branch.name);
const result = await switchRef({
Expand Down Expand Up @@ -309,6 +314,7 @@ export function NewTaskBranchPickerRouteScreen() {
}
},
[
canWriteSourceControl,
flow.selectBranch,
flow.selectedProject,
flow.setBranchQuery,
Expand All @@ -323,16 +329,26 @@ export function NewTaskBranchPickerRouteScreen() {
<BranchSelectionRow
badge={branchBadgeLabel({ branch: item, project: flow.selectedProject })}
branch={item}
disabled={switchingBranchName !== null}
disabled={
switchingBranchName !== null ||
(!canWriteSourceControl &&
shouldCheckoutNewTaskBranch({
branchIsCurrent: item.current,
branchWorktreePath: item.worktreePath,
workspaceMode: flow.workspaceMode,
}))
}
isFirst={index === 0}
isLast={index === flow.filteredBranches.length - 1}
onSelect={selectBranch}
selected={selectedBranchName === item.name}
/>
),
[
canWriteSourceControl,
flow.filteredBranches.length,
flow.selectedProject,
flow.workspaceMode,
selectBranch,
selectedBranchName,
switchingBranchName,
Expand Down
Loading
Loading