The Rust command existed but nothing called it. All four frontend call sites still used `openUrl` from `@tauri-apps/plugin-opener`, so the environment fix was inert and the three dialogs carried the same Linux bug as the terminal: DockerInstallDialog's docs link, ClaudeAuthModal's sign-in link and UpdateDialog's release link would all have reported success while launching nothing. `openUrlExternal` in tauri-commands.ts is now the single sink. There is no platform branch: Linux gets the sanitized spawn, macOS and Windows reach the same plugin as before but from Rust, and every platform picks up the Rust-side re-validation, which matters because these URLs originate in an untrusted container. Comments in urlRelay.ts and urlDetector.ts that named `openUrl` as the sink they guard are updated to match, and the two test files that mocked `@tauri-apps/plugin-opener` now mock the command instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
|
import ClaudeAuthModal from "./ClaudeAuthModal";
|
|
|
|
const acquireClaudeToken = vi.fn();
|
|
const submitClaudeTokenCode = vi.fn();
|
|
|
|
vi.mock("../../lib/tauri-commands", () => ({
|
|
acquireClaudeToken: (...args: unknown[]) => acquireClaudeToken(...args),
|
|
submitClaudeTokenCode: (...args: unknown[]) => submitClaudeTokenCode(...args),
|
|
hasClaudeToken: vi.fn(),
|
|
clearClaudeToken: vi.fn(),
|
|
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
|
|
openUrlExternal: (...args: unknown[]) => openUrlExternal(...args),
|
|
}));
|
|
|
|
const cancelClaudeToken = vi.fn(() => Promise.resolve());
|
|
|
|
const openUrlExternal = vi.fn();
|
|
|
|
/** Captured event handlers, keyed by event name, so tests can emit. */
|
|
const handlers = new Map<string, (event: { payload: unknown }) => void>();
|
|
const unlisten = vi.fn();
|
|
|
|
vi.mock("@tauri-apps/api/event", () => ({
|
|
listen: vi.fn(async (name: string, handler: (e: { payload: unknown }) => void) => {
|
|
handlers.set(name, handler);
|
|
return unlisten;
|
|
}),
|
|
}));
|
|
|
|
/** Every event the hook subscribes to, so the unmount test counts the right
|
|
* number of teardowns instead of a magic number that drifts. */
|
|
const EVENT_NAMES = [
|
|
"claude-token-progress",
|
|
"claude-token-output",
|
|
"claude-token-link",
|
|
"claude-token-code-rejected",
|
|
];
|
|
|
|
/** The sign-in URL at its real length (346 characters, measured against
|
|
* Claude Code 2.1.226) and the 80-column slice of it that is all the visible
|
|
* transcript ever contains. */
|
|
const FULL_URL =
|
|
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
|
const TRUNCATED_URL = FULL_URL.slice(0, 80);
|
|
|
|
function emitOutput(chunk: string, projectId = "p1") {
|
|
act(() => {
|
|
handlers.get("claude-token-output")?.({
|
|
payload: { project_id: projectId, chunk },
|
|
});
|
|
});
|
|
}
|
|
|
|
function emitLink(url: string, projectId = "p1") {
|
|
act(() => {
|
|
handlers.get("claude-token-link")?.({
|
|
payload: { project_id: projectId, url },
|
|
});
|
|
});
|
|
}
|
|
|
|
function emitCodeRejected(message: string, attemptsRemaining: number) {
|
|
act(() => {
|
|
handlers.get("claude-token-code-rejected")?.({
|
|
payload: {
|
|
project_id: "p1",
|
|
message,
|
|
attempts_remaining: attemptsRemaining,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderModal(
|
|
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
|
|
) {
|
|
return render(
|
|
<ClaudeAuthModal
|
|
projectId="p1"
|
|
projectName="api-server"
|
|
onClose={overrides.onClose ?? vi.fn()}
|
|
onAuthenticated={overrides.onAuthenticated ?? vi.fn()}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
/** Both listeners register before `acquire_claude_token` is invoked. */
|
|
async function flowStarted() {
|
|
await waitFor(() => expect(acquireClaudeToken).toHaveBeenCalledWith("p1"));
|
|
}
|
|
|
|
describe("ClaudeAuthModal", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
handlers.clear();
|
|
// A flow that never resolves on its own — the CLI is sitting on its prompt.
|
|
acquireClaudeToken.mockImplementation(() => new Promise(() => {}));
|
|
submitClaudeTokenCode.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("starts the flow for the given project", async () => {
|
|
renderModal();
|
|
await flowStarted();
|
|
});
|
|
|
|
it("submits the pasted code to the backend", async () => {
|
|
renderModal();
|
|
await flowStarted();
|
|
|
|
fireEvent.change(screen.getByLabelText("Authentication code"), {
|
|
target: { value: " code-123 " },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
|
|
|
// Trimmed on the way out — the backend rejects surrounding whitespace noise.
|
|
await waitFor(() =>
|
|
expect(submitClaudeTokenCode).toHaveBeenCalledWith("code-123"),
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.getByLabelText("Authentication code")).toHaveValue(""),
|
|
);
|
|
});
|
|
|
|
it("submits on Enter as well as on the button", async () => {
|
|
renderModal();
|
|
await flowStarted();
|
|
|
|
const input = screen.getByLabelText("Authentication code");
|
|
fireEvent.change(input, { target: { value: "code-456" } });
|
|
fireEvent.submit(input.closest("form")!);
|
|
|
|
await waitFor(() =>
|
|
expect(submitClaudeTokenCode).toHaveBeenCalledWith("code-456"),
|
|
);
|
|
});
|
|
|
|
it("refuses an empty code without calling the backend", async () => {
|
|
renderModal();
|
|
await flowStarted();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
|
|
|
await screen.findByText("Enter the code shown after signing in.");
|
|
expect(submitClaudeTokenCode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports a backend rejection instead of dumping the raw value", async () => {
|
|
submitClaudeTokenCode.mockRejectedValue(
|
|
"That code contains invalid characters. Copy it again and retry.",
|
|
);
|
|
renderModal();
|
|
await flowStarted();
|
|
|
|
fireEvent.change(screen.getByLabelText("Authentication code"), {
|
|
target: { value: "bad" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
|
|
|
await screen.findByText(
|
|
"That code contains invalid characters. Copy it again and retry.",
|
|
);
|
|
});
|
|
|
|
it("linkifies the sign-in URL from the streamed output and opens it in the host browser", async () => {
|
|
renderModal();
|
|
await flowStarted();
|
|
|
|
const url = "https://claude.ai/oauth/authorize?code=true&client_id=abc";
|
|
emitOutput(`Use this url to sign in:\n${url}\n`);
|
|
|
|
const link = await screen.findByRole("link", { name: url });
|
|
fireEvent.click(link);
|
|
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(url));
|
|
});
|
|
|
|
it("ignores output belonging to a different project", async () => {
|
|
renderModal();
|
|
await flowStarted();
|
|
|
|
emitOutput("https://claude.ai/oauth/authorize?code=other", "p2");
|
|
expect(screen.getByTestId("claude-auth-output")).not.toHaveTextContent(
|
|
"code=other",
|
|
);
|
|
});
|
|
|
|
it("surfaces an actionable failure when the flow ends badly", async () => {
|
|
acquireClaudeToken.mockRejectedValue(
|
|
"`claude setup-token` finished but printed no recognisable token. Nothing was stored.",
|
|
);
|
|
renderModal();
|
|
|
|
const banner = await screen.findByTestId("claude-auth-error");
|
|
expect(banner).toHaveTextContent(/printed no recognisable token/);
|
|
});
|
|
|
|
it("announces success and notifies the caller", async () => {
|
|
acquireClaudeToken.mockResolvedValue(undefined);
|
|
const onAuthenticated = vi.fn();
|
|
renderModal({ onAuthenticated });
|
|
|
|
await screen.findByTestId("claude-auth-success");
|
|
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("confirms before cancelling, then aborts the container-side CLI", async () => {
|
|
const onClose = vi.fn();
|
|
renderModal({ onClose });
|
|
await flowStarted();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
|
expect(screen.getByText(/no token is stored/i)).toBeInTheDocument();
|
|
// Confirming is required — the first click must not cancel anything.
|
|
expect(cancelClaudeToken).not.toHaveBeenCalled();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel sign-in" }));
|
|
await waitFor(() => expect(cancelClaudeToken).toHaveBeenCalledTimes(1));
|
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
});
|
|
|
|
it("still closes when the cancel command rejects", async () => {
|
|
cancelClaudeToken.mockRejectedValueOnce(new Error("nope"));
|
|
const onClose = vi.fn();
|
|
renderModal({ onClose });
|
|
await flowStarted();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel sign-in" }));
|
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
});
|
|
|
|
it("removes its event listeners on unmount", async () => {
|
|
const { unmount } = renderModal();
|
|
await flowStarted();
|
|
unmount();
|
|
await waitFor(() =>
|
|
expect(unlisten).toHaveBeenCalledTimes(EVENT_NAMES.length),
|
|
);
|
|
});
|
|
|
|
// ── The hyperlink target, not the wrapped display text ────────────────
|
|
//
|
|
// `claude setup-token` slices the *visible* text of its OSC 8 hyperlink to
|
|
// the terminal width, so the transcript holds five 80-character pieces of a
|
|
// 346-character URL. The backend lifts the whole thing out of the hyperlink
|
|
// parameter and sends it on `claude-token-link`.
|
|
|
|
it("prefers the hyperlink target over the wrapped copy in the transcript", async () => {
|
|
renderModal();
|
|
await flowStarted();
|
|
|
|
// What the transcript holds: the first slice only.
|
|
emitOutput(`Browser didn't open? Use the url below to sign in\n${TRUNCATED_URL}\n`);
|
|
// What the hyperlink parameter holds: all of it.
|
|
emitLink(FULL_URL);
|
|
|
|
const link = await screen.findByRole("link", { name: FULL_URL });
|
|
fireEvent.click(link);
|
|
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(FULL_URL));
|
|
expect(openUrlExternal).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
|
});
|
|
|
|
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
|
|
renderModal();
|
|
await flowStarted();
|
|
|
|
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
|
|
|
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
expect(openUrlExternal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores a hyperlink belonging to a different project", async () => {
|
|
renderModal();
|
|
await flowStarted();
|
|
|
|
emitLink(FULL_URL, "p2");
|
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
});
|
|
|
|
// ── A refused code is recoverable, not a hang ─────────────────────────
|
|
|
|
it("reports a rejected code and lets another one be submitted", async () => {
|
|
renderModal();
|
|
await flowStarted();
|
|
|
|
const input = screen.getByLabelText("Authentication code");
|
|
fireEvent.change(input, { target: { value: "truncated" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
|
await waitFor(() =>
|
|
expect(submitClaudeTokenCode).toHaveBeenCalledWith("truncated"),
|
|
);
|
|
// Before the rejection arrives the UI claims the sign-in is completing.
|
|
expect(screen.getByText("Finishing sign-in")).toBeInTheDocument();
|
|
|
|
emitCodeRejected(
|
|
"That code was rejected — `claude setup-token` reports the full code was not copied. Copy it again from the Anthropic page and submit it; 2 attempts left.",
|
|
2,
|
|
);
|
|
|
|
// Reported, not waited out — and the flow is still live.
|
|
await screen.findByText(/That code was rejected/);
|
|
expect(screen.getByText("Code rejected — try again")).toBeInTheDocument();
|
|
expect(screen.queryByText("Finishing sign-in")).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId("claude-auth-error")).not.toBeInTheDocument();
|
|
|
|
// A second code goes through without restarting the whole flow.
|
|
fireEvent.change(input, { target: { value: "the-whole-code" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
|
await waitFor(() =>
|
|
expect(submitClaudeTokenCode).toHaveBeenLastCalledWith("the-whole-code"),
|
|
);
|
|
expect(acquireClaudeToken).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("ends with a reported failure when the retries run out", async () => {
|
|
acquireClaudeToken.mockRejectedValue(
|
|
"`claude setup-token` rejected the code 3 times, so the sign-in was abandoned. No token was stored.",
|
|
);
|
|
renderModal();
|
|
|
|
const banner = await screen.findByTestId("claude-auth-error");
|
|
expect(banner).toHaveTextContent(/rejected the code 3 times/);
|
|
});
|
|
});
|