fix(test): align order dependency fault preflight with Nacos v3

This commit is contained in:
2026-10-03 06:21:57 +08:00
parent bde1cd532e
commit c55bd7f4b6
4 changed files with 57 additions and 14 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。
`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos 配置;`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。
`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。
`event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id` 或 `--container-booking-id`;柜次选择精确匹配 payload.sourceContainerBookingId,用于海外卸柜回写,不接受混合选择条件。`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。同一选择范围内锁定首个事件后不改选其他事件 ID,原 ID 改摘要或载荷直接拒绝。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。
`admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。
@@ -248,4 +248,4 @@ async function main(argv) {
if(!result.success)process.exitCode=1
}
if(process.argv[1] && import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(()=>{console.error('ORDER_DEPENDENCY_ARGUMENT_OR_CONFIGURATION_ERROR');process.exitCode=1})
if(process.argv[1] && import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(error=>{console.error(/^[A-Z_0-9]{1,90}$/.test(error.message)?error.message:'ORDER_DEPENDENCY_ARGUMENT_OR_CONFIGURATION_ERROR');process.exitCode=1})
@@ -13,6 +13,21 @@ function fixture() {
}
const env={CHONGCHONG_ENV:'test',JENKINS_URL:'https://offline.invalid/',JENKINS_USER:'offline',JENKINS_API_TOKEN:'never-print-this',FBA_CORE_JENKINS_AGENT:'test-agent'}
test('runtime classifies dependency HTTP stages without exposing tokens, URLs or response bodies',()=>{
const script=String.raw`
import importlib.util,sys,urllib.error
spec=importlib.util.spec_from_file_location('diagnostic_runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
for path,expected in [('/nacos/v3/auth/user/login','NACOS_LOGIN_404'),('/nacos/v3/admin/cs/config','NACOS_CONFIG_404'),('/internal/finance/wallet','DEPENDENCY_HTTP_404')]:
error=urllib.error.HTTPError('http://private.invalid'+path+'?accessToken=secret',404,'private response secret',{},None)
assert m.safe_error_code(error)==expected
assert m.safe_error_code(urllib.error.URLError('http://private.invalid?secret'))=='DEPENDENCY_TRANSPORT_FAILED'
assert m.safe_error_code(RuntimeError('KNOWN_FAILURE'))=='KNOWN_FAILURE'
assert m.safe_error_code(ValueError('secret in response'))=='ORDER_DEPENDENCY_ACTION_FAILED'
`
const result=spawnSync(process.env.PYTHON||'python',['-c',script,fileURLToPath(new URL('./order-submit-dependency-fault-runtime.py',import.meta.url))],{encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}})
assert.equal(result.status,0,result.stderr||result.error?.message)
})
test('renderer binds TEST scope and emits no Jenkins credential; ambiguous remote mutation is never retried',async()=>{
const {plan}=fixture(),scope=scopeFor(plan)
assert.ok(!renderOrderFaultAction('status',scope,env.FBA_CORE_JENKINS_AGENT).includes(env.JENKINS_API_TOKEN))
@@ -167,7 +182,7 @@ class RuntimeTests(unittest.TestCase):
f,r,s,saved,controls=self.fixture()
f.current['Config']['Env']+=['NACOS_SERVER_ADDR=host.docker.internal:8848','NACOS_USERNAME=offline-user',
'NACOS_PASSWORD=offline-password','NACOS_GROUP=BAIYUE','NACOS_NAMESPACE=test-only']
routing={'nacos':{'baseUrl':'http://127.0.0.1:8848','loginPath':'/nacos/v1/auth/users/login','configPath':'/nacos/v1/cs/configs'}}
routing={'nacos':{'baseUrl':'http://127.0.0.1:8848','loginPath':'/nacos/v3/auth/user/login','configPath':'/nacos/v3/admin/cs/config'}}
calls=[]
class Response:
status=200
@@ -177,12 +192,19 @@ class RuntimeTests(unittest.TestCase):
def read(self):return self.value
def read(req,timeout):
calls.append(req)
return Response(b'{"accessToken":"offline-token"}' if not isinstance(req,str) else b'spring:\n test: only\n')
if req.data is not None:return Response(b'{"accessToken":"offline-token"}')
query=m.urllib.parse.parse_qs(m.urllib.parse.urlsplit(req.full_url).query)
self.assertEqual(req.get_method(),'GET');self.assertEqual(req.get_header('Authorization'),'Bearer offline-token')
self.assertNotIn('offline-token',req.full_url)
self.assertEqual(query['namespaceId'],['test-only']);self.assertEqual(query['groupName'],['BAIYUE'])
return Response(json.dumps({'code':0,'data':{'dataId':query['dataId'][0],'namespaceId':'test-only','groupName':'BAIYUE','content':'spring:\n test: only\n'}}).encode())
with patch.object(m,'http_open',side_effect=read):
value=m.Runtime.config_fingerprint(r,f.current,routing)
self.assertEqual(len(value),64);self.assertEqual(len(calls),3)
self.assertIn(b'password=offline-password',calls[0].data)
self.assertTrue(all('accessToken=offline-token' in url for url in calls[1:]))
self.assertTrue(all('/nacos/v3/admin/cs/config?' in req.full_url for req in calls[1:]))
with patch.object(m,'http_open',return_value=Response(b'{"accessToken":"offline-token","code":0,"data":{"content":"secret config","dataId":"foreign"}}')):
with self.assertRaisesRegex(RuntimeError,'NACOS_SOURCE_IDENTITY_MISMATCH'):m.Runtime.config_fingerprint(r,f.current,routing)
routing['nacos']['baseUrl']='http://other.invalid:8848'
with self.assertRaisesRegex(RuntimeError,'EXPLICIT_NACOS'):m.Runtime.config_fingerprint(r,f.current,routing)
self.assertEqual(len(calls),3)
@@ -19,6 +19,7 @@ import sys
import time
import urllib.parse
import urllib.request
import urllib.error
from pathlib import Path
ORDER = 'firstleg-test-order'
@@ -139,7 +140,7 @@ class Runtime:
return results
def config_fingerprint(self, container, routing):
"""Read the two actual Nacos imports using explicitly reviewed v1 endpoints.
"""Read the two actual Nacos imports using the current v3 Admin endpoints.
No endpoint guessing/fallback and no Nacos write operation is supported.
"""
env = self.tools['env_of'](container); config = routing.get('nacos', {})
@@ -150,8 +151,8 @@ class Runtime:
network.get('Gateway') for network in container['NetworkSettings']['Networks'].values()}
if (base.scheme not in ('http', 'https') or base.hostname not in allowed_hosts or base.port != configured.port
or base.path not in ('', '/') or base.username or base.password or base.query or base.fragment
or config.get('loginPath') not in ('/nacos/v1/auth/users/login', '/nacos/v1/auth/login')
or config.get('configPath') != '/nacos/v1/cs/configs'):
or config.get('loginPath') != '/nacos/v3/auth/user/login'
or config.get('configPath') != '/nacos/v3/admin/cs/config'):
raise RuntimeError('EXPLICIT_NACOS_READ_ENDPOINTS_REQUIRED')
if any(not env.get(key) for key in ['NACOS_USERNAME', 'NACOS_PASSWORD', 'NACOS_GROUP', 'NACOS_NAMESPACE']):
raise RuntimeError('NACOS_READ_IDENTITY_UNAVAILABLE')
@@ -163,11 +164,20 @@ class Runtime:
if not isinstance(token, str) or not token: raise RuntimeError('NACOS_READ_LOGIN_FAILED')
fingerprints = {}
for data_id in ('baiyue-common.yml', 'order-service.yml'):
query = urllib.parse.urlencode({'dataId': data_id, 'group': env['NACOS_GROUP'], 'tenant': env['NACOS_NAMESPACE'], 'accessToken': token})
with http_open(origin + config['configPath'] + '?' + query, timeout=8) as response:
query = urllib.parse.urlencode({'dataId': data_id, 'groupName': env['NACOS_GROUP'], 'namespaceId': env['NACOS_NAMESPACE']})
request = urllib.request.Request(origin + config['configPath'] + '?' + query,
headers={'Authorization': 'Bearer ' + token})
with http_open(request, timeout=8) as response:
if response.status != 200: raise RuntimeError('NACOS_SOURCE_READ_FAILED')
content = response.read()
if not content or content.lstrip().startswith((b'{', b'<')): raise RuntimeError('NACOS_SOURCE_NOT_YAML')
value = json.load(response)
data = value.get('data')
if value.get('code') != 0 or not isinstance(data, dict): raise RuntimeError('NACOS_SOURCE_RESPONSE_INVALID')
if (data.get('dataId') != data_id or data.get('groupName') != env['NACOS_GROUP']
or data.get('namespaceId') != env['NACOS_NAMESPACE']): raise RuntimeError('NACOS_SOURCE_IDENTITY_MISMATCH')
content = data.get('content')
if not isinstance(content, str) or not content.strip() or content.lstrip().startswith(('{', '<')):
raise RuntimeError('NACOS_SOURCE_NOT_YAML')
content = content.encode('utf-8')
fingerprints[data_id] = hashlib.sha256(content).hexdigest()
return digest({'imports': fingerprints, 'namespace': env['NACOS_NAMESPACE'], 'group': env['NACOS_GROUP']})
@@ -436,10 +446,21 @@ def main(config):
finally: os.close(fd)
def safe_error_code(error):
message = str(error)
if isinstance(error, urllib.error.HTTPError):
path = urllib.parse.urlsplit(error.geturl()).path
stage = 'NACOS_CONFIG' if path == '/nacos/v3/admin/cs/config' else (
'NACOS_LOGIN' if path == '/nacos/v3/auth/user/login' else 'DEPENDENCY_HTTP')
return stage + '_' + str(error.code)
if isinstance(error, urllib.error.URLError):
return 'DEPENDENCY_TRANSPORT_FAILED'
return message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'ORDER_DEPENDENCY_ACTION_FAILED'
if __name__ == '__main__':
try:
result = main(json.loads(base64.b64decode(sys.argv[1])))
print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': True, 'data': result}))
except Exception as error:
message = str(error)
print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': False, 'error': message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'ORDER_DEPENDENCY_ACTION_FAILED'}))
print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': False, 'error': safe_error_code(error)}))