diff --git a/.trellis/spec/hyb-oa/wecom-boundary.md b/.trellis/spec/hyb-oa/wecom-boundary.md index 3fa012db..c3bd383e 100644 --- a/.trellis/spec/hyb-oa/wecom-boundary.md +++ b/.trellis/spec/hyb-oa/wecom-boundary.md @@ -251,6 +251,9 @@ business role assignment -> separate administrator-audited operation - CLI: `npm run import:wecom-directory-export -- --input=- --dry-run`. - Apply: `WECOM_DIRECTORY_EXPORT_APPLY=1 ... --input=- --apply --actor-employee-no=`. +- Binary stdin reader: `fs.readFileSync(0)`; do not assume + `fs.promises.readFile(0)` accepts a numeric file descriptor on the production + Node runtime. - User API and frontend employee/user types expose `phone: string | null`; create/update accepts an omitted or explicit `null` phone. @@ -266,6 +269,9 @@ business role assignment -> separate administrator-audited operation supplied valid phone participates in update and unique-conflict checks. - The XLSX is accepted only from stdin and defaults to dry-run. Apply requires both `--apply` and `WECOM_DIRECTORY_EXPORT_APPLY=1`, plus an active actor. +- Read the XLSX directly from fd 0 without a temporary file. A runtime that + rejects the stdin read must stop before Prisma sync; do not fall back to + persisting the workbook on the host or in the container. - CLI output contains counts and categories only. The workbook, full local path, names, accounts, phones, emails, departments and raw rows must not enter Git, Trellis artifacts or reports. @@ -283,6 +289,9 @@ business role assignment -> separate administrator-audited operation `duplicate_values`; error output contains counts only. - Existing OA unique-field or binding conflict -> conflict; no automatic merge. - `--apply` without confirmation env or active actor -> refuse before sync. +- Promise-based stdin fd read returns `ERR_INVALID_ARG_TYPE` on the deployed + Node runtime -> no sync/write; use the fd-compatible reader and rerun dry-run + before requesting apply approval. - Migration rollback with any `phone IS NULL` row -> stop; do not fill fake values merely to restore `NOT NULL`. @@ -293,6 +302,8 @@ business role assignment -> separate administrator-audited operation only aggregate counts. - Base: a later export still omits a member phone; the member's existing OA phone is preserved. +- Base: the local and production Node versions differ; the same binary stdin + workbook parses through `fs.readFileSync(0)` without a temporary file. - Bad: write an empty string or shared placeholder, use personal email in place of the required enterprise email for bulk identity, or auto-grant a business role during import. @@ -302,6 +313,8 @@ business role assignment -> separate administrator-audited operation - Prisma validation plus a migration review proving only `DROP NOT NULL`. - Unit tests for instruction rows/Chinese headers, five-present/one-missing, malformed phone, missing enterprise email and duplicate account/phone/email. +- Unit regression asserts the CLI stdin helper calls `fs.readFileSync(0)`; + production dry-run must prove aggregate parsing/sync counts before apply. - Route/service tests for create/update/import `null`, non-empty unique checks, OAuth missing phone and empty-source preservation of an existing phone. - Frontend typecheck/build and displays that render a clear empty-phone label. @@ -323,3 +336,15 @@ phone: source.phone || "00000000000" phone: source.phone || null // Existing employee updates omit `phone` entirely when the source is empty. ``` + +#### Wrong + +```js +const workbook = await fs.promises.readFile(0); +``` + +#### Correct + +```js +const workbook = fs.readFileSync(0); +``` diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md index 55b0f010..b1cee2d8 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md @@ -75,14 +75,20 @@ isolated drill ## 真实职责写入门 -- 先读取全量候选并在内存中按目标姓名匹配,只输出两个匹配计数、身份哈希是否不同、缺失字段数和计划动作数量。 +- 从管理员通讯录文件在本地内存中筛选两名目标,只把两人子集通过 stdin 送入生产 dry-run/apply;其余四人不进入生产输入、不创建账号,留待后续独立批次。 +- dry-run 和 apply 只输出两个匹配计数、身份是否不同、缺失字段数、冲突数和计划动作数量,不持久化筛选文件、完整路径或原始行。 - 只有两个匹配计数均为 1、企微身份不同、均为 active、必填资料完整且手机号/邮箱不冲突时,才进入 apply。 - 员工和企微绑定通过既有 HR master sync 服务创建或更新;随后通过既有角色写入语义分别设置 `employee + finance` 和 `employee + ceo`。 -- 角色变更递增 `token_version`、撤销活动会话并写 `SYSTEM_USER_ROLES_SET` 审计;最终只核对数量、角色集合、不同 employee id、审计存在和付款业务零写入。 +- `finance` 的批准范围明确包含 `payroll.manage`、`employee.bank.read`、财务付款和付款批次管理,不包含 `asset.transfer`;不以 `finance_mgr` 解决 CEO 本人报销兜底。 +- 角色变更递增 `token_version`、撤销活动会话并写 `SYSTEM_USER_ROLES_SET` 审计;最终只核对数量、角色集合、不同 employee id、角色待办计数变化、审计存在和付款业务零写入。 +- maker-checker 只验证付款批次创建人不能复核同一批次,不宣称费用审批与财务处理完全分离;生产不创建测试付款批次,使用合成路由测试和生产只读身份/权限证据。 +- 回滚是通过相同角色接口恢复两人的写入前角色集合,并再次撤销会话、保留审计;不得通过整库恢复回退角色。 - 任一门禁失败即停止,不进行部分角色写入;如员工同步已经成功而角色阶段失败,保留同步审计并立即报告,不用删除真实员工记录进行破坏性回滚。 ## 企微本人敏感信息授权边界 +- 本轮生产身份建档不走本人授权,而走管理员通讯录两人子集。以下 OAuth 合同继续保留为已实现、默认关闭的未来补资料能力,本轮验收标记为不适用,不要求成员点击授权链接。 + - `/user/get` 和人事助手批量读取继续只负责 userid、姓名、部门等可读字段;不得把批量缺字段误判为企微完全不支持手机号和邮箱。 - 2022-06-20 后新建自建应用的敏感字段走独立 OAuth 数据流:管理员在应用详情选择字段,成员以 `snsapi_privateinfo` 手工确认,`getuserinfo` 返回短时 `user_ticket`,服务端立即调用 `/cgi-bin/auth/getuserdetail`。 - `user_ticket`、授权 code 和完整敏感响应不得写日志、任务证据或审计 extra;只保存业务所需的规范化手机号/邮箱及字段存在性审计。 @@ -115,6 +121,7 @@ GET /auth/wecom/login?mode=onboard - `Employee.phone` 改为 nullable unique:缺失值统一保存为 `NULL`,非空值继续唯一。企业邮箱仍必填且唯一,企微账号仍是绑定主键;不生成占位手机号。 - 来源未提供手机号时,不得把既有 OA 手机号清空;来源提供手机号时才进入变更和唯一性检查。本人授权同样允许手机号缺失,但邮箱或企业邮箱仍必须有效。 - apply 需要命令参数和独立环境变量双门,且真实运行另行批准;dry-run 与 apply 的报告都只含计数、动作、冲突类别和脱敏摘要。 +- 本轮在内存中筛选两名目标后再送入 stdin;筛选结果不落盘,目标外四人不进入 dry-run/apply,也不因出现在原始导出文件中而获得 OA 活跃账号。 - 生产迁移回滚前必须确认不存在 `phone IS NULL`;否则停止回滚,不用假数据填充。 ## 部署目录统一设计约束 diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md index 91dfb674..bb065d4b 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md @@ -27,8 +27,12 @@ - [x] 让成对 manifest 尊重显式 `BUILD_SHA` 并支持活动镜像 digest;补 Node/Python 等价与非法来源标识负向测试。 - [x] 把旧 Compose project 实测、镜像 digest、58 项迁移与关键表计数、影子逐表/附件计数验收、停机后复制并保留旧目录写入 runbook/checklist/spec。 - [x] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。 -- [ ] 用户在企微应用详情选择手机、邮箱、企业邮箱敏感字段;部署开关开启后,两名目标分别通过一次性链接本人确认授权。 -- [ ] 两人自助开通成功后写入获批 `finance` / `ceo` 职责,复核不同身份、maker-checker、会话撤销、审计和付款零写入。 +- [x] 本轮不适用/延后:不启用企微敏感资料 OAuth 开关,不要求两名目标点击一次性授权;入口保持默认关闭,未来仅用于本人补资料。 +- [x] 从管理员通讯录在内存中筛选两名目标,先执行生产脱敏 dry-run;其余四人不进入输入、不创建账号,另轮处理。 +- [ ] dry-run 零冲突后另行批准 apply,确认两人账号/企微绑定唯一且初始角色严格为 `employee`,不持久化通讯录文件。 +- [ ] 记录两人原角色、活跃会话、角色待办和审计基线,再分别写入 `employee + finance` / `employee + ceo`;明确 `finance` 含薪资/银行资料权限且不含 `asset.transfer`,不授予 `finance_mgr`。 +- [ ] 复核角色接口递增 token version、撤销会话和 `SYSTEM_USER_ROLES_SET` 审计;以合成测试证明付款批次 maker-checker,不在生产创建测试付款数据,也不把结论扩大为费用审批完全分离。 +- [ ] 如需回滚,只通过相同角色接口恢复两人原角色集合并复核会话/审计,不恢复整库;记录 CEO 既有角色待办可见性变化及本人无主管报销继续阻断的已知限制。 - [x] 将 `Employee.phone` 改为 nullable unique,并同步员工创建/批量导入、企微 HR master、本人授权、登录查询和前端空值合同;不执行生产 migration。 - [x] 增加企微通讯录 XLSX/stdin 默认 dry-run 入口,复用 HR master 写入语义,apply 使用独立双门且本阶段不执行。 - [x] 用完全合成的通讯录覆盖“5 个手机号 + 1 个缺失”、重复账号/手机号/邮箱、说明行/中文表头、既有手机号不被空来源清除和报告隐私门禁。 @@ -81,6 +85,8 @@ - `prisma validate`、四个后端改动文件语法、前端 typecheck/build、Trellis task/governance、敏感扫描、流程合同、文档注册审计和 `git diff --check` 通过。 - 管理员提供的真实通讯录仅执行解析级只读检查:6 名 active、5 个手机号、1 个缺失、6 个企业邮箱、0 个不完整;未连接数据库、未执行 apply、未分配角色、未发送企微消息,文件保持 Git 排除。 - 最终只读复核确认 migration 仅删除手机号 `NOT NULL`、唯一索引保留;导入及本人开通只创建 `employee`,不自动授予 `finance`、`ceo`、`admin` 或 `system_admin`;空来源更新省略 phone 字段,真实文件名、完整路径和个人字段未进入差异。 +- 获批生产脱敏 dry-run 只接收内存筛选的两人子集,结果为输入 2、active 2、不完整 0、计划创建 2、更新 0、冲突/跳过/失败均为 0;两名目标均有手机号和企业邮箱。dry-run 未写库、未创建账号、未分配角色,目标外四人未进入输入。 +- 生产 Node 对 Promise 版 `readFile(0)` 返回 `ERR_INVALID_ARG_TYPE`,正式 CLI 在解析前安全失败;本地改为 `fs.readFileSync(0)` 并增加 fd 读取回归。生产证据使用同一解析、同步和报告逻辑的 fd 兼容只读入口取得,正式 CLI 修复部署前不用于 apply。 ## 生产部署与 timer 收口证据(2026-07-27) diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md index b027fe9e..8116ea01 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md @@ -23,6 +23,7 @@ - 唯一匹配且必填资料、冲突检查通过后,将两名成员同步为 OA 员工,并分别配置 `employee + finance` 与 `employee + ceo`,形成不同自然人的付款制单/复核职责。 - 增加独立的一次性企微敏感资料授权入口:普通登录继续使用 `snsapi_base`,首次开通使用 `snsapi_privateinfo`;未绑定的在职可见成员授权成功后只自动获得 `employee` 角色。 - 支持管理员提供的企微通讯录 XLSX 通过 stdin 做脱敏 dry-run,并允许个别成员暂缺手机号;企业邮箱、企微账号、姓名和部门仍作为建档门禁。 +- 本轮通讯录导入只筛选两名已具备职责验收计划的目标成员;其余四名成员不进入 dry-run apply 输入、不创建活跃账号,明确留待后续独立批次。 - 修复部署脚本与现场端口、目录、容器和构建标识契约,统一真实运行来源;切换前保留活动数据、环境文件和旧容器回滚点。 ## Requirements @@ -40,9 +41,14 @@ - 真实同步前只输出匹配数量、身份是否不同、缺失字段数、冲突数和计划创建/更新数,不输出姓名、userid、手机号、邮箱、部门原文或原始 payload。 - `Employee.phone` 允许为 `NULL`,但不得写空字符串或假手机号;非空手机号继续保持唯一。企微账号和企业邮箱必须非空且唯一,缺手机号成员使用企微或邮箱登录,后续可由本人授权或管理员补齐。 - 真实写入必须复用现有员工同步、角色、会话撤销和审计语义;财务处理人与 CEO 复核人必须是两个不同企微身份,且不得把 `admin` 当作普通业务复核角色。 +- 目标角色固定为 `employee + finance` 与 `employee + ceo`,不授予 `finance_mgr`。接受 `finance` 同时具备 `payroll.manage` 和 `employee.bank.read`,意味着财务处理人可访问薪资和员工银行账号;`finance`、`finance_mgr` 均不得拥有 `asset.transfer`。 +- 本轮 maker-checker 验收只针对付款批次复核:创建人复核同一批次必须被 `maker_checker_conflict` 拒绝;不得把结论表述为费用审批、财务付款或全部财务操作已完全按自然人分离。 - 敏感资料授权必须显式受 `WECOM_OAUTH_ONBOARDING_ENABLED` 控制,默认关闭;成员必须在应用可见范围、企微状态 active,并至少实际同意邮箱或企业邮箱授权;手机号可暂缺,但返回时必须规范化并参与唯一性检查。 - `user_ticket` 只在服务端内存中用于一次 `getuserdetail` 调用,不写日志、Cookie、数据库、审计或错误详情;授权 code 和原始敏感响应同样不得持久化。 - 自助开通按企微 userid、非空手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。 +- 本轮真实建档改用管理员导出的通讯录两人子集:先以 stdin dry-run/apply 创建或更新 employee 与企微绑定,且新账号严格只有 `employee`;再由管理员单独写入附加角色。一次性 `snsapi_privateinfo/getuserdetail` 入口保持默认关闭,仅作为未来本人补资料能力,不作为本轮生产验收门禁。 +- 角色写入前后记录目标原角色集合、不同 employee 身份、对应角色待办计数、活跃会话数量和审计计数;回滚只把两人恢复为写入前角色集合,复用 token version、会话撤销和审计,不恢复整库。 +- CEO 角色会让既有 `assignee_role=ceo` 待办立即可见,并影响新提交生成的角色节点,但不重写既有审批快照,也不恢复过去失败的提交。CEO 本人无主管且没有 active 非本人 `finance_mgr` 时,报销保持阻断,本轮不通过扩权绕过。 - 普通 `snsapi_base` 登录、已绑定账号登录和现有绑定流程保持兼容,不因资料授权功能改成每次手工确认。 - 生产统一以仓库既定部署目录和 Compose 契约为目标,但活动 PostgreSQL 与附件数据是唯一数据事实源;停机切换不得复制运行中的 PostgreSQL 文件、启动第二套空库或覆盖环境变量。 - Compose 项目名、容器身份与 DB/附件目录必须在同一次获批停机窗口切换;DB 不发布宿主端口,备份不得猜测容器名或附件目录。 @@ -60,14 +66,14 @@ - [x] 相关 shell 语法、QA 脚本、Trellis 治理、敏感扫描和 `git diff --check` 通过。 - [ ] 在获得单独授权后,确认真实运行 commit、访问边界、职责分离、异机备份及恢复后 `/ready`;未授权前保持未勾选且不宣称正式可用。 - [x] 双 Secret 调用测试证明成员 ID 由通讯录 token 读取,成员详情、部门和 HR 资料由自建应用 token 读取。 -- [ ] 真实脱敏 dry-run 证明两个目标各唯一匹配、身份不同、必填资料和唯一约束无阻塞。 +- [x] 真实脱敏 dry-run 证明两个目标各唯一匹配、身份不同、必填资料和唯一约束无阻塞。 - [ ] 获批写入后,两名活跃员工分别具有 `employee + finance` 与 `employee + ceo`,企微绑定不同,角色审计存在且未产生付款业务写入。 - [x] 普通企微登录仍生成 `snsapi_base`,一次性开通入口生成 `snsapi_privateinfo`;缺少开关、`user_ticket`、成员授权字段、active 状态或唯一性时均拒绝创建(本地 synthetic 验证)。 -- [ ] 一次性授权通过 `getuserdetail` 获取成员同意的手机和邮箱,自动创建或补齐 OA 员工与企微绑定;新建账号角色严格等于 `employee`,日志和审计不含票据或完整敏感响应。 +- [x] 本轮不适用:生产不启用 `snsapi_privateinfo/getuserdetail` 本人授权,OAuth 入口保持默认关闭并延后为补资料能力;真实建档改用企微通讯录两人子集,仍要求新账号角色严格等于 `employee`,且不持久化通讯录文件或原始行。 - [x] 部署目录、Compose、环境文件、运维 timer 和活动容器来源统一;活动 DB/附件连续,远端 migrations 与批准提交一致,公网 `/ready` 和前端 marker 返回同一短构建标识。 - [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。 - [x] 部署入口拒绝隐式/相对数据目录及缺少数据模式,成对 manifest 可记录显式 build SHA 或活动镜像 digest,并有 Node/Python 等价测试。 -- [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `employee + finance` 与 `employee + ceo`;maker-checker、会话撤销和角色审计通过。 +- [ ] 两名目标成员经企微通讯录两人子集 dry-run/apply 后账号唯一、企微绑定不同且初始角色仅为 `employee`,再由管理员写入 `employee + finance` 与 `employee + ceo`;其余四人保持未导入,付款批次 maker-checker、会话撤销和角色审计通过。 - [x] 本地合成企微导出证明 5 人有手机号、1 人手机号为空时 dry-run 可通过;空值保存为 `NULL`,非空手机号/邮箱/企微账号重复均被拒绝,报告不含个人字段原文。 ## Out of Scope @@ -77,6 +83,7 @@ - 不引入 MinIO、云备份 SDK、全量灾备平台、自动跨地域复制或 24×7 值守。 - 不在仓库提交备份文件、恢复数据、环境变量、凭证、真实账号或完整本地路径。 - 不发送企微消息、不创建企微审批模板、不把邮箱改为可空、不写占位假数据,也不将敏感授权变成普通登录的强制步骤。 +- 不导入通讯录中的其余四名成员,不授予 `finance_mgr`,不修改审批规则来放行 CEO 本人无主管报销。 - 不在本任务完成企微调休、导航、报销或模块退役;它们属于后续批次。 ## Test Plan @@ -124,3 +131,5 @@ - 企微结构化探测证明 6 名成员的批量详情接口均成功并返回部门,但手机号、邮箱和企业邮箱可读数量均为 0;HR 接口均成功但没有 `staff_info`。官方文档确认这不代表字段不可获取:2022-06-20 后新建自建应用应由管理员在应用详情选择敏感字段,再由成员通过 `snsapi_privateinfo` 手工授权,并用 `user_ticket` 调 `/cgi-bin/auth/getuserdetail` 获取本人手机号、邮箱或企业邮箱。 - 当前 OA OAuth 只使用 `snsapi_base`,且没有调用 `/cgi-bin/auth/getuserdetail`,因此现有登录链路不能采集敏感字段。两名目标各唯一匹配且身份不同,但批量 dry-run 均缺 `phone`、`email`,真实写入已按门禁停止。 - 本轮已在本地实现默认关闭的一次性 `onboard` 入口;普通登录继续静默。全量后端门禁与安全补修后的企微聚焦回归结果记录在 `implement.md`。该证据不代表功能已部署,也不代表企微后台敏感字段已由管理员选择。 +- 2026-07-27 经明确授权,仅把管理员通讯录的两人内存子集通过 stdin 送入活动 server 做生产 dry-run:输入 2、active 2、手机号存在 2、企业邮箱存在 2、不完整 0,计划创建 2、更新 0、冲突 0、跳过 0、失败 0;未执行 apply、未写数据库、未持久化筛选文件,其余四人未进入输入。 +- 首次正式 CLI dry-run 暴露生产 Node 对 `fs.promises.readFile(0)` 不兼容并在解析前停止;随后用同一解析器、同一 HR master 服务和同一脱敏报告构造器,仅将 stdin 读取替换为 fd 兼容方式完成上述 dry-run。本地已修复 CLI 读取方式并补回归,修复部署前不得直接使用正式 CLI 执行 apply。 diff --git a/docs/产品口径.md b/docs/产品口径.md index f74d3ba0..633a0657 100644 --- a/docs/产品口径.md +++ b/docs/产品口径.md @@ -152,3 +152,14 @@ ### R-3 资产转移权限 - `asset.transfer` 只给 `administration / 资产管理员` - `finance_mgr` 有 `asset.manage` 但不拥有实物调拨权限,这是有意设计 + +### R-4 小团队财务处理与付款复核边界 +- 日常财务处理角色使用 `finance`,付款复核角色使用 `ceo`;不得为了负责人本人报销兜底而顺带授予权限更宽的 `finance_mgr`。 +- `finance` 除费用审批、财务付款和付款批次管理外,还包含 `payroll.manage` 与 `employee.bank.read`,因此会接触薪资数据和员工银行账号;这属于明确接受的财务职责范围,不是隐含副作用。 +- `ceo` 通过 `payment.batch.review` 承担付款批次复核。maker-checker 只保证付款批次创建人与复核人不同,不代表费用审批、财务付款和全部财务操作已经按自然人完全分离。 +- `finance`、`finance_mgr` 均不拥有 `asset.transfer`;实物调拨继续只属于 `administration`。 + +### R-5 CEO 本人无主管报销限制 +- 现有负责人本人报销规则只允许 active、非本人的 `finance_mgr` 作为 `ownerSelfFallbackRole`;普通 `finance` 不满足该兜底角色。 +- 当 CEO 本人没有可解析直属主管且公司没有合适的非本人 `finance_mgr` 时,报销提交保持阻断。不得通过给财务人员顺带增加 `finance_mgr`、使用 `admin` 代批或恢复整库来绕过。 +- 授予 `ceo` 后,既有 `assignee_role=ceo` 待办会对该角色持有人可见,新提交单据按当前规则生成 CEO 节点;既有审批快照不重路由,过去提交失败的单据也不会自动恢复。若未来要放开 CEO 本人报销,应另开产品任务调整兜底规则和最小权限,而不是在账号配置阶段扩权。 diff --git a/server/src/scripts/import-wecom-directory-export.js b/server/src/scripts/import-wecom-directory-export.js index 6fc18841..74d607b8 100644 --- a/server/src/scripts/import-wecom-directory-export.js +++ b/server/src/scripts/import-wecom-directory-export.js @@ -35,6 +35,10 @@ Reports contain counts and categories only; personal field values are never prin `); } +function readStdinBuffer() { + return fs.readFileSync(0); +} + function parseArgs(argv) { const options = { input: "-", @@ -258,7 +262,7 @@ async function main() { printHelp(); return; } - const parsed = await parseDirectoryExport(await fs.promises.readFile(0)); + const parsed = await parseDirectoryExport(readStdinBuffer()); resources = createPrismaClient(); const actorId = await resolveActorId(resources.prisma, options.actorEmployeeNo); const syncRun = await runWecomHrMasterSync({ @@ -293,4 +297,5 @@ module.exports = { findHeaderRow, parseArgs, parseDirectoryExport, + readStdinBuffer, }; diff --git a/server/tests/unit/scripts/import-wecom-directory-export.test.js b/server/tests/unit/scripts/import-wecom-directory-export.test.js index f981aa6c..7eb8fc37 100644 --- a/server/tests/unit/scripts/import-wecom-directory-export.test.js +++ b/server/tests/unit/scripts/import-wecom-directory-export.test.js @@ -1,3 +1,4 @@ +const fs = require("node:fs"); const ExcelJS = require("exceljs"); const { @@ -5,6 +6,7 @@ const { buildSafeReport, parseArgs, parseDirectoryExport, + readStdinBuffer, } = require("../../../src/scripts/import-wecom-directory-export"); async function buildDirectoryWorkbook(rows) { @@ -39,6 +41,17 @@ async function buildDirectoryWorkbook(rows) { } describe("WeCom directory export importer", () => { + it("reads stdin through the fd-compatible synchronous API", () => { + const expected = Buffer.from("synthetic-xlsx"); + const readFileSync = vi.spyOn(fs, "readFileSync").mockReturnValue(expected); + try { + expect(readStdinBuffer()).toBe(expected); + expect(readFileSync).toHaveBeenCalledWith(0); + } finally { + readFileSync.mockRestore(); + } + }); + it("requires stdin, the apply environment gate, and an actor for apply", () => { const previous = process.env.WECOM_DIRECTORY_EXPORT_APPLY; delete process.env.WECOM_DIRECTORY_EXPORT_APPLY;