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(shared = {}) { const local = shared.local || new Map(); const session = new Map(); const redirects = []; const listeners = new Map(); const timers = new Map(); let clock = Date.now(); 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', search: '', replace: value => redirects.push(value) }, addEventListener: (name, callback) => listeners.set(name, callback), removeEventListener: name => listeners.delete(name) }; 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] = typeof initial === 'function' ? initial() : initial; return [hooks[index], value => { hooks[index] = typeof value === 'function' ? value(hooks[index]) : value; }]; }, useRef: initial => { const index = cursor++; return hooks[index] ||= { current: initial }; }, useEffect: (effect, deps) => { const index = cursor++; const previous = hooks[index]; if (!previous || !deps || deps.some((value, i) => value !== previous.deps[i])) { previous?.cleanup?.(); hooks[index] = { deps, cleanup: effect() }; } } }; const queryState = { isLoading: false, isError: false, error: null, refetch: async () => {} }; const queryClient = { clears: 0, clear() { this.clears++; } }; let queryOptions; const navigator = { locks: Object.hasOwn(shared, 'locks') ? shared.locks : { request: (_name, callback) => callback() } }; const dispatches = []; function render(component, props = {}) { cursor = 0; return component(props); } 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 === '@tanstack/react-query') return { useQuery: options => { queryOptions = options; return queryState; }, useQueryClient: () => queryClient }; 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, navigator, process, Error, Promise, URLSearchParams, console, Date: class extends Date { static now() { return clock; } }, setInterval: callback => { const id = timers.size + 1; timers.set(id, callback); return id; }, clearInterval: id => timers.delete(id), setTimeout, })(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, window, load, render, dispatches, listeners, queryState, queryClient, get queryOptions() { return queryOptions; }, advance: ms => { clock += ms; timers.forEach(callback => callback()); } }; } 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/components/auth/customer-login-form.tsx').default; elements(h.render(component), item => item.type === 'input')[0].props.onChange({ target: { value: '09123456789' } }); 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('customer login does not navigate when SMS is rejected or account is absent', async () => { const h = harness(); const component = h.load('src/components/auth/customer-login-form.tsx').default; elements(h.render(component), item => item.type === 'input')[0].props.onChange({ target: { value: '09123456789' } }); h.axios.defaults.adapter = config => Promise.reject(failure(config, 400, { mobile: ['کاربر ثبت نشده است'] })); await elements(h.render(component), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} }); assert.ok(textContent(h.render(component)).includes('کاربر ثبت نشده است')); assert.equal(h.dispatches.length, 0); assert.equal(h.redirects.length, 0); }); test('OTP page retains code input after wrong code', async () => { const h = harness(); const component = h.load('src/components/auth/customer-login-form.tsx').default; h.auth.rememberLoginMobile('9123456789'); const render = () => h.render(component, { initialStep: 'verify' }); let tree = render(); elements(tree, item => item.type === 'input')[0].props.onChange({ target: { value: '۱۲۳۴۵۶' } }); h.axios.defaults.adapter = config => Promise.reject(failure(config, 400, { opt: ['کد تأیید اشتباه است'] })); await elements(render(), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} }); tree = render(); assert.ok(textContent(tree).includes('کد تأیید اشتباه است')); assert.equal(elements(tree, item => item.type === 'input')[0].props.value, '۱۲۳۴۵۶'); 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); }); function customerForm(h, props = {}) { const component = h.load('src/components/auth/customer-login-form.tsx').default; return { render: () => h.render(component, props), submit: () => elements(h.render(component, props), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} }), input: value => elements(h.render(component, props), item => item.type === 'input')[0].props.onChange({ target: { value } }) }; } const customerOption = name => ({ uuid: name, cuser_role: { sys_ws: { name }, role_type: { role_name_en: 'workspace_customer' } } }); const settle = () => new Promise(resolve => setImmediate(resolve)); test('SMS success displays OTP in the same page even when sessionStorage is blocked', async () => { const h = harness(); const form = customerForm(h); h.window.sessionStorage.setItem = () => { throw new Error('Storage denied'); }; h.axios.defaults.adapter = config => Promise.resolve(response(config, { result: { mobile: 9123456789, retry_after: 60 } })); form.input('۰۹۱۲۳۴۵۶۷۸۹'); await form.submit(); assert.ok(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length); assert.ok(textContent(form.render()).includes('09123456789')); assert.equal(h.redirects.length, 0); assert.equal(h.auth.readLoginMobile(), '9123456789'); }); test('same-render double login submits only one SMS request', async () => { const h = harness(); const form = customerForm(h); let resolve; let requests = 0; form.input('09123456789'); h.axios.defaults.adapter = config => { requests++; return new Promise(done => { resolve = () => done(response(config, { result: { mobile: 9123456789 } })); }); }; const handler = elements(form.render(), item => item.type === 'form')[0].props.onSubmit; const first = handler({ preventDefault() {} }); const second = handler({ preventDefault() {} }); await settle(); assert.equal(requests, 1); assert.ok(elements(form.render(), item => item.type === 'button').every(item => item.props.disabled)); resolve(); await Promise.all([first, second]); }); test('absent account and disabled panel messages render without navigating or opening OTP', async () => { for (const [status, message] of [[400, 'کاربری با این شماره ثبت نشده است'], [403, 'دسترسی شما به پنل مشتریان فعال نیست']]) { const h = harness(); const form = customerForm(h); form.input('09123456789'); h.axios.defaults.adapter = config => Promise.reject(failure(config, status, { detail: message })); await form.submit(); assert.ok(textContent(form.render()).includes(message)); assert.equal(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length, 0); assert.equal(h.redirects.length, 0); } }); test('lost SMS response permits entering the code that was actually received', async () => { const h = harness(); const form = customerForm(h); form.input('09123456789'); h.axios.defaults.adapter = config => Promise.reject(new realAxios.AxiosError('Timeout', 'ECONNABORTED', config)); await form.submit(); const recovery = elements(form.render(), item => item.type === 'button' && textContent(item).includes('دریافت کرده'))[0]; assert.ok(recovery); recovery.props.onClick(); assert.ok(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length); }); test('429 displays a countdown and can recover the previously sent code', async () => { const h = harness(); const form = customerForm(h); form.input('09123456789'); h.axios.defaults.adapter = config => Promise.reject(failure(config, 429, { detail: 'کد قبلی را وارد کنید', retry_after: 30 })); await form.submit(); const tree = form.render(); assert.ok(textContent(tree).includes('30 ثانیه')); assert.equal(elements(tree, item => item.type === 'button' && item.props.type === 'submit')[0].props.disabled, true); h.advance(31000); assert.equal(elements(form.render(), item => item.type === 'button' && item.props.type === 'submit')[0].props.disabled, false); }); test('resend remains disabled during its cooldown and sends once after it ends', async () => { const h = harness(); const form = customerForm(h); form.input('09123456789'); let requests = 0; h.axios.defaults.adapter = config => { requests++; return Promise.resolve(response(config, { result: { mobile: 9123456789, retry_after: 60 } })); }; await form.submit(); let resend = elements(form.render(), item => item.type === 'button' && textContent(item) === 'دریافت دوباره کد')[0]; assert.equal(resend.props.disabled, true); await resend.props.onClick(); assert.equal(requests, 1); h.advance(61000); resend = elements(form.render(), item => item.type === 'button' && textContent(item) === 'دریافت دوباره کد')[0]; assert.equal(resend.props.disabled, false); await resend.props.onClick(); assert.equal(requests, 2); }); test('same-render double OTP submit verifies and exchanges tokens only once', async () => { const h = harness(); h.auth.rememberLoginMobile('9123456789'); const form = customerForm(h, { initialStep: 'verify' }); form.input('۱۲۳۴۵۶'); let verifyRequests = 0; let tokenRequests = 0; let release; h.axios.defaults.adapter = config => { if (config.url.includes('/verify/')) { verifyRequests++; return new Promise(done => { release = () => done(response(config, [customerOption('Workspace')])); }); } tokenRequests++; return Promise.resolve(response(config, { access: 'access.logged-in', refresh: 'refresh.logged-in' })); }; const handler = elements(form.render(), item => item.type === 'form')[0].props.onSubmit; const first = handler({ preventDefault() {} }); const second = handler({ preventDefault() {} }); await settle(); assert.equal(verifyRequests, 1); release(); await Promise.all([first, second]); assert.equal(tokenRequests, 1); assert.equal(h.auth.getAccessToken(), 'access.logged-in'); assert.deepEqual(h.redirects, ['/shop']); assert.ok(elements(form.render(), item => item.type === 'button').every(item => item.props.disabled)); }); test('token exchange failure preserves verified choices for retry without consuming OTP twice', async () => { const h = harness(); h.auth.rememberLoginMobile('9123456789'); const form = customerForm(h, { initialStep: 'verify' }); form.input('123456'); let verifications = 0; let exchanges = 0; h.axios.defaults.adapter = config => { if (config.url.includes('/verify/')) { verifications++; return Promise.resolve(response(config, [customerOption('Workspace')])); } exchanges++; return exchanges === 1 ? Promise.reject(failure(config, 503, { detail: 'ارتباط قطع شد' })) : Promise.resolve(response(config, { access: 'access.ok', refresh: 'refresh.ok' })); }; await form.submit(); assert.ok(textContent(form.render()).includes('ارتباط قطع شد')); const choice = elements(form.render(), item => item.type === 'button' && textContent(item) === 'Workspace')[0]; await choice.props.onClick(); assert.equal(verifications, 1); assert.equal(exchanges, 2); assert.deepEqual(h.redirects, ['/shop']); }); test('multiple workspace choice uses a synchronous lock and ignores operator roles', async () => { const h = harness(); h.auth.rememberLoginMobile('9123456789'); const form = customerForm(h, { initialStep: 'verify' }); form.input('123456'); let exchanges = 0; let release; const operator = customerOption('Operator'); operator.cuser_role.role_type.role_name_en = 'workspace_operator'; h.axios.defaults.adapter = config => config.url.includes('/verify/') ? Promise.resolve(response(config, [customerOption('One'), customerOption('Two'), operator])) : new Promise(done => { exchanges++; release = () => done(response(config, { access: 'access.ok', refresh: 'refresh.ok' })); }); await form.submit(); assert.equal(exchanges, 0); assert.ok(!textContent(form.render()).includes('Operator')); const choice = elements(form.render(), item => item.type === 'button' && textContent(item) === 'One')[0]; const first = choice.props.onClick(); const second = choice.props.onClick(); await settle(); assert.equal(exchanges, 1); release(); await Promise.all([first, second]); }); test('direct verification URL without navigation state presents a usable mobile form', () => { const h = harness(); const form = customerForm(h, { initialStep: 'verify' }); assert.ok(elements(form.render(), item => item.props?.id === 'mobile').length); assert.equal(h.redirects.length, 0); }); test('OTP verified route uses remembered state without Redux or working sessionStorage', () => { const h = harness(); h.window.sessionStorage.setItem = () => { throw new Error('denied'); }; h.auth.rememberLoginMobile('9123456789'); const form = customerForm(h, { initialStep: 'verify' }); assert.ok(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length); }); test('logout double invocation revokes once and redirects once', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0; let release; h.axios.defaults.adapter = config => { calls++; return new Promise(done => { release = () => done(response(config, null, 204)); }); }; const first = h.auth.logout(); const second = h.auth.logout(); assert.equal(first, second); await settle(); assert.equal(calls, 1); assert.equal(h.auth.getAccessToken(), null); release(); await Promise.all([first, second]); assert.deepEqual(h.redirects, ['/login']); }); test('late login token response after logout is revoked and cannot restore the session', async () => { const h = harness(); let release; let revoked = 0; h.axios.defaults.adapter = config => config.url.includes('/auth/token/') ? new Promise(done => { release = () => done(response(config, { access: 'access.late', refresh: 'refresh.late' })); }) : (revoked++, Promise.resolve(response(config, null, 204))); const pending = h.auth.obtainTokens('login-option'); await settle(); await h.auth.logout(); release(); await assert.rejects(pending); assert.equal(h.auth.getAccessToken(), null); assert.equal(revoked, 1); }); test('storage failure has a useful message and partially written tokens are rolled back', () => { const h = harness(); const original = h.window.localStorage.setItem; h.window.localStorage.setItem = (key, value) => { if (key === 'refresh') throw new Error('quota'); original(key, value); }; assert.throws(() => h.auth.saveTokens({ access: 'access.new', refresh: 'refresh.new' }), /ذخیره/); assert.equal(h.auth.getAccessToken(), null); assert.equal(h.auth.getRefreshToken(), null); }); test('clearing session still invalidates in-memory reads when storage removal fails', () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); h.window.localStorage.removeItem = () => { throw new Error('denied'); }; h.auth.clearSession(); assert.equal(h.auth.getAccessToken(), null); assert.equal(h.auth.getRefreshToken(), null); assert.match(h.document.cookie, /max-age=0/); }); test('old account request is never retried under a newly logged-in identity', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0; h.instance.defaults.adapter = config => { calls++; h.auth.saveTokens({ access: 'access.other-person', refresh: 'refresh.other-person' }); return Promise.reject(failure(config, 401)); }; await assert.rejects(h.instance.post('/shop/cart/', { product: 10 })); assert.equal(calls, 1); assert.equal(h.auth.getAccessToken(), 'access.other-person'); assert.equal(h.redirects.length, 0); }); test('simultaneous refreshes in separate tabs serialize through Web Locks', async () => { const local = new Map(); let queue = Promise.resolve(); let lockCalls = 0; let refreshCalls = 0; const locks = { request(name, callback) { assert.equal(name, 'crm-customer-jwt-refresh'); lockCalls++; const result = queue.then(callback); queue = result.catch(() => {}); return result; } }; const first = harness({ local, locks }); const second = harness({ local, locks }); first.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); for (const h of [first, second]) { h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, {})); h.axios.defaults.adapter = async config => { refreshCalls++; await settle(); return response(config, { access: 'access.rotated', refresh: 'refresh.rotated' }); }; } await Promise.all([first.instance.get('/first/'), second.instance.get('/second/')]); assert.equal(lockCalls, 2); assert.equal(refreshCalls, 1); assert.equal(second.auth.getAccessToken(), 'access.rotated'); }); test('a new identity can refresh while a previous identity refresh is still pending', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let releaseOld; h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.current' ? Promise.resolve(response(config, {})) : Promise.reject(failure(config, 401)); h.axios.defaults.adapter = config => JSON.parse(config.data).refresh === 'refresh.old' ? new Promise(done => { releaseOld = () => done(response(config, { access: 'access.stale', refresh: 'refresh.stale' })); }) : Promise.resolve(response(config, { access: 'access.current', refresh: 'refresh.current' })); const old = h.instance.get('/previous-person/'); await settle(); h.auth.saveTokens({ access: 'access.new-person', refresh: 'refresh.new-person' }); await h.instance.get('/current-person/'); releaseOld(); await assert.rejects(old); assert.equal(h.auth.getAccessToken(), 'access.current'); assert.equal(h.redirects.length, 0); }); test('protected UI with a stale routing cookie and no real session never displays children', () => { const h = harness(); h.document.cookie = 'isAuth=true'; const guard = h.load('src/components/auth/session-guard.tsx').default; const tree = h.render(guard, { children: 'Private customer data' }); assert.ok(!textContent(tree).includes('Private customer data')); assert.equal(h.queryOptions.enabled, false); assert.deepEqual(h.redirects, ['/login?reason=session']); }); test('protected session checks revalidate on focus and periodically, preserving network failure for retry', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); const guard = h.load('src/components/auth/session-guard.tsx').default; h.render(guard, { children: 'Private data' }); h.render(guard, { children: 'Private data' }); assert.equal(h.queryOptions.enabled, true); assert.equal(h.queryOptions.retry, false); assert.equal(h.queryOptions.refetchOnWindowFocus, true); assert.equal(h.queryOptions.refetchInterval, 60000); h.queryState.isError = true; h.queryState.error = failure({}, 503, { detail: 'ارتباط موقتاً قطع است' }); const tree = h.render(guard, { children: 'Private data' }); assert.ok(textContent(tree).includes('ارتباط موقتاً قطع است')); assert.ok(!textContent(tree).includes('Private data')); assert.equal(h.auth.getRefreshToken(), 'refresh.old'); assert.equal(h.redirects.length, 0); }); test('logout or identity change in another tab clears protected cached data', () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); const guard = h.load('src/components/auth/session-guard.tsx').default; h.render(guard, { children: 'Private data' }); h.render(guard, { children: 'Private data' }); h.local.delete('access'); h.local.delete('refresh'); h.local.delete('authSession'); h.listeners.get('storage')({ key: 'authSession', newValue: null }); assert.equal(h.queryClient.clears, 1); assert.deepEqual(h.redirects, ['/login?reason=session']); assert.ok(!textContent(h.render(guard, { children: 'Private data' })).includes('Private data')); }); test('invalid stored OTP mobile falls back to the mobile form', () => { const h = harness(); h.session.set('loginMobile', 'undefined'); const form = customerForm(h, { initialStep: 'verify' }); assert.ok(elements(form.render(), item => item.props?.id === 'mobile').length); }); test('new login in another tab survives this tab previously clearing its session', () => { const local = new Map(); const first = harness({ local }); const second = harness({ local }); first.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); first.auth.clearSession(); second.auth.saveTokens({ access: 'access.new-person', refresh: 'refresh.new-person' }); assert.equal(first.auth.getAccessToken(), 'access.new-person'); assert.equal(first.auth.getRefreshToken(), 'refresh.new-person'); }); test('late successful response from a previous identity is rejected instead of displaying old data', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); h.instance.defaults.adapter = config => { h.auth.saveTokens({ access: 'access.other-person', refresh: 'refresh.other-person' }); return Promise.resolve(response(config, { private: 'previous person' })); }; await assert.rejects(h.instance.get('/orders/'), /نشست/); assert.equal(h.auth.getAccessToken(), 'access.other-person'); }); test('old request sees another-tab rotation and retries without issuing another refresh', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let first = true; let refreshes = 0; h.axios.defaults.adapter = config => { refreshes++; return Promise.resolve(response(config, {})); }; h.instance.defaults.adapter = config => { if (first) { first = false; h.auth.saveTokens({ access: 'access.rotated', refresh: 'refresh.rotated' }, 'refresh.old'); return Promise.reject(failure(config, 401)); } assert.equal(config.headers.Authorization, 'Bearer access.rotated'); return Promise.resolve(response(config, {})); }; await h.instance.get('/orders/'); assert.equal(refreshes, 0); }); test('navigation wrappers both use the shared form and preserve the verification route', () => { const h = harness(); const login = h.load('src/app/(login_register)/login/page.tsx').default; const verify = h.load('src/app/(login_register)/login/verify/page.tsx').default; const form = h.load('src/components/auth/customer-login-form.tsx').default; assert.equal(h.render(login).type, form); assert.equal(h.render(verify).type, form); assert.equal(h.render(verify).props.initialStep, 'verify'); }); test('logout button handles same-render repeated clicks and becomes disabled', async () => { const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0; h.axios.defaults.adapter = config => { calls++; return Promise.resolve(response(config, null, 204)); }; const component = h.load('src/app/components/layout/customer-info.tsx').default; const button = elements(h.render(component), item => item.type === 'button')[0]; button.props.onClick(); button.props.onClick(); await settle(); assert.equal(calls, 1); assert.equal(elements(h.render(component), item => item.type === 'button')[0].props.disabled, true); }); test('tabs without Web Locks serialize rotating refresh through an expiring storage lease', async () => { const local = new Map(); const first = harness({ local, locks: null }); const second = harness({ local, locks: null }); first.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let requests = 0; for (const h of [first, second]) { h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, {})); h.axios.defaults.adapter = async config => { requests++; await settle(); return response(config, { access: 'access.rotated', refresh: 'refresh.rotated' }); }; } await Promise.all([first.instance.get('/first/'), second.instance.get('/second/')]); assert.equal(requests, 1); assert.equal(local.has('customerRefreshLease'), false); }); test('an abandoned expired refresh lease cannot block a later refresh', async () => { const h = harness({ locks: null }); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); h.local.set('customerRefreshLease', JSON.stringify({ owner: 'closed-tab', identity: h.auth.getSessionIdentity(), expires: 0 })); h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, {})); h.axios.defaults.adapter = config => Promise.resolve(response(config, { access: 'access.new', refresh: 'refresh.new' })); await h.instance.get('/orders/'); assert.equal(h.auth.getAccessToken(), 'access.new'); assert.equal(h.local.has('customerRefreshLease'), false); });