HCLT 收口企微通讯录公网闭环测试
这个提交包含在:
@@ -38,6 +38,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
||||
- 2026-05-17 LAUNCH-AUTO-OPS-V1 已完成正式启用后自动化收口脚本和证据刷新:本地 Prisma/node check/focused 单测、前端 typecheck/build、公网 readiness、PDF live OCR、统一考勤 live 检查、公网移动矩阵和企业微信 UA/渲染矩阵均通过;显式企微打卡同步检查返回 `SKIP wecom_checkin_sync_disabled`;但只读 flag 检查发现当前公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 记录的正式常开口径不一致,结论为 `PAUSE_FOR_FIX`。runner 已跳过付款批次写入,未切换 flag、未部署、不接真实支付、不接 ERP。
|
||||
- 2026-05-17 AUTO-REMAINING-CLOSEOUT 已完成剩余无需人工操作收口:受控恢复公网 `payment_batch_v1=force_on`,保持 `erp_sync=force_off`;复跑 `run-launch-auto-ops.sh` 通过,公网测试报销 `BX-20260517-0027` 完成审批、付款批次创建/复核、回单上传/下载、付款执行、AP 导出和审计核验;公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed。显式企微打卡同步仍为 `SKIP wecom_checkin_sync_disabled`,属于外部后台配置缺口,不阻断 OA 统一考勤入口和正式运营门禁;结论 `GO_TO_OPERATE`。
|
||||
- 2026-05-17 HR-WECOM-DIRECTORY-V1 已完成并部署公网:产品口径已调整为 ERP 未来不接入 OA;人事生命周期和考勤同属 HR 大域但分模块实现。OA 继续作为员工、权限、审批和审计主事实源;企业微信作为通讯录、入口、通知和打卡采集基础设施。HRWD 新增企业微信通讯录同步 flag、配置、同步记录、入职创建/资料更新/离职禁用钩子和员工管理重试入口。本地后端/前端/Playwright 通过,部署后公网 readiness、PDF live OCR、HRWD 端点 SKIP 检查和公网移动/企微矩阵通过;部署过程中发现 `seed-config` 会覆盖正式 feature flag,已修正为只创建缺失开关并复部署验证,最终 `payment_batch_v1=force_on`、`erp_sync=force_off`。
|
||||
- 2026-05-17 HRWD-CONTACT-LIVE-TEST 已完成公网企业微信通讯录真实写入闭环:使用 `HRWDTEST-20260517214548` 测试员工,验证 OA 创建员工后由 onboarding 触发企微 create、资料更新触发企微 update、离职 checklist 完成后禁用 OA 账号并触发企微 disable;三次企微同步均为 `wecom_contact_synced` 且 `errcode=0`。普通员工查看/重试他人同步状态被拒绝,审计日志包含 `WECOM_DIRECTORY_CREATE/UPDATE/DISABLE`;测试成员按约定保留离职/禁用状态,不删除。
|
||||
|
||||
## 关键证据索引
|
||||
|
||||
@@ -83,6 +84,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
||||
| LAUNCH-AUTO-OPS-V1 | 阶段收口:自动化证据完成,结论 `PAUSE_FOR_FIX`;除付款批次正式 flag 漂移外,本地门禁、公网 readiness/PDF live OCR、统一考勤、公网移动和企业微信 UA/渲染矩阵均通过;付款批次写入因前置失败被跳过 | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv`、`artifacts/qa-flow/launch-auto-ops-20260517-175548/summary.json`、`artifacts/qa-flow/live-smoke-readiness-20260517-175755/summary.json` |
|
||||
| AUTO-REMAINING-CLOSEOUT | 已收口:剩余自动化运营门禁通过,结论 `GO_TO_OPERATE`;公网 `payment_batch_v1=force_on`、`erp_sync=force_off`,付款批次常开闭环、AP 导出、审计、公网移动和企业微信渲染矩阵均通过;企微打卡同步外部配置仍记 `SKIP` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv`、`artifacts/qa-flow/launch-auto-ops-20260517-193317/summary.json`、`artifacts/qa-flow/launch-auto-ops-20260517-193317/live-result.json` |
|
||||
| HR-WECOM-DIRECTORY-V1 | 已收口:人事生命周期与企业微信通讯录同步闭环已部署公网;ERP 改为未来不接入 OA;员工创建/入职/调岗/资料变更/离职可触发企微通讯录创建、更新、禁用并记录同步状态;缺外部配置时公网重试记录 `SKIP wecom_contact_sync_disabled`,不阻断 OA 人事流程;部署 seed 已修正为不覆盖正式 flag | `issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv`、`server/tests/unit/routes/employees.wecomDirectory.test.js`、`artifacts/qa-flow/live-smoke-readiness-20260517-205813/summary.json`、`docs/需求-企业微信基础设施规约.md` |
|
||||
| HRWD-CONTACT-LIVE-TEST | 已收口:公网企微通讯录真实写入闭环通过;测试员工 `HRWDTEST-20260517214548` 完成 create/update/disable,企微 `errcode=0`,OA 最终 archived/inactive,离职后登录负例、普通员工权限负例和审计动作均通过;最终保留离职/禁用,不删除成员 | `issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv`、`artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json`、`artifacts/qa-flow/live-smoke-readiness-20260517-214632/summary.json` |
|
||||
|
||||
## 当前门禁
|
||||
|
||||
@@ -117,6 +119,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
||||
| ATTV1 考勤统一入口 | 通过,已部署 | 部署提交 `916cc3b` + 移动入口修正 `b2b4447`;历史 OA 自建打卡关闭,补卡、外出、加班已进入 OA 统一审批和月度解释;公网 `/ready`、live smoke、PDF live OCR、移动/企业微信矩阵 14 passed;企微 live 同步缺配置时按 `SKIP` 记录。 |
|
||||
| LAUNCH-AUTO-OPS-V1 正式启用后自动化收口 | 暂停,结论 `PAUSE_FOR_FIX` | `run-launch-auto-ops.sh` 报告 `artifacts/qa-flow/launch-auto-ops-20260517-175548/`;本地门禁、live readiness、PDF live OCR、考勤统一入口、公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed;只读 flag 检查失败:`payment_batch_v1=force_off`,期望为正式常开 `force_on`,因此付款批次写入回归被安全跳过。 |
|
||||
| AUTO-REMAINING-CLOSEOUT 剩余自动化收口 | 通过,结论 `GO_TO_OPERATE` | 新增 QA-only flag 恢复脚本并受控恢复 `payment_batch_v1=force_on`;`run-launch-auto-ops.sh` 报告 `artifacts/qa-flow/launch-auto-ops-20260517-193317/`,本地门禁、live readiness、PDF live OCR、正式 flag、统一考勤、付款批次常开闭环、公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed;测试报销 `BX-20260517-0027` paid,票据 archived,AP 台账 paid;企微打卡同步缺后台配置按 `SKIP wecom_checkin_sync_disabled` 记录。 |
|
||||
| HRWD-CONTACT-LIVE-TEST 企微通讯录公网闭环 | 通过,结论 `PASS` | 新增 QA-only runner `scripts/qa/run-hrwd-contact-live-test.mjs`;公网创建 `HRWDTEST-20260517214548` 测试员工,onboarding 触发企微 create、资料变更触发 update、offboarding checklist 触发 disable,三次同步均 `errcode=0`;OA 账号最终 archived/inactive,离职后登录 403 `AUTH_003`,普通员工同步状态权限负例和审计动作均通过;报告 `artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json`。 |
|
||||
|
||||
## R7EP / OPS8 / QAF8 / UX8 / P2T 预检推进
|
||||
|
||||
|
||||
+2
-1
@@ -59,6 +59,7 @@
|
||||
| `LAUNCH-AUTO-OPS-V1` | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv` | `EVIDENCE` | 正式启用后自动化收口已阶段完成,结论 `PAUSE_FOR_FIX`:本地门禁、公网 readiness/PDF live OCR、考勤统一入口、移动矩阵和企业微信 UA/渲染矩阵通过;企微打卡同步显式检查为 `SKIP wecom_checkin_sync_disabled`;公网 `payment_batch_v1=force_off` 与正式常开口径不一致,付款批次写入已安全跳过 |
|
||||
| `AUTO-REMAINING-CLOSEOUT` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv` | `EVIDENCE` | 剩余无需人工操作事项已收口,结论 `GO_TO_OPERATE`:公网 `payment_batch_v1` 已恢复 `force_on`,`erp_sync=force_off`;正式自动巡检报告 `artifacts/qa-flow/launch-auto-ops-20260517-193317/` 为 PASS,付款批次常开闭环、移动矩阵和企业微信渲染矩阵通过;企微打卡同步仍因外部配置 `SKIP wecom_checkin_sync_disabled` |
|
||||
| `HR-WECOM-DIRECTORY-V1` | `issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv` | `EVIDENCE` | 人事生命周期与企业微信通讯录同步已部署并收口:ERP 未来不接入 OA;HR 大域拆分为人事生命周期和考勤两个模块;OA 驱动企微通讯录创建/更新/禁用,失败不回滚 OA 人事动作;公网端点检查显示缺通讯录配置时重试记录 `SKIP wecom_contact_sync_disabled`,不阻断 OA 人事闭环;seed-config 已改为不覆盖已有正式 feature flag,复部署后 `payment_batch_v1=force_on`、`erp_sync=force_off` |
|
||||
| `HRWD-CONTACT-LIVE-TEST` | `issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv` | `EVIDENCE` | 公网企微通讯录真实写入闭环已收口:`HRWDTEST-20260517214548` 测试员工完成 create/update/disable,企微 `errcode=0`,OA 员工最终 archived/inactive,登录负例、普通员工权限负例和审计动作均通过;报告 `artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json` |
|
||||
|
||||
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
|
||||
|
||||
@@ -172,4 +173,4 @@
|
||||
|
||||
- `ME6-3` 代表性企业微信真机门禁已关闭。
|
||||
- MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。
|
||||
- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障、REQ-COMPLETE-AUDIT 需求完成度复核、ATTV1 考勤统一入口和 AUTO-REMAINING-CLOSEOUT 剩余自动化收口均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同。LAUNCH-AUTO-OPS-V1 发现的公网 `payment_batch_v1=force_off` 漂移已由 AUTOREM 恢复为 `force_on`,正式自动巡检结论为 `GO_TO_OPERATE`;`erp_sync=force_off`,ERP 未来不接入 OA,正式支付接口、图片 OCR 和银行流水导入继续不进入当前路线。ATTV1 已部署:历史 OA 自建打卡 `attendance_v1` 关闭,OA 补卡/外出/加班审批和月度考勤解释已纳入统一考勤主线;企微打卡 live 同步能力依赖 `WECOM_CHECKIN_SECRET` 和企微 API 权限,缺配置时记录 `SKIP`。HRWD 当前补齐 OA 驱动企业微信通讯录同步,live 写入同样依赖通讯录 Secret、默认部门和 API 权限。
|
||||
- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障、REQ-COMPLETE-AUDIT 需求完成度复核、ATTV1 考勤统一入口、AUTO-REMAINING-CLOSEOUT 剩余自动化收口和 HRWD-CONTACT-LIVE-TEST 公网企微通讯录真实写入闭环均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同。LAUNCH-AUTO-OPS-V1 发现的公网 `payment_batch_v1=force_off` 漂移已由 AUTOREM 恢复为 `force_on`,正式自动巡检结论为 `GO_TO_OPERATE`;`erp_sync=force_off`,ERP 未来不接入 OA,正式支付接口、图片 OCR 和银行流水导入继续不进入当前路线。ATTV1 已部署:历史 OA 自建打卡 `attendance_v1` 关闭,OA 补卡/外出/加班审批和月度考勤解释已纳入统一考勤主线;企微打卡 live 同步能力依赖 `WECOM_CHECKIN_SECRET` 和企微 API 权限,缺配置时记录 `SKIP`。HRWD 已完成 OA 驱动企业微信通讯录同步,并通过 `HRWDTEST-20260517214548` 真实公网 create/update/disable 验证;测试成员按审计要求保留离职/禁用状态。
|
||||
|
||||
+3
-1
@@ -20,7 +20,7 @@
|
||||
|
||||
## 最近事实源链
|
||||
|
||||
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1。
|
||||
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST。
|
||||
|
||||
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
|
||||
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
|
||||
@@ -53,6 +53,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
||||
- LAUNCH-AUTO-OPS-V1 是 ATTV1 后的正式启用自动化收口合同:新增当前正式状态 QA-only runner、live feature flag 只读检查和统一考勤 live 检查;本地门禁、公网 readiness、PDF live OCR、统一考勤检查、移动矩阵和企业微信 UA/渲染矩阵均通过。显式企微打卡同步检查返回 `SKIP wecom_checkin_sync_disabled`。实际公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 记录的正式常开口径不一致,因此结论为 `PAUSE_FOR_FIX`;runner 已跳过付款批次写入,未切换 flag、未部署、未接真实支付或 ERP。
|
||||
- AUTO-REMAINING-CLOSEOUT 是 LAUNCH-AUTO-OPS-V1 后的剩余自动化收口合同:已受控恢复公网 `payment_batch_v1=force_on` 并保持 `erp_sync=force_off`,复跑正式启用后自动巡检通过;`LAUNCHAUTO` marker 测试报销 `BX-20260517-0027` 完成付款批次、回单、AP 导出和审计闭环;公网移动矩阵 6 passed,企业微信渲染矩阵 8 passed。企微打卡 live 同步仍因外部配置记录 `SKIP wecom_checkin_sync_disabled`,不影响 OA 统一考勤入口完成态。
|
||||
- HR-WECOM-DIRECTORY-V1 是 HRWD 当前合同:ERP 从冻结待办改为未来不接入 OA;人事生命周期和考勤同属 HR 大域但分模块实现;OA 为员工、权限、审批和审计主事实源,企业微信作为通讯录、入口、通知和打卡采集基础设施。新增 `hr_wecom_directory_sync_v1`、企业微信通讯录同步记录、入职创建/调岗更新/离职禁用和管理员重试;缺通讯录 Secret 或 API 权限时 OA 人事闭环照常完成,同步记录为可解释 SKIP/失败。
|
||||
- HRWD-CONTACT-LIVE-TEST 已完成公网企微通讯录真实写入闭环:使用 `HRWDTEST-20260517214548` 测试员工验证 OA 创建员工后由 onboarding 触发企微成员创建、资料更新触发企微 update、离职 checklist 完成后禁用 OA 账号并触发企微 disable;同步均返回 `wecom_contact_synced`、`errcode=0`,普通员工同步记录权限负例和审计动作核验通过。测试成员按约定保留离职/禁用状态,不删除。
|
||||
|
||||
## 当前活跃治理流
|
||||
|
||||
@@ -92,6 +93,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
||||
| `LAUNCH-AUTO-OPS-V1` | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv` | 阶段收口:自动化完成,结论 `PAUSE_FOR_FIX` | 正式启用后自动 runner、live flag 只读校验、考勤统一入口 live 检查、移动/企微矩阵和证据回填;报告 `artifacts/qa-flow/launch-auto-ops-20260517-175548/` | 不切换正式 flag、不部署、不接真实支付、不接 ERP;`payment_batch_v1` 当前 force_off 漂移需单独决策恢复 |
|
||||
| `AUTO-REMAINING-CLOSEOUT` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv` | 已收口:剩余自动化运营门禁通过,结论 `GO_TO_OPERATE` | 恢复正式 `payment_batch_v1=force_on`、复跑 `run-launch-auto-ops.sh`、付款批次常开闭环、移动/企微矩阵、企微同步 SKIP 证据;报告 `artifacts/qa-flow/launch-auto-ops-20260517-193317/` | 不部署、不改业务代码、不接真实支付、不接 ERP、不开放图片 OCR、不导入银行流水;企微打卡同步外部配置仍需后台配置 |
|
||||
| `HR-WECOM-DIRECTORY-V1` | `issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv` | 已收口:人事生命周期与企业微信通讯录同步闭环已部署公网 | ERP 永不接入 OA;入职/调岗/资料变更/离职驱动企微通讯录创建、更新、禁用;员工管理和详情展示同步状态与重试;部署后 live smoke、HRWD 端点 SKIP 检查、公网移动/企微矩阵通过;seed 已改为不覆盖正式 flag | 不使用企业微信原生审批、不接真实支付、不开放图片 OCR、不导入银行流水;企微通讯录真实写入依赖后台 Secret、默认部门和 API 权限 |
|
||||
| `HRWD-CONTACT-LIVE-TEST` | `issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv` | 已收口:公网企微通讯录真实写入闭环通过 | `HRWDTEST-20260517214548` 测试员工完成 OA 创建、onboarding 企微 create、资料 update、offboarding 企微 disable、OA 禁用、登录负例、权限负例和审计核验;报告 `artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json` | 不删除企业微信测试成员、不重置数据库、不改业务 API/schema/UI、不部署;测试成员最终保留离职/禁用状态 |
|
||||
|
||||
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||
"HCLT-0","合同建档","记录用户已批准创建并禁用一个带 HRWDTEST 标记的公网 OA 测试员工和企业微信测试成员;本轮只做 QA-only runner、公网闭环验证和事实源回填,不改业务 API/schema/UI。","1) Issue CSV 校验通过;2) 范围明确为 HRWDTEST 创建-更新-禁用闭环;3) 明确不删除企微成员、不打印 secret/token/cookie/完整手机号;4) 不涉及 ERP、真实支付、图片 OCR、银行流水导入、生产恢复或数据库重置。","受影响流程:HRWD 公网验证合同 -> 后续 QA runner;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv","[]","回滚路径:回退本 CSV 和文档回填;运行期测试员工不删除,按离职/禁用保留审计链。"
|
||||
"HCLT-1","公网前置核验","核验 /ready、live smoke、正式 feature flags 和企业微信入口状态,确认 employee_lifecycle_v1、hr_wecom_directory_sync_v1、payment_batch_v1、erp_sync 均符合正式口径。","1) live smoke env strict 通过;2) feature flag 检查通过:payment_batch_v1=force_on、erp_sync=force_off、employee_lifecycle_v1=enabled、hr_wecom_directory_sync_v1=force_on;3) 输出不包含 secret、token、cookie 或完整手机号。","受影响流程:公网前置 -> HRWD 通讯录写入安全门;验证步骤:set -a && source ~/.hyb-oa/live-smoke.env && set +a;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-live-smoke-env.mjs --strict;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-live-feature-flags.mjs --expect payment_batch_v1=force_on --expect erp_sync=force_off --expect employee_lifecycle_v1=enabled --expect hr_wecom_directory_sync_v1=force_on。","node|bash","DONE","DONE","DONE","scripts/qa/check-live-smoke-env.mjs|scripts/qa/check-live-feature-flags.mjs","[""HCLT-0""]","公网只读前置;失败则不创建测试员工。"
|
||||
"HCLT-2","QA-only runner","新增 scripts/qa/run-hrwd-contact-live-test.mjs,要求 HRWD_CONTACT_LIVE_APPROVED=1 才允许公网写入,统一使用 live smoke 账号、HRWDTEST marker 和脱敏输出。","1) 缺 HRWD_CONTACT_LIVE_APPROVED=1 时 runner 拒绝写入;2) runner 不打印一次性密码、完整手机号、cookie、token、secret 或企微原始 payload;3) node --check 通过;4) 仅新增 QA-only 脚本。","受影响流程:QA runner -> 管理员登录 -> 测试员工闭环;验证步骤:node --check scripts/qa/run-hrwd-contact-live-test.mjs;git diff --check。","node|bash","DONE","DONE","DONE","scripts/qa/run-hrwd-contact-live-test.mjs","[""HCLT-0""]","如脚本自身阻断可最小修复;若发现业务代码缺陷,停止并另建 hotfix Issue。"
|
||||
"HCLT-3","入职创建","管理员创建 HRWDTEST 测试员工,payload 使用 syncWecomDirectory=false;随后发起 onboarding,由 onboarding 钩子触发企业微信成员创建。","1) OA 测试员工创建成功并返回脱敏员工 ID;2) onboarding 后企业微信通讯录 create 同步 success;3) 企微 errcode=0;4) 创建失败时自动换测试手机号重试一次;5) 创建成功后不删除测试员工。","受影响流程:管理员 -> 创建员工 -> 入职启动 -> 企微通讯录创建 -> 绑定;验证步骤:HRWD_CONTACT_LIVE_APPROVED=1 QA_ISSUE_ID=HCLT LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/run-hrwd-contact-live-test.mjs。","node|fetch","DONE","DONE","DONE","scripts/qa/run-hrwd-contact-live-test.mjs","[""HCLT-1"",""HCLT-2""]","测试数据 marker:HRWDTEST;手机号 199 开头,只输出 mask。"
|
||||
"HCLT-4","资料更新","修改 HRWDTEST 测试员工职位字段,验证企业微信通讯录 update 成功。","1) OA profile patch 成功;2) 企微 update 同步 success;3) 企微 errcode=0;4) 同步记录可由管理员读取。","受影响流程:管理员 -> 修改员工资料 -> 企微通讯录更新 -> 同步记录;验证步骤:同 HCLT-3 runner 输出 update 阶段 PASS。","node|fetch","DONE","DONE","DONE","scripts/qa/run-hrwd-contact-live-test.mjs","[""HCLT-3""]","仅修改 HRWDTEST 测试员工;不触碰真实员工。"
|
||||
"HCLT-5","离职禁用","发起 HRWDTEST 测试员工离职并完成 checklist,验证 OA 账号归档/禁用、会话失效、企业微信成员 disable 成功。","1) offboarding start 成功;2) checklist 完成后 OA 员工 status=archived 或 employmentStatus=inactive;3) 企微 disable 同步 success 且 errcode=0;4) 离职后使用一次性密码登录失败;5) 企业微信成员保留禁用不删除。","受影响流程:管理员 -> 离职启动 -> checklist -> OA 账号禁用 -> 企微成员禁用 -> 登录负例;验证步骤:同 HCLT-3 runner 输出 disable 和 login-negative 阶段 PASS。","node|fetch","DONE","DONE","DONE","scripts/qa/run-hrwd-contact-live-test.mjs","[""HCLT-4""]","若禁用失败,停止并记录 PAUSE_FOR_FIX;不删除企微成员。"
|
||||
"HCLT-6","权限审计","验证普通员工不可查看/重试他人同步状态,管理员可查看同步记录,审计包含创建、更新、禁用或重试相关动作。","1) 普通员工读取 HRWDTEST 同步状态返回 403/权限拒绝;2) 管理员可读取同步记录;3) 审计日志能查到 WECOM_DIRECTORY_CREATE、WECOM_DIRECTORY_UPDATE、WECOM_DIRECTORY_DISABLE;4) 若审计接口不可用则明确 FAIL,不伪造通过。","受影响流程:权限边界 -> 同步记录 -> 审计日志;验证步骤:同 HCLT-3 runner 输出 permission 和 audit 阶段 PASS。","node|fetch","DONE","DONE","DONE","scripts/qa/run-hrwd-contact-live-test.mjs","[""HCLT-5""]","审计输出只记录动作和数量,不打印 extra 中的敏感字段。"
|
||||
"HCLT-7","收口回填","回填 CSV、人工试运行报告和事实源状态,运行公网 readiness,中文提交。","1) Issue CSV 按实测回填 DONE/阻断;2) docs/trialrun/人工试运行报告.md、docs/文档索引.md、docs/文档事实源状态矩阵.md 已记录 HCLT 结果;3) run-live-smoke-readiness 通过;4) audit-docs、git diff --check 通过;5) 中文 commit 引用 HCLT。","受影响流程:HRWD 通讯录公网闭环 -> 事实源 -> 公网 readiness -> 提交;验证步骤:QA_ISSUE_ID=HCLT LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;node scripts/qa/audit-docs.mjs --check-registry;git diff --check;git commit -m 'HCLT 收口企微通讯录公网闭环测试'。","bash|node|git","DONE","DONE","DONE","docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv","[""HCLT-6""]","公网测试员工最终保留离职/禁用状态;不删除成员,不重置数据库。"
|
||||
|
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomInt } from "node:crypto";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://oa.hyinda.com";
|
||||
const DEFAULT_REPORT_ROOT = "artifacts/qa-flow";
|
||||
const REQUIRED_APPROVAL = "HRWD_CONTACT_LIVE_APPROVED";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL,
|
||||
issueId: process.env.QA_ISSUE_ID || "HCLT",
|
||||
reportDir: process.env.HRWD_CONTACT_LIVE_REPORT_DIR || "",
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg.startsWith("--base-url=")) {
|
||||
options.baseUrl = arg.slice("--base-url=".length);
|
||||
} else if (arg.startsWith("--report-dir=")) {
|
||||
options.reportDir = arg.slice("--report-dir=".length);
|
||||
}
|
||||
}
|
||||
|
||||
options.baseUrl = options.baseUrl.trim().replace(/\/$/, "");
|
||||
options.issueId = options.issueId.trim() || "HCLT";
|
||||
if (!options.reportDir) {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
options.reportDir = join(DEFAULT_REPORT_ROOT, `hrwd-contact-live-test-${stamp}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function getCredential(role) {
|
||||
const prefixByRole = {
|
||||
admin: "LIVE_SMOKE_ADMIN",
|
||||
employee: "LIVE_SMOKE_EMPLOYEE",
|
||||
};
|
||||
const prefix = prefixByRole[role];
|
||||
const identifier = process.env[`${prefix}_IDENTIFIER`]?.trim();
|
||||
const password = process.env[`${prefix}_PASSWORD`]?.trim();
|
||||
if (!identifier || !password) {
|
||||
return null;
|
||||
}
|
||||
return { identifier, password };
|
||||
}
|
||||
|
||||
async function fetchJson(url, init = {}) {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { ok: response.ok, status: response.status, json };
|
||||
}
|
||||
|
||||
function summarizeError(json) {
|
||||
const error = json?.error ?? {};
|
||||
return {
|
||||
code: typeof error.code === "string" ? error.code : null,
|
||||
featureFlag: typeof error.details?.featureFlag === "string" ? error.details.featureFlag : null,
|
||||
reason: typeof error.details?.reason === "string" ? error.details.reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function login(baseUrl, role, credential) {
|
||||
const result = await fetchJson(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-device-fingerprint": `hrwd-contact-live-${role}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: credential.identifier,
|
||||
password: credential.password,
|
||||
}),
|
||||
});
|
||||
const accessToken = result.json?.data?.accessToken;
|
||||
if (!result.ok || typeof accessToken !== "string" || !accessToken) {
|
||||
throw new Error(`${role} login failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim());
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async function callApi(baseUrl, token, method, pathName, body) {
|
||||
return fetchJson(`${baseUrl}${pathName}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
accept: "application/json",
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function maskedId(value) {
|
||||
const raw = String(value ?? "");
|
||||
if (!raw) return null;
|
||||
return raw.length <= 8 ? `${raw.slice(0, 2)}***` : `${raw.slice(0, 8)}***`;
|
||||
}
|
||||
|
||||
function buildMarker() {
|
||||
const now = new Date();
|
||||
const pad = (value) => String(value).padStart(2, "0");
|
||||
return [
|
||||
now.getFullYear(),
|
||||
pad(now.getMonth() + 1),
|
||||
pad(now.getDate()),
|
||||
pad(now.getHours()),
|
||||
pad(now.getMinutes()),
|
||||
pad(now.getSeconds()),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function randomTestPhone() {
|
||||
return `199${String(randomInt(0, 100_000_000)).padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
function buildTestPayload(marker, attempt) {
|
||||
const suffix = attempt === 0 ? marker : `${marker}R${attempt}`;
|
||||
return {
|
||||
employeeNo: `HRWDTEST-${suffix}`,
|
||||
name: `HRWDTEST-${suffix}`,
|
||||
phone: randomTestPhone(),
|
||||
email: `hrwdtest-${suffix.toLowerCase()}@hyinda.com`,
|
||||
title: "HRWDTEST-Onboarding",
|
||||
roles: ["employee"],
|
||||
syncWecomDirectory: false,
|
||||
};
|
||||
}
|
||||
|
||||
function syncRunSummary(run) {
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
const responseSummary = run.responseSummary && typeof run.responseSummary === "object" ? run.responseSummary : {};
|
||||
return {
|
||||
operation: run.operation ?? null,
|
||||
syncStatus: run.status ?? null,
|
||||
code: run.errorCode ?? null,
|
||||
errcode: responseSummary.errcode ?? null,
|
||||
externalUserIdPrefix: maskedId(run.externalUserId),
|
||||
};
|
||||
}
|
||||
|
||||
function assertWecomSyncSuccess(label, syncResult, expectedOperation) {
|
||||
const run = syncResult?.run ?? null;
|
||||
const errcode = run?.responseSummary?.errcode;
|
||||
if (
|
||||
!syncResult
|
||||
|| syncResult.status !== "success"
|
||||
|| syncResult.code !== "wecom_contact_synced"
|
||||
|| run?.operation !== expectedOperation
|
||||
|| Number(errcode ?? 0) !== 0
|
||||
) {
|
||||
const summary = syncRunSummary(run);
|
||||
throw new Error(`${label} failed: ${JSON.stringify({
|
||||
status: syncResult?.status ?? null,
|
||||
code: syncResult?.code ?? null,
|
||||
run: summary,
|
||||
})}`);
|
||||
}
|
||||
return syncRunSummary(run);
|
||||
}
|
||||
|
||||
function passStep(name, details = {}) {
|
||||
return { name, ...details, status: "PASS" };
|
||||
}
|
||||
|
||||
function failStep(name, details = {}) {
|
||||
return { name, ...details, status: "FAIL" };
|
||||
}
|
||||
|
||||
async function createEmployeeWithRetry(baseUrl, adminToken, marker) {
|
||||
let lastError = null;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const payload = buildTestPayload(marker, attempt);
|
||||
const result = await callApi(baseUrl, adminToken, "POST", "/api/users", payload);
|
||||
if (result.ok) {
|
||||
const data = result.json?.data ?? {};
|
||||
return {
|
||||
employee: data,
|
||||
oneTimePassword: data.oneTimePassword,
|
||||
marker: payload.name,
|
||||
};
|
||||
}
|
||||
lastError = {
|
||||
httpStatus: result.status,
|
||||
error: summarizeError(result.json),
|
||||
};
|
||||
if (result.status !== 409) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new Error(`employee create failed: ${JSON.stringify(lastError)}`);
|
||||
}
|
||||
|
||||
function getPendingOffboardingTasks(profileLike) {
|
||||
const tasks = profileLike?.lifecycle?.checklistTasks ?? profileLike?.checklistTasks ?? [];
|
||||
return tasks.filter((task) => task.scene === "offboarding" && task.status === "pending");
|
||||
}
|
||||
|
||||
async function getDirectoryState(baseUrl, adminToken, employeeId) {
|
||||
const result = await callApi(baseUrl, adminToken, "GET", `/api/employees/${employeeId}/wecom-directory-sync`);
|
||||
if (!result.ok) {
|
||||
throw new Error(`directory state failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim());
|
||||
}
|
||||
return result.json?.data ?? {};
|
||||
}
|
||||
|
||||
async function assertAuditAction(baseUrl, adminToken, action, targetId, dateFrom) {
|
||||
const params = new URLSearchParams({
|
||||
action,
|
||||
targetType: "employee",
|
||||
page: "1",
|
||||
pageSize: "100",
|
||||
dateFrom,
|
||||
});
|
||||
const result = await callApi(baseUrl, adminToken, "GET", `/api/audit-logs?${params.toString()}`);
|
||||
if (!result.ok) {
|
||||
throw new Error(`audit ${action} failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim());
|
||||
}
|
||||
const items = result.json?.data?.items ?? [];
|
||||
const matched = items.some((item) => item.targetId === targetId && item.action === action);
|
||||
if (!matched) {
|
||||
throw new Error(`audit ${action} not found for target`);
|
||||
}
|
||||
return { action, matched: true };
|
||||
}
|
||||
|
||||
async function assertEmployeeCannotAccessSync(baseUrl, employeeToken, targetEmployeeId) {
|
||||
const view = await callApi(baseUrl, employeeToken, "GET", `/api/employees/${targetEmployeeId}/wecom-directory-sync`);
|
||||
if (view.status !== 403) {
|
||||
throw new Error(`employee sync view should be forbidden, got HTTP ${view.status}`);
|
||||
}
|
||||
const retry = await callApi(baseUrl, employeeToken, "POST", `/api/employees/${targetEmployeeId}/wecom-directory-sync/retry`, {
|
||||
operation: "update",
|
||||
});
|
||||
if (retry.status !== 403) {
|
||||
throw new Error(`employee sync retry should be forbidden, got HTTP ${retry.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (process.env[REQUIRED_APPROVAL] !== "1") {
|
||||
throw new Error(`${REQUIRED_APPROVAL}=1 is required before public HRWDTEST writes`);
|
||||
}
|
||||
|
||||
const adminCredential = getCredential("admin");
|
||||
const employeeCredential = getCredential("employee");
|
||||
if (!adminCredential || !employeeCredential) {
|
||||
throw new Error("missing LIVE_SMOKE_ADMIN_* or LIVE_SMOKE_EMPLOYEE_* credentials");
|
||||
}
|
||||
|
||||
mkdirSync(options.reportDir, { recursive: true });
|
||||
const startedAt = new Date().toISOString();
|
||||
const markerStamp = buildMarker();
|
||||
const steps = [];
|
||||
let createdEmployeeId = null;
|
||||
let createdMarker = `HRWDTEST-${markerStamp}`;
|
||||
let finalStatus = "FAIL";
|
||||
|
||||
try {
|
||||
const adminToken = await login(options.baseUrl, "admin", adminCredential);
|
||||
steps.push(passStep("admin-login"));
|
||||
const employeeToken = await login(options.baseUrl, "employee", employeeCredential);
|
||||
steps.push(passStep("employee-login"));
|
||||
|
||||
const created = await createEmployeeWithRetry(options.baseUrl, adminToken, markerStamp);
|
||||
const employee = created.employee;
|
||||
createdEmployeeId = employee.id;
|
||||
createdMarker = created.marker;
|
||||
const oneTimePassword = created.oneTimePassword;
|
||||
if (!createdEmployeeId || !oneTimePassword) {
|
||||
throw new Error("employee create response missing id or one-time password");
|
||||
}
|
||||
steps.push(passStep("employee-created", {
|
||||
employeeIdPrefix: maskedId(createdEmployeeId),
|
||||
marker: createdMarker,
|
||||
wecomSyncSkippedAtCreate: employee.wecomDirectorySync == null,
|
||||
}));
|
||||
|
||||
const onboarding = await callApi(options.baseUrl, adminToken, "POST", `/api/employees/${createdEmployeeId}/onboarding/start`, {
|
||||
effectiveDate: new Date().toISOString().slice(0, 10),
|
||||
notes: `${options.issueId} ${createdMarker}`,
|
||||
});
|
||||
if (!onboarding.ok) {
|
||||
throw new Error(`onboarding failed: HTTP ${onboarding.status} ${summarizeError(onboarding.json).code ?? ""}`.trim());
|
||||
}
|
||||
const createSync = assertWecomSyncSuccess("wecom create", onboarding.json?.data?.wecomDirectorySync, "create");
|
||||
steps.push(passStep("wecom-create", createSync));
|
||||
|
||||
await assertEmployeeCannotAccessSync(options.baseUrl, employeeToken, createdEmployeeId);
|
||||
steps.push(passStep("employee-sync-permission-denied"));
|
||||
|
||||
const updatedTitle = `HRWDTEST-Update-${markerStamp}`;
|
||||
const update = await callApi(options.baseUrl, adminToken, "PATCH", `/api/employees/${createdEmployeeId}/profile`, {
|
||||
title: updatedTitle,
|
||||
});
|
||||
if (!update.ok) {
|
||||
throw new Error(`profile update failed: HTTP ${update.status} ${summarizeError(update.json).code ?? ""}`.trim());
|
||||
}
|
||||
const updateSync = assertWecomSyncSuccess("wecom update", update.json?.data?.wecomDirectorySync, "update");
|
||||
steps.push(passStep("wecom-update", updateSync));
|
||||
|
||||
const offboarding = await callApi(options.baseUrl, adminToken, "POST", `/api/employees/${createdEmployeeId}/offboarding/start`, {
|
||||
effectiveDate: new Date().toISOString().slice(0, 10),
|
||||
lastWorkingDate: new Date().toISOString().slice(0, 10),
|
||||
notes: `${options.issueId} ${createdMarker}`,
|
||||
});
|
||||
if (!offboarding.ok) {
|
||||
throw new Error(`offboarding start failed: HTTP ${offboarding.status} ${summarizeError(offboarding.json).code ?? ""}`.trim());
|
||||
}
|
||||
steps.push(passStep("offboarding-started"));
|
||||
|
||||
let profile = offboarding.json?.data ?? {};
|
||||
let tasks = getPendingOffboardingTasks(profile);
|
||||
if (!tasks.length) {
|
||||
const latestProfile = await callApi(options.baseUrl, adminToken, "GET", `/api/employees/${createdEmployeeId}/profile`);
|
||||
if (!latestProfile.ok) {
|
||||
throw new Error(`profile fetch failed: HTTP ${latestProfile.status} ${summarizeError(latestProfile.json).code ?? ""}`.trim());
|
||||
}
|
||||
profile = latestProfile.json?.data ?? {};
|
||||
tasks = getPendingOffboardingTasks(profile);
|
||||
}
|
||||
if (!tasks.length) {
|
||||
throw new Error("offboarding checklist has no pending tasks");
|
||||
}
|
||||
|
||||
let disableSync = null;
|
||||
for (const task of tasks) {
|
||||
const completed = await callApi(options.baseUrl, adminToken, "POST", `/api/employees/${createdEmployeeId}/checklist-tasks/${task.id}/complete`, {});
|
||||
if (!completed.ok) {
|
||||
throw new Error(`checklist complete failed: HTTP ${completed.status} ${summarizeError(completed.json).code ?? ""}`.trim());
|
||||
}
|
||||
if (completed.json?.data?.wecomDirectorySync) {
|
||||
disableSync = completed.json.data.wecomDirectorySync;
|
||||
}
|
||||
}
|
||||
const disableSummary = assertWecomSyncSuccess("wecom disable", disableSync, "disable");
|
||||
steps.push(passStep("wecom-disable", {
|
||||
...disableSummary,
|
||||
completedTaskCount: tasks.length,
|
||||
}));
|
||||
|
||||
const finalProfile = await callApi(options.baseUrl, adminToken, "GET", `/api/employees/${createdEmployeeId}/profile`);
|
||||
if (!finalProfile.ok) {
|
||||
throw new Error(`final profile failed: HTTP ${finalProfile.status} ${summarizeError(finalProfile.json).code ?? ""}`.trim());
|
||||
}
|
||||
const finalEmployee = finalProfile.json?.data?.employee ?? {};
|
||||
if (finalEmployee.status !== "archived" || finalEmployee.employmentStatus !== "inactive") {
|
||||
throw new Error(`employee final state mismatch: ${JSON.stringify({
|
||||
status: finalEmployee.status ?? null,
|
||||
employmentStatus: finalEmployee.employmentStatus ?? null,
|
||||
})}`);
|
||||
}
|
||||
steps.push(passStep("oa-employee-disabled", {
|
||||
employeeStatus: finalEmployee.status,
|
||||
employmentStatus: finalEmployee.employmentStatus,
|
||||
}));
|
||||
|
||||
const deniedLogin = await fetchJson(`${options.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-device-fingerprint": "hrwd-contact-live-disabled-employee",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: employee.email,
|
||||
password: oneTimePassword,
|
||||
}),
|
||||
});
|
||||
if (deniedLogin.ok || deniedLogin.json?.data?.accessToken) {
|
||||
throw new Error("disabled employee login unexpectedly succeeded");
|
||||
}
|
||||
steps.push(passStep("disabled-employee-login-denied", {
|
||||
httpStatus: deniedLogin.status,
|
||||
code: summarizeError(deniedLogin.json).code,
|
||||
}));
|
||||
|
||||
const directoryState = await getDirectoryState(options.baseUrl, adminToken, createdEmployeeId);
|
||||
const latestSync = directoryState.wecomDirectory?.latestSync ?? null;
|
||||
steps.push(passStep("admin-sync-state-readable", {
|
||||
itemCount: Array.isArray(directoryState.items) ? directoryState.items.length : null,
|
||||
latest: syncRunSummary(latestSync),
|
||||
}));
|
||||
|
||||
const auditResults = [];
|
||||
for (const action of ["WECOM_DIRECTORY_CREATE", "WECOM_DIRECTORY_UPDATE", "WECOM_DIRECTORY_DISABLE"]) {
|
||||
auditResults.push(await assertAuditAction(options.baseUrl, adminToken, action, createdEmployeeId, startedAt));
|
||||
}
|
||||
steps.push(passStep("audit-actions", {
|
||||
actions: auditResults.map((item) => item.action),
|
||||
}));
|
||||
|
||||
finalStatus = "PASS";
|
||||
} catch (error) {
|
||||
steps.push(failStep("hrwd-contact-live-test", {
|
||||
message: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500),
|
||||
}));
|
||||
} finally {
|
||||
const summary = {
|
||||
checkedAt: new Date().toISOString(),
|
||||
issueId: options.issueId,
|
||||
baseUrl: options.baseUrl,
|
||||
status: finalStatus,
|
||||
marker: createdMarker,
|
||||
employeeIdPrefix: maskedId(createdEmployeeId),
|
||||
finalPolicy: "keep_offboarded_and_wecom_disabled_do_not_delete",
|
||||
steps,
|
||||
};
|
||||
|
||||
writeFileSync(join(options.reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
||||
console.log(`[hrwd-contact-live-test] status=${summary.status} base=${summary.baseUrl} marker=${summary.marker}`);
|
||||
for (const step of steps) {
|
||||
const detail = step.httpStatus ? ` http=${step.httpStatus}` : "";
|
||||
const code = step.code ? ` code=${step.code}` : "";
|
||||
console.log(`[hrwd-contact-live-test] ${step.name} status=${step.status}${detail}${code}`);
|
||||
}
|
||||
console.log(`[hrwd-contact-live-test] report=${options.reportDir}`);
|
||||
}
|
||||
|
||||
if (finalStatus !== "PASS") {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[hrwd-contact-live-test][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户