fix(test): align order dependency fault preflight with Nacos v3
This commit is contained in:
+1
-1
@@ -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)}))
|
||||
|
||||
Reference in New Issue
Block a user