Files
cc-switch/tests/components/DeepLinkImportDialog.test.tsx
T
Jason bee47fdf48 feat(deeplink): let provider links bring their own icon via iconUrl
A provider link can now carry `iconUrl`, an https address of the site's
own logo. The confirmation dialog downloads it as soon as it opens and
shows it at the top; importing stores it as the card's custom icon. No
extra confirmation step. Any failure is silent: the card falls back to
the built-in `icon` name, or to the name's initials.

The image goes through the same path as a local upload: the backend
returns a data URI, the frontend redraws it on a canvas into a 96x96 PNG,
and the provider save validation checks it again. The address itself is
never stored or rendered, and the import command never downloads.

The download (`deeplink/icon_fetch.rs`):
- Parsing drops addresses that are not https, use an IP or a non-default
  port, carry credentials or exceed 2048 characters; the rest of the link
  still imports.
- Every resolved address must be public, and the connection is pinned to
  them so a second DNS answer cannot point it elsewhere. Redirects are
  followed by hand, up to 3, and each hop is checked again.
- No system proxy, no cookies, no Referer, 5 s overall timeout, body read
  as a stream and capped at 256 KB.
- Content-Type must be PNG, JPEG, WebP or SVG and match the file's magic
  bytes; bitmap dimensions are read from the header and capped at 4096.
- 198.18.0.0/15 is allowed on purpose: Clash and Surge fake-ip mode
  resolve every domain into it. The trade-off is written next to
  `is_public_ipv4`.
- Errors are logged without the URL, which may carry query tokens.

Closes #8117
2026-10-12 23:26:43 +08:00

630 lines
22 KiB
TypeScript

import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { http, HttpResponse } from "msw";
import i18n from "i18next";
import { emitTauriEvent } from "../msw/tauriMocks";
import { server } from "../msw/server";
import { peekPendingDeepLink, setPendingDeepLink } from "../msw/state";
import { pngDataUri } from "../fixtures/customIcon";
// jsdom 没有 canvas,重新编码这一步换成可控的假实现
const { normalizeIconDataUri } = vi.hoisted(() => ({
normalizeIconDataUri: vi.fn<(dataUri: string) => Promise<string>>(),
}));
vi.mock("@/utils/customIcon", async (importOriginal) => ({
...(await importOriginal<typeof import("@/utils/customIcon")>()),
normalizeIconDataUri,
}));
// 模拟后端:先暂存链接,再发 deeplink-pending 通知前端来取
const deliverDeepLink = (request: Record<string, unknown>) => {
setPendingDeepLink({ kind: "import", request });
emitTauriEvent("deeplink-pending", null);
};
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h1>{children}</h1>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
);
describe("DeepLinkImportDialog", () => {
it("renders masked usage access token and user id for provider imports", async () => {
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => {
deliverDeepLink({
version: "v1",
resource: "provider",
app: "claude",
name: "Test Provider",
homepage: "https://example.com",
endpoint: "https://api.example.com",
apiKey: "sk-provider-key",
usageEnabled: true,
usageScript: btoa("console.log('usage');"),
usageApiKey: "sk-usage-key",
usageBaseUrl: "https://usage.example.com",
usageAccessToken: "pat-secret-token",
usageUserId: "user-12345",
usageAutoInterval: 60,
});
});
await waitFor(() => {
expect(screen.getByText("用量访问令牌")).toBeInTheDocument();
});
expect(screen.getByText("用量用户 ID")).toBeInTheDocument();
expect(screen.getByText("user-12345")).toBeInTheDocument();
// Masked: first 4 chars + 12 stars
expect(screen.getByText("pat-************")).toBeInTheDocument();
});
it("shows usage credentials even when the deeplink carries no usageScript", async () => {
// 后端 build_provider_meta 在任一 usage 字段存在时即持久化(含 access_token
// 与 user_id)。若对话框只在 usageScript 存在时开门,这条链接会把凭据静默
// 写进供应商配置。撤销门槛 widening(恢复只按 usageScript 开门)本测试即失败。
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => {
deliverDeepLink({
version: "v1",
resource: "provider",
app: "claude",
name: "Token Only Provider",
homepage: "https://example.com",
endpoint: "https://api.example.com",
apiKey: "sk-provider-key",
usageAccessToken: "pat-secret-token",
usageUserId: "user-12345",
});
});
await waitFor(() => {
expect(screen.getByText("用量访问令牌")).toBeInTheDocument();
});
expect(screen.getByText("pat-************")).toBeInTheDocument();
expect(screen.getByText("用量用户 ID")).toBeInTheDocument();
expect(screen.getByText("user-12345")).toBeInTheDocument();
// 没有脚本就不应渲染脚本执行警告与脚本代码区
expect(
screen.queryByText(
"这是一段 JavaScript 代码,启用后会在查询用量时执行。请确认来源可信后再导入。",
),
).not.toBeInTheDocument();
expect(screen.queryByText("脚本代码")).not.toBeInTheDocument();
});
// 徽章要和后端 `usage_enabled.unwrap_or(!code.is_empty())` 一致:
// 链接写了 usageEnabled 就照办,没写时带脚本即启用
it.each([
{ usageEnabled: undefined, expected: "已启用" },
{ usageEnabled: true, expected: "已启用" },
{ usageEnabled: false, expected: "未启用" },
])(
"shows the usage badge as $expected when usageEnabled is $usageEnabled and a script is attached",
async ({ usageEnabled, expected }) => {
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => {
deliverDeepLink({
version: "v1",
resource: "provider",
app: "claude",
name: "Usage Provider",
homepage: "https://example.com",
endpoint: "https://api.example.com",
apiKey: "sk-provider-key",
usageScript: btoa("console.log('usage');"),
...(usageEnabled === undefined ? {} : { usageEnabled }),
});
});
await waitFor(() => {
expect(screen.getByText(expected)).toBeInTheDocument();
});
},
);
it("shows a link that arrived before the dialog mounted (cold start)", async () => {
// 冷启动时链接早于前端监听到达,后端已暂存;组件挂载后必须主动取一次
setPendingDeepLink({
kind: "import",
request: {
version: "v1",
resource: "provider",
app: "claude",
name: "Cold Start Provider",
homepage: "https://example.com",
endpoint: "https://api.example.com",
apiKey: "sk-provider-key",
},
});
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
await waitFor(() => {
expect(screen.getByText("Cold Start Provider")).toBeInTheDocument();
});
});
it("warns that enabled=true switches to the provider right after import", async () => {
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => {
deliverDeepLink({
version: "v1",
resource: "provider",
app: "claude",
name: "Switching Provider",
homepage: "https://example.com",
endpoint: "https://api.example.com",
apiKey: "sk-provider-key",
enabled: true,
});
});
await waitFor(() => {
expect(
screen.getByText("deeplink.providerEnabledWarning"),
).toBeInTheDocument();
});
});
it("does not show the switch warning when enabled is absent", async () => {
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => {
deliverDeepLink({
version: "v1",
resource: "provider",
app: "claude",
name: "Add Only Provider",
homepage: "https://example.com",
endpoint: "https://api.example.com",
apiKey: "sk-provider-key",
});
});
await waitFor(() => {
expect(screen.getByText("Add Only Provider")).toBeInTheDocument();
});
expect(
screen.queryByText("deeplink.providerEnabledWarning"),
).not.toBeInTheDocument();
});
describe("the activate-after-import switch", () => {
// 记下导入请求里的 enabled:开关的值必须覆盖链接里的 enabled
const captureImport = () => {
const sent: Array<Record<string, unknown>> = [];
server.use(
http.post(
"http://tauri.local/import_from_deeplink_unified",
async ({ request }) => {
const body = (await request.json()) as {
request: Record<string, unknown>;
};
sent.push(body.request);
return HttpResponse.json(
body.request.resource === "prompt"
? { type: "prompt", id: "p1" }
: { type: "provider", id: "p1" },
);
},
),
);
return sent;
};
const providerLink = (extra: Record<string, unknown>) => ({
version: "v1",
resource: "provider",
name: "Toggle Provider",
homepage: "https://example.com",
endpoint: "https://api.example.com",
apiKey: "sk-provider-key",
...extra,
});
it("starts from the link's enabled and lets the user turn switching off", async () => {
const sent = captureImport();
const user = userEvent.setup();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => deliverDeepLink(providerLink({ app: "claude", enabled: true })));
const toggle = await screen.findByRole("switch");
expect(toggle).toBeChecked();
expect(
screen.getByText("deeplink.providerEnabledWarning"),
).toBeInTheDocument();
await user.click(toggle);
expect(toggle).not.toBeChecked();
expect(
screen.queryByText("deeplink.providerEnabledWarning"),
).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].enabled).toBe(false);
});
it("lets the user opt into switching when the link does not ask for it", async () => {
const sent = captureImport();
const user = userEvent.setup();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => deliverDeepLink(providerLink({ app: "codex" })));
const toggle = await screen.findByRole("switch");
expect(toggle).not.toBeChecked();
expect(screen.getByText("deeplink.switchAfterImport")).toBeInTheDocument();
await user.click(toggle);
expect(
screen.getByText("deeplink.providerEnabledWarning"),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].enabled).toBe(true);
});
it("adds additive-app providers to the app config unless the link opts out", async () => {
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => deliverDeepLink(providerLink({ app: "opencode" })));
const toggle = await screen.findByRole("switch");
expect(toggle).toBeChecked();
expect(
screen.getByText("deeplink.addToAppAfterImport"),
).toBeInTheDocument();
// 累加式应用没有「切换」,不提示改发请求
expect(
screen.queryByText("deeplink.providerEnabledWarning"),
).not.toBeInTheDocument();
});
it("lets the user decide whether an imported prompt is enabled", async () => {
const sent = captureImport();
const user = userEvent.setup();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() =>
deliverDeepLink({
version: "v1",
resource: "prompt",
app: "claude",
name: "Review Prompt",
content: btoa("# review"),
enabled: true,
}),
);
const toggle = await screen.findByRole("switch");
expect(toggle).toBeChecked();
expect(
screen.getByText("deeplink.prompt.enabledWarning"),
).toBeInTheDocument();
await user.click(toggle);
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].enabled).toBe(false);
});
});
describe("the link's iconUrl", () => {
const DOWNLOADED = "data:image/svg+xml;base64,PHN2Zy8+";
const NORMALIZED = pngDataUri(96, 96);
const iconLink = (extra: Record<string, unknown> = {}) => ({
version: "v1",
resource: "provider",
app: "claude",
name: "Icon Provider",
endpoint: "https://api.example.com",
apiKey: "sk-provider-key",
iconUrl: "https://cdn.example.com/logo.svg",
...extra,
});
// 记下下载请求和导入请求;download 决定后端返回什么
const mockBackend = (download: () => Promise<string | null>) => {
const fetched: string[] = [];
const sent: Array<Record<string, unknown>> = [];
server.use(
http.post(
"http://tauri.local/fetch_deeplink_icon",
async ({ request }) => {
const { url } = (await request.json()) as { url: string };
fetched.push(url);
return HttpResponse.json(await download());
},
),
http.post(
"http://tauri.local/import_from_deeplink_unified",
async ({ request }) => {
const body = (await request.json()) as {
request: Record<string, unknown>;
};
sent.push(body.request);
return HttpResponse.json({ type: "provider", id: "p1" });
},
),
);
return { fetched, sent };
};
afterEach(() => {
normalizeIconDataUri.mockReset();
});
it("downloads the icon on open, previews it and imports it without asking", async () => {
normalizeIconDataUri.mockResolvedValue(NORMALIZED);
const { fetched, sent } = mockBackend(async () => DOWNLOADED);
const user = userEvent.setup();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => deliverDeepLink(iconLink({ icon: "deepseek" })));
const preview = await screen.findByAltText("Icon Provider");
expect(preview).toHaveAttribute("src", NORMALIZED);
expect(fetched).toEqual(["https://cdn.example.com/logo.svg"]);
expect(normalizeIconDataUri).toHaveBeenCalledWith(DOWNLOADED);
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].icon).toBe(NORMALIZED);
expect(sent[0]).not.toHaveProperty("iconUrl");
});
it("falls back to the built-in icon when the download fails", async () => {
const { sent } = mockBackend(async () => null);
const user = userEvent.setup();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => deliverDeepLink(iconLink({ icon: "deepseek" })));
await user.click(
await screen.findByRole("button", { name: "deeplink.import" }),
);
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].icon).toBe("deepseek");
expect(normalizeIconDataUri).not.toHaveBeenCalled();
});
it("imports without an icon when re-encoding fails and there is no fallback", async () => {
normalizeIconDataUri.mockRejectedValue(new Error("tainted canvas"));
const { sent } = mockBackend(async () => DOWNLOADED);
const user = userEvent.setup();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => deliverDeepLink(iconLink()));
// 首字母占位
expect(await screen.findByText("IP")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].icon).toBeUndefined();
});
it("waits for a download still in flight when the user imports", async () => {
normalizeIconDataUri.mockResolvedValue(NORMALIZED);
let finishDownload!: (value: string) => void;
const download = new Promise<string>((resolve) => {
finishDownload = resolve;
});
const { fetched, sent } = mockBackend(() => download);
const user = userEvent.setup();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() => deliverDeepLink(iconLink()));
await waitFor(() => expect(fetched).toHaveLength(1));
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
// 下载没完成之前不提交
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sent).toHaveLength(0);
finishDownload(DOWNLOADED);
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].icon).toBe(NORMALIZED);
});
it("does not download for links without iconUrl", async () => {
const { fetched } = mockBackend(async () => DOWNLOADED);
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
act(() =>
deliverDeepLink(iconLink({ iconUrl: undefined, icon: "deepseek" })),
);
await screen.findByRole("button", { name: "deeplink.import" });
expect(fetched).toHaveLength(0);
});
});
describe("when processing is interrupted after the link was read", () => {
const coldStartLink = {
kind: "import",
request: {
version: "v1",
resource: "provider",
app: "claude",
name: "Interrupted Provider",
homepage: "https://example.com",
endpoint: "https://api.example.com",
apiKey: "sk-provider-key",
},
};
// 让 peek 的响应停在半路:返回 hit(请求已到达)与 release(放行响应)
const holdPeek = () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
let markHit!: () => void;
const hit = new Promise<void>((resolve) => (markHit = resolve));
server.use(
http.post("http://tauri.local/peek_pending_deeplink", async () => {
const entry = peekPendingDeepLink();
markHit();
await gate;
return HttpResponse.json(entry);
}),
);
return { hit, release };
};
afterEach(async () => {
await act(async () => {
await i18n.changeLanguage("zh");
});
});
it("still shows the link when the language changes mid-read", async () => {
// 启动时按后端设置校正语言会换掉 t;effect 若随 t 重建,读到一半的链接就被丢下
setPendingDeepLink(coldStartLink);
const { hit, release } = holdPeek();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
await hit;
await act(async () => {
await i18n.changeLanguage("en");
});
release();
await waitFor(() => {
expect(screen.getByText("Interrupted Provider")).toBeInTheDocument();
});
await waitFor(() => expect(peekPendingDeepLink()).toBeNull());
});
it("keeps the link for the next mount when unmounted mid-read", async () => {
// 窗口销毁重建:旧实例读到了但没显示,链接必须留在后端给新实例
setPendingDeepLink(coldStartLink);
const { hit, release } = holdPeek();
const first = render(<DeepLinkImportDialog />, { wrapper: Wrapper });
await hit;
first.unmount();
release();
server.resetHandlers();
expect(peekPendingDeepLink()).not.toBeNull();
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
await waitFor(() => {
expect(screen.getByText("Interrupted Provider")).toBeInTheDocument();
});
});
it("does not let a late read of an older link replace a newer one", async () => {
// A 的读取响应迟到;期间 B 到达。若读取可以并发,B 先显示并被清除,
// A 随后返回把 B 盖掉,B 从界面消失、后端也取不回来了
const providerLink = (name: string) => ({
...coldStartLink,
request: { ...coldStartLink.request, name },
});
setPendingDeepLink(providerLink("Older Provider"));
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
let markHit!: () => void;
const hit = new Promise<void>((resolve) => (markHit = resolve));
let calls = 0;
server.use(
http.post("http://tauri.local/peek_pending_deeplink", async () => {
const entry = peekPendingDeepLink();
if (++calls === 1) {
markHit();
await gate;
}
return HttpResponse.json(entry);
}),
);
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
await hit;
act(() => {
deliverDeepLink(providerLink("Newer Provider").request);
});
// 给并发读取(如果有)留出完成的时间
await new Promise((resolve) => setTimeout(resolve, 30));
release();
await waitFor(() => {
expect(screen.getByText("Newer Provider")).toBeInTheDocument();
});
await waitFor(() => expect(peekPendingDeepLink()).toBeNull());
expect(screen.queryByText("Older Provider")).not.toBeInTheDocument();
});
it("shows the newer link when it arrives while the older one is merging its config", async () => {
// 读取已返回 A,A 带 config 正在合并(异步),此时 B 到达:最终必须停在 B
setPendingDeepLink({
...coldStartLink,
request: {
...coldStartLink.request,
name: "Merging Provider",
config: btoa('{"env":{}}'),
},
});
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
let markHit!: () => void;
const hit = new Promise<void>((resolve) => (markHit = resolve));
server.use(
http.post(
"http://tauri.local/merge_deeplink_config",
async ({ request }) => {
const body = (await request.json()) as {
request: Record<string, unknown>;
};
markHit();
await gate;
return HttpResponse.json(body.request);
},
),
);
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
await hit;
act(() => {
deliverDeepLink({
...coldStartLink.request,
name: "Arrived During Merge",
});
});
await new Promise((resolve) => setTimeout(resolve, 30));
release();
await waitFor(() => {
expect(screen.getByText("Arrived During Merge")).toBeInTheDocument();
});
await waitFor(() => expect(peekPendingDeepLink()).toBeNull());
expect(screen.queryByText("Merging Provider")).not.toBeInTheDocument();
});
});
});