mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Webdav (#923)
* feat: WebDAV backup/restore - Add WebDAV test/backup/restore commands and settings\n- Fix ja i18n missing keys; decode PROPFIND href as UTF-8\n- Stabilize Windows prompt auto-import tests via CC_SWITCH_TEST_HOME * chore: format and minor cleanups * fix: update build config * feat(webdav): unify sync UX and hardening fixes * fix(webdav): harden sync flow and stabilize sync UX/tests * fix(webdav): add resource limits to skills.zip extraction Prevent zip bomb / resource exhaustion by enforcing: - MAX_EXTRACT_ENTRIES (10,000 files) - MAX_EXTRACT_BYTES (512 MB cumulative) * refactor(webdav): drop deviceId and display deviceName only --------- Co-authored-by: small-lovely-cat <77799160+small-lovely-cat@users.noreply.github.com> Co-authored-by: saladday <1203511142@qq.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import "@testing-library/jest-dom";
|
||||
import { createContext, useContext } from "react";
|
||||
import { createContext, useContext, type ComponentProps } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { SettingsPage } from "@/components/settings/SettingsPage";
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
@@ -156,6 +157,7 @@ vi.mock("@/components/ui/dialog", () => ({
|
||||
DialogHeader: ({ children }: any) => <div>{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
|
||||
DialogDescription: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tabs", () => {
|
||||
@@ -235,8 +237,29 @@ vi.mock("@/components/settings/AboutSection", () => ({
|
||||
AboutSection: ({ isPortable }: any) => <div>about:{String(isPortable)}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/settings/WebdavSyncSection", () => ({
|
||||
WebdavSyncSection: ({ config }: any) => (
|
||||
<div>webdav-sync-section:{config?.baseUrl ?? "none"}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
let settingsApi: any;
|
||||
|
||||
const renderSettingsPage = (
|
||||
props?: Partial<ComponentProps<typeof SettingsPage>>,
|
||||
) => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SettingsPage open={true} onOpenChange={vi.fn()} {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("SettingsPage Component", () => {
|
||||
beforeEach(async () => {
|
||||
tMock.mockImplementation((key: string) => key);
|
||||
@@ -263,7 +286,7 @@ describe("SettingsPage Component", () => {
|
||||
it("should not render form content when loading", () => {
|
||||
settingsMock = createSettingsMock({ settings: null, isLoading: true });
|
||||
|
||||
render(<SettingsPage open={true} onOpenChange={vi.fn()} />);
|
||||
renderSettingsPage();
|
||||
|
||||
expect(screen.queryByText("language:zh")).not.toBeInTheDocument();
|
||||
// 加载状态下显示 spinner 而不是表单内容
|
||||
@@ -271,13 +294,24 @@ describe("SettingsPage Component", () => {
|
||||
});
|
||||
|
||||
it("should reset import/export status when dialog transitions to open", () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
const { rerender } = render(
|
||||
<SettingsPage open={false} onOpenChange={vi.fn()} />,
|
||||
<QueryClientProvider client={client}>
|
||||
<SettingsPage open={false} onOpenChange={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
importExportMock.resetStatus.mockClear();
|
||||
|
||||
rerender(<SettingsPage open={true} onOpenChange={vi.fn()} />);
|
||||
rerender(
|
||||
<QueryClientProvider client={client}>
|
||||
<SettingsPage open={true} onOpenChange={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(importExportMock.resetStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -289,7 +323,7 @@ describe("SettingsPage Component", () => {
|
||||
selectedFile: "/tmp/config.json",
|
||||
});
|
||||
|
||||
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
|
||||
renderSettingsPage({ onOpenChange });
|
||||
|
||||
expect(screen.getByText("language:zh")).toBeInTheDocument();
|
||||
expect(screen.getByText("theme-settings")).toBeInTheDocument();
|
||||
@@ -306,6 +340,7 @@ describe("SettingsPage Component", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
fireEvent.click(screen.getByText("settings.advanced.data.title"));
|
||||
expect(screen.getByText("webdav-sync-section:none")).toBeInTheDocument();
|
||||
|
||||
// 有文件时,点击导入按钮执行 importConfig
|
||||
fireEvent.click(
|
||||
@@ -326,13 +361,7 @@ describe("SettingsPage Component", () => {
|
||||
it("should pass onImportSuccess callback to useImportExport hook", async () => {
|
||||
const onImportSuccess = vi.fn();
|
||||
|
||||
render(
|
||||
<SettingsPage
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
onImportSuccess={onImportSuccess}
|
||||
/>,
|
||||
);
|
||||
renderSettingsPage({ onImportSuccess });
|
||||
|
||||
expect(useImportExportSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ onImportSuccess }),
|
||||
@@ -349,7 +378,7 @@ describe("SettingsPage Component", () => {
|
||||
const onOpenChange = vi.fn();
|
||||
importExportMock = createImportExportMock();
|
||||
|
||||
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
|
||||
renderSettingsPage({ onOpenChange });
|
||||
|
||||
// 保存按钮在 advanced tab 中
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
@@ -370,7 +399,7 @@ describe("SettingsPage Component", () => {
|
||||
saveSettings: vi.fn().mockResolvedValue({ requiresRestart: true }),
|
||||
});
|
||||
|
||||
render(<SettingsPage open={true} onOpenChange={vi.fn()} />);
|
||||
renderSettingsPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("settings.restartRequired"),
|
||||
@@ -390,7 +419,7 @@ describe("SettingsPage Component", () => {
|
||||
const onOpenChange = vi.fn();
|
||||
settingsMock = createSettingsMock({ requiresRestart: true });
|
||||
|
||||
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
|
||||
renderSettingsPage({ onOpenChange });
|
||||
|
||||
expect(
|
||||
await screen.findByText("settings.restartRequired"),
|
||||
@@ -409,7 +438,7 @@ describe("SettingsPage Component", () => {
|
||||
});
|
||||
|
||||
it("should trigger directory management callbacks inside advanced tab", () => {
|
||||
render(<SettingsPage open={true} onOpenChange={vi.fn()} />);
|
||||
renderSettingsPage();
|
||||
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
fireEvent.click(screen.getByText("settings.advanced.configDir.title"));
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import "@testing-library/jest-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
||||
import type { WebDavSyncSettings } from "@/types";
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
const toastErrorMock = vi.fn();
|
||||
const toastWarningMock = vi.fn();
|
||||
const toastInfoMock = vi.fn();
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||
error: (...args: unknown[]) => toastErrorMock(...args),
|
||||
warning: (...args: unknown[]) => toastWarningMock(...args),
|
||||
info: (...args: unknown[]) => toastInfoMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/input", () => ({
|
||||
Input: (props: any) => <input {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({ value, onValueChange, children }: any) => (
|
||||
<select
|
||||
data-testid="webdav-preset-select"
|
||||
value={value}
|
||||
onChange={(event) => onValueChange?.(event.target.value)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectTrigger: ({ children }: any) => <>{children}</>,
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: any) => <>{children}</>,
|
||||
SelectItem: ({ value, children }: any) => (
|
||||
<option value={value}>{children}</option>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
|
||||
DialogContent: ({ children }: any) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: any) => <div>{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: any) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
const { settingsApiMock } = vi.hoisted(() => ({
|
||||
settingsApiMock: {
|
||||
webdavTestConnection: vi.fn(),
|
||||
webdavSyncSaveSettings: vi.fn(),
|
||||
webdavSyncFetchRemoteInfo: vi.fn(),
|
||||
webdavSyncUpload: vi.fn(),
|
||||
webdavSyncDownload: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
settingsApi: settingsApiMock,
|
||||
}));
|
||||
|
||||
const baseConfig: WebDavSyncSettings = {
|
||||
enabled: true,
|
||||
baseUrl: "https://dav.example.com/dav/",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
remoteRoot: "cc-switch-sync",
|
||||
profile: "default",
|
||||
status: {},
|
||||
};
|
||||
|
||||
function renderSection(config?: WebDavSyncSettings) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WebdavSyncSection config={config} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("WebdavSyncSection", () => {
|
||||
beforeEach(() => {
|
||||
toastSuccessMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
toastWarningMock.mockReset();
|
||||
toastInfoMock.mockReset();
|
||||
settingsApiMock.webdavTestConnection.mockReset();
|
||||
settingsApiMock.webdavSyncSaveSettings.mockReset();
|
||||
settingsApiMock.webdavSyncFetchRemoteInfo.mockReset();
|
||||
settingsApiMock.webdavSyncUpload.mockReset();
|
||||
settingsApiMock.webdavSyncDownload.mockReset();
|
||||
|
||||
settingsApiMock.webdavSyncSaveSettings.mockResolvedValue({ success: true });
|
||||
settingsApiMock.webdavTestConnection.mockResolvedValue({
|
||||
success: true,
|
||||
message: "ok",
|
||||
});
|
||||
settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValue({
|
||||
deviceName: "My MacBook",
|
||||
createdAt: "2026-02-01T10:00:00Z",
|
||||
snapshotId: "snapshot-1",
|
||||
version: 2,
|
||||
compatible: true,
|
||||
artifacts: ["db.sql", "skills.zip"],
|
||||
});
|
||||
settingsApiMock.webdavSyncUpload.mockResolvedValue({ status: "uploaded" });
|
||||
settingsApiMock.webdavSyncDownload.mockResolvedValue({ status: "downloaded" });
|
||||
});
|
||||
|
||||
it("shows validation error when saving without base url", async () => {
|
||||
renderSection({ ...baseConfig, baseUrl: "" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("settings.webdavSync.missingUrl");
|
||||
expect(settingsApiMock.webdavSyncSaveSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves settings and auto tests connection", async () => {
|
||||
renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://dav.example.com/dav/",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavTestConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://dav.example.com/dav/",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith(
|
||||
"settings.webdavSync.saveAndTestSuccess",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks upload when there are unsaved changes", async () => {
|
||||
renderSection(baseConfig);
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("settings.webdavSync.usernamePlaceholder"),
|
||||
{ target: { value: "bob" } },
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.webdavSync.upload" }),
|
||||
);
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
||||
"settings.webdavSync.unsavedChanges",
|
||||
);
|
||||
expect(settingsApiMock.webdavSyncFetchRemoteInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables sync buttons until config is saved", () => {
|
||||
renderSection(undefined);
|
||||
|
||||
const uploadButton = screen.getByRole("button", {
|
||||
name: "settings.webdavSync.upload",
|
||||
});
|
||||
const downloadButton = screen.getByRole("button", {
|
||||
name: "settings.webdavSync.download",
|
||||
});
|
||||
|
||||
expect(uploadButton).toBeDisabled();
|
||||
expect(downloadButton).toBeDisabled();
|
||||
expect(settingsApiMock.webdavSyncFetchRemoteInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches remote info and uploads after confirmation", async () => {
|
||||
renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.webdavSync.upload" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "settings.webdavSync.confirmUpload.confirm",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncUpload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith(
|
||||
"settings.webdavSync.uploadSuccess",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks upload confirmation if form changes after dialog opens", async () => {
|
||||
renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.webdavSync.upload" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("cc-switch-sync"), {
|
||||
target: { value: "new-root" },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "settings.webdavSync.confirmUpload.confirm",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
||||
"settings.webdavSync.unsavedChanges",
|
||||
);
|
||||
});
|
||||
expect(settingsApiMock.webdavSyncUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches remote info and downloads after confirmation", async () => {
|
||||
renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.webdavSync.download" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "settings.webdavSync.confirmDownload.confirm",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith(
|
||||
"settings.webdavSync.downloadSuccess",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks download confirmation if form changes after dialog opens", async () => {
|
||||
renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.webdavSync.download" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("default"), {
|
||||
target: { value: "new-profile" },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "settings.webdavSync.confirmDownload.confirm",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
||||
"settings.webdavSync.unsavedChanges",
|
||||
);
|
||||
});
|
||||
expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows info when no remote snapshot is found for download", async () => {
|
||||
settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValueOnce({ empty: true });
|
||||
renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.webdavSync.download" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastInfoMock).toHaveBeenCalledWith("settings.webdavSync.noRemoteData");
|
||||
});
|
||||
expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks download when remote snapshot is incompatible", async () => {
|
||||
settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValueOnce({
|
||||
deviceName: "Legacy Machine",
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
snapshotId: "legacy-snapshot",
|
||||
version: 1,
|
||||
compatible: false,
|
||||
artifacts: ["db.sql"],
|
||||
});
|
||||
renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.webdavSync.download" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
||||
"settings.webdavSync.incompatibleVersion",
|
||||
);
|
||||
});
|
||||
expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "settings.webdavSync.confirmDownload.confirm",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error when download fails after confirmation", async () => {
|
||||
settingsApiMock.webdavSyncDownload.mockRejectedValueOnce(new Error("boom"));
|
||||
renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.webdavSync.download" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "settings.webdavSync.confirmDownload.confirm",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
||||
"settings.webdavSync.downloadFailed",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user