修复退出后历史缓存恢复旧控制台并阻止过期会话响应
This commit is contained in:
parent
eb5951accb
commit
41d6137716
@ -1,4 +1,4 @@
|
||||
import { onMounted, ref } from "vue";
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
export function useAppShell() {
|
||||
const setupState = ref(null);
|
||||
@ -8,9 +8,13 @@ export function useAppShell() {
|
||||
const error = ref("");
|
||||
const routePath = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
const standalonePaths = ["/login", "/password-reset", "/reset"];
|
||||
let bootstrapVersion = 0;
|
||||
|
||||
async function bootstrap() {
|
||||
const version = ++bootstrapVersion;
|
||||
bootstrapping.value = true;
|
||||
authenticated.value = null;
|
||||
isAdmin.value = null;
|
||||
error.value = "";
|
||||
try {
|
||||
let response = await fetch("/api/v1/bootstrap/status", { cache: "no-store" });
|
||||
@ -37,36 +41,39 @@ export function useAppShell() {
|
||||
if (!settings.ok) throw new Error("请使用服务器 bootstrap-url 文件中的完整一次性地址访问,或在原浏览器中继续初始化。");
|
||||
result = await settings.json();
|
||||
}
|
||||
if (version !== bootstrapVersion) return;
|
||||
const session = result.initialized
|
||||
? await checkAuth()
|
||||
: { authenticated: false, isAdmin: false };
|
||||
if (version !== bootstrapVersion) return;
|
||||
setupState.value = result;
|
||||
if (result.initialized) {
|
||||
await checkAuth();
|
||||
} else {
|
||||
authenticated.value = false;
|
||||
isAdmin.value = false;
|
||||
}
|
||||
authenticated.value = session.authenticated;
|
||||
isAdmin.value = session.isAdmin;
|
||||
} catch (err) {
|
||||
if (version !== bootstrapVersion) return;
|
||||
setupState.value = null;
|
||||
authenticated.value = null;
|
||||
isAdmin.value = null;
|
||||
error.value = err.message || "无法连接核心服务,请先启动 scert serve";
|
||||
} finally {
|
||||
bootstrapping.value = false;
|
||||
if (version === bootstrapVersion) bootstrapping.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
const response = await fetch("/api/v1/auth/session", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
authenticated.value = false;
|
||||
isAdmin.value = false;
|
||||
return;
|
||||
return { authenticated: false, isAdmin: false };
|
||||
}
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.error || "读取登录状态失败");
|
||||
authenticated.value = result.authenticated === true;
|
||||
isAdmin.value = result.is_admin === true || ["super_admin", "admin"].includes(result.role);
|
||||
return {
|
||||
authenticated: result.authenticated === true,
|
||||
isAdmin: result.is_admin === true || ["super_admin", "admin"].includes(result.role),
|
||||
};
|
||||
}
|
||||
|
||||
function handleInitialized(result) {
|
||||
@ -91,14 +98,34 @@ export function useAppShell() {
|
||||
isAdmin.value = false;
|
||||
}
|
||||
|
||||
function handlePageHide() {
|
||||
// 历史缓存不保留已验证权限,旧请求也不能恢复离开前的登录状态。
|
||||
bootstrapVersion += 1;
|
||||
authenticated.value = null;
|
||||
isAdmin.value = null;
|
||||
bootstrapping.value = true;
|
||||
}
|
||||
|
||||
function handlePageShow(event) {
|
||||
if (event.persisted) bootstrap();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (standalonePaths.includes(routePath)) {
|
||||
bootstrapping.value = false;
|
||||
return;
|
||||
}
|
||||
window.addEventListener("pagehide", handlePageHide);
|
||||
window.addEventListener("pageshow", handlePageShow);
|
||||
bootstrap();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
bootstrapVersion += 1;
|
||||
window.removeEventListener("pagehide", handlePageHide);
|
||||
window.removeEventListener("pageshow", handlePageShow);
|
||||
});
|
||||
|
||||
return {
|
||||
setupState,
|
||||
authenticated,
|
||||
|
||||
@ -1,7 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { useAppShell } from "./App.js";
|
||||
import { mountComposable } from "./test-support/mount.js";
|
||||
import { deferred, mountComposable } from "./test-support/mount.js";
|
||||
|
||||
function dispatchPageEvent(type, persisted = true) {
|
||||
const event = new Event(type);
|
||||
Object.defineProperty(event, "persisted", { value: persisted });
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
test("历史缓存恢复先隐藏旧控制台,再按真实会话显示登录页", async (t) => {
|
||||
let active = true;
|
||||
const sessionOptions = [];
|
||||
const { state } = await mountComposable(t, useAppShell, async (path, options) => {
|
||||
if (path.endsWith("/status")) return Response.json({ initialized: true });
|
||||
sessionOptions.push(options);
|
||||
return active ? Response.json({ authenticated: true, is_admin: true }) : new Response(null, { status: 401 });
|
||||
});
|
||||
assert.equal(state.authenticated.value, true);
|
||||
dispatchPageEvent("pagehide");
|
||||
assert.equal(state.authenticated.value, null);
|
||||
assert.equal(state.bootstrapping.value, true);
|
||||
active = false;
|
||||
dispatchPageEvent("pageshow");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(state.authenticated.value, false);
|
||||
assert.equal(state.isAdmin.value, false);
|
||||
assert.equal(state.bootstrapping.value, false);
|
||||
assert.equal(sessionOptions.length, 2);
|
||||
assert.ok(sessionOptions.every((options) => options.cache === "no-store" && options.credentials === "include"));
|
||||
});
|
||||
|
||||
test("非缓存页面显示不重复检查,卸载后移除恢复监听", async (t) => {
|
||||
let calls = 0;
|
||||
const { unmount } = await mountComposable(t, useAppShell, async (path) => {
|
||||
calls += 1;
|
||||
return Response.json(path.endsWith("/status") ? { initialized: true } : { authenticated: true });
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
dispatchPageEvent("pageshow", false);
|
||||
assert.equal(calls, 2);
|
||||
unmount();
|
||||
dispatchPageEvent("pageshow");
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test("恢复期间网络失败不恢复旧权限或业务界面", async (t) => {
|
||||
let failed = false;
|
||||
const { state } = await mountComposable(t, useAppShell, async (path) => {
|
||||
if (failed) throw new Error("连接失败");
|
||||
return Response.json(path.endsWith("/status") ? { initialized: true } : { authenticated: true, is_admin: true });
|
||||
});
|
||||
failed = true;
|
||||
dispatchPageEvent("pageshow");
|
||||
assert.equal(state.authenticated.value, null);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(state.setupState.value, null);
|
||||
assert.equal(state.isAdmin.value, null);
|
||||
assert.equal(state.error.value, "连接失败");
|
||||
});
|
||||
|
||||
test("离开页面前的在途会话响应不能覆盖恢复后的未登录状态", async (t) => {
|
||||
const pending = deferred();
|
||||
let sessionCalls = 0;
|
||||
const { state } = await mountComposable(t, useAppShell, async (path) => {
|
||||
if (path.endsWith("/status")) return Response.json({ initialized: true });
|
||||
sessionCalls += 1;
|
||||
return sessionCalls === 1 ? pending.promise : new Response(null, { status: 401 });
|
||||
});
|
||||
dispatchPageEvent("pagehide");
|
||||
dispatchPageEvent("pageshow");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(state.authenticated.value, false);
|
||||
pending.resolve(Response.json({ authenticated: true, is_admin: true }));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(state.authenticated.value, false);
|
||||
assert.equal(state.isAdmin.value, false);
|
||||
});
|
||||
|
||||
test("初始化令牌兑换前清除地址片段,后续请求只用同源会话", async (t) => {
|
||||
const calls = [];
|
||||
|
||||
@ -28,12 +28,16 @@ export function verifiedResponse(token = "test-token") {
|
||||
|
||||
// 运行真实组合式逻辑和生命周期,不加载浏览器,也不连接 DNS 或业务数据库。
|
||||
export async function mountComposable(t, factory, fetcher) {
|
||||
const windowEvents = new EventTarget();
|
||||
const intervals = new Map();
|
||||
let nextIntervalID = 1;
|
||||
let currentTime = Date.now();
|
||||
const originals = new Map();
|
||||
for (const [key, value] of Object.entries({
|
||||
window: {
|
||||
addEventListener: windowEvents.addEventListener.bind(windowEvents),
|
||||
removeEventListener: windowEvents.removeEventListener.bind(windowEvents),
|
||||
dispatchEvent: windowEvents.dispatchEvent.bind(windowEvents),
|
||||
setInterval(callback) {
|
||||
const intervalID = nextIntervalID;
|
||||
nextIntervalID += 1;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user