From eca3aa8fe559982962a9b16333b94d41d97d6318 Mon Sep 17 00:00:00 2001 From: CieanYau Date: Sat, 7 Nov 2026 03:58:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=B0=86=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=BC=82=E6=AD=A5=E5=89=AF=E4=BD=9C=E7=94=A8=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=88=B0=E5=BD=93=E5=89=8D=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pages/forgot-password.vue | 33 +- app/pages/messages/index.vue | 85 +++-- app/pages/security/ssh-keys/index.vue | 38 +- app/pages/tickets/[id].vue | 69 ++-- app/pages/tickets/create.vue | 35 +- tests/review-page-ownership.e2e.test.mjs | 453 +++++++++++++++++++++++ 6 files changed, 623 insertions(+), 90 deletions(-) create mode 100644 tests/review-page-ownership.e2e.test.mjs diff --git a/app/pages/forgot-password.vue b/app/pages/forgot-password.vue index 52fa581..8618719 100644 --- a/app/pages/forgot-password.vue +++ b/app/pages/forgot-password.vue @@ -11,6 +11,7 @@ definePageMeta({ alias: [`/forgot_password`], }); import { toDisplayString } from "vue"; +import { onScopeDispose } from "vue"; import { normalizeClass } from "vue"; import { createBlock } from "vue"; import { ref } from "vue"; @@ -30,7 +31,7 @@ import { useRoute } from "#app"; import { useRouter } from "#app"; import { useHead } from "#app"; import { NuxtLink } from "#components"; -import { useApi } from "~/lib/auth.js"; +import { useApi, useAuth } from "~/lib/auth.js"; import button from "openvue/button"; import message from "openvue/message"; import { n as auth_redirect_n } from "~/lib/auth-redirect.js"; @@ -79,6 +80,12 @@ var D = { z = defineComponent({ __name: `forgot-password`, setup(f) { + const session = useAuth().prepareLogin(); + let disposed = false; + const isCurrent = () => !disposed && session.isCurrent(); + onScopeDispose(() => { + disposed = true; + }); (useHead({ title: `找回密码 - 破碎工坊网络`, }), @@ -133,7 +140,7 @@ var D = { } // 校验账号、区号、验证码和两次密码后提交重置;失败交由验证码实例处理挑战刷新。 async function pe() { - if (G.value || Q.value) return; + if (!isCurrent() || G.value || Q.value) return; W.value = ``; let e = U.account.trim(); if (!e) @@ -145,26 +152,28 @@ var D = { return (W.value = `两次输入的密码不一致`); G.value = !0; try { - (await z.post(`/account/password_reset`, { + await z.post(`/account/password_reset`, { type: H.value, account: e, phone_code: `86`, code: U.code.trim(), password: U.password, re_password: U.re_password, - }), - await B.push({ - path: `/login`, - query: { - reset: `1`, - ...auth_redirect_n(V.query.redirect), - }, - })); + }); + if (!isCurrent()) return; + await B.push({ + path: `/login`, + query: { + reset: `1`, + ...auth_redirect_n(V.query.redirect), + }, + }); } catch (e) { + if (!isCurrent()) return; ((W.value = e instanceof Error ? e.message : `重置失败`), await ue(e)); } finally { - G.value = !1; + if (isCurrent()) G.value = !1; } } // 手机号/邮箱输入、验证码和密码表单共享提交锁;成功导航由 pe 负责,渲染层不直接建立会话。 diff --git a/app/pages/messages/index.vue b/app/pages/messages/index.vue index 9998b6a..8218272 100644 --- a/app/pages/messages/index.vue +++ b/app/pages/messages/index.vue @@ -10,6 +10,7 @@ definePageMeta({ }); import { toDisplayString } from "vue"; import { onMounted } from "vue"; +import { onScopeDispose } from "vue"; import { normalizeClass } from "vue"; import { createBlock } from "vue"; import { ref } from "vue"; @@ -27,7 +28,7 @@ import { createVNode } from "vue"; import { openBlock } from "vue"; import { withModifiers } from "vue"; import { useRoute } from "#app"; -import { useApi } from "~/lib/auth.js"; +import { useApi, useAuth } from "~/lib/auth.js"; import dialog from "openvue/dialog"; import button from "openvue/button"; import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js"; @@ -111,6 +112,12 @@ var ge = { defineComponent({ __name: `index`, setup(p) { + const session = useAuth().prepareLogin(); + let disposed = false; + const isCurrent = () => !disposed && session.isCurrent(); + onScopeDispose(() => { + disposed = true; + }); composables_t({ key: (e) => e.fullPath, }); @@ -185,37 +192,39 @@ var ge = { } // 将指定 ID 标为已读,完成后清除选择并刷新列表与全局未读计数。 async function ze(e) { - if (!(!e.length || B.value || G.value)) { + if (isCurrent() && !(!e.length || B.value || G.value)) { ((B.value = !0), (U.value = ``)); try { - (await y.put(`/client_care/mail/read`, { + await y.put(`/client_care/mail/read`, { id: e, - }), - (z.value = []), - await R(), - await D()); + }); + if (!isCurrent()) return; + z.value = []; + await refreshMessages(); } catch (e) { - U.value = e instanceof Error ? e.message : `操作失败`; + if (isCurrent()) + U.value = e instanceof Error ? e.message : `操作失败`; } finally { - B.value = !1; + if (isCurrent()) B.value = !1; } } } // 使用 all 标记请求服务端全部已读,不局限于当前可见页。 async function Be() { - if (!(B.value || G.value)) { + if (isCurrent() && !(B.value || G.value)) { ((B.value = !0), (U.value = ``)); try { - (await y.put(`/client_care/mail/read`, { + await y.put(`/client_care/mail/read`, { all: 1, - }), - (z.value = []), - await R(), - await D()); + }); + if (!isCurrent()) return; + z.value = []; + await refreshMessages(); } catch (e) { - U.value = e instanceof Error ? e.message : `操作失败`; + if (isCurrent()) + U.value = e instanceof Error ? e.message : `操作失败`; } finally { - B.value = !1; + if (isCurrent()) B.value = !1; } } } @@ -231,24 +240,30 @@ var ge = { } // 仅提交选中消息的 ID;成功后同步列表与未读计数。 async function He() { - if (!z.value.length || G.value || B.value) return; + if (!isCurrent() || !z.value.length || G.value || B.value) return; let e = z.value.map((e) => e.id); ((G.value = !0), (U.value = ``)); try { - (await y.del(`/client_care/mail`, { + await y.del(`/client_care/mail`, { id: e, - }), - (W.value = !1), - (z.value = []), - await R(), - await D()); + }); + if (!isCurrent()) return; + W.value = !1; + z.value = []; + await refreshMessages(); } catch (e) { + if (!isCurrent()) return; ((W.value = !1), (U.value = e instanceof Error ? e.message : `删除失败`)); } finally { - G.value = !1; + if (isCurrent()) G.value = !1; } } + async function refreshMessages() { + if (!isCurrent()) return; + await R(); + if (isCurrent()) await D(); + } let K = ref(!1), q = ref(null), J = ref(!1), @@ -257,7 +272,9 @@ var ge = { Z = 0; // 详情打开时清除旧正文并递增请求序号;加载成功后尝试标读,标读失败不覆盖已加载的正文。 async function Q(e) { + if (!isCurrent()) return; let t = ++Z; + const ownsDetail = () => isCurrent() && t === Z; ((X.value = e), (K.value = !0), (J.value = !0), @@ -265,7 +282,7 @@ var ge = { (q.value = null)); try { let n = await y.get(`/client_care/mail/${e}`); - if (t !== Z) return; + if (!ownsDetail()) return; if (!n?.mail?.id) { Y.value = `消息不存在或已删除`; return; @@ -274,23 +291,25 @@ var ge = { let r = M.value.find((t) => t.id === e); if (!r || r.read === 0) try { - (await y.put(`/client_care/mail/read`, { + await y.put(`/client_care/mail/read`, { id: [e], - }), - r && (r.read = 1), - await D()); + }); + if (!ownsDetail()) return; + if (r) r.read = 1; + await D(); } catch {} } catch (e) { - t === Z && (Y.value = e instanceof Error ? e.message : `加载失败`); + ownsDetail() && + (Y.value = e instanceof Error ? e.message : `加载失败`); } finally { - t === Z && (J.value = !1); + ownsDetail() && (J.value = !1); } } return ( onMounted(async () => { await D(); let e = Number(T.query.id); - e && Q(e); + isCurrent() && e && Q(e); }), // 批量操作基于当前选择集,详情正文经公共清理后展示;详情 ID 与加载代次防止快速切换串信。 (t, i) => { diff --git a/app/pages/security/ssh-keys/index.vue b/app/pages/security/ssh-keys/index.vue index ececde1..9bd9c9d 100644 --- a/app/pages/security/ssh-keys/index.vue +++ b/app/pages/security/ssh-keys/index.vue @@ -9,6 +9,7 @@ import { reactive, ref, useId, + watch, } from "vue"; import Button from "openvue/button"; import Column from "openvue/column"; @@ -19,11 +20,12 @@ import Message from "openvue/message"; import Paginator from "openvue/paginator"; import Textarea from "openvue/textarea"; import ListEmpty from "~/components/ListEmpty.vue"; -import { useApi } from "~/lib/auth.js"; +import { useApi, useAuth } from "~/lib/auth.js"; import { n as loadCompleteList } from "~/lib/complete-list.js"; import "~/assets/styles/ssh-keys.WWTOf2zG.css"; const api = useApi(); +const session = useAuth().prepareLogin(); const fieldId = useId(); const draftSearch = ref(""); const appliedSearch = ref(""); @@ -43,6 +45,16 @@ const copiedKeyId = ref(null); let copyResetTimer; let requestGeneration = 0; let disposed = false; +let editorGeneration = 0; +const isCurrent = () => !disposed && session.isCurrent(); +watch( + editorVisible, + () => { + editorGeneration++; + saving.value = false; + }, + { flush: "sync" }, +); const firstRecord = computed(() => (currentPage.value - 1) * pageSize.value); const visibleRows = computed(() => @@ -53,7 +65,7 @@ const visibleRows = computed(() => // 仅允许当前加载代次且组件尚未卸载时修改列表反馈。 function isCurrentLoad(generation) { - return !disposed && generation === requestGeneration; + return isCurrent() && generation === requestGeneration; } // 删除或搜索后夹紧页码;返回是否变化,供服务端分页决定是否重读修正后的页。 @@ -67,7 +79,7 @@ function clampPage() { // 非搜索模式请求当前服务端页,删除末页最后一条时先修正页码再重读。 async function loadKeys() { - if (disposed) return; + if (!isCurrent()) return; if (appliedSearch.value) return loadSearchResults(); const generation = ++requestGeneration; searchLoading.value = false; @@ -174,7 +186,7 @@ function openEditDialog(key) { // 校验名称非空、字符数上限和公钥非空;按编辑对象选择创建/更新,成功后刷新当前模式列表。 async function saveKey() { - if (saving.value || disposed) return; + if (saving.value || !isCurrent()) return; actionError.value = ""; if (!keyForm.name.trim()) { actionError.value = "请输入密钥名称"; @@ -189,6 +201,8 @@ async function saveKey() { return; } saving.value = true; + const generation = editorGeneration; + const ownsDraft = () => isCurrent() && generation === editorGeneration; try { if (editingKey.value) { await api.put("/ssh_key/" + editingKey.value.id, { @@ -198,25 +212,25 @@ async function saveKey() { } else { await api.post("/ssh_key", { ...keyForm }); } - if (disposed) return; - editorVisible.value = false; + if (!isCurrent()) return; + if (ownsDraft()) editorVisible.value = false; await loadKeys(); } catch (error) { - if (!disposed) + if (ownsDraft()) actionError.value = error instanceof Error ? error.message : "保存失败"; } finally { - if (!disposed) saving.value = false; + if (ownsDraft()) saving.value = false; } } // 删除后复用加载逻辑;末页恢复由 loadKeys/clampPage 统一处理。 async function deleteKey(key) { - if (disposed) return; + if (!isCurrent()) return; try { await api.del("/ssh_key/" + key.id); - await loadKeys(); + if (isCurrent()) await loadKeys(); } catch (error) { - if (!disposed) + if (isCurrent()) actionError.value = error instanceof Error ? error.message : "删除失败"; } } @@ -225,7 +239,7 @@ async function deleteKey(key) { async function copyPublicKey(key) { try { await navigator.clipboard.writeText(key.public_key); - if (disposed) return; + if (!isCurrent()) return; clearTimeout(copyResetTimer); copiedKeyId.value = key.id; copyResetTimer = setTimeout(() => (copiedKeyId.value = null), 2000); diff --git a/app/pages/tickets/[id].vue b/app/pages/tickets/[id].vue index 0175a13..67d27a5 100644 --- a/app/pages/tickets/[id].vue +++ b/app/pages/tickets/[id].vue @@ -12,6 +12,7 @@ definePageMeta({ import { toDisplayString } from "vue"; import { renderList } from "vue"; import { onMounted } from "vue"; +import { onScopeDispose } from "vue"; import { normalizeClass } from "vue"; import { createBlock } from "vue"; import { ref } from "vue"; @@ -30,7 +31,7 @@ import { openBlock } from "vue"; import { normalizeStyle } from "vue"; import { useRoute } from "#app"; import { NuxtLink } from "#components"; -import { useApi } from "~/lib/auth.js"; +import { useApi, useAuth } from "~/lib/auth.js"; import button from "openvue/button"; import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js"; import message from "openvue/message"; @@ -124,6 +125,12 @@ var le = { defineComponent({ __name: `[id]`, setup(m) { + const session = useAuth().prepareLogin(); + let disposed = false; + const isCurrent = () => !disposed && session.isCurrent(); + onScopeDispose(() => { + disposed = true; + }); let y = useRoute(), z = useApi(), B = String(y.params.id ?? y.query.id ?? ``); @@ -143,17 +150,20 @@ var le = { const canOperate = computed(() => V.value?.can_operate === 1); // 读取工单并确认实际存在;后续回复/催单/关闭完成也复用此入口同步服务端状态。 async function X() { + if (!isCurrent()) return; try { let e = await z.get(`/ticket/${B}`); + if (!isCurrent()) return; if (!e.ticket || !e.ticket.id) { U.value = `工单不存在或已删除`; return; } V.value = e.ticket; } catch (e) { - U.value = e instanceof Error ? e.message : `加载失败`; + if (isCurrent()) + U.value = e instanceof Error ? e.message : `加载失败`; } finally { - H.value = !1; + if (isCurrent()) H.value = !1; } } let Z = computed(() => { @@ -192,15 +202,16 @@ var le = { ); // 工单允许操作时逐个上传并检查 save_name,成功部分保留在当前附件草稿。 async function xe(e) { - if (J.value || G.value || !canOperate.value) return; + if (!isCurrent() || J.value || G.value || !canOperate.value) return; let t = e.target, n = Array.from(t.files ?? []); if (n.length) { ((J.value = !0), (U.value = ``)); try { for (let e of n) { + if (!isCurrent() || !canOperate.value) return; let t = await z.upload(e); - if (!canOperate.value) return; + if (!isCurrent() || !canOperate.value) return; if (!t?.save_name) throw Error(`附件「${e.name}」上传失败,请重新选择文件`); q.value.push({ @@ -209,9 +220,10 @@ var le = { }); } } catch (e) { - U.value = e instanceof Error ? e.message : `附件上传失败`; + if (isCurrent()) + U.value = e instanceof Error ? e.message : `附件上传失败`; } finally { - ((J.value = !1), (t.value = ``)); + if (isCurrent()) ((J.value = !1), (t.value = ``)); } } } @@ -254,47 +266,56 @@ var le = { } // 回复要求内容非空、附件已上传且服务端允许操作;成功后清空草稿与引用并重读工单。 async function De() { - if (!(G.value || !W.value.trim() || J.value || !canOperate.value)) { + if ( + isCurrent() && + !(G.value || !W.value.trim() || J.value || !canOperate.value) + ) { G.value = !0; try { - (await z.post(`/ticket/${B}/reply`, { + await z.post(`/ticket/${B}/reply`, { content: W.value.trim(), attachment: q.value.map((e) => e.save_name), quote_reply_id: Y.value, - }), - (W.value = ``), - (q.value = []), - (Y.value = 0), - await X()); + }); + if (!isCurrent()) return; + W.value = ``; + q.value = []; + Y.value = 0; + await X(); } catch (e) { - U.value = e instanceof Error ? e.message : `回复失败`; + if (isCurrent()) + U.value = e instanceof Error ? e.message : `回复失败`; } finally { - G.value = !1; + if (isCurrent()) G.value = !1; } } } // 催单后重新读取工单,结果与可继续操作状态以服务端为准。 async function Oe() { - if (!canOperate.value) return; + if (!isCurrent() || !canOperate.value) return; K.value = !0; try { - (await z.put(`/ticket/${B}/urge`), await X()); + await z.put(`/ticket/${B}/urge`); + if (isCurrent()) await X(); } catch (e) { - U.value = e instanceof Error ? e.message : `催单失败`; + if (isCurrent()) + U.value = e instanceof Error ? e.message : `催单失败`; } finally { - K.value = !1; + if (isCurrent()) K.value = !1; } } // 关闭工单完成后重读详情,由服务端权限控制回复与上传入口。 async function ke() { - if (!canOperate.value) return; + if (!isCurrent() || !canOperate.value) return; K.value = !0; try { - (await z.put(`/ticket/${B}/close`), await X()); + await z.put(`/ticket/${B}/close`); + if (isCurrent()) await X(); } catch (e) { - U.value = e instanceof Error ? e.message : `关闭失败`; + if (isCurrent()) + U.value = e instanceof Error ? e.message : `关闭失败`; } finally { - K.value = !1; + if (isCurrent()) K.value = !1; } } return ( diff --git a/app/pages/tickets/create.vue b/app/pages/tickets/create.vue index 7bc2292..9f73a82 100644 --- a/app/pages/tickets/create.vue +++ b/app/pages/tickets/create.vue @@ -7,6 +7,7 @@ import "~/assets/styles/create.gX6gpD00.css"; import { toDisplayString } from "vue"; import { renderList } from "vue"; import { onMounted } from "vue"; +import { onScopeDispose } from "vue"; import { createBlock } from "vue"; import { ref } from "vue"; import { withCtx } from "vue"; @@ -24,7 +25,7 @@ import { openBlock } from "vue"; import { withModifiers } from "vue"; import { useRouter } from "#app"; import { NuxtLink } from "#components"; -import { useApi } from "~/lib/auth.js"; +import { useApi, useAuth } from "~/lib/auth.js"; import button from "openvue/button"; import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js"; import message from "openvue/message"; @@ -100,6 +101,12 @@ var le = { defineComponent({ __name: `create`, setup(m) { + const session = useAuth().prepareLogin(); + let disposed = false; + const isCurrent = () => !disposed && session.isCurrent(); + onScopeDispose(() => { + disposed = true; + }); let b = useApi(), z = useRouter(), B = ref([]), @@ -136,14 +143,16 @@ var le = { Z = ref(!1); // 逐个上传并要求 save_name,上传部分成功的附件仍保留,失败显示具体文件问题。 async function he(e) { - if (Z.value || W.value) return; + if (!isCurrent() || Z.value || W.value) return; let t = e.target, n = Array.from(t.files ?? []); if (n.length) { ((Z.value = !0), (G.value = ``)); try { for (let e of n) { + if (!isCurrent()) return; let t = await b.upload(e); + if (!isCurrent()) return; if (!t?.save_name) throw Error(`附件「${e.name}」上传失败,请重新选择文件`); X.value.push({ @@ -152,9 +161,10 @@ var le = { }); } } catch (e) { - G.value = e instanceof Error ? e.message : `上传失败`; + if (isCurrent()) + G.value = e instanceof Error ? e.message : `上传失败`; } finally { - ((Z.value = !1), (t.value = ``)); + if (isCurrent()) ((Z.value = !1), (t.value = ``)); } } } @@ -164,45 +174,50 @@ var le = { } // 类型/部门加载成功后再开放提交;实例可选列表分页读取,最多 100 页,实例查询失败不阻止无关联工单创建。 async function Q() { - if (!K.value) { + if (isCurrent() && !K.value) { ((K.value = !0), (q.value = !1), (G.value = ``)); try { let [e, t] = await Promise.all([ b.get(`/ticket/type`), b.get(`/ticket/department`), ]); + if (!isCurrent()) return; ((B.value = e.list ?? []), (V.value = t.list ?? [])); try { let e = [], t = 1, n = 0; do { + if (!isCurrent()) return; let r = await b.get(`/host`, { page: t, limit: 200, }); + if (!isCurrent()) return; (e.push(...(r.list ?? [])), (n = Number(r.count ?? e.length)), (t += 1)); } while (e.length < n && t <= 100); H.value = e; } catch { + if (!isCurrent()) return; H.value = []; } ((U.ticket_type_id = B.value[0]?.id ?? null), (U.admin_role_id = V.value[0]?.id ?? null), (q.value = !0)); } catch (e) { + if (!isCurrent()) return; G.value = e instanceof Error ? e.message : `加载工单选项失败,请重试`; } finally { - K.value = !1; + if (isCurrent()) K.value = !1; } } } // 标题和内容非空且选项就绪后创建工单,只提交附件保存标识,成功进入返回 ID 的详情。 async function $() { - if (!(W.value || Z.value || K.value || !q.value)) { + if (isCurrent() && !(W.value || Z.value || K.value || !q.value)) { if (((G.value = ``), !U.title.trim())) { G.value = `请输入工单标题`; return; @@ -221,11 +236,13 @@ var le = { host_ids: U.host_ids, attachment: X.value.map((e) => e.save_name), }); + if (!isCurrent()) return; z.push(`/tickets/${e.id}`); } catch (e) { - G.value = e instanceof Error ? e.message : `提交失败`; + if (isCurrent()) + G.value = e instanceof Error ? e.message : `提交失败`; } finally { - W.value = !1; + if (isCurrent()) W.value = !1; } } } diff --git a/tests/review-page-ownership.e2e.test.mjs b/tests/review-page-ownership.e2e.test.mjs new file mode 100644 index 0000000..417e2f3 --- /dev/null +++ b/tests/review-page-ownership.e2e.test.mjs @@ -0,0 +1,453 @@ +/** 页面/会话/草稿归属:真实页面与真实登录,只有外部 API 响应受控。 */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + appOrigin, + backendOrigin, + login, + loginAccount, + loginPassword, + scenario, +} from "./helpers/console-browser.mjs"; + +function deferred() { + let resolve; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +} + +function respond(route, data, status = 200) { + return route.fulfill({ + json: { status, msg: status === 200 ? "" : "本地延迟失败", data }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); +} + +async function boundary(page, handle) { + const requests = []; + await page + .context() + .route(`${backendOrigin}/console/v1/**`, async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^\/console\/v1/, ""); + const entry = { + method: request.method(), + path, + authenticated: !!request.headers().authorization, + }; + requests.push(entry); + if (await handle(route, path, entry, url)) return; + return route.fallback(); + }); + return requests; +} + +async function releaseResponse(page, release, path) { + const response = page.waitForResponse( + (reply) => new URL(reply.url()).pathname === `/console/v1${path}`, + ); + release.resolve(); + await (await response).finished(); + // Absence assertions observe a bounded idle window after the real fetch has completed. + await page.waitForLoadState("networkidle"); +} + +async function signInOnCurrentDocument(page) { + await page.getByLabel("账号", { exact: true }).fill(loginAccount); + await page.getByLabel("密码", { exact: true }).fill(loginPassword); + await page.getByRole("button", { name: "登录", exact: true }).click(); + await page.waitForURL((url) => url.pathname !== "/login"); + await page.getByRole("button", { name: "用户菜单", exact: true }).waitFor(); +} + +async function logoutAndSignIn(page) { + await page.getByRole("button", { name: "用户菜单", exact: true }).click(); + await page.getByRole("menuitem", { name: "退出登录", exact: true }).click(); + await page.waitForURL((url) => url.pathname === "/login"); + await signInOnCurrentDocument(page); +} + +for (const transition of ["current", "leave", "reauthenticate"]) { + test( + `message detail ownership after ${transition}`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + const requests = await boundary(page, async (route, path) => { + if (path === "/account/notification-preferences") { + await respond(route, { + email_enabled: false, + sms_enabled: false, + email_available: true, + sms_available: false, + }); + return true; + } + if (path === "/client_care/mail/list") { + await respond(route, { list: [], count: 0 }); + return true; + } + if (path === "/client_care/mail/701") { + started.resolve(); + await release.promise; + await respond(route, { + mail: { + id: 701, + title: "归属测试消息", + content: "

本地正文

", + }, + }); + return true; + } + if (path === "/client_care/mail/read") { + await respond(route, {}); + return true; + } + return false; + }); + await login(page, "/messages?id=701"); + await started.promise; + if (transition !== "current") await page.keyboard.press("Escape"); + if (transition === "leave") { + await page + .getByRole("navigation", { name: "会员中心导航" }) + .getByRole("link", { name: "SSH 密钥", exact: true }) + .click(); + await page.waitForURL((url) => url.pathname === "/security/ssh-keys"); + } + if (transition === "reauthenticate") await logoutAndSignIn(page); + await releaseResponse(page, release, "/client_care/mail/701"); + const reads = requests.filter( + (entry) => entry.path === "/client_care/mail/read", + ); + assert.equal( + reads.length, + transition === "current" ? 1 : 0, + "Only the current detail owner may derive mark-read HTTP", + ); + return { + transition, + markReadRequests: reads.length, + authenticated: reads.every((entry) => entry.authenticated), + }; + }), + ); +} + +for (const routePath of ["/tickets/create", "/tickets/71"]) { + for (const transition of ["current", "leave"]) { + test( + `ticket attachment queue ${routePath} after ${transition}`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + let uploads = 0; + const requests = await boundary(page, async (route, path) => { + if (path === "/ticket/config") { + await respond(route, { ticket_notice_open: 0 }); + return true; + } + if (["/ticket/type", "/ticket/department"].includes(path)) { + await respond(route, { list: [{ id: 1, name: "本地支持" }] }); + return true; + } + if (path === "/ticket/71") { + await respond(route, { + ticket: { + id: 71, + title: "本地工单", + content: "

本地内容

", + can_operate: 1, + attachment: [], + replies: [], + create_time: 1700000000, + }, + }); + return true; + } + if (path === "/upload") { + uploads += 1; + if (uploads === 1) { + started.resolve(); + await release.promise; + } + await respond(route, { + save_name: `local-attachment-${uploads}.txt`, + }); + return true; + } + return false; + }); + await login(page, routePath); + const input = page.locator('input[type="file"]'); + await input.setInputFiles( + [1, 2].map((id) => ({ + name: `local-${id}.txt`, + mimeType: "text/plain", + buffer: Buffer.from("non-sensitive local fixture"), + })), + ); + await started.promise; + if (transition === "leave") { + await page + .getByRole("navigation", { name: "会员中心导航" }) + .getByRole("link", { name: "SSH 密钥", exact: true }) + .click(); + await page.waitForURL( + (url) => url.pathname === "/security/ssh-keys", + ); + } + await releaseResponse(page, release, "/upload"); + assert.equal( + uploads, + transition === "current" ? 2 : 1, + "A departed upload owner must not send the next attachment", + ); + if (transition === "current") + await page.getByText("local-2.txt", { exact: true }).waitFor(); + return { + routePath, + transition, + uploads, + authenticated: requests + .filter((entry) => entry.path === "/upload") + .every((entry) => entry.authenticated), + }; + }), + ); + } +} + +for (const transition of ["current", "leave"]) { + test( + `password reset navigation after ${transition}`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + await boundary(page, async (route, path) => { + if (path !== "/account/password_reset") return false; + started.resolve(); + await release.promise; + await respond(route, {}); + return true; + }); + await page.goto(`${appOrigin}/forgot-password`); + await page.getByLabel("手机号", { exact: true }).fill("13800138000"); + await page.getByLabel("验证码 *", { exact: true }).fill("123456"); + await page + .getByLabel("新密码 *", { exact: true }) + .fill("local-test-password"); + await page + .getByLabel("确认新密码 *", { exact: true }) + .fill("local-test-password"); + await page + .getByRole("button", { name: "重置密码", exact: true }) + .click(); + await started.promise; + if (transition === "leave") { + await page + .getByRole("link", { name: "想起密码了?返回登录", exact: true }) + .click(); + await page + .getByRole("link", { name: "注册账号", exact: true }) + .click(); + await page.waitForURL((url) => url.pathname === "/register"); + } + await releaseResponse(page, release, "/account/password_reset"); + assert.equal( + new URL(page.url()).pathname, + transition === "current" ? "/login" : "/register", + "Only the current reset operation may navigate", + ); + return { transition, finalRoute: new URL(page.url()).pathname }; + }), + ); +} + +for (const action of ["options", "submit", "reply", "urge", "close"]) { + test( + `ticket ${action} cannot derive work after leaving`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + const detail = ["reply", "urge", "close"].includes(action); + const heldPath = + action === "options" + ? "/host" + : action === "submit" + ? "/ticket" + : `/ticket/71/${action}`; + let hostPages = 0; + let detailReads = 0; + await boundary(page, async (route, path, entry) => { + if (path === "/ticket/config") { + await respond(route, { ticket_notice_open: 0 }); + return true; + } + if (["/ticket/type", "/ticket/department"].includes(path)) { + await respond(route, { list: [{ id: 1, name: "本地支持" }] }); + return true; + } + if (path === "/ticket/71" && entry.method === "GET") { + detailReads += 1; + await respond(route, { + ticket: { + id: 71, + title: "本地工单", + content: "

本地内容

", + can_operate: 1, + attachment: [], + replies: [], + create_time: 1700000000, + }, + }); + return true; + } + if (path !== heldPath) return false; + if (action === "options") hostPages += 1; + started.resolve(); + await release.promise; + await respond( + route, + action === "options" + ? { list: [{ id: 1, name: "本地实例" }], count: 2 } + : { id: 71 }, + ); + return true; + }); + await login(page, detail ? "/tickets/71" : "/tickets/create"); + if (action === "submit") { + await page + .getByPlaceholder("输入工单标题", { exact: true }) + .fill("本地测试标题"); + await page + .getByRole("textbox", { name: "工单内容", exact: true }) + .fill("本地测试内容"); + await page + .getByRole("button", { name: "创建工单", exact: true }) + .click(); + } + if (detail) { + await page + .getByRole("heading", { name: "本地工单", exact: true }) + .waitFor(); + if (action === "reply") + await page + .getByRole("textbox", { name: "回复内容", exact: true }) + .fill("本地测试回复"); + await page + .getByRole("button", { + name: { reply: "提交回复", urge: "催单", close: "关闭工单" }[ + action + ], + exact: true, + }) + .click(); + } + await started.promise; + await page + .getByRole("navigation", { name: "会员中心导航" }) + .getByRole("link", { name: "SSH 密钥", exact: true }) + .click(); + await page.waitForURL((url) => url.pathname === "/security/ssh-keys"); + await releaseResponse(page, release, heldPath); + assert.equal( + new URL(page.url()).pathname, + "/security/ssh-keys", + "Old ticket completion must not navigate the current page", + ); + if (action === "options") + assert.equal( + hostPages, + 1, + "Departed options loader cannot fetch the next host page", + ); + if (detail) + assert.equal( + detailReads, + 1, + "Departed ticket write cannot derive a detail reload", + ); + return { action, currentRoutePreserved: true, hostPages, detailReads }; + }), + ); +} + +for (const outcome of ["success", "error"]) { + test( + `SSH save ${outcome} cannot own a reopened editor`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + let saves = 0; + await boundary(page, async (route, path, entry) => { + if (path !== "/ssh_key" || entry.method !== "POST") return false; + saves += 1; + if (saves === 1) { + started.resolve(); + await release.promise; + } + await respond( + route, + {}, + saves === 1 && outcome === "error" ? 422 : 200, + ); + return true; + }); + await login(page); + await page.getByText("audit-01", { exact: true }).waitFor(); + await page + .getByRole("button", { name: "添加密钥", exact: true }) + .click(); + let editor = page.getByRole("dialog", { + name: "添加密钥", + exact: true, + }); + await editor.getByLabel("名称 *", { exact: true }).fill("old-draft"); + await editor + .getByLabel("公钥内容 *", { exact: true }) + .fill("ssh-ed25519 local-old-public-key"); + await editor.getByRole("button", { name: "保存", exact: true }).click(); + await started.promise; + await editor.getByRole("button", { name: "取消", exact: true }).click(); + await page + .getByRole("button", { name: "添加密钥", exact: true }) + .click(); + editor = page.getByRole("dialog", { name: "添加密钥", exact: true }); + await editor.getByLabel("名称 *", { exact: true }).fill("new-draft"); + await editor + .getByLabel("公钥内容 *", { exact: true }) + .fill("ssh-ed25519 local-new-public-key"); + await releaseResponse(page, release, "/ssh_key"); + assert.equal( + await editor.isVisible(), + true, + "Old completion cannot close the new editor", + ); + assert.equal( + await editor.getByLabel("名称 *", { exact: true }).inputValue(), + "new-draft", + ); + assert.equal( + await editor.getByRole("alert").count(), + 0, + "Old failure cannot become the new draft's error", + ); + await editor.getByRole("button", { name: "保存", exact: true }).click(); + await editor.waitFor({ state: "hidden" }); + assert.equal(saves, 2, "The new editor remains saveable"); + return { outcome, newerDraftPreserved: true, saves }; + }), + ); +}