From f56c68d95241e8f1c4098c5d34d52d5eb6cb958f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 20:13:50 +0800 Subject: [PATCH] test(fleet): isolate recovery actor phone generation --- scripts/fleet-test-env-acceptance.mjs | 19 ++++++++++++++- scripts/fleet-test-env-acceptance.test.mjs | 28 +++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 71cb3c3f5..13e8040da 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -448,7 +448,7 @@ export async function prepareFleetCostActors(context) { await request('PUT', `${base}/roles/permissions`, { roleId: actor.roleId, permissionIds: kind === 'reader' ? [id(list[0].id)] : [] }) const password = `Fleet${randomUUID().replaceAll('-', '')}A9` const user = await request('POST', `${base}/users`, { username, displayName: `${context.marker}-${kind}`, - phone: await context.findAvailablePhone(MODULE), email: `${username}@example.com`, tenantId: '0', + phone: await findFleetActorPhone(context, suffix), email: `${username}@example.com`, tenantId: '0', roleIds: [actor.roleId], password, status: 'ACTIVE' }) if (!id(user?.membershipId) || user.username !== username || id(user.tenantId) !== '0' || user.roleIds?.length !== 1 || id(user.roleIds[0]) !== actor.roleId) throw Error('Fresh cost account ownership mismatch') @@ -469,6 +469,23 @@ export async function prepareFleetCostActors(context) { } } +export async function findFleetActorPhone(context, actorSuffix) { + if (!context.marker || !/^[a-f0-9]{16}$/.test(actorSuffix)) throw Error('Fresh actor phone scope required') + for (let attempt = 0; attempt < 100; attempt++) { + const number = createHash('sha256').update(`${context.marker}:${actorSuffix}:${attempt}`).digest().readUInt32BE(0) + const phone = `18${String(number).padStart(9, '0').slice(-9)}` + const users = await context.request('GET', `/api/platform/v1/iam/users?${new URLSearchParams({ page: '1', size: '20', keyword: phone })}`) + if (!success(users) || !users.requestId || !users.traceId || !Array.isArray(users.body.data?.records) + || !Number.isSafeInteger(Number(users.body.data.total)) || Number(users.body.data.total) !== users.body.data.records.length) + throw Error('Cannot verify platform actor phone availability') + if (users.body.data.records.some(row => row.phone === phone)) continue + const customer = await context.request('GET', `/api/platform/v1/iam/tenants/administrator-phone-availability?phone=${encodeURIComponent(phone)}`) + if (!success(customer) || !customer.requestId || !customer.traceId) throw Error('Cannot verify customer administrator phone availability') + if (customer.body.data === true) return phone + } + throw Error('No available phone in the fresh actor scope; do not replay user creation') +} + export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), onHandoverReady, costActors, resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) { if (terminalReadOnly) return verifyFleetTerminalState(context) diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 84ffe7ff7..fc46aeb5f 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -116,7 +116,7 @@ test('changed source report SHA blocks recovery before creating context or loggi rmSync(temporary, { recursive: true, force: true }) } }) -import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario } from './fleet-test-env-acceptance.mjs' +import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario } from './fleet-test-env-acceptance.mjs' for (const response of [ { http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' }, @@ -357,6 +357,8 @@ for (const failLogin of [false, true]) test(`cost actors create exact isolated r const context = { marker: 'RUN', token: 'admin-secret', createdResources: {}, async findAvailablePhone() { return '19900000000' }, async checkpoint() { saved.push(structuredClone(context.createdResources)) }, async request(method, path, body, token) { + if (method === 'GET' && (path.startsWith('/api/platform/v1/iam/users?') || path.includes('/administrator-phone-availability?'))) + return { http: 200, body: { code: '200', data: path.includes('/administrator-phone-availability?') ? true : { records: [], total: 0 } }, requestId: 'r', traceId: 't' } const pending = saved.at(-1).fleetCostActors.requests.at(-1) assert.equal(pending.status, 'SENDING'); assert.equal(pending.path, path) calls.push({ method, path, body, token }) @@ -388,6 +390,30 @@ for (const failLogin of [false, true]) test(`cost actors create exact isolated r assert.ok(!calls.some(row => row.method === 'DELETE')) }) +test('fresh cost actor phones avoid existing platform users and use an independent namespace under the same original marker', async () => { + const phones = [], ctx = { marker: 'ORIGINAL-RUN', async request(method, path) { + assert.equal(method, 'GET') + let data + if (path.includes('/users?')) { + const phone = new URL('https://test.invalid' + path).searchParams.get('keyword'); phones.push(phone) + data = { records: phones.length === 1 ? [{ phone }] : [], total: phones.length === 1 ? 1 : 0 } + } else data = true + return { http: 200, body: { code: '200', data }, requestId: 'r', traceId: 't' } + } } + const first = await findFleetActorPhone(ctx, 'a'.repeat(16)) + assert.notEqual(first, phones[0]); assert.match(first, /^18\d{9}$/) + const second = await findFleetActorPhone(ctx, 'b'.repeat(16)) + assert.notEqual(first, second) +}) + +test('phone lookup failure stops without any user creation or guessing', async () => { + let calls = 0 + await assert.rejects(findFleetActorPhone({ marker: 'RUN', async request(method) { + assert.equal(method, 'GET'); calls++; return { http: 0 } + } }, 'a'.repeat(16)), /Cannot verify/) + assert.equal(calls, 1) +}) + test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', async () => { const source = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8') const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')")