From e3924a53c151f130d1cff56e9e74305452dc32d0 Mon Sep 17 00:00:00 2001 From: anonsaber Date: Wed, 30 Sep 2026 01:24:42 +0800 Subject: [PATCH] test: cover business config read-back and partial submit Adds the missing test coverage for the GET /api/business-config read-back and the partial-patch PUT, then syncs the docs to the behaviour the code actually has. src/notify.rs - PUT response body now carries "revision" alongside the x-business-config-revision header, which the SPA surfaces in its status line. - 4 test helpers (a router that returns (Router, MemoryState) so a config can be seeded first, a full valid wire value, a direct state writer that skips validation, and a wire value whose JMAP URL fails fast) plus 9 tests: - GET 401 leaks no business field at all (body is exactly error + request_id) - GET echoes the 10 non-secret values and returns secrets only as presence booleans; the serialised body is asserted to contain none of the 6 secret values (SAF-NO-SECRET-ECHO) - GET with nothing stored returns 200 configured:false revision:0, not 404 - a first save with an incomplete patch is rejected and writes nothing - a partial submit replaces only the fields it carries, verified by reading the merged result back over the same path the SPA uses - validation runs on the merged result, so changing one field cannot bypass the rest, and a rejected submit neither persists nor bumps the revision - telegram_chat_id is accepted as a number or a numeric string and a garbage string is rejected without overwriting the stored value - a typo in a field name is rejected rather than silently dropped src/config.rs - Pure deserialisation test for de_i64_from_number_or_string, with no router and no store. This is the only place that separates "the custom deserializer refuses the string" from "a later validation step refused it" - a real distinction, since the SPA submits telegram_chat_id as a string on a partial submit while the read-back model returns a number. cloudflare-worker/test/index.test.js - The forwarded-route test asserted a fixed upstream origin, which is not stable: the LB rotates its start index between tests. Assert the origin is one of the configured backends and check the path and method exactly instead. docs - 17 spots bring the docs in line with the shipped contract: GET is now documented, PUT as a partial patch, the worker safelist at 16 routes, and the preflight path listed as forwarded. - Corrections the docs had backwards: the response field is "configured" (revision is a u64, 0 when nothing is stored); Invalid returns 422, only Unreachable returns 503; there is no chat_allowlist_empty error code. - "Blank keeps the stored value" is now stated as a client-side contract. The backend does not distinguish an omitted field from an empty string - apply() uses unwrap_or_else, so an explicitly submitted "" really does clear the secret (validation then rejects it). Saying it was server-side was wrong. - POST /worker was listed as a forwarded route though it is not in SAFE_ROUTES, and the per-category counts did not match the lists they introduced. - POST /api/bootstrap does not share the patch-merge contract; it deserialises a full BusinessConfigWire, which is the only path that can create a config from nothing. - Re-anchored 9 src/notify.rs line refs that had drifted blank and raised the baseline to 86 passed / 0 failed / 2 ignored. Gates: fmt, clippy -D warnings, test 86/0/2 ignored, worker 32/32, SPA 17/17, GATE-DOCS 6/6 clean. --- cloudflare-worker/README.md | 4 +- cloudflare-worker/test/index.test.js | 12 +- docs/charter.md | 4 +- docs/deployment.md | 14 +- docs/design.md | 9 +- docs/opengaps.md | 2 +- docs/reference.md | 63 +++-- docs/retired.md | 2 +- src/config.rs | 37 +++ src/notify.rs | 401 ++++++++++++++++++++++++++- 10 files changed, 508 insertions(+), 40 deletions(-) diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 9dbe8a3..5395038 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -3,7 +3,7 @@ 统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 **透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 -> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 15 条白名单、 +> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 16 条白名单、 > 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与 > 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。 @@ -35,7 +35,7 @@ + 相同 App Password(各后端实例的 `JMAP` 可不同,`docs/deployment.md` §10.2)。 ### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL) -- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/enabled`(业务总开关,SPA 服务卡片读写)、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。 +- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/enabled`(业务总开关,SPA 服务卡片读写)、`GET|PUT /api/config`、`GET|PUT /api/business-config`(读回 + 增量保存)、`POST /api/business-config/preflight`(保存前逐组件连通性预检)。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。 - 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`。 - 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。 - method 不符 → **405**。 diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index ce707f9..ab825d4 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -117,9 +117,17 @@ test("Worker: business config read-back GET and preflight POST are both forwarde assert.equal(preflight.status, 200); assert.equal(seen.length, 2); - assert.equal(seen[0][0], "https://a.example/api/business-config"); + // The LB rotates its start index across tests, so the origin is not stable; + // only the path and method are. Assert the origin is one of the configured + // backends (not arbitrary) and check the path/method exactly. + const backends = JSON.parse(makeEnv().BACKEND_ORIGINS_JSON); + for (const [url, method] of seen) { + const target = new URL(url); + assert.ok(backends.includes(target.origin), `${url} reached an unknown upstream`); + } + assert.equal(new URL(seen[0][0]).pathname, "/api/business-config"); assert.equal(seen[0][1], "GET"); - assert.equal(seen[1][0], "https://a.example/api/business-config/preflight"); + assert.equal(new URL(seen[1][0]).pathname, "/api/business-config/preflight"); assert.equal(seen[1][1], "POST"); } finally { restore(); diff --git a/docs/charter.md b/docs/charter.md index 6a69b5d..097b197 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -116,7 +116,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' ``` -**当前基线:77 passed / 0 failed / 2 ignored**(2026-09-29 复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app`,均需外部真实 JMAP 服务器凭据)。 +**当前基线:86 passed / 0 failed / 2 ignored**(2026-09-30 复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app`,均需外部真实 JMAP 服务器凭据)。 **注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 @@ -275,7 +275,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查 | `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭。开启信号走 env 而非 argv,使启动命令保持静态、开关可在平台控制台单点切换 | 安全 | | `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | | `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 | -| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 15 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 | +| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 16 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 | | `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 | | `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 | | `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 | diff --git a/docs/deployment.md b/docs/deployment.md index bd92d07..f4201d3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -78,7 +78,7 @@ **鉴权** -7 条路由全部要求 `Authorization: Bearer `,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:495`),因此令牌是**常数时间比较**(`src/notify.rs:1416`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 +7 条路由全部要求 `Authorization: Bearer `,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:496`),因此令牌是**常数时间比较**(`src/notify.rs:1416`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 **7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取) @@ -307,7 +307,7 @@ AI 授权期限由用户选择(临时一次、今天、7天或直到撤销) > 业务鉴权变量由 Redis `config:business` 管理,不再要求作为启动环境变量。缺少 `REDIS_URL` 或非法/缺失 `CONFIG_ENCRYPTION_KEY` 时服务仍监听并提供 SPA、`/api/status` 与探针,状态为 `configuration-setup`,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。 > 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。 -> **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 `CONFIG_ENCRYPTION_KEY` 后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。 +> **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 `CONFIG_ENCRYPTION_KEY` 后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 通过 `GET /api/business-config` 回读已保存配置:返回 10 个非密钥字段的值 + 6 个密钥的存在性布尔(`SAF-NO-SECRET-ECHO`,不返回密钥值本身);`PUT` 接受部分补丁,只替换提交中出现的字段,省略的密钥沿用已存值,因此表单回读后只需修改要改的字段,无需重新填写密钥。但后端**不**把空字符串当作"未提交":显式提交 `""` 会真的清空该密钥,"留空即保留"这一保证由 SPA 在提交前丢弃空白密钥字段实现;`chat_allowlist` 提交空数组会被拒绝(422)。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。 --- @@ -332,7 +332,8 @@ AI 授权期限由用户选择(临时一次、今天、7天或直到撤销) 成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销 或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存 不可逆哈希和过期时间,重启后按 Redis TTL 恢复,进程不保存本地状态。GET 永不回显 -任何密钥,配置缺失或业务依赖未就绪时保持 HTTP/SPA 可用但业务路由返回未就绪。 +任何密钥值,只返回密钥是否存在(presence 布尔),配置缺失或业务依赖未就绪时保持 +HTTP/SPA 可用但业务路由返回未就绪。 `config:business` 使用 AES-256-GCM(版本、随机 nonce、认证 tag 均在密文 envelope 中) 写入 Redis;应用启动时使用 `CONFIG_ENCRYPTION_KEY` 解密,密钥永不写 Redis、日志、响应 @@ -358,7 +359,8 @@ GET /api/status 公开启动状态;只返回 ready/mode/missing POST /api/admin/session Bearer CONFIG_ENCRYPTION_KEY;成功返回 900 秒 admin session POST /api/admin/session/revoke Bearer admin session;成功返回 204 GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503 -PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;校验通过后必持久化, +GET|PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;GET 回读非密钥值 + 6 个密钥存在性布尔(不回显密钥值), + PUT 接受部分补丁(省略的密钥沿用已存值),校验通过后必持久化, 客户端构建失败返回 200+warnings(不阻断保存),reload 成功才 runtime_applied=true POST /api/business-config/preflight Bearer admin session 或 WORKER_TOKEN;同样的校验与客户端构建, 不写入、不换 worker;返回 200 + 逐组件结论 @@ -379,7 +381,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 ### 6.2 Redis Streams / worker(MOD-STREAMS) - Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞 -- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:380`,路由 `src/notify.rs:1608`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。 +- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:381`,路由 `src/notify.rs:1608`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。 - 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:397`;批量读取在 `src/state.rs:444`),逐条处理后确认消费(`src/state.rs:324`);处理失败转重试 / DLQ(`src/notify.rs:478`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。 - 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底) @@ -731,7 +733,7 @@ Stalwart PushSub ────┼─▶ https://lb..workers.dev ← 唯 ### 10.4 路由与故障转移 -- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **15 条**(`SAFE_ROUTES` at `backends.js:9-25` + `ROUTE_METHODS` at `index.js:45-61`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(队列排空端点,由外部调度周期性调用而非人工触发,Bearer 鉴权,见 §6.3.1)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:676` 读、开关写入在 `:1048`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 +- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **16 条**(`SAFE_ROUTES` at `backends.js:9-26` + `ROUTE_METHODS` at `index.js:46-63`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`GET|PUT /api/business-config`、`POST /api/business-config/preflight`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(队列排空端点,由外部调度周期性调用而非人工触发,Bearer 鉴权,见 §6.3.1)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:676` 读、开关写入在 `:1048`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`GET|PUT /api/business-config` 与 `POST /api/business-config/preflight` 同属此类必须透传的路由:SPA 由 Worker 服务,配置回读、提交前校验与保存都由它发起,缺了这两条,回读与预校验经网关部署时恒 404/405,整条配置管理页面只剩后端直连可用;两者同样强制 admin-session Bearer 鉴权,且回读只返回密钥存在性布尔(`SAF-NO-SECRET-ECHO`),不新增凭据暴露面。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 - **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 - **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 diff --git a/docs/design.md b/docs/design.md index 129cd6e..3219e01 100644 --- a/docs/design.md +++ b/docs/design.md @@ -431,10 +431,11 @@ message-weave/ } ``` - 三个 timeout 单位均为毫秒;默认值、取值范围与 `max_retries` 的硬上限以 `docs/reference.md` §6.1 为唯一权威(下表与上面的示例响应体只做示意,不重复数值)。`max_retries` 表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值对象;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。 -- `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置;Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。**校验是唯一写入闸门**:`validate_business_wire` 先跑,拒绝时返回真实的 `422`;Wire 合法则配置**一定**被持久化,之后才构建客户端并切换运行 worker。因此依赖连不通时返回 `200` + `{ "persisted": true, "runtime_applied": false, "warnings": [{"component", "step", "detail"}] }`,而不是 `503` —— 配置已保存、故障被如实上报,而不是被一个不可达的 JMAP 静默吞掉。`runtime_applied` 仅在 reload 提交成功时为 `true`;两种情况都返回 `x-business-config-revision` 头。响应从不回显配置或密钥。 -- `POST /api/business-config/preflight` 对提交体执行完全相同的校验与客户端构建,返回逐组件结论,**不写入、不触碰运行 worker**:`{ "persisted": false, "validation": { "ok", "errors" }, "components": { "jmap": {...}, "llm": ... } }`。校验已失败时 `components` 为 `null`(被拒的 Wire 无需再探客户端);`llm_enabled`/`llm_allow_net` 为 false 时 `llm` 为 `null` 而非虚假的 `ok`。鉴权与管理会话门(`config_authorized`)一致,刻意不挂在 `DEBUG_TOKEN` 之后。 -- `POST /api/bootstrap` 与 PUT 共用上述契约:失败也持久化并回 `200` + warnings。 -- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`(未授权)、`400`(JSON 无效)、`422`(Wire 校验失败)与 `503`(Redis 写入或 revision 读取失败);依赖客户端构建失败**不**返回错误状态码,而是 `200` + warnings(见上条)。其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 +- `GET /api/business-config` 回读已保存的配置供管理 SPA 预填表单:返回 `{ "configured", "revision", "values", "secrets_present" }`,其中 `values` 是 10 个非密钥字段的值,`secrets_present` 是 6 个密钥的存在性布尔(`bot_token`、`jmap_password`、`telegram_webhook_secret`、`reconcile_token`、`worker_token`、`llm_api_key`)——**从不返回密钥值本身**(`SAF-NO-SECRET-ECHO`)。尚未保存过时返回 `200` + `configured: false`、`revision: 0`、空 `values` 与全 false 的存在性布尔,SPA 因此只有一条代码路径,无需区分首次配置与修改配置。 +- `PUT /api/business-config` 接受 `BusinessConfigPatch`(完整或部分),**只替换提交体中出现的字段,其余沿用已存值**(`BusinessConfigPatch::apply`);Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。**校验是唯一写入闸门**,且校验对象是**合并后的**配置而非提交体本身:补丁从不被单独校验,已存的完整 Wire 也从不被丢弃。`validate_business_wire` 先跑,拒绝时返回真实的 `422`;合并结果合法则配置**一定**被持久化(持久化的是合并后的 Wire,不是请求体),之后才构建客户端并切换运行 worker。因此依赖连不通时返回 `200` + `{ "persisted": true, "runtime_applied": false, "warnings": [{"component", "step", "detail"}] }`,而不是 `503` —— 配置已保存、故障被如实上报,而不是被一个不可达的 JMAP 静默吞掉。`runtime_applied` 仅在 reload 提交成功时为 `true`;两种情况都返回 `x-business-config-revision` 头。响应从不回显配置或密钥。密钥是"提交才替换":**省略**的密钥沿用已存值,因此由 `GET` 回填的表单只需提交要改的字段;但后端不区分"未提交"与"提交空字符串",显式提交 `""` 会真的清空该密钥 —— "留空即保留"是 SPA 在提交前丢弃空白密钥字段实现的客户端契约,不是服务端保证。`chat_allowlist` 必须为非空列表,空数组会被 `validate_business_wire` 拒绝(422)。增量语义只在**已有配置**之后成立:没有任何已存配置时没有可回退的值,补丁必须自带全部必填字段,否则返回 `422`(从零创建的入口是 `POST /api/bootstrap`)。 +- `POST /api/business-config/preflight` 对提交体执行完全相同的校验与客户端构建,返回逐组件结论,**不写入、不触碰运行 worker**:`{ "persisted": false, "validation": { "ok", "errors" }, "components": { "jmap": {...}, "llm": ... } }`。与 PUT 不同,它的提交体必须是**完整的** `BusinessConfigWire`(不做补丁合并),所以 SPA 在预校验前要先把 `GET` 回读的值合并进本地表单。校验已失败时 `components` 为 `null`(被拒的 Wire 无需再探客户端);`llm_enabled`/`llm_allow_net` 为 false 时 `llm` 为 `null` 而非虚假的 `ok`。鉴权与管理会话门(`config_authorized`)一致,刻意不挂在 `DEBUG_TOKEN` 之后。 +- `POST /api/bootstrap` 也接受**完整的** `BusinessConfigWire`(不做补丁合并),并共用上面「持久化与报错分离」的语义:校验失败是 `422`、持久化后构建失败仍是 `200` + warnings。因为不合并,它是唯一能**从零创建**业务配置的补丁路径(PUT 在已存配置缺失且补丁不全时返回 `422`,见上条)。 +- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`(未授权)、`400`(JSON 无效)、`422`(补丁无法解析、已存配置不可解密、合并结果校验失败、首次保存时补丁不完整)、`500`(合并结果序列化失败)与 `503`(Redis 不可达、写入失败或 reload 提交失败);业务 GET 使用 `401`、`400`、`422`(已存配置不可解密)与 `503`(Redis 不可达)——注意 `Invalid` 是 422 不是 503,`Unreachable` 才是 503。**业务 PUT 没有 `409`**:`x-business-config-revision` 头只被发出、从不被校验,请求侧不读任何 header,两个并发管理会话保存业务配置会静默互相覆盖(SPA 目前只在状态栏显示 revision,不回传);`409 conflict` 只出现在 `refresh_business_config` 的锁竞争路径。依赖客户端构建失败**不**返回错误状态码,而是 `200` + warnings(见上条)。其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 ### 7.4 TLS(代码/依赖行为) - TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。 diff --git a/docs/opengaps.md b/docs/opengaps.md index 043bc7d..978f81a 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -28,7 +28,7 @@ `docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。 -同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:331` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:380`。 +同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:331` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:381`。 ## 阻塞 diff --git a/docs/reference.md b/docs/reference.md index e2f15b4..6d38d32 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -185,10 +185,10 @@ field. `request_id` is always present. `Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal string `"30"`: -- `error_response` (`notify.rs:370-378`, inserted at `:374`) sets it **only when its `retry` +- `error_response` (`notify.rs:371-379`, inserted at `:375`) sets it **only when its `retry` flag is true**. The readiness failure path (`notify.rs:210`) passes `retry = true`, so `/ready` is covered by that rule rather than by a special case. -- `error_response_with_id` (`notify.rs:1368-1375`, inserted at `:1371`) sets it +- `error_response_with_id` (`notify.rs:1464-1471`, inserted at `:1467`) sets it **unconditionally**. It is used only for the push register/disable failures `push_state_unavailable` and `push_destroy_failed` (`notify.rs:1233`, `:1250`, `:1267`, `:1304`), which are always `503`. @@ -217,7 +217,7 @@ only three of this table — `/healthz`, `/ready` and `/api/status` — and no b | POST | `/worker` | `worker` | | GET, PUT | `/api/config` | `get_config` / `put_config` | | GET, PUT | `/api/enabled` | `get_enabled` / `put_enabled` | -| PUT | `/api/business-config` | `put_business_config` | +| GET, PUT | `/api/business-config` | `get_business_config` / `put_business_config` | | POST | `/api/business-config/preflight` | `preflight_business_config` | | POST | `/api/bootstrap` | `bootstrap` | | POST | `/api/admin/session/revoke` | `revoke_admin_session` | @@ -260,7 +260,7 @@ Response conventions: `probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated. The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw session URL unauthenticated would report not-ready forever and make ingress stop routing. -- `GET /api/status` (**always 200**, `notify.rs:214-223`) returns +- `GET /api/status` (**always 200**, `notify.rs:215-224`) returns `{"ready": , "mode": "configured"|"configuration-setup", "missing": [...], "version": ""}`. `ready` is `false` and `missing` lists the absent required keys (`REDIS_URL`, `CONFIG_ENCRYPTION_KEY`) when a variable is absent — the route itself never errors, so it is @@ -280,7 +280,7 @@ Response conventions: ## 4. Gateway vs backend route matrix > **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES` -> (backends.js:9-25), 15 entries, alongside `ROUTE_METHODS` (index.js:45-61) +> (backends.js:9-26), 16 entries, alongside `ROUTE_METHODS` (index.js:46-63) > which fixes one method set per path. The worker entry point is `src/index.js` > (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`, > `C-NO-LONG-CONN`). @@ -290,10 +290,11 @@ all: a path missing from `SAFE_ROUTES` (backends.js:9-26) returns **404** (index path with the wrong method returns **405** (index.js:86-89), and a missing or unparseable backend pool returns **503** rather than passing the request through (index.js:91-99). -**Forwarded by the worker (15):** +**Forwarded by the worker (16):** `/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` · -`/api/business-config` · `/api/admin/session` · `/api/admin/session/revoke` · +`/api/business-config` · `/api/business-config/preflight` · `/api/admin/session` · +`/api/admin/session/revoke` · `/api/enabled` · `/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` · `/ready` @@ -303,7 +304,7 @@ backend pool returns **503** rather than passing the request through (index.js:9 | Path | Why it is absent from the gateway | |---|---| | `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path | -| `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd, `notify.rs:380`; the cron is spelled out in deployment.md §6.3.1); not part of the public gateway path | +| `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd, `notify.rs:381`; the cron is spelled out in deployment.md §6.3.1); not part of the public gateway path | | `GET /healthz` | Liveness is aggregated by the gateway itself | | `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); absent from `SAFE_ROUTES`, so it is reachable **only** by talking to the backend origin directly | @@ -359,14 +360,32 @@ is the single most likely cause of "the container is healthy but nothing happens ### 5.2 Redis-resident business configuration -Written by the SPA through `PUT /api/business-config` (effective within 1 s); the first -successful write creates the configuration, later writes hot-reload it. The same write is -also exposed as a one-shot `POST /api/bootstrap` against the backend origin for automation. -The process never reads these from the environment in normal operation. +Read by the SPA through `GET /api/business-config` and written through +`PUT /api/business-config` (effective within 1 s); the first successful write creates the +configuration, later writes hot-reload it. `GET` returns four keys — `configured`, `revision`, +`values`, `secrets_present` — holding the 10 non-secret fields plus one presence boolean per +secret (`bot_token`, `jmap_password`, `telegram_webhook_secret`, `reconcile_token`, +`worker_token`, `llm_api_key`), never the secret values themselves (`SAF-NO-SECRET-ECHO`). With +nothing saved yet it returns `200`, `configured: false`, `revision: 0`, an empty `values` and +every flag false, so the SPA needs no special-case code path. The PUT body is a **partial +patch** that replaces only the fields it names and keeps the stored value for the rest (`apply` +in `config.rs`); a secret omitted from the patch keeps the stored secret, so prefilled form +values are safe to resubmit. An explicitly submitted empty string is stored as-is and really +clears the secret — "blank means unchanged" is a client-side contract, enforced by the SPA +dropping blank secret fields before it sends, not by the server. -**Persist-and-report, not persist-if-connectable.** Validation is the only gate on the write: -`validate_business_wire` runs first and a rejection is a genuine `422`. Once the wire is valid -the configuration is *always* persisted, and only then are the clients built and the running +The increment semantics only hold once a configuration exists. With nothing stored there is no +value to fall back to, so an incomplete patch is rejected with **422 `invalid_configuration`** +— the first save has to be complete. `POST /api/bootstrap` against the backend origin is the +one-shot write path that creates a configuration from scratch, for automation. The process never +reads these from the environment in normal operation. + +**Persist-and-report, not persist-if-connectable.** Validation is the only gate on the write, +and on a `PUT` it runs against the **merged** configuration, not the submitted body: the patch +is never validated on its own, and the full stored wire is never discarded (`apply` in +`config.rs`). `validate_business_wire` runs first and a rejection is a genuine `422`. Once +the merged wire is valid it is *always* persisted — the merged wire, not the request body — and +only then are the clients built and the running worker swapped. A failed build therefore returns **200** with `persisted: true`, `runtime_applied: false` and a `warnings` array of `{component, step, detail}` objects, where `component` is `jmap` or `llm` and `step` is `connect`, `account`, `config` or `build`, rather @@ -374,12 +393,14 @@ than a 503. The configuration is saved and the outage is reported instead of hid `runtime_applied` is `true` only when the reload was committed. This is deliberate: an unreachable JMAP must not turn a valid configuration into a silent data loss. `PUT /api/business-config` and -`POST /api/bootstrap` both follow this contract; the revision header is still returned in both -cases. +`POST /api/bootstrap` both follow this contract; the revision is returned in both cases as the +`x-business-config-revision` header, and only `PUT /api/business-config` also returns it in the +response body, as `"revision"` (a `u64`), which the SPA surfaces in its status line. Bootstrap's +body carries no `revision` field. -`POST /api/business-config/preflight` (`notify.rs:808-871`) runs the identical validation and -client build against the submitted wire and returns the per-component verdict without writing -anything and without touching the running worker: +`POST /api/business-config/preflight` (`preflight_business_config`) runs the identical +validation and client build against the submitted wire and returns the per-component verdict +without writing anything and without touching the running worker: ```json { @@ -421,7 +442,7 @@ The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as **Trust root.** The SPA admin credential is `CONFIG_ENCRYPTION_KEY` itself: read at startup (main.rs:63) and held as `admin_token` (main.rs:92), it is checked in constant time by -`worker_authorized` (notify.rs:495; compare at notify.rs:1136) at the top of both +`worker_authorized` (notify.rs:496; compare at notify.rs:1136) at the top of both `POST /api/bootstrap` (notify.rs:605) and `POST /api/admin/session` (notify.rs:775). It is only compared against the request bearer — never echoed, logged, or stored. The session issued by `/api/admin/session` is a freshly generated random 32-byte hex token diff --git a/docs/retired.md b/docs/retired.md index a2b92f1..e15cefe 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -78,7 +78,7 @@ | `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:718`) | | `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:898-901`) | | `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 | -| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:380`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:385`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | +| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:381`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:385`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | --- diff --git a/src/config.rs b/src/config.rs index 1d7d558..65a7f19 100644 --- a/src/config.rs +++ b/src/config.rs @@ -860,4 +860,41 @@ mod tests { "Asia/Kolkata" ); } + + /// Pure deserialisation check for `de_i64_from_number_or_string`, with no router, no store and + /// no other field present. This is the only place that can tell "the field's custom + /// deserializer refuses the string" apart from a later validation step, which is a real + /// distinction: the SPA submits `telegram_chat_id` as a JSON string on a partial submit while + /// the read-back model returns it as a JSON number. + #[test] + fn business_config_patch_reads_a_chat_id_from_a_number_or_a_numeric_string() { + let parse = |body: serde_json::Value| -> Result { + serde_json::from_value(body) + }; + + assert_eq!( + parse(serde_json::json!({"telegram_chat_id": -987654321i64})) + .unwrap() + .telegram_chat_id, + Some(-987654321) + ); + assert_eq!( + parse(serde_json::json!({"telegram_chat_id": "-987654321"})) + .unwrap() + .telegram_chat_id, + Some(-987654321) + ); + // A string that does not parse to an i64 is rejected, not truncated to zero. + assert!(parse(serde_json::json!({"telegram_chat_id": "not-a-chat-id"})).is_err()); + assert!(parse(serde_json::json!({"telegram_chat_id": 1.5})).is_err()); + assert!(parse(serde_json::json!({"telegram_chat_id": "true"})).is_err()); + + // Absent means "keep what is stored", which the patch merge relies on. + let missing = parse(serde_json::json!({})).unwrap(); + assert!(missing.telegram_chat_id.is_none()); + assert!(parse(serde_json::json!({"timezone": "Etc/UTC"})) + .unwrap() + .telegram_chat_id + .is_none()); + } } diff --git a/src/notify.rs b/src/notify.rs index 69faf8c..3252c3e 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -779,6 +779,7 @@ async fn put_business_config( [("x-business-config-revision", revision.to_string())], Json(json!({ "persisted": true, + "revision": revision, "runtime_applied": runtime_applied, "warnings": warnings, })), @@ -1729,7 +1730,10 @@ fn router_with_worker_state_runtime_bootstrap_config mod tests { use super::*; use crate::state::MemoryState; - use axum::{body::Body, http::Request}; + use axum::{ + body::Body, + http::{self, Request}, + }; use tower::ServiceExt; fn test_router() -> Router { @@ -2304,6 +2308,401 @@ mod tests { assert_preflight_wrote_nothing(app).await; } + /// Router wired for admin calls, plus a handle on the same state so a test can seed or inspect + /// the stored configuration without another HTTP round trip. + fn business_config_router() -> (Router, MemoryState) { + let state = MemoryState::enabled_for_tests(); + let router = router_with_state( + AuthSecrets { + reconcile_token: SecretString::new("reconcile-secret".into()), + telegram_webhook_secret: SecretString::new("telegram-secret".into()), + }, + SecretString::new("worker-secret".into()), + state.clone(), + HashSet::new(), + ); + (router, state) + } + + /// A full, valid configuration as it lands on the wire. The JMAP URL is refused immediately, + /// so a save that reaches `build_worker_report` fails there instead of waiting on a timeout. + fn stored_business_wire_value() -> serde_json::Value { + json!({ + "bot_token": "bot-secret-value", + "telegram_chat_id": -5260770881i64, + "chat_allowlist": [1, 2], + "telegram_webhook_secret": "hook-secret-value", + "jmap_session_url": "https://mail.example.invalid/session", + "account_id": "account-7", + "jmap_username": "user@example.invalid", + "jmap_password": "jmap-secret-value", + "llm_enabled": false, + "llm_allow_net": false, + "llm_api_key": null, + "llm_base_url": null, + "llm_model": null, + "reconcile_token": "reconcile-secret-value", + "worker_token": "worker-secret-value", + "timezone": "Etc/UTC", + }) + } + + async fn seed_business_config(state: &MemoryState, wire: serde_json::Value) { + state.set_business_config(&wire).await.unwrap(); + } + + /// The stored configuration with a JMAP URL that fails the instant it is dialed, for tests + /// whose save proceeds far enough to reach `build_worker_report`. + fn immediate_fail_business_wire_value() -> serde_json::Value { + let mut wire = stored_business_wire_value(); + wire["jmap_session_url"] = json!("https://127.0.0.1:1"); + wire + } + + #[tokio::test] + async fn business_config_get_without_session_returns_401_without_any_field() { + let (router, _) = business_config_router(); + let response = router + .oneshot( + Request::builder() + .uri("/api/business-config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = json_body(response).await; + assert_eq!(body["error"], json!("unauthorized")); + // `request_id` is minted per request, so assert its shape rather than its value. + let request_id = body["request_id"].as_str().unwrap_or(""); + assert_eq!(request_id.len(), 32); + assert!(request_id.chars().all(|c| c.is_ascii_hexdigit())); + assert!(body.get("values").is_none()); + assert!(body.get("secrets_present").is_none()); + assert!(body.get("configured").is_none()); + assert_eq!(body.as_object().unwrap().len(), 2); + } + + #[tokio::test] + async fn business_config_get_echoes_plaintext_fields_and_secrets_as_presence_only() { + let (router, state) = business_config_router(); + let mut wire = stored_business_wire_value(); + // Populate the whole LLM block, so its plaintext fields and its secret flag are both + // exercised; this is the fully valid shape a real save can produce. + wire["timezone"] = json!("Asia/Tokyo"); + wire["llm_enabled"] = json!(true); + wire["llm_base_url"] = json!("https://llm.example.invalid/v1"); + wire["llm_model"] = json!("local-model"); + wire["llm_api_key"] = json!("llm-secret-value"); + seed_business_config(&state, wire).await; + + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri("/api/business-config") + .header(http::header::AUTHORIZATION, "Bearer worker-secret") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response).await; + assert_eq!(body["configured"], json!(true)); + assert_eq!(body["revision"], json!(1)); + + let values = &body["values"]; + assert_eq!(values["timezone"], json!("Asia/Tokyo")); + assert_eq!( + values["jmap_session_url"], + json!("https://mail.example.invalid/session") + ); + assert_eq!(values["jmap_username"], json!("user@example.invalid")); + assert_eq!(values["account_id"], json!("account-7")); + assert_eq!(values["telegram_chat_id"], json!(-5260770881i64)); + assert_eq!(values["chat_allowlist"], json!([1, 2])); + assert_eq!(values["llm_enabled"], json!(true)); + assert_eq!(values["llm_allow_net"], json!(false)); + assert_eq!( + values["llm_base_url"], + json!("https://llm.example.invalid/v1") + ); + assert_eq!(values["llm_model"], json!("local-model")); + + assert_eq!(body["secrets_present"]["bot_token"], json!(true)); + assert_eq!(body["secrets_present"]["jmap_password"], json!(true)); + assert_eq!( + body["secrets_present"]["telegram_webhook_secret"], + json!(true) + ); + assert_eq!(body["secrets_present"]["reconcile_token"], json!(true)); + assert_eq!(body["secrets_present"]["worker_token"], json!(true)); + assert_eq!(body["secrets_present"]["llm_api_key"], json!(true)); + + // SAF-NO-SECRET-ECHO: presence is allowed, the credential value never is. + let body_text = serde_json::to_string(&body).unwrap(); + for secret in [ + "bot-secret-value", + "jmap-secret-value", + "hook-secret-value", + "reconcile-secret-value", + "worker-secret-value", + "llm-secret-value", + ] { + assert!( + !body_text.contains(secret), + "{secret} leaked into the response" + ); + } + } + + #[tokio::test] + async fn business_config_get_without_any_stored_config_reports_absent_instead_of_404() { + let (router, _) = business_config_router(); + + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri("/api/business-config") + .header(http::header::AUTHORIZATION, "Bearer worker-secret") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response).await; + assert_eq!(body["configured"], json!(false)); + assert_eq!(body["revision"], json!(0)); + assert_eq!(body["values"]["chat_allowlist"], json!([])); + assert_eq!(body["values"]["timezone"], json!("Asia/Shanghai")); + assert_eq!(body["values"]["jmap_session_url"], json!("")); + assert!(body["values"]["account_id"].is_null()); + assert!(body["values"]["llm_base_url"].is_null()); + assert!(body["values"]["llm_model"].is_null()); + assert_eq!(body["values"]["llm_enabled"], json!(false)); + for key in [ + "bot_token", + "jmap_password", + "telegram_webhook_secret", + "reconcile_token", + "worker_token", + "llm_api_key", + ] { + assert_eq!(body["secrets_present"][key], json!(false), "{key}"); + } + } + + #[tokio::test] + async fn business_config_first_save_rejects_a_patch_that_is_missing_fields() { + let (router, state) = business_config_router(); + + let response = router + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/business-config") + .header(http::header::AUTHORIZATION, "Bearer worker-secret") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_string(&json!({"timezone": "Asia/Tokyo"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body = json_body(response).await; + assert_eq!(body["error"], json!("invalid_configuration")); + // The partial submit must not have been recorded as a one-field configuration. + assert!(state.get_business_config().await.unwrap().is_none()); + } + + #[tokio::test] + async fn business_config_partial_submit_replaces_only_the_fields_it_carries() { + let (router, state) = business_config_router(); + seed_business_config(&state, immediate_fail_business_wire_value()).await; + + let response = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/business-config") + .header(http::header::AUTHORIZATION, "Bearer worker-secret") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_string(&json!({"timezone": "Asia/Tokyo"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let saved = json_body(response).await; + assert_eq!(saved["persisted"], json!(true)); + assert_eq!(saved["revision"], json!(2)); + + // Read it back over the wire rather than from the saved response: this is what the SPA does. + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri("/api/business-config") + .header(http::header::AUTHORIZATION, "Bearer worker-secret") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response).await; + assert_eq!(body["configured"], json!(true)); + assert_eq!(body["revision"], json!(2)); + + let values = &body["values"]; + assert_eq!(values["timezone"], json!("Asia/Tokyo")); + // Every field the patch did not carry must still hold its stored value. + assert_eq!(values["jmap_session_url"], json!("https://127.0.0.1:1")); + assert_eq!(values["jmap_username"], json!("user@example.invalid")); + assert_eq!(values["account_id"], json!("account-7")); + assert_eq!(values["telegram_chat_id"], json!(-5260770881i64)); + assert_eq!(values["chat_allowlist"], json!([1, 2])); + assert_eq!(values["llm_enabled"], json!(false)); + assert_eq!(values["llm_allow_net"], json!(false)); + assert!(values["llm_base_url"].is_null()); + assert!(values["llm_model"].is_null()); + for key in [ + "bot_token", + "jmap_password", + "telegram_webhook_secret", + "reconcile_token", + "worker_token", + ] { + assert_eq!(body["secrets_present"][key], json!(true), "{key}"); + } + assert_eq!(body["secrets_present"]["llm_api_key"], json!(false)); + } + + #[tokio::test] + async fn business_config_partial_submit_cannot_skip_validation_of_the_merged_result() { + let (router, state) = business_config_router(); + seed_business_config(&state, stored_business_wire_value()).await; + + let response = router + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/business-config") + .header(http::header::AUTHORIZATION, "Bearer worker-secret") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_string(&json!({"timezone": "Europe/London"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body = json_body(response).await; + assert_eq!(body["error"], json!("invalid_configuration")); + + // Rejected submissions leave the stored configuration and its revision untouched. + let stored = state.get_business_config().await.unwrap().unwrap(); + assert_eq!(stored["timezone"], json!("Etc/UTC")); + assert_eq!(state.business_config_revision().await.unwrap(), 1); + } + + #[tokio::test] + async fn business_config_accepts_a_chat_id_submitted_as_a_number_or_a_string() { + let (router, state) = business_config_router(); + seed_business_config(&state, immediate_fail_business_wire_value()).await; + + let submitted = json!({"telegram_chat_id": "-987654321"}); + let response = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/business-config") + .header(http::header::AUTHORIZATION, "Bearer worker-secret") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_string(&submitted).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["persisted"], json!(true)); + + let stored = state.get_business_config().await.unwrap().unwrap(); + assert_eq!(stored["telegram_chat_id"], json!(-987654321i64)); + + // A chat id that parses to no integer is still rejected rather than coerced. + let response = router + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/business-config") + .header(http::header::AUTHORIZATION, "Bearer worker-secret") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_string(&json!({"telegram_chat_id": "not-a-chat-id"})) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + json_body(response).await["error"], + json!("invalid_configuration") + ); + assert_eq!( + state.get_business_config().await.unwrap().unwrap()["telegram_chat_id"], + json!(-987654321i64) + ); + } + + #[tokio::test] + async fn business_config_put_rejects_a_field_the_patch_type_does_not_declare() { + let (router, state) = business_config_router(); + seed_business_config(&state, stored_business_wire_value()).await; + + let response = router + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/business-config") + .header(http::header::AUTHORIZATION, "Bearer worker-secret") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_string(&json!({"timezone": "Etc/UTC", "typo_field": 1})) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + json_body(response).await["error"], + json!("invalid_configuration") + ); + assert_eq!(state.business_config_revision().await.unwrap(), 1); + } + #[tokio::test] async fn business_config_preflight_reports_component_failure_and_writes_nothing() { let app = test_router();