Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

357 linhas
25 KiB

  1. import assert from 'node:assert/strict'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import crypto from 'node:crypto'
  5. import { fileURLToPath } from 'node:url'
  6. import { api, localCredentials } from './review-api.mjs'
  7. // Development integration test. Creates isolated students, tasks and runs;
  8. // credentials stay in memory and no hardware adapter is contacted.
  9. // node tools/training-modes-api-live.mjs --physical 163:215 --confrontation 164:216
  10. const args = process.argv.slice(2)
  11. const parameter = name => args[args.indexOf(name) + 1]
  12. function reference(name) {
  13. if (!args.includes(name) || !/^\d+:\d+$/.test(parameter(name) || '')) throw new Error(`Required: ${name} projectId:publishedVersionId`)
  14. const [projectId, versionId] = parameter(name).split(':')
  15. return { projectId, versionId }
  16. }
  17. const waitForConfrontation = args.includes('--wait-confrontation')
  18. const references = { PHYSICAL: reference('--physical'), CONFRONTATION: waitForConfrontation ? null : reference('--confrontation') }
  19. const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/training-modes-20260905')
  20. const readyFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../.codex-tmp/training-modes-confrontation-ready.json')
  21. fs.mkdirSync(reportDir, { recursive: true })
  22. const evidencePath = path.join(reportDir, 'api-evidence.json')
  23. const suffix = Date.now().toString(36).toUpperCase()
  24. const prefix = `UTE2E-MODES-${suffix}`
  25. const evidence = {
  26. title: '实装人工事件与红蓝对抗正式 API 闭环', status: 'RUNNING',
  27. startedAt: new Date().toISOString(), baseURL: 'http://127.0.0.1:6180',
  28. method: '真实 Auth / Gateway / Tran API;新建隔离学生与开发测试任务;不连接设备、视觉推理或外部通知。',
  29. references, checks: [], assignments: [], runs: [], actors: [], requests: [],
  30. limitations: [
  31. '实装事件均为本次自动化提交的 MANUAL 人工确认或明确的权限拒绝测试,不代表真实设备、PLC、摄像机或视觉模型联调。',
  32. '教学运行结果来自正式 API;默认实装离线回放和默认对抗本地工作台的显示另行记录,不混同为服务端运行闭环。',
  33. '对抗按四名隔离学员的独立会话依次操作,检查服务端截止时间已生效;未等待整局自然超时,也不代表多人同时操作的压力或网络同步测试。',
  34. '测试账号使用本次随机密码,密码与会话仅在运行内存中;报告只保留用户 ID、队伍和岗位以便追溯。',
  35. ],
  36. }
  37. const credentials = localCredentials()
  38. const ephemeralPassword = `Tm!${crypto.randomBytes(8).toString('hex')}`
  39. const secretValues = [credentials.username, credentials.password, ephemeralPassword]
  40. const sessions = []
  41. let admin
  42. const save = () => fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`)
  43. function safeError(error) {
  44. let result = error instanceof Error ? error.message : String(error)
  45. for (const value of secretValues) if (value) result = result.split(value).join('[redacted]')
  46. return result.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]').replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[redacted]')
  47. }
  48. async function call(url, actor, body, method) {
  49. const result = await api(url, typeof actor === 'string' ? actor : actor?.token, body, method)
  50. if (url.startsWith('/api/tran/v1/teaching/')) evidence.requests.push({ method: method || (body === undefined ? 'GET' : 'POST'), path: url, status: result.status })
  51. return result
  52. }
  53. async function request(url, actor, body, method) {
  54. const result = await call(url, actor, body, method)
  55. if (result.status !== 200) throw new Error(`${method || (body === undefined ? 'GET' : 'POST')} ${url}: HTTP ${result.status}; ${safeError(result.message || '')}`)
  56. return result.data
  57. }
  58. async function denied(url, actor, body, expected, method) {
  59. const result = await call(url, actor, body, method)
  60. assert.ok([expected].flat().includes(result.status), `${method || 'POST'} ${url}: expected ${expected}, received ${result.status}: ${safeError(result.message || '')}`)
  61. return { status: result.status, message: safeError(result.message || '') }
  62. }
  63. function passed(title, details) { evidence.checks.push({ title, status: 'PASS', details }); save() }
  64. async function section(title, fn) {
  65. try { await fn() } catch (error) {
  66. evidence.checks.push({ title, status: 'FAIL', details: safeError(error) }); save()
  67. }
  68. }
  69. const uuid = () => crypto.randomUUID()
  70. const teaching = relative => `/api/tran/v1/teaching${relative}`
  71. const codeOf = step => String(step.stepCode || step.code || step.id || '')
  72. const actionOf = step => String(step.actionCode || step.action?.code || '')
  73. const slimRun = run => ({ id: run.id, assignmentId: run.assignmentId, status: run.status, version: run.version, progressPercent: run.progressPercent, currentStepCode: run.currentStepCode, score: run.score, passed: run.passed })
  74. const slimFacts = facts => facts.map(fact => ({ stepCode: fact.stepCode, status: fact.statusCode, targetProgressPercent: fact.targetProgressPercent, attemptCount: fact.attemptCount, failureCount: fact.failureCount, evidenceSourceCode: fact.evidenceSourceCode }))
  75. const rowsOf = value => value?.records || value || []
  76. const runOf = (id, actor) => request(teaching(`/runs/${id}`), actor)
  77. const factsOf = (id, actor) => request(teaching(`/runs/${id}/steps`), actor)
  78. function rememberRun(run, extra = {}) {
  79. const entry = { ...slimRun(run), ...extra }
  80. const index = evidence.runs.findIndex(item => item.id === run.id)
  81. if (index >= 0) evidence.runs[index] = { ...evidence.runs[index], ...entry }
  82. else evidence.runs.push(entry)
  83. save()
  84. }
  85. async function loginAdmin() {
  86. const login = await request('/api/auth/v1/auth/login', null, { ...credentials, roleCode: 'admin', rememberMe: false })
  87. admin = { token: login.accessToken }
  88. secretValues.push(admin.token)
  89. sessions.push(admin)
  90. }
  91. async function createActors() {
  92. const roles = rowsOf(await request('/api/auth/v1/roles?size=100', admin))
  93. const role = roles.find(item => item.code === 'student')
  94. assert.ok(role?.id, 'student role must exist')
  95. const context = await request('/api/auth/v1/auth/login-context', admin)
  96. const department = context.departments?.find(item => item.children?.length)?.children?.[0] || context.departments?.[0]
  97. assert.ok(department?.id, 'An active department must exist')
  98. const actors = []
  99. for (const team of ['RED', 'BLUE']) for (const position of ['COMMANDER', 'OPERATOR']) {
  100. const username = `tm_${suffix.toLowerCase()}_${team[0].toLowerCase()}${position[0].toLowerCase()}`
  101. secretValues.push(username)
  102. const created = await request('/api/auth/v1/users', admin, {
  103. username, displayName: `模式实测-${suffix}-${team}-${position}`,
  104. departmentId: department.id, roleIds: [role.id], defaultRoleId: role.id,
  105. enabled: true, password: ephemeralPassword, remark: `${prefix}; isolated development API test; no hardware`,
  106. })
  107. const user = created.user || created
  108. assert.ok(user.id, 'New student must have an id')
  109. evidence.actors.push({ userId: String(user.id), team, position, departmentId: String(department.id) })
  110. save()
  111. const logged = await request('/api/auth/v1/auth/login', null, { userId: user.id, roleCode: 'student', password: ephemeralPassword, rememberMe: false })
  112. const actor = { id: String(user.id), token: logged.accessToken, team, position }
  113. secretValues.push(actor.token)
  114. sessions.push(actor)
  115. actors.push(actor)
  116. }
  117. return actors
  118. }
  119. async function contentReference(channel) {
  120. const reference = references[channel]
  121. const detail = await request(`/api/tran/v1/content/projects/${reference.projectId}`, admin)
  122. const project = detail.project || detail
  123. assert.equal(project.status, 'PUBLISHED')
  124. assert.equal(String(project.publishedVersionId), reference.versionId)
  125. const version = await request(`/api/tran/v1/content/projects/${reference.projectId}/versions/${reference.versionId}`, admin)
  126. const content = version.content || version.version?.content
  127. assert.ok(content && Array.isArray(content.steps) && content.steps.length > 1, `${channel}: published steps required`)
  128. return { reference, content, sceneDependencies: (version.dependencies || []).filter(dependency => dependency.relationType === 'TRAINING_SCENE') }
  129. }
  130. async function verifyFrozenSnapshots(profiles) {
  131. assert.equal(evidence.assignments.length, 2, 'Both isolated teaching assignments must exist')
  132. const snapshots = []
  133. for (const assignment of evidence.assignments) {
  134. const profile = profiles[assignment.channel]
  135. const current = await request(teaching(`/assignments/${assignment.id}`), admin)
  136. assert.equal(String(current.contentProjectId), profile.reference.projectId)
  137. assert.equal(String(current.contentVersionId), profile.reference.versionId)
  138. assert.equal(current.definitionSnapshot?.trainingMode, assignment.channel)
  139. assert.deepEqual(current.definitionSnapshot.steps.map(codeOf), profile.content.steps.map(codeOf))
  140. assert.equal(profile.sceneDependencies.length, 1)
  141. const scene = current.definitionSnapshot.teachingScene
  142. const expected = profile.sceneDependencies[0]
  143. assert.equal(String(scene.sourceProjectId), String(expected.targetProjectId))
  144. assert.equal(String(scene.sourceVersionId), String(expected.targetVersionId))
  145. const models = scene.objects.filter(object => object.targetProjectId).map(object => ({ projectId: object.targetProjectId, versionId: object.targetVersionId, assetCode: object.assetCode }))
  146. assert.ok(models.length && models.every(model => model.versionId && model.assetCode))
  147. const frozenScene = { projectId: scene.sourceProjectId, versionId: scene.sourceVersionId, models }
  148. assignment.frozenScene = frozenScene
  149. snapshots.push({ assignmentId: assignment.id, channel: assignment.channel, content: profile.reference, frozenScene })
  150. }
  151. passed('正式任务快照固定训练、场景与模型发布版本', { snapshots })
  152. }
  153. async function waitForConfrontationPublication() {
  154. evidence.waitingFor = 'CONFRONTATION published project/version reference from the UI test owner'
  155. save()
  156. console.log(JSON.stringify({ phase: 'WAITING_FOR_CONFRONTATION', physicalChecks: evidence.checks.length, readyFile }))
  157. const until = Date.now() + 30 * 60 * 1000
  158. while (Date.now() < until) {
  159. if (fs.existsSync(readyFile) && fs.statSync(readyFile).mtimeMs >= Date.parse(evidence.startedAt)) {
  160. const ready = JSON.parse(fs.readFileSync(readyFile, 'utf8').replace(/^\uFEFF/, ''))
  161. assert.match(String(ready.projectId), /^\d+$/)
  162. assert.match(String(ready.versionId), /^\d+$/)
  163. references.CONFRONTATION = { projectId: String(ready.projectId), versionId: String(ready.versionId) }
  164. delete evidence.waitingFor
  165. save()
  166. return
  167. }
  168. await new Promise(resolve => setTimeout(resolve, 1500))
  169. }
  170. throw new Error('Timed out waiting for the published CONFRONTATION UI reference; PHYSICAL evidence and all retained IDs are preserved')
  171. }
  172. async function newAssignment(channel, profile, actors) {
  173. const members = channel === 'PHYSICAL'
  174. ? [{ userId: actors[0].id, memberType: 'LEARNER', teamCode: 'NEUTRAL', positionCode: 'OPERATOR', sortOrder: 0 }]
  175. : actors.map((actor, index) => ({ userId: actor.id, memberType: 'LEARNER', teamCode: actor.team, positionCode: actor.position, sortOrder: index }))
  176. const created = await request(teaching('/assignments'), admin, {
  177. code: `${prefix}-${channel}`, name: `开发实测-${channel === 'PHYSICAL' ? '实装人工事件' : '红蓝岗位对抗'}-${suffix}`,
  178. description: '开发环境正式 API 流程验证;人工/模拟事件,不代表设备联调或实际技能评定。',
  179. channel, assignmentKind: 'TRAINING', executionMode: 'PRACTICE', audienceType: 'ASSIGNED',
  180. collaborationMode: channel === 'PHYSICAL' ? 'INDIVIDUAL' : 'TEAM', digitalHumanAllowed: false,
  181. contentProjectId: profile.reference.projectId, contentVersionId: profile.reference.versionId, members,
  182. })
  183. evidence.assignments.push({ id: created.id, code: created.code, channel, contentProjectId: profile.reference.projectId, contentVersionId: profile.reference.versionId, status: created.status })
  184. save()
  185. const published = await request(teaching(`/assignments/${created.id}/publish`), admin, { version: created.version })
  186. evidence.assignments.at(-1).status = published.status
  187. evidence.assignments.at(-1).version = published.version
  188. save()
  189. return published
  190. }
  191. async function accept(assignment, actor, physical = false) {
  192. const run = await request(teaching(`/assignments/${assignment.id}/accept`), actor, {
  193. assignmentVersion: assignment.version, teamCode: physical ? 'NEUTRAL' : actor.team,
  194. positionCode: physical ? 'OPERATOR' : actor.position,
  195. })
  196. rememberRun(run, { channel: assignment.channel, team: physical ? 'NEUTRAL' : actor.team })
  197. return run
  198. }
  199. async function startWithReplay(run, actor) {
  200. const body = { version: run.version, commandId: uuid() }
  201. const started = await request(teaching(`/runs/${run.id}/start`), actor, body)
  202. const replay = await request(teaching(`/runs/${run.id}/start`), actor, body)
  203. assert.equal(replay.version, started.version)
  204. const facts = await factsOf(run.id, actor)
  205. assert.equal(facts[0].attemptCount, 1)
  206. assert.equal(facts[0].statusCode, 'IN_PROGRESS')
  207. passed('开始命令重放不会重复激活或递增次数', { runId: run.id, version: started.version, attemptCount: facts[0].attemptCount })
  208. return started
  209. }
  210. async function submitAndReview(run, actor, channel) {
  211. const body = { version: run.version, commandId: uuid(), summary: '开发自动化合同测试完成;不代表真实技能训练。', evidence: { testOnly: true, source: 'development-api-contract' }, payload: {} }
  212. const submitted = await request(teaching(`/runs/${run.id}/submit`), actor, body)
  213. const replay = await request(teaching(`/runs/${run.id}/submit`), actor, body)
  214. assert.equal(submitted.status, 'SUBMITTED')
  215. assert.equal(replay.version, submitted.version)
  216. const reviewBody = { version: submitted.version, commandId: uuid(), score: 100, passed: true, feedback: '仅测试正式 API 评定留痕,不代表实际维修能力。', rubric: { testOnly: true } }
  217. const reviewed = await request(teaching(`/runs/${run.id}/review`), admin, reviewBody)
  218. const reviewReplay = await request(teaching(`/runs/${run.id}/review`), admin, reviewBody)
  219. assert.equal(reviewed.status, 'REVIEWED')
  220. assert.equal(reviewReplay.version, reviewed.version)
  221. const facts = await factsOf(run.id, admin)
  222. assert.ok(facts.every(fact => fact.statusCode === 'COMPLETED'))
  223. rememberRun(reviewed, { channel, stepFacts: slimFacts(facts) })
  224. passed(`${channel} 提交、教员评定与结果回读`, { run: slimRun(reviewed), submitReplay: true, reviewReplay: true, facts: slimFacts(facts) })
  225. return reviewed
  226. }
  227. async function physicalFlow(profile, actors) {
  228. const steps = profile.content.steps
  229. assert.ok(steps.every(step => step.physicalEventRule?.source === 'MANUAL'), 'This test only drives explicitly configured MANUAL rules')
  230. assert.deepEqual(steps.map(step => step.physicalEventRule.progressPercent), steps.map((_, index) => Math.round((index + 1) * 100 / steps.length)))
  231. const assignment = await newAssignment('PHYSICAL', profile, actors)
  232. const actor = actors[0]
  233. let run = await accept(assignment, actor, true)
  234. run = await startWithReplay(run, actor)
  235. passed('实装任务固定发布版本、分配学员并开始', { assignmentId: assignment.id, run: slimRun(run), content: profile.reference, rules: steps.map(step => ({ stepCode: codeOf(step), source: step.physicalEventRule.source, eventType: step.physicalEventRule.eventType, progressPercent: step.physicalEventRule.progressPercent })) })
  236. const eventBody = (step, extra = {}) => ({ eventId: `MANUAL-${uuid()}`, runVersion: run.version, source: 'MANUAL', eventType: step.physicalEventRule.eventType, stepCode: codeOf(step), manualConfirmed: true, occurredAt: Math.floor(Date.now() / 1000), publicPayload: { testOnly: true, evidenceKind: 'api-contract-simulation' }, ...extra })
  237. const forbiddenProgress = await denied(teaching(`/runs/${run.id}/progress`), actor, { version: run.version, commandId: uuid(), completedStepCode: codeOf(steps[0]), actionCode: actionOf(steps[0]) }, 403, 'PUT')
  238. const prematureSubmit = await denied(teaching(`/runs/${run.id}/submit`), actor, { version: run.version, commandId: uuid(), summary: 'negative test' }, 409)
  239. const skipped = await denied(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[1]), 409)
  240. const untrustedDevice = await denied(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[0], { source: 'DEVICE' }), 403)
  241. const managerManual = await denied(teaching(`/runs/${run.id}/physical-events`), admin, eventBody(steps[0]), 403)
  242. assert.equal((await runOf(run.id, actor)).progressPercent, 0)
  243. passed('实装拒绝普通进度接口、抢跑、提前提交及错误事件身份', { runId: run.id, forbiddenProgress, prematureSubmit, skipped, untrustedDevice, managerManual, progressUnchanged: 0 })
  244. const failed = await request(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[0], { manualConfirmed: false }))
  245. assert.equal(failed.outcome, 'UNMATCHED')
  246. assert.equal(failed.progressApplied, false)
  247. let facts = await factsOf(run.id, actor)
  248. assert.equal(facts[0].statusCode, 'FAILED')
  249. assert.equal(facts[0].failureCount, 1)
  250. run = await runOf(run.id, actor)
  251. const beforeRetry = await denied(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[0]), 409)
  252. run = await request(teaching(`/runs/${run.id}/start`), actor, { version: run.version, commandId: uuid() })
  253. facts = await factsOf(run.id, actor)
  254. assert.equal(facts[0].attemptCount, 2)
  255. passed('实装不匹配事件失败,必须重试后才可继续', { runId: run.id, outcome: failed.outcome, beforeRetry, facts: slimFacts(facts) })
  256. const progression = []
  257. for (const [index, step] of steps.entries()) {
  258. const body = eventBody(step)
  259. const result = await request(teaching(`/runs/${run.id}/physical-events`), actor, body)
  260. assert.equal(result.outcome, 'MATCHED')
  261. assert.equal(result.progressApplied, true)
  262. const after = await runOf(run.id, actor)
  263. assert.equal(after.progressPercent, Math.round((index + 1) * 100 / steps.length))
  264. const replay = await request(teaching(`/runs/${run.id}/physical-events`), actor, body)
  265. assert.equal(replay.runVersion, after.version)
  266. assert.equal((await runOf(run.id, actor)).version, after.version)
  267. const differentPayload = await denied(teaching(`/runs/${run.id}/physical-events`), actor, { ...body, publicPayload: { testOnly: true, altered: true } }, 409)
  268. progression.push({ stepCode: codeOf(step), eventId: result.eventId, progress: after.progressPercent, replayVersion: replay.runVersion, alteredEventStatus: differentPayload.status })
  269. run = after
  270. }
  271. facts = await factsOf(run.id, actor)
  272. assert.ok(facts.every(fact => fact.statusCode === 'COMPLETED' && fact.evidenceSourceCode === 'MANUAL'))
  273. passed('实装逐步人工确认、事件幂等与载荷冲突', { runId: run.id, progression, facts: slimFacts(facts) })
  274. await submitAndReview(run, actor, 'PHYSICAL')
  275. }
  276. async function confrontationFlow(profile, actors) {
  277. const configuration = profile.content.channelProfiles?.CONFRONTATION || profile.content.modeConfig
  278. assert.equal(configuration.teamSize, 2, 'Isolated test expects two learners per team')
  279. assert.ok(configuration.submitPositions.includes('COMMANDER'))
  280. assert.ok(profile.content.steps.every(step => step.allowedPositions?.includes('COMMANDER') && !step.allowedPositions?.includes('OPERATOR')), 'Each tested action must be COMMANDER-only for the negative role test')
  281. const assignment = await newAssignment('CONFRONTATION', profile, actors)
  282. const runs = new Map()
  283. for (const actor of actors) {
  284. const run = await accept(assignment, actor)
  285. if (runs.has(actor.team)) assert.equal(run.id, runs.get(actor.team).id)
  286. runs.set(actor.team, run)
  287. }
  288. assert.notEqual(runs.get('RED').id, runs.get('BLUE').id)
  289. const listed = rowsOf(await request(teaching(`/runs?assignmentId=${assignment.id}&size=100`), admin))
  290. assert.equal(listed.length, 2)
  291. passed('对抗红蓝两队各两人,按队伍共享且运行互相独立', { assignmentId: assignment.id, content: profile.reference, configuration: { teamSize: configuration.teamSize, roundDurationMinutes: configuration.roundDurationMinutes, submitPositions: configuration.submitPositions }, runs: listed.map(slimRun) })
  292. const redCommander = actors.find(actor => actor.team === 'RED' && actor.position === 'COMMANDER')
  293. const crossTeam = await denied(teaching(`/runs/${runs.get('BLUE').id}`), redCommander, undefined, 403, 'GET')
  294. passed('对抗学员不能读取另一队运行明细', { requestingTeam: 'RED', otherRunId: runs.get('BLUE').id, response: crossTeam })
  295. for (const team of ['RED', 'BLUE']) {
  296. const commander = actors.find(actor => actor.team === team && actor.position === 'COMMANDER')
  297. const operator = actors.find(actor => actor.team === team && actor.position === 'OPERATOR')
  298. let run = await startWithReplay(runs.get(team), commander)
  299. const execution = await request(teaching(`/runs/${run.id}/execution`), commander)
  300. assert.ok(Number.isFinite(execution.deadlineAt) && execution.deadlineAt > execution.serverNow, 'Server deadline must be active')
  301. const steps = profile.content.steps
  302. const progressBody = step => ({ version: run.version, commandId: uuid(), completedStepCode: codeOf(step), actionCode: actionOf(step), checkpoint: { testOnly: true }, automaticResult: {} })
  303. const wrongPosition = await denied(teaching(`/runs/${run.id}/progress`), operator, progressBody(steps[0]), 403, 'PUT')
  304. const skipped = await denied(teaching(`/runs/${run.id}/progress`), commander, progressBody(steps[1]), [400, 409], 'PUT')
  305. const earlySubmit = await denied(teaching(`/runs/${run.id}/submit`), commander, { version: run.version, commandId: uuid(), summary: 'negative test' }, 409)
  306. assert.equal((await runOf(run.id, commander)).progressPercent, 0)
  307. passed(`${team} 队岗位越权、跳步和提前提交均拒绝`, { runId: run.id, wrongPosition, skipped, earlySubmit, deadlineAt: execution.deadlineAt, serverNow: execution.serverNow })
  308. const progression = []
  309. for (const [index, step] of steps.entries()) {
  310. const body = progressBody(step)
  311. const advanced = await request(teaching(`/runs/${run.id}/progress`), commander, body, 'PUT')
  312. const replay = await request(teaching(`/runs/${run.id}/progress`), commander, body, 'PUT')
  313. assert.equal(replay.version, advanced.version)
  314. assert.equal(advanced.progressPercent, Math.round((index + 1) * 100 / steps.length))
  315. const conflict = await denied(teaching(`/runs/${run.id}/progress`), commander, { ...body, checkpoint: { testOnly: true, altered: true } }, 409, 'PUT')
  316. progression.push({ stepCode: codeOf(step), progress: advanced.progressPercent, replayVersion: replay.version, changedCommandStatus: conflict.status })
  317. run = advanced
  318. }
  319. const wrongSubmitPosition = await denied(teaching(`/runs/${run.id}/submit`), operator, { version: run.version, commandId: uuid(), summary: 'negative role test' }, 403)
  320. passed(`${team} 队授权动作逐步推进、重放幂等、提交负责人限制`, { runId: run.id, progression, wrongSubmitPosition })
  321. await submitAndReview(run, commander, 'CONFRONTATION')
  322. if (team === 'RED') {
  323. const blue = await runOf(runs.get('BLUE').id, admin)
  324. assert.equal(blue.progressPercent, 0)
  325. passed('红队完成不会推进蓝队进度', { redRunId: run.id, blueRun: slimRun(blue) })
  326. }
  327. }
  328. }
  329. save()
  330. try {
  331. await loginAdmin()
  332. const physical = await contentReference('PHYSICAL')
  333. let confrontation = waitForConfrontation ? null : await contentReference('CONFRONTATION')
  334. const actors = await createActors()
  335. await section('PHYSICAL 正式流程', () => physicalFlow(physical, actors))
  336. if (waitForConfrontation) {
  337. await waitForConfrontationPublication()
  338. confrontation = await contentReference('CONFRONTATION')
  339. }
  340. await section('CONFRONTATION 正式流程', () => confrontationFlow(confrontation, actors))
  341. await section('固定发布快照回读', () => verifyFrozenSnapshots({ PHYSICAL: physical, CONFRONTATION: confrontation }))
  342. } catch (error) {
  343. evidence.checks.push({ title: '测试准备', status: 'FAIL', details: safeError(error) })
  344. } finally {
  345. for (const actor of sessions.reverse()) await api('/api/auth/v1/auth/logout', actor.token, {}).catch(() => {})
  346. evidence.status = evidence.checks.some(check => check.status === 'FAIL') ? 'FAIL' : 'PASS'
  347. evidence.finishedAt = new Date().toISOString()
  348. evidence.retainedData = { assignments: evidence.assignments.map(item => item.id), runs: evidence.runs.map(item => item.id), users: evidence.actors.map(item => item.userId), note: '仅新建本次开发测试数据,未修改或删除既有账号、任务与运行。' }
  349. save()
  350. console.log(JSON.stringify({ status: evidence.status, checks: evidence.checks.length, passed: evidence.checks.filter(check => check.status === 'PASS').length, evidence: 'reports/training-modes-20260905/api-evidence.json', retainedData: evidence.retainedData }))
  351. if (evidence.status !== 'PASS') process.exitCode = 1
  352. }