155 lines
7.8 KiB
JavaScript
155 lines
7.8 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { readFileSync } from 'node:fs'
|
|
import ts from 'typescript'
|
|
import * as vue from 'vue'
|
|
import * as pinia from 'pinia'
|
|
|
|
function compile(path, dependencies = {}) {
|
|
const code = ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
|
}).outputText
|
|
const exports = {}
|
|
new Function('require', 'exports', code)(id => { assert.ok(id in dependencies, id); return dependencies[id] }, exports)
|
|
return exports
|
|
}
|
|
function storage() {
|
|
const values = new Map()
|
|
return { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) }
|
|
}
|
|
function token(user = 1, tenant = 1, membership = 1, nonce = 'a') {
|
|
return `test.${Buffer.from(JSON.stringify({ user_id: user, tenant_id: tenant, membership_id: membership, username: `user-${user}`, app_code: 'MERCHANT', exp: 4102444800, nonce })).toString('base64url')}.test`
|
|
}
|
|
const flush = () => new Promise(resolve => setTimeout(resolve, 10))
|
|
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
|
|
|
function harness(t, expired = false) {
|
|
globalThis.localStorage = storage(); globalThis.sessionStorage = storage()
|
|
const listeners = new Map()
|
|
globalThis.window = { addEventListener: (name, fn) => listeners.set(name, fn) }
|
|
const session = compile('../../../api/session.ts')
|
|
const initial = expired ? `test.${Buffer.from(JSON.stringify({ user_id: 1, tenant_id: 1, membership_id: 1, username: 'user-1', app_code: 'MERCHANT', exp: 1 })).toString('base64url')}.test` : token()
|
|
session.persistTokenPair({ accessToken: initial, refreshToken: 'refresh-a' })
|
|
let reads = 0
|
|
const behavior = { permissions: async () => ['edit'], tenant: async () => ({ tenantName: 'company' }) }
|
|
class ApiError extends Error { constructor(status) { super('failed'); this.status = status } }
|
|
const module = compile('./auth.ts', {
|
|
vue, pinia, '@/api/session': session,
|
|
'@/modules/auth/api/auth': { logoutMerchant: async refresh => behavior.logout?.(refresh) },
|
|
'@/modules/auth/utils/jwt': compile('../utils/jwt.ts'),
|
|
'@/api/http': { ApiError, refreshAuthenticatedSession: async () => {
|
|
session.persistTokenPair({ accessToken: token(1, 1, 1, 'refreshed'), refreshToken: 'refresh-new' })
|
|
listeners.get('cc-client-session-updated')()
|
|
return session.getAccessToken()
|
|
} },
|
|
'@/modules/tenant-management/public': {
|
|
FRONTEND_TO_MERCHANT_PERMISSION_CODES: { 'order:first-leg:edit': 'edit' },
|
|
hasMappedMerchantPermission: (_, codes) => codes.has('edit'),
|
|
getCurrentMerchantPermissionCodes: () => { reads++; return behavior.permissions() },
|
|
getTenantProfile: () => behavior.tenant(), getMerchantPersonalProfile: async () => ({}),
|
|
},
|
|
})
|
|
const context = pinia.createPinia(); pinia.setActivePinia(context)
|
|
t.after(async () => { await flush(); pinia.disposePinia(context) })
|
|
const auth = module.useAuthStore()
|
|
const event = key => listeners.get('storage')({ key, storageArea: localStorage })
|
|
function rotate(nextToken = token(1, 1, 1, 'b')) {
|
|
session.persistTokenPair({ accessToken: nextToken, refreshToken: 'refresh-b' })
|
|
event(session.ACCESS_TOKEN_KEY); event(session.REFRESH_TOKEN_KEY)
|
|
}
|
|
return { auth, session, event, rotate, behavior, listeners, ApiError, reads: () => reads }
|
|
}
|
|
|
|
test('pair storage rotation retains same-identity rights and automatically reloads once without navigation', async t => {
|
|
const h = harness(t); await h.auth.restoreSession()
|
|
h.behavior.permissions = async () => []
|
|
h.rotate()
|
|
assert.equal(h.auth.hasPermission('order:first-leg:edit'), true)
|
|
assert.equal(h.auth.sessionRestored, false)
|
|
await flush()
|
|
assert.equal(h.auth.sessionRestored, true)
|
|
assert.equal(h.auth.hasPermission('order:first-leg:edit'), false, 'new server rights replace the retained snapshot')
|
|
assert.equal(h.reads(), 2)
|
|
h.listeners.get('cc-client-session-updated')()
|
|
await flush()
|
|
assert.equal(h.reads(), 2, 'already applied token does not reload twice')
|
|
})
|
|
|
|
test('startup expired-token refresh waits for restored rights even when local rotation emits an event', async t => {
|
|
const h = harness(t, true)
|
|
await h.auth.restoreSession()
|
|
assert.equal(h.auth.isAuthenticated, true)
|
|
assert.equal(h.auth.hasPermission('order:first-leg:edit'), true)
|
|
assert.equal(h.auth.sessionRestored, true)
|
|
assert.equal(h.reads(), 1)
|
|
})
|
|
|
|
test('logout storage event immediately clears rights and late context cannot restore them', async t => {
|
|
const h = harness(t); const old = deferred(); h.behavior.permissions = () => old.promise
|
|
const pending = h.auth.restoreSession()
|
|
h.session.clearSessionTokens(); h.event(h.session.ACCESS_TOKEN_KEY)
|
|
assert.equal(h.auth.isAuthenticated, false)
|
|
assert.deepEqual(h.auth.permissionCodes, [])
|
|
old.resolve(['edit']); await pending; await flush()
|
|
assert.equal(h.auth.user, null)
|
|
assert.deepEqual(h.auth.permissionCodes, [])
|
|
})
|
|
|
|
test('account, customer and membership switches revoke rights immediately and reject stale request results', async t => {
|
|
const h = harness(t); await h.auth.restoreSession()
|
|
const old = deferred(); h.behavior.permissions = () => old.promise
|
|
h.rotate(); await flush()
|
|
h.behavior.permissions = async () => []
|
|
h.rotate(token(2, 2, 2, 'new'))
|
|
assert.equal(h.auth.hasPermission('order:first-leg:edit'), false)
|
|
assert.equal(h.auth.user.id, '2')
|
|
await flush(); old.resolve(['edit']); await flush()
|
|
assert.equal(h.auth.hasPermission('order:first-leg:edit'), false)
|
|
assert.equal(h.auth.user.id, '2')
|
|
h.behavior.permissions = async () => ['edit']; h.rotate(token(2, 3, 3, 'next')); await flush()
|
|
assert.equal(h.auth.hasPermission('order:first-leg:edit'), true)
|
|
h.rotate(token(2, 3, 4, 'membership'))
|
|
assert.equal(h.auth.hasPermission('order:first-leg:edit'), false)
|
|
})
|
|
|
|
test('context failure is not marked restored and explicit retry coalesces concurrent callers', async t => {
|
|
const h = harness(t)
|
|
h.behavior.permissions = async () => { throw new Error('offline') }
|
|
await h.auth.restoreSession()
|
|
assert.equal(h.auth.sessionRestored, false)
|
|
assert.equal(h.auth.hasPermission('order:first-leg:edit'), false)
|
|
const pending = deferred(); h.behavior.permissions = () => pending.promise
|
|
const first = h.auth.restoreSession(), second = h.auth.restoreSession()
|
|
assert.equal(h.reads(), 2)
|
|
pending.resolve(['edit']); await Promise.all([first, second])
|
|
assert.equal(h.auth.sessionRestored, true)
|
|
assert.equal(h.auth.hasPermission('order:first-leg:edit'), true)
|
|
})
|
|
|
|
test('stale unauthorized context response cannot clear a newer account', async t => {
|
|
const h = harness(t); const old = deferred(); h.behavior.permissions = () => old.promise
|
|
const pending = h.auth.restoreSession()
|
|
h.behavior.permissions = async () => []
|
|
h.rotate(token(2, 2, 2)); await flush()
|
|
old.reject(new h.ApiError(401)); await pending
|
|
assert.equal(h.auth.user.id, '2')
|
|
assert.equal(h.auth.isAuthenticated, true)
|
|
assert.equal(h.auth.sessionRestored, true)
|
|
})
|
|
|
|
test('store logout clears immediately and late revocation failure preserves a later cross-tab login', async t => {
|
|
const h = harness(t); await h.auth.restoreSession()
|
|
const remote = deferred(); let sentRefresh
|
|
h.behavior.logout = refresh => { sentRefresh = refresh; return remote.promise }
|
|
const pending = h.auth.logout()
|
|
const rejected = assert.rejects(pending, /offline/)
|
|
assert.equal(sentRefresh, 'refresh-a')
|
|
assert.equal(h.auth.isAuthenticated, false)
|
|
assert.equal(h.session.getAccessToken(), '')
|
|
h.rotate(token(2, 2, 2)); await flush()
|
|
remote.reject(new Error('offline')); await rejected
|
|
assert.equal(h.auth.user.id, '2')
|
|
assert.equal(h.auth.isAuthenticated, true)
|
|
assert.equal(h.session.getRefreshToken(), 'refresh-b')
|
|
})
|