mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
mint_installation_token now wraps JWT build + HTTP + parsing so raw httpx/jwt failures surface as GitHubAppError and the existing PAT fallback catches them all (a GitHub outage no longer crashes git operations for App-bound projects). The PEM is validated at credential-set time instead of first mint, and the installation-token cache is cleared when credentials are deleted. Also: the video preview root resolves symlinks like its sibling route (frames under a symlinked workspaces_root no longer false-404), the tracked dangling motion/node_modules symlink is removed and the gitignore gains a slash-less entry that actually matches symlinks, changelog caption input strips GHSA refs like PR refs, and the edit-project dialog hides the GitHub App section for auto-detected gitlab.com projects and warns before a save that would clear both auth sources.
270 lines
8.2 KiB
TypeScript
270 lines
8.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ReactNode } from "react";
|
|
import React from "react";
|
|
import { Team } from "@/types";
|
|
import type { Project } from "@/types";
|
|
|
|
// jsdom has no ResizeObserver; Radix Switch (the always-rendered "Active"
|
|
// toggle) measures its thumb via one on mount — mirrors
|
|
// a2a-conversation-list.test.tsx's stub.
|
|
if (typeof window !== "undefined" && !window.ResizeObserver) {
|
|
window.ResizeObserver = class {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
} as unknown as typeof ResizeObserver;
|
|
}
|
|
|
|
// Radix Select's SelectValue sizing hook calls ResizeObserver, absent in
|
|
// jsdom — mirrors select-repo-picker.test.tsx's functional replacement
|
|
// (SelectItem wired to onValueChange via context) so the dialog's Forge /
|
|
// Assigned Cell selects mount without crashing.
|
|
vi.mock("@/components/ui/select", () => {
|
|
const Ctx = React.createContext<(v: string) => void>(() => {});
|
|
return {
|
|
Select: ({
|
|
onValueChange,
|
|
children,
|
|
}: {
|
|
onValueChange?: (v: string) => void;
|
|
children: React.ReactNode;
|
|
}) => (
|
|
<Ctx.Provider value={onValueChange ?? (() => {})}>
|
|
{children}
|
|
</Ctx.Provider>
|
|
),
|
|
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
SelectValue: () => null,
|
|
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
SelectItem: ({
|
|
value,
|
|
children,
|
|
}: {
|
|
value: string;
|
|
children: React.ReactNode;
|
|
}) => {
|
|
const onValueChange = React.useContext(Ctx);
|
|
return (
|
|
<button type="button" onClick={() => onValueChange(value)}>
|
|
{children}
|
|
</button>
|
|
);
|
|
},
|
|
};
|
|
});
|
|
|
|
const { useProject, useUpdateProject, mutateAsync } = vi.hoisted(() => ({
|
|
useProject: vi.fn(),
|
|
useUpdateProject: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
}));
|
|
vi.mock("@/hooks/use-projects", () => ({ useProject, useUpdateProject }));
|
|
|
|
const {
|
|
getCredentialsStatus,
|
|
listInstallations,
|
|
listInstallationRepositories,
|
|
} = vi.hoisted(() => ({
|
|
getCredentialsStatus: vi.fn(async () => ({ has_credentials: true })),
|
|
listInstallations: vi.fn(async () => [{ id: 42, account_login: "acme" }]),
|
|
listInstallationRepositories: vi.fn(async () => [
|
|
{
|
|
full_name: "acme/widgets",
|
|
clone_url: "https://github.com/acme/widgets.git",
|
|
private: false,
|
|
},
|
|
]),
|
|
}));
|
|
vi.mock("@/lib/api", () => ({
|
|
githubAppApi: {
|
|
getCredentialsStatus,
|
|
listInstallations,
|
|
listInstallationRepositories,
|
|
},
|
|
}));
|
|
|
|
import { EditProjectDialog } from "../edit-project-dialog";
|
|
|
|
function makeProject(overrides: Partial<Project> = {}): Project {
|
|
return {
|
|
id: "proj-1",
|
|
name: "RoboCo API",
|
|
slug: "roboco-api",
|
|
git_url: "https://github.com/org/repo.git",
|
|
git_provider: "github",
|
|
github_installation_id: null,
|
|
default_branch: "main",
|
|
environments: null,
|
|
protected_branches: ["main"],
|
|
assigned_cell: Team.BACKEND,
|
|
has_git_token: true,
|
|
is_active: true,
|
|
test_command: null,
|
|
lint_command: null,
|
|
format_command: null,
|
|
typecheck_command: null,
|
|
build_command: null,
|
|
quality_command: null,
|
|
codegen_command: null,
|
|
ci_watch_enabled: false,
|
|
ci_watch_workflow: null,
|
|
video_engine_enabled: false,
|
|
dep_update_command: null,
|
|
dep_update_paths: null,
|
|
sandbox_services: null,
|
|
sandbox_extensions: null,
|
|
workspace_path: null,
|
|
last_synced_at: null,
|
|
head_commit: null,
|
|
created_by: "ceo",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function withQueryClient(ui: ReactNode) {
|
|
const client = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
|
}
|
|
|
|
function renderDialog(project: Project) {
|
|
useProject.mockReturnValue({ data: project, isLoading: false });
|
|
return render(
|
|
withQueryClient(
|
|
<EditProjectDialog projectId={project.id} open onOpenChange={vi.fn()} />,
|
|
),
|
|
);
|
|
}
|
|
|
|
describe("EditProjectDialog — GitHub App binding", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
getCredentialsStatus.mockResolvedValue({ has_credentials: true });
|
|
listInstallations.mockResolvedValue([{ id: 42, account_login: "acme" }]);
|
|
listInstallationRepositories.mockResolvedValue([
|
|
{
|
|
full_name: "acme/widgets",
|
|
clone_url: "https://github.com/acme/widgets.git",
|
|
private: false,
|
|
},
|
|
]);
|
|
mutateAsync.mockResolvedValue(makeProject());
|
|
useUpdateProject.mockReturnValue({ mutateAsync, isPending: false });
|
|
});
|
|
|
|
it("shows the PAT state and a repo picker when the App is configured", async () => {
|
|
renderDialog(makeProject());
|
|
|
|
expect(
|
|
await screen.findByText("Using personal access token"),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("button", { name: /Select repo/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the current binding and an Unbind button when already bound", async () => {
|
|
renderDialog(makeProject({ github_installation_id: 42 }));
|
|
|
|
expect(
|
|
await screen.findByText("Using GitHub App (installation #42)"),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /Unbind/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows a muted note and no picker when the App is not configured", async () => {
|
|
getCredentialsStatus.mockResolvedValue({ has_credentials: false });
|
|
renderDialog(makeProject());
|
|
|
|
expect(
|
|
await screen.findByText(/Configure the GitHub App/i),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: /Select repo/i }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("hides the picker for a non-GitHub forge provider", async () => {
|
|
renderDialog(makeProject({ git_provider: "gitlab" }));
|
|
|
|
expect(
|
|
await screen.findByText(/App auth is GitHub-only/i),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: /Select repo/i }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("hides the picker for an auto-detected gitlab.com URL (git_provider stored as null)", async () => {
|
|
renderDialog(
|
|
makeProject({
|
|
git_provider: null,
|
|
git_url: "https://gitlab.com/acme/widgets.git",
|
|
}),
|
|
);
|
|
|
|
expect(
|
|
await screen.findByText(/App auth is GitHub-only/i),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: /Select repo/i }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("warns when saving would clear both the App binding and the PAT", async () => {
|
|
renderDialog(
|
|
makeProject({ github_installation_id: null, has_git_token: true }),
|
|
);
|
|
|
|
expect(
|
|
screen.queryByText(/no git credentials at all/i),
|
|
).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(await screen.findByRole("switch", { name: /clear token/i }));
|
|
|
|
expect(
|
|
await screen.findByText(/no git credentials at all/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("binding via the repo picker sets github_installation_id in the submitted payload", async () => {
|
|
renderDialog(makeProject());
|
|
|
|
const pickButton = await screen.findByRole("button", {
|
|
name: /Select repo/i,
|
|
});
|
|
fireEvent.click(pickButton);
|
|
fireEvent.click(await screen.findByText("acme/widgets"));
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
|
|
|
|
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
|
|
const call = mutateAsync.mock.calls[0][0] as {
|
|
updates: { github_installation_id?: number | null };
|
|
};
|
|
expect(call.updates.github_installation_id).toBe(42);
|
|
});
|
|
|
|
it("unbinding sends an explicit null so the backend clears the stored installation", async () => {
|
|
renderDialog(makeProject({ github_installation_id: 42 }));
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: /Unbind/i }));
|
|
fireEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
|
|
|
|
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
|
|
const call = mutateAsync.mock.calls[0][0] as {
|
|
updates: { github_installation_id?: number | null };
|
|
};
|
|
expect(call.updates.github_installation_id).toBeNull();
|
|
});
|
|
});
|