import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' import ts from 'typescript' import axios from 'axios' function compile(name, dependencies = {}) { const source = readFileSync(new URL(name, import.meta.url), 'utf8') .replace('import.meta.env.VITE_API_BASE_URL', "'/api'") const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }, }).outputText const exports = {} new Function('require', 'exports', code)((id) => { assert.ok(id in dependencies, `unexpected dependency ${id}`) return dependencies[id] }, exports) return exports } function access(user, tenant = 1, membership = 1, nonce = 'original') { return `test.${Buffer.from(JSON.stringify({ user_id: user, tenant_id: tenant, membership_id: membership, app_code: 'MERCHANT', nonce })).toString('base64url')}.test` } const oldAccess = access(1) const newAccess = access(1, 1, 1, 'rotated') const otherAccess = access(2, 2, 2) function harness(adapter) { let token = oldAccess let refreshToken = 'old-refresh' const http = compile('./http.ts', { axios: { ...axios, create: (config) => axios.create({ ...config, adapter }) }, '@chongchong/shared': { parseApiJsonResponse: (data) => data }, '@/api/session': { accessTokenIdentity: compile('./session.ts').accessTokenIdentity, getAccessToken: () => token, getRefreshToken: () => refreshToken, persistTokenPair: (tokens) => { token = tokens.accessToken; refreshToken = tokens.refreshToken }, clearSessionTokens: () => { token = ''; refreshToken = '' }, }, '@/api/errorDetails': compile('./errorDetails.ts'), '@/api/types': compile('./types.ts'), '@/api/crossTabRefresh': compile('./crossTabRefresh.ts'), }) return { ...http, setSession: (access, refresh) => { token = access; refreshToken = refresh }, session: () => ({ token, refreshToken }) } } test('order and lastleg 401s share one refresh and replay the unified access token', async () => { let refreshes = 0 const calls = [] const http = harness(async (config) => { calls.push({ url: config.url, base: config.baseURL, token: config.headers.Authorization }) if (config.url === '/auth/refresh') { refreshes++ await new Promise((resolve) => setTimeout(resolve, 10)) return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: newAccess, refreshToken: 'new-refresh' } } } } if (config.headers.Authorization === `Bearer ${oldAccess}`) { throw new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } }) } return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: config.url } } }) const urls = ['/merchant/v1/orders', '/merchant/v1/last-leg/inbound-plans'] assert.deepEqual(await Promise.all(urls.map((url) => http.apiRequest({ url }))), urls) assert.equal(refreshes, 1) assert.ok(calls.every((call) => call.base === '/api')) assert.equal(calls.find((call) => call.url === '/auth/refresh').token, undefined) assert.equal(calls.filter((call) => call.token === `Bearer ${newAccess}`).length, 2) }) test('successful order submit with explicit null data is accepted', async () => { const http = harness(async (config) => ({ config, status: 200, headers: {}, data: { code: '200', message: 'success', data: null }, })) assert.equal(await http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/order-1/submit' }), null) }) test('data-less security failures preserve diagnostics and malformed success is rejected', async () => { for (const data of [{ code: 'A0403', message: 'Denied', traceId: 'trace-1', requestId: 'request-1' }, { code: '200', message: 'OK' }]) { const http = harness(async (config) => ({ config, status: 200, headers: {}, data })) await assert.rejects(http.apiRequest({ url: '/merchant/v1/last-leg/inbound-plans' }), (error) => { assert.equal(error.code, data.code === '200' ? 'INVALID_RESPONSE' : 'A0403') if (data.code !== '200') { assert.equal(error.traceId, 'trace-1'); assert.equal(error.requestId, 'request-1') } return true }) } }) test('late refresh response cannot overwrite a session changed in another tab', async () => { let complete, started const ready = new Promise(resolve => { started = resolve }) const response = new Promise(resolve => { complete = resolve }) const http = harness(async config => { started(); await response; return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: oldAccess, refreshToken: 'stale-refresh' } } } }) const pending = http.refreshAuthenticatedSession() await ready http.setSession(otherAccess, 'other-refresh') complete() await pending.catch(error => assert.equal(error.code, 'SESSION_CHANGED')) assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'other-refresh' }) }) function deferred() { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } } function unauthorized(config) { return new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } }) } test('late A write 401 after user, customer or membership switch never refreshes or replays under B', async () => { for (const next of [access(2), access(1, 2), access(1, 1, 2)]) { const sent = deferred(), release = deferred(), calls = [] const http = harness(async config => { calls.push(config.url); sent.resolve(); await release.promise throw unauthorized(config) }) const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit', data: { expectedVersion: 1 } }) const rejected = assert.rejects(pending, error => error.code === 'SESSION_CHANGED') await sent.promise; http.setSession(next, 'refresh-b'); release.resolve(); await rejected assert.deepEqual(calls, ['/merchant/v1/orders/first-leg/one/submit']) assert.deepEqual(http.session(), { token: next, refreshToken: 'refresh-b' }) } }) test('switch during in-flight refresh cannot reuse B token to replay an A write', async () => { const refreshing = deferred(), release = deferred(), calls = [] const http = harness(async config => { calls.push(config.url) if (config.url === '/auth/refresh') { refreshing.resolve(); await release.promise return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: newAccess, refreshToken: 'new-refresh' } } } } throw unauthorized(config) }) const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit' }) const rejected = assert.rejects(pending, error => error.code === 'SESSION_CHANGED') await refreshing.promise; http.setSession(otherAccess, 'refresh-b'); release.resolve(); await rejected assert.deepEqual(calls, ['/merchant/v1/orders/first-leg/one/submit', '/auth/refresh']) assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'refresh-b' }) }) test('refresh lock wait reuses same-identity rotation but rejects changed-identity reuse', async t => { const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator') t.after(() => { if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator); else delete globalThis.navigator }) for (const next of [newAccess, otherAccess]) { const waiting = deferred(), release = deferred(), calls = [] Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks: { request: async (_name, _options, run) => { waiting.resolve(); await release.promise; return run() } } } }) const http = harness(async config => { calls.push({ url: config.url, authorization: config.headers.Authorization }) if (config.headers.Authorization === `Bearer ${oldAccess}`) throw unauthorized(config) return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: null } } }) const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit' }) const outcome = next === otherAccess ? assert.rejects(pending, error => error.code === 'SESSION_CHANGED') : pending await waiting.promise; http.setSession(next, 'rotated-refresh'); release.resolve(); await outcome assert.equal(calls.some(call => call.url === '/auth/refresh'), false) assert.equal(calls.length, next === otherAccess ? 1 : 2) assert.deepEqual(http.session(), { token: next, refreshToken: 'rotated-refresh' }) } }) function authApi(http) { return compile('../modules/auth/api/auth.ts', { '@/api/http': http }) } function ok(config, data = null) { return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data } } } test('logout after immediate local clear revokes captured Bearer and Refresh', async () => { const calls=[]; const http=harness(async c=>{calls.push(c);return ok(c)}) http.setSession('', '');await authApi(http).logoutMerchant(oldAccess,'old-refresh') assert.equal(calls.length,1);assert.equal(calls[0].headers.Authorization,`Bearer ${oldAccess}`) assert.deepEqual(JSON.parse(calls[0].data),{refreshToken:'old-refresh'});assert.deepEqual(http.session(),{token:'',refreshToken:''}) }) test('only explicit logout401 refreshes once and revokes same-identity replacement without storing it', async () => { const calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'});if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)}) http.setSession('','');await authApi(http).logoutMerchant(oldAccess,'old-refresh') assert.deepEqual(calls.map(x=>x.url),['/auth/logout','/auth/refresh','/auth/logout']);assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[2].data).refreshToken,'new-refresh');assert.deepEqual(http.session(),{token:'',refreshToken:''}) }) test('unknown logout failure never refreshes; changed identity cannot be revoked as replacement', async () => { for(const mode of ['network','wrongIdentity','second401']){const calls=[];const http=harness(async c=>{calls.push(c);if(mode==='network')throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c);if(c.url==='/auth/refresh')return ok(c,{accessToken:mode==='wrongIdentity'?otherAccess:newAccess,refreshToken:'replacement'});throw unauthorized(c)}) http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(oldAccess,'old-refresh'));assert.equal(calls.length,mode==='network'?1:mode==='wrongIdentity'?2:3);assert.deepEqual(http.session(),{token:'',refreshToken:''})} }) test('logout consumes already inflight refresh once while old business caller cannot revive session',async()=>{ const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}return ok(c)}) const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing,e=>e.code==='SESSION_CHANGED');await started.promise const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');release.resolve();await leaving;await rejected assert.deepEqual(calls.map(x=>x.url),['/auth/refresh','/auth/logout']);assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:'',refreshToken:''}) }) test('late successful business response after logout rejects even after same-identity new login',async()=>{ const started=deferred(),release=deferred();const http=harness(async c=>{if(c.url==='/merchant/v1/orders'){started.resolve();await release.promise}return ok(c,'old-data')}) const pending=http.apiRequest({url:'/merchant/v1/orders'});const rejected=assert.rejects(pending,e=>e.code==='SESSION_CHANGED');await started.promise await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected;assert.equal(http.session().refreshToken,'new-login-refresh') }) test('inflight refresh unknown outcome stops logout without competing retry and preserves later login',async()=>{ const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);started.resolve();await release.promise;throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c)}) const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');const failure=assert.rejects(leaving,e=>e.code==='NETWORK_ERROR');http.setSession(otherAccess,'other-refresh');release.resolve();await failure;await rejected assert.equal(calls.length,1);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'}) }) test('logout401 refresh and replacement revocation cannot overwrite later login',async()=>{ const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)}) const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');await started.promise;http.setSession(otherAccess,'other-refresh');release.resolve();await leaving assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'}) }) test('late old business401 after logout does not refresh or replay under same-identity new login',async()=>{ const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/merchant/v1/orders/first-leg/one/submit'){started.resolve();await release.promise;throw unauthorized(c)}return ok(c)}) const request=http.apiRequest({method:'POST',url:'/merchant/v1/orders/first-leg/one/submit'});const rejected=assert.rejects(request,e=>e.code==='SESSION_CHANGED');await started.promise;await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected assert.deepEqual(calls.map(x=>x.url),['/merchant/v1/orders/first-leg/one/submit','/auth/logout']);assert.equal(http.session().refreshToken,'new-login-refresh') }) test('same identity new pair logout does not adopt another session inflight refresh',async()=>{ const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'old-session-rotated'})}return ok(c)}) const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise;http.setSession(newAccess,'new-login-refresh') const leaving=authApi(http).logoutMerchant(newAccess,'new-login-refresh');http.setSession('','');await leaving assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[1].data).refreshToken,'new-login-refresh');release.resolve();await rejected;assert.deepEqual(http.session(),{token:'',refreshToken:''}) }) test('logout rejects refreshed 19 digit identity differing only in low digit',async()=>{ const raw='{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"}' const original=`test.${Buffer.from(raw).toString('base64url')}.test` for(const field of ['user_id','tenant_id','membership_id']){ const changed=raw.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`),calls=[] const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:`test.${Buffer.from(changed).toString('base64url')}.test`,refreshToken:'replacement'});throw unauthorized(c)}) http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(original,'original-refresh'),e=>e.code==='SESSION_CHANGED');assert.equal(calls.length,2) } })