diff --git a/app/pages/tickets/[id].vue b/app/pages/tickets/[id].vue index 1a51907..01f7038 100644 --- a/app/pages/tickets/[id].vue +++ b/app/pages/tickets/[id].vue @@ -203,6 +203,7 @@ var le = { try { for (let e of n) { let t = await z.upload(e); + if (!canOperate.value) return; if (!t?.save_name) throw Error(`附件「${e.name}」上传失败,请重新选择文件`); q.value.push({ diff --git a/tests/review-routes.e2e.test.mjs b/tests/review-routes.e2e.test.mjs index 40f4dc6..47ca7c9 100644 --- a/tests/review-routes.e2e.test.mjs +++ b/tests/review-routes.e2e.test.mjs @@ -435,3 +435,102 @@ for (const [routePath, status] of [ }), ); } + +test( + "in-flight ticket uploads stop the remaining files when server permission is revoked", + { timeout: 25000 }, + (t) => + scenario(t, { loginMode: "password-only" }, async (page) => { + const uploads = []; + const ticket = { + id: 2, + ticket_num: "AUDIT-2", + title: "上传期间关闭工单", + content: "

本机权限并发验收

", + status: "处理中", + can_operate: 1, + create_time: 1730000000, + username: "audit-user", + client_id: 1, + color: "#777", + attachment: [], + replies: [], + }; + let releaseFirstUpload; + const firstUploadResponse = new Promise((resolve) => { + releaseFirstUpload = resolve; + }); + await page.route( + (url) => + url.origin === backendOrigin && + (url.pathname.startsWith("/console/v1/ticket/") || + url.pathname === "/console/v1/upload"), + async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname.replace( + "/console/v1", + "", + ); + if (path === "/ticket/config") + return fulfillApi(route, { ticket_notice_open: 0 }); + if (request.method() === "GET" && path === "/ticket/2") + return fulfillApi(route, { ticket }); + if (request.method() === "PUT" && path === "/ticket/2/close") { + ticket.can_operate = 0; + ticket.status = "处理完毕"; + return fulfillApi(route, {}); + } + if (request.method() === "POST" && path === "/upload") { + const index = uploads.push({ method: "POST", path }); + if (index === 1) await firstUploadResponse; + return fulfillApi(route, { save_name: `attachment-${index}.txt` }); + } + return route.fallback(); + }, + ); + try { + await login(page, "/tickets/2"); + await page + .getByRole("heading", { name: ticket.title, exact: true }) + .waitFor(); + await page.locator("#reply-files").setInputFiles( + ["first.txt", "second.txt"].map((name) => ({ + name, + mimeType: "text/plain", + buffer: Buffer.from("local permission regression"), + })), + ); + await eventually( + () => uploads.length, + (count) => count === 1, + "First upload reached the local HTTP boundary", + ); + await page + .getByRole("button", { name: "关闭工单", exact: true }) + .click(); + await page + .getByRole("button", { name: "关闭工单", exact: true }) + .waitFor({ state: "hidden" }); + releaseFirstUpload(); + await page + .getByText("正在上传…", { exact: true }) + .waitFor({ state: "hidden" }); + assert.equal( + uploads.length, + 1, + "Revoked permission must stop the second upload", + ); + assert.equal(await page.locator(".reply-file-list").count(), 0); + return { + selectedFiles: 2, + uploadsStarted: uploads.length, + serverCanOperate: ticket.can_operate, + alreadyStartedUploadCompleted: true, + remainingUploadsStopped: true, + revokedUploadNotAddedToDraft: true, + }; + } finally { + releaseFirstUpload(); + } + }), +);