From c55bd7f4b666987037b90ab03586bcc78d147e59 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 06:21:57 +0800 Subject: [PATCH] fix(test): align order dependency fault preflight with Nacos v3 --- scripts/README.md | 2 +- ...order-submit-dependency-fault-executor.mjs | 2 +- ...-submit-dependency-fault-executor.test.mjs | 28 +++++++++++-- .../order-submit-dependency-fault-runtime.py | 39 ++++++++++++++----- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index f6e535b2f..d3a824463 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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;环境执行仍需本轮写入授权。 diff --git a/scripts/order-submit-dependency-fault-executor.mjs b/scripts/order-submit-dependency-fault-executor.mjs index 442037037..d433d6aa2 100644 --- a/scripts/order-submit-dependency-fault-executor.mjs +++ b/scripts/order-submit-dependency-fault-executor.mjs @@ -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}) diff --git a/scripts/order-submit-dependency-fault-executor.test.mjs b/scripts/order-submit-dependency-fault-executor.test.mjs index 1e204dedf..048c34201 100644 --- a/scripts/order-submit-dependency-fault-executor.test.mjs +++ b/scripts/order-submit-dependency-fault-executor.test.mjs @@ -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) diff --git a/scripts/order-submit-dependency-fault-runtime.py b/scripts/order-submit-dependency-fault-runtime.py index 3823f2d2a..eb4014ec1 100644 --- a/scripts/order-submit-dependency-fault-runtime.py +++ b/scripts/order-submit-dependency-fault-runtime.py @@ -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)}))