diff --git a/packages/filesystem/auth.test.ts b/packages/filesystem/auth.test.ts new file mode 100644 index 000000000..ca26aad36 --- /dev/null +++ b/packages/filesystem/auth.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthVerify } from "./auth"; +import { LocalStorageDAO } from "@App/app/repo/localStorage"; + +describe("AuthVerify", () => { + const localStorageDAO = new LocalStorageDAO(); + const key = "netdisk:token:onedrive"; + let originalFetch: typeof fetch; + + beforeEach(async () => { + vi.clearAllMocks(); + await chrome.storage.local.clear(); + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + vi.stubGlobal("fetch", originalFetch); + }); + + it("expired token refresh network failure should reject, not fallback old token", async () => { + await localStorageDAO.saveValue(key, { + accessToken: "old-access", + refreshToken: "old-refresh", + createtime: Date.now() - 3600000 - 1000, + }); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new Error("refresh network failed"))); + + await expect(AuthVerify("onedrive")).rejects.toThrow("refresh network failed"); + }); + + it("non-expired token should return cached token without refresh", async () => { + await localStorageDAO.saveValue(key, { + accessToken: "cached-access", + refreshToken: "cached-refresh", + createtime: Date.now(), + }); + + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(AuthVerify("onedrive")).resolves.toBe("cached-access"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/filesystem/auth.ts b/packages/filesystem/auth.ts index a15e55fa7..603fd409b 100644 --- a/packages/filesystem/auth.ts +++ b/packages/filesystem/auth.ts @@ -100,7 +100,8 @@ export async function AuthVerify(netDiskType: NetDiskType, invalid?: boolean) { await localStorageDAO.saveValue(key, token); } // token过期或者失效 - if (Date.now() >= token.createtime + 3600000 || invalid) { + const expired = Date.now() >= token.createtime + 3600000; + if (expired || invalid) { // 大于一小时刷新token try { const resp = await RefreshToken(netDiskType, token.refreshToken); @@ -119,9 +120,10 @@ export async function AuthVerify(netDiskType: NetDiskType, invalid?: boolean) { }; // 更新token await localStorageDAO.saveValue(key, token); - } catch (_) { - // 报错返回原token - return token.accessToken; + } catch (e) { + // 已过期或已被服务端判定失效的 token 不能继续回退使用 + console.warn(e); + throw e; } } else { return token.accessToken; diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts new file mode 100644 index 000000000..14d0e5078 --- /dev/null +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -0,0 +1,17 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import DropboxFileSystem from "./dropbox"; + +describe("DropboxFileSystem", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("delete should be idempotent on path not found", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.spyOn(fs, "request").mockRejectedValue( + new Error('Dropbox API Error: 409 - {"error_summary":"path_lookup/not_found/..."}') + ); + + await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); + }); +}); diff --git a/packages/filesystem/dropbox/dropbox.ts b/packages/filesystem/dropbox/dropbox.ts index 9e1674e08..9550b67f4 100644 --- a/packages/filesystem/dropbox/dropbox.ts +++ b/packages/filesystem/dropbox/dropbox.ts @@ -78,14 +78,28 @@ export default class DropboxFileSystem implements FileSystem { request(url: string, config?: RequestInit, nothen?: boolean) { config = config || {}; const headers = config.headers || new Headers(); - headers.append(`Authorization`, `Bearer ${this.accessToken}`); + headers.set(`Authorization`, `Bearer ${this.accessToken}`); config.headers = headers; - const ret = fetch(url, config); + const doFetch = () => fetch(url, config); + const retryWithFreshToken = async () => { + const token = await AuthVerify("dropbox", true); + this.accessToken = token; + headers.set(`Authorization`, `Bearer ${this.accessToken}`); + return doFetch(); + }; if (nothen) { - return >ret; + return doFetch().then(async (resp) => { + if (resp.status === 401) { + return retryWithFreshToken(); + } + return resp; + }); } - return ret + return doFetch() .then(async (response) => { + if (response.status === 401) { + response = await retryWithFreshToken(); + } if (!response.ok) { const errorText = await response.text(); throw new Error(`Dropbox API Error: ${response.status} - ${errorText}`); @@ -126,13 +140,20 @@ export default class DropboxFileSystem implements FileSystem { const myHeaders = new Headers(); myHeaders.append("Content-Type", "application/json"); - await this.request("https://api.dropboxapi.com/2/files/delete_v2", { - method: "POST", - headers: myHeaders, - body: JSON.stringify({ - path: fullPath, - }), - }); + try { + await this.request("https://api.dropboxapi.com/2/files/delete_v2", { + method: "POST", + headers: myHeaders, + body: JSON.stringify({ + path: fullPath, + }), + }); + } catch (e: any) { + if (e.message?.includes("path_lookup/not_found") || e.message?.includes("path/not_found")) { + return; + } + throw e; + } // 清除相关缓存 this.clearRelatedCache(fullPath); diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts new file mode 100644 index 000000000..a6e099bc6 --- /dev/null +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import GoogleDriveFileSystem from "./googledrive"; + +describe("GoogleDriveFileSystem", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("delete should be idempotent when file id is missing", async () => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue(null); + const requestSpy = vi.spyOn(fs, "request"); + + await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it("delete should be idempotent on 404 response", async () => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue("file-1"); + vi.spyOn(fs, "request").mockResolvedValue({ + status: 404, + text: vi.fn().mockResolvedValue("not found"), + } as unknown as Response); + + await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); + }); +}); diff --git a/packages/filesystem/googledrive/googledrive.ts b/packages/filesystem/googledrive/googledrive.ts index 192941aaa..fb54786e2 100644 --- a/packages/filesystem/googledrive/googledrive.ts +++ b/packages/filesystem/googledrive/googledrive.ts @@ -110,23 +110,44 @@ export default class GoogleDriveFileSystem implements FileSystem { request(url: string, config?: RequestInit, nothen?: boolean) { config = config || {}; const headers = config.headers || new Headers(); - headers.append(`Authorization`, `Bearer ${this.accessToken}`); + headers.set(`Authorization`, `Bearer ${this.accessToken}`); config.headers = headers; - const ret = fetch(url, config); + const doFetch = () => fetch(url, config); + const retryWithFreshToken = async () => { + const token = await AuthVerify("googledrive", true); + this.accessToken = token; + headers.set(`Authorization`, `Bearer ${this.accessToken}`); + return doFetch(); + }; if (nothen) { - return >ret; + return doFetch().then(async (resp) => { + if (resp.status === 401) { + return retryWithFreshToken(); + } + return resp; + }); } - return ret - .then((data) => data.json()) + return doFetch() + .then(async (resp) => { + if (resp.status === 401) { + resp = await retryWithFreshToken(); + } + if (!resp.ok) { + throw new Error(await resp.text()); + } + return resp.json(); + }) .then(async (data) => { if (data.error) { if (data.error.code === 401) { // Token可能过期,尝试刷新 - const token = await AuthVerify("googledrive", true); - this.accessToken = token; - headers.set(`Authorization`, `Bearer ${this.accessToken}`); - return fetch(url, config) - .then((retryData) => retryData.json()) + return retryWithFreshToken() + .then(async (retryResp) => { + if (!retryResp.ok) { + throw new Error(await retryResp.text()); + } + return retryResp.json(); + }) .then((retryData) => { if (retryData.error) { throw new Error(JSON.stringify(retryData)); @@ -145,7 +166,7 @@ export default class GoogleDriveFileSystem implements FileSystem { // 首先,找到要删除的文件或文件夹 const fileId = await this.getFileId(fullPath); if (!fileId) { - throw new Error(`File or directory not found: ${fullPath}`); + return; } // 删除文件或文件夹 @@ -156,6 +177,9 @@ export default class GoogleDriveFileSystem implements FileSystem { }, true ).then(async (resp) => { + if (resp.status === 404) { + return; + } if (resp.status !== 204 && resp.status !== 200) { throw new Error(await resp.text()); } diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts new file mode 100644 index 000000000..f2092991b --- /dev/null +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import OneDriveFileSystem from "./onedrive"; +import { LocalStorageDAO } from "@App/app/repo/localStorage"; + +function createMockResponse(options: { ok?: boolean; status?: number; text?: string; json?: any }): Response { + const { ok = true, status = 200, text = "", json = {} } = options; + return { + ok, + status, + text: vi.fn().mockResolvedValue(text), + json: vi.fn().mockResolvedValue(json), + headers: new Headers(), + } as unknown as Response; +} + +describe("OneDriveFileSystem", () => { + const localStorageDAO = new LocalStorageDAO(); + let originalFetch: typeof fetch; + + beforeEach(async () => { + vi.clearAllMocks(); + await chrome.storage.local.clear(); + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + vi.stubGlobal("fetch", originalFetch); + }); + + it("request should return retry result after token refresh", async () => { + await localStorageDAO.saveValue("netdisk:token:onedrive", { + accessToken: "expired-token", + refreshToken: "refresh-token", + createtime: Date.now(), + }); + + const fs = new OneDriveFileSystem("/", "expired-token"); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + json: { + error: { + code: "InvalidAuthenticationToken", + }, + }, + }) + ) + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValue({ + code: 0, + data: { + token: { + access_token: "fresh-token", + refresh_token: "fresh-refresh-token", + }, + }, + }), + } as unknown as Response) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + json: { + value: [ + { + name: "ok.txt", + size: 1, + eTag: "tag", + createdDateTime: new Date().toISOString(), + lastModifiedDateTime: new Date().toISOString(), + }, + ], + }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const data = await fs.request("https://graph.microsoft.com/v1.0/me/drive/special/approot/children"); + + expect(data.value).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("delete should be idempotent on 404", async () => { + const fs = new OneDriveFileSystem("/", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ + status: 404, + text: vi.fn().mockResolvedValue("not found"), + } as unknown as Response); + + await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); + }); +}); diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index 3e8a2a17c..d7792203e 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -74,28 +74,51 @@ export default class OneDriveFileSystem implements FileSystem { config = config || {}; const headers = config.headers || new Headers(); if (!url.includes("uploadSession")) { - headers.append(`Authorization`, `Bearer ${this.accessToken}`); + headers.set(`Authorization`, `Bearer ${this.accessToken}`); } config.headers = headers; - const ret = fetch(url, config); + const doFetch = () => fetch(url, config); + const retryWithFreshToken = async () => { + const token = await AuthVerify("onedrive", true); + this.accessToken = token; + if (!url.includes("uploadSession")) { + headers.set(`Authorization`, `Bearer ${this.accessToken}`); + } + return doFetch(); + }; if (nothen) { - return >ret; + return doFetch().then(async (resp) => { + if (resp.status === 401 && !url.includes("uploadSession")) { + return retryWithFreshToken(); + } + return resp; + }); } - return ret - .then((data) => data.json()) + return doFetch() + .then(async (resp) => { + if (resp.status === 401 && !url.includes("uploadSession")) { + resp = await retryWithFreshToken(); + } + if (!resp.ok) { + throw new Error(await resp.text()); + } + return resp.json(); + }) .then(async (data) => { if (data.error) { if (data.error.code === "InvalidAuthenticationToken") { - const token = await AuthVerify("onedrive", true); - this.accessToken = token; - headers.set(`Authorization`, `Bearer ${this.accessToken}`); - return fetch(url, config) - .then((retryData) => retryData.json()) + return retryWithFreshToken() + .then(async (retryResp) => { + if (!retryResp.ok) { + throw new Error(await retryResp.text()); + } + return retryResp.json(); + }) .then((retryData) => { if (retryData.error) { throw new Error(JSON.stringify(retryData)); } - return data; + return retryData; }); } throw new Error(JSON.stringify(data)); @@ -112,10 +135,12 @@ export default class OneDriveFileSystem implements FileSystem { }, true ); + if (resp.status === 404) { + return; + } if (resp.status !== 204) { throw new Error(await resp.text()); } - return resp; } async list(): Promise { diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index cf389c0d0..d49dc6c9c 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -161,6 +161,16 @@ describe("WebDAVFileSystem", () => { expect(mockClient.deleteFile).toHaveBeenCalledWith("/test.txt"); }); + + it("应当在 404 时静默成功(幂等删除)", async () => { + (mockClient.deleteFile as ReturnType).mockRejectedValue({ + response: { status: 404 }, + message: "404 Not Found", + }); + const fs = createTestFS(mockClient); + + await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); + }); }); describe("list", () => { diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index 10ea10b21..7582324aa 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -88,7 +88,14 @@ export default class WebDAVFileSystem implements FileSystem { } async delete(path: string): Promise { - return this.client.deleteFile(joinPath(this.basePath, path)); + try { + await this.client.deleteFile(joinPath(this.basePath, path)); + } catch (e: any) { + if (e.response?.status === 404 || e.message?.includes("404")) { + return; + } + throw e; + } } async list(): Promise {