Pre-Production Test Plan for an Accounting API
A reliability playbook for engineering teams: duplicate writes, idempotency reuse, webhook signatures, out-of-order events, decimal money, scopes, period locks, and recovery.
来自会计 API 的 201 Created 几乎说明不了什么。它只能确认服务器作出了响应,但并不能保证正好存在一张发票、借贷金额相等、单据编号仅被分配了一次,或者在连接中断后进行重试不会记入第二份副本。绝大多数生产环境中的会计数据重复都源于这一空白:某个修改操作已经在服务器端提交,而客户端在结果未知的情况下静默地使用新密钥进行了重试。
这份测试计划适用于在将会计 API 接入真实账套之前对其进行评估的工程团队。它侧重于故障行为而非顺利路径 (happy path)。以下各个领域均明确指出了测试内容、断言条件以及通过标准。此处的实现背景是 Nordlet,其 API 使用带作用域的 bearer 密钥、十进制字符串金额、幂等键以及带签名的 Webhook。有关确切的端点语法和响应契约,请阅读API 约定和快速入门页面,切勿盲目进行假设。
“通过”的实际含义
只有在验证了语义而非状态码时,测试才能证明会计集成是安全的。对于每一个场景,测试套件都应确认:
- 业务副作用:正好存在一个客户、发票、日记账分录、编号分配或付款状态转换。
- 会计完整性:借贷平衡,且货币精度在序列化过程中得以保留。
- 重放行为:重试会返回原始结果,而不是创建新记录。
- 持久性:一旦操作提交,审计记录和 Webhook 事件就会存在。
- 无副作用的失败:被拒绝的请求不消耗单据编号,也不写入任何部分的账簿行。
- 恢复能力:可以重试瞬时错误,且不会发生重复或永久丢失。
Google 的 API 指南划定了清晰的界限:只有反复执行会保持相同最终状态的操作才可以安全重试,并且 API 应当明确标识哪些操作具有幂等性,而不应该盲目重试每一个失败的操作。
搭建一个可丢弃、可观测的环境
请使用沙盒公司 (sandbox company),而不是生产账套。Nordlet 沙盒公司使用与真实公司相同的模块和 API 行为,并被标记为测试数据,这使得它非常适合进行大容量的重复测试和故障测试。该标记在创建后不可更改,因此生产账套日后无法被重新归类为测试数据。请注意,沙盒的使用会像真实公司一样进行计量和计费,因此请根据你所处的定价方案来评估容量测试的规模。
在编写任何测试之前,请准备好:
- 一个
isSandbox: true的专用沙盒公司。 - 仅限于被测模块的测试 API 密钥。
- 能够保留原始请求体的 Webhook 端点或本地隧道。
- 已预置的客户、供应商以及已知的会计科目表。
- 至少两个会计期间:一个开启,一个锁定。
- 如果实现支持,则准备一个测试时钟或日期夹具 (date fixture)。
- 一个本地存储,用于记录每一次请求尝试、幂等键、响应哈希、Webhook 交付以及对账结果。
切勿对共享的暂存 (staging) 数据运行重复测试。来自早期运行的遗留客户或发票会使后续的断言变得含糊不清。
你对每一次修改操作的夹具 (fixture) 都应捕获:方法、端点、完整请求体、幂等键、密钥作用域、客户端关联 ID、开始和结束时间戳、响应状态、标头、请求体、请求 ID,以及在响应到达之前连接是否中断。请使用诸如 "75.0000" 这样的固定十进制字符串来表示金额。Nordlet 将货币值表示为最多带四位小数的十进制字符串,其发票示例也采用了这种格式。
准备工作的另一半是故障注入。你的测试框架应该能够:延迟响应、在服务器收到请求后丢弃 TCP 连接、返回合成的 429/500/502/503/504 响应、从 Webhook 接收方返回非 2xx 响应、两次投递相同的 Webhook、对事件进行乱序处理、破坏 Webhook 请求体的一个字节、在保持密钥不变的同时更改幂等键的有效载荷、并发运行两个相同的请求,以及在设置和过账之间锁定期间。这些场景的价值远超一百个顺利路径的示例。
在编写重试逻辑之前锁定契约
生成或获取 OpenAPI 架构 (schema),并将其与你的客户端进行比对。Nordlet 在API 参考中发布了其 OpenAPI 规范,并基于同一规范生成其类型化的 SDK,因此基于架构的测试胜过手写的猜测。
验证必填字段和类型、十进制字符串字段、日期和时间戳格式、允许的操作和作用域、错误信封 (error envelope) 的形态、Webhook 事件架构、哪些修改操作接受幂等键,以及哪些响应字段标识了所创建的对象。
通过标准。 客户端在架构允许的情况下于本地拒绝无效的有效载荷。服务器的响应符合记录在案的架构。无效的十进制数、错误的日期以及缺失的必填字段会产生结构化的验证错误。对于确定性的验证失败,绝不进行重试。
需要捕获的故障模式。 客户端将 "121.0000" 解析为二进制浮点数,并在稍后序列化时产生略微不同的金额。整数可以通过,但增值税 (VAT) 行和小数字段会失败。请端到端地将货币测试为字符串。
授权与租户隔离
对读和写使用不同的密钥。然后测试整个网格:缺失密钥、无效密钥、具有不足模块作用域的有效密钥、具有正确作用域的有效密钥、属于其他公司的密钥,以及读取不同公司对象的尝试。
断言:
- 缺失或无效的凭证返回
401。 - 缺少所需权限的有效密钥返回
403。 - 任何失败都不会产生会计副作用。
- 成功的请求无法读取或修改另一家公司的记录。
- 安全失败绝不会自动重试。
通过标准。 你可以通过测试后的读取操作来证明授权的正确性和租户隔离,而不仅仅依赖状态码。
验证一次干净的交易
运行一个小型、确定性的流程:创建或查找测试客户,创建带有十进制字符串金额的发票,将其发行,读取发票及其会计影响,读取审计记录,并接收 Webhook。Nordlet 的快速入门示例展示了发票发行如何分配无间隙的编号、检查期间是否开启,并在同一事务中过账平衡的日记账分录。
断言发票达到预期状态,单据编号恰好分配了一次,借贷平衡,增值税和行总计符合预期的四舍五入(half-up)计算,审计记录中列出了操作者和变更内容,Webhook 最终得以投递,并且 API 响应与后续的读取操作在发票 ID 和状态上保持一致。
有一个值得坚持的区别:收到 Webhook 并不证明修改操作已经提交。API 响应加上回读才是你的主要交易断言。Webhook 证明了下游通知,它需要单独测试。
幂等性:相同的密钥,相同的请求体
每个逻辑业务操作生成一个密钥,而不是每个网络尝试生成一个密钥。使用密钥 K 和请求体 B 发送修改操作,保存创建的 ID,然后再次发送完全相同的请求。
断言第二个响应代表相同的操作,对象 ID 相同,并且只存在一个业务对象、一个过账记录、一个单据编号、一个财务影响。重放的响应应带有记录在案的重放标头(在 Nordlet 的情况下为 x-idempotent-replay: true),并且审计追踪应显示没有第二次修改。
两条存储规则改变了你的重试循环的预期,因此请直接测试它们:存储的 4xx 状态码会像任何其他响应一样被重放,而 5xx 状态码则根本不会被存储,因此服务器出错后的重试会重新执行操作,而不是进行重放。密钥也会过期(在 Nordlet 中为 24 小时后),这限制了重放可用的时间范围。
Nordlet 的文档承诺,添加 Idempotency-Key 标头可防止重试的请求创建重复项,其 API 约定定义了准确的重放标头和逐字节响应要求。该原则与 Stripe 发布的模型相匹配:密钥标识逻辑操作,重复请求返回存储的结果,而使用不同参数重用密钥则会被拒绝,而不是被静默更改。
通过标准。 无论客户端在重放窗口内发送相同请求多少次,都只存在一个财务状态转换。
幂等性:相同的密钥,更改的请求体
使用密钥 K 和请求体 B1 发送原始请求,然后发送带有密钥 K 和请求体 B2 的第二个请求,其中至少有一个金额、客户、日期或行存在差异。
预期结果:请求被拒绝,并返回记录在案的密钥重用错误(Nordlet 返回 422 idempotency_key_reuse),原始记录保持不变,没有出现第二个记录、编号或审计修改,并且客户端不会通过生成新密钥来“修复”它。静默接受 B2 会使重试密钥变得不安全。
并发重复请求
在同一瞬间使用相同的密钥发出两个相同的请求。根据记录在案的行为,其中一个执行,而另一个收到进行中的冲突(Nordlet 返回 409 idempotency_in_progress),或者两者最终观察到相同的结果。
断言两次调用无法创建独立的财务影响,最多分配一个单据编号,临时冲突会使用相同的密钥而不是新密钥进行重试,最终资源完整且可读,并且幂等记录不会永久卡住。
故障模式。 每次尝试都铸造一个全新 UUID 的重试库会将一个逻辑操作变成多个独立的写入,从而完全破坏幂等性。
连接中断后的不确定结果
这是最重要的生产环境模拟。使用密钥 K 发送修改操作,让服务器提交它,在响应到达之前断开连接,将结果记录为未知,然后使用相同的密钥 K 重试,并通过读取资源和审计数据进行对账。
断言重试返回原始或记录在案的重放响应,正好存在一个财务副作用,客户端仅在成功重放或对账读取后才将操作标记为完成,并且没有生成新的密钥。超时并不意味着“什么都没发生”。Stripe 将幂等性精确地描述为对连接错误的保护,在这些错误中,服务器可能在客户端接收到响应之前就已经处理了修改操作。
保守的重试策略
重试决策取决于错误以及操作是否具有幂等性,而不仅取决于 HTTP 方法。
通常可重试,保留相同的密钥: 幂等修改后的连接重置或超时、408、在遵守 retry-after 之后的 429,以及 502/503/504。记录在案的进行中幂等响应可以在短暂延迟后重试。
未经对账不可重试: 400/422 验证错误、401/403 认证失败、资源缺失的 404、与飞行中(in-flight)密钥无关的业务 409 冲突、现有密钥上的有效载荷更改,以及期间锁定拒绝。
断言带有抖动的有界指数退避、明确的最大尝试次数和耗时、对速率限制遵守 retry-after、在单次操作的重试中保持相同的密钥、为每个新操作使用新密钥,以及将原始尝试与重试分开的日志。
单独测试速率限制。越过记录在案的阈值(Nordlet 默认每个 API 密钥每分钟允许 300 个请求,并且某些未经身份验证的端点带有其自身的较低每地址限制),然后确认服务器返回速率限制响应,客户端将 retry-after 解析为延迟而不是错误,它会暂停而不是持续猛击,并且受到节流的修改重试会重用原始密钥。x-ratelimit-* 标头报告了剩余额度,这构成了很好的预断言。除非你的测试夹具能够区分节流尝试和已提交的尝试,否则请将速率限制测试远离财务断言。
Webhook 签名验证
构建一个保留原始字节的接收方。对于每个事件:捕获原始请求体,读取 x-nordlet-signature 标头,使用订阅密钥计算期望的 HMAC-SHA256,使用常数时间函数进行比较,仅在验证后解析 JSON,记录投递,并在持久接受后快速返回 2xx。Nordlet 对原始请求体进行 HMAC 签名,将其发送为 sha256=<hex>,并通过指数退避重试投递。Stripe 的 Webhook 指南强调了使用常数时间比较来验证未经修改的原始请求体。
运行负向测试:正确的请求体和密钥、正确的请求体配错误的密钥、单字节的请求体更改、改变了空白字符的重新序列化 JSON、缺失的标头、格式错误的签名、如果方案包含时间戳则重放旧的投递、以及投递到错误订阅的有效签名。
通过标准。 仅接受正确的原始请求体和密钥。无效签名获得非 2xx 响应。无效的有效载荷绝不会进入会计队列。密钥远离普通日志。处理程序仅在事件被持久记录后才进行确认。
重复和乱序的 Webhook
投递可靠性是一个与 API 幂等性不同的问题,接收方也必须是幂等的。投递相同的事件两次,在 sale_invoice.issued 事件之前投递 sale_invoice.paid 事件,在持久化之后但在响应之前返回 500,以及重放消费者已经处理过的事件。
断言重复投递不会重复付款、状态更改或日记账操作,去重密钥在存在时使用提供商的投递或事件 ID(或记录在案的复合密钥,绝不使用任意时间戳),乱序事件绝不会用较旧的状态覆盖较新状态,当顺序不足时消费者可以从 API 获取当前状态,并且失败的消费者尝试通过队列或死信路径路由。Stripe 明确警告不保证投递顺序,并建议存储已处理的事件 ID。
保持端点精简:验证形态、验证签名、持久化或入队、确认、返回 2xx。对账和通知属于异步处理。通过让下游工作休眠超过投递超时来测试它;正确的接收方在持久排队后仍然会进行确认。
开启和锁定的会计期间
Nordlet 在过账时跨手动日记账、单据流和导入强制执行期间锁定,因此请在多个地方测试此控制。创建日期处于开启期间、锁定期间、锁定前边界、下一个开启的第一天、以及如果 API 将两者分开则具有有效单据日期但无效过账日期的等效单据。
对于锁定期间的情况,断言请求被拒绝,并返回记录在案的期间锁定错误(Nordlet 中为 409 conflict),并且没有写入日记账行,没有消耗单据编号,单据没有转换为已过账状态,也没有触发成功 Webhook。开启期间的版本应当以相同的财务价值成功。
竞态测试。 针对开启期间启动过账,在请求进行中时锁定期间,并确认提交无法绕过锁定。关键通过条件:已提交的过账绝不会落入在事务提交之前就被锁定的期间。
审计与对账
对于每一个成功的修改,确认业务对象存在、借贷相等的会计分录存在、审计记录存在、Webhook 事件存在或可检索,并且你的操作记录指向提供商对象 ID,且重试引用相同的逻辑操作。
对于每一个被拒绝的修改,确认不存在部分单据、除非契约允许预留否则没有消耗编号、不存在总账分录、不存在成功 Webhook,并且错误带有用于诊断的请求 ID。Nordlet 的约定将修改可审计性和平衡过账描述为平台保证,平衡性由提交时的延迟数据库触发器而不是仅由应用程序代码强制执行。将这些保证转化为测试后的对账查询,而不是信任 HTTP 响应。
核心测试矩阵
| 领域 | 测试操作 | 预期断言 | 通过标准 |
|---|---|---|---|
| 认证 | 无密钥、无效密钥、作用域不足 | 结构化的 401/403;无副作用 |
所有被拒绝的尝试都不创建会计记录 |
| 架构与资金 | 无效十进制数、日期、必填字段 | 字段级验证错误 | 不发生重试或部分写入 |
| 正常过账 | 在开启期间创建并发行发票 | 一份单据、一本平衡日记账、一条审计记录 | 回读状态与总账一致 |
| 同密钥重放 | 重复相同的修改,相同的密钥和请求体 | 重放原始结果;无重复 | 对象 ID、编号、日记账影响、审计保持单一 |
| 更改请求体,同密钥 | 以不同金额重用密钥 | 记录在案的密钥重用拒绝 | 原始记录不变;不写入任何新内容 |
| 连接中断 | 提交、断开、使用相同密钥重试 | 重放的结果 | 正好存在一个财务副作用 |
| 锁定期间 | 过账日期处于锁定期间的单据 | 期间锁定拒绝 | 无日记账、无编号、无成功 Webhook |
| Webhook 签名 | 错误的密钥、更改的请求体 | 接收方返回非 2xx | 仅接受正确的原始请求体和密钥 |
我会优先做什么
如果时间紧迫,请按捕获最多高成本漏洞的顺序运行测试。从连接中断重试开始,因为这单一场景产生了大多数真实的重复。然后是同密钥和更改请求体的幂等对。接着是锁定期间,因为在关闭的月份中过账是一个会迟到的审计问题。签名验证和乱序 Webhook 其次,一旦客户端稳定,完整的架构和授权网格就可以在 CI 中持续运行。
本计划中的其他一切都为这五个方面服务。针对沙盒公司通过这些测试,并配有确认副作用的对账读取,你就会知道集成在触及任何真实总账之前是安全的。你可以在几分钟内从快速入门创建一个沙盒公司和一个带作用域的测试密钥,并根据功能列表检查你需要的模块。
FAQ
2xx 响应是否足以确认会计写入成功?
不足够。状态码只能确认服务器作出了响应,而不能保证正好存在一份单据、一个编号分配以及一个平衡的日记账分录。每个修改测试都应在响应之后对该对象、其会计影响及其审计记录进行回读。危险的情况是相反的方向:操作已经提交,而客户端根本没有收到响应。
请求超时时,我是否应该生成新的幂等密钥?
不应该,这是导致大多数生产环境重复的错误。密钥标识逻辑业务操作,而不是网络尝试。重试相同的密钥,然后通过读取资源进行对账。每次尝试都铸造一个全新 UUID 的重试库会将一个预期的发票变成多个独立的写入。
如果我使用不同的有效载荷重用幂等密钥会发生什么?
正确的 API 会拒绝它,而不是静默应用新的请求体。Nordlet 返回 422 idempotency_key_reuse 并保持原始记录不被触及。你的客户端应将此视为需要修复的编程错误,而不是通过生成新密钥来解决的情况。
Webhook 能否替代回读所创建的对象?
不能。Webhook 投递证明了下游通知,并不证明事务以你期望的方式提交,且投递顺序不保证。验证原始请求体的签名,根据投递或事件 ID 去除重复,并且每当仅凭事件顺序不足以决定发生了什么更改时,就从 API 获取当前状态。
如何测试过账到锁定的会计期间?
在开启期间和锁定期间分别创建等效单据,并断言锁定情况被拒绝,且没有日记账行、没有消耗单据编号、没有状态转换、也没有成功的 Webhook。然后运行竞态版本:针对开启期间启动过账并在请求进行中时锁定期间。已提交的过账绝不能落入在事务提交之前已被锁定的期间。
我能否在沙盒公司中运行大容量重复测试?
可以。沙盒公司表现得像真实公司一样,被标记为测试数据,并且会立即删除而不是在保留期之后删除,因此它是进行破坏性和重复测试的正确场所。沙盒的使用仍然会像真实情况一样进行计量和计费,因此请规划容量而不是无限循环。