Files
danyang-match/ui-tests/ui-document-baseline-contracts.mjs
T
WorkBuddy 8bd1ab804a fix(organization): 修复创建机构审计缺操作人;补全机构修复的验证并部署 Docker 测试环境
一、机构审计归属缺陷(本轮活栈验收中新发现)
- 现象:audit_logs 中 CREATE_ORGANIZATION 的 operator_id 7/7 为 NULL,
  而 DISABLE/ENABLE_ORGANIZATION 均正确记录操作人。审计不可归属 = 无效审计。
- 根因:OrganizationController.create 持有 @AuthenticationPrincipal OperatorPrincipal,
  却只把 params 传给 Service,未透传 principal.id() → saveAuditLog(null, ...)。
- 修复:createOrganization(params, operatorId) 签名透传;新增测试断言
  createOrganizationRecordsOperatorInAuditLog 长期守卫。
- 复验:新写入的 7 条 CREATE 审计全部带 operator_id,旧 14 条仍为 NULL(前后对照清晰)。

二、补全 rc.22 的验证缺口(此前记为「已修复待验证」)
- 全量回归 757 / 0 / 0 / 3,BUILD SUCCESS,108 个测试类,6:44(基线 741 + 新增 16)。
- 负向注入:ORG_STATUS_ACTIVE 改回 "normal" → 16 例变 3 失败 + 7 错误
  (守卫三条断言各自命中 + 服务侧 7 例被 H2 CHK_ORG_STATUS 拦下)。
  注入采用「仓库只读挂载 + 容器内副本改写」,工作区哈希前后一致,源码零污染。
- 活栈实测(Docker 全栈):创建/停用/启用均 2xx、非法输入 400、不存在 404、全程零 5xx(15/15)。
- 对已部署栈 pytest tests/e2e → 65 passed。

三、Docker 测试环境部署与工具链固化
- 测试镜像 danyang-match-backend-test 与命名卷 danyang-m2-cache 因 Docker Desktop 重装丢失,
  构建定义此前是临时产物 → 落盘为仓库资产 infrastructure/test-toolchain/Dockerfile。
- 本地栈统一使用 docker compose -p danyang(原项目名 infra 与另一项目撞名,
  其 --remove-orphans 会误删本项目容器;已确认邻项目数据无丢失,见 known-risks KR-09)。

四、UI 门禁脆弱性修复
- ui-document-baseline-contracts.mjs 解析 docker-compose.staging.yml 时报假红
  (「守卫自身失效」);真因是该文件为 CRLF 而守卫正则要求 \n 紧随 container_name 之后
  (prod.yml 是 LF 所以此前能过)。解析改为行尾无关,真实断言未削弱。门禁 18/18。

五、文档
- STATUS.md 新增 rc.23 并将 rc.22 由「待验证」订正为「已验证关闭」。
- 实施计划附 B.1、PROGRESS-ASSESSMENT-2026-09-21.md 同步订正。
- known-risks.md:KR-07 解除(Docker 已恢复)、KR-01/KR-06 按复测订正,
  新增 KR-08(Docker Hub 域名 DNS 被投毒,改用国内镜像源绕行)与 KR-09(compose 项目名撞名)。

验证状态:全量回归 757/0/0/3 全绿;UI 契约门禁 18/18;对已部署栈 E2E 65/65;
负向注入可变红;工作区哈希未被注入过程污染。
未验证:GitHub Actions 首次真绿(仍未推送,见 KR-06)。
2026-09-22 00:46:10 +08:00

231 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const read = (path) => readFileSync(resolve(root, path), 'utf8')
const spec = 'docs/superpowers/specs/2026-09-13-reference-driven-dual-client-ui-redesign-design.md'
const obsolete = [
'docs/superpowers/specs/2026-09-10-ui-v11-fidelity-design.md',
'docs/superpowers/specs/2026-09-11-codex-baseline-cleanup-design.md',
'docs/superpowers/specs/2026-09-11-dual-client-full-ui-redesign-design.md',
'docs/superpowers/plans/2026-09-10-ui-v11-fidelity.md',
'docs/superpowers/plans/2026-09-11-codex-baseline-cleanup.md',
'docs/superpowers/plans/2026-09-11-admin-full-ui-redesign.md',
'docs/superpowers/plans/2026-09-11-miniapp-full-ui-redesign.md',
'docs/superpowers/plans/2026-09-11-ui-foundation-and-pilot.md',
'docs/UI-V11-Visual-Acceptance-2026-09-10.md',
'docs/善缘丹阳 V1.1 前端 UI-UX 高保真原型修订说明.md',
'design-qa.md',
'artifacts/phase07-miniapp-home.png',
'artifacts/phase07-miniapp-discover.png',
'artifacts/phase07-home-comparison.png',
'artifacts/phase07-discover-comparison.png',
'artifacts/ui-v11/admin-counselor-1366.png',
'artifacts/ui-v11/admin-dashboard-1440.png',
'artifacts/ui-v11/admin-station-1366.png',
'artifacts/ui-v11/miniapp-home-320.png',
'artifacts/ui-v11/miniapp-home-375.png',
'artifacts/ui-v11/miniapp-home-390.png',
'artifacts/ui-v11/miniapp-home-430.png',
'artifacts/ui-v11/miniapp-home-preview.html',
'artifacts/tabbar-icons-preview.png',
'ui-tests/admin-redesign-contracts.mjs',
'ui-tests/miniapp-redesign-contracts.mjs',
]
const agents = read('AGENTS.md')
assert.ok(agents.includes(spec), 'AGENTS.md 未指向唯一视觉规格')
assert.doesNotMatch(agents, /善缘丹阳 V1\.1 前端 UI-UX 高保真原型修订说明\.md/, 'AGENTS.md 仍引用旧高保真规范')
for (const file of obsolete) {
assert.equal(existsSync(resolve(root, file)), false, `旧视觉入口仍存在: ${file}`)
}
const status = read('docs/STATUS.md')
const backlog = read('docs/JIRA_BACKLOG.md')
const phase07 = read('docs/Phase-07-V11-Recovery-Completion-Report-2026-09-13.md')
const matrix = read('docs/UI_REDESIGN_PAGE_MATRIX_V1.1.md')
// —— 文档基线口径 ——
// UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)。
// 四份文档必须一致记录该结论。若有人把口径改回"视觉否决 / 待重新实现 / REOPENED",
// 本门禁必须变红 —— 这是防止验收状态被静默回退的哨兵。
assert.match(status, /UI REDESIGN ACCEPTED BY USER/, 'STATUS 头部未记录 UI 已通过用户验收')
assert.match(status, /Phase 07[^\n]*已通过用户验收/, 'STATUS 未标记 Phase 07 UI 验收通过')
// JIRA 表格行必须已关闭;主表中的 REOPENED 不得复活(历史校正段落中的引用不算)。
assert.match(backlog, /^\|\s*Phase 07[^\n]*已通过用户验收/m, 'JIRA 未关闭 Phase 07 UI 条目')
assert.doesNotMatch(backlog, /^\|\s*Phase 07[^\n]*REOPENED/m, 'JIRA 主表仍将 Phase 07 UI 标记为 REOPENED')
// Phase 07 报告是历史文档:既要保留 2026-09-13 的失效判定,也要记录重新实现后的验收结论。
assert.match(phase07, /视觉结论已失效/, 'Phase 07 报告未保留 2026-09-13 历史判定')
assert.match(phase07, /首批 11 页于 2026-09-16 通过用户验收/, 'Phase 07 报告未记录重新实现后的验收结论')
assert.match(matrix, /小程序首批 11 个核心页面/, '页面矩阵未切换到当前首批范围')
assert.match(matrix, /2026-09-16 验收通过/, '页面矩阵未记录 11 页验收结论')
assert.doesNotMatch(matrix, /待重新实现|待开始/, '页面矩阵仍残留未完成状态')
assert.doesNotMatch(matrix, /63 个页面.*(全部|全量).*重构/, '页面矩阵仍要求本批全量重构')
// —— 管理端端口一致性(本地开发 / 文档 / 生产发布) ——
// 同一个端口写在三处:admin-web/vite.config.ts 是唯一权威,
// docs/STATUS.md 的「当前运行状态」是给人照做的,
// infrastructure/docker-compose.prod.yml 是生产 admin 容器的发布端口。
// 只改一处不会让任何测试变红,但会让照文档操作的人打不开页面,
// 或让本机直连排查打到错的端口 —— 属于本项目反复出现的「声明与兑现失联」同族问题,
// 因此用门控钉住。注意断言的是「几处一致」而非写死 13520:改端口是允许的,
// 允许的是「一起改」,不是「只改一处」。
// 前两处(vite / STATUS)是版本控制内的硬断言;第三处(生产 compose)只能
// 「存在才断言」,因为它被刻意排除在版本控制之外 —— 理由见下。
//
// 刻意不纳入本组守卫的另一处「管理端入口」:
// - infrastructure/docker-compose.yml:admin 容器不发布端口,管理端经 nginx 的 6688
// 与 /api 一起暴露(6688 是 nginx 入口端口,不是纯管理端端口),故不参与"等于 vite 端口"。
// staging(docker-compose.staging.yml)原也列在"不纳入"——它按「生产 + 1」错开,端口本就不
// 等于 vite 端口。但它与 tests/uat/run-uat.sh 默认值之间的一致性是可断言的(两者都在版本
// 控制内),而两者不一致正是"照脚本去跑、管理端却打不开"的直接原因,故改为单独一组断言:
// 见下方「staging 管理端入口一致性」。
const viteConfig = read('admin-web/vite.config.ts')
const vitePort = viteConfig.match(/^\s*port:\s*(\d+),/m)?.[1]
// 解析失败即失败:正则匹配不到时捕获组为 undefined,断言会退化成"与 undefined 比较"
// 而失去意义 —— 守卫静默失效是本项目踩过的坑。
assert.ok(vitePort, '未能从 admin-web/vite.config.ts 解析出 server.port(守卫自身失效)')
// 用 equal 而不是 match:match 失败会把整份文档打进报错里,
// 3000 行的噪声会盖掉真正的不一致信息。
const docPort = status.match(/管理后台:\s*http:\/\/localhost:(\d+)\//)?.[1]
assert.ok(docPort, 'docs/STATUS.md「当前运行状态」未声明管理后台地址(守卫自身失效)')
assert.equal(docPort, vitePort, `管理后台端口不一致:STATUS.md=${docPort},vite.config.ts=${vitePort}`)
// 生产 compose 的 admin 发布端口。
// ⚠️ infrastructure/docker-compose.prod.yml 位于 .gitignore 的「Secrets and certificates」
// 段,刻意不进版本控制 —— 因此 CI(全新克隆)上这个文件根本不存在。若在这里硬 read(),
// 门控会在 CI 上抛 ENOENT 变成假红。故取「存在即断言、不存在则显式打印跳过」:
// 本机有它,本机就能拦住漂移;跳过时打印原因,不让它被误读成「已校验通过」。
const prodComposePath = 'infrastructure/docker-compose.prod.yml'
if (!existsSync(resolve(root, prodComposePath))) {
console.log(`skip: ${prodComposePath} 不在版本控制内(CI 上不存在),跳过生产管理端端口校验`)
} else {
// 先切出 admin-web 服务块(到下一个 2 空格缩进的服务名为止),
// 再取块内唯一的带引号端口映射(形如 "127.0.0.1:13520:80")。
// 不直接写 /ports:\s*\n\s*-\s*"/ —— 端口上方允许有注释行,那样会匹配不到并让守卫误判为失效。
const prodCompose = read(prodComposePath)
const prodAdminBlock = prodCompose.match(
/container_name:\s*danyang-match-admin\r?\n([\s\S]*?)(?=\r?\n {2}[a-z]|$)/,
)?.[1]
assert.ok(prodAdminBlock, '未能从 docker-compose.prod.yml 切出 admin-web 服务块(守卫自身失效)')
const prodAdminMapping = prodAdminBlock.match(/-\s*"([^"]+)"/)?.[1]
assert.ok(prodAdminMapping, '未能从 docker-compose.prod.yml 的 admin-web 解析出端口映射(守卫自身失效)')
// "127.0.0.1:13520:80" → 主机端口取倒数第二段(兼容不带绑定地址的 "13520:80")。
const prodAdminPort = prodAdminMapping.split(':').at(-2)
assert.equal(
prodAdminPort,
vitePort,
`管理端端口不一致:docker-compose.prod.yml=${prodAdminPort},vite.config.ts=${vitePort}`,
)
}
// —— staging 管理端入口一致性(发布端口 / UAT 脚本默认值 / 子路径) ——
// 2026-09-18 之前,tests/uat/run-uat.sh 里的 ADMIN_URL 是个「定义了却无人消费」的常量:
// 端口改了、脚本没同步,不会有任何检查变红。现该变量已接到实测的管理端可达性检查上,
// 这里再用门控保证它与 staging compose 的发布端口一致 —— 否则实测会稳定地测错对象。
const stagingCompose = read('infrastructure/docker-compose.staging.yml')
// 行尾必须容忍 CRLF —— 本仓库同时存在两种行尾(staging.yml / run-uat.sh 是 CRLF,
// prod.yml 是 LF),而 prod 那段在 CI 上因文件被 gitignore 根本不执行,
// 所以只有 staging 这段会在 Windows 工作区暴雷。若写死 \n:
// 窗口内夹了 \r 后解析直接失败,守卫会报「自身失效」——
// 那是**假红**(真实端口 13521 与 run-uat.sh 一致),比不检查更坏。
const stagingAdminMapping = stagingCompose
.match(/container_name:\s*danyang-match-staging-admin\r?\n([\s\S]*?)(?=\r?\n {2}[a-z]|$)/)?.[1]
?.match(/-\s*"([^"]+)"/)?.[1]
assert.ok(stagingAdminMapping, '未能从 docker-compose.staging.yml 的 admin-web 解析出端口映射(守卫自身失效)')
const stagingAdminPort = stagingAdminMapping.split(':').at(-2)
const uatAdminUrlMatch = read('tests/uat/run-uat.sh')
.match(/^ADMIN_URL="\$\{UAT_ADMIN_URL:-http:\/\/localhost:(\d+)(\/[^}"]*)?\}"/m)
assert.ok(uatAdminUrlMatch, '未能从 tests/uat/run-uat.sh 解析出 ADMIN_URL 默认值(守卫自身失效)')
assert.equal(
uatAdminUrlMatch[1],
stagingAdminPort,
`管理端 UAT 入口端口不一致:run-uat.sh=${uatAdminUrlMatch[1]},docker-compose.staging.yml=${stagingAdminPort}`,
)
// 子路径:产物部署在 /admin/ 下,UAT 入口必须带该前缀。缺了它测的是根路径(301 或 404)——
// "检查了但检查错了对象"比不检查更坏,因为它给出的是通过。
assert.equal(uatAdminUrlMatch[2], '/admin/', `run-uat.sh 的管理端入口未指向 /admin/ 子路径:${uatAdminUrlMatch[0]}`)
// staging 与生产同机共存,端口必须错开。生产端口在 CI 上不可知(prod compose 不在版本
// 控制内),故只能断言"不撞车",不断言具体的「生产 + 1」。
assert.notEqual(stagingAdminPort, vitePort, `staging 管理端端口与主管理端端口撞车(都是 ${stagingAdminPort}),同机部署会冲突`)
// —— 管理端子路径部署一致性 ——
// 产物部署在 /admin/ 子路径下(vite base 按 command 分叉)。该形态依赖五处同时成立:
// vite base、router history base、生产 nginx、本地整套栈 nginx、admin-web 容器自身 nginx。
// 任一处退回根路径假设,都会造成"页面能打开但白屏 / 裂图 / 指到别的东西"。
// 历史缺陷:生产 nginx 的 /admin/ 曾写 `alias /usr/share/nginx/html/` —— 那是 nginx:alpine
// 镜像自带的默认页目录(本容器内并没有管理端产物),于是 /admin/ 返回 200 + nginx 欢迎页:
// 假成功,比 404 更难发现。故下面既断言"代理到 upstream",也反向断言"不得再退回 alias"。
assert.match(
viteConfig,
/base:\s*command\s*===\s*'build'\s*\?\s*'\/admin\/'\s*:\s*'\/'/,
'vite 未把构建 base 设为 /admin/(产物会引用站点根路径的资源)',
)
assert.match(
read('admin-web/src/router/index.ts'),
/createWebHistory\(import\.meta\.env\.BASE_URL\)/,
'router 的 history base 未跟随 vite base(子路径部署下刷新或直达深层路由会白屏)',
)
const prodNginx = read('infrastructure/nginx-production.conf')
assert.match(prodNginx, /upstream\s+admin_web\s*\{[\s\S]*?server\s+admin-web:80;/, '生产 nginx 未声明 admin_web upstream')
assert.match(
prodNginx,
/location\s+\/admin\/\s*\{[^}]*proxy_pass\s+http:\/\/admin_web;/,
'生产 nginx 的 /admin/ 未代理到 admin_web upstream',
)
assert.doesNotMatch(
prodNginx,
/location\s+\/admin\/\s*\{[^}]*alias\s/,
'生产 nginx 的 /admin/ 又退回 alias 本容器目录(会返回镜像默认页,属假成功)',
)
assert.match(
read('infrastructure/nginx.conf'),
/location\s+\/admin\/\s*\{[^}]*proxy_pass\s+http:\/\/admin_web;/,
'本地整套栈 nginx 的 /admin/ 未代理到 admin_web(6688 上取不到管理端)',
)
const adminContainerNginx = read('admin-web/nginx-admin.conf')
assert.match(
adminContainerNginx,
/location\s+\/admin\/\s*\{[\s\S]*?alias\s+\/usr\/share\/nginx\/html\/;/,
'admin-web 容器 nginx 未服务 /admin/ 子路径',
)
assert.match(
adminContainerNginx,
/try_files\s+\$uri\s+\$uri\/\s+\/admin\/index\.html;/,
'admin-web 容器 nginx 的 SPA 兜底未指向 /admin/index.html(alias 配 try_files 写成文件路径会失效)',
)
// public/ 下的运行时资源不受 vite base 影响(原样复制到产物根),因此写死 `src="/brand/...`
// 或 `url('/brand/...')` 在子路径部署下会 404(logo / 插画 / 背景图裂)。统一走 src/brand.ts。
for (const file of [
'admin-web/src/layouts/MainLayout.vue',
'admin-web/src/views/LoginView.vue',
'admin-web/src/views/DashboardView.vue',
]) {
const src = read(file)
assert.doesNotMatch(src, /src="\/brand\//, `${file} 仍写死 /brand/ 根路径引用(子路径部署下会裂图)`)
assert.doesNotMatch(src, /url\('\/brand\//, `${file} 仍写死 url('/brand/...')(子路径部署下会裂图)`)
}
assert.match(
read('admin-web/index.html'),
/href="\/brand\/logo-mark\.png"/,
'index.html 的 favicon 未指向存在的品牌资源(vite 会为它补上 base 前缀)',
)
assert.doesNotMatch(
read('admin-web/index.html'),
/favicon\.svg/,
'index.html 仍引用不存在的 /favicon.svg(仓库内无此文件,且绝对路径不随 base 变化)',
)
console.log('UI document baseline contracts passed')