hecks 2.9.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (600) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +51 -6
  3. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  6. data/lib/hecks/adapters/driven/memory.rb +16 -3
  7. data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
  8. data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
  9. data/lib/hecks/adapters/driven/postgres.rb +4 -2
  10. data/lib/hecks/adapters/driven/prism.rb +41 -8
  11. data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
  12. data/lib/hecks/behaviors/expectations.rb +1 -1
  13. data/lib/hecks/behaviors.rb +1 -1
  14. data/lib/hecks/bench/cli.rb +62 -0
  15. data/lib/hecks/bench/environment.rb +71 -0
  16. data/lib/hecks/bench/postgres_probe.rb +30 -0
  17. data/lib/hecks/bench/report.rb +76 -0
  18. data/lib/hecks/bench/ruby_runner.rb +93 -0
  19. data/lib/hecks/bench/run.rb +55 -0
  20. data/lib/hecks/bench/rust_runner.rb +101 -0
  21. data/lib/hecks/bench/stats.rb +46 -0
  22. data/lib/hecks/bench/suite.rb +98 -0
  23. data/lib/hecks/bench/workload.rb +89 -0
  24. data/lib/hecks/bench.rb +17 -0
  25. data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
  26. data/lib/hecks/bluebook/attribute.rb +1 -1
  27. data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
  28. data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
  29. data/lib/hecks/bluebook/chapter.rb +6 -2
  30. data/lib/hecks/bluebook/domain_port.rb +36 -5
  31. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
  32. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
  33. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
  34. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
  35. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
  36. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
  37. data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
  39. data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
  40. data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
  41. data/lib/hecks/bluebook/expression/resolver.rb +3 -2
  42. data/lib/hecks/bluebook/hexagon.rb +13 -5
  43. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
  44. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
  45. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
  46. data/lib/hecks/bluebook/meta_validator.rb +9 -4
  47. data/lib/hecks/bluebook/model_check.rb +23 -0
  48. data/lib/hecks/bluebook/pattern_subset.rb +38 -0
  49. data/lib/hecks/bluebook/query.rb +20 -5
  50. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  51. data/lib/hecks/canonical_json.rb +30 -0
  52. data/lib/hecks/chapters.rb +102 -0
  53. data/lib/hecks/cli/behaviors.rb +61 -0
  54. data/lib/hecks/cli/canonicalise.rb +25 -0
  55. data/lib/hecks/cli/check_engine_agreement.rb +32 -0
  56. data/lib/hecks/cli/check_era.rb +88 -0
  57. data/lib/hecks/cli/console.rb +66 -0
  58. data/lib/hecks/cli/deploy_template_diff.rb +40 -0
  59. data/lib/hecks/cli/doc_coverage.rb +32 -0
  60. data/lib/hecks/cli/document.rb +2 -2
  61. data/lib/hecks/cli/expression_projection.rb +41 -0
  62. data/lib/hecks/cli/follow.rb +88 -0
  63. data/lib/hecks/cli/history.rb +44 -0
  64. data/lib/hecks/cli/ir.rb +1 -1
  65. data/lib/hecks/cli/mcp.rb +3 -3
  66. data/lib/hecks/cli/model_check.rb +7 -4
  67. data/lib/hecks/cli/pattern_cases.rb +47 -0
  68. data/lib/hecks/cli/present.rb +39 -0
  69. data/lib/hecks/cli/project_cli.rb +224 -32
  70. data/lib/hecks/cli/project_diagrams.rb +4 -3
  71. data/lib/hecks/cli/project_glossary.rb +44 -0
  72. data/lib/hecks/cli/reference.rb +46 -0
  73. data/lib/hecks/cli/refresh_projections.rb +44 -0
  74. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
  75. data/lib/hecks/cli/release.rb +70 -0
  76. data/lib/hecks/cli/release_gem.rb +59 -0
  77. data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
  78. data/lib/hecks/cli/rspec_shard_files.rb +66 -0
  79. data/lib/hecks/cli/run.rb +3 -3
  80. data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
  81. data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
  82. data/lib/hecks/cli/shape.rb +70 -0
  83. data/lib/hecks/cli/smoke_http.rb +230 -0
  84. data/lib/hecks/cli/smoke_test.rb +5 -3
  85. data/lib/hecks/cli/spec_example.rb +27 -0
  86. data/lib/hecks/cli/statements.rb +32 -0
  87. data/lib/hecks/cli/stores.rb +4 -5
  88. data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
  89. data/lib/hecks/cli.rb +201 -52
  90. data/lib/hecks/codemod.rb +324 -0
  91. data/lib/hecks/corpus.rb +423 -0
  92. data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
  93. data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
  94. data/lib/hecks/deploy/oidc.json +82 -2
  95. data/lib/hecks/doc/reference.rb +351 -0
  96. data/lib/hecks/doors/cli_door.rb +196 -0
  97. data/lib/hecks/doors/cli_runner.rb +297 -0
  98. data/lib/hecks/{facade → doors}/command_request.rb +1 -1
  99. data/lib/hecks/{facade → doors}/handle.rb +2 -2
  100. data/lib/hecks/{facade → doors}/json_door.rb +4 -4
  101. data/lib/hecks/doors/launcher_options.rb +129 -0
  102. data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
  103. data/lib/hecks/doors/mcp_door_scope.rb +97 -0
  104. data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
  105. data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
  106. data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
  107. data/lib/hecks/doors.rb +61 -0
  108. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
  109. data/lib/hecks/embryonaut_bluebook.rb +1 -1
  110. data/lib/hecks/engine_agreement.rb +156 -0
  111. data/lib/hecks/facade.rb +24 -37
  112. data/lib/hecks/forms/app.rb +6 -6
  113. data/lib/hecks/forms/banking_presentation.rb +50 -0
  114. data/lib/hecks/forms/field_shape.rb +2 -1
  115. data/lib/hecks/forms/port_argument.rb +1 -1
  116. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
  117. data/lib/hecks/fuzzing/combination_miner.rb +86 -0
  118. data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
  119. data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
  120. data/lib/hecks/fuzzing/differential.rb +156 -0
  121. data/lib/hecks/fuzzing/domain_generator.rb +615 -0
  122. data/lib/hecks/fuzzing/era_boundary.rb +71 -0
  123. data/lib/hecks/fuzzing/form_census.rb +136 -0
  124. data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
  125. data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
  126. data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
  127. data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
  128. data/lib/hecks/fuzzing/outside_world.rb +45 -0
  129. data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
  130. data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
  131. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
  132. data/lib/hecks/fuzzing/properties/guards.rb +190 -0
  133. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
  134. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
  135. data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
  136. data/lib/hecks/fuzzing/properties/querying.rb +175 -0
  137. data/lib/hecks/fuzzing/properties.rb +162 -0
  138. data/lib/hecks/fuzzing/qa_settings.rb +123 -0
  139. data/lib/hecks/fuzzing/replay.rb +505 -0
  140. data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
  141. data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
  142. data/lib/hecks/fuzzing/self_consistency.rb +339 -0
  143. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
  144. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
  145. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
  146. data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
  147. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
  148. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
  149. data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
  150. data/lib/hecks/fuzzing/shrinker.rb +166 -0
  151. data/lib/hecks/fuzzing/structural_skips.rb +33 -0
  152. data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
  153. data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
  154. data/lib/hecks/fuzzing/value_generator.rb +216 -0
  155. data/lib/hecks/fuzzing.rb +23 -0
  156. data/lib/hecks/grammar/evolve.rb +336 -0
  157. data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
  158. data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
  159. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
  160. data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
  161. data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
  162. data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
  163. data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
  164. data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
  165. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
  166. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
  167. data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
  168. data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
  169. data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
  170. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
  171. data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
  172. data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
  173. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
  174. data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
  175. data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
  176. data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
  177. data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
  178. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
  179. data/lib/hecks/hecks/adapters/git.adapter +7 -0
  180. data/lib/hecks/hecks/adapters/git.rb +102 -0
  181. data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
  182. data/lib/hecks/hecks/adapters/host_http.rb +65 -0
  183. data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
  184. data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
  185. data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
  186. data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
  187. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
  188. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
  189. data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
  190. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
  191. data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
  192. data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
  193. data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
  194. data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
  195. data/lib/hecks/hecks/adapters/local_files.rb +46 -0
  196. data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
  197. data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
  198. data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
  199. data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
  200. data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
  201. data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
  202. data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
  203. data/lib/hecks/hecks/adapters/shell.adapter +7 -0
  204. data/lib/hecks/hecks/adapters/shell.rb +49 -0
  205. data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
  206. data/lib/hecks/hecks/adapters/terminal.rb +74 -0
  207. data/lib/hecks/hecks/codebase.behaviors +172 -0
  208. data/lib/hecks/hecks/codebase.bluebook +2453 -0
  209. data/lib/hecks/hecks/context_map.hecksagon +13 -0
  210. data/lib/hecks/hecks/custodian.behaviors +179 -0
  211. data/lib/hecks/hecks/custodian.bluebook +1847 -0
  212. data/lib/hecks/hecks/deploy.behaviors +74 -0
  213. data/lib/hecks/hecks/deploy.world +12 -0
  214. data/lib/hecks/hecks/environments/memory.world +10 -0
  215. data/lib/hecks/hecks/governance.world +7 -0
  216. data/lib/hecks/hecks/hecks.behaviors +62 -0
  217. data/lib/hecks/hecks/hecks.bluebook +124 -0
  218. data/lib/hecks/hecks/hecks.hecksagon +390 -0
  219. data/lib/hecks/hecks/hecks.world +42 -0
  220. data/lib/hecks/kernel_capabilities.rb +194 -0
  221. data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
  222. data/lib/hecks/language/bluebook/query.bluebook +7 -0
  223. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
  224. data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
  225. data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
  226. data/lib/hecks/mcp_stdio_guard.rb +2 -2
  227. data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
  228. data/lib/hecks/persistence_legacy_fixture.rb +113 -0
  229. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
  230. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
  231. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +6 -3
  237. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
  238. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
  239. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
  240. data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
  241. data/lib/hecks/ports/persistence/state_codec.rb +24 -5
  242. data/lib/hecks/projection_files.rb +285 -0
  243. data/lib/hecks/projections/bootstrap_table.rb +1 -1
  244. data/lib/hecks/projections/deploy/fargate.rb +5 -5
  245. data/lib/hecks/projections/deploy/lambda.rb +43 -32
  246. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  247. data/lib/hecks/projections/deploy/preview/template.rb +1 -1
  248. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
  249. data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
  250. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
  251. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
  252. data/lib/hecks/projections/deploy/shared.rb +1 -1
  253. data/lib/hecks/projections/diagrams.rb +3 -3
  254. data/lib/hecks/projections/model/deviations.rb +1 -1
  255. data/lib/hecks/projections/parser_table.rb +4 -4
  256. data/lib/hecks/projections/reference.rb +2 -3
  257. data/lib/hecks/projections/rust_vocabulary.rb +1 -1
  258. data/lib/hecks/projector/cli_projector.rb +106 -37
  259. data/lib/hecks/projector.rb +1 -1
  260. data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
  261. data/lib/hecks/quality_control/adapters/agent.rb +58 -0
  262. data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
  263. data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
  264. data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
  265. data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
  266. data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
  267. data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
  268. data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
  269. data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
  270. data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
  271. data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
  272. data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
  273. data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
  274. data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
  275. data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
  276. data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
  277. data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
  278. data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
  279. data/lib/hecks/quality_control/cli/child.rb +45 -0
  280. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
  281. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
  282. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
  283. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
  284. data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
  285. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
  286. data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
  287. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
  288. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
  289. data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
  290. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
  291. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
  292. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
  293. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
  294. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
  295. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
  296. data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
  297. data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
  298. data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
  299. data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
  300. data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
  301. data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
  302. data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
  303. data/lib/hecks/query_ir.rb +346 -0
  304. data/lib/hecks/query_ir_mcp.rb +198 -0
  305. data/lib/hecks/release/runner/ci_publisher.rb +2 -2
  306. data/lib/hecks/release/runner/clean_tree.rb +42 -0
  307. data/lib/hecks/release/runner/console.rb +6 -0
  308. data/lib/hecks/release/runner/gem_publisher.rb +14 -7
  309. data/lib/hecks/release/runner/npm_publisher.rb +6 -36
  310. data/lib/hecks/release/runner/preflight.rb +26 -2
  311. data/lib/hecks/release/runner/published.rb +8 -26
  312. data/lib/hecks/release/runner/tagger.rb +30 -15
  313. data/lib/hecks/release/runner.rb +57 -6
  314. data/lib/hecks/router/namespace_installer.rb +3 -3
  315. data/lib/hecks/runtime/adapter_lookup.rb +124 -0
  316. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
  317. data/lib/hecks/runtime/dependency_planning.rb +20 -2
  318. data/lib/hecks/runtime/dispatcher.rb +53 -3
  319. data/lib/hecks/runtime/interpreting.rb +3 -0
  320. data/lib/hecks/runtime/loader.rb +65 -12
  321. data/lib/hecks/runtime/outbox.rb +1 -1
  322. data/lib/hecks/runtime/policy_interpreter.rb +5 -2
  323. data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
  324. data/lib/hecks/runtime/query_interpreter.rb +77 -0
  325. data/lib/hecks/runtime/reaction_outcome.rb +89 -0
  326. data/lib/hecks/runtime/refusal_wording.rb +23 -1
  327. data/lib/hecks/runtime/registry/verification.rb +163 -4
  328. data/lib/hecks/runtime/registry.rb +57 -3
  329. data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
  330. data/lib/hecks/runtime/value/coercion.rb +23 -135
  331. data/lib/hecks/runtime/value/field_checks.rb +172 -0
  332. data/lib/hecks/runtime/value.rb +4 -1
  333. data/lib/hecks/runtime.rb +20 -6
  334. data/lib/hecks/rust_build/conformance.rb +158 -0
  335. data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
  336. data/lib/hecks/rust_build/coverage.rb +218 -0
  337. data/lib/hecks/rust_build/native_build.rb +124 -0
  338. data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
  339. data/lib/hecks/rust_build/project_rust.rb +332 -0
  340. data/lib/hecks/rust_build/wasm.rb +105 -0
  341. data/lib/hecks/rust_build/wasm_browser.rb +113 -0
  342. data/lib/hecks/rust_build.rb +144 -0
  343. data/lib/hecks/storehouse.rb +16 -16
  344. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
  345. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
  346. data/lib/hecks/three_zero/forms.yml +127 -0
  347. data/lib/hecks/three_zero.rb +12 -0
  348. data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
  349. data/lib/hecks/tools/comment_style.rb +1070 -0
  350. data/lib/hecks/tools/corpus_report.rb +87 -0
  351. data/lib/hecks/tools/deploy_recipe.rb +255 -0
  352. data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
  353. data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
  354. data/lib/hecks/tools/era_projection_backfill.rb +94 -0
  355. data/lib/hecks/tools/era_reattest.rb +146 -0
  356. data/lib/hecks/tools/evolve_run.rb +189 -0
  357. data/lib/hecks/tools/fuzz_sweep.rb +373 -0
  358. data/lib/hecks/tools/heki_compaction.rb +100 -0
  359. data/lib/hecks/tools/hoist_local_givens.rb +246 -0
  360. data/lib/hecks/tools/journal_compaction.rb +89 -0
  361. data/lib/hecks/tools/oidc_manifests.rb +70 -0
  362. data/lib/hecks/tools/query_ir_run.rb +81 -0
  363. data/lib/hecks/tools/regeneration_run.rb +166 -0
  364. data/lib/hecks/tools/rust_comment_style.rb +642 -0
  365. data/lib/hecks/tools/sequence_script.rb +62 -0
  366. data/lib/hecks/tools/tail_merge.rb +117 -0
  367. data/lib/hecks/tools/tenant_provisioning.rb +142 -0
  368. data/lib/hecks/tools/translation_audit.rb +208 -0
  369. data/lib/hecks/tools/translation_scaffold.rb +131 -0
  370. data/lib/hecks/tools.rb +86 -0
  371. data/lib/hecks/version.rb +1 -1
  372. data/lib/hecks.rb +32 -15
  373. data/qa/settings.yml +66 -0
  374. data/rust/.cargo/config.toml +6 -0
  375. data/rust/.gitignore +2 -0
  376. data/rust/Cargo.lock +54 -0
  377. data/rust/Cargo.toml +61 -0
  378. data/rust/build/.cargo/config.toml +3 -0
  379. data/rust/build/.gitignore +1 -0
  380. data/rust/build/Cargo.lock +7 -0
  381. data/rust/build/Cargo.toml +19 -0
  382. data/rust/build/src/build_artifact.rs +47 -0
  383. data/rust/build/src/cargo_sync.rs +248 -0
  384. data/rust/build/src/json.rs +421 -0
  385. data/rust/build/src/lineage_pass.rs +285 -0
  386. data/rust/build/src/main.rs +51 -0
  387. data/rust/build/src/optional_pass.rs +150 -0
  388. data/rust/build/src/pipeline.rs +264 -0
  389. data/rust/build/src/reserved_names.rs +76 -0
  390. data/rust/build/src/resolve.rs +124 -0
  391. data/rust/build/src/root.rs +25 -0
  392. data/rust/build/src/sidecars.rs +73 -0
  393. data/rust/build/src/subprocess.rs +51 -0
  394. data/rust/build/src/tmp.rs +31 -0
  395. data/rust/codegen/.cargo/config.toml +3 -0
  396. data/rust/codegen/.gitignore +1 -0
  397. data/rust/codegen/Cargo.lock +7 -0
  398. data/rust/codegen/Cargo.toml +20 -0
  399. data/rust/codegen/src/attr.rs +32 -0
  400. data/rust/codegen/src/bridging.rs +481 -0
  401. data/rust/codegen/src/commands.rs +1226 -0
  402. data/rust/codegen/src/constraints.rs +109 -0
  403. data/rust/codegen/src/dependency_planning.rs +209 -0
  404. data/rust/codegen/src/domain_generator.rs +1288 -0
  405. data/rust/codegen/src/exemplar.rs +230 -0
  406. data/rust/codegen/src/expr_emitter.rs +87 -0
  407. data/rust/codegen/src/fielded.rs +192 -0
  408. data/rust/codegen/src/hecks_naming.rs +33 -0
  409. data/rust/codegen/src/json.rs +335 -0
  410. data/rust/codegen/src/json_codec.rs +697 -0
  411. data/rust/codegen/src/literal.rs +207 -0
  412. data/rust/codegen/src/main.rs +290 -0
  413. data/rust/codegen/src/manifest.rs +227 -0
  414. data/rust/codegen/src/mutations.rs +784 -0
  415. data/rust/codegen/src/naming.rs +286 -0
  416. data/rust/codegen/src/ports.rs +208 -0
  417. data/rust/codegen/src/prelude.rs +116 -0
  418. data/rust/codegen/src/queries.rs +786 -0
  419. data/rust/codegen/src/reactions.rs +532 -0
  420. data/rust/codegen/src/read_models.rs +697 -0
  421. data/rust/codegen/src/reference_specs.rs +40 -0
  422. data/rust/codegen/src/registry.rs +966 -0
  423. data/rust/codegen/src/reserved_names.rs +76 -0
  424. data/rust/codegen/src/shared.rs +93 -0
  425. data/rust/codegen/src/skip_reason.rs +25 -0
  426. data/rust/codegen/src/types.rs +362 -0
  427. data/rust/host/.cargo/config.toml +3 -0
  428. data/rust/host/.gitignore +1 -0
  429. data/rust/host/Cargo.lock +4297 -0
  430. data/rust/host/Cargo.toml +61 -0
  431. data/rust/host/HECKS_RELEASE +1 -0
  432. data/rust/host/rds-ca-bundle.pem +2736 -0
  433. data/rust/host/src/api.rs +1596 -0
  434. data/rust/host/src/approval.rs +652 -0
  435. data/rust/host/src/auth.rs +1287 -0
  436. data/rust/host/src/bin/expr_harness.rs +46 -0
  437. data/rust/host/src/bin/lineage_harness.rs +119 -0
  438. data/rust/host/src/bin/mint_harness.rs +135 -0
  439. data/rust/host/src/checkout.rs +265 -0
  440. data/rust/host/src/dispatch.rs +1443 -0
  441. data/rust/host/src/expr_json.rs +1117 -0
  442. data/rust/host/src/field_hints.rs +52 -0
  443. data/rust/host/src/ir.rs +733 -0
  444. data/rust/host/src/journal.rs +874 -0
  445. data/rust/host/src/lambda_client.rs +378 -0
  446. data/rust/host/src/log.rs +147 -0
  447. data/rust/host/src/main.rs +474 -0
  448. data/rust/host/src/mint.rs +1865 -0
  449. data/rust/host/src/payments/keystore.rs +330 -0
  450. data/rust/host/src/payments/tests.rs +1400 -0
  451. data/rust/host/src/payments.rs +767 -0
  452. data/rust/host/src/presentation.rs +698 -0
  453. data/rust/host/src/presentation_write.rs +1431 -0
  454. data/rust/host/src/query_step.rs +149 -0
  455. data/rust/host/src/rate_limit.rs +904 -0
  456. data/rust/host/src/reference_transform.rs +241 -0
  457. data/rust/host/src/reference_validate.rs +338 -0
  458. data/rust/host/src/resend.rs +296 -0
  459. data/rust/host/src/secrets.rs +106 -0
  460. data/rust/host/src/server.rs +663 -0
  461. data/rust/host/src/storage_shape.rs +175 -0
  462. data/rust/host/src/test_pg.rs +40 -0
  463. data/rust/host/src/ui_schema.rs +1351 -0
  464. data/rust/host/src/wasm_runner.rs +191 -0
  465. data/rust/host/src/web/newsletter/cooldown.rs +126 -0
  466. data/rust/host/src/web/newsletter/tests.rs +104 -0
  467. data/rust/host/src/web/newsletter.rs +310 -0
  468. data/rust/host/src/web/newsletter_send.rs +357 -0
  469. data/rust/host/src/web/registrations.rs +1505 -0
  470. data/rust/host/src/web.rs +2937 -0
  471. data/rust/lsp/.gitignore +1 -0
  472. data/rust/lsp/Cargo.lock +7 -0
  473. data/rust/lsp/Cargo.toml +16 -0
  474. data/rust/lsp/README.md +133 -0
  475. data/rust/lsp/src/diagnostics.rs +165 -0
  476. data/rust/lsp/src/json.rs +385 -0
  477. data/rust/lsp/src/main.rs +399 -0
  478. data/rust/lsp/src/outline.rs +177 -0
  479. data/rust/lsp/src/rpc.rs +67 -0
  480. data/rust/parser/.cargo/config.toml +5 -0
  481. data/rust/parser/.gitignore +1 -0
  482. data/rust/parser/Cargo.lock +7 -0
  483. data/rust/parser/Cargo.toml +21 -0
  484. data/rust/parser/src/build/closed_sets.rs +27 -0
  485. data/rust/parser/src/build/identity.rs +276 -0
  486. data/rust/parser/src/build/mod.rs +12 -0
  487. data/rust/parser/src/build/naming.rs +141 -0
  488. data/rust/parser/src/build/pattern_subset.rs +161 -0
  489. data/rust/parser/src/build/query_derive.rs +72 -0
  490. data/rust/parser/src/build/query_inference.rs +269 -0
  491. data/rust/parser/src/build/query_options.rs +100 -0
  492. data/rust/parser/src/build/read_model.rs +94 -0
  493. data/rust/parser/src/build/references.rs +32 -0
  494. data/rust/parser/src/canonical.rs +114 -0
  495. data/rust/parser/src/diag.rs +55 -0
  496. data/rust/parser/src/emit.rs +953 -0
  497. data/rust/parser/src/expr/ast_json.rs +147 -0
  498. data/rust/parser/src/expr/evaluator.rs +81 -0
  499. data/rust/parser/src/expr/mod.rs +84 -0
  500. data/rust/parser/src/expr/resolver.rs +497 -0
  501. data/rust/parser/src/ir.rs +387 -0
  502. data/rust/parser/src/keywords.rs +585 -0
  503. data/rust/parser/src/lex.rs +662 -0
  504. data/rust/parser/src/main.rs +252 -0
  505. data/rust/parser/src/parse/aggregate.rs +612 -0
  506. data/rust/parser/src/parse/chapter.rs +517 -0
  507. data/rust/parser/src/parse/command.rs +637 -0
  508. data/rust/parser/src/parse/domain_port.rs +127 -0
  509. data/rust/parser/src/parse/entity.rs +527 -0
  510. data/rust/parser/src/parse/file.rs +67 -0
  511. data/rust/parser/src/parse/hecksagon.rs +231 -0
  512. data/rust/parser/src/parse/lifecycle.rs +162 -0
  513. data/rust/parser/src/parse/mod.rs +1262 -0
  514. data/rust/parser/src/parse/policy.rs +87 -0
  515. data/rust/parser/src/parse/process_manager.rs +305 -0
  516. data/rust/parser/src/parse/query.rs +100 -0
  517. data/rust/parser/src/parse/read_model.rs +214 -0
  518. data/rust/parser/src/parse/value_object.rs +250 -0
  519. data/rust/parser/src/ruby_value.rs +422 -0
  520. data/rust/project/bridging.rb +257 -0
  521. data/rust/project/commands.rb +877 -0
  522. data/rust/project/constraints.rb +86 -0
  523. data/rust/project/dependency_planning.rb +178 -0
  524. data/rust/project/domain_generator.rb +980 -0
  525. data/rust/project/exemplar.rb +148 -0
  526. data/rust/project/expr_emitter.rb +72 -0
  527. data/rust/project/fielded.rb +141 -0
  528. data/rust/project/json_codec.rb +641 -0
  529. data/rust/project/mutations.rb +721 -0
  530. data/rust/project/naming.rb +205 -0
  531. data/rust/project/ports.rb +65 -0
  532. data/rust/project/queries.rb +493 -0
  533. data/rust/project/reactions.rb +317 -0
  534. data/rust/project/read_models.rb +690 -0
  535. data/rust/project/reference_specs.rb +27 -0
  536. data/rust/project/registry.rb +396 -0
  537. data/rust/project/skip_reason.rb +29 -0
  538. data/rust/project/types.rb +225 -0
  539. data/rust/project/write_if_changed.rb +83 -0
  540. data/rust/project.rb +30 -0
  541. data/rust/project_rust_pipeline.rb +383 -0
  542. data/rust/src/exemplar/commands.rs +279 -0
  543. data/rust/src/exemplar/constraints.rs +18 -0
  544. data/rust/src/exemplar/fielded.rs +280 -0
  545. data/rust/src/exemplar/json.rs +277 -0
  546. data/rust/src/exemplar/mod.rs +17 -0
  547. data/rust/src/exemplar/mutations.rs +80 -0
  548. data/rust/src/exemplar/queries.rs +33 -0
  549. data/rust/src/exemplar/reactions.rs +88 -0
  550. data/rust/src/exemplar/read_models.rs +48 -0
  551. data/rust/src/exemplar/registry.rs +166 -0
  552. data/rust/src/exemplar/types.rs +44 -0
  553. data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
  554. data/rust/src/kernel/attribute_shapes/list.rs +18 -0
  555. data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
  556. data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
  557. data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
  558. data/rust/src/kernel/cli.rs +585 -0
  559. data/rust/src/kernel/dispatch.rs +1090 -0
  560. data/rust/src/kernel/expr.rs +458 -0
  561. data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
  562. data/rust/src/kernel/expression_operators/comparison.rs +48 -0
  563. data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
  564. data/rust/src/kernel/expression_operators/logical.rs +13 -0
  565. data/rust/src/kernel/expression_operators/membership.rs +18 -0
  566. data/rust/src/kernel/expression_operators/mod.rs +68 -0
  567. data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
  568. data/rust/src/kernel/expression_operators/positional.rs +126 -0
  569. data/rust/src/kernel/expression_operators/presence.rs +101 -0
  570. data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
  571. data/rust/src/kernel/expression_operators/sized.rs +35 -0
  572. data/rust/src/kernel/expression_operators/text.rs +79 -0
  573. data/rust/src/kernel/expression_operators/to_string.rs +18 -0
  574. data/rust/src/kernel/json.rs +779 -0
  575. data/rust/src/kernel/mod.rs +133 -0
  576. data/rust/src/kernel/named_query.rs +219 -0
  577. data/rust/src/kernel/orchestrate.rs +1810 -0
  578. data/rust/src/kernel/pattern.rs +307 -0
  579. data/rust/src/kernel/query_comparators.rs +206 -0
  580. data/rust/src/kernel/query_ordering.rs +103 -0
  581. data/rust/src/kernel/read_model.rs +914 -0
  582. data/rust/src/kernel/reference_lookup.rs +149 -0
  583. data/rust/src/kernel/repository.rs +468 -0
  584. data/rust/src/kernel/routing.rs +612 -0
  585. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
  586. data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
  587. data/rust/src/kernel/vocab/field_hint.rs +70 -0
  588. data/rust/src/kernel/vocab/mod.rs +20 -0
  589. data/rust/src/kernel/vocab/query_comparator.rs +65 -0
  590. data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
  591. data/rust/src/lib.rs +8 -0
  592. data/rust/src/main.rs +16 -0
  593. data/rust/web/.gitignore +1 -0
  594. data/rust/web/Cargo.lock +119 -0
  595. data/rust/web/Cargo.toml +18 -0
  596. data/rust/web/src/lib.rs +8 -0
  597. metadata +477 -12
  598. data/lib/hecks/facade/cli_door.rb +0 -96
  599. data/lib/hecks/facade/cli_runner.rb +0 -127
  600. data/lib/hecks/mcp_door_scope.rb +0 -95
@@ -0,0 +1,1400 @@
1
+ // Route-level tests for payments.rs, run against real Postgres and the compiled
2
+ // checkout_fixture wasm; every Stripe call goes to a local recording server, not the network.
3
+
4
+ use super::keystore::MemoryStore;
5
+ use super::*;
6
+ use crate::dispatch::tests::{provision_lineage, scratch_db};
7
+ use crate::lambda_client::NeverInvoker;
8
+ use crate::web;
9
+ use axum::extract::State;
10
+ use axum::http::StatusCode;
11
+ use axum::response::IntoResponse;
12
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
13
+ use std::sync::{Arc, Mutex as StdMutex};
14
+
15
+ const SESSION_SECRET: &str = "s3cret";
16
+ const WEBHOOK_SECRET: &str = "whsec_own_account";
17
+ const DIRECT_KEY: &str = "sk_test_OWN_ACCOUNT_KEY_DO_NOT_LEAK";
18
+ const DIRECT_PUBLISHABLE_KEY: &str = "pk_test_OWN_ACCOUNT_PUBLISHABLE";
19
+ // Keys pasted into the Payments page and saved (rather than set in the environment).
20
+ const SAVED_KEY: &str = "rk_test_SAVED_KEY_DO_NOT_LEAK";
21
+ const SAVED_KEY_TWO: &str = "rk_test_SECOND_SAVED_KEY_DO_NOT_LEAK";
22
+ const SAVED_PUBLISHABLE_KEY: &str = "pk_test_SAVED_PUBLISHABLE";
23
+ const LIVE_SAVED_KEY: &str = "rk_live_LIVE_SAVED_KEY_DO_NOT_LEAK";
24
+ const LIVE_SAVED_PUBLISHABLE_KEY: &str = "pk_live_LIVE_SAVED_PUBLISHABLE";
25
+
26
+ const OWNER: &str = "owner@example.com";
27
+ const ADMIN: &str = "admin@example.com";
28
+ const OPERATOR: &str = "operator@example.com";
29
+ const DISABLED_OWNER: &str = "gone@example.com";
30
+
31
+ #[derive(Clone, Debug)]
32
+ struct Recorded {
33
+ method: String,
34
+ path: String,
35
+ headers: HashMap<String, String>,
36
+ body: String,
37
+ }
38
+
39
+ impl Recorded {
40
+ fn header(&self, name: &str) -> Option<&str> {
41
+ self.headers.get(name).map(String::as_str)
42
+ }
43
+ }
44
+
45
+ struct FakeState {
46
+ requests: StdMutex<Vec<Recorded>>,
47
+ refuse_sessions: AtomicBool,
48
+ refuse_account: AtomicBool,
49
+ // A key Stripe does not recognise at all (401 on every call).
50
+ invalid_key: AtomicBool,
51
+ // A restricted key without the Webhook Endpoints permission.
52
+ refuse_webhooks: AtomicBool,
53
+ // Numbers the webhook endpoints created, so each has its own id and secret.
54
+ webhooks_created: AtomicUsize,
55
+ // Stripe cannot delete a webhook endpoint (an outage, say).
56
+ fail_deletes: AtomicBool,
57
+ }
58
+
59
+ struct FakeStripe {
60
+ base: String,
61
+ state: Arc<FakeState>,
62
+ }
63
+
64
+ impl FakeStripe {
65
+ async fn start() -> Self {
66
+ let state = Arc::new(FakeState {
67
+ requests: StdMutex::new(Vec::new()),
68
+ refuse_sessions: AtomicBool::new(false),
69
+ refuse_account: AtomicBool::new(false),
70
+ invalid_key: AtomicBool::new(false),
71
+ refuse_webhooks: AtomicBool::new(false),
72
+ webhooks_created: AtomicUsize::new(0),
73
+ fail_deletes: AtomicBool::new(false),
74
+ });
75
+ let app = axum::Router::new().fallback(answer).with_state(state.clone());
76
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind a local port");
77
+ let base = format!("http://{}", listener.local_addr().unwrap());
78
+ tokio::spawn(async move {
79
+ let _ = axum::serve(listener, app).await;
80
+ });
81
+ Self { base, state }
82
+ }
83
+
84
+ fn requests(&self) -> Vec<Recorded> {
85
+ self.state.requests.lock().unwrap().clone()
86
+ }
87
+
88
+ fn requests_to(&self, path: &str) -> Vec<Recorded> {
89
+ self.requests().into_iter().filter(|r| r.path == path).collect()
90
+ }
91
+ }
92
+
93
+ async fn answer(State(state): State<Arc<FakeState>>, request: axum::extract::Request) -> axum::response::Response {
94
+ let (parts, body) = request.into_parts();
95
+ let bytes = axum::body::to_bytes(body, 1 << 20).await.unwrap();
96
+ let recorded = Recorded {
97
+ method: parts.method.to_string(),
98
+ path: parts.uri.path().to_string(),
99
+ headers: parts.headers.iter().map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string())).collect(),
100
+ body: String::from_utf8_lossy(&bytes).to_string(),
101
+ };
102
+ let path = recorded.path.clone();
103
+ let method = recorded.method.clone();
104
+ state.requests.lock().unwrap().push(recorded);
105
+
106
+ let (status, reply) = match (method.as_str(), path.as_str()) {
107
+ // Stripe does not recognise the key: every call it authenticates is a 401.
108
+ (_, p) if state.invalid_key.load(Ordering::SeqCst) && p.starts_with("/v1/") => (StatusCode::UNAUTHORIZED, json!({"error": {"message": "Invalid API Key provided: rk_test_****"}})),
109
+ // The webhook endpoints of the business's own account.
110
+ ("POST", "/v1/webhook_endpoints") if state.refuse_webhooks.load(Ordering::SeqCst) => {
111
+ (StatusCode::FORBIDDEN, json!({"error": {"message": "The provided key does not have the required permissions"}}))
112
+ }
113
+ ("POST", "/v1/webhook_endpoints") => {
114
+ let n = state.webhooks_created.fetch_add(1, Ordering::SeqCst) + 1;
115
+ (StatusCode::OK, json!({"id": format!("we_{n}"), "object": "webhook_endpoint", "secret": format!("whsec_SAVED_{n}")}))
116
+ }
117
+ ("DELETE", p) if p.starts_with("/v1/webhook_endpoints/") && state.fail_deletes.load(Ordering::SeqCst) => {
118
+ (StatusCode::INTERNAL_SERVER_ERROR, json!({"error": {"message": "Stripe is having trouble"}}))
119
+ }
120
+ ("DELETE", p) if p.starts_with("/v1/webhook_endpoints/") => (StatusCode::OK, json!({"id": p.trim_start_matches("/v1/webhook_endpoints/"), "deleted": true})),
121
+ // The business's own account, read with its own key.
122
+ ("GET", "/v1/account") if state.refuse_account.load(Ordering::SeqCst) => (StatusCode::FORBIDDEN, json!({"error": {"message": "key lacks permission"}})),
123
+ ("GET", "/v1/account") => (StatusCode::OK, json!({"id": "acct_own_1", "business_profile": {"name": "Own Studio"}})),
124
+ ("POST", "/v1/checkout/sessions") if state.refuse_sessions.load(Ordering::SeqCst) => {
125
+ (StatusCode::BAD_REQUEST, json!({"error": {"message": "no such price"}}))
126
+ }
127
+ // An embedded session: Stripe answers with a client secret for the
128
+ // browser and no hosted `url`.
129
+ ("POST", "/v1/checkout/sessions") => (StatusCode::OK, json!({"id": "cs_test_1", "ui_mode": "embedded_page", "client_secret": "cs_test_1_secret_CLIENT"})),
130
+ _ => (StatusCode::NOT_FOUND, json!({"error": {"message": "no such fake route"}})),
131
+ };
132
+ (status, axum::Json(reply)).into_response()
133
+ }
134
+
135
+ struct Tenant {
136
+ client: Mutex<Client>,
137
+ domain_ir: Value,
138
+ config: LineageConfig,
139
+ wasm: std::path::PathBuf,
140
+ fake: FakeStripe,
141
+ platform: PlatformConfig,
142
+ // The in-memory stand-in for the saved-keys secret, when the tenant has one.
143
+ raw: Option<Arc<MemoryStore>>,
144
+ }
145
+
146
+ async fn tenant(name: &str) -> Tenant {
147
+ let client = scratch_db(name).await;
148
+ {
149
+ let guard = client.lock().await;
150
+ provision_lineage(&guard, "CheckoutFixture", 1, &["Event", "Registration", "Payment", "PaymentConnection"]).await;
151
+ guard
152
+ .batch_execute(
153
+ "CREATE TABLE acme_member_head_snapshot_1 (id text PRIMARY KEY, ordinal bigint NOT NULL, state jsonb NOT NULL);
154
+ CREATE VIEW acme_member_head AS SELECT id, state FROM acme_member_head_snapshot_1;
155
+ CREATE TABLE hecks_journal_acme (
156
+ ordinal bigserial PRIMARY KEY, era int NOT NULL, aggregate text NOT NULL,
157
+ aggregate_id text NOT NULL, operation text NOT NULL, state jsonb, mirrors jsonb
158
+ );",
159
+ )
160
+ .await
161
+ .unwrap();
162
+ let person = |email: &str, role: Option<&str>, disabled: bool| {
163
+ let mut state = json!({"name": {"value": email}, "email": {"value": email}, "role": role.map(|r| json!({"value": r}))});
164
+ if disabled {
165
+ state["disabled"] = json!(true);
166
+ }
167
+ state
168
+ };
169
+ for (email, state) in [
170
+ (OWNER, person(OWNER, Some("Owner"), false)),
171
+ (ADMIN, person(ADMIN, Some("Admin"), false)),
172
+ (OPERATOR, person(OPERATOR, Some("Admin"), false)),
173
+ (DISABLED_OWNER, person(DISABLED_OWNER, Some("Owner"), true)),
174
+ ] {
175
+ guard
176
+ .execute("INSERT INTO acme_member_head_snapshot_1 (id, ordinal, state) VALUES ($1, 0, $2::jsonb)", &[&email, &state])
177
+ .await
178
+ .unwrap();
179
+ }
180
+ }
181
+ let domain_ir = json!({
182
+ "name": "Acme",
183
+ "lineage": {"capable_aggregates": [{"name": "Member", "storage_name": "member"}]},
184
+ "membership": {"provider": "Acme", "aggregate": "Acme::Member"},
185
+ });
186
+ let fake = FakeStripe::start().await;
187
+ let platform = PlatformConfig {
188
+ site_url: "https://site.example".to_string(),
189
+ webhook_base_url: "https://webhooks.example.test".to_string(),
190
+ store: None,
191
+ stored: Arc::new(StoredDocument::default()),
192
+ api_base: fake.base.clone(),
193
+ webhook_description: "Checkout Fixture website".to_string(),
194
+ direct_test_key: DIRECT_KEY.to_string(),
195
+ direct_live_key: String::new(),
196
+ direct_test_publishable_key: DIRECT_PUBLISHABLE_KEY.to_string(),
197
+ direct_live_publishable_key: String::new(),
198
+ webhook_secret: Some(WEBHOOK_SECRET.to_string()),
199
+ operators: vec![OPERATOR.to_string()],
200
+ };
201
+ Tenant {
202
+ client,
203
+ domain_ir,
204
+ config: LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(1), mirrored: None },
205
+ wasm: std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/checkout_fixture.wasm"),
206
+ fake,
207
+ platform,
208
+ raw: None,
209
+ }
210
+ }
211
+
212
+ impl Tenant {
213
+ // The platform settings for one request, the way `PlatformConfig::load`
214
+ // builds them: what the tenant was given, with the key store read in.
215
+ async fn platform_now(&self) -> PlatformConfig {
216
+ let mut platform = self.platform.clone();
217
+ platform.apply_store().await;
218
+ platform
219
+ }
220
+
221
+ // A tenant whose business can paste its keys into the Payments page: no
222
+ // environment keys, only a key store (in memory).
223
+ async fn saving(name: &str) -> Tenant {
224
+ let mut t = tenant(name).await;
225
+ t.platform.direct_test_key = String::new();
226
+ t.platform.direct_test_publishable_key = String::new();
227
+ let raw = Arc::new(MemoryStore::default());
228
+ t.platform.store = Some(Arc::new(KeyStore::new(raw.clone())));
229
+ t.raw = Some(raw);
230
+ t
231
+ }
232
+
233
+ // The Payments page's Save, as the given person.
234
+ async fn save_keys(&self, secret_key: &str, publishable_key: &str, as_email: Option<&str>) -> (u64, Value) {
235
+ self.call("POST", "/payments/connection/direct", json!({"secret_key": secret_key, "publishable_key": publishable_key}), as_email).await
236
+ }
237
+
238
+ // What the saved-keys secret holds right now (`Null` before the first save).
239
+ fn saved_document(&self) -> Value {
240
+ let raw = self.raw.as_ref().expect("a tenant with a key store");
241
+ let text = raw.document.lock().unwrap().clone();
242
+ text.map(|text| serde_json::from_str(&text).unwrap()).unwrap_or(Value::Null)
243
+ }
244
+
245
+ // One request against the payments routes; `None` means no session cookie.
246
+ async fn call(&self, method: &str, path: &str, body: Value, as_email: Option<&str>) -> (u64, Value) {
247
+ let mut cookies = HashMap::new();
248
+ if let Some(email) = as_email {
249
+ cookies.insert(auth::account_cookie_name(), auth::account_token(SESSION_SECRET, email, 60));
250
+ }
251
+ let platform = self.platform_now().await;
252
+ let response = route(method, path, &body.to_string(), &cookies, SESSION_SECRET, &self.domain_ir, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker)
253
+ .await
254
+ .expect("a payments path");
255
+ (response["statusCode"].as_u64().unwrap(), serde_json::from_str(response["body"].as_str().unwrap()).unwrap_or(Value::Null))
256
+ }
257
+
258
+ async fn status(&self) -> String {
259
+ let read = dispatch::read(&self.client, &self.wasm).await.unwrap();
260
+ connection(&read, &self.config.domain).map(|c| c.status).unwrap_or_else(|| "none".to_string())
261
+ }
262
+
263
+ // Owner records the business's own test-mode account as the connection.
264
+ async fn connect(&self) {
265
+ let (status, body) = self.call("POST", "/payments/connection/direct", json!({"mode": "test"}), Some(OWNER)).await;
266
+ assert_eq!(status, 200, "{body}");
267
+ }
268
+
269
+ // Every row of every table in the scratch database, as text.
270
+ async fn dump_database(&self) -> String {
271
+ let guard = self.client.lock().await;
272
+ let tables = guard.query("SELECT tablename FROM pg_tables WHERE schemaname = 'public'", &[]).await.unwrap();
273
+ let mut dump = String::new();
274
+ for table in tables {
275
+ let name: String = table.get(0);
276
+ for row in guard.query(&format!("SELECT t::text FROM \"{name}\" t"), &[]).await.unwrap() {
277
+ dump.push_str(&row.get::<_, String>(0));
278
+ dump.push('\n');
279
+ }
280
+ }
281
+ dump
282
+ }
283
+
284
+ async fn enable(&self) {
285
+ let (status, body) = self.call("POST", "/payments/connection/enable", json!({}), Some(OPERATOR)).await;
286
+ assert_eq!(status, 200, "{body}");
287
+ }
288
+
289
+ async fn schedule_event(&self, slug: &str) {
290
+ self.schedule_event_with_capacity(slug, 20).await;
291
+ }
292
+
293
+ async fn schedule_event_with_capacity(&self, slug: &str, capacity: i64) {
294
+ let args = json!({"slug": {"value": slug}, "name": {"value": "Sample Studio"}, "price": {"cents": 4200}, "capacity": {"value": capacity}});
295
+ let outcome = dispatch::handle(&self.client, &self.wasm, "CheckoutFixture::Event.Schedule", args, None, &self.config, &NeverInvoker).await.unwrap();
296
+ assert!(outcome.accepted, "{:?}", outcome.result);
297
+ }
298
+
299
+ // POST /registrations for `slug`; the checkout URL and registration id on success.
300
+ async fn register(&self, slug: &str) -> (u64, Value) {
301
+ let body = json!({"event_slug": slug, "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
302
+ let platform = self.platform_now().await;
303
+ let response = web::registrations_route(&body, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
304
+ (response["statusCode"].as_u64().unwrap(), serde_json::from_str(response["body"].as_str().unwrap()).unwrap_or(Value::Null))
305
+ }
306
+
307
+ async fn webhook(&self, event: Value) -> u64 {
308
+ self.webhook_signed_with(WEBHOOK_SECRET, event).await
309
+ }
310
+
311
+ // A webhook delivery signed with `secret`, the way Stripe would sign it.
312
+ async fn webhook_signed_with(&self, secret: &str, event: Value) -> u64 {
313
+ let payload = event.to_string();
314
+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
315
+ let header = sign(secret, now, &payload);
316
+ let platform = self.platform_now().await;
317
+ let response = web::webhook_route(&payload, &header, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
318
+ response["statusCode"].as_u64().unwrap()
319
+ }
320
+
321
+ // POST /registrations/:id/complete with a successful outcome (the mock
322
+ // walkthrough's "Pay" button).
323
+ async fn settle(&self, reference: &str) -> u64 {
324
+ let response = web::registration_complete_route(reference, r#"{"outcome":"succeeded"}"#, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
325
+ response["statusCode"].as_u64().unwrap()
326
+ }
327
+
328
+ async fn payment_status(&self, reference: &str) -> String {
329
+ let read = dispatch::read(&self.client, &self.wasm).await.unwrap();
330
+ read["instances"][format!("Payments::Payment#{reference}")]["status"].as_str().unwrap_or("none").to_string()
331
+ }
332
+ }
333
+
334
+ fn sign(secret: &str, now: i64, payload: &str) -> String {
335
+ use hmac::{Hmac, Mac};
336
+ use sha2::Sha256;
337
+ let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
338
+ mac.update(format!("{now}.{payload}").as_bytes());
339
+ format!("t={now},v1={}", mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect::<String>())
340
+ }
341
+
342
+ fn completed(reference: &str) -> Value {
343
+ json!({"type": "checkout.session.completed", "data": {"object": {"id": "cs_test_1", "metadata": {"registration_id": reference}}}})
344
+ }
345
+
346
+ fn expired(reference: &str) -> Value {
347
+ json!({"type": "checkout.session.expired", "data": {"object": {"id": "cs_test_1", "metadata": {"registration_id": reference}}}})
348
+ }
349
+
350
+ #[tokio::test]
351
+ async fn every_route_refuses_a_missing_session_with_a_json_401_and_touches_nothing() {
352
+ let t = tenant("hecks_pay_test_401").await;
353
+ for (method, path) in [
354
+ ("GET", "/payments/connection"),
355
+ ("POST", "/payments/connection/direct"),
356
+ ("POST", "/payments/connection/disconnect"),
357
+ ("POST", "/payments/connection/enable"),
358
+ ("POST", "/payments/connection/disable"),
359
+ ] {
360
+ let (status, body) = t.call(method, path, json!({}), None).await;
361
+ assert_eq!((status, body["error"].as_str()), (401, Some("not logged in")), "{method} {path}");
362
+ }
363
+ assert!(t.fake.requests().is_empty(), "no Stripe call without a session");
364
+ assert_eq!(t.status().await, "none");
365
+ }
366
+
367
+ #[tokio::test]
368
+ async fn a_tampered_or_disabled_session_is_a_401_not_an_owner() {
369
+ let t = tenant("hecks_pay_test_401_disabled").await;
370
+ let mut cookies = HashMap::new();
371
+ cookies.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
372
+ let response = route("GET", "/payments/connection", "", &cookies, SESSION_SECRET, &t.domain_ir, &t.platform, &t.client, &t.wasm, &t.config, &NeverInvoker).await.unwrap();
373
+ assert_eq!(response["statusCode"], 401);
374
+
375
+ // An Owner disabled after the cookie was signed: the signature is still valid.
376
+ let (status, _) = t.call("GET", "/payments/connection", json!({}), Some(DISABLED_OWNER)).await;
377
+ assert_eq!(status, 401);
378
+ let (status, _) = t.call("POST", "/payments/connection/direct", json!({"mode": "test"}), Some(DISABLED_OWNER)).await;
379
+ assert_eq!(status, 401);
380
+ }
381
+
382
+ #[tokio::test]
383
+ async fn only_an_owner_may_connect_or_disconnect_and_only_the_operator_may_enable() {
384
+ let t = tenant("hecks_pay_test_roles").await;
385
+
386
+ // A non-Owner admin: read refused, every owner action refused.
387
+ let (status, _) = t.call("GET", "/payments/connection", json!({}), Some(ADMIN)).await;
388
+ assert_eq!(status, 403);
389
+ for path in ["/payments/connection/direct", "/payments/connection/disconnect"] {
390
+ let (status, body) = t.call("POST", path, json!({"mode": "test"}), Some(ADMIN)).await;
391
+ assert_eq!((status, body["error"].as_str()), (403, Some("only an Owner can manage payments")), "{path}");
392
+ }
393
+
394
+ t.connect().await;
395
+ assert_eq!(t.status().await, "connected");
396
+
397
+ // The Owner is not the operator: enable and disable are refused.
398
+ for path in ["/payments/connection/enable", "/payments/connection/disable"] {
399
+ let (status, _) = t.call("POST", path, json!({}), Some(OWNER)).await;
400
+ assert_eq!(status, 403, "{path}");
401
+ }
402
+ // The operator is not an Owner: cannot disconnect.
403
+ let (status, _) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(OPERATOR)).await;
404
+ assert_eq!(status, 403);
405
+ assert_eq!(t.status().await, "connected", "nothing above changed the connection");
406
+
407
+ // ...but the operator may read the page state, and enable.
408
+ let (status, body) = t.call("GET", "/payments/connection", json!({}), Some(OPERATOR)).await;
409
+ assert_eq!(status, 200);
410
+ assert_eq!((body["can_manage"].clone(), body["can_enable"].clone()), (json!(false), json!(true)));
411
+ t.enable().await;
412
+ assert_eq!(t.status().await, "enabled");
413
+ }
414
+
415
+ #[tokio::test]
416
+ async fn an_admin_granted_owner_passes_the_payments_gate_and_keeps_the_admin_gate() {
417
+ let t = tenant("hecks_pay_test_owner_bootstrap").await;
418
+ let members = LineageConfig { domain: "Acme".to_string(), era: Some(1), mirrored: None };
419
+ let cookies = |email: &str| HashMap::from([(auth::account_cookie_name(), auth::account_token(SESSION_SECRET, email, 60))]);
420
+
421
+ // An Admin is not an Owner: the payments routes refuse them.
422
+ let (status, _) = t.call("GET", "/payments/connection", json!({}), Some(ADMIN)).await;
423
+ assert_eq!(status, 403);
424
+
425
+ // An Admin grants Owner to a new person; that person passes the Payments
426
+ // Owner gate and is still admitted by the members admin gate.
427
+ let body = json!({"email": "boss@example.com", "name": "Boss", "role": "Owner"}).to_string();
428
+ let response = web::add_member_route(&t.domain_ir, &body, &cookies(ADMIN), SESSION_SECRET, &t.client, &t.wasm, &members).await;
429
+ assert_eq!(response["statusCode"], 201, "{response:?}");
430
+ let (status, body) = t.call("GET", "/payments/connection", json!({}), Some("boss@example.com")).await;
431
+ assert_eq!((status, body["can_manage"].clone()), (200, json!(true)), "{body}");
432
+ let response = web::add_member_route(&t.domain_ir, r#"{"email": "next@example.com", "name": "Next"}"#, &cookies("boss@example.com"), SESSION_SECRET, &t.client, &t.wasm, &members).await;
433
+ assert_eq!(response["statusCode"], 201, "{response:?}");
434
+
435
+ // Granting Owner to an existing Admin turns them into an Owner too.
436
+ assert!(auth::grant_access(&t.client, &t.wasm, &members, &t.domain_ir, ADMIN, "Owner").await.unwrap());
437
+ let (status, body) = t.call("GET", "/payments/connection", json!({}), Some(ADMIN)).await;
438
+ assert_eq!((status, body["can_manage"].clone()), (200, json!(true)), "{body}");
439
+ assert!(auth::caller_is_admin(&t.client, &t.domain_ir, ADMIN).await.unwrap());
440
+ }
441
+
442
+ #[tokio::test]
443
+ async fn the_status_route_reports_not_connected_and_which_modes_have_keys() {
444
+ let t = tenant("hecks_pay_test_status_empty").await;
445
+ let (status, body) = t.call("GET", "/payments/connection", json!({}), Some(OWNER)).await;
446
+ assert_eq!(status, 200);
447
+ assert_eq!(body["status"], "not_connected");
448
+ assert_eq!(body["processor"], Value::Null);
449
+ assert_eq!((body["direct_modes"].clone(), body["direct"].clone(), body["adapters"].clone()), (json!(["test"]), json!(false), json!([])));
450
+ assert_eq!((body["can_manage"].clone(), body["can_enable"].clone()), (json!(true), json!(false)));
451
+
452
+ // With no keys at all, no mode is offered.
453
+ let mut bare = tenant("hecks_pay_test_status_bare").await;
454
+ bare.platform.direct_test_key = String::new();
455
+ let (_, body) = bare.call("GET", "/payments/connection", json!({}), Some(OWNER)).await;
456
+ assert_eq!(body["direct_modes"], json!([]));
457
+ }
458
+
459
+ #[tokio::test]
460
+ async fn enable_needs_a_connection_and_follows_the_lifecycle() {
461
+ let t = tenant("hecks_pay_test_enable").await;
462
+ let (status, body) = t.call("POST", "/payments/connection/enable", json!({}), Some(OPERATOR)).await;
463
+ assert_eq!((status, body["error"].as_str()), (409, Some("connect an account first")));
464
+
465
+ t.connect().await;
466
+ let (status, body) = t.call("POST", "/payments/connection/disable", json!({}), Some(OPERATOR)).await;
467
+ assert_eq!(status, 422, "disable is refused unless payments are enabled: {body}");
468
+
469
+ t.enable().await;
470
+ let (status, body) = t.call("GET", "/payments/connection", json!({}), Some(OWNER)).await;
471
+ assert_eq!((status, body["status"].as_str()), (200, Some("enabled")));
472
+ let (status, _) = t.call("POST", "/payments/connection/enable", json!({}), Some(OPERATOR)).await;
473
+ assert_eq!(status, 422, "enable is refused when already enabled");
474
+
475
+ let (status, body) = t.call("POST", "/payments/connection/disable", json!({}), Some(OPERATOR)).await;
476
+ assert_eq!((status, body["status"].as_str()), (200, Some("connected")));
477
+ }
478
+
479
+ #[tokio::test]
480
+ async fn disconnecting_a_not_yet_enabled_account_unlinks_it_and_it_can_be_reconnected() {
481
+ let t = tenant("hecks_pay_test_disconnect").await;
482
+ let (status, body) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(OWNER)).await;
483
+ assert_eq!((status, body["error"].as_str()), (409, Some("nothing to disconnect")));
484
+
485
+ t.connect().await;
486
+ let (status, body) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(OWNER)).await;
487
+ assert_eq!(status, 200, "{body}");
488
+ assert_eq!(body["status"], "disconnected");
489
+ assert!(t.fake.requests().iter().all(|r| r.method != "DELETE"), "there is no webhook to remove for keys from the environment");
490
+
491
+ t.connect().await;
492
+ assert_eq!(t.status().await, "connected", "a disconnected link reconnects");
493
+ }
494
+
495
+ #[tokio::test]
496
+ async fn disconnecting_while_payments_are_enabled_pauses_them() {
497
+ let t = tenant("hecks_pay_test_disconnect_enabled").await;
498
+ t.connect().await;
499
+ t.enable().await;
500
+
501
+ let (status, body) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(OWNER)).await;
502
+ assert_eq!(status, 200, "{body}");
503
+ assert_eq!(body["status"], "paused");
504
+
505
+ // Paused is not disconnected: it cannot be disconnected again, and a new
506
+ // connection resumes it instead of starting over.
507
+ let (status, _) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(OWNER)).await;
508
+ assert_eq!(status, 409);
509
+ t.connect().await;
510
+ assert_eq!(t.status().await, "enabled", "reconnecting a paused connection resumes it");
511
+ }
512
+
513
+ #[tokio::test]
514
+ async fn checkout_stays_on_the_mock_until_payments_are_enabled() {
515
+ let t = tenant("hecks_pay_test_checkout_mock").await;
516
+ t.schedule_event("mock-event").await;
517
+
518
+ // No connection at all.
519
+ let (status, body) = t.register("mock-event").await;
520
+ assert_eq!(status, 200, "{body}");
521
+ assert!(body["checkout_url"].as_str().unwrap().starts_with("https://site.example/pay/"), "{body}");
522
+
523
+ // Connected but not enabled: still the mock.
524
+ t.connect().await;
525
+ let (status, body) = t.register("mock-event").await;
526
+ assert_eq!(status, 200, "{body}");
527
+ assert!(body["checkout_url"].as_str().unwrap().starts_with("https://site.example/pay/"), "{body}");
528
+ assert!(t.fake.requests_to("/v1/checkout/sessions").is_empty(), "the mock never calls Stripe");
529
+
530
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
531
+ let processors: Vec<String> = instances_for(&read, "Payments::Payment#").iter().map(|(_, p)| p["processor"]["value"].as_str().unwrap().to_string()).collect();
532
+ assert_eq!(processors, vec!["mock_stripe", "mock_stripe"]);
533
+ }
534
+
535
+ #[tokio::test]
536
+ async fn an_enabled_connection_charges_on_the_business_own_account() {
537
+ let t = tenant("hecks_pay_test_checkout_stripe").await;
538
+ t.schedule_event("real-event").await;
539
+ t.connect().await;
540
+ t.enable().await;
541
+
542
+ let (status, body) = t.register("real-event").await;
543
+ assert_eq!(status, 200, "{body}");
544
+ // The guest pays in a form embedded in the site: what the browser needs to
545
+ // mount it, and no hosted page to send them to.
546
+ assert_eq!(body.get("checkout_url"), None, "nothing leaves the site: {body}");
547
+ assert_eq!(
548
+ body["embedded_checkout"],
549
+ json!({
550
+ "client_secret": "cs_test_1_secret_CLIENT",
551
+ "publishable_key": DIRECT_PUBLISHABLE_KEY,
552
+ "session_id": "cs_test_1",
553
+ })
554
+ );
555
+ let reference = body["registration_id"].as_str().unwrap();
556
+
557
+ let sent = t.fake.requests_to("/v1/checkout/sessions");
558
+ assert_eq!(sent.len(), 1);
559
+ assert_eq!(sent[0].header("stripe-account"), None, "the business's own account is the default account");
560
+ assert_eq!(sent[0].header("authorization"), Some(format!("Bearer {DIRECT_KEY}").as_str()), "authenticated with the account's own key");
561
+ assert_eq!(sent[0].header("stripe-version"), Some("2026-04-22.dahlia"), "pinned, not the account's default");
562
+ assert!(sent[0].body.contains(&format!("metadata%5Bregistration_id%5D={reference}")), "{}", sent[0].body);
563
+ assert!(sent[0].body.contains("unit_amount%5D=4200"), "{}", sent[0].body);
564
+ assert!(sent[0].body.contains("ui_mode=embedded_page"), "an embedded session, not a hosted one: {}", sent[0].body);
565
+ assert!(sent[0].body.contains("redirect_on_completion=never"), "{}", sent[0].body);
566
+ assert!(!sent[0].body.contains("success_url") && !sent[0].body.contains("cancel_url"), "no return pages exist: {}", sent[0].body);
567
+
568
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
569
+ assert_eq!(read["instances"][format!("Payments::Payment#{reference}")]["processor"]["value"], "stripe");
570
+ }
571
+
572
+ #[tokio::test]
573
+ async fn a_stripe_refusal_answers_502_and_leaves_no_payment_or_registration_behind() {
574
+ let t = tenant("hecks_pay_test_checkout_stripe_refuses").await;
575
+ t.schedule_event("refused-event").await;
576
+ t.connect().await;
577
+ t.enable().await;
578
+ t.fake.state.refuse_sessions.store(true, Ordering::SeqCst);
579
+
580
+ let (status, body) = t.register("refused-event").await;
581
+ assert_eq!((status, body["error"].as_str()), (502, Some("payments are temporarily unavailable")));
582
+ assert_eq!(body.get("checkout_url"), None);
583
+ assert_eq!(body.get("embedded_checkout"), None);
584
+ assert!(!body.to_string().contains("no such price"), "Stripe's own wording stays in the log: {body}");
585
+ assert_eq!(t.fake.requests_to("/v1/checkout/sessions").len(), 1, "the session was attempted");
586
+
587
+ // The session is opened first, so nothing was recorded for the failed try.
588
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
589
+ assert!(instances_for(&read, "Payments::Payment#").is_empty(), "no orphaned Payment");
590
+ assert!(instances_for(&read, "CheckoutFixture::Registration#").is_empty(), "no pending Registration");
591
+
592
+ // Once Stripe recovers a retry registers normally.
593
+ t.fake.state.refuse_sessions.store(false, Ordering::SeqCst);
594
+ let (status, body) = t.register("refused-event").await;
595
+ assert_eq!(status, 200, "{body}");
596
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
597
+ assert_eq!(instances_for(&read, "Payments::Payment#").len(), 1);
598
+ assert_eq!(instances_for(&read, "CheckoutFixture::Registration#").len(), 1);
599
+ }
600
+
601
+ fn unix_secs() -> i64 {
602
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
603
+ }
604
+
605
+ #[tokio::test]
606
+ async fn a_full_event_answers_409_and_writes_nothing_and_calls_no_stripe() {
607
+ let t = tenant("hecks_pay_test_capacity_full").await;
608
+ t.schedule_event_with_capacity("one-seat", 1).await;
609
+ t.connect().await;
610
+ t.enable().await;
611
+
612
+ let (status, body) = t.register("one-seat").await;
613
+ assert_eq!(status, 200, "{body}");
614
+ let sessions = t.fake.requests_to("/v1/checkout/sessions").len();
615
+
616
+ let (status, body) = t.register("one-seat").await;
617
+ assert_eq!((status, body["error"].as_str()), (409, Some("this event is full")));
618
+ assert_eq!(body.get("embedded_checkout"), None);
619
+ assert_eq!(t.fake.requests_to("/v1/checkout/sessions").len(), sessions, "no session is opened for a full event");
620
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
621
+ assert_eq!(instances_for(&read, "Payments::Payment#").len(), 1, "no second Payment");
622
+ assert_eq!(instances_for(&read, "CheckoutFixture::Registration#").len(), 1, "no second Registration");
623
+ }
624
+
625
+ #[tokio::test]
626
+ async fn the_last_seat_goes_through_the_mock_walkthrough_and_the_next_attempt_is_409() {
627
+ let t = tenant("hecks_pay_test_capacity_mock").await;
628
+ t.schedule_event_with_capacity("two-seats", 2).await;
629
+
630
+ let mut references = Vec::new();
631
+ for _ in 0..2 {
632
+ let (status, body) = t.register("two-seats").await;
633
+ assert_eq!(status, 200, "{body}");
634
+ assert!(body["checkout_url"].is_string(), "the mock plan still answers a checkout_url: {body}");
635
+ references.push(body["registration_id"].as_str().unwrap().to_string());
636
+ }
637
+ for reference in &references {
638
+ assert_eq!(t.settle(reference).await, 200);
639
+ assert_eq!(t.payment_status(reference).await, "succeeded");
640
+ }
641
+
642
+ let (status, body) = t.register("two-seats").await;
643
+ assert_eq!((status, body["error"].as_str()), (409, Some("this event is full")));
644
+ assert!(t.fake.requests().is_empty(), "the mock walkthrough never calls Stripe");
645
+ }
646
+
647
+ #[tokio::test]
648
+ async fn an_unpaid_checkout_holds_the_seat_until_its_session_expires() {
649
+ let t = tenant("hecks_pay_test_capacity_hold").await;
650
+ t.schedule_event_with_capacity("held", 1).await;
651
+ t.connect().await;
652
+ t.enable().await;
653
+
654
+ let (status, body) = t.register("held").await;
655
+ assert_eq!(status, 200, "{body}");
656
+ let reference = body["registration_id"].as_str().unwrap().to_string();
657
+ assert_eq!(t.payment_status(&reference).await, "pending");
658
+
659
+ let (status, _) = t.register("held").await;
660
+ assert_eq!(status, 409, "a pending checkout holds the seat");
661
+
662
+ assert_eq!(t.webhook(expired(&reference)).await, 200);
663
+ assert_eq!(t.payment_status(&reference).await, "failed");
664
+ let (status, body) = t.register("held").await;
665
+ assert_eq!(status, 200, "an expired checkout gives the seat back: {body}");
666
+ }
667
+
668
+ #[tokio::test]
669
+ async fn a_declined_payment_frees_the_seat_and_a_paid_one_keeps_it() {
670
+ let t = tenant("hecks_pay_test_capacity_declined").await;
671
+ t.schedule_event_with_capacity("declined", 1).await;
672
+
673
+ let (_, body) = t.register("declined").await;
674
+ let first = body["registration_id"].as_str().unwrap().to_string();
675
+ let response = web::registration_complete_route(&first, r#"{"outcome":"failed"}"#, &t.client, &t.wasm, &t.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
676
+ assert_eq!(response["statusCode"].as_u64(), Some(200));
677
+ assert_eq!(t.payment_status(&first).await, "failed");
678
+
679
+ let (status, body) = t.register("declined").await;
680
+ assert_eq!(status, 200, "a declined payment gives the seat back: {body}");
681
+ let second = body["registration_id"].as_str().unwrap().to_string();
682
+ assert_eq!(t.settle(&second).await, 200);
683
+
684
+ let (status, _) = t.register("declined").await;
685
+ assert_eq!(status, 409, "a paid registration keeps its seat");
686
+ }
687
+
688
+ // Archives or restores one Registration through the kernel, the way the site's
689
+ // internal dispatch does; the outcome is the kernel's own accept/refuse.
690
+ async fn registration_command(t: &Tenant, verb: &str, reference: &str) -> dispatch::Outcome {
691
+ let verb = format!("CheckoutFixture::Registration.{verb}");
692
+ dispatch::handle(&t.client, &t.wasm, &verb, json!({"id": reference}), None, &t.config, &NeverInvoker).await.unwrap()
693
+ }
694
+
695
+ async fn registration_status(t: &Tenant, reference: &str) -> String {
696
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
697
+ read["instances"][format!("CheckoutFixture::Registration#{reference}")]["status"].as_str().unwrap_or("none").to_string()
698
+ }
699
+
700
+ #[tokio::test]
701
+ async fn archiving_a_paid_registration_frees_its_seat_and_restoring_it_takes_the_seat_back() {
702
+ let t = tenant("hecks_pay_test_capacity_archive").await;
703
+ t.schedule_event_with_capacity("archive-me", 1).await;
704
+
705
+ let (_, body) = t.register("archive-me").await;
706
+ let reference = body["registration_id"].as_str().unwrap().to_string();
707
+ assert_eq!(t.settle(&reference).await, 200);
708
+ assert_eq!(registration_status(&t, &reference).await, "active", "a new Registration starts active");
709
+ let (status, _) = t.register("archive-me").await;
710
+ assert_eq!(status, 409, "the paid registration holds the only seat");
711
+
712
+ let archived = registration_command(&t, "Archive", &reference).await;
713
+ assert!(archived.accepted, "{:?}", archived.result);
714
+ assert_eq!(registration_status(&t, &reference).await, "archived");
715
+ assert_eq!(t.payment_status(&reference).await, "succeeded", "archiving never touches the Payment");
716
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
717
+ assert_eq!(web::seats_left(&read, "CheckoutFixture", &crate::ir::fixture_payments(), "archive-me"), Some(1));
718
+
719
+ let (status, body) = t.register("archive-me").await;
720
+ assert_eq!(status, 200, "the archived registration's seat is free again: {body}");
721
+ let second = body["registration_id"].as_str().unwrap().to_string();
722
+ assert_eq!(t.settle(&second).await, 200);
723
+ let (status, _) = t.register("archive-me").await;
724
+ assert_eq!(status, 409, "the seat is taken again by the new guest");
725
+
726
+ let restored = registration_command(&t, "Restore", &reference).await;
727
+ assert!(restored.accepted, "{:?}", restored.result);
728
+ assert_eq!(registration_status(&t, &reference).await, "active");
729
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
730
+ assert_eq!(
731
+ web::seats_taken(&read, "CheckoutFixture", &crate::ir::fixture_payments(), "archive-me"),
732
+ 2,
733
+ "a restored registration counts again, even past capacity: the site refuses a restore with no seat"
734
+ );
735
+ assert_eq!(web::seats_left(&read, "CheckoutFixture", &crate::ir::fixture_payments(), "archive-me"), Some(0));
736
+ }
737
+
738
+ #[tokio::test]
739
+ async fn only_an_active_registration_can_be_archived_and_only_an_archived_one_restored() {
740
+ let t = tenant("hecks_pay_test_archive_guards").await;
741
+ t.schedule_event("guards").await;
742
+ let (_, body) = t.register("guards").await;
743
+ let reference = body["registration_id"].as_str().unwrap().to_string();
744
+
745
+ assert!(!registration_command(&t, "Restore", &reference).await.accepted, "an active registration cannot be restored");
746
+ assert!(registration_command(&t, "Archive", &reference).await.accepted);
747
+ assert!(!registration_command(&t, "Archive", &reference).await.accepted, "an archived registration cannot be archived again");
748
+ assert_eq!(registration_status(&t, &reference).await, "archived");
749
+ }
750
+
751
+ #[tokio::test]
752
+ async fn a_stripe_session_expires_about_thirty_one_minutes_after_it_is_created() {
753
+ let t = tenant("hecks_pay_test_capacity_expiry").await;
754
+ t.schedule_event("expiring").await;
755
+ t.connect().await;
756
+ t.enable().await;
757
+
758
+ let before = unix_secs();
759
+ let (status, body) = t.register("expiring").await;
760
+ let after = unix_secs();
761
+ assert_eq!(status, 200, "{body}");
762
+
763
+ let sent = t.fake.requests_to("/v1/checkout/sessions");
764
+ assert_eq!(sent.len(), 1);
765
+ let expires_at: i64 = sent[0].body.split('&').find_map(|pair| pair.strip_prefix("expires_at=")).expect("an expires_at form field").parse().unwrap();
766
+ let hold = crate::checkout::SESSION_HOLD_SECONDS;
767
+ assert!((before + hold..=after + hold).contains(&expires_at), "expires_at {expires_at} is not {hold}s after creation ({before}..{after})");
768
+ assert!((30 * 60..=35 * 60).contains(&hold), "Stripe's minimum is 30 minutes, and the hold should stay close to it");
769
+ }
770
+
771
+ #[tokio::test]
772
+ async fn an_enabled_connection_without_a_publishable_key_is_paused_before_anything_is_written() {
773
+ let mut t = tenant("hecks_pay_test_checkout_no_publishable").await;
774
+ t.schedule_event("nopk-event").await;
775
+ t.connect().await;
776
+ t.enable().await;
777
+
778
+ // The secret key alone cannot mount the form in the browser, and the
779
+ // answer must be neither the mock nor a hosted page.
780
+ t.platform.direct_test_publishable_key = String::new();
781
+ let (status, body) = t.register("nopk-event").await;
782
+ assert_eq!((status, body["error"].as_str()), (503, Some("payments are temporarily unavailable")));
783
+ assert_eq!(body.get("checkout_url"), None);
784
+ assert_eq!(body.get("embedded_checkout"), None);
785
+
786
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
787
+ assert!(instances_for(&read, "Payments::Payment#").is_empty(), "no orphaned Payment");
788
+ assert!(instances_for(&read, "CheckoutFixture::Registration#").is_empty());
789
+ assert!(t.fake.requests_to("/v1/checkout/sessions").is_empty(), "Stripe is never called");
790
+ }
791
+
792
+ #[tokio::test]
793
+ async fn a_paused_connection_refuses_registrations_with_a_503_and_never_falls_back_to_the_mock() {
794
+ let t = tenant("hecks_pay_test_checkout_paused").await;
795
+ t.schedule_event("paused-event").await;
796
+ t.connect().await;
797
+ t.enable().await;
798
+ let (status, _) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(OWNER)).await;
799
+ assert_eq!(status, 200);
800
+ assert_eq!(t.status().await, "paused");
801
+
802
+ let (status, body) = t.register("paused-event").await;
803
+ assert_eq!((status, body["error"].as_str()), (503, Some("payments are temporarily unavailable")));
804
+ assert_eq!(body.get("checkout_url"), None);
805
+
806
+ // Nothing was written: no Payment, no Registration, no Stripe call.
807
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
808
+ assert!(instances_for(&read, "Payments::Payment#").is_empty());
809
+ assert!(instances_for(&read, "CheckoutFixture::Registration#").is_empty());
810
+ assert!(t.fake.requests_to("/v1/checkout/sessions").is_empty());
811
+ }
812
+
813
+ #[tokio::test]
814
+ async fn an_enabled_connection_whose_secret_key_is_missing_is_paused_not_mock() {
815
+ let mut t = tenant("hecks_pay_test_checkout_no_key").await;
816
+ t.schedule_event("nokey-event").await;
817
+ t.connect().await;
818
+ t.enable().await;
819
+
820
+ t.platform.direct_test_key = String::new();
821
+ let (status, body) = t.register("nokey-event").await;
822
+ assert_eq!((status, body["error"].as_str()), (503, Some("payments are temporarily unavailable")));
823
+ }
824
+
825
+ #[tokio::test]
826
+ async fn only_a_mock_payment_can_be_settled_by_hand() {
827
+ let t = tenant("hecks_pay_test_complete").await;
828
+ t.schedule_event("hand-event").await;
829
+
830
+ let (_, mock) = t.register("hand-event").await;
831
+ let mock_ref = mock["registration_id"].as_str().unwrap().to_string();
832
+ t.connect().await;
833
+ t.enable().await;
834
+ let (_, real) = t.register("hand-event").await;
835
+ let real_ref = real["registration_id"].as_str().unwrap().to_string();
836
+
837
+ assert_eq!(t.settle(&real_ref).await, 403, "a real processor's payment is settled only by its webhook");
838
+ assert_eq!(t.payment_status(&real_ref).await, "pending");
839
+ assert_eq!(t.settle(&mock_ref).await, 200);
840
+ assert_eq!(t.payment_status(&mock_ref).await, "succeeded");
841
+ }
842
+
843
+ #[tokio::test]
844
+ async fn an_event_naming_an_account_is_acknowledged_and_ignored() {
845
+ let t = tenant("hecks_pay_test_webhook_other").await;
846
+ t.schedule_event("hook-event").await;
847
+ t.connect().await;
848
+ t.enable().await;
849
+ let (_, body) = t.register("hook-event").await;
850
+ let reference = body["registration_id"].as_str().unwrap().to_string();
851
+
852
+ // The business's own endpoint delivers events with no account; one that
853
+ // names an account is some other account's and settles nothing here.
854
+ let mut foreign = completed(&reference);
855
+ foreign["account"] = json!("acct_someone_else");
856
+ assert_eq!(t.webhook(foreign).await, 200);
857
+ assert_eq!(t.payment_status(&reference).await, "pending");
858
+
859
+ // Control: the same event without an account does settle it.
860
+ assert_eq!(t.webhook(completed(&reference)).await, 200);
861
+ assert_eq!(t.payment_status(&reference).await, "succeeded");
862
+ }
863
+
864
+ #[tokio::test]
865
+ async fn an_event_verified_only_by_the_public_mock_secret_is_refused_for_a_real_payment() {
866
+ let mut t = tenant("hecks_pay_test_webhook_fallback").await;
867
+ t.connect().await;
868
+ t.enable().await;
869
+ t.schedule_event("fallback-event").await;
870
+ let (_, body) = t.register("fallback-event").await;
871
+ let reference = body["registration_id"].as_str().unwrap().to_string();
872
+
873
+ // STRIPE_WEBHOOK_SECRET unset: events verify against the public mock secret.
874
+ t.platform.webhook_secret = None;
875
+ let event = completed(&reference);
876
+ let payload = event.to_string();
877
+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
878
+ let header = sign(web::MOCK_STRIPE_WEBHOOK_SECRET, now, &payload);
879
+ let response = web::webhook_route(&payload, &header, &t.platform, &t.client, &t.wasm, &t.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
880
+ assert_eq!(response["statusCode"], 500, "{event}");
881
+ assert_eq!(t.payment_status(&reference).await, "pending", "a forged completion must not settle a real payment");
882
+ }
883
+
884
+ #[test]
885
+ fn checkout_plan_is_decided_from_the_connection_lifecycle() {
886
+ let platform = PlatformConfig { direct_test_key: "sk_test_own".to_string(), direct_test_publishable_key: "pk_test_own".to_string(), ..test_platform() };
887
+ let with = |status: &str, processor: &str, mode: &str, account_ref: &str| Connection {
888
+ status: status.to_string(),
889
+ processor: processor.to_string(),
890
+ account_ref: account_ref.to_string(),
891
+ mode: mode.to_string(),
892
+ display_name: "Own Studio".to_string(),
893
+ };
894
+ let own = |status: &str, mode: &str| with(status, "stripe", mode, SELF_ACCOUNT);
895
+ assert!(matches!(checkout_plan(None, &platform), CheckoutPlan::Mock));
896
+ for status in ["connected", "disconnected"] {
897
+ assert!(matches!(checkout_plan(Some(&own(status, "test")), &platform), CheckoutPlan::Mock), "{status}");
898
+ }
899
+ assert!(matches!(checkout_plan(Some(&own("paused", "test")), &platform), CheckoutPlan::Paused));
900
+ assert!(matches!(
901
+ checkout_plan(Some(&own("enabled", "test")), &platform),
902
+ CheckoutPlan::Stripe { ref api_key, ref publishable_key } if api_key == "sk_test_own" && publishable_key == "pk_test_own"
903
+ ));
904
+ assert!(matches!(checkout_plan(Some(&own("enabled", "live")), &platform), CheckoutPlan::Paused), "no live key configured");
905
+ assert!(matches!(checkout_plan(Some(&with("enabled", "paypal", "test", SELF_ACCOUNT)), &platform), CheckoutPlan::Paused), "a processor this host cannot charge");
906
+ assert!(
907
+ matches!(checkout_plan(Some(&with("enabled", "stripe", "test", "acct_1")), &platform), CheckoutPlan::Paused),
908
+ "a connection to any other account cannot be charged"
909
+ );
910
+
911
+ let secret_only = PlatformConfig { direct_test_key: "sk_test_own".to_string(), ..test_platform() };
912
+ assert!(matches!(checkout_plan(Some(&own("enabled", "test")), &secret_only), CheckoutPlan::Paused), "a key without its publishable key cannot mount the form");
913
+ assert_eq!(platform.direct_modes(), vec!["test"]);
914
+ assert!(secret_only.direct_modes().is_empty());
915
+ }
916
+
917
+ #[tokio::test]
918
+ async fn using_the_own_account_needs_an_owner_a_mode_and_configured_keys() {
919
+ // No keys configured: refused with a clear message.
920
+ let mut t = tenant("hecks_pay_test_direct_refused").await;
921
+ t.platform.direct_test_key = String::new();
922
+ t.platform.direct_test_publishable_key = String::new();
923
+ let (status, body) = t.call("POST", "/payments/connection/direct", json!({"mode": "test"}), Some(OWNER)).await;
924
+ assert_eq!(status, 422, "{body}");
925
+ assert!(body["error"].as_str().unwrap().contains("not set up for test mode"), "{body}");
926
+ assert_eq!(t.status().await, "none");
927
+ assert!(t.fake.requests().is_empty(), "no Stripe call without configured keys");
928
+
929
+ let t = tenant("hecks_pay_test_direct_access").await;
930
+ let (status, _) = t.call("POST", "/payments/connection/direct", json!({"mode": "test"}), Some(ADMIN)).await;
931
+ assert_eq!(status, 403, "only an Owner may choose the account");
932
+ let (status, body) = t.call("POST", "/payments/connection/direct", json!({}), Some(OWNER)).await;
933
+ assert_eq!((status, body["error"].as_str()), (400, Some("missing mode")));
934
+ let (status, _) = t.call("POST", "/payments/connection/direct", json!({"mode": "live"}), Some(OWNER)).await;
935
+ assert_eq!(status, 422, "live keys are not configured");
936
+ assert_eq!(t.status().await, "none");
937
+ }
938
+
939
+ #[tokio::test]
940
+ async fn using_the_own_account_records_the_reserved_ref_and_only_public_facts() {
941
+ let t = tenant("hecks_pay_test_direct_connect").await;
942
+ let (_, body) = t.call("GET", "/payments/connection", json!({}), Some(OWNER)).await;
943
+ assert_eq!((body["direct_modes"].clone(), body["direct"].clone(), body["adapters"].clone()), (json!(["test"]), json!(false), json!([])));
944
+
945
+ let (status, body) = t.call("POST", "/payments/connection/direct", json!({"mode": "test"}), Some(OWNER)).await;
946
+ assert_eq!(status, 200, "{body}");
947
+ assert_eq!(
948
+ (body["status"].as_str(), body["processor"].as_str(), body["account_ref"].as_str(), body["mode"].as_str(), body["display_name"].as_str(), body["direct"].clone()),
949
+ (Some("connected"), Some("stripe"), Some("self"), Some("test"), Some("Own Studio"), json!(true))
950
+ );
951
+
952
+ let lookups = t.fake.requests_to("/v1/account");
953
+ assert_eq!(lookups.len(), 1);
954
+ assert_eq!(lookups[0].header("authorization"), Some(format!("Bearer {DIRECT_KEY}").as_str()), "read with the account's own key");
955
+
956
+ let dump = t.dump_database().await;
957
+ assert!(!dump.contains(DIRECT_KEY) && !dump.contains(DIRECT_PUBLISHABLE_KEY), "no key is ever stored");
958
+ assert!(!body.to_string().contains(DIRECT_KEY), "no key is ever shown");
959
+
960
+ let (status, body) = t.call("POST", "/payments/connection/direct", json!({"mode": "test"}), Some(OWNER)).await;
961
+ assert_eq!((status, body["error"].as_str()), (409, Some("an account is already connected — disconnect it first")));
962
+ }
963
+
964
+ #[tokio::test]
965
+ async fn the_own_account_falls_back_to_a_generic_name_when_stripe_will_not_say() {
966
+ let t = tenant("hecks_pay_test_direct_name").await;
967
+ t.fake.state.refuse_account.store(true, Ordering::SeqCst);
968
+ let (status, body) = t.call("POST", "/payments/connection/direct", json!({"mode": "test"}), Some(OWNER)).await;
969
+ assert_eq!(status, 200, "{body}");
970
+ assert_eq!(body["display_name"], "Your Stripe account");
971
+ }
972
+
973
+ #[tokio::test]
974
+ async fn the_own_account_charges_with_its_own_key_and_names_no_other_account() {
975
+ let t = tenant("hecks_pay_test_direct_checkout").await;
976
+ t.schedule_event("own-event").await;
977
+ t.connect().await;
978
+
979
+ // Connected but not enabled: still the mock, no Stripe call.
980
+ let (status, body) = t.register("own-event").await;
981
+ assert_eq!(status, 200, "{body}");
982
+ assert!(body["checkout_url"].as_str().unwrap().starts_with("https://site.example/pay/"), "{body}");
983
+ assert!(t.fake.requests_to("/v1/checkout/sessions").is_empty());
984
+
985
+ t.enable().await;
986
+ let (status, body) = t.register("own-event").await;
987
+ assert_eq!(status, 200, "{body}");
988
+ assert_eq!(body.get("checkout_url"), None, "{body}");
989
+ assert_eq!(
990
+ body["embedded_checkout"],
991
+ json!({"client_secret": "cs_test_1_secret_CLIENT", "publishable_key": DIRECT_PUBLISHABLE_KEY, "session_id": "cs_test_1"})
992
+ );
993
+
994
+ let sent = t.fake.requests_to("/v1/checkout/sessions");
995
+ assert_eq!(sent.len(), 1);
996
+ assert_eq!(sent[0].header("stripe-account"), None, "the business's own account is the default account");
997
+ assert_eq!(sent[0].header("authorization"), Some(format!("Bearer {DIRECT_KEY}").as_str()));
998
+ assert_eq!(sent[0].header("stripe-version"), Some("2026-04-22.dahlia"));
999
+ assert!(sent[0].body.contains("ui_mode=embedded_page"), "{}", sent[0].body);
1000
+
1001
+ let reference = body["registration_id"].as_str().unwrap();
1002
+ let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
1003
+ assert_eq!(read["instances"][format!("Payments::Payment#{reference}")]["processor"]["value"], "stripe");
1004
+ }
1005
+
1006
+ #[tokio::test]
1007
+ async fn the_own_account_follows_enable_disable_pause_and_resume() {
1008
+ let t = tenant("hecks_pay_test_direct_lifecycle").await;
1009
+ let (status, body) = t.call("POST", "/payments/connection/enable", json!({}), Some(OPERATOR)).await;
1010
+ assert_eq!((status, body["error"].as_str()), (409, Some("connect an account first")));
1011
+
1012
+ t.connect().await;
1013
+ t.enable().await;
1014
+ let (status, body) = t.call("POST", "/payments/connection/disable", json!({}), Some(OPERATOR)).await;
1015
+ assert_eq!((status, body["status"].as_str()), (200, Some("connected")));
1016
+ t.enable().await;
1017
+
1018
+ // Disconnecting while enabled pauses registrations.
1019
+ let (status, body) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(OWNER)).await;
1020
+ assert_eq!(status, 200, "{body}");
1021
+ assert_eq!(body["status"], "paused");
1022
+ t.schedule_event("paused-event").await;
1023
+ let (status, body) = t.register("paused-event").await;
1024
+ assert_eq!((status, body["error"].as_str()), (503, Some("payments are temporarily unavailable")));
1025
+
1026
+ // Choosing the account again resumes the paused connection.
1027
+ t.connect().await;
1028
+ assert_eq!(t.status().await, "enabled");
1029
+
1030
+ // A connection that was never enabled unlinks and can be chosen again.
1031
+ let fresh = tenant("hecks_pay_test_direct_unlink").await;
1032
+ fresh.connect().await;
1033
+ let (status, body) = fresh.call("POST", "/payments/connection/disconnect", json!({}), Some(OWNER)).await;
1034
+ assert_eq!((status, body["status"].as_str()), (200, Some("disconnected")));
1035
+ fresh.connect().await;
1036
+ assert_eq!(fresh.status().await, "connected");
1037
+ }
1038
+
1039
+ #[tokio::test]
1040
+ async fn the_own_account_settles_from_account_less_events() {
1041
+ let t = tenant("hecks_pay_test_direct_webhook").await;
1042
+ t.schedule_event("own-hook-event").await;
1043
+ t.connect().await;
1044
+ t.enable().await;
1045
+ let (_, body) = t.register("own-hook-event").await;
1046
+ let reference = body["registration_id"].as_str().unwrap().to_string();
1047
+
1048
+ // The business's own endpoint delivers events with no account: this settles.
1049
+ assert_eq!(t.webhook(completed(&reference)).await, 200);
1050
+ assert_eq!(t.payment_status(&reference).await, "succeeded");
1051
+ }
1052
+
1053
+ // None of the fake secrets may appear in `text`.
1054
+ fn assert_no_secret_in(text: &str) {
1055
+ for secret in [SAVED_KEY, SAVED_KEY_TWO, LIVE_SAVED_KEY, "whsec_SAVED_"] {
1056
+ assert!(!text.contains(secret), "a secret leaked: {secret}");
1057
+ }
1058
+ }
1059
+
1060
+ #[tokio::test]
1061
+ async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_of_the_database() {
1062
+ let t = Tenant::saving("hecks_pay_test_save_ok").await;
1063
+ let (_, before) = t.call("GET", "/payments/connection", json!({}), Some(OWNER)).await;
1064
+ assert_eq!((before["can_save_keys"].clone(), before["direct_modes"].clone()), (json!(true), json!([])));
1065
+
1066
+ let (status, body) = t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1067
+ assert_eq!(status, 200, "{body}");
1068
+ assert_eq!(
1069
+ (body["status"].as_str(), body["processor"].as_str(), body["account_ref"].as_str(), body["mode"].as_str(), body["display_name"].as_str(), body["direct"].clone()),
1070
+ (Some("connected"), Some("stripe"), Some("self"), Some("test"), Some("Own Studio"), json!(true))
1071
+ );
1072
+ assert_eq!(body["direct_modes"], json!(["test"]));
1073
+ assert_no_secret_in(&body.to_string());
1074
+
1075
+ // Stripe was asked to check the key, then to deliver the three events to this site.
1076
+ let lookups = t.fake.requests_to("/v1/account");
1077
+ assert_eq!(lookups.len(), 1);
1078
+ assert_eq!(lookups[0].header("authorization"), Some(format!("Bearer {SAVED_KEY}").as_str()));
1079
+ assert_eq!(lookups[0].header("stripe-version"), Some("2026-04-22.dahlia"));
1080
+ let created = t.fake.requests_to("/v1/webhook_endpoints");
1081
+ assert_eq!(created.len(), 1);
1082
+ assert_eq!(created[0].method, "POST");
1083
+ assert_eq!(created[0].header("authorization"), Some(format!("Bearer {SAVED_KEY}").as_str()));
1084
+ assert_eq!(created[0].header("stripe-version"), Some("2026-04-22.dahlia"));
1085
+ assert_eq!(created[0].header("stripe-account"), None);
1086
+ for expected in [
1087
+ "url=https%3A%2F%2Fwebhooks.example.test%2Fwebhooks%2Fstripe",
1088
+ "description=Checkout+Fixture+website",
1089
+ "enabled_events%5B0%5D=checkout.session.completed",
1090
+ "enabled_events%5B1%5D=checkout.session.expired",
1091
+ "enabled_events%5B2%5D=charge.refunded",
1092
+ ] {
1093
+ assert!(created[0].body.contains(expected), "{expected} missing from {}", created[0].body);
1094
+ }
1095
+
1096
+ // Saved in the secret store in the agreed shape...
1097
+ let saved = t.saved_document();
1098
+ assert!(saved.get("live").is_none());
1099
+ let entry = saved["test"].as_object().unwrap();
1100
+ let mut names: Vec<&str> = entry.keys().map(String::as_str).collect();
1101
+ names.sort_unstable();
1102
+ assert_eq!(names, ["publishable_key", "saved_at", "secret_key", "webhook_endpoint_id", "webhook_secret"]);
1103
+ assert_eq!(entry["secret_key"], SAVED_KEY);
1104
+ assert_eq!(entry["publishable_key"], SAVED_PUBLISHABLE_KEY);
1105
+ assert_eq!(entry["webhook_secret"], "whsec_SAVED_1");
1106
+ assert_eq!(entry["webhook_endpoint_id"], "we_1");
1107
+ assert!(entry["saved_at"].as_str().unwrap().ends_with('Z'));
1108
+ // ...and nowhere in the tenant's database.
1109
+ let dump = t.dump_database().await;
1110
+ assert_no_secret_in(&dump);
1111
+ assert!(!dump.contains(SAVED_PUBLISHABLE_KEY), "not even the publishable key is stored there");
1112
+ }
1113
+
1114
+ #[tokio::test]
1115
+ async fn saving_live_keys_derives_the_live_mode_from_the_keys() {
1116
+ let t = Tenant::saving("hecks_pay_test_save_live").await;
1117
+ let (status, body) = t.save_keys(LIVE_SAVED_KEY, LIVE_SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1118
+ assert_eq!(status, 200, "{body}");
1119
+ assert_eq!((body["mode"].as_str(), body["direct_modes"].clone()), (Some("live"), json!(["live"])));
1120
+ let saved = t.saved_document();
1121
+ assert_eq!(saved["live"]["secret_key"], LIVE_SAVED_KEY);
1122
+ assert!(saved.get("test").is_none());
1123
+ }
1124
+
1125
+ #[tokio::test]
1126
+ async fn saved_keys_charge_the_business_account_and_its_saved_webhook_secret_settles_payments() {
1127
+ let t = Tenant::saving("hecks_pay_test_save_charge").await;
1128
+ t.schedule_event("saved-event").await;
1129
+ let (status, body) = t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1130
+ assert_eq!(status, 200, "{body}");
1131
+ t.enable().await;
1132
+
1133
+ let (status, body) = t.register("saved-event").await;
1134
+ assert_eq!(status, 200, "{body}");
1135
+ assert_eq!(body["embedded_checkout"]["publishable_key"], SAVED_PUBLISHABLE_KEY);
1136
+ let sent = t.fake.requests_to("/v1/checkout/sessions");
1137
+ assert_eq!(sent.len(), 1);
1138
+ assert_eq!(sent[0].header("authorization"), Some(format!("Bearer {SAVED_KEY}").as_str()));
1139
+ assert_eq!(sent[0].header("stripe-account"), None, "the business's own account is the default account");
1140
+ assert_no_secret_in(&body.to_string());
1141
+
1142
+ let reference = body["registration_id"].as_str().unwrap().to_string();
1143
+ // A delivery signed with anything else is refused.
1144
+ assert_eq!(t.webhook_signed_with("whsec_someone_else", completed(&reference)).await, 400);
1145
+ assert_eq!(t.payment_status(&reference).await, "pending");
1146
+ // The signing secret Stripe returned when the webhook was created settles it.
1147
+ assert_eq!(t.webhook_signed_with("whsec_SAVED_1", completed(&reference)).await, 200);
1148
+ assert_eq!(t.payment_status(&reference).await, "succeeded");
1149
+ // The environment's secret still works alongside it.
1150
+ let (_, body) = t.register("saved-event").await;
1151
+ let second = body["registration_id"].as_str().unwrap().to_string();
1152
+ assert_eq!(t.webhook(completed(&second)).await, 200);
1153
+ assert_eq!(t.payment_status(&second).await, "succeeded");
1154
+ }
1155
+
1156
+ #[tokio::test]
1157
+ async fn saving_keys_refuses_with_plain_messages_and_saves_nothing() {
1158
+ // No key store on this deploy.
1159
+ let t = tenant("hecks_pay_test_save_nostore").await;
1160
+ let (status, body) = t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1161
+ assert_eq!((status, body["error"].as_str()), (422, Some("saving keys is not set up on this site")));
1162
+ let (_, shown) = t.call("GET", "/payments/connection", json!({}), Some(OWNER)).await;
1163
+ assert_eq!(shown["can_save_keys"], json!(false));
1164
+
1165
+ let t = Tenant::saving("hecks_pay_test_save_refuse").await;
1166
+ assert_eq!(t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(ADMIN)).await.0, 403, "only an Owner may save keys");
1167
+ assert_eq!(t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, None).await.0, 401);
1168
+ let (status, body) = t.call("POST", "/payments/connection/direct", json!({"secret_key": SAVED_KEY}), Some(OWNER)).await;
1169
+ assert_eq!((status, body["error"].as_str()), (400, Some("missing publishable_key")));
1170
+
1171
+ let cases = [
1172
+ ("nonsense", SAVED_PUBLISHABLE_KEY, "restricted key"),
1173
+ (SAVED_KEY, "nonsense", "publishable key"),
1174
+ (SAVED_KEY, LIVE_SAVED_PUBLISHABLE_KEY, "test mode and the other is for live mode"),
1175
+ (LIVE_SAVED_KEY, SAVED_PUBLISHABLE_KEY, "test mode and the other is for live mode"),
1176
+ ];
1177
+ for (secret, publishable, expected) in cases {
1178
+ let (status, body) = t.save_keys(secret, publishable, Some(OWNER)).await;
1179
+ assert_eq!(status, 422, "{body}");
1180
+ assert!(body["error"].as_str().unwrap().contains(expected), "{body}");
1181
+ assert_no_secret_in(&body.to_string());
1182
+ }
1183
+ assert!(t.fake.requests().is_empty(), "nothing malformed reaches Stripe");
1184
+
1185
+ // Stripe does not recognise the key.
1186
+ t.fake.state.invalid_key.store(true, Ordering::SeqCst);
1187
+ let (status, body) = t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1188
+ assert_eq!((status, body["error"].as_str()), (422, Some("Stripe did not accept that key.")));
1189
+ assert!(t.fake.requests_to("/v1/webhook_endpoints").is_empty(), "no webhook for a key Stripe rejected");
1190
+ t.fake.state.invalid_key.store(false, Ordering::SeqCst);
1191
+
1192
+ // A restricted key that may not create webhooks.
1193
+ t.fake.state.refuse_webhooks.store(true, Ordering::SeqCst);
1194
+ let (status, body) = t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1195
+ assert_eq!(
1196
+ (status, body["error"].as_str()),
1197
+ (422, Some("That key can't create the webhook. In Stripe, give it Webhook Endpoints → Write as well and try again."))
1198
+ );
1199
+ assert_no_secret_in(&body.to_string());
1200
+
1201
+ assert_eq!(t.status().await, "none");
1202
+ assert_eq!(t.saved_document(), Value::Null, "nothing was written to the store");
1203
+ assert_eq!(t.raw.as_ref().unwrap().writes.load(Ordering::SeqCst), 0);
1204
+ }
1205
+
1206
+ #[tokio::test]
1207
+ async fn a_restricted_key_that_cannot_read_the_account_still_saves_with_a_generic_name() {
1208
+ let t = Tenant::saving("hecks_pay_test_save_name").await;
1209
+ t.fake.state.refuse_account.store(true, Ordering::SeqCst);
1210
+ let (status, body) = t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1211
+ assert_eq!((status, body["display_name"].as_str()), (200, Some("Your Stripe account")), "{body}");
1212
+ }
1213
+
1214
+ #[tokio::test]
1215
+ async fn saving_again_replaces_the_webhook_and_the_keys_without_disturbing_the_connection() {
1216
+ let t = Tenant::saving("hecks_pay_test_save_again").await;
1217
+ assert_eq!(t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await.0, 200);
1218
+ t.enable().await;
1219
+ let (status, body) = t.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1220
+ assert_eq!(status, 200, "{body}");
1221
+ assert_eq!(body["status"], "enabled", "the connection stays as it was");
1222
+ assert_no_secret_in(&body.to_string());
1223
+
1224
+ // The first save's webhook was removed (with the first key), a new one made (with the second).
1225
+ let removed = t.fake.requests_to("/v1/webhook_endpoints/we_1");
1226
+ assert_eq!(removed.len(), 1);
1227
+ assert_eq!((removed[0].method.as_str(), removed[0].header("authorization")), ("DELETE", Some(format!("Bearer {SAVED_KEY}").as_str())));
1228
+ let created = t.fake.requests_to("/v1/webhook_endpoints");
1229
+ assert_eq!(created.len(), 2);
1230
+ assert_eq!(created[1].header("authorization"), Some(format!("Bearer {SAVED_KEY_TWO}").as_str()));
1231
+ // The new webhook exists before the old one goes, so a failed save never leaves none.
1232
+ let all = t.fake.requests();
1233
+ let second_create = all.iter().rposition(|r| r.method == "POST" && r.path == "/v1/webhook_endpoints").unwrap();
1234
+ let delete = all.iter().position(|r| r.method == "DELETE" && r.path == "/v1/webhook_endpoints/we_1").unwrap();
1235
+ assert!(second_create < delete, "the new webhook was made first");
1236
+ let saved = t.saved_document();
1237
+ assert_eq!(
1238
+ (saved["test"]["secret_key"].as_str(), saved["test"]["webhook_secret"].as_str(), saved["test"]["webhook_endpoint_id"].as_str()),
1239
+ (Some(SAVED_KEY_TWO), Some("whsec_SAVED_2"), Some("we_2"))
1240
+ );
1241
+
1242
+ // Keys for the other mode cannot be saved over a connected account.
1243
+ let (status, body) = t.save_keys(LIVE_SAVED_KEY, LIVE_SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1244
+ assert_eq!((status, body["error"].as_str()), (409, Some("an account is already connected — disconnect it first")));
1245
+ }
1246
+
1247
+ #[tokio::test]
1248
+ async fn a_failed_re_save_leaves_the_old_webhook_and_keys_working() {
1249
+ let t = Tenant::saving("hecks_pay_test_save_resave_fail").await;
1250
+ assert_eq!(t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await.0, 200);
1251
+ t.fake.state.refuse_webhooks.store(true, Ordering::SeqCst);
1252
+ let (status, body) = t.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1253
+ assert_eq!(status, 422, "{body}");
1254
+ assert!(t.fake.requests().iter().all(|r| r.method != "DELETE"), "the old webhook was not removed");
1255
+ let saved = t.saved_document();
1256
+ assert_eq!((saved["test"]["secret_key"].as_str(), saved["test"]["webhook_endpoint_id"].as_str()), (Some(SAVED_KEY), Some("we_1")));
1257
+ assert_eq!(t.status().await, "connected");
1258
+ }
1259
+
1260
+ #[tokio::test]
1261
+ async fn disconnecting_forgets_the_keys_even_when_stripe_cannot_remove_the_webhook() {
1262
+ let t = Tenant::saving("hecks_pay_test_save_disc_down").await;
1263
+ assert_eq!(t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await.0, 200);
1264
+ t.fake.state.fail_deletes.store(true, Ordering::SeqCst);
1265
+ let (status, body) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(OWNER)).await;
1266
+ assert_eq!((status, body["status"].as_str()), (200, Some("disconnected")), "{body}");
1267
+ assert_eq!(t.fake.requests_to("/v1/webhook_endpoints/we_1").len(), 1, "removal was attempted");
1268
+ assert!(t.saved_document().get("test").is_none(), "the keys are gone anyway");
1269
+ }
1270
+
1271
+ #[tokio::test]
1272
+ async fn the_public_mock_secret_is_refused_whenever_a_real_credential_is_configured_or_saved() {
1273
+ let mock = web::MOCK_STRIPE_WEBHOOK_SECRET;
1274
+ // A key in the environment and no signing secret to check events against.
1275
+ let mut t = tenant("hecks_pay_test_mock_env").await;
1276
+ t.platform.webhook_secret = None;
1277
+ assert_eq!(t.webhook_signed_with(mock, completed("ref-1")).await, 500);
1278
+
1279
+ // Keys saved from the Payments page count too: only the saved signing secret verifies.
1280
+ let mut t = Tenant::saving("hecks_pay_test_mock_saved").await;
1281
+ t.platform.webhook_secret = None;
1282
+ assert_eq!(t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await.0, 200);
1283
+ assert_eq!(t.webhook_signed_with(mock, completed("ref-2")).await, 400, "the mock secret does not verify");
1284
+ assert_eq!(t.webhook_signed_with("whsec_SAVED_1", completed("ref-2")).await, 200);
1285
+
1286
+ // No saved signing secret at all: refused outright, not trusted on the mock secret.
1287
+ let raw = t.raw.as_ref().unwrap();
1288
+ let mut document = t.saved_document();
1289
+ document["test"]["webhook_secret"] = json!("");
1290
+ *raw.document.lock().unwrap() = Some(document.to_string());
1291
+ t.platform.store.as_ref().unwrap().invalidate();
1292
+ assert_eq!(t.webhook_signed_with(mock, completed("ref-3")).await, 500);
1293
+ }
1294
+
1295
+ #[tokio::test]
1296
+ async fn disconnecting_removes_the_webhook_from_stripe_and_deletes_the_saved_keys() {
1297
+ let t = Tenant::saving("hecks_pay_test_save_disconnect").await;
1298
+ assert_eq!(t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await.0, 200);
1299
+ t.enable().await;
1300
+ let (status, body) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(OWNER)).await;
1301
+ assert_eq!(status, 200, "{body}");
1302
+ assert_eq!(body["status"], "paused");
1303
+ assert_eq!(body["direct_modes"], json!([]), "the keys are gone");
1304
+ let removed = t.fake.requests_to("/v1/webhook_endpoints/we_1");
1305
+ assert_eq!(removed.len(), 1);
1306
+ assert_eq!(removed[0].header("authorization"), Some(format!("Bearer {SAVED_KEY}").as_str()));
1307
+ assert!(t.saved_document().get("test").is_none(), "{}", t.saved_document());
1308
+
1309
+ // Registrations pause rather than fall back to the mock...
1310
+ t.schedule_event("gone-event").await;
1311
+ let (status, body) = t.register("gone-event").await;
1312
+ assert_eq!((status, body["error"].as_str()), (503, Some("payments are temporarily unavailable")));
1313
+ // ...and saving again resumes the paused connection with fresh keys.
1314
+ let (status, body) = t.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1315
+ assert_eq!((status, body["status"].as_str()), (200, Some("enabled")), "{body}");
1316
+ assert_eq!(t.saved_document()["test"]["webhook_endpoint_id"], "we_2");
1317
+ }
1318
+
1319
+ #[tokio::test]
1320
+ async fn keys_set_in_the_environment_win_over_saved_ones() {
1321
+ let mut t = Tenant::saving("hecks_pay_test_save_env").await;
1322
+ t.platform.direct_test_key = DIRECT_KEY.to_string();
1323
+ t.platform.direct_test_publishable_key = DIRECT_PUBLISHABLE_KEY.to_string();
1324
+ t.schedule_event("env-event").await;
1325
+ let (status, body) = t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1326
+ assert_eq!(status, 200, "{body}");
1327
+ t.enable().await;
1328
+
1329
+ let (_, body) = t.register("env-event").await;
1330
+ assert_eq!(body["embedded_checkout"]["publishable_key"], DIRECT_PUBLISHABLE_KEY);
1331
+ let sent = t.fake.requests_to("/v1/checkout/sessions");
1332
+ assert_eq!(sent[0].header("authorization"), Some(format!("Bearer {DIRECT_KEY}").as_str()));
1333
+ }
1334
+
1335
+ #[tokio::test]
1336
+ async fn a_store_that_cannot_be_written_saves_nothing_and_takes_the_webhook_back() {
1337
+ let t = Tenant::saving("hecks_pay_test_save_storefail").await;
1338
+ t.raw.as_ref().unwrap().fail_writes.store(true, Ordering::SeqCst);
1339
+ let (status, body) = t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await;
1340
+ assert_eq!(status, 500, "{body}");
1341
+ assert_eq!(body["error"], "The keys could not be stored, so nothing was saved. Please try again.");
1342
+ assert_no_secret_in(&body.to_string());
1343
+ assert_eq!(t.fake.requests_to("/v1/webhook_endpoints/we_1").len(), 1, "the webhook just made was removed again");
1344
+ assert_eq!(t.status().await, "none");
1345
+ }
1346
+
1347
+ #[tokio::test]
1348
+ async fn the_saved_keys_are_read_from_a_short_cache_and_a_save_refreshes_it() {
1349
+ let t = Tenant::saving("hecks_pay_test_save_cache").await;
1350
+ assert_eq!(t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await.0, 200);
1351
+ let raw = t.raw.as_ref().unwrap();
1352
+ let reads = raw.reads.load(Ordering::SeqCst);
1353
+ for _ in 0..3 {
1354
+ t.call("GET", "/payments/connection", json!({}), Some(OWNER)).await;
1355
+ }
1356
+ assert_eq!(raw.reads.load(Ordering::SeqCst), reads, "reads are served from the cache");
1357
+
1358
+ // Saving different keys shows up in the very next request, not a minute later.
1359
+ assert_eq!(t.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await.0, 200);
1360
+ assert_eq!(t.platform_now().await.direct_key("test"), SAVED_KEY_TWO);
1361
+ }
1362
+
1363
+ #[test]
1364
+ fn key_shapes_decide_the_mode_and_a_mismatch_is_refused_without_quoting_a_key() {
1365
+ assert_eq!(keys_mode("rk_test_a", "pk_test_a"), Ok("test"));
1366
+ assert_eq!(keys_mode("sk_live_a", "pk_live_a"), Ok("live"));
1367
+ assert!(keys_mode("rk_test_a", "pk_live_a").is_err());
1368
+ assert!(keys_mode("pk_test_a", "pk_test_a").is_err(), "a publishable key is not a secret key");
1369
+ assert!(keys_mode("rk_test_a", "sk_test_a").is_err());
1370
+ for message in [keys_mode("rk_test_KEYMATERIAL", "x").unwrap_err(), keys_mode("x", "pk_test_KEYMATERIAL").unwrap_err(), keys_mode("rk_test_KEYMATERIAL", "pk_live_KEYMATERIAL").unwrap_err()] {
1371
+ assert!(!message.contains("KEYMATERIAL"), "{message}");
1372
+ }
1373
+ }
1374
+
1375
+ #[test]
1376
+ fn the_webhook_description_is_the_configured_text_else_the_domain_name_and_website() {
1377
+ assert_eq!(webhook_description(None, "Atelier"), "Atelier website");
1378
+ assert_eq!(webhook_description(Some(""), "Atelier"), "Atelier website");
1379
+ assert_eq!(webhook_description(Some(" "), "Atelier"), "Atelier website");
1380
+ assert_eq!(webhook_description(Some(" Atelier bookings "), "Atelier"), "Atelier bookings");
1381
+ }
1382
+
1383
+ #[tokio::test]
1384
+ async fn the_webhook_endpoint_is_created_with_the_platform_description() {
1385
+ let mut t = Tenant::saving("hecks_pay_test_webhook_description").await;
1386
+ t.platform.webhook_description = "Atelier bookings".to_string();
1387
+ assert_eq!(t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER)).await.0, 200);
1388
+ let created = t.fake.requests_to("/v1/webhook_endpoints");
1389
+ assert_eq!(created.len(), 1);
1390
+ assert!(created[0].body.contains("description=Atelier+bookings"), "{}", created[0].body);
1391
+ }
1392
+
1393
+ #[test]
1394
+ fn purpose_tokens_only_verify_for_their_own_purpose_and_secret() {
1395
+ let token = auth::purpose_token("k", "one", json!({"email": "a@b.c"}), 60);
1396
+ assert_eq!(auth::verify_purpose_token("k", "one", &token).unwrap()["email"], "a@b.c");
1397
+ assert!(auth::verify_purpose_token("k", "two", &token).is_none());
1398
+ assert!(auth::verify_purpose_token("other", "one", &token).is_none());
1399
+ assert!(auth::verify_account_token("k", &token).is_none(), "not a session cookie");
1400
+ }