141 lines
6.2 KiB
JavaScript
141 lines
6.2 KiB
JavaScript
// Isolated navigation/authentication checks: no server or real device requests.
|
|
import assert from 'node:assert/strict'
|
|
import { test } from 'node:test'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import vm from 'node:vm'
|
|
import { fileURLToPath } from 'node:url'
|
|
import ts from 'typescript'
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src')
|
|
|
|
function environment(pathname = '/dashboard') {
|
|
const storage = new Map(), redirects = [], cache = new Map()
|
|
const handlers = {}
|
|
const http = {
|
|
interceptors: {
|
|
request: { use: callback => { handlers.request = callback } },
|
|
response: { use: (success, failure) => { handlers.success = success; handlers.failure = failure } },
|
|
},
|
|
}
|
|
const window = { location: { pathname, replace: url => redirects.push(url) } }
|
|
const mocks = {
|
|
axios: { default: { create: () => http } },
|
|
'vue-router': {
|
|
createWebHistory: () => ({}),
|
|
createRouter: options => ({
|
|
options,
|
|
beforeEach: callback => { handlers.guard = callback },
|
|
afterEach: callback => { handlers.after = callback },
|
|
}),
|
|
},
|
|
}
|
|
function load(relative) {
|
|
const file = path.resolve(root, relative)
|
|
if (cache.has(file)) return cache.get(file).exports
|
|
const module = { exports: {} }; cache.set(file, module)
|
|
const source = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
|
|
}).outputText
|
|
vm.runInNewContext(source, {
|
|
exports: module.exports, module, window,
|
|
localStorage: {
|
|
getItem: key => storage.get(key) ?? null,
|
|
setItem: (key, value) => storage.set(key, value),
|
|
removeItem: key => storage.delete(key),
|
|
},
|
|
require: name => mocks[name] ?? (name.endsWith('.vue') ? { default: {} }
|
|
: load(path.relative(root, path.resolve(path.dirname(file), `${name}.ts`)))),
|
|
}, { filename: file })
|
|
return module.exports
|
|
}
|
|
const auth = load('services/auth.ts')
|
|
load('services/http.ts')
|
|
return { auth, handlers, redirects, storage, load }
|
|
}
|
|
|
|
function request(env) {
|
|
return env.handlers.request({ headers: {} })
|
|
}
|
|
|
|
test('a current HTTP 401 clears both tokens and redirects only once', async () => {
|
|
const env = environment()
|
|
env.auth.saveTokens('session-one', 'refresh-one')
|
|
const config = request(env)
|
|
const fail = () => env.handlers.failure({ config, response: { status: 401 }, message: 'Unauthorized' })
|
|
await assert.rejects(fail(), /Unauthorized/)
|
|
await assert.rejects(fail(), /Unauthorized/)
|
|
assert.equal(env.auth.hasToken(), false)
|
|
assert.equal(env.storage.size, 0)
|
|
assert.deepEqual(env.redirects, ['/auth'])
|
|
})
|
|
|
|
test('an HTTP 200 with an unauthorized envelope also expires the current session', async () => {
|
|
const env = environment()
|
|
env.auth.saveTokens('session-one', 'refresh-one')
|
|
const config = request(env)
|
|
await assert.rejects(env.handlers.success({ config, data: { code: 401, type: 'error', result: null } }), /登录已过期/)
|
|
assert.equal(env.auth.hasToken(), false)
|
|
assert.deepEqual(env.redirects, ['/auth'])
|
|
})
|
|
|
|
test('a late HTTP/envelope 401 from the previous login cannot remove a newer session', async () => {
|
|
const env = environment()
|
|
env.auth.saveTokens('old-session', 'old-refresh')
|
|
const oldConfig = request(env)
|
|
env.auth.saveTokens('new-session', 'new-refresh')
|
|
await assert.rejects(env.handlers.failure({ config: oldConfig, response: { status: 401 }, message: 'Expired' }), /Expired/)
|
|
await assert.rejects(env.handlers.success({ config: oldConfig, data: { code: 401, message: 'Expired' } }), /Expired/)
|
|
assert.equal(env.auth.getToken(), 'new-session')
|
|
assert.equal(env.storage.size, 2)
|
|
assert.deepEqual(env.redirects, [])
|
|
})
|
|
|
|
test('an anonymous request that returns 401 cannot log out a later authenticated session', async () => {
|
|
const env = environment('/auth')
|
|
const anonymousConfig = request(env)
|
|
assert.equal(anonymousConfig.headers.Authorization, undefined)
|
|
env.auth.saveTokens('new-session', 'new-refresh')
|
|
await assert.rejects(env.handlers.failure({ config: anonymousConfig, response: { status: 401 }, message: 'Invalid password' }))
|
|
assert.equal(env.auth.getToken(), 'new-session')
|
|
assert.deepEqual(env.redirects, [])
|
|
})
|
|
|
|
test('failed credentials on the login page do not reload that page', async () => {
|
|
const env = environment('/auth')
|
|
await assert.rejects(env.handlers.failure({ config: request(env), response: { status: 401 }, message: 'Invalid password' }))
|
|
assert.deepEqual(env.redirects, [])
|
|
})
|
|
|
|
test('normal API results still unwrap and business failures are still rejected', async () => {
|
|
const env = environment()
|
|
env.auth.saveTokens('session-one', 'refresh-one')
|
|
assert.equal(request(env).headers.Authorization, 'Bearer session-one')
|
|
const result = { success: false, message: 'device unavailable' }
|
|
assert.equal(env.handlers.success({ data: { code: 200, type: 'success', result } }), result)
|
|
await assert.rejects(env.handlers.success({ data: { type: 'error', result: null, message: 'failed' } }), /failed/)
|
|
assert.equal(env.auth.hasToken(), true)
|
|
})
|
|
|
|
test('route guards protect pages while allowing login, with a fallback for unknown URLs', () => {
|
|
const env = environment(), router = env.load('router.ts').default
|
|
assert.equal(env.handlers.guard({ meta: {} }), '/auth')
|
|
assert.equal(env.handlers.guard({ meta: { guest: true } }), undefined)
|
|
env.auth.saveTokens('session-one', 'refresh-one')
|
|
assert.equal(env.handlers.guard({ meta: {} }), undefined)
|
|
assert.equal(env.handlers.guard({ meta: { guest: true } }), '/dashboard')
|
|
assert.equal(router.options.routes.at(-1).path, '/:pathMatch(.*)*')
|
|
assert.equal(router.options.routes.at(-1).redirect, '/dashboard')
|
|
const devices = router.options.routes.find(route => route.path === '/device-control')
|
|
assert.equal(devices.children.find(route => route.name === 'device-detail').path, ':deviceId')
|
|
})
|
|
|
|
test('new pages open at the top and browser back preserves its saved scroll position', () => {
|
|
const env = environment(), router = env.load('router.ts').default
|
|
const saved = { left: 0, top: 512 }
|
|
assert.equal(router.options.scrollBehavior({}, {}, saved), saved)
|
|
const fresh = router.options.scrollBehavior({}, {}, null)
|
|
assert.equal(fresh.left, 0)
|
|
assert.equal(fresh.top, 0)
|
|
})
|