Skip to content

Commit 7d9510d

Browse files
CodFrmcyfung1031
andauthored
🐛 针对 WebDAV 修复 cookies 认证冲突 及 authType 支持 (#1308)
* 🐛 修复 WebDAV 同步请求携带浏览器 cookies 导致认证冲突的问题 (#1297) * 代码修正 * webDavPatched -> patched * webdav.ts: 修正多个代码写法问题 * 次序调整 * Update webdav.ts * 加入 visibilityFor 修正UI显示,实作 token 和 none 等 authType方式 * 注释补充 * type check * UI Fix * 修复 token auth * accessToken & UI display * ✅ 添加 WebDAVFileSystem 单元测试 --------- Co-authored-by: cyfung1031 <44498510+cyfung1031@users.noreply.github.com>
1 parent 180d0a9 commit 7d9510d

12 files changed

Lines changed: 385 additions & 75 deletions

File tree

packages/filesystem/factory.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import ZipFileSystem from "./zip/zip";
88
import S3FileSystem from "./s3/s3";
99
import { t } from "@App/locales/locales";
1010
import LimiterFileSystem from "./limiter";
11+
import type { WebDAVClientOptions, OAuthToken } from "webdav";
1112

1213
export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive" | "googledrive" | "dropbox" | "s3";
1314

@@ -16,18 +17,47 @@ export type FileSystemParams = {
1617
title: string;
1718
type?: "select" | "authorize" | "password";
1819
options?: string[];
20+
visibilityFor?: string[];
21+
minWidth?: string;
1922
};
2023
};
2124

2225
export default class FileSystemFactory {
2326
static create(type: FileSystemType, params: any): Promise<FileSystem> {
2427
let fs: FileSystem;
28+
let options;
2529
switch (type) {
2630
case "zip":
2731
fs = new ZipFileSystem(params);
2832
break;
2933
case "webdav":
30-
fs = new WebDAVFileSystem(params.authType, params.url, params.username, params.password);
34+
/*
35+
Auto = "auto",
36+
Digest = "digest", // 需要避免密码直接传输
37+
None = "none", // 公开资源 / 自定义认证
38+
Password = "password", // 普通 WebDAV 服务,需要确保 HTTPS / Nextcloud 生产环境
39+
Token = "token" // OAuth2 / 现代云服务 / Nextcloud 生产环境
40+
*/
41+
if (params.authType === "none") {
42+
options = {
43+
authType: params.authType,
44+
} satisfies WebDAVClientOptions;
45+
} else if (params.authType === "token") {
46+
options = {
47+
authType: params.authType,
48+
token: {
49+
token_type: "Bearer",
50+
access_token: params.accessToken,
51+
} satisfies OAuthToken,
52+
} satisfies WebDAVClientOptions;
53+
} else {
54+
options = {
55+
authType: params.authType || "auto", // UI 问题,有undefined机会。undefined等价于 password, 但此处用 webdav 本身的 auto 侦测算了
56+
username: params.username,
57+
password: params.password,
58+
} satisfies WebDAVClientOptions;
59+
}
60+
fs = WebDAVFileSystem.fromCredentials(params.url, options);
3161
break;
3262
case "baidu-netdsik":
3363
fs = new BaiduFileSystem();
@@ -64,10 +94,12 @@ export default class FileSystemFactory {
6494
title: t("auth_type"),
6595
type: "select",
6696
options: ["password", "digest", "none", "token"],
97+
minWidth: "140px",
6798
},
6899
url: { title: t("url") },
69-
username: { title: t("username") },
70-
password: { title: t("password"), type: "password" },
100+
username: { title: t("username"), visibilityFor: ["password", "digest"] },
101+
password: { title: t("password"), type: "password", visibilityFor: ["password", "digest"] },
102+
accessToken: { title: t("access_token_bearer"), visibilityFor: ["token"] },
71103
},
72104
"baidu-netdsik": {},
73105
onedrive: {},
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import type { WebDAVClient } from "webdav";
3+
import { getPatcher } from "webdav";
4+
import WebDAVFileSystem from "./webdav";
5+
import { WarpTokenError } from "../error";
6+
7+
/** 创建 mock WebDAVClient */
8+
function createMockClient(overrides?: Partial<WebDAVClient>): WebDAVClient {
9+
return {
10+
getQuota: vi.fn().mockResolvedValue({}),
11+
getDirectoryContents: vi.fn().mockResolvedValue([]),
12+
getFileContents: vi.fn().mockResolvedValue("content"),
13+
putFileContents: vi.fn().mockResolvedValue(true),
14+
createDirectory: vi.fn().mockResolvedValue(undefined),
15+
deleteFile: vi.fn().mockResolvedValue(undefined),
16+
...overrides,
17+
} as unknown as WebDAVClient;
18+
}
19+
20+
/** 创建可测试的 WebDAVFileSystem 实例(替换 client 为 mock) */
21+
function createTestFS(mockClient: WebDAVClient, url = "https://dav.example.com"): WebDAVFileSystem {
22+
const fs = WebDAVFileSystem.fromCredentials(url, {});
23+
fs.client = mockClient;
24+
return fs;
25+
}
26+
27+
describe("WebDAVFileSystem", () => {
28+
let mockClient: WebDAVClient;
29+
30+
beforeEach(() => {
31+
vi.clearAllMocks();
32+
mockClient = createMockClient();
33+
});
34+
35+
describe("initWebDAVPatch", () => {
36+
it("应当通过 getPatcher 注册 fetch patch,设置 credentials 为 omit", () => {
37+
// fromCredentials 内部调用 initWebDAVPatch,验证 patcher 已注册 fetch
38+
WebDAVFileSystem.fromCredentials("https://dav.example.com", {});
39+
40+
const patcher = getPatcher();
41+
// 验证 fetch 已被 patch(patcher 内部有 fetch 注册)
42+
expect(patcher.isPatched("fetch")).toBe(true);
43+
});
44+
});
45+
46+
describe("fromCredentials", () => {
47+
it("应当创建 WebDAVFileSystem 实例并设置 url 和 basePath", () => {
48+
const fs = WebDAVFileSystem.fromCredentials("https://dav.example.com", {
49+
authType: "password" as any,
50+
username: "user",
51+
password: "pass",
52+
});
53+
54+
expect(fs).toBeInstanceOf(WebDAVFileSystem);
55+
expect(fs.url).toBe("https://dav.example.com");
56+
expect(fs.basePath).toBe("/");
57+
});
58+
});
59+
60+
describe("fromSameClient", () => {
61+
it("应当复用已有 client 并设置新 basePath", () => {
62+
const fs = createTestFS(mockClient);
63+
const subFs = WebDAVFileSystem.fromSameClient(fs, "/subdir");
64+
65+
expect(subFs).toBeInstanceOf(WebDAVFileSystem);
66+
expect(subFs.url).toBe("https://dav.example.com");
67+
expect(subFs.basePath).toBe("/subdir");
68+
expect(subFs.client).toBe(mockClient);
69+
});
70+
});
71+
72+
describe("verify", () => {
73+
it("应当成功验证", async () => {
74+
const fs = createTestFS(mockClient);
75+
76+
await expect(fs.verify()).resolves.toBeUndefined();
77+
expect(mockClient.getQuota).toHaveBeenCalled();
78+
});
79+
80+
it("应当在 401 时抛出 WarpTokenError", async () => {
81+
(mockClient.getQuota as ReturnType<typeof vi.fn>).mockRejectedValue({
82+
response: { status: 401 },
83+
message: "Unauthorized",
84+
});
85+
const fs = createTestFS(mockClient);
86+
87+
await expect(fs.verify()).rejects.toBeInstanceOf(WarpTokenError);
88+
});
89+
90+
it("应当在其他错误时抛出包含原始信息的 Error", async () => {
91+
(mockClient.getQuota as ReturnType<typeof vi.fn>).mockRejectedValue({
92+
message: "Network error",
93+
});
94+
const fs = createTestFS(mockClient);
95+
96+
await expect(fs.verify()).rejects.toThrow("WebDAV verify failed: Network error");
97+
});
98+
});
99+
100+
describe("openDir", () => {
101+
it("应当返回新实例并拼接路径", async () => {
102+
const fs = createTestFS(mockClient);
103+
const subFs = (await fs.openDir("docs")) as WebDAVFileSystem;
104+
105+
expect(subFs).toBeInstanceOf(WebDAVFileSystem);
106+
expect(subFs.basePath).toBe("/docs");
107+
expect(subFs.client).toBe(mockClient);
108+
});
109+
110+
it("应当支持嵌套 openDir", async () => {
111+
const fs = createTestFS(mockClient);
112+
const sub1 = (await fs.openDir("a")) as WebDAVFileSystem;
113+
const sub2 = (await sub1.openDir("b")) as WebDAVFileSystem;
114+
115+
expect(sub2.basePath).toBe("/a/b");
116+
});
117+
});
118+
119+
describe("createDir", () => {
120+
it("应当调用 createDirectory", async () => {
121+
const fs = createTestFS(mockClient);
122+
123+
await fs.createDir("new-folder");
124+
125+
expect(mockClient.createDirectory).toHaveBeenCalledWith("/new-folder");
126+
});
127+
128+
it("应当在 405 错误时静默成功(目录已存在)", async () => {
129+
(mockClient.createDirectory as ReturnType<typeof vi.fn>).mockRejectedValue({
130+
response: { status: 405 },
131+
message: "405 Method Not Allowed",
132+
});
133+
const fs = createTestFS(mockClient);
134+
135+
await expect(fs.createDir("existing")).resolves.toBeUndefined();
136+
});
137+
138+
it("应当在 message 包含 405 时也静默成功", async () => {
139+
(mockClient.createDirectory as ReturnType<typeof vi.fn>).mockRejectedValue({
140+
message: "Request failed with status code 405",
141+
});
142+
const fs = createTestFS(mockClient);
143+
144+
await expect(fs.createDir("existing")).resolves.toBeUndefined();
145+
});
146+
147+
it("应当在其他错误时抛出异常", async () => {
148+
const err = new Error("Forbidden");
149+
(mockClient.createDirectory as ReturnType<typeof vi.fn>).mockRejectedValue(err);
150+
const fs = createTestFS(mockClient);
151+
152+
await expect(fs.createDir("denied")).rejects.toThrow("Forbidden");
153+
});
154+
});
155+
156+
describe("delete", () => {
157+
it("应当调用 deleteFile", async () => {
158+
const fs = createTestFS(mockClient);
159+
160+
await fs.delete("test.txt");
161+
162+
expect(mockClient.deleteFile).toHaveBeenCalledWith("/test.txt");
163+
});
164+
});
165+
166+
describe("list", () => {
167+
it("应当列出文件并过滤目录", async () => {
168+
(mockClient.getDirectoryContents as ReturnType<typeof vi.fn>).mockResolvedValue([
169+
{
170+
type: "file",
171+
basename: "test.txt",
172+
lastmod: "2024-01-01T00:00:00Z",
173+
etag: '"abc"',
174+
size: 1024,
175+
},
176+
{
177+
type: "directory",
178+
basename: "subdir",
179+
lastmod: "2024-01-01T00:00:00Z",
180+
etag: "",
181+
size: 0,
182+
},
183+
]);
184+
const fs = createTestFS(mockClient);
185+
186+
const files = await fs.list();
187+
188+
expect(files).toHaveLength(1);
189+
expect(files[0]).toMatchObject({
190+
name: "test.txt",
191+
path: "/",
192+
digest: '"abc"',
193+
size: 1024,
194+
});
195+
});
196+
197+
it("应当在 404 时返回空数组", async () => {
198+
(mockClient.getDirectoryContents as ReturnType<typeof vi.fn>).mockRejectedValue({
199+
response: { status: 404 },
200+
});
201+
const fs = createTestFS(mockClient);
202+
203+
const files = await fs.list();
204+
expect(files).toHaveLength(0);
205+
});
206+
207+
it("应当在其他错误时抛出异常", async () => {
208+
const err = new Error("Server Error");
209+
(err as any).response = { status: 500 };
210+
(mockClient.getDirectoryContents as ReturnType<typeof vi.fn>).mockRejectedValue(err);
211+
const fs = createTestFS(mockClient);
212+
213+
await expect(fs.list()).rejects.toThrow("Server Error");
214+
});
215+
});
216+
217+
describe("getDirUrl", () => {
218+
it("应当返回 url + basePath", async () => {
219+
const fs = createTestFS(mockClient);
220+
const subFs = (await fs.openDir("docs")) as WebDAVFileSystem;
221+
222+
expect(await subFs.getDirUrl()).toBe("https://dav.example.com/docs");
223+
});
224+
225+
it("根路径应返回 url + /", async () => {
226+
const fs = createTestFS(mockClient);
227+
228+
expect(await fs.getDirUrl()).toBe("https://dav.example.com/");
229+
});
230+
});
231+
});

0 commit comments

Comments
 (0)