1 parent
216354cef1
commit
6e2c114474
2 files changed
+117
-22
No files matched your search
+11
-6
@@ -4,7 +4,7 @@
|
|||||||
* 路由放行与组件实际挂载同时受会话约束,防止重定向等待期间仍执行受保护页面请求。
|
* 路由放行与组件实际挂载同时受会话约束,防止重定向等待期间仍执行受保护页面请求。
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, onBeforeUnmount } from "vue";
|
import { computed, onMounted, onBeforeUnmount } from "vue";
|
||||||
import { useRoute } from "#app";
|
import { useRouter } from "#app";
|
||||||
import { NuxtLayout, NuxtPage, NuxtRouteAnnouncer } from "#components";
|
import { NuxtLayout, NuxtPage, NuxtRouteAnnouncer } from "#components";
|
||||||
import BuildUpdateNotice from "~/components/BuildUpdateNotice.vue";
|
import BuildUpdateNotice from "~/components/BuildUpdateNotice.vue";
|
||||||
import OperationToasts from "~/components/OperationToasts.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";
|
import { isPublicShop, isAuthPage, isPublicRoute } from "~/utils/access.js";
|
||||||
|
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const route = useRoute();
|
// 入口控制 NuxtPage 是否挂载,必须读取守卫已确认的路由;Nuxt useRoute 要等页面完成才同步,会形成循环等待。
|
||||||
|
const route = useRouter().currentRoute;
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
// 只为访客切换公开购买布局;登录后的同一路由继续使用会员中心布局。
|
// 只为访客切换公开购买布局;登录后的同一路由继续使用会员中心布局。
|
||||||
const layout = computed(() =>
|
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(() =>
|
const routeSessionKey = computed(() =>
|
||||||
isAuthPage(route.path) || route.path === "/home.htm"
|
isAuthPage(route.value.path) || route.value.path === "/home.htm"
|
||||||
? route.path
|
? route.value.path
|
||||||
: auth.token || "anonymous",
|
: auth.token || "anonymous",
|
||||||
);
|
);
|
||||||
// 主题监听由入口统一注册和释放,避免每次页面导航重复绑定系统主题事件。
|
// 主题监听由入口统一注册和释放,避免每次页面导航重复绑定系统主题事件。
|
||||||
|
|||||||
+106
-16
@@ -7,6 +7,7 @@ import { createDecipheriv } from "node:crypto";
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import {
|
import {
|
||||||
appOrigin,
|
appOrigin,
|
||||||
|
backendOrigin,
|
||||||
eventually,
|
eventually,
|
||||||
fixtureRequest,
|
fixtureRequest,
|
||||||
login,
|
login,
|
||||||
@@ -24,29 +25,105 @@ async function visibleKeyIds(page) {
|
|||||||
return rows.map((row) => Number(row.match(/#(\d+)/)[1]));
|
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(
|
test(
|
||||||
"auth guard preserves the protected destination",
|
"public catalog switches from guest to member layout after real login",
|
||||||
{ timeout: 20000 },
|
{ timeout: 25000 },
|
||||||
(t) =>
|
(t) =>
|
||||||
scenario(t, {}, async (page) => {
|
scenario(t, {}, async (page) => {
|
||||||
// 访客导航保留查询串和锚点,但不能先发受保护的列表请求再跳转。
|
// 分类是公开 HTTP 边界;空分类契约足以验证入口布局,不引入测试专用产品参数。
|
||||||
await page.goto(`${appOrigin}/security/ssh-keys?m=7#details`);
|
await page
|
||||||
await page.waitForURL((url) => url.pathname === "/login");
|
.context()
|
||||||
const url = new URL(page.url());
|
.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(
|
assert.equal(
|
||||||
url.searchParams.get("redirect"),
|
await page.getByRole("navigation", { name: "会员中心导航" }).count(),
|
||||||
"/security/ssh-keys?m=7#details",
|
0,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.evaluate(() => localStorage.getItem("jwt")),
|
(await fixtureRequest("requests")).some((request) =>
|
||||||
null,
|
["/index", "/account", "/menu"].includes(request.path),
|
||||||
);
|
),
|
||||||
const requests = await fixtureRequest("requests");
|
|
||||||
assert.equal(
|
|
||||||
requests.some((request) => request.path === "/ssh_key"),
|
|
||||||
false,
|
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 },
|
{ timeout: 25000 },
|
||||||
(t) =>
|
(t) =>
|
||||||
scenario(t, {}, async (page) => {
|
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
|
await page
|
||||||
.getByRole("heading", { name: "SSH 密钥", exact: true })
|
.getByRole("heading", { name: "SSH 密钥", exact: true })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
@@ -581,6 +664,7 @@ test(
|
|||||||
await page.waitForURL((url) => url.pathname === "/login", {
|
await page.waitForURL((url) => url.pathname === "/login", {
|
||||||
timeout: 1500,
|
timeout: 1500,
|
||||||
});
|
});
|
||||||
|
await visibleLoginForm(page);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.evaluate(() => localStorage.getItem("jwt")),
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
||||||
null,
|
null,
|
||||||
@@ -596,6 +680,7 @@ test(
|
|||||||
);
|
);
|
||||||
await page.goto(`${appOrigin}/security/ssh-keys`);
|
await page.goto(`${appOrigin}/security/ssh-keys`);
|
||||||
await page.waitForURL((url) => url.pathname === "/login");
|
await page.waitForURL((url) => url.pathname === "/login");
|
||||||
|
await visibleLoginForm(page);
|
||||||
return {
|
return {
|
||||||
localSessionClearedWithoutResponse: true,
|
localSessionClearedWithoutResponse: true,
|
||||||
nextProtectedNavigationBlocked: true,
|
nextProtectedNavigationBlocked: true,
|
||||||
@@ -620,6 +705,7 @@ test(
|
|||||||
"Authenticated page must request its data",
|
"Authenticated page must request its data",
|
||||||
);
|
);
|
||||||
await page.waitForURL((url) => url.pathname === "/login");
|
await page.waitForURL((url) => url.pathname === "/login");
|
||||||
|
await visibleLoginForm(page);
|
||||||
await eventually(
|
await eventually(
|
||||||
() => page.evaluate(() => localStorage.getItem("jwt")),
|
() => page.evaluate(() => localStorage.getItem("jwt")),
|
||||||
(jwt) => jwt === null,
|
(jwt) => jwt === null,
|
||||||
@@ -863,6 +949,7 @@ test(
|
|||||||
await page.waitForURL((url) => url.pathname === "/login", {
|
await page.waitForURL((url) => url.pathname === "/login", {
|
||||||
timeout: 1500,
|
timeout: 1500,
|
||||||
});
|
});
|
||||||
|
await visibleLoginForm(page);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.evaluate(() => localStorage.getItem("jwt")),
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
||||||
null,
|
null,
|
||||||
@@ -906,6 +993,7 @@ test(
|
|||||||
await fixtureRequest("options", { expiredPayment: true });
|
await fixtureRequest("options", { expiredPayment: true });
|
||||||
await page.getByRole("button", { name: "确认支付", exact: true }).click();
|
await page.getByRole("button", { name: "确认支付", exact: true }).click();
|
||||||
await page.waitForURL((url) => url.pathname === "/login");
|
await page.waitForURL((url) => url.pathname === "/login");
|
||||||
|
await visibleLoginForm(page);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.evaluate(() => localStorage.getItem("jwt")),
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
||||||
null,
|
null,
|
||||||
@@ -964,6 +1052,8 @@ test(
|
|||||||
await page.waitForURL((url) => url.pathname === "/login", {
|
await page.waitForURL((url) => url.pathname === "/login", {
|
||||||
timeout: 1500,
|
timeout: 1500,
|
||||||
});
|
});
|
||||||
|
await visibleLoginForm(page);
|
||||||
|
await visibleLoginForm(secondTab);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.evaluate(() => localStorage.getItem("jwt")),
|
await page.evaluate(() => localStorage.getItem("jwt")),
|
||||||
null,
|
null,
|
||||||
|
|||||||
Reference in new issue
Block a user