This commit is contained in:
2026-08-08 10:17:43 +08:00
parent dd8147c535
commit 73bfc5a65c
11 changed files with 3109 additions and 27 deletions
+197
View File
@@ -61,6 +61,8 @@ const report = {
tenantId: null,
membershipId: null,
reviewId: null,
businessLicenseQualificationId: null,
businessLicenseDocumentUrl: null,
productImageFileId: null,
enterpriseAddressId: null,
receivingWarehouseId: null,
@@ -367,6 +369,8 @@ async function merchantAfterApprovalModule() {
const businessLicense = arrayData(approvedQualifications.json.data)
.find((item) => item.qualificationType === 'BUSINESS_LICENSE')
assertPresent(businessLicense?.documentUrl, 'business license documentUrl')
report.resources.businessLicenseQualificationId = businessLicense.id ?? businessLicense.qualificationId
report.resources.businessLicenseDocumentUrl = businessLicense.documentUrl
await apiStep('merchant-session', 'preview approved qualification file', {
method: 'GET', path: apiPath(businessLicense.documentUrl), token: merchantToken, responseType: 'bytes',
}, ({ status, bytes, headers }) => {
@@ -524,6 +528,9 @@ async function rbacAndMemberModule() {
.filter((item) => item.status === 'ACTIVE' && desiredCodes.includes(item.permissionCode))
assertEqual(grantedPermissions.length, desiredCodes.length, 'order permission count')
const permissionIds = grantedPermissions.map((item) => item.id)
const orderListPermissionId = grantedPermissions
.find((item) => item.permissionCode === 'merchant:order:list')?.id
assertPresent(orderListPermissionId, 'merchant:order:list permission id')
const ownerPermissions = await apiStep('menu-permissions', 'load owner effective permissions', {
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: merchantToken,
@@ -773,6 +780,55 @@ async function rbacAndMemberModule() {
assertSetEqual(visibleClientMenus(arrayData(json.data)), ['account:profile:view', 'dashboard:view', 'order:first-leg:list'], 'visible Client menus')
}, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) }))
await apiStep('menu-permissions', 'remove order view permission from assigned role', {
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
body: { roleId, permissionIds: [orderListPermissionId] },
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: [desiredCodes[0]] }))
await softApiStep('menu-permissions', 'permission removal revokes stale member token', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
}, ({ json, status }) => {
assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`)
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
const reducedPermissionLogin = await apiStep('menu-permissions', 'member re-login after permission removal', {
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'member accessToken')
}, () => ({ username: memberUsername }))
memberToken = reducedPermissionLogin.json.data.accessToken
await apiStep('menu-permissions', 'permission removal is reflected after re-login', {
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
}, ({ json }) => {
requireSuccess(json)
assertSetEqual(arrayData(json.data), [desiredCodes[0]], 'reduced member permissions')
}, ({ json }) => ({ backendPermissions: json.data }))
await apiStep('menu-permissions', 'member without view permission cannot read order detail', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
}, ({ json, status }) => {
assertTrue([200, 403].includes(status), `unexpected HTTP status ${status}`)
assertEqual(String(json?.code), 'A0403', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code }))
await apiStep('menu-permissions', 'restore assigned role permissions', {
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
body: { roleId, permissionIds },
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: desiredCodes }))
const restoredPermissionLogin = await apiStep('menu-permissions', 'member re-login after permission restoration', {
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'member accessToken')
}, () => ({ username: memberUsername }))
memberToken = restoredPermissionLogin.json.data.accessToken
await apiStep('menu-permissions', 'restored permission allows order detail again', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.order?.id), String(report.resources.orderIds[0]), 'order id')
}, ({ json }) => ({ orderId: json.data.order.id }))
const editedMemberBody = {
displayName: `已编辑订单成员-${marker}`.slice(0, 64),
phone: memberBody.phone,
@@ -811,6 +867,12 @@ async function rbacAndMemberModule() {
requireSuccess(json)
assertEqual(json.data?.status, 'DISABLED', 'member status')
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status }))
await softApiStep('rbac-member', 'disabled member stale token is rejected', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
}, ({ json, status }) => {
assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`)
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
}, ({ json }) => ({ membershipId, rejectionCode: json?.code }))
await apiStep('rbac-member', 'disabled member login is rejected', {
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: resetMemberPassword },
}, ({ json, status }) => {
@@ -824,6 +886,17 @@ async function rbacAndMemberModule() {
assertEqual(json.data?.status, 'ACTIVE', 'member status')
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status }))
const enabledMemberLogin = await apiStep('rbac-member', 'member re-login after account is enabled', {
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: resetMemberPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'member accessToken')
}, () => ({ username: memberUsername }))
memberToken = enabledMemberLogin.json.data.accessToken
await apiStep('rbac-member', 're-enabled member can list orders before roles are cleared', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
}, ({ json }) => requireSuccess(json), ({ json }) => ({ total: json.data?.total }))
await apiStep('rbac-member', 'clear enterprise member roles', {
method: 'PUT', path: '/api/merchant/v1/iam/members/roles', token: merchantToken,
body: { membershipId, roleIds: [] },
@@ -882,6 +955,33 @@ async function orderLifecycleModule() {
assertEqual(String(json?.code), 'A0400', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
const emptyImageForm = new FormData()
emptyImageForm.append('file', new Blob([], { type: 'image/png' }), `empty-product-${marker}.png`)
await softApiStep('order-product-image', 'empty product image is rejected', {
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: emptyImageForm,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0400', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
const forgedImageForm = new FormData()
forgedImageForm.append('file', new Blob(['not-a-real-png'], { type: 'image/png' }), `forged-product-${marker}.png`)
await softApiStep('order-product-image', 'forged product image signature is rejected', {
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: forgedImageForm,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0400', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
const oversizedImageForm = new FormData()
oversizedImageForm.append('file', new Blob([Buffer.alloc(5 * 1024 * 1024 + 1)], { type: 'image/png' }), `oversized-product-${marker}.png`)
await softApiStep('order-product-image', 'product image over 5MB is rejected', {
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: oversizedImageForm,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0400', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
await apiStep('order-lifecycle', 'download own product image', {
method: 'GET', path: `/api/merchant/v1/files/product-images/${productImageFileId}`, token: merchantToken,
responseType: 'bytes',
@@ -925,6 +1025,18 @@ async function orderLifecycleModule() {
name: 'invalid product image file ID is rejected',
mutate(value) { value.cargoes[0].productImageFileId = -1 },
},
{
name: 'zero package count weight and volume are rejected',
mutate(value) {
value.cargoes[0].packageCount = 0
value.cargoes[0].grossWeightKg = 0
value.cargoes[0].volumeCbm = 0
},
},
{
name: 'customer remark over 1000 characters is rejected',
mutate(value) { value.customerRemark = 'x'.repeat(1001) },
},
]
for (const testCase of invalidOrderCases) {
const invalidPayload = structuredClone(payload)
@@ -937,6 +1049,19 @@ async function orderLifecycleModule() {
assertEqual(String(json?.code), 'A0422', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
}
await softApiStep('order-validation', 'missing Idempotency-Key is rejected', {
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, body: payload,
}, ({ json, status }) => {
assertTrue([200, 400].includes(status), `unexpected HTTP status ${status}`)
assertEqual(String(json?.code), 'A0400', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
await softApiStep('order-validation', 'Idempotency-Key over 128 characters is rejected', {
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
headers: { 'Idempotency-Key': 'x'.repeat(129) }, body: payload,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0422', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
const createKey = newIdempotencyKey('approved-create')
const created = await apiStep('order-lifecycle', 'create valid draft after approval', {
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
@@ -1044,6 +1169,41 @@ async function orderLifecycleModule() {
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'submitted status-filtered order')
}, ({ json }) => ({ total: json.data?.total, orderId, status: 'SUBMITTED' }))
await apiStep('order-filters', 'warehouse channel and destination filters contain submitted order', {
method: 'GET',
path: `/api/merchant/v1/orders/first-leg?page=1&size=50&receivingWarehouseId=${payload.receivingWarehouseId}`
+ `&channelName=${encodeURIComponent(payload.channelName)}&destinationType=${payload.destinationType}`
+ `&destinationWarehouseId=${payload.destinationWarehouseId}`,
token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'combined-filtered order')
}, ({ json }) => ({ total: json.data?.total, orderId }))
await apiStep('order-filters', 'sender phone filter contains submitted order', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact=${encodeURIComponent(legalPhone)}`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'sender-phone-filtered order')
}, ({ json }) => ({ total: json.data?.total, orderId }))
await apiStep('order-filters', 'unmatched keyword returns an empty order page', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${randomUUID()}`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(pageRecords(json.data).length, 0, 'unmatched order count')
}, ({ json }) => ({ total: json.data?.total }))
await softApiStep('order-validation', 'invalid order status filter is rejected', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20&status=UNKNOWN', token: merchantToken,
}, ({ json, status }) => {
assertTrue([200, 400].includes(status), `unexpected HTTP status ${status}`)
assertEqual(String(json?.code), 'A0400', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
await apiStep('order-boundary', 'unknown order detail is rejected', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg/1', token: merchantToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0404', 'business code')
}, ({ json }) => ({ orderId: 1, rejectionCode: json?.code }))
await softApiStep('order-boundary', 'submitted order cannot be edited by client', {
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload,
}, ({ json, status }) => {
@@ -1142,9 +1302,37 @@ async function isolationModule() {
assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`)
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
}, ({ json }) => ({ fileId: report.resources.productImageFileId, rejectionCode: json?.code }))
await softApiStep('isolation', 'cross-client member detail access is rejected', {
method: 'GET', path: `/api/merchant/v1/iam/members/${report.resources.testMembershipId}`, token: existingClientToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
}, ({ json }) => ({ membershipId: report.resources.testMembershipId, rejectionCode: json?.code }))
await apiStep('isolation', 'existing client address list excludes new enterprise address', {
method: 'GET', path: '/api/merchant/v1/iam/tenant/addresses', token: existingClientToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(!arrayData(json.data).some((item) => String(item.id) === String(report.resources.enterpriseAddressId)),
'cross-client address must not be visible')
}, ({ json }) => ({ addressCount: arrayData(json.data).length, excludedAddressId: report.resources.enterpriseAddressId }))
await apiStep('isolation', 'existing client qualification list excludes new enterprise qualification', {
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: existingClientToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(!arrayData(json.data).some((item) => String(item.id ?? item.qualificationId)
=== String(report.resources.businessLicenseQualificationId)), 'cross-client qualification must not be visible')
}, ({ json }) => ({ qualificationCount: arrayData(json.data).length,
excludedQualificationId: report.resources.businessLicenseQualificationId }))
await softApiStep('isolation', 'cross-client qualification file access is rejected', {
method: 'GET', path: apiPath(report.resources.businessLicenseDocumentUrl), token: existingClientToken,
}, ({ json, status }) => {
assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`)
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
}, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code }))
}
async function accountSecurityModule() {
const consumedRefreshToken = merchantRefreshToken
const rotated = await apiStep('account-security', 'rotate refresh token', {
method: 'POST', path: '/api/auth/refresh', body: { refreshToken: merchantRefreshToken },
}, ({ json }) => {
@@ -1154,6 +1342,12 @@ async function accountSecurityModule() {
}, () => ({ rotated: true }))
merchantToken = rotated.json.data.accessToken
merchantRefreshToken = rotated.json.data.refreshToken
await apiStep('account-security', 'consumed refresh token cannot be replayed', {
method: 'POST', path: '/api/auth/refresh', body: { refreshToken: consumedRefreshToken },
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0401', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code }))
temporaryOwnerPassword = `Temporary${randomUUID().replaceAll('-', '').slice(0, 14)}6!`
await apiStep('account-security', 'change owner password to temporary value', {
@@ -1204,6 +1398,9 @@ async function accountSecurityModule() {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0401', 'business code')
}, ({ json }) => ({ rejectionCode: json.code }))
await apiStep('account-security', 'repeated logout remains idempotent', {
method: 'POST', path: '/api/auth/logout', body: { refreshToken: loggedOutRefreshToken },
}, ({ json }) => requireSuccess(json), () => ({ repeated: true }))
}
async function recoverOwnerPassword() {