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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";

Expand All @@ -15,6 +17,7 @@ import {
findTemplateForGithubRepo,
templateCanDeployWithDefaults,
} from "@/features/deploy/github/github-template-match";
import { normalizeGithubRepoUrl } from "../github-repo-url";
import { templateSensitiveKeys } from "../template-deployer";
import type {
GithubDeployerActions,
Expand Down Expand Up @@ -50,6 +53,36 @@ export function GithubDeployerRoot({
initialRepoUrl?: string;
states: GithubDeployerStates;
}) {
const autoAuthorizeAttemptedRef = useRef(false);
const { onAutoAuthorize } = actions;
const { deployedRepo, isAuthorized, isLoading } = states;
const requestedRepoUrl = normalizeGithubRepoUrl(initialRepoUrl);

useEffect(() => {
if (
!autoDeploy ||
requestedRepoUrl == null ||
deployedRepo ||
isAuthorized ||
isLoading ||
onAutoAuthorize == null ||
autoAuthorizeAttemptedRef.current
) {
return;
}
// Root remains mounted while Shell hides the repository input before authorization.
// Closing or blocking the popup must not start another authorization loop.
autoAuthorizeAttemptedRef.current = true;
onAutoAuthorize();
}, [
autoDeploy,
deployedRepo,
isAuthorized,
isLoading,
onAutoAuthorize,
requestedRepoUrl,
]);

const [selectedRepoId, setSelectedRepoId] = useState(
() => states.repos[0]?.id ?? ""
);
Expand All @@ -73,12 +106,14 @@ export function GithubDeployerRoot({

const resolvedActions = useMemo(
() => ({
onAutoAuthorize: actions.onAutoAuthorize,
onAuthorize: actions.onAuthorize,
onDisconnect: actions.onDisconnect,
onDeploy: actions.onDeploy,
onDeployTemplate: actions.onDeployTemplate,
}),
[
actions.onAutoAuthorize,
actions.onAuthorize,
actions.onDeploy,
actions.onDeployTemplate,
Expand Down
100 changes: 96 additions & 4 deletions apps/ui/src/features/deploy/github-deployer/github-deployer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,17 @@ test("GithubDeployer auto deploys a restored GitHub URL only once", async () =>
const dom = installTestDom();
const previousActEnvironment = setActEnvironment(true);
let deployCalls = 0;
let authorizeCalls = 0;
const onDeploy = () => {
deployCalls += 1;
};
const initialProps = {
actions: { onDeploy },
actions: {
onAutoAuthorize: () => {
authorizeCalls += 1;
},
onDeploy,
},
autoDeploy: true,
initialRepoUrl: "https://github.com/acme/api",
states: {
Expand All @@ -207,25 +213,37 @@ test("GithubDeployer auto deploys a restored GitHub URL only once", async () =>
await actAndDrain(() => {
rendered = render(
<GithubDeployer.Root {...initialProps}>
<GithubDeployer.UrlInput />
<GithubDeployer.Shell />
</GithubDeployer.Root>
);
});
assert.equal(deployCalls, 0);
assert.equal(authorizeCalls, 1);

// A cancelled authorization must leave the manual button available without retrying.
await actAndDrain(() => {
rendered?.rerender(
<GithubDeployer.Root {...initialProps}>
<GithubDeployer.Shell />
</GithubDeployer.Root>
);
});
assert.equal(authorizeCalls, 1);

await actAndDrain(() => {
rendered?.rerender(
<GithubDeployer.Root {...authorizedProps}>
<GithubDeployer.UrlInput />
<GithubDeployer.Shell />
</GithubDeployer.Root>
);
});
assert.equal(deployCalls, 1);
assert.equal(authorizeCalls, 1);

await actAndDrain(() => {
rendered?.rerender(
<GithubDeployer.Root {...authorizedProps}>
<GithubDeployer.UrlInput />
<GithubDeployer.Shell />
</GithubDeployer.Root>
);
});
Expand All @@ -241,6 +259,80 @@ test("GithubDeployer auto deploys a restored GitHub URL only once", async () =>
}
});

test("GithubDeployer only auto-connects a valid deployment after auth readiness", async () => {
const dom = installTestDom();
const previousActEnvironment = setActEnvironment(true);
let authorizeCalls = 0;
const onAutoAuthorize = () => {
authorizeCalls += 1;
};
let rendered: ReturnType<typeof render> | undefined;
try {
const cases = [
{ autoDeploy: false, initialRepoUrl: "https://github.com/acme/api" },
{ autoDeploy: true, initialRepoUrl: "https://example.com/acme/api" },
{ autoDeploy: true, initialRepoUrl: "" },
{
autoDeploy: true,
initialRepoUrl: "https://github.com/acme/api",
isAuthorized: true,
},
{
autoDeploy: true,
initialRepoUrl: "https://github.com/acme/api",
isLoading: true,
},
];
for (const entry of cases) {
await actAndDrain(() => {
rendered = render(
<GithubDeployer.Root
actions={{ onAutoAuthorize }}
autoDeploy={entry.autoDeploy}
initialRepoUrl={entry.initialRepoUrl}
states={{
isAuthorized: entry.isAuthorized ?? false,
isLoading: entry.isLoading,
repos: [],
}}
>
<GithubDeployer.Shell />
</GithubDeployer.Root>
);
});
assert.equal(authorizeCalls, 0);
await actAndDrain(() => rendered?.unmount());
}

// The host withholds the automatic action until credentials and auth lookup are ready.
const props = {
autoDeploy: true,
initialRepoUrl: "https://github.com/acme/api",
states: { isAuthorized: false, repos: [] },
} as const;
await actAndDrain(() => {
rendered = render(
<GithubDeployer.Root {...props}>
<GithubDeployer.Shell />
</GithubDeployer.Root>
);
});
assert.equal(authorizeCalls, 0);
await actAndDrain(() => {
rendered?.rerender(
<GithubDeployer.Root {...props} actions={{ onAutoAuthorize }}>
<GithubDeployer.Shell />
</GithubDeployer.Root>
);
});
assert.equal(authorizeCalls, 1);
} finally {
await actAndDrain(() => rendered?.unmount());
restoreActEnvironment(previousActEnvironment);
await dom.restore();
}
});

test("GithubDeployer auto deploy stays one-shot after the URL is edited", async () => {
const dom = installTestDom();
const previousActEnvironment = setActEnvironment(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export interface GithubDeployerStates {
export interface GithubDeployerActions {
/** Invoked when the user connects or reconfigures workspace GitHub access. */
onAuthorize?: () => void;
/** Starts authorization for a restored one-click deployment, once auth status is known. */
onAutoAuthorize?: () => void;
/** Invoked when Deploy is pressed with the selected repo. */
onDeploy?: (repo: GithubDeployerRepo) => void | Promise<void>;
/** Invoked when the user accepts a matched app-store template recommendation. */
Expand All @@ -69,6 +71,7 @@ export interface GithubDeployerActions {

export interface GithubDeployerResolvedActions {
onAuthorize?: () => void;
onAutoAuthorize?: () => void;
onDeploy?: (repo: GithubDeployerRepo) => void | Promise<void>;
onDeployTemplate?: GithubDeployerActions["onDeployTemplate"];
onDisconnect?: () => void;
Expand Down
26 changes: 26 additions & 0 deletions apps/ui/src/features/deploy/github/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { githubDeployProjectPath } from "../github-deploy-link";
import {
parseInstallNamespaceParam,
parseInstallReturnPathParam,
Expand All @@ -24,4 +25,29 @@ test("parseInstallReturnPathParam rejects paths that browsers may treat as exter
assert.equal(parseInstallReturnPathParam("/projects"), "/projects");
assert.equal(parseInstallReturnPathParam("/%5Cevil.example"), null);
assert.equal(parseInstallReturnPathParam("/\\evil.example"), null);
for (const path of [
"https://evil.example",
"//evil.example",
"/%2Fevil.example",
"/\t/evil.example",
"/%0a/evil.example",
]) {
assert.equal(parseInstallReturnPathParam(path), null);
}
});

test("GitHub deployment return paths survive every OAuth validation unchanged", () => {
const path = githubDeployProjectPath("https://github.com/zjy365/aster", "1");
let returnPath: string | null = path;
// Session creation, callback URL construction, completion page, opener message.
for (let step = 0; step < 4; step += 1) {
returnPath = parseInstallReturnPathParam(returnPath);
assert.equal(returnPath, path);
}
});

test("return paths permit repository URLs in query values without decoding them", () => {
const path =
"/deploy?githubRepo=https%3A%2F%2Fgithub.com%2Fzjy365%2Faster&autoDeploy=1";
assert.equal(parseInstallReturnPathParam(path), path);
});
16 changes: 12 additions & 4 deletions apps/ui/src/features/deploy/github/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const MAX_INSTALL_RETURN_PATH_LEN = 2048;

/** Conservative namespace format accepted from the browser during install. */
const INSTALL_NAMESPACE_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/;
const RETURN_PATH_SUFFIX_RE = /[?#]/;

/** Max length for Kubernetes namespace names. */
const MAX_INSTALL_NAMESPACE_LEN = 63;
Expand All @@ -38,25 +39,32 @@ export function parseInstallReturnPathParam(raw: string | null): string | null {
if (raw == null || raw === "") {
return null;
}
const value = raw.trim();
if (value.length > MAX_INSTALL_RETURN_PATH_LEN || !value.startsWith("/")) {
return null;
}
let decoded: string;
try {
decoded = decodeURIComponent(raw);
decoded = decodeURIComponent(value);
} catch {
return null;
}
decoded = decoded.trim();
const pathname = decoded.split(RETURN_PATH_SUFFIX_RE, 1)[0] ?? "";
if (
decoded.length > MAX_INSTALL_RETURN_PATH_LEN ||
!decoded.startsWith("/") ||
decoded.startsWith("//") ||
decoded.includes("\\") ||
decoded.includes("://") ||
pathname.includes("://") ||
decoded.includes("\t") ||
decoded.includes("\n") ||
decoded.includes("\r")
) {
return null;
}
return decoded;
// JSON and URLSearchParams already decode the transport layer. Preserve query
// encoding: nested pane URLs must survive every OAuth callback validation.
return value;
}

/**
Expand Down
19 changes: 19 additions & 0 deletions apps/ui/src/features/deploy/github/use-github-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { githubDeployProjectPath } from "../github-deploy-link";

import { GITHUB_APP_INSTALL_COMPLETE_MESSAGE } from "./types";
import { githubInstallReturnPathForNavigation } from "./use-github-auth";

test("OAuth completion preserves the original one-click deployment pane", () => {
const returnPath = githubDeployProjectPath(
"https://github.com/zjy365/aster",
"1"
);
assert.equal(
githubInstallReturnPathForNavigation(
{
returnPath,
state: "install-state",
type: GITHUB_APP_INSTALL_COMPLETE_MESSAGE,
},
{ applyReturnPath: true }
),
returnPath
);
});

test("githubInstallReturnPathForNavigation only applies targeted return paths", () => {
const message = {
returnPath: "/projects?source=github",
Expand Down
Loading