test: harden overseas pda token session

This commit is contained in:
2026-08-25 00:24:57 +08:00
parent bc986f1eef
commit f2b9d48268
8 changed files with 373 additions and 34 deletions
+2
View File
@@ -15,6 +15,8 @@
## 服务与发布边界 ## 服务与发布边界
- 国内 Auth 是唯一账号和 Token 签发方;海外业务请求统一经 `gateway-overseas`。 - 国内 Auth 是唯一账号和 Token 签发方;海外业务请求统一经 `gateway-overseas`。
- 登录、刷新和本地会话必须同时校验国内/海外 Token 的平台主体、成员、冻结海外仓一致;海外 Token 还必须精确包含 `WAREHOUSE_PICKER` 与 `lastleg:pda:operate`。无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。
- 并发海外 401 只允许一次国内 refresh;双 Token 完整校验后原子替换,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。
- 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。 - 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。
- `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。 - `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。
- 环境地址只通过 `.env.local` 等未提交环境文件配置,不写入源码。 - 环境地址只通过 `.env.local` 等未提交环境文件配置,不写入源码。
+1
View File
@@ -39,5 +39,6 @@ npm run build:app
- 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。 - 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。
- 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、SKU/箱唛与既有物理托盘解析、FBA 按 SKU 和箱数收货、OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。 - 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、SKU/箱唛与既有物理托盘解析、FBA 按 SKU 和箱数收货、OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。 - 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`WAREHOUSE_PICKER`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。
- 库内、换标和现场查询仍是可运行的静态原型,不会模拟海外服务提交成功。 - 库内、换标和现场查询仍是可运行的静态原型,不会模拟海外服务提交成功。
- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。 - 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
+1 -1
View File
@@ -1,4 +1,4 @@
import { API_ROOT } from '@/config/runtime.js' import { API_ROOT } from '../config/runtime.js'
function authRequest(path, data) { function authRequest(path, data) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+24 -10
View File
@@ -1,5 +1,5 @@
import { refresh } from '@/api/auth.js' import { refresh } from './auth.js'
import { session, replaceTokens } from '@/stores/session.js' import { session, replaceTokens, signOut } from '../stores/session.js'
let refreshPromise = null let refreshPromise = null
@@ -22,14 +22,24 @@ function successful(response, body) {
} }
async function refreshOverseasToken() { async function refreshOverseasToken() {
if (!session.refreshToken) throw new Error('登录状态已过期,请重新登录') if (!session.refreshToken) {
signOut()
throw new Error('登录状态已过期,请重新登录')
}
if (!refreshPromise) { 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 }) .finally(() => { refreshPromise = null })
} }
return refreshPromise return refreshPromise
} }
async function tokenAfterUnauthorized(failedToken) {
if (session.token && session.token !== failedToken) return session.token
return refreshOverseasToken()
}
function rawRequest(method, path, data, token) { function rawRequest(method, path, data, token) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
uni.request({ uni.request({
@@ -44,10 +54,11 @@ function rawRequest(method, path, data, token) {
} }
export async function overseasRequest(method, path, data, retry = true) { 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)) { if (retry && unauthorized(response)) {
await refreshOverseasToken() const replacementToken = await tokenAfterUnauthorized(attemptedToken)
response = await rawRequest(method, path, data, session.token) response = await rawRequest(method, path, data, replacementToken)
} }
const body = response?.data || {} const body = response?.data || {}
if (!successful(response, body)) throw errorFrom(response, '海外作业请求失败') 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) { 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 let body = null
try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ } try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ }
if (retry && unauthorized({ ...response, data: body })) { if (retry && unauthorized({ ...response, data: body })) {
await refreshOverseasToken() const replacementToken = await tokenAfterUnauthorized(attemptedToken)
response = await rawUpload(path, filePath, name, session.token) response = await rawUpload(path, filePath, name, replacementToken)
try { body = JSON.parse(response.data || '{}') } catch (_) { body = null } try { body = JSON.parse(response.data || '{}') } catch (_) { body = null }
} }
if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '卸柜照片上传失败') if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '卸柜照片上传失败')
return body.data return body.data
} }
export function resetOverseasRequestStateForTest() { refreshPromise = null }
+3 -2
View File
@@ -1,5 +1,6 @@
const configuredAuthApiRoot = import.meta.env.VITE_AUTH_API_BASE_URL || '' const runtimeEnvironment = import.meta.env || {}
const configuredOverseasApiRoot = import.meta.env.VITE_OVERSEAS_API_BASE_URL || '' 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 API_ROOT = String(configuredAuthApiRoot || '/api').replace(/\/$/, '')
export const OVERSEAS_API_ROOT = String(configuredOverseasApiRoot || '/api/overseas').replace(/\/$/, '') export const OVERSEAS_API_ROOT = String(configuredOverseasApiRoot || '/api/overseas').replace(/\/$/, '')
+76 -21
View File
@@ -1,7 +1,9 @@
import { reactive } from 'vue' 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' 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 }) 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] const payload = String(token || '').split('.')[1]
if (!payload) throw new Error('登录响应中的 accessToken 格式无效') if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
try { 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) { } catch (error) {
if (error.message === '当前运行环境无法解析登录令牌') throw error if (error.message === '当前运行环境无法解析登录令牌') throw error
throw new Error('登录响应中的 accessToken 无法解析') 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() { function storageAvailable() {
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function' 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) { export function signInWithTokens(tokens) {
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken') if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
if (!tokens?.overseasAccessToken) throw new Error('登录响应缺少 overseasAccessToken') if (!tokens?.overseasAccessToken) throw new Error('登录响应缺少 overseasAccessToken')
const claims = decodeAccessToken(tokens.accessToken) const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken)
const overseasClaims = decodeAccessToken(tokens.overseasAccessToken) const user = userFromClaims(pair)
if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端') return signIn(user, user.warehouse, {
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 || []
}, '', {
token: tokens.overseasAccessToken, token: tokens.overseasAccessToken,
domesticToken: tokens.accessToken, domesticToken: tokens.accessToken,
refreshToken: tokens.refreshToken || '', refreshToken: tokens.refreshToken || '',
tenantId: claims.tenant_id == null ? '' : String(claims.tenant_id) tenantId: claimId(pair.domestic, 'tenant_id')
}) })
} }
export function replaceTokens(tokens) { export function replaceTokens(tokens) {
if (!tokens?.accessToken || !tokens?.overseasAccessToken) throw new Error('刷新响应缺少双区域访问令牌') if (!tokens?.accessToken || !tokens?.overseasAccessToken) throw new Error('刷新响应缺少双区域访问令牌')
decodeAccessToken(tokens.accessToken) const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken)
decodeAccessToken(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.domesticToken = tokens.accessToken
session.token = tokens.overseasAccessToken session.token = tokens.overseasAccessToken
session.refreshToken = tokens.refreshToken || session.refreshToken session.refreshToken = nextRefreshToken
session.tenantId = claimId(pair.domestic, 'tenant_id')
session.currentUser = user
persistSession() persistSession()
} }
export function restoreSession() { export function restoreSession() {
if (!storageAvailable()) return false if (!storageAvailable()) return false
const saved = uni.getStorageSync(SESSION_KEY) 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 { try {
const claims = decodeAccessToken(saved.token) const pair = validatePdaTokenPair(saved.domesticToken, saved.token)
if (claims.exp && claims.exp * 1000 <= Date.now()) return false Object.assign(session, saved, {
Object.assign(session, saved, { apiBase: saved.apiBase || OVERSEAS_API_ROOT }) currentUser: userFromClaims(pair), tenantId: claimId(pair.domestic, 'tenant_id'),
apiBase: saved.apiBase || OVERSEAS_API_ROOT
})
return true return true
} catch (_) { } catch (_) {
signOut()
return false return false
} }
} }
+150
View File
@@ -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)
})
+116
View File
@@ -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)
})