From f2b9d48268bb38a948225914b7fe1bc0ef8f852f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 00:24:57 +0800 Subject: [PATCH] test: harden overseas pda token session --- AGENTS.md | 2 + README.md | 1 + src/api/auth.js | 2 +- src/api/overseas.js | 34 +++++--- src/config/runtime.js | 5 +- src/stores/session.js | 97 ++++++++++++++++----- tests/overseas-request.test.mjs | 150 ++++++++++++++++++++++++++++++++ tests/session-auth.test.mjs | 116 ++++++++++++++++++++++++ 8 files changed, 373 insertions(+), 34 deletions(-) create mode 100644 tests/overseas-request.test.mjs create mode 100644 tests/session-auth.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 398772c..d6479fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,8 @@ ## 服务与发布边界 - 国内 Auth 是唯一账号和 Token 签发方;海外业务请求统一经 `gateway-overseas`。 +- 登录、刷新和本地会话必须同时校验国内/海外 Token 的平台主体、成员、冻结海外仓一致;海外 Token 还必须精确包含 `WAREHOUSE_PICKER` 与 `lastleg:pda:operate`。无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。 +- 并发海外 401 只允许一次国内 refresh;双 Token 完整校验后原子替换,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。 - 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。 - `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。 - 环境地址只通过 `.env.local` 等未提交环境文件配置,不写入源码。 diff --git a/README.md b/README.md index 84c13be..a749c68 100644 --- a/README.md +++ b/README.md @@ -39,5 +39,6 @@ npm run build:app - 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。 - 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、SKU/箱唛与既有物理托盘解析、FBA 按 SKU 和箱数收货、OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。 - 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。 +- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`WAREHOUSE_PICKER`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。 - 库内、换标和现场查询仍是可运行的静态原型,不会模拟海外服务提交成功。 - 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。 diff --git a/src/api/auth.js b/src/api/auth.js index 4ed5cb5..02cf1c6 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -1,4 +1,4 @@ -import { API_ROOT } from '@/config/runtime.js' +import { API_ROOT } from '../config/runtime.js' function authRequest(path, data) { return new Promise((resolve, reject) => { diff --git a/src/api/overseas.js b/src/api/overseas.js index 8968cb9..41d4e4c 100644 --- a/src/api/overseas.js +++ b/src/api/overseas.js @@ -1,5 +1,5 @@ -import { refresh } from '@/api/auth.js' -import { session, replaceTokens } from '@/stores/session.js' +import { refresh } from './auth.js' +import { session, replaceTokens, signOut } from '../stores/session.js' let refreshPromise = null @@ -22,14 +22,24 @@ function successful(response, body) { } async function refreshOverseasToken() { - if (!session.refreshToken) throw new Error('登录状态已过期,请重新登录') + if (!session.refreshToken) { + signOut() + throw new Error('登录状态已过期,请重新登录') + } if (!refreshPromise) { - refreshPromise = refresh(session.refreshToken).then((tokens) => { replaceTokens(tokens); return session.token }) + refreshPromise = refresh(session.refreshToken) + .then((tokens) => { replaceTokens(tokens); return session.token }) + .catch((error) => { signOut(); throw error }) .finally(() => { refreshPromise = null }) } return refreshPromise } +async function tokenAfterUnauthorized(failedToken) { + if (session.token && session.token !== failedToken) return session.token + return refreshOverseasToken() +} + function rawRequest(method, path, data, token) { return new Promise((resolve, reject) => { uni.request({ @@ -44,10 +54,11 @@ function rawRequest(method, path, data, token) { } export async function overseasRequest(method, path, data, retry = true) { - let response = await rawRequest(method, path, data, session.token) + const attemptedToken = session.token + let response = await rawRequest(method, path, data, attemptedToken) if (retry && unauthorized(response)) { - await refreshOverseasToken() - response = await rawRequest(method, path, data, session.token) + const replacementToken = await tokenAfterUnauthorized(attemptedToken) + response = await rawRequest(method, path, data, replacementToken) } const body = response?.data || {} if (!successful(response, body)) throw errorFrom(response, '海外作业请求失败') @@ -68,14 +79,17 @@ function rawUpload(path, filePath, name, token) { } export async function overseasUpload(path, filePath, name = 'file', retry = true) { - let response = await rawUpload(path, filePath, name, session.token) + const attemptedToken = session.token + let response = await rawUpload(path, filePath, name, attemptedToken) let body = null try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ } if (retry && unauthorized({ ...response, data: body })) { - await refreshOverseasToken() - response = await rawUpload(path, filePath, name, session.token) + const replacementToken = await tokenAfterUnauthorized(attemptedToken) + response = await rawUpload(path, filePath, name, replacementToken) try { body = JSON.parse(response.data || '{}') } catch (_) { body = null } } if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '卸柜照片上传失败') return body.data } + +export function resetOverseasRequestStateForTest() { refreshPromise = null } diff --git a/src/config/runtime.js b/src/config/runtime.js index d248e36..5d4ca4b 100644 --- a/src/config/runtime.js +++ b/src/config/runtime.js @@ -1,5 +1,6 @@ -const configuredAuthApiRoot = import.meta.env.VITE_AUTH_API_BASE_URL || '' -const configuredOverseasApiRoot = import.meta.env.VITE_OVERSEAS_API_BASE_URL || '' +const runtimeEnvironment = import.meta.env || {} +const configuredAuthApiRoot = runtimeEnvironment.VITE_AUTH_API_BASE_URL || '' +const configuredOverseasApiRoot = runtimeEnvironment.VITE_OVERSEAS_API_BASE_URL || '' export const API_ROOT = String(configuredAuthApiRoot || '/api').replace(/\/$/, '') export const OVERSEAS_API_ROOT = String(configuredOverseasApiRoot || '/api/overseas').replace(/\/$/, '') diff --git a/src/stores/session.js b/src/stores/session.js index 77e5df5..e1238a4 100644 --- a/src/stores/session.js +++ b/src/stores/session.js @@ -1,7 +1,9 @@ import { reactive } from 'vue' -import { OVERSEAS_API_ROOT } from '@/config/runtime.js' +import { OVERSEAS_API_ROOT } from '../config/runtime.js' const SESSION_KEY = 'overseas-pda-auth-session' +export const PDA_ROLE = 'WAREHOUSE_PICKER' +export const PDA_AUTHORITY = 'lastleg:pda:operate' export const session = reactive({ currentUser: null, token: '', domesticToken: '', refreshToken: '', tenantId: '', apiBase: OVERSEAS_API_ROOT }) @@ -16,13 +18,64 @@ export function decodeAccessToken(token) { const payload = String(token || '').split('.')[1] if (!payload) throw new Error('登录响应中的 accessToken 格式无效') try { - return JSON.parse(decodeBase64Url(payload)) + const decoded = decodeBase64Url(payload) + // Preserve snowflake IDs before JSON.parse rounds unquoted JWT numbers beyond Number.MAX_SAFE_INTEGER. + const safeLongClaims = decoded.replace( + /("(?:user_id|membership_id|tenant_id|overseas_warehouse_id)"\s*:\s*)(-?\d{16,})(?=\s*[,}])/g, + '$1"$2"' + ) + return JSON.parse(safeLongClaims) } catch (error) { if (error.message === '当前运行环境无法解析登录令牌') throw error throw new Error('登录响应中的 accessToken 无法解析') } } +function claimId(claims, name) { + const value = claims?.[name] + return value === null || value === undefined ? '' : String(value) +} + +function includesClaim(values, expected) { + return Array.isArray(values) && values.map(String).includes(expected) +} + +function requireActiveClaims(claims, label) { + if (claims.exp && Number(claims.exp) * 1000 <= Date.now()) throw new Error(`${label}已过期`) + if (claims.app_code !== 'PLATFORM' || claimId(claims, 'tenant_id') !== '0') { + throw new Error('当前账号不属于平台仓库作业端') + } + if (!claimId(claims, 'user_id') || !claimId(claims, 'membership_id')) { + throw new Error(`${label}缺少完整作业主体`) + } +} + +export function validatePdaTokenPair(accessToken, overseasAccessToken) { + const domestic = decodeAccessToken(accessToken) + const overseas = decodeAccessToken(overseasAccessToken) + requireActiveClaims(domestic, '登录状态') + requireActiveClaims(overseas, '海外作业令牌') + for (const claim of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) { + if (claimId(domestic, claim) !== claimId(overseas, claim)) throw new Error('国内与海外令牌作业主体不一致') + } + const warehouseId = claimId(overseas, 'overseas_warehouse_id') + if (!/^\d+$/.test(warehouseId) || warehouseId === '0') throw new Error('海外PDA账号未绑定有效作业仓库') + if (!includesClaim(overseas.roles, PDA_ROLE)) throw new Error('当前账号不是海外仓库拣货员') + if (!includesClaim(overseas.authorities, PDA_AUTHORITY)) throw new Error(`当前账号缺少${PDA_AUTHORITY}权限`) + return { domestic, overseas, warehouseId } +} + +function userFromClaims(pair) { + return { + id: claimId(pair.domestic, 'user_id'), + name: pair.domestic.username || '仓库作业员', + role: PDA_ROLE, + roles: [...pair.overseas.roles], + warehouse: `海外仓 #${pair.warehouseId}`, + warehouseId: pair.warehouseId + } +} + function storageAvailable() { return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function' } @@ -53,44 +106,46 @@ export function signIn(account, warehouse, options = {}) { export function signInWithTokens(tokens) { if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken') if (!tokens?.overseasAccessToken) throw new Error('登录响应缺少 overseasAccessToken') - const claims = decodeAccessToken(tokens.accessToken) - const overseasClaims = decodeAccessToken(tokens.overseasAccessToken) - if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端') - if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期') - if (overseasClaims.exp && overseasClaims.exp * 1000 <= Date.now()) throw new Error('海外作业令牌已过期') - return signIn({ - id: claims.user_id ?? '', - name: claims.username || '仓库作业员', - role: Array.isArray(claims.roles) ? claims.roles.join('、') : '', - roles: claims.roles || [] - }, '', { + const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken) + const user = userFromClaims(pair) + return signIn(user, user.warehouse, { token: tokens.overseasAccessToken, domesticToken: tokens.accessToken, refreshToken: tokens.refreshToken || '', - tenantId: claims.tenant_id == null ? '' : String(claims.tenant_id) + tenantId: claimId(pair.domestic, 'tenant_id') }) } export function replaceTokens(tokens) { if (!tokens?.accessToken || !tokens?.overseasAccessToken) throw new Error('刷新响应缺少双区域访问令牌') - decodeAccessToken(tokens.accessToken) - decodeAccessToken(tokens.overseasAccessToken) + const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken) + const user = userFromClaims(pair) + const nextRefreshToken = tokens.refreshToken || session.refreshToken + // Validate the complete replacement before mutating reactive or persisted session state. session.domesticToken = tokens.accessToken session.token = tokens.overseasAccessToken - session.refreshToken = tokens.refreshToken || session.refreshToken + session.refreshToken = nextRefreshToken + session.tenantId = claimId(pair.domestic, 'tenant_id') + session.currentUser = user persistSession() } export function restoreSession() { if (!storageAvailable()) return false const saved = uni.getStorageSync(SESSION_KEY) - if (!saved?.token || !saved?.currentUser) return false + if (!saved?.token || !saved?.domesticToken || !saved?.refreshToken || !saved?.currentUser) { + signOut() + return false + } try { - const claims = decodeAccessToken(saved.token) - if (claims.exp && claims.exp * 1000 <= Date.now()) return false - Object.assign(session, saved, { apiBase: saved.apiBase || OVERSEAS_API_ROOT }) + const pair = validatePdaTokenPair(saved.domesticToken, saved.token) + Object.assign(session, saved, { + currentUser: userFromClaims(pair), tenantId: claimId(pair.domestic, 'tenant_id'), + apiBase: saved.apiBase || OVERSEAS_API_ROOT + }) return true } catch (_) { + signOut() return false } } diff --git a/tests/overseas-request.test.mjs b/tests/overseas-request.test.mjs new file mode 100644 index 0000000..5c6d86a --- /dev/null +++ b/tests/overseas-request.test.mjs @@ -0,0 +1,150 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { overseasRequest, overseasUpload, resetOverseasRequestStateForTest } from '../src/api/overseas.js' +import { PDA_AUTHORITY, PDA_ROLE, session, signInWithTokens, signOut } from '../src/stores/session.js' + +const future = () => Math.floor(Date.now() / 1000) + 3600 +const jwt = (claims) => [ + Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'), + Buffer.from(JSON.stringify(claims)).toString('base64url'), 'signature' +].join('.') + +function tokens(version = 'old') { + const base = { user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM', overseas_warehouse_id: 900, exp: future(), version } + return { + accessToken: jwt({ ...base, username: 'picker-42' }), + overseasAccessToken: jwt({ ...base, roles: [PDA_ROLE], authorities: [PDA_AUTHORITY] }), + refreshToken: `refresh-${version}` + } +} + +function prepareSession() { + globalThis.uni = { + setStorageSync() {}, getStorageSync() {}, removeStorageSync() {} + } + resetOverseasRequestStateForTest(); signOut() + const value = tokens('old') + signInWithTokens(value) + return value +} + +const ok = (data, headers = {}) => ({ statusCode: 200, data: { code: '200', data }, header: headers }) +const denied = (requestId = 'request-401', traceId = 'trace-401') => ({ + statusCode: 401, data: { code: 'A0401', message: 'expired', requestId, traceId }, header: {} +}) + +test('concurrent old-token 401 responses share one refresh and each replay once with the atomically replaced token', async () => { + const old = prepareSession(); const next = tokens('new') + let refreshCount = 0; let oldCalls = 0; let newCalls = 0 + globalThis.uni.request = (options) => { + if (options.url.endsWith('/auth/refresh')) { + refreshCount += 1 + return setTimeout(() => options.success(ok(next)), 10) + } + if (options.header.Authorization === `Bearer ${old.overseasAccessToken}`) { + oldCalls += 1 + return setTimeout(() => options.success(denied()), oldCalls === 1 ? 0 : 30) + } + assert.equal(options.header.Authorization, `Bearer ${next.overseasAccessToken}`) + newCalls += 1 + setTimeout(() => options.success(ok({ call: newCalls })), 0) + } + + const results = await Promise.all([ + overseasRequest('GET', '/pda/one'), overseasRequest('GET', '/pda/two') + ]) + assert.equal(results.length, 2) + assert.equal(refreshCount, 1) + assert.equal(oldCalls, 2) + assert.equal(newCalls, 2) + assert.equal(session.token, next.overseasAccessToken) + assert.equal(session.domesticToken, next.accessToken) +}) + +test('request retries only once and preserves the final request and trace identifiers', async () => { + prepareSession(); const next = tokens('retry') + let businessCalls = 0; let refreshCount = 0 + globalThis.uni.request = (options) => { + if (options.url.endsWith('/auth/refresh')) { + refreshCount += 1 + return options.success(ok(next)) + } + businessCalls += 1 + options.success(denied(`request-${businessCalls}`, `trace-${businessCalls}`)) + } + + await assert.rejects(overseasRequest('POST', '/pda/write', { value: 1 }), (error) => { + assert.equal(error.code, 'A0401') + assert.equal(error.requestId, 'request-2') + assert.equal(error.traceId, 'trace-2') + return true + }) + assert.equal(refreshCount, 1) + assert.equal(businessCalls, 2) +}) + +test('refresh rejection clears every local token and returns the auth trace', async () => { + prepareSession() + globalThis.uni.request = (options) => { + if (options.url.endsWith('/auth/refresh')) { + return options.success({ statusCode: 200, data: { code: 'A0401', message: 'refresh expired', requestId: 'auth-request', traceId: 'auth-trace' }, header: {} }) + } + options.success(denied()) + } + + await assert.rejects(overseasRequest('GET', '/pda/current'), (error) => { + assert.equal(error.requestId, 'auth-request') + assert.equal(error.traceId, 'auth-trace') + return true + }) + assert.equal(session.currentUser, null) + assert.equal(session.token, '') + assert.equal(session.domesticToken, '') + assert.equal(session.refreshToken, '') +}) + +test('photo upload refreshes and replays once while retaining the file payload', async () => { + const old = prepareSession(); const next = tokens('upload') + let refreshCount = 0; const uploads = [] + globalThis.uni.request = (options) => { + refreshCount += 1 + assert.ok(options.url.endsWith('/auth/refresh')) + options.success(ok(next)) + } + globalThis.uni.uploadFile = (options) => { + uploads.push({ filePath: options.filePath, name: options.name, authorization: options.header.Authorization }) + if (options.header.Authorization === `Bearer ${old.overseasAccessToken}`) { + return options.success({ statusCode: 401, data: JSON.stringify({ code: 'A0401', requestId: 'upload-old', traceId: 'upload-old-trace' }), header: {} }) + } + options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '99' } }), header: {} }) + } + + assert.deepEqual(await overseasUpload('/pda/photo', '/tmp/photo.jpg'), { fileId: '99' }) + assert.equal(refreshCount, 1) + assert.equal(uploads.length, 2) + assert.deepEqual(uploads.map(({ filePath, name }) => ({ filePath, name })), [ + { filePath: '/tmp/photo.jpg', name: 'file' }, { filePath: '/tmp/photo.jpg', name: 'file' } + ]) +}) + +test('business and network errors retain diagnostics without refreshing or clearing a valid session', async () => { + prepareSession(); let refreshCount = 0 + globalThis.uni.request = (options) => { + if (options.url.endsWith('/auth/refresh')) refreshCount += 1 + options.success({ statusCode: 200, data: { code: 'A0409', message: 'locked', requestId: 'business-request', traceId: 'business-trace' }, header: {} }) + } + await assert.rejects(overseasRequest('POST', '/pda/locked'), (error) => { + assert.equal(error.code, 'A0409') + assert.equal(error.requestId, 'business-request') + assert.equal(error.traceId, 'business-trace') + return true + }) + assert.equal(refreshCount, 0) + assert.ok(session.currentUser) + + globalThis.uni.request = (options) => options.fail({ errMsg: 'socket disconnected' }) + await assert.rejects(overseasRequest('GET', '/pda/network'), (error) => error.network === true && /socket/.test(error.message)) + assert.ok(session.currentUser) +}) + diff --git a/tests/session-auth.test.mjs b/tests/session-auth.test.mjs new file mode 100644 index 0000000..29f6ff9 --- /dev/null +++ b/tests/session-auth.test.mjs @@ -0,0 +1,116 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + PDA_AUTHORITY, PDA_ROLE, replaceTokens, restoreSession, session, + decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair +} from '../src/stores/session.js' + +const future = () => Math.floor(Date.now() / 1000) + 3600 +const jwt = (claims) => [ + Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'), + Buffer.from(JSON.stringify(claims)).toString('base64url'), 'signature' +].join('.') +const rawJwt = (json) => [ + Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'), + Buffer.from(json).toString('base64url'), 'signature' +].join('.') + +function tokenPair(overrides = {}) { + const base = { + user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM', + overseas_warehouse_id: 900, exp: future() + } + const domestic = { ...base, username: 'picker-42', ...(overrides.domestic || {}) } + const overseas = { + ...base, roles: [PDA_ROLE], authorities: [PDA_AUTHORITY], + ...(overrides.overseas || {}) + } + return { + accessToken: jwt(domestic), overseasAccessToken: jwt(overseas), + refreshToken: overrides.refreshToken ?? 'refresh-42' + } +} + +function storage() { + const values = new Map() + globalThis.uni = { + setStorageSync: (key, value) => values.set(key, JSON.parse(JSON.stringify(value))), + getStorageSync: (key) => values.get(key), + removeStorageSync: (key) => values.delete(key) + } + return values +} + +test('valid platform warehouse picker tokens freeze the same operator and warehouse', () => { + storage(); signOut() + const tokens = tokenPair() + const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken) + assert.equal(pair.warehouseId, '900') + const user = signInWithTokens(tokens) + assert.deepEqual(user.roles, [PDA_ROLE]) + assert.equal(user.warehouseId, '900') + assert.equal(user.warehouse, '海外仓 #900') + assert.equal(session.tenantId, '0') + assert.equal(session.token, tokens.overseasAccessToken) +}) + +test('JWT decoding preserves unquoted snowflake subjects and warehouse IDs exactly', () => { + const decoded = decodeAccessToken(rawJwt('{"user_id":2091919753535229955,"membership_id":2091919753535229956,"tenant_id":0,"overseas_warehouse_id":2091919753155878914}')) + assert.equal(decoded.user_id, '2091919753535229955') + assert.equal(decoded.membership_id, '2091919753535229956') + assert.equal(decoded.overseas_warehouse_id, '2091919753155878914') +}) + +test('login fails closed for wrong role, authority, warehouse, application, or token subject', () => { + storage(); signOut() + const invalidPairs = [ + tokenPair({ overseas: { roles: ['PLATFORM_ADMIN'] } }), + tokenPair({ overseas: { authorities: [] } }), + tokenPair({ overseas: { overseas_warehouse_id: null } }), + tokenPair({ domestic: { app_code: 'MERCHANT', tenant_id: 100 } }), + tokenPair({ overseas: { user_id: 99 } }), + tokenPair({ overseas: { membership_id: 99 } }), + tokenPair({ overseas: { overseas_warehouse_id: 901 } }) + ] + for (const tokens of invalidPairs) { + assert.throws(() => signInWithTokens(tokens)) + assert.equal(session.currentUser, null) + } +}) + +test('refresh replacement validates both tokens before atomically changing session', () => { + storage(); signOut() + const original = tokenPair() + signInWithTokens(original) + const before = { domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken } + const invalid = tokenPair({ overseas: { authorities: [] }, refreshToken: 'invalid-refresh' }) + assert.throws(() => replaceTokens(invalid), /lastleg:pda:operate/) + assert.deepEqual({ domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken }, before) + + const replacement = tokenPair({ refreshToken: 'refresh-43' }) + replaceTokens(replacement) + assert.equal(session.domesticToken, replacement.accessToken) + assert.equal(session.token, replacement.overseasAccessToken) + assert.equal(session.refreshToken, 'refresh-43') +}) + +test('restore rebuilds identity from claims and removes expired or tampered storage', () => { + const values = storage(); signOut() + signInWithTokens(tokenPair()) + const saved = structuredClone([...values.values()][0]) + signOut(); values.set('overseas-pda-auth-session', saved) + assert.equal(restoreSession(), true) + assert.equal(session.currentUser.warehouseId, '900') + + const tampered = { ...saved, token: tokenPair({ overseas: { overseas_warehouse_id: 901 } }).overseasAccessToken } + signOut(); values.set('overseas-pda-auth-session', tampered) + assert.equal(restoreSession(), false) + assert.equal(session.currentUser, null) + assert.equal(values.has('overseas-pda-auth-session'), false) + + const expired = tokenPair({ domestic: { exp: 1 }, overseas: { exp: 1 } }) + values.set('overseas-pda-auth-session', { ...saved, domesticToken: expired.accessToken, token: expired.overseasAccessToken }) + assert.equal(restoreSession(), false) + assert.equal(values.has('overseas-pda-auth-session'), false) +})