From 6e2c11447463310f8ba7320c45a772a2b00daf1e Mon Sep 17 00:00:00 2001 From: CieanYau Date: Fri, 6 Nov 2026 03:55:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A6=96=E6=AC=A1=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E8=B7=B3=E8=BD=AC=E7=99=BD=E5=B1=8F=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E7=99=BB=E5=BD=95=E8=A1=A8=E5=8D=95=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app.vue | 17 ++++-- tests/console.e2e.test.mjs | 122 ++++++++++++++++++++++++++++++++----- 2 files changed, 117 insertions(+), 22 deletions(-) diff --git a/app/app.vue b/app/app.vue index 7bda34c..54d4288 100644 --- a/app/app.vue +++ b/app/app.vue @@ -4,7 +4,7 @@ * 路由放行与组件实际挂载同时受会话约束,防止重定向等待期间仍执行受保护页面请求。 */ import { computed, onMounted, onBeforeUnmount } from "vue"; -import { useRoute } from "#app"; +import { useRouter } from "#app"; import { NuxtLayout, NuxtPage, NuxtRouteAnnouncer } from "#components"; import BuildUpdateNotice from "~/components/BuildUpdateNotice.vue"; import OperationToasts from "~/components/OperationToasts.vue"; @@ -13,18 +13,23 @@ import { t as useTheme } from "~/lib/useTheme.js"; import { isPublicShop, isAuthPage, isPublicRoute } from "~/utils/access.js"; const auth = useAuth(); -const route = useRoute(); +// 入口控制 NuxtPage 是否挂载,必须读取守卫已确认的路由;Nuxt useRoute 要等页面完成才同步,会形成循环等待。 +const route = useRouter().currentRoute; const theme = useTheme(); // 只为访客切换公开购买布局;登录后的同一路由继续使用会员中心布局。 const layout = computed(() => - !auth.isLoggedIn && isPublicShop(route) ? "public-order" : route.meta.layout, + !auth.isLoggedIn && isPublicShop(route.value) + ? "public-order" + : route.value.meta.layout, ); // 中间件负责导航,入口负责禁止未授权组件树挂载,两者处理的是不同的生命周期阶段。 -const canRenderRoute = computed(() => auth.isLoggedIn || isPublicRoute(route)); +const canRenderRoute = computed( + () => auth.isLoggedIn || isPublicRoute(route.value), +); // 账号切换时重新创建布局与页面,清除旧账号的局部状态;登录入口自身按路径区分。 const routeSessionKey = computed(() => - isAuthPage(route.path) || route.path === "/home.htm" - ? route.path + isAuthPage(route.value.path) || route.value.path === "/home.htm" + ? route.value.path : auth.token || "anonymous", ); // 主题监听由入口统一注册和释放,避免每次页面导航重复绑定系统主题事件。 diff --git a/tests/console.e2e.test.mjs b/tests/console.e2e.test.mjs index b0bc8de..a5872be 100644 --- a/tests/console.e2e.test.mjs +++ b/tests/console.e2e.test.mjs @@ -7,6 +7,7 @@ import { createDecipheriv } from "node:crypto"; import { test } from "node:test"; import { appOrigin, + backendOrigin, eventually, fixtureRequest, login, @@ -24,29 +25,105 @@ async function visibleKeyIds(page) { return rows.map((row) => Number(row.match(/#(\d+)/)[1])); } +async function visibleLoginForm(page) { + // 地址跳转不代表登录页已挂载:必须能看到并操作真实表单,避免白屏被误报通过。 + await page.getByLabel("账号", { exact: true }).waitFor(); + const password = page.getByLabel("密码", { exact: true }); + await password.waitFor(); + assert.equal(await password.getAttribute("type"), "password"); + const submit = page.getByRole("button", { name: "登录", exact: true }); + await submit.waitFor(); + assert.equal(await submit.isEnabled(), true); +} + +for (const destination of [ + "/", + "/security/ssh-keys?m=7#details", + "/servers/server?id=5", + "/products/123?edit_position=0", +]) { + test( + `auth guard renders the login form after opening ${destination}`, + { timeout: 20000 }, + (t) => + scenario(t, {}, async (page) => { + // 覆盖首次打开根入口、列表与嵌套详情;不能只检查地址而漏掉登录表单的挂载死锁。 + await page.goto(`${appOrigin}${destination}`); + await page.waitForURL((url) => url.pathname === "/login"); + await visibleLoginForm(page); + assert.equal( + new URL(page.url()).searchParams.get("redirect"), + destination === "/" ? null : destination, + ); + assert.equal( + await page.evaluate(() => localStorage.getItem("jwt")), + null, + ); + const requests = await fixtureRequest("requests"); + assert.equal( + requests.some((request) => + ["/ssh_key", "/host/5", "/menu", "/account"].includes(request.path), + ), + false, + "The redirected login page must not mount the protected page or layout", + ); + return { + loginFormVisible: true, + destinationPreserved: true, + protectedApiNotCalled: true, + }; + }), + ); +} + test( - "auth guard preserves the protected destination", - { timeout: 20000 }, + "public catalog switches from guest to member layout after real login", + { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { - // 访客导航保留查询串和锚点,但不能先发受保护的列表请求再跳转。 - await page.goto(`${appOrigin}/security/ssh-keys?m=7#details`); - await page.waitForURL((url) => url.pathname === "/login"); - const url = new URL(page.url()); + // 分类是公开 HTTP 边界;空分类契约足以验证入口布局,不引入测试专用产品参数。 + await page + .context() + .route(`${backendOrigin}/console/v1/product/group/*`, (route) => + route.fulfill({ + json: { status: 200, msg: "", data: { list: [] } }, + headers: { "Access-Control-Allow-Origin": "*" }, + }), + ); + await page.goto(`${appOrigin}/products`); + await page + .getByRole("heading", { name: "订购产品", exact: true }) + .waitFor(); + await page + .getByRole("navigation", { name: "产品导航", exact: true }) + .waitFor(); assert.equal( - url.searchParams.get("redirect"), - "/security/ssh-keys?m=7#details", + await page.getByRole("navigation", { name: "会员中心导航" }).count(), + 0, ); assert.equal( - await page.evaluate(() => localStorage.getItem("jwt")), - null, - ); - const requests = await fixtureRequest("requests"); - assert.equal( - requests.some((request) => request.path === "/ssh_key"), + (await fixtureRequest("requests")).some((request) => + ["/index", "/account", "/menu"].includes(request.path), + ), false, ); - return { destinationPreserved: true, protectedApiNotCalled: true }; + await page.getByRole("link", { name: "登录", exact: true }).click(); + await visibleLoginForm(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 === "/products"); + await page.getByRole("navigation", { name: "会员中心导航" }).waitFor(); + await page + .getByRole("heading", { name: "订购产品", exact: true }) + .waitFor(); + assert.equal( + await page + .getByRole("navigation", { name: "产品导航", exact: true }) + .count(), + 0, + ); + return { guestCatalogVisible: true, memberLayoutAfterLogin: true }; }), ); @@ -55,7 +132,13 @@ test( { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { - await login(page, "/security/ssh-keys?m=1"); + // 从受保护入口跳出的表单继续登录,不能用 helper 重新打开 /login 掩盖首次导航缺陷。 + await page.goto(`${appOrigin}/security/ssh-keys?m=1`); + await page.waitForURL((url) => url.pathname === "/login"); + await visibleLoginForm(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 .getByRole("heading", { name: "SSH 密钥", exact: true }) .waitFor(); @@ -581,6 +664,7 @@ test( await page.waitForURL((url) => url.pathname === "/login", { timeout: 1500, }); + await visibleLoginForm(page); assert.equal( await page.evaluate(() => localStorage.getItem("jwt")), null, @@ -596,6 +680,7 @@ test( ); await page.goto(`${appOrigin}/security/ssh-keys`); await page.waitForURL((url) => url.pathname === "/login"); + await visibleLoginForm(page); return { localSessionClearedWithoutResponse: true, nextProtectedNavigationBlocked: true, @@ -620,6 +705,7 @@ test( "Authenticated page must request its data", ); await page.waitForURL((url) => url.pathname === "/login"); + await visibleLoginForm(page); await eventually( () => page.evaluate(() => localStorage.getItem("jwt")), (jwt) => jwt === null, @@ -863,6 +949,7 @@ test( await page.waitForURL((url) => url.pathname === "/login", { timeout: 1500, }); + await visibleLoginForm(page); assert.equal( await page.evaluate(() => localStorage.getItem("jwt")), null, @@ -906,6 +993,7 @@ test( await fixtureRequest("options", { expiredPayment: true }); await page.getByRole("button", { name: "确认支付", exact: true }).click(); await page.waitForURL((url) => url.pathname === "/login"); + await visibleLoginForm(page); assert.equal( await page.evaluate(() => localStorage.getItem("jwt")), null, @@ -964,6 +1052,8 @@ test( await page.waitForURL((url) => url.pathname === "/login", { timeout: 1500, }); + await visibleLoginForm(page); + await visibleLoginForm(secondTab); assert.equal( await page.evaluate(() => localStorage.getItem("jwt")), null,