diff --git a/frontend/src/utils/__tests__/check-conflict.test.ts b/frontend/src/utils/__tests__/check-conflict.test.ts index c60b33ab..75f31ef2 100644 --- a/frontend/src/utils/__tests__/check-conflict.test.ts +++ b/frontend/src/utils/__tests__/check-conflict.test.ts @@ -286,4 +286,102 @@ describe("checkConflict", () => { expect(conflicts).toHaveLength(1); }); + + it.each([ + ["forward slashes", "/target/My SubDir/file.txt"], + ["Windows backslashes", "\\target\\My SubDir\\file.txt"], + ])( + "detects conflicts below an encoded destination path with %s", + async (_label, serverPath) => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: serverPath, + name: "file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + ]); + + const conflicts = await checkConflict( + [moveItem("file.txt", "/files/target/My%20SubDir/")], + "/files/target/My%20SubDir/" + ); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0].name).toBe("/target/My SubDir/file.txt"); + } + ); + + it("detects nested folder-upload conflicts below an encoded destination path", async () => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: "\\target\\My SubDir\\folder\\nested file.txt", + name: "nested file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + ]); + + const conflicts = await checkConflict( + [ + { + name: "nested file.txt", + size: 12, + isDir: false, + fullPath: "folder/nested file.txt", + }, + ], + "/files/target/My%20SubDir/" + ); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0].name).toBe("/target/My SubDir/folder/nested file.txt"); + }); + + it("leaves malformed destination path segments unchanged", async () => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: "/target/bad%ZZ/file.txt", + name: "file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + ]); + + const conflicts = await checkConflict( + [moveItem("file.txt", "/files/target/bad%ZZ/")], + "/files/target/bad%ZZ/" + ); + + expect(conflicts).toHaveLength(1); + }); + + it("does not match files outside the decoded destination path", async () => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: "/target/My SubDir/other.txt", + name: "other.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + { + path: "/target/My Other SubDir/file.txt", + name: "file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + ]); + + const conflicts = await checkConflict( + [moveItem("file.txt", "/files/target/My%20SubDir/")], + "/files/target/My%20SubDir/" + ); + + expect(conflicts).toHaveLength(0); + }); }); diff --git a/frontend/src/utils/upload.ts b/frontend/src/utils/upload.ts index fec3c2a6..d331f7f2 100644 --- a/frontend/src/utils/upload.ts +++ b/frontend/src/utils/upload.ts @@ -44,13 +44,26 @@ function conflictPath(entry: ServerConflictEntry): string { return entry.path.replace(/\\/g, "/"); } +function decodePath(path: string): string { + return path + .split("/") + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join("/"); +} + function buildConflictMap( serverEntries: ServerConflictEntry[], basePath: string, includeDirectories: boolean ): Map { const serverMap = new Map(); - const normBase = removePrefix(basePath).replace(/\/+$/, ""); + const normBase = decodePath(removePrefix(basePath)).replace(/\/+$/, ""); for (const entry of serverEntries) { // A Windows server may return OS-native backslash separators; normalize to // forward slashes so the prefix strip and key lookup line up. diff --git a/frontend/src/views/files/FileListing.vue b/frontend/src/views/files/FileListing.vue index 9bf4c488..77b41ae3 100644 --- a/frontend/src/views/files/FileListing.vue +++ b/frontend/src/views/files/FileListing.vue @@ -846,7 +846,7 @@ const drop = async (event: DragEvent) => { } } if (files.length > 0) { - upload.handleFiles(files, path, true); + upload.handleFiles(files, path); fileStore.preselect = preselect; } }, @@ -902,7 +902,7 @@ const uploadInput = async (event: Event) => { } } if (uploadFiles.length > 0) { - upload.handleFiles(uploadFiles, path, true); + upload.handleFiles(uploadFiles, path); } }, }); diff --git a/frontend/src/views/files/__tests__/upload-conflict-resolution.test.ts b/frontend/src/views/files/__tests__/upload-conflict-resolution.test.ts new file mode 100644 index 00000000..3e1ede9c --- /dev/null +++ b/frontend/src/views/files/__tests__/upload-conflict-resolution.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import FileListing from "@/views/files/FileListing.vue"; +import UploadPrompt from "@/components/prompts/Upload.vue"; + +const harness = vi.hoisted(() => ({ + checkConflict: vi.fn(), + scanFiles: vi.fn(), + handleFiles: vi.fn(), + queueUpload: vi.fn(), + showHover: vi.fn(), + closeHovers: vi.fn(), + fileStore: { + req: { items: [] }, + selected: [] as number[], + selectedCount: 0, + multiple: false, + preselect: "", + }, +})); + +vi.mock("@/stores/auth", () => ({ + useAuthStore: () => ({ user: null }), +})); +vi.mock("@/stores/clipboard", () => ({ + useClipboardStore: () => ({ items: [], $patch: vi.fn() }), +})); +vi.mock("@/stores/file", () => ({ + useFileStore: () => harness.fileStore, +})); +vi.mock("@/stores/layout", () => ({ + useLayoutStore: () => ({ + currentPrompt: null, + showHover: harness.showHover, + closeHovers: harness.closeHovers, + }), +})); +vi.mock("@/stores/upload", () => ({ + useUploadStore: () => ({ upload: harness.queueUpload }), +})); +vi.mock("@/api", () => ({ + users: {}, + files: {}, +})); +vi.mock("@/utils/constants", () => ({ enableExec: false })); +vi.mock("@/utils/auth", () => ({})); +vi.mock("@/router", () => ({ default: {} })); +vi.mock("@/i18n", () => ({ default: {} })); +vi.mock("@/utils/upload", () => ({ + checkConflict: harness.checkConflict, + scanFiles: harness.scanFiles, + handleFiles: harness.handleFiles, +})); +vi.mock("@/utils/css", () => ({ default: vi.fn() })); +vi.mock("@/components/header/HeaderBar.vue", () => ({ default: {} })); +vi.mock("@/components/header/Action.vue", () => ({ default: {} })); +vi.mock("@/components/Search.vue", () => ({ default: {} })); +vi.mock("@/components/files/ListingItem.vue", () => ({ default: {} })); +vi.mock("@/components/ContextMenu.vue", () => ({ default: {} })); +vi.mock("vue-router", () => ({ + useRoute: () => ({ path: "/files/target/" }), + onBeforeRouteUpdate: vi.fn(), +})); +vi.mock("vue-i18n", async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }), +})); +vi.mock("pinia", async (importOriginal) => ({ + ...(await importOriginal()), + storeToRefs: () => ({ req: { value: harness.fileStore.req } }), +})); +vi.mock("vue", async (importOriginal) => ({ + ...(await importOriginal()), + inject: () => vi.fn(), + watch: vi.fn(), + onMounted: vi.fn(), + onBeforeUnmount: vi.fn(), + useSSRContext: () => ({ modules: new Set() }), +})); + +const file = (name: string): UploadEntry => ({ + file: { name, size: 12, type: "text/plain" } as File, + name, + size: 12, + isDir: false, +}); + +const conflict = ( + index: number, + checked: Array<"origin" | "dest"> +): ConflictingResource => ({ + index, + name: `/target/file-${index}.txt`, + origin: { size: 12 }, + dest: { size: 10 }, + checked, + isSmallerOnServer: true, +}); + +function setup(component: any) { + return component.setup({}, { expose: vi.fn() }); +} + +function confirmPrompt(result: ConflictingResource[]) { + const prompt = harness.showHover.mock.calls[0][0]; + prompt.confirm({ preventDefault: vi.fn() }, result); +} + +async function runActualHandleFiles() { + const [files, path, overwrite] = harness.handleFiles.mock.calls[0]; + const actual = + await vi.importActual("@/utils/upload"); + actual.handleFiles(files, path, overwrite); +} + +function uploadFlags() { + return harness.queueUpload.mock.calls.map((call) => call[3]); +} + +describe("upload conflict resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + harness.fileStore.preselect = ""; + vi.stubGlobal("window", { + innerWidth: 1024, + innerHeight: 768, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal("document", { + getElementsByClassName: () => [], + }); + }); + + it("authorizes an explicit drag/drop Replace choice", async () => { + const upload = file("file-0.txt"); + harness.scanFiles.mockResolvedValue([upload]); + harness.checkConflict.mockResolvedValue([conflict(0, ["origin"])]); + const bindings = setup(FileListing); + + await bindings.drop({ + preventDefault: vi.fn(), + dataTransfer: { files: [upload.file], items: [] }, + target: { + classList: { contains: () => false }, + parentElement: null, + }, + }); + confirmPrompt([conflict(0, ["origin"])]); + await runActualHandleFiles(); + + expect(uploadFlags()).toEqual([true]); + }); + + it("keeps the forbidden both-selected upload overwrite-safe", async () => { + const upload = file("file-0.txt"); + harness.scanFiles.mockResolvedValue([upload]); + harness.checkConflict.mockResolvedValue([conflict(0, ["origin"])]); + const bindings = setup(FileListing); + + await bindings.drop({ + preventDefault: vi.fn(), + dataTransfer: { files: [upload.file], items: [] }, + target: { + classList: { contains: () => false }, + parentElement: null, + }, + }); + confirmPrompt([conflict(0, ["origin", "dest"])]); + await runActualHandleFiles(); + + expect(uploadFlags()).toEqual([false]); + }); + + it("applies Replace only to the selected file in a mixed batch", async () => { + const conflicting = file("file-0.txt"); + const nonconflicting = file("new-file.txt"); + harness.checkConflict.mockResolvedValue([conflict(0, ["origin"])]); + const bindings = setup(FileListing); + + await bindings.uploadInput({ + currentTarget: { files: [conflicting.file, nonconflicting.file] }, + }); + confirmPrompt([conflict(0, ["origin"])]); + await runActualHandleFiles(); + + expect(uploadFlags()).toEqual([true, false]); + }); + + it("keeps a nonconflicting sibling protected after Skip", async () => { + const conflicting = file("file-0.txt"); + const nonconflicting = file("late-file.txt"); + harness.checkConflict.mockResolvedValue([conflict(0, ["origin"])]); + const bindings = setup(FileListing); + + await bindings.uploadInput({ + currentTarget: { files: [conflicting.file, nonconflicting.file] }, + }); + confirmPrompt([conflict(0, ["dest"])]); + await runActualHandleFiles(); + + expect(harness.queueUpload).toHaveBeenCalledTimes(1); + expect(harness.queueUpload).toHaveBeenCalledWith( + "/files/target/late-file.txt", + "late-file.txt", + nonconflicting.file, + false, + "text" + ); + }); + + it("matches the Upload prompt per-file overwrite semantics", async () => { + const conflicting = file("file-0.txt"); + const nonconflicting = file("new-file.txt"); + harness.checkConflict.mockResolvedValue([conflict(0, ["origin"])]); + const bindings = setup(UploadPrompt); + + await bindings.uploadInput({ + currentTarget: { files: [conflicting.file, nonconflicting.file] }, + }); + confirmPrompt([conflict(0, ["origin"])]); + await runActualHandleFiles(); + + expect(uploadFlags()).toEqual([true, false]); + }); +});