fix(client): clear protected views on logout and show customer customs state

This commit is contained in:
2026-10-03 03:54:06 +08:00
parent b1f538b3ac
commit 5c53eba31b
7 changed files with 149 additions and 21 deletions
+49 -1
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import ts from 'typescript'
import * as vue from 'vue'
const layoutSource = readFileSync(new URL('./AppLayout.vue', import.meta.url), 'utf8')
const shellStyles = readFileSync(new URL('../styles/shell.css', import.meta.url), 'utf8')
@@ -108,7 +110,7 @@ test('source contract: locks body scrolling while open and restores it on close
})
test('source contract: handles confirmation cancellation but rethrows unexpected logout errors', () => {
assert.match(layoutSource, /async function handleLogout\(\) \{\s*try \{/)
assert.match(layoutSource, /async function handleLogout\(\) \{\s*const logoutToken = auth.token\s*try \{/)
assert.match(layoutSource, /\} catch \(error\) \{\s*if \(error === 'cancel' \|\| error === 'close'\) return\s*throw error\s*\}/)
})
@@ -148,3 +150,49 @@ test('source contract: routes denied pages to an explicit 403 state', () => {
assert.match(routerSource, /!auth\.hasPermission\(permission\)/)
assert.match(routerSource, /getFirstAccessiblePath\(auth\.permissionCodes\)/)
})
function logoutHarness(t) {
const script = layoutSource.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const ast = ts.createSourceFile('layout.ts', script, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const nodes = ast.statements.filter(node => (ts.isFunctionDeclaration(node) && node.name?.text === 'handleLogout') || (ts.isExpressionStatement(node) && node.getText(ast).startsWith('watch(') && node.getText(ast).includes('() => auth.isAuthenticated')))
const code = ts.transpileModule(nodes.map(node => node.getText(ast)).join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
let rejectLogout, confirm
const remote = new Promise((_, no) => { rejectLogout = no })
const confirmation = new Promise(yes => { confirm = yes })
const calls = [], warnings = []
const auth = vue.reactive({ token: 'A', isAuthenticated: true, logout: () => { calls.push('logout'); auth.token = ''; auth.isAuthenticated = false; return remote } })
const scope = vue.effectScope(); t.after(() => scope.stop())
const handler = scope.run(() => new Function('watch', 'auth', 'workspace', 'unsaved', 'router', 'ElMessageBox', 'ElMessage', code + ';return handleLogout')(vue.watch, auth, { clear: () => calls.push('workspace') }, { hasDirty: false, clear: () => calls.push('unsaved') }, { replace: path => { calls.push(path); return Promise.resolve() } }, { confirm: () => confirmation }, { warning: value => warnings.push(value) }))
return { auth, handler, confirm, rejectLogout, calls, warnings }
}
test('logout navigates before remote completion and late failure cannot affect a new login', async t => {
const h = logoutHarness(t)
const clicked = h.handler(); h.confirm(); await clicked; await vue.nextTick()
assert.deepEqual(h.calls, ['logout', 'workspace', 'unsaved', '/login'])
assert.equal(h.auth.isAuthenticated, false)
h.auth.token = 'B'; h.auth.isAuthenticated = true; await vue.nextTick()
h.rejectLogout(new Error('SESSION_CHANGED')); await Promise.resolve(); await vue.nextTick()
assert.equal(h.auth.token, 'B'); assert.equal(h.auth.isAuthenticated, true)
assert.equal(h.calls.filter(item => item === '/login').length, 1)
assert.equal(h.warnings.length, 0)
})
test('cross-tab logout leaves protected route and stale confirmation never logs out a new identity', async t => {
const h = logoutHarness(t)
const clicked = h.handler()
h.auth.token = 'B'; h.confirm(); await clicked
assert.equal(h.calls.length, 0)
h.auth.token = ''; h.auth.isAuthenticated = false; await vue.nextTick()
assert.deepEqual(h.calls, ['workspace', 'unsaved', '/login'])
assert.match(layoutSource, /<div v-if="auth.isAuthenticated" class="client-shell"/)
})
test('remote logout failure reports incomplete server revocation without leaving protected content visible', async t => {
const h = logoutHarness(t)
const clicked = h.handler(); h.confirm(); await clicked; await vue.nextTick()
h.rejectLogout(new Error('offline')); await Promise.resolve()
assert.equal(h.auth.isAuthenticated, false)
assert.ok(h.calls.includes('/login'))
assert.deepEqual(h.warnings, ['已退出本页,服务器会话注销未完成'])
})