const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const ts = require('typescript'); const realAxios = require('axios'); // Execute the actual TypeScript modules against Axios adapters and a browser stub. // No additional runtime/test dependencies, no network, no production API. function harness() { const local = new Map(); const session = new Map(); const redirects = []; const storage = map => ({ getItem: key => map.get(key) ?? null, setItem: (key, value) => map.set(key, String(value)), removeItem: key => map.delete(key) }); const window = { localStorage: storage(local), sessionStorage: storage(session), location: { pathname: '/shop', replace: value => redirects.push(value) } }; const document = { cookie: '' }; const axios = realAxios.create(); axios.create = realAxios.create; axios.isAxiosError = realAxios.isAxiosError; const cache = {}; const hooks = []; let cursor = 0; const react = { ...require('react'), useState: initial => { const index = cursor++; if (!(index in hooks)) hooks[index] = initial; return [hooks[index], value => { hooks[index] = typeof value === 'function' ? value(hooks[index]) : value; }]; }, useEffect: effect => effect() }; const dispatches = []; function render(component) { cursor = 0; return component(); } function load(filename) { const full = path.resolve(filename); if (cache[full]) return cache[full].exports; const module = { exports: {} }; cache[full] = module; const source = ts.transpileModule(fs.readFileSync(full, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true, jsx: ts.JsxEmit.ReactJSX } }).outputText; const requireStub = name => { if (name === 'axios') return axios; if (name === 'react') return react; if (name === 'react/jsx-runtime') return require(name); if (name === 'next/navigation') return { useRouter: () => ({ push: value => redirects.push(value), replace: value => redirects.push(value) }) }; if (name === 'react-redux') return { useDispatch: () => action => dispatches.push(action), useSelector: select => select({ mobileopt: { mobile: 9123456789 } }) }; if (name.includes('mobileoptSlice')) return { addMobile: payload => ({ payload }) }; const base = path.join('src', name.slice(2)); return load(base + (fs.existsSync(base + '.ts') ? '.ts' : '.tsx')); }; vm.runInNewContext('(function(require, module, exports){' + source + '\n})', { window, document, process, Error, Promise, URLSearchParams, console })(requireStub, module, module.exports); return module.exports; } const auth = load('src/lib/auth.ts'); const instance = load('src/axios/axiosInstance.ts').default; return { auth, instance, axios, local, session, redirects, document, load, render, dispatches }; } function failure(config, status, data = { detail: 'Error' }) { return new realAxios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', config, null, { data, status, statusText: 'Failed', headers: {}, config }); } function response(config, data, status = 200) { return { config, data, status, statusText: 'OK', headers: {} }; } test('stores valid tokens and rejects malformed success responses', () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); assert.equal(h.auth.getAccessToken(), 'access.old'); assert.equal(h.auth.getRefreshToken(), 'refresh.old'); assert.match(h.document.cookie, /isAuth=true/); assert.throws(() => h.auth.saveTokens({ access: undefined, refresh: 'bad' })); }); test('request with no token never sends Bearer null or undefined', async () => { const h = harness(); h.instance.defaults.adapter = config => { assert.equal(config.headers.Authorization, undefined); return Promise.resolve(response(config, {})); }; await h.instance.get('/public/'); }); test('400, 403, 500 and network failures always reject and preserve session', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); for (const status of [400, 403, 500, null]) { h.instance.defaults.adapter = config => Promise.reject(status ? failure(config, status) : new realAxios.AxiosError('Offline', 'ERR_NETWORK', config)); await assert.rejects(h.instance.get('/customer/v2/create/')); assert.equal(h.auth.getAccessToken(), 'access.old'); assert.equal(h.redirects.length, 0); } }); test('simultaneous 401 requests perform one refresh and both retry', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0; h.axios.defaults.adapter = async config => { calls++; await new Promise(resolve => setImmediate(resolve)); return response(config, { access: 'access.new', refresh: 'refresh.new' }); }; h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, { ok: true })); const result = await Promise.all([h.instance.get('/first/'), h.instance.get('/second/')]); assert.equal(calls, 1); assert.equal(result.length, 2); assert.equal(h.auth.getAccessToken(), 'access.new'); }); test('revoked refresh clears tokens, cookie and redirects to login', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); h.instance.defaults.adapter = config => Promise.reject(failure(config, 401)); h.axios.defaults.adapter = config => Promise.reject(failure(config, 401)); await assert.rejects(h.instance.get('/cuser/info/')); assert.equal(h.auth.getAccessToken(), null); assert.equal(h.auth.getRefreshToken(), null); assert.equal(h.redirects[0], '/login?reason=session'); assert.match(h.document.cookie, /max-age=0/); }); test('401 without refresh logs out and rejects', async () => { const h = harness(); h.instance.defaults.adapter = config => Promise.reject(failure(config, 401)); await assert.rejects(h.instance.get('/cuser/info/')); assert.equal(h.redirects[0], '/login?reason=session'); }); test('network failure while refreshing preserves recoverable session', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); h.instance.defaults.adapter = config => Promise.reject(failure(config, 401)); h.axios.defaults.adapter = config => Promise.reject(new realAxios.AxiosError('Offline', 'ERR_NETWORK', config)); await assert.rejects(h.instance.get('/cuser/info/')); assert.equal(h.auth.getRefreshToken(), 'refresh.old'); assert.equal(h.redirects.length, 0); }); test('malformed refresh success logs out instead of storing undefined', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); h.instance.defaults.adapter = config => Promise.reject(failure(config, 401)); h.axios.defaults.adapter = config => Promise.resolve(response(config, { error: 'Invalid' })); await assert.rejects(h.instance.get('/cuser/info/')); assert.equal(h.auth.getAccessToken(), null); }); test('retry 401 does not refresh forever', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0; h.instance.defaults.adapter = config => Promise.reject(failure(config, 401)); h.axios.defaults.adapter = config => { calls++; return Promise.resolve(response(config, { access: 'access.new', refresh: 'refresh.new' })); }; await assert.rejects(h.instance.get('/cuser/info/')); assert.equal(calls, 1); assert.equal(h.auth.getAccessToken(), null); }); test('public auth 401 displays error without triggering token refresh', async () => { const h = harness(); let calls = 0; h.instance.defaults.adapter = config => Promise.reject(failure(config, 401)); h.axios.defaults.adapter = config => { calls++; return Promise.resolve(response(config, {})); }; await assert.rejects(h.instance.post('/auth/token/', {})); assert.equal(calls, 0); assert.equal(h.redirects.length, 0); }); test('logout revokes original refresh token and clears all local identity', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); h.local.set('nameFamily', 'Previous'); h.axios.defaults.adapter = config => { assert.match(config.url, /auth\/logout/); assert.equal(JSON.parse(config.data).refresh, 'refresh.old'); return Promise.resolve(response(config, null, 204)); }; await h.auth.logout(); assert.equal(h.local.size, 0); assert.equal(h.redirects[0], '/login'); }); test('late refresh response cannot restore a logged-out session', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let resolveRefresh; h.instance.defaults.adapter = config => Promise.reject(failure(config, 401)); h.axios.defaults.adapter = config => new Promise(resolve => { resolveRefresh = () => resolve(response(config, { access: 'access.new', refresh: 'refresh.new' })); }); const pending = h.instance.get('/cuser/info/'); await new Promise(resolve => setImmediate(resolve)); h.auth.clearSession(); resolveRefresh(); await assert.rejects(pending); assert.equal(h.auth.getAccessToken(), null); }); test('API messages including duplicate mobile and SMS failure are preserved', () => { const h = harness(); const config = {}; assert.equal(h.auth.apiErrorMessage(failure(config, 400, { mobile: ['شماره تکراری است'] })), 'شماره تکراری است'); assert.equal(h.auth.apiErrorMessage(failure(config, 503, { detail: 'ارسال پیامک انجام نشد' })), 'ارسال پیامک انجام نشد'); }); function elements(tree, predicate) { if (!tree || typeof tree !== 'object') return []; if (Array.isArray(tree)) return tree.flatMap(item => elements(item, predicate)); return [...(predicate(tree) ? [tree] : []), ...elements(tree.props?.children, predicate)]; } function textContent(tree) { if (typeof tree === 'string' || typeof tree === 'number') return String(tree); if (Array.isArray(tree)) return tree.map(textContent).join(''); return tree?.props ? textContent(tree.props.children) : ''; } test('login page renders API errors and re-enables submit', async () => { const h = harness(); const component = h.load('src/app/(login_register)/login/page.tsx').default; h.axios.defaults.adapter = config => Promise.reject(failure(config, 503, { detail: 'ارسال پیامک انجام نشد' })); const form = elements(h.render(component), item => item.type === 'form')[0]; await form.props.onSubmit({ preventDefault() {} }); const tree = h.render(component); const alerts = elements(tree, item => item.props?.role === 'alert'); assert.ok(alerts.some(item => textContent(item).includes('ارسال پیامک انجام نشد'))); assert.ok(elements(tree, item => item.type === 'button').some(item => !item.props.disabled)); assert.equal(h.redirects.length, 0); }); test('multi-role login renders role choices without automatically entering first workspace', async () => { const h = harness(); const component = h.load('src/app/(login_register)/login/page.tsx').default; h.axios.defaults.adapter = config => Promise.resolve(response(config, [1,2].map(id => ({ uuid: String(id), cuser_role: { sys_ws: { name: 'Workspace'+id }, role_type: { role_name: 'Operator' } } })))); await elements(h.render(component), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} }); const tree = h.render(component); assert.equal(elements(tree, item => item.type === 'button' && item.props.type === 'button').length, 2); assert.equal(h.auth.getAccessToken(), null); assert.equal(h.redirects.length, 0); }); test('a stale retry failure does not clear a newer session from another tab', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); h.instance.defaults.adapter = config => { if (config.headers.Authorization === 'Bearer access.new') h.auth.saveTokens({ access: 'access.other-tab', refresh: 'refresh.other-tab' }); return Promise.reject(failure(config, 401)); }; h.axios.defaults.adapter = config => Promise.resolve(response(config, { access: 'access.new', refresh: 'refresh.new' })); await assert.rejects(h.instance.get('/cuser/info/')); assert.equal(h.auth.getAccessToken(), 'access.other-tab'); assert.equal(h.redirects.length, 0); });