- T01: V055 audit_logs 扩展 4 字段(purpose_code/service_assignment_id/actor_role_snapshot/request_trace_id, 全可空幂等) + AuditLog/DataScopeGuard/AuthorizationAuditListener/MatchmakerServiceService 写入链路改造 + schema-h2 同步 + LATEST_MIGRATION 54→55 - T02: JwtAuthFilter 运营分支改实时查库权限回收(每请求查 operators.status + 权限, 停用即401/撤权即403, 零缓存, claims 权限降级只写不读), 不碰 SecurityConfig/JwtTokenService/用户端 - T03: getServiceDetail 持有人分支接入 SensitiveAccessLog(成功查看落行 dataType=matchmaker_service_detail, 拒绝路径只落 audit_logs DATA_SCOPE_DENIED, 双记录结构性互斥) - T04: 写接口 404 统一防探测(transfer/revoke/end/recommend/followup/complete/转介范围违规 404 化 deny, 先审计后 404, create/assign 无资源寻址维持 403, ReferralService 消灭裸 IllegalArgumentException) - T05: OperatorEndpointPermissionCoverageTest +3 守卫(类级 RequestMapping 前缀合成/缺类级则逐方法 PreAuthorize/matchmaker+referral 禁 PatchMapping) + run-backend-contracts CURATED 14→18 - 新增测试 4 类 20 用例: MatchmakerAuditFieldsTest / OperatorPermissionRevocationTest / MatchmakerSensitiveAccessAuditTest / MatchmakerWriteDenyUniformityTest - 验证: mvn verify 884/884(3 skip) + UI 契约 24/24 + 后端契约门禁 198 通过; 负向注入 5(工程师)+2(QA) 判红→还原复绿; QA fresh eyes 独立验收 PASS(0 P0/P1, 2 P2 观察项移交 ZC-MM-007) - ContactAuthorizationPolicy 维持 DEFERRED; C 端死代码退役评估 3 项留待 007/专项治理
236 lines
11 KiB
JavaScript
236 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
// 后端契约门禁统一执行器(本地 + CI 共用入口)
|
||
//
|
||
// 背景:run-ui-contracts.mjs 固化了一组「源码结构与关键行为断言」守卫(ui-tests/*.mjs,纯 node、零依赖),
|
||
// 但后端回归/契约测试是 Java/Maven,无法用 node 直接跑。此前它们只随 CI 的 `mvn verify` 全量执行,
|
||
// 没有「curated + 下限硬校验」这层保护 —— 即:有人把关键回归测试类**改名/删除**后,
|
||
// `mvn verify` 只会少跑几个测试、照样 SUCCESS,门禁假绿。
|
||
//
|
||
// 本脚本把「固化关键后端契约测试 + 文件存在性下限 + 真跑并解析 surefire 汇总 + 非零退出」固化下来,
|
||
// 作为 run-ui-contracts.mjs 的**同级后端门禁**:
|
||
//
|
||
// node scripts/run-backend-contracts.mjs # 跑 CURATED 列表(默认)
|
||
// node scripts/run-backend-contracts.mjs --list # 只打印 curated 列表,不执行
|
||
//
|
||
// 三条约定(对应 run-ui-contracts.mjs 的同名纪律):
|
||
// 1) **文件存在性下限硬校验**:CURATED 中的每个测试类源文件必须存在于 backend/src/test/java,
|
||
// 缺一个即拒绝报绿(防止「测试被静默删除/改名」后门禁仍然全绿)。
|
||
// 新增契约测试请上调 CURATED 列表;有意删减则下调并说明原因。
|
||
// 可用环境变量 BACKEND_GUARDS_MIN 覆盖(仅用于让「数量下限」这条分支可被自检)。
|
||
// 2) 真跑:用 `mvn test -Dtest=<列表>` 执行(CI 的 backend job 已装好 JDK/Maven 且刚跑过 verify,
|
||
// 复用编译产物与依赖缓存,仅重跑指定测试,开销很小)。任一测试失败/报错 → 非零退出。
|
||
// 3) **本地无 JDK/Maven 时绝不谎报通过**:探测 mvn/mvnw → Docker 测试镜像 → 都不行则明确退出码 2
|
||
// (区别于 0 / 1),提示走 CI 或 Docker,绝不假装绿。
|
||
|
||
import { spawnSync } from 'node:child_process'
|
||
import { existsSync, readdirSync, statSync } from 'node:fs'
|
||
import { basename, dirname, resolve } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||
const backendTestRoot = resolve(root, 'backend', 'src', 'test', 'java')
|
||
|
||
// ── curated 后端契约/回归测试 ────────────────────────────────────────────────
|
||
// 第 1~4 项:本轮(2026-09-30)为 v1.1.0 修复补的回归测试 + 扩展断言
|
||
// AuthServiceRefreshTtlTest —— refresh token TTL 须用 refresh 过期(14天) 而非 access(4h)
|
||
// PhotoServiceConfirmUploadTest —— 照片直传 confirm 落库护栏(归属前缀 + 对象存在性)
|
||
// RealIdentityVerificationProviderTest —— 生产实名核验走真实校验位 + 可配置第三方 + fail-closed
|
||
// JwtAuthFilterTest —— Authorization 头优先、缺失回退 dy_op_at Cookie
|
||
// 第 5~13 项:核心不变量/契约守卫(持续盯防,改坏会漏)
|
||
// CoreInvariantsTest / V11InvariantsTest / PermissionMatrixTest
|
||
// MeRouteContractTest / CanonicalRouteContractTest
|
||
// PhotoLimitGuardTest / PhotoLimitConcurrencyGuardTest / PhotoUploadValidationTest
|
||
// OperatorAuthServiceTest
|
||
// 第 14 项(2026-10-09):审核决策端点的「请求体契约」守卫 —— 前端统一发送 {comment, reasonCodes[]},
|
||
// 若 @RequestBody 被再度收窄为 Map<String,String>,数组值反序列化失败即 400
|
||
// (现象:「小程序提交资料后,管理员点通过直接报错」)。
|
||
// ReviewControllerRequestBodyContractTest
|
||
// 第 15~18 项(2026-10-11,ZC-MM-006):
|
||
// MatchmakerAuditFieldsTest —— audit_logs 扩展审计字段(V055)写入/兼容/拒绝快照
|
||
// OperatorPermissionRevocationTest —— JWT 权限回收实时查库(禁用 401 / 撤权 403+审计)
|
||
// MatchmakerSensitiveAccessAuditTest —— 服务详情敏感访问日志(success 行 / 双记录互斥)
|
||
// MatchmakerWriteDenyUniformityTest —— 写接口 404 统一防探测(5 拒绝 ≡ 读详情 404)
|
||
// 新增/删减请同步修改本数组(下限随之变化)。
|
||
const CURATED = [
|
||
'AuthServiceRefreshTtlTest',
|
||
'PhotoServiceConfirmUploadTest',
|
||
'RealIdentityVerificationProviderTest',
|
||
'JwtAuthFilterTest',
|
||
'CoreInvariantsTest',
|
||
'V11InvariantsTest',
|
||
'PermissionMatrixTest',
|
||
'MeRouteContractTest',
|
||
'CanonicalRouteContractTest',
|
||
'PhotoLimitGuardTest',
|
||
'PhotoLimitConcurrencyGuardTest',
|
||
'PhotoUploadValidationTest',
|
||
'OperatorAuthServiceTest',
|
||
'ReviewControllerRequestBodyContractTest',
|
||
'MatchmakerAuditFieldsTest',
|
||
'OperatorPermissionRevocationTest',
|
||
'MatchmakerSensitiveAccessAuditTest',
|
||
'MatchmakerWriteDenyUniformityTest',
|
||
]
|
||
|
||
const MIN_GUARDS = Number(process.env.BACKEND_GUARDS_MIN ?? CURATED.length)
|
||
|
||
// ── 步骤 1:文件存在性下限校验 ──────────────────────────────────────────────
|
||
function findClassFile(name) {
|
||
// 在 backend/src/test/java 下递归找 `${name}.java`
|
||
const stack = [backendTestRoot]
|
||
while (stack.length) {
|
||
const dir = stack.pop()
|
||
let entries
|
||
try {
|
||
entries = readdirSync(dir, { withFileTypes: true })
|
||
} catch {
|
||
continue
|
||
}
|
||
for (const e of entries) {
|
||
const full = resolve(dir, e.name)
|
||
if (e.isDirectory()) stack.push(full)
|
||
else if (e.name === `${name}.java`) return full
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// --list 模式:只打印,不执行
|
||
if (process.argv.includes('--list')) {
|
||
console.log(`后端契约门禁 curated 列表(${CURATED.length} 项,下限 ${MIN_GUARDS}):`)
|
||
for (const n of CURATED) console.log(` - ${n}`)
|
||
process.exit(0)
|
||
}
|
||
|
||
console.log(`后端契约门禁 — 校验 ${CURATED.length} 个契约测试源文件存在性(下限 ${MIN_GUARDS})\n`)
|
||
|
||
const missing = []
|
||
for (const name of CURATED) {
|
||
const f = findClassFile(name)
|
||
if (!f) {
|
||
missing.push(name)
|
||
console.log(` MISSING ${name}`)
|
||
} else {
|
||
console.log(` OK ${name}`)
|
||
}
|
||
}
|
||
|
||
if (missing.length > 0) {
|
||
console.error(
|
||
`\nERROR: ${missing.length} 个契约测试源文件缺失,少于下限 ${MIN_GUARDS} —— 测试可能被删除或改名,拒绝报绿。`,
|
||
)
|
||
console.error(' 新增契约测试请上调 CURATED 列表;确为有意删减请下调并说明原因。')
|
||
process.exit(1)
|
||
}
|
||
if (CURATED.length < MIN_GUARDS) {
|
||
console.error(
|
||
`ERROR: 仅 ${CURATED.length} 个契约测试,少于下限 ${MIN_GUARDS} —— 拒绝报绿。`,
|
||
)
|
||
process.exit(1)
|
||
}
|
||
console.log(`\n✅ 全部 ${CURATED.length} 个契约测试源文件存在,下限校验通过。\n`)
|
||
|
||
// ── 步骤 2:真跑(mvn test -Dtest=<列表>) ─────────────────────────────────
|
||
function detectMvn() {
|
||
for (const cand of ['mvnw', 'mvn']) {
|
||
const r = spawnSync(cand, ['-v'], { cwd: root, encoding: 'utf8' })
|
||
if (r.status === 0) return cand
|
||
}
|
||
// Windows 常见:backend 下有 mvnw.cmd
|
||
const winMvnw = resolve(root, 'backend', 'mvnw.cmd')
|
||
if (existsSync(winMvnw)) return winMvnw
|
||
return null
|
||
}
|
||
|
||
const testList = CURATED.join(',')
|
||
const t0 = Date.now()
|
||
|
||
let mvn = detectMvn()
|
||
if (mvn) {
|
||
console.log(`▶ 使用 Maven 执行:${mvn} test -Dtest=<${CURATED.length} 个契约测试>\n`)
|
||
const r = spawnSync(mvn, ['-B', 'test', `-Dtest=${testList}`, '-DfailIfNoTests=true'], {
|
||
cwd: resolve(root, 'backend'),
|
||
encoding: 'utf8',
|
||
env: { ...process.env, NO_COLOR: '1' },
|
||
})
|
||
const out = [r.stdout, r.stderr].filter(Boolean).join('\n')
|
||
analyze(out, r.status)
|
||
} else {
|
||
// 退路:Docker 测试镜像(与 .workbuddy/run-regression.sh 同三件套)
|
||
// 关键:docker daemon 在本机可能无响应,`spawnSync` 不设 timeout 会**挂死整个脚本**
|
||
// (2026-09-30 实测:docker CLI 在 PATH 但 daemon 未起,`image inspect` 直接卡住)。
|
||
// 因此所有 docker 探测都带 timeout,超时即视为不可用 → 走 cannotRun,绝不假绿也不挂死。
|
||
const docker = spawnSync('docker', ['--version'], { encoding: 'utf8', timeout: 10000 })
|
||
const IMG = 'danyang-match-backend-test:latest'
|
||
if (docker.status === 0) {
|
||
const imgCheck = spawnSync('docker', ['image', 'inspect', IMG], {
|
||
encoding: 'utf8',
|
||
timeout: 15000,
|
||
})
|
||
if (imgCheck.status === 0) {
|
||
console.log(`▶ 本地无 Maven,回退 Docker 测试镜像 ${IMG}\n`)
|
||
const dockerExe =
|
||
process.env.DOCKER_EXE || 'D:/Data/Docker/DockerDesktop/resources/bin/docker.exe'
|
||
const settings = resolve(root, '.workbuddy', 'maven-settings-aliyun.xml')
|
||
const r = spawnSync(
|
||
dockerExe,
|
||
[
|
||
'run', '--rm',
|
||
'-v', `${root}:/proj`,
|
||
'-v', 'danyang-m2-cache:/root/.m2',
|
||
'-v', `${settings}:/root/aliyun-settings.xml`,
|
||
'-w', '/proj/backend', '--memory=3g', '-e', 'MAVEN_OPTS=-Xmx256m',
|
||
IMG, 'mvn', '-B', '-s', '/root/aliyun-settings.xml', 'test',
|
||
`-Dtest=${testList}`, '-DfailIfNoTests=true',
|
||
],
|
||
{ encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' }, timeout: 600000 },
|
||
)
|
||
const out = [r.stdout, r.stderr].filter(Boolean).join('\n')
|
||
analyze(out, r.status)
|
||
} else {
|
||
cannotRun()
|
||
}
|
||
} else {
|
||
cannotRun()
|
||
}
|
||
}
|
||
|
||
function cannotRun() {
|
||
console.error(
|
||
'\n⚠️ 本机无 JDK/Maven 且无可用的后端测试 Docker 镜像,无法本地执行后端契约门禁。',
|
||
)
|
||
console.error(' 该门禁由 CI(backend job)强制执行,请在 CI 中验证;或安装 Maven / 构建')
|
||
console.error(' danyang-match-backend-test 镜像后本地复跑。')
|
||
console.error(' (以退出码 2 表示「本环境无法执行」,而非谎报通过。)')
|
||
process.exit(2)
|
||
}
|
||
|
||
function analyze(out, status) {
|
||
// 解析 surefire 汇总:Tests run: X, Failures: Y, Errors: Z, Skipped: S
|
||
const re = /Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+)/g
|
||
let m
|
||
let total = 0
|
||
let failures = 0
|
||
let errors = 0
|
||
while ((m = re.exec(out)) !== null) {
|
||
total += Number(m[1])
|
||
failures += Number(m[2])
|
||
errors += Number(m[3])
|
||
}
|
||
const secs = ((Date.now() - t0) / 1000).toFixed(1)
|
||
console.log('')
|
||
if (status === 0 && failures === 0 && errors === 0) {
|
||
console.log(`✅ PASS 后端契约门禁:${total} 测试全部通过,用时 ${secs}s`)
|
||
process.exit(0)
|
||
}
|
||
console.error(`❌ 后端契约门禁失败(exit=${status ?? 'null'}):`)
|
||
console.error(` 已执行测试 ${total},Failures=${failures},Errors=${errors}`)
|
||
// 把 surefire 关键片段打印出来便于定位
|
||
const tail = out.split('\n').filter((l) => /Tests run:|BUILD FAILURE|<<< FAILURE|<<< ERROR|No tests were executed/.test(l))
|
||
if (tail.length) {
|
||
console.error(' --- 关键输出 ---')
|
||
for (const l of tail.slice(-25)) console.error(' ' + l.trim())
|
||
}
|
||
process.exit(1)
|
||
}
|