Skip to content
Merged
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
121 changes: 121 additions & 0 deletions apps/dokploy/__test__/git-provider/github-compose-files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockFindGithubById = vi.hoisted(() => vi.fn());
const mockGetBranch = vi.hoisted(() => vi.fn());
const mockGetTree = vi.hoisted(() => vi.fn());

vi.mock("@dokploy/server/services/github", () => ({
findGithubById: mockFindGithubById,
}));

vi.mock("@octokit/auth-app", () => ({
createAppAuth: vi.fn(),
}));

vi.mock("octokit", () => ({
Octokit: class {
rest = {
repos: { getBranch: mockGetBranch },
git: { getTree: mockGetTree },
};
},
}));

const { getGithubComposeFiles, isComposeFilePath } = await import(
"@dokploy/server/utils/providers/github"
);

const input = {
githubId: "gh-1",
owner: "acme",
repo: "web",
branch: "feature/compose",
};

describe("getGithubComposeFiles", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFindGithubById.mockResolvedValue({
githubId: "gh-1",
githubUrl: "https://github.com",
githubAppId: 1,
githubPrivateKey: "key",
githubInstallationId: "42",
});
mockGetBranch.mockResolvedValue({
data: { commit: { commit: { tree: { sha: "tree-sha" } } } },
});
});

it("lists compose files of the branch tree, root files first", async () => {
mockGetTree.mockResolvedValue({
data: {
tree: [
{ path: "deploy/docker-compose.prod.yml", type: "blob" },
{ path: "docker-compose.yml", type: "blob" },
{ path: "compose.yaml", type: "blob" },
{ path: "compose", type: "tree" },
{ path: ".github/workflows/ci.yml", type: "blob" },
{ path: "README.md", type: "blob" },
],
},
});

const files = await getGithubComposeFiles(input);

expect(mockGetBranch).toHaveBeenCalledWith({
owner: "acme",
repo: "web",
branch: "feature/compose",
});
expect(mockGetTree).toHaveBeenCalledWith({
owner: "acme",
repo: "web",
tree_sha: "tree-sha",
recursive: "true",
});
expect(files).toEqual([
"./compose.yaml",
"./docker-compose.yml",
"./deploy/docker-compose.prod.yml",
]);
});

it("returns no files for a missing branch or an empty repository", async () => {
mockGetBranch.mockRejectedValue(
Object.assign(new Error("Not Found"), { status: 404 }),
);

await expect(getGithubComposeFiles(input)).resolves.toEqual([]);
});

it("passes other GitHub errors through", async () => {
mockGetBranch.mockRejectedValue(
Object.assign(new Error("Bad credentials"), { status: 401 }),
);

await expect(getGithubComposeFiles(input)).rejects.toThrow(
"Bad credentials",
);
});
});

describe("isComposeFilePath", () => {
it.each([
"docker-compose.yml",
"compose.yaml",
"docker-compose.override.yml",
"stack/my-compose.dev.YML",
])("accepts %s", (path) => {
expect(isComposeFilePath(path)).toBe(true);
});

it.each([
"compose/app.yml",
"docker-compose.yml.bak",
".github/workflows/ci.yml",
"Dockerfile",
])("rejects %s", (path) => {
expect(isComposeFilePath(path)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({
getGiteaBranches: vi.fn(),
getGiteaRepositories: vi.fn(),
getGithubBranches: vi.fn(),
getGithubComposeFiles: vi.fn(),
getGithubRepositories: vi.fn(),
getGitlabBranches: vi.fn(),
getGitlabRepositories: vi.fn(),
Expand Down Expand Up @@ -68,6 +69,7 @@ vi.mock("@dokploy/server", () => ({
getGiteaBranches: mocks.getGiteaBranches,
getGiteaRepositories: mocks.getGiteaRepositories,
getGithubBranches: mocks.getGithubBranches,
getGithubComposeFiles: mocks.getGithubComposeFiles,
getGithubRepositories: mocks.getGithubRepositories,
getGitlabBranches: mocks.getGitlabBranches,
getGitlabRepositories: mocks.getGitlabRepositories,
Expand Down Expand Up @@ -257,6 +259,28 @@ describe("github provider router security boundary", () => {
expect(mocks.getGithubRepositories).not.toHaveBeenCalled();
});

it("rejects compose file listing before the repository tree is read", async () => {
mocks.assertGitProviderAccess.mockRejectedValue(
new TRPCError({
code: "UNAUTHORIZED",
message: "denied",
}),
);

await expect(
createGithubCaller().getGithubComposeFiles({
githubId: "gh-1",
owner: "acme",
repo: "web",
branch: "main",
}),
).rejects.toMatchObject({
code: "UNAUTHORIZED",
});

expect(mocks.getGithubComposeFiles).not.toHaveBeenCalled();
});

it("does not mask test connection authorization failures as provider errors", async () => {
mocks.assertGitProviderAccess.mockRejectedValue(
new TRPCError({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { CheckIcon, ChevronsUpDown, FileCode } from "lucide-react";
import type * as React from "react";
import { useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";

const normalizePath = (path: string) =>
path.trim().replace(/^\.\//, "").toLowerCase();

interface Props
extends Pick<
React.ComponentProps<"input">,
"id" | "aria-describedby" | "aria-invalid"
> {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
name?: string;
files?: string[];
isLoading?: boolean;
// Shown instead of the list while there is no branch to read files from.
hint?: string;
placeholder?: string;
}

export const ComposePathInput = ({
value,
onChange,
onBlur,
name,
files,
isLoading,
hint,
placeholder = "docker-compose.yml",
...inputProps
}: Props) => {
const [open, setOpen] = useState(false);
// null until the user types, so a field that already holds a path still
// lists every file when it is opened.
const [query, setQuery] = useState<string | null>(null);
// -1 keeps Enter submitting the form until a file is picked with the
// arrows or the mouse.
const [highlighted, setHighlighted] = useState(-1);
const anchorRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

const visibleFiles =
query === null || !query.trim()
? (files ?? [])
: (files ?? []).filter((file) =>
normalizePath(file).includes(normalizePath(query)),
);

const openList = () => {
setQuery(null);
setHighlighted(-1);
setOpen(true);
};

const select = (file: string) => {
onChange(file);
setOpen(false);
setQuery(null);
};

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div ref={anchorRef} className="relative">
<Input
{...inputProps}
ref={inputRef}
name={name}
value={value}
placeholder={placeholder}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
className="pr-9"
onFocus={openList}
onBlur={() => {
setOpen(false);
onBlur?.();
}}
onChange={(event) => {
onChange(event.target.value);
setQuery(event.target.value);
setHighlighted(-1);
setOpen(true);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setOpen(false);
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!open) {
openList();
return;
}
const step = event.key === "ArrowDown" ? 1 : -1;
const count = visibleFiles.length;
if (count > 0) {
setHighlighted((current) =>
current === -1
? step === 1
? 0
: count - 1
: (current + step + count) % count,
);
}
return;
}
if (event.key === "Enter" && open) {
const file = visibleFiles[highlighted];
if (file) {
event.preventDefault();
select(file);
}
}
}}
/>
<button
type="button"
tabIndex={-1}
aria-label="Show compose files"
className="absolute inset-y-0 right-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
onMouseDown={(event) => {
// Keeps focus in the input, so its blur does not close the list
// right before this toggles it.
event.preventDefault();
if (open) {
setOpen(false);
} else {
inputRef.current?.focus();
openList();
}
}}
>
<ChevronsUpDown className="h-4 w-4 opacity-50" />
</button>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-1"
onOpenAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
if (anchorRef.current?.contains(event.target as Node)) {
event.preventDefault();
}
}}
onMouseDown={(event) => event.preventDefault()}
>
{hint ? (
<p className="px-2 py-3 text-center text-sm text-muted-foreground">
{hint}
</p>
) : isLoading ? (
<p className="px-2 py-3 text-center text-sm text-muted-foreground">
Loading compose files...
</p>
) : visibleFiles.length === 0 ? (
<p className="px-2 py-3 text-center text-sm text-muted-foreground">
{files?.length
? "No matching files. The typed path will be used."
: "No compose files found in this branch. Type the path."}
</p>
) : (
<ScrollArea className={cn(visibleFiles.length > 8 && "h-72")}>
<div role="listbox">
{visibleFiles.map((file, index) => (
<div
key={file}
role="option"
tabIndex={-1}
aria-selected={index === highlighted}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm",
index === highlighted && "bg-accent text-accent-foreground",
)}
onMouseEnter={() => setHighlighted(index)}
onClick={() => select(file)}
>
<FileCode className="h-4 w-4 shrink-0 opacity-60" />
<span className="truncate">{file}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4 shrink-0",
normalizePath(file) === normalizePath(value)
? "opacity-100"
: "opacity-0",
)}
/>
</div>
))}
</div>
</ScrollArea>
)}
</PopoverContent>
</Popover>
);
};
Loading