212 lines
9.3 KiB
JavaScript
212 lines
9.3 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const vm = require('node:vm')
|
|
const ts = require('typescript')
|
|
const vue = require('vue')
|
|
const { parse } = require('@vue/compiler-sfc')
|
|
const { compile } = require('@vue/compiler-dom')
|
|
|
|
function mountHome(auth) {
|
|
const requests = []
|
|
const { descriptor } = parse(fs.readFileSync(__dirname + '/../pages/index.client.vue', 'utf8'))
|
|
const context = vm.createContext({ useAuthStore: () => auth, setPageLayout() {} })
|
|
vm.runInContext(descriptor.scriptSetup.content.replace(/^import .*$/gm, ''), context)
|
|
const remove = node => {
|
|
if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1)
|
|
node.parent = null
|
|
}
|
|
const renderer = vue.createRenderer({
|
|
createElement: type => ({ type, children: [], parent: null }),
|
|
createText: text => ({ text, children: [], parent: null }),
|
|
createComment: text => ({ text, children: [], parent: null }),
|
|
setElementText: (node, text) => { node.text = text },
|
|
setText: (node, text) => { node.text = text },
|
|
parentNode: node => node.parent,
|
|
nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1] || null,
|
|
insert(node, parent, anchor) {
|
|
remove(node)
|
|
node.parent = parent
|
|
const index = anchor ? parent.children.indexOf(anchor) : -1
|
|
if (index < 0) parent.children.push(node)
|
|
else parent.children.splice(index, 0, node)
|
|
},
|
|
remove, patchProp() {},
|
|
})
|
|
const component = {
|
|
setup: () => vm.runInContext('({ auth })', context),
|
|
render: new Function('Vue', compile(descriptor.template.content, { mode: 'function', prefixIdentifiers: true }).code)(vue),
|
|
}
|
|
const app = renderer.createApp(component)
|
|
app.component('DashboardHome', {
|
|
setup() {
|
|
requests.push(auth.activeTenant)
|
|
return () => vue.h('div', 'Dashboard ' + auth.activeTenant)
|
|
},
|
|
})
|
|
for (const name of ['UDashboardNavbar', 'UDashboardPanelContent', 'UCard', 'UButton', 'TenantDropdown']) {
|
|
app.component(name, { setup: (_, { slots }) => () => vue.h('div', slots.default?.()) })
|
|
}
|
|
const root = { children: [] }
|
|
app.mount(root)
|
|
const content = node => [node.text || '', ...node.children.map(content)].join(' ')
|
|
return { requests, close: () => app.unmount(), text: () => content(root) }
|
|
}
|
|
|
|
test('dashboard data only mounts with a tenant and remounts when the tenant changes', async t => {
|
|
const auth = vue.reactive({ activeTenant: null, tenants: [{ id: '1' }, { id: '2' }], user: {} })
|
|
const ui = mountHome(auth)
|
|
t.after(ui.close)
|
|
assert.deepEqual(ui.requests, [])
|
|
assert.match(ui.text(), /Mandant auswählen/)
|
|
auth.activeTenant = '1'
|
|
await vue.nextTick()
|
|
assert.deepEqual(ui.requests, ['1'])
|
|
auth.activeTenant = '2'
|
|
await vue.nextTick()
|
|
assert.deepEqual(ui.requests, ['1', '2'])
|
|
auth.activeTenant = null
|
|
await vue.nextTick()
|
|
assert.deepEqual(ui.requests, ['1', '2'])
|
|
assert.match(ui.text(), /Mandant auswählen/)
|
|
})
|
|
|
|
test('an account without assigned tenants receives an explanation without loading widgets', t => {
|
|
const ui = mountHome(vue.reactive({ activeTenant: null, tenants: [], user: { is_admin: true } }))
|
|
t.after(ui.close)
|
|
assert.deepEqual(ui.requests, [])
|
|
assert.match(ui.text(), /noch kein Mandant zugeordnet/)
|
|
assert.match(ui.text(), /Mandanten verwalten/)
|
|
})
|
|
|
|
function authFixture() {
|
|
const logs = [], calls = [], sessions = [], navigations = []
|
|
const token = 'test-access-token'
|
|
const refreshToken = 'test-refresh-token'
|
|
let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null }
|
|
const context = vm.createContext({
|
|
exports: {}, process: { client: false },
|
|
defineStore: (_, definition) => definition,
|
|
console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) },
|
|
useTempStore: () => ({ setStoredTempConfig() {} }),
|
|
navigateTo: path => { navigations.push(path) },
|
|
useNuxtApp: () => ({ $api: async (url, options) => {
|
|
calls.push({ url, options })
|
|
if (url === '/api/me') return me
|
|
if (url === '/api/tenant') return { id: me.activeTenant }
|
|
if (url === '/api/auth/password/change') {
|
|
me = { ...me, user: { ...me.user, must_change_password: false } }
|
|
return { success: true }
|
|
}
|
|
if (url === '/auth/login' || url === '/api/tenant/switch') return { token, refreshToken }
|
|
throw Error('Unexpected API request: ' + url)
|
|
} }),
|
|
})
|
|
const source = fs.readFileSync(__dirname + '/../stores/auth.ts', 'utf8').replace(/^import .*$/gm, '')
|
|
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context)
|
|
const definition = context.exports.useAuthStore
|
|
const store = definition.state()
|
|
for (const [name, action] of Object.entries(definition.actions)) store[name] = action.bind(store)
|
|
store.getStoredToken = () => token
|
|
store.getStoredRefreshToken = () => refreshToken
|
|
store.scheduleSessionTimers = () => {}
|
|
store.setSession = (...args) => sessions.push(args)
|
|
return { store, context, logs, calls, sessions, navigations, token, refreshToken, setMe: value => { me = value } }
|
|
}
|
|
|
|
test('refreshing a session without an active tenant clears the previous tenant state', async () => {
|
|
const f = authFixture()
|
|
f.store.activeTenant = '12'
|
|
f.store.activeTenantData = { id: '12', name: 'Old tenant' }
|
|
await f.store.fetchMe()
|
|
assert.equal(f.store.activeTenant, null)
|
|
assert.equal(f.store.activeTenantData, null)
|
|
assert.deepEqual(f.calls.map(call => call.url), ['/api/me'])
|
|
})
|
|
|
|
test('login and tenant switching store credentials without writing them to the console', async () => {
|
|
const f = authFixture()
|
|
await f.store.login('test@example.com', 'test-password')
|
|
await f.store.switchTenant('1')
|
|
assert.equal(f.sessions.length, 2)
|
|
assert.equal(f.sessions[0][0], f.token)
|
|
assert.equal(f.sessions[1][1], f.refreshToken)
|
|
const output = JSON.stringify(f.logs)
|
|
assert.ok(!output.includes(f.token))
|
|
assert.ok(!output.includes(f.refreshToken))
|
|
})
|
|
|
|
function portalSession(mustChangePassword = false) {
|
|
return {
|
|
user: { email: 'customer@example.com', must_change_password: mustChangePassword },
|
|
activeTenant: 7, tenants: [{ id: 7, name: 'Test tenant' }], permissions: [],
|
|
profile: { customer_for_portal: 123 },
|
|
}
|
|
}
|
|
|
|
function runPage(f, name, action, data) {
|
|
const { descriptor } = parse(fs.readFileSync(__dirname + `/../pages/${name}.vue`, 'utf8'))
|
|
Object.assign(f.context, {
|
|
useAuthStore: () => f.store, useToast: () => ({ add() {} }), definePageMeta() {},
|
|
useRouter: () => ({ push: path => { f.navigations.push(path) } }),
|
|
reactive: vue.reactive, ref: vue.ref, formData: data,
|
|
})
|
|
const source = descriptor.scriptSetup.content.replace(/^import .*$/gm, '')
|
|
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), f.context)
|
|
return vm.runInContext(`${action}({ data: formData })`, f.context)
|
|
}
|
|
|
|
test('the login form navigates portal customers once, directly to their portal', async () => {
|
|
const f = authFixture()
|
|
f.setMe(portalSession())
|
|
await runPage(f, 'login', 'doLogin', { email: 'customer@example.com', password: 'password', rememberMe: true })
|
|
assert.deepEqual(f.navigations, ['/customer-portal'])
|
|
assert.equal(f.store.loading, false)
|
|
assert.equal(f.store.profile.customer_for_portal, 123)
|
|
assert.equal(f.store.activeTenant, '7')
|
|
})
|
|
|
|
test('first login opens password change and continues into the portal after saving', async () => {
|
|
const f = authFixture()
|
|
f.setMe(portalSession(true))
|
|
await f.store.login('customer@example.com', 'initial-password')
|
|
assert.deepEqual(f.navigations, ['/password-change'])
|
|
await runPage(f, 'password-change', 'doChange', { oldPassword: 'initial-password', newPassword: 'new-password' })
|
|
assert.deepEqual(f.navigations, ['/password-change', '/customer-portal'])
|
|
assert.equal(f.store.user.must_change_password, false)
|
|
assert.equal(f.calls.some(call => call.url === '/auth/logout'), false)
|
|
})
|
|
|
|
test('internal users still land on the dashboard', async () => {
|
|
const f = authFixture()
|
|
await runPage(f, 'login', 'doLogin', { email: 'staff@example.com', password: 'password' })
|
|
assert.deepEqual(f.navigations, ['/'])
|
|
assert.equal(f.store.loading, false)
|
|
})
|
|
|
|
test('restoring a portal session keeps portal routing and password-change precedence', async () => {
|
|
const f = authFixture()
|
|
f.setMe(portalSession())
|
|
Object.assign(f.context, { defineNuxtRouteMiddleware: fn => fn, useAuthStore: () => f.store })
|
|
const middleware = fs.readFileSync(__dirname + '/../middleware/auth.global.ts', 'utf8')
|
|
vm.runInContext(ts.transpile(middleware, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), f.context)
|
|
const route = f.context.exports.default
|
|
await route({ path: '/' })
|
|
assert.deepEqual(f.navigations, ['/customer-portal'])
|
|
await route({ path: '/customer-portal' })
|
|
assert.equal(f.navigations.length, 1)
|
|
await route({ path: '/login' })
|
|
assert.equal(f.navigations.at(-1), '/customer-portal')
|
|
f.store.user.must_change_password = true
|
|
await route({ path: '/customer-portal' })
|
|
assert.equal(f.navigations.at(-1), '/password-change')
|
|
})
|
|
|
|
test('selecting a portal tenant also opens its portal', async () => {
|
|
const f = authFixture()
|
|
f.setMe(portalSession())
|
|
await f.store.switchTenant('7')
|
|
assert.deepEqual(f.navigations, ['/customer-portal'])
|
|
assert.equal(f.store.loading, false)
|
|
})
|