450 lines
19 KiB
JavaScript
450 lines
19 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
|
|
import { overseasDownload, overseasRequest, overseasUpload, resetOverseasRequestStateForTest } from '../src/api/overseas.js'
|
|
import { resolvePallet } from '../src/api/pallet.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' }),
|
|
refreshToken: `refresh-${version}`,
|
|
roles: [PDA_ROLE], authorities: [PDA_AUTHORITY]
|
|
}
|
|
}
|
|
|
|
function prepareSession() {
|
|
globalThis.uni = {
|
|
setStorageSync() {}, getStorageSync() {}, removeStorageSync() {},
|
|
launches: [], reLaunch(options) { this.launches.push(options.url) }
|
|
}
|
|
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('H5 proxy and App API roots normalize request upload and download paths to one public api prefix', async () => {
|
|
prepareSession()
|
|
const requests = []; const uploads = []; const downloads = []
|
|
globalThis.uni.request = (options) => { requests.push(options.url); options.success(ok({ palletCode: 'PLT-1' })) }
|
|
globalThis.uni.uploadFile = (options) => { uploads.push(options.url); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '1' } }), header: {} }) }
|
|
globalThis.uni.downloadFile = (options) => { downloads.push(options.url); options.success({ statusCode: 200, tempFilePath: '/tmp/photo.jpg', header: {} }) }
|
|
|
|
session.apiBase = '/gateway-api'
|
|
await resolvePallet('PLT-1')
|
|
await overseasUpload('/api/pda/v1/last-leg/photos/files', '/tmp/photo.jpg')
|
|
await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download')
|
|
|
|
session.apiBase = 'https://overseas.example.com/api/'
|
|
await resolvePallet('PLT-1')
|
|
await overseasUpload('/api/pda/v1/last-leg/photos/files', '/tmp/photo.jpg')
|
|
await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download')
|
|
|
|
assert.deepEqual(requests, [
|
|
'/gateway-api/pda/v1/last-leg/pallet-operations/pallets/resolve',
|
|
'https://overseas.example.com/api/pda/v1/last-leg/pallet-operations/pallets/resolve'
|
|
])
|
|
assert.deepEqual(uploads, [
|
|
'/gateway-api/pda/v1/last-leg/photos/files',
|
|
'https://overseas.example.com/api/pda/v1/last-leg/photos/files'
|
|
])
|
|
assert.deepEqual(downloads, [
|
|
'/gateway-api/pda/v1/last-leg/photos/files/1/download',
|
|
'https://overseas.example.com/api/pda/v1/last-leg/photos/files/1/download'
|
|
])
|
|
for (const url of [...requests, ...uploads, ...downloads]) assert.doesNotMatch(url, /\/api\/api\//)
|
|
})
|
|
|
|
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.accessToken}`) {
|
|
oldCalls += 1
|
|
return setTimeout(() => options.success(denied()), oldCalls === 1 ? 0 : 30)
|
|
}
|
|
assert.equal(options.header.Authorization, `Bearer ${next.accessToken}`)
|
|
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.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)
|
|
assert.equal(session.token, '')
|
|
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
|
})
|
|
|
|
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.refreshToken, '')
|
|
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
|
})
|
|
|
|
test('concurrent refresh rejection clears the session and navigates to login only once', async () => {
|
|
prepareSession()
|
|
let refreshCount = 0
|
|
uni.request = (options) => {
|
|
if (options.url.endsWith('/auth/refresh')) {
|
|
refreshCount += 1
|
|
return setTimeout(() => options.success(denied()), 5)
|
|
}
|
|
options.success(denied())
|
|
}
|
|
const results = await Promise.allSettled([overseasRequest('GET', '/one'), overseasRequest('GET', '/two')])
|
|
assert.ok(results.every(result => result.status === 'rejected'))
|
|
assert.equal(refreshCount, 1)
|
|
assert.equal(session.currentUser, null)
|
|
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
|
})
|
|
|
|
for (const refreshSucceeds of [true, false]) {
|
|
test(`late refresh ${refreshSucceeds ? 'success' : 'failure'} cannot restore logout or clear a new login`, async () => {
|
|
for (const relogin of [false, true]) {
|
|
prepareSession()
|
|
let completeRefresh
|
|
let started
|
|
const refreshStarted = new Promise(resolve => { started = resolve })
|
|
let businessCalls = 0
|
|
uni.request = (options) => {
|
|
if (options.url.endsWith('/auth/refresh')) {
|
|
completeRefresh = () => options.success(refreshSucceeds ? ok(tokens('late')) : denied())
|
|
started()
|
|
return
|
|
}
|
|
businessCalls += 1
|
|
options.success(denied())
|
|
}
|
|
const pending = overseasRequest('POST', '/write')
|
|
await refreshStarted
|
|
signOut()
|
|
const replacement = tokens('new-login')
|
|
if (relogin) signInWithTokens(replacement)
|
|
completeRefresh()
|
|
await assert.rejects(pending)
|
|
assert.equal(session.token, relogin ? replacement.accessToken : '')
|
|
assert.equal(businessCalls, 1)
|
|
assert.deepEqual(uni.launches, [])
|
|
}
|
|
})
|
|
}
|
|
|
|
test('an old business response cannot replay with a newly logged-in session', async () => {
|
|
prepareSession()
|
|
let complete
|
|
let calls = 0
|
|
uni.request = options => { calls += 1; complete = () => options.success(denied()) }
|
|
const pending = overseasRequest('POST', '/write')
|
|
signOut()
|
|
const replacement = tokens('another-login')
|
|
signInWithTokens(replacement)
|
|
complete()
|
|
await assert.rejects(pending, /会话已变更/)
|
|
assert.equal(calls, 1)
|
|
assert.equal(session.token, replacement.accessToken)
|
|
assert.deepEqual(uni.launches, [])
|
|
})
|
|
|
|
for (const kind of ['upload', 'download']) {
|
|
test(`${kind} final unauthorized response navigates once and stops after one replay`, async () => {
|
|
prepareSession()
|
|
let refreshCount = 0
|
|
let calls = 0
|
|
uni.request = options => { refreshCount += 1; options.success(ok(tokens(kind))) }
|
|
uni.uploadFile = options => { calls += 1; options.success({ ...denied(), data: JSON.stringify(denied().data) }) }
|
|
uni.downloadFile = options => { calls += 1; options.success(denied()) }
|
|
await assert.rejects(kind === 'upload' ? overseasUpload('/photo', '/tmp/photo.jpg') : overseasDownload('/photo'))
|
|
assert.equal(refreshCount, 1)
|
|
assert.equal(calls, 2)
|
|
assert.equal(session.currentUser, null)
|
|
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
|
})
|
|
}
|
|
|
|
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.accessToken}`) {
|
|
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('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile', async () => {
|
|
const value = prepareSession()
|
|
const originalWindow = globalThis.window
|
|
const originalLocation = globalThis.location
|
|
const originalFetch = globalThis.fetch
|
|
let uploadFileCalls = 0
|
|
let uploaded
|
|
globalThis.window = {}
|
|
globalThis.location = { protocol: 'http:' }
|
|
globalThis.uni.uploadFile = () => { uploadFileCalls += 1 }
|
|
globalThis.fetch = async (url, options) => {
|
|
if (String(url).startsWith('blob:')) return new Response(new Blob(['photo'], { type: 'image/png' }))
|
|
uploaded = { url, options }
|
|
return new Response(JSON.stringify({ code: '200', data: { fileId: 'h5-1' } }), { status: 200 })
|
|
}
|
|
try {
|
|
session.apiBase = '/gateway-api'
|
|
assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', 'blob:http://localhost/photo'), { fileId: 'h5-1' })
|
|
assert.equal(uploadFileCalls, 0)
|
|
assert.equal(uploaded.url, '/gateway-api/pda/v1/last-leg/photos/files')
|
|
assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.accessToken}`)
|
|
assert.ok(uploaded.options.body instanceof FormData)
|
|
} finally {
|
|
globalThis.fetch = originalFetch
|
|
if (originalWindow === undefined) delete globalThis.window
|
|
else globalThis.window = originalWindow
|
|
if (originalLocation === undefined) delete globalThis.location
|
|
else globalThis.location = originalLocation
|
|
}
|
|
})
|
|
|
|
test('H5 XMLHttpRequest timeout and abort reject once so the page can retry without an automatic upload replay', async () => {
|
|
prepareSession()
|
|
const originals = { window: globalThis.window, location: globalThis.location, fetch: globalThis.fetch, XMLHttpRequest: globalThis.XMLHttpRequest }
|
|
globalThis.window = {}; globalThis.location = { protocol: 'http:' }
|
|
globalThis.fetch = async () => new Response(new Blob(['photo'], { type: 'image/png' }))
|
|
const requests = []
|
|
globalThis.XMLHttpRequest = class {
|
|
constructor() { requests.push(this) }
|
|
open() {} setRequestHeader() {}
|
|
send() { queueMicrotask(() => requests.length === 1 ? this.ontimeout() : this.onabort()) }
|
|
}
|
|
try {
|
|
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /超时/.test(error.message))
|
|
assert.equal(requests.length, 1); assert.equal(requests[0].timeout, 60000)
|
|
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /中断/.test(error.message))
|
|
assert.equal(requests.length, 2)
|
|
} finally {
|
|
for (const [key, value] of Object.entries(originals)) { if (value === undefined) delete globalThis[key]; else globalThis[key] = value }
|
|
}
|
|
})
|
|
|
|
test('H5 fetch upload aborts a stalled transfer, clears its timer, and permits a later upload', async () => {
|
|
prepareSession()
|
|
const originals = { window: globalThis.window, location: globalThis.location, fetch: globalThis.fetch, XMLHttpRequest: globalThis.XMLHttpRequest, setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout }
|
|
let timeoutCallback, posts = 0, cleared = 0
|
|
globalThis.window = {}; globalThis.location = { protocol: 'http:' }; delete globalThis.XMLHttpRequest
|
|
globalThis.setTimeout = (callback, delay) => { assert.equal(delay, 60000); timeoutCallback = callback; return 'upload-timer' }
|
|
globalThis.clearTimeout = handle => { assert.equal(handle, 'upload-timer'); cleared++ }
|
|
globalThis.fetch = async (url, options) => {
|
|
if (String(url).startsWith('blob:')) return new Response(new Blob(['photo'], { type: 'image/png' }))
|
|
if (++posts === 1) return new Promise((_resolve, reject) => { options.signal.addEventListener('abort', () => reject(new Error('aborted'))); queueMicrotask(timeoutCallback) })
|
|
return new Response(JSON.stringify({ code: '200', data: { fileId: 'retried-photo' } }), { status: 200 })
|
|
}
|
|
try {
|
|
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /超时/.test(error.message))
|
|
assert.equal(posts, 1); assert.equal(cleared, 1)
|
|
assert.deepEqual(await overseasUpload('/pda/photo', 'blob:photo'), { fileId: 'retried-photo' })
|
|
assert.equal(posts, 2); assert.equal(cleared, 2)
|
|
} finally {
|
|
for (const [key, value] of Object.entries(originals)) { if (value === undefined) delete globalThis[key]; else globalThis[key] = value }
|
|
}
|
|
})
|
|
|
|
test('H5 File photo is appended directly without rereading a temporary blob URL', async () => {
|
|
const value = prepareSession()
|
|
const originalWindow = globalThis.window
|
|
const originalLocation = globalThis.location
|
|
globalThis.window = {}
|
|
globalThis.location = { protocol: 'http:' }
|
|
const selected = new Blob(['photo'], { type: 'image/png' })
|
|
let calls = 0
|
|
globalThis.uni.uploadFile = (options) => {
|
|
calls += 1
|
|
assert.equal(options.url, '/gateway-api/pda/photo')
|
|
assert.equal(options.header.Authorization, `Bearer ${value.accessToken}`)
|
|
assert.equal(options.files[0].file, selected)
|
|
assert.equal(options.files[0].name, 'file')
|
|
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'direct-file' } }), header: {} })
|
|
}
|
|
try {
|
|
session.apiBase = '/gateway-api'
|
|
assert.deepEqual(await overseasUpload('/pda/photo', selected), { fileId: 'direct-file' })
|
|
assert.equal(calls, 1)
|
|
} finally {
|
|
if (originalWindow === undefined) delete globalThis.window
|
|
else globalThis.window = originalWindow
|
|
if (originalLocation === undefined) delete globalThis.location
|
|
else globalThis.location = originalLocation
|
|
}
|
|
})
|
|
|
|
test('native file runtime rejects an H5-relative overseas root before upload with a clear configuration error', async () => {
|
|
prepareSession()
|
|
const originalLocation = globalThis.location
|
|
globalThis.location = { protocol: 'file:' }
|
|
session.apiBase = '/gateway-api'
|
|
let uploadCalls = 0
|
|
globalThis.uni.uploadFile = () => { uploadCalls += 1 }
|
|
try {
|
|
await assert.rejects(overseasUpload('/pda/photo', '_doc/photo.jpg'), (error) => {
|
|
assert.equal(error.code, 'PDA_API_BASE_REQUIRED')
|
|
assert.match(error.message, /VITE_API_BASE_URL/)
|
|
assert.match(error.message, /HTTP\(S\)/)
|
|
return true
|
|
})
|
|
assert.equal(uploadCalls, 0)
|
|
} finally {
|
|
if (originalLocation === undefined) delete globalThis.location
|
|
else globalThis.location = originalLocation
|
|
}
|
|
})
|
|
|
|
test('native file runtime uploads to an absolute overseas root', async () => {
|
|
prepareSession()
|
|
const originalLocation = globalThis.location
|
|
globalThis.location = { protocol: 'file:' }
|
|
session.apiBase = 'https://overseas.example.com/api/'
|
|
let call
|
|
globalThis.uni.uploadFile = (options) => {
|
|
call = options
|
|
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'native-1' } }), header: {} })
|
|
}
|
|
try {
|
|
assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', '_doc/photo.jpg'), { fileId: 'native-1' })
|
|
assert.equal(call.url, 'https://overseas.example.com/api/pda/v1/last-leg/photos/files')
|
|
assert.equal(call.filePath, '_doc/photo.jpg')
|
|
} finally {
|
|
if (originalLocation === undefined) delete globalThis.location
|
|
else globalThis.location = originalLocation
|
|
}
|
|
})
|
|
|
|
test('authenticated photo download maps the public api path through the H5 overseas proxy', async () => {
|
|
const value = prepareSession(); const calls = []
|
|
globalThis.uni.downloadFile = (options) => {
|
|
calls.push(options)
|
|
options.success({ statusCode: 200, tempFilePath: '/tmp/restored.jpg', header: {} })
|
|
}
|
|
assert.equal(await overseasDownload('/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download'), '/tmp/restored.jpg')
|
|
assert.equal(calls.length, 1)
|
|
assert.equal(calls[0].url, '/gateway-api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download')
|
|
assert.equal(calls[0].header.Authorization, `Bearer ${value.accessToken}`)
|
|
})
|
|
|
|
test('native image download accepts a valid temp file when App-Plus omits response headers', async () => {
|
|
prepareSession()
|
|
globalThis.uni.downloadFile = (options) => options.success({
|
|
statusCode: 200,
|
|
tempFilePath: '_doc/uniapp_temp/photo.jpg',
|
|
header: {}
|
|
})
|
|
|
|
assert.equal(
|
|
await overseasDownload('/api/pda/v1/last-leg/photo', { expectedContentTypePrefix: 'image/' }),
|
|
'_doc/uniapp_temp/photo.jpg'
|
|
)
|
|
})
|
|
|
|
test('image download rejects a JSON error envelope returned with HTTP 200', async () => {
|
|
prepareSession()
|
|
globalThis.uni.downloadFile = (options) => options.success({
|
|
statusCode: 200,
|
|
tempFilePath: '/tmp/error.json',
|
|
header: { 'Content-Type': 'application/json', 'X-Request-Id': 'photo-request', 'X-Trace-Id': 'photo-trace' }
|
|
})
|
|
|
|
await assert.rejects(
|
|
overseasDownload('/api/pda/v1/last-leg/photo', { expectedContentTypePrefix: 'image/' }),
|
|
(error) => error.message === '照片服务返回了无效内容,请稍后重试'
|
|
&& error.requestId === 'photo-request' && error.traceId === 'photo-trace'
|
|
)
|
|
})
|
|
|
|
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: '当前柜已分配至其他海外接柜仓,无法在本仓操作', requestId: 'business-request', traceId: 'business-trace' }, header: {} })
|
|
}
|
|
await assert.rejects(overseasRequest('POST', '/pda/locked'), (error) => {
|
|
assert.equal(error.code, 'A0409')
|
|
assert.equal(error.message, '当前柜已分配至其他海外接柜仓,无法在本仓操作')
|
|
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)
|
|
})
|