hecks 3.13.0 → 4.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.
- checksums.yaml +4 -4
- data/examples/pizzas/adapters/fake_processor.rb +34 -0
- data/examples/pizzas/adapters/payment_signature.rb +66 -0
- data/examples/pizzas/adapters/payment_webhook.rb +87 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
- data/examples/pizzas/bluebook/pizzas.bluebook +4 -4
- data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
- data/examples/pizzas/config.ru +22 -0
- data/lib/hecks/adapters/driven/prism.rb +0 -2
- data/lib/hecks/adapters/driven.rb +0 -2
- data/lib/hecks/adapters/driving/json.rb +1 -7
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +15 -5
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
- data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
- data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
- data/lib/hecks/bluebook/hecksagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
- data/lib/hecks/bluebook/meta_validator.rb +0 -11
- data/lib/hecks/bluebook/named_pattern.rb +33 -0
- data/lib/hecks/bluebook/translation.rb +18 -10
- data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
- data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
- data/lib/hecks/bluebook/translation_expression.rb +39 -0
- data/lib/hecks/bluebook/translation_plan.rb +70 -0
- data/lib/hecks/bluebook.rb +3 -0
- data/lib/hecks/chapters.rb +3 -3
- data/lib/hecks/cli/domain_stub.rb +1 -1
- data/lib/hecks/cli/interview_aggregate.rb +4 -4
- data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
- data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
- data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
- data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
- data/lib/hecks/composer/adapters/jev.adapter +3 -0
- data/lib/hecks/composer/adapters/jev.rb +66 -0
- data/lib/hecks/composer/agent.rb +113 -0
- data/lib/hecks/composer/assistant.rb +110 -0
- data/lib/hecks/composer/behavior_run.rb +53 -0
- data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
- data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
- data/lib/hecks/composer/board.rb +65 -0
- data/lib/hecks/composer/board_edit/command_text.rb +39 -0
- data/lib/hecks/composer/board_edit/source.rb +84 -0
- data/lib/hecks/composer/board_edit.rb +144 -0
- data/lib/hecks/composer/check.rb +89 -0
- data/lib/hecks/composer/domains.rb +81 -0
- data/lib/hecks/composer/jobs.rb +134 -0
- data/lib/hecks/composer/layout.rb +37 -0
- data/lib/hecks/composer/live.rb +150 -0
- data/lib/hecks/composer/outline.rb +48 -0
- data/lib/hecks/composer/page.html +989 -0
- data/lib/hecks/composer/panels.rb +142 -0
- data/lib/hecks/composer/parse.rb +83 -0
- data/lib/hecks/composer/plain.rb +28 -0
- data/lib/hecks/composer/ports/composer_agent.port +7 -0
- data/lib/hecks/composer/session.rb +141 -0
- data/lib/hecks/composer/toolbox.rb +166 -0
- data/lib/hecks/composer/web.rb +156 -0
- data/lib/hecks/composer/workbench.rb +51 -0
- data/lib/hecks/composer/workspace.rb +81 -0
- data/lib/hecks/composer.rb +17 -0
- data/lib/hecks/corpus/adapters.rb +22 -0
- data/lib/hecks/corpus/rust.rb +1 -1
- data/lib/hecks/corpus/worlds.rb +19 -0
- data/lib/hecks/corpus.rb +5 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -34
- data/lib/hecks/forms/app/commands.rb +2 -3
- data/lib/hecks/forms/app/queries.rb +2 -3
- data/lib/hecks/forms/app/records.rb +17 -5
- data/lib/hecks/forms/command_form_renderer.rb +42 -16
- data/lib/hecks/forms/field_controls.rb +2 -2
- data/lib/hecks/forms/field_renderer.rb +1 -1
- data/lib/hecks/forms/field_shape.rb +36 -17
- data/lib/hecks/forms/index_renderer.rb +2 -1
- data/lib/hecks/forms/page.rb +1 -1
- data/lib/hecks/forms/params.rb +0 -13
- data/lib/hecks/forms/query_form_renderer.rb +7 -6
- data/lib/hecks/forms/record_renderer.rb +11 -7
- data/lib/hecks/forms/rule_words.rb +100 -0
- data/lib/hecks/forms/value_object_shape.rb +2 -1
- data/lib/hecks/framework/bluebook/change.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- data/lib/hecks/fuzzing/domain_generator/block_rendering.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
- data/lib/hecks/fuzzing/replay/filters.rb +2 -6
- data/lib/hecks/gate/stages.yml +25 -0
- data/lib/hecks/grammar/evolve/seeds.rb +0 -6
- data/lib/hecks/grammar/evolve.rb +0 -3
- data/lib/hecks/grammar/translation.bluebook +1 -1
- data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
- data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
- data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
- data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
- data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +58 -37
- data/lib/hecks/hecks/composer.behaviors +68 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.behaviors +0 -6
- data/lib/hecks/hecks/custodian.bluebook +15 -92
- data/lib/hecks/hecks/hecks.bluebook +2 -2
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +10 -16
- data/lib/hecks/hecks/tooling.bluebook +22 -22
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +1 -0
- data/lib/hecks/language/bluebook/command.bluebook +1 -0
- data/lib/hecks/language/bluebook/entity.bluebook +1 -0
- data/lib/hecks/language/bluebook/query.bluebook +1 -0
- data/lib/hecks/language/bluebook/shape.bluebook +8 -7
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- data/lib/hecks/language/hecksagon/port_operation.bluebook +1 -0
- data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
- data/lib/hecks/ports/access_control.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
- data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
- data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
- data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
- data/lib/hecks/ports.rb +0 -1
- data/lib/hecks/projection_files.rb +3 -1
- data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
- data/lib/hecks/projections/glossary/entries.rb +10 -4
- data/lib/hecks/projections/glossary/groups.rb +42 -0
- data/lib/hecks/projections/glossary/html/page.rb +7 -2
- data/lib/hecks/projections/glossary/html/parser.rb +1 -1
- data/lib/hecks/projections/glossary/html/sections.rb +31 -11
- data/lib/hecks/projections/glossary/html.rb +1 -2
- data/lib/hecks/projections/glossary/markdown.rb +31 -28
- data/lib/hecks/projections/glossary/mermaid.rb +3 -0
- data/lib/hecks/projections/glossary/page.css +143 -153
- data/lib/hecks/projections/glossary/page.js +63 -3
- data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
- data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
- data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
- data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
- data/lib/hecks/projections/glossary/sentences.rb +13 -3
- data/lib/hecks/projections/glossary/slugs.rb +4 -1
- data/lib/hecks/projections/glossary.rb +2 -1
- data/lib/hecks/projections/plain_english.rb +97 -0
- data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
- data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary.rb +14 -6
- data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
- data/lib/hecks/projections/statements/actions.rb +99 -0
- data/lib/hecks/projections/statements/relationships.rb +86 -0
- data/lib/hecks/projections/statements.rb +15 -24
- data/lib/hecks/projector/exporter/adapters.rb +112 -0
- data/lib/hecks/projector/exporter/translations.rb +18 -35
- data/lib/hecks/projector/exporter/worlds.rb +103 -0
- data/lib/hecks/projector/exporter.rb +5 -1
- data/lib/hecks/quality_control/adapters/agent.rb +6 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
- data/lib/hecks/quality_control/quality_control.bluebook +51 -51
- data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
- data/lib/hecks/runtime/outbox.rb +2 -9
- data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
- data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
- data/lib/hecks/runtime/value.rb +13 -0
- data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
- data/lib/hecks/rust_build/wasm.rb +0 -12
- data/lib/hecks/rust_build/write_if_changed.rb +12 -12
- data/lib/hecks/rust_format.rb +77 -0
- data/lib/hecks/site/bluebook/site.bluebook +5 -5
- data/lib/hecks/sme/bluebook/sme.bluebook +12 -12
- data/lib/hecks/tickets/bluebook/tickets.bluebook +3 -3
- data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
- data/lib/hecks/tools/fuzz_sweep.rb +1 -3
- data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
- data/lib/hecks/tools/translation_audit.rb +8 -10
- data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- data/lib/hecks.rb +3 -0
- data/prebuilt/{58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
- data/prebuilt/{2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
- data/prebuilt/verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json +1 -0
- data/rust/Cargo.toml +40 -0
- data/rust/build/Cargo.toml +36 -0
- data/rust/build/clippy.toml +4 -0
- data/rust/build/src/adapters_pass.rs +196 -0
- data/rust/build/src/binding_pass.rs +54 -13
- data/rust/build/src/build_artifact.rs +23 -5
- data/rust/build/src/cargo_sync.rs +69 -26
- data/rust/build/src/lineage_pass.rs +101 -87
- data/rust/build/src/main.rs +11 -2
- data/rust/build/src/pipeline.rs +80 -19
- data/rust/build/src/reserved_names.rs +6 -50
- data/rust/build/src/resolve.rs +82 -35
- data/rust/build/src/rustfmt.rs +100 -0
- data/rust/build/src/seams_pass.rs +85 -16
- data/rust/build/src/subprocess.rs +30 -6
- data/rust/build/src/tmp.rs +5 -2
- data/rust/build/src/translation_pass.rs +72 -22
- data/rust/build/src/world_pass.rs +109 -0
- data/rust/clippy.toml +4 -0
- data/rust/codegen/Cargo.toml +36 -0
- data/rust/codegen/clippy.toml +4 -0
- data/rust/codegen/src/asks.rs +40 -14
- data/rust/codegen/src/attr.rs +2 -2
- data/rust/codegen/src/bridging.rs +676 -117
- data/rust/codegen/src/commands.rs +979 -373
- data/rust/codegen/src/constraints.rs +88 -25
- data/rust/codegen/src/dependency_planning.rs +128 -34
- data/rust/codegen/src/domain_generator.rs +300 -170
- data/rust/codegen/src/exemplar.rs +87 -32
- data/rust/codegen/src/expr_emitter.rs +54 -6
- data/rust/codegen/src/fielded.rs +193 -41
- data/rust/codegen/src/hecks_naming.rs +124 -16
- data/rust/codegen/src/hecks_naming_tests.rs +81 -0
- data/rust/codegen/src/json.rs +80 -44
- data/rust/codegen/src/json_codec.rs +585 -149
- data/rust/codegen/src/literal.rs +104 -138
- data/rust/codegen/src/main.rs +145 -52
- data/rust/codegen/src/manifest.rs +76 -14
- data/rust/codegen/src/mutations.rs +1212 -239
- data/rust/codegen/src/naming.rs +111 -60
- data/rust/codegen/src/optional_pass.rs +90 -28
- data/rust/codegen/src/ports.rs +5 -4
- data/rust/codegen/src/prelude.rs +139 -49
- data/rust/codegen/src/queries.rs +601 -226
- data/rust/codegen/src/reactions.rs +268 -74
- data/rust/codegen/src/read_models.rs +606 -205
- data/rust/codegen/src/reference_specs.rs +16 -3
- data/rust/codegen/src/registry.rs +128 -79
- data/rust/codegen/src/reserved_names.rs +6 -50
- data/rust/codegen/src/shared.rs +71 -22
- data/rust/codegen/src/sidecars.rs +15 -5
- data/rust/codegen/src/skip_reason.rs +8 -2
- data/rust/codegen/src/types.rs +306 -108
- data/rust/host/Cargo.lock +1 -0
- data/rust/host/Cargo.toml +40 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/clippy.toml +4 -0
- data/rust/host/src/api.rs +698 -218
- data/rust/host/src/auth.rs +491 -214
- data/rust/host/src/b64.rs +91 -0
- data/rust/host/src/bin/expr_harness.rs +6 -2
- data/rust/host/src/bin/lineage_harness.rs +47 -11
- data/rust/host/src/bin/mint_harness.rs +70 -37
- data/rust/host/src/bin/validate_harness.rs +16 -8
- data/rust/host/src/boot.rs +144 -68
- data/rust/host/src/boundary_fuzz/api.rs +130 -32
- data/rust/host/src/boundary_fuzz/auth.rs +108 -23
- data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
- data/rust/host/src/boundary_fuzz/ir.rs +164 -12
- data/rust/host/src/boundary_fuzz/server.rs +283 -36
- data/rust/host/src/boundary_fuzz/web.rs +56 -8
- data/rust/host/src/checkout.rs +78 -32
- data/rust/host/src/clock.rs +8 -0
- data/rust/host/src/commerce_ir.rs +333 -95
- data/rust/host/src/crypto.rs +33 -0
- data/rust/host/src/dispatch.rs +990 -331
- data/rust/host/src/expr_json.rs +1021 -273
- data/rust/host/src/extension.rs +24 -4
- data/rust/host/src/field_hints.rs +11 -7
- data/rust/host/src/fuzz_support.rs +85 -13
- data/rust/host/src/host.rs +100 -0
- data/rust/host/src/http.rs +22 -0
- data/rust/host/src/ir.rs +326 -16
- data/rust/host/src/journal.rs +277 -176
- data/rust/host/src/lambda_client.rs +219 -53
- data/rust/host/src/lib.rs +16 -4
- data/rust/host/src/log.rs +62 -11
- data/rust/host/src/mint.rs +1153 -326
- data/rust/host/src/needs.rs +185 -44
- data/rust/host/src/payments/keystore.rs +197 -45
- data/rust/host/src/payments/roles.rs +191 -0
- data/rust/host/src/payments/tests.rs +1445 -275
- data/rust/host/src/payments.rs +409 -121
- data/rust/host/src/platform_signin/attack_tests.rs +5 -5
- data/rust/host/src/platform_signin/config.rs +11 -11
- data/rust/host/src/platform_signin/cookie.rs +2 -2
- data/rust/host/src/platform_signin/extension.rs +2 -1
- data/rust/host/src/platform_signin/handler.rs +9 -8
- data/rust/host/src/platform_signin/jti.rs +4 -2
- data/rust/host/src/platform_signin/jwks.rs +2 -1
- data/rust/host/src/platform_signin/jws.rs +2 -37
- data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
- data/rust/host/src/platform_signin/mode_tests.rs +5 -3
- data/rust/host/src/platform_signin/pages.rs +20 -20
- data/rust/host/src/platform_signin/session.rs +8 -1
- data/rust/host/src/platform_signin/settings.rs +6 -6
- data/rust/host/src/platform_signin/strict_json.rs +12 -9
- data/rust/host/src/platform_signin/verify.rs +41 -41
- data/rust/host/src/platform_signin/wiring_support.rs +13 -18
- data/rust/host/src/presentation.rs +207 -63
- data/rust/host/src/presentation_write.rs +502 -187
- data/rust/host/src/query_step.rs +69 -18
- data/rust/host/src/rate_limit.rs +541 -142
- data/rust/host/src/reference_transform.rs +77 -25
- data/rust/host/src/reference_validate.rs +443 -104
- data/rust/host/src/resend.rs +190 -55
- data/rust/host/src/secrets.rs +116 -21
- data/rust/host/src/server.rs +483 -104
- data/rust/host/src/storage_shape.rs +88 -21
- data/rust/host/src/test_pg.rs +17 -7
- data/rust/host/src/ui_schema.rs +495 -207
- data/rust/host/src/wasm_runner.rs +21 -6
- data/rust/host/src/web/commerce.rs +101 -23
- data/rust/host/src/web/newsletter/cooldown.rs +10 -3
- data/rust/host/src/web/newsletter/template.rs +26 -7
- data/rust/host/src/web/newsletter/tests.rs +112 -23
- data/rust/host/src/web/newsletter.rs +209 -59
- data/rust/host/src/web/newsletter_send.rs +251 -79
- data/rust/host/src/web/registration_receipt/tests.rs +16 -4
- data/rust/host/src/web/registration_receipt.rs +100 -24
- data/rust/host/src/web/registrations.rs +1384 -332
- data/rust/host/src/web/resend_webhook.rs +138 -80
- data/rust/host/src/web.rs +2416 -508
- data/rust/lsp/Cargo.toml +36 -0
- data/rust/lsp/clippy.toml +4 -0
- data/rust/lsp/src/json.rs +29 -22
- data/rust/lsp/src/main.rs +81 -31
- data/rust/lsp/src/outline.rs +23 -7
- data/rust/lsp/src/rpc.rs +17 -4
- data/rust/parser/Cargo.toml +36 -0
- data/rust/parser/clippy.toml +4 -0
- data/rust/parser/src/build/closed_sets.rs +6 -1
- data/rust/parser/src/build/identity.rs +4 -3
- data/rust/parser/src/build/mod.rs +1 -0
- data/rust/parser/src/build/named_pattern.rs +26 -0
- data/rust/parser/src/build/naming.rs +2 -80
- data/rust/parser/src/build/pattern_subset.rs +6 -8
- data/rust/parser/src/build/query_derive.rs +3 -1
- data/rust/parser/src/build/query_inference.rs +14 -5
- data/rust/parser/src/build/read_model.rs +4 -1
- data/rust/parser/src/build/references.rs +4 -3
- data/rust/parser/src/canonical.rs +34 -15
- data/rust/parser/src/emit.rs +187 -105
- data/rust/parser/src/expr/ast_json.rs +134 -42
- data/rust/parser/src/expr/evaluator.rs +27 -9
- data/rust/parser/src/expr/mod.rs +54 -13
- data/rust/parser/src/expr/resolver.rs +299 -92
- data/rust/parser/src/ir.rs +35 -3
- data/rust/parser/src/keywords.rs +7168 -510
- data/rust/parser/src/lex.rs +11 -25
- data/rust/parser/src/lib.rs +1 -0
- data/rust/parser/src/main.rs +65 -6
- data/rust/parser/src/parse/adapter.rs +157 -0
- data/rust/parser/src/parse/aggregate.rs +19 -10
- data/rust/parser/src/parse/chapter.rs +16 -17
- data/rust/parser/src/parse/command.rs +35 -16
- data/rust/parser/src/parse/domain_port.rs +4 -7
- data/rust/parser/src/parse/entity.rs +21 -11
- data/rust/parser/src/parse/hecksagon.rs +10 -4
- data/rust/parser/src/parse/lifecycle.rs +9 -5
- data/rust/parser/src/parse/mod.rs +43 -91
- data/rust/parser/src/parse/needs.rs +21 -12
- data/rust/parser/src/parse/policy.rs +2 -2
- data/rust/parser/src/parse/process_manager.rs +9 -11
- data/rust/parser/src/parse/query.rs +8 -4
- data/rust/parser/src/parse/read_model.rs +57 -16
- data/rust/parser/src/parse/value_object.rs +7 -2
- data/rust/parser/src/parse/world.rs +322 -0
- data/rust/parser/src/ruby_value.rs +7 -6
- data/rust/src/exemplar/json.rs +4 -4
- data/rust/src/exemplar/mutations.rs +18 -0
- data/rust/src/exemplar/read_models.rs +1 -10
- data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
- data/rust/src/kernel/attribute_shapes/list.rs +2 -2
- data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
- data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
- data/rust/src/kernel/cli.rs +229 -112
- data/rust/src/kernel/dispatch.rs +323 -115
- data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
- data/rust/src/kernel/expr.rs +203 -86
- data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
- data/rust/src/kernel/expression_operators/comparison.rs +87 -2
- data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
- data/rust/src/kernel/expression_operators/logical.rs +9 -3
- data/rust/src/kernel/expression_operators/membership.rs +3 -1
- data/rust/src/kernel/expression_operators/mod.rs +34 -21
- data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
- data/rust/src/kernel/expression_operators/positional.rs +84 -30
- data/rust/src/kernel/expression_operators/presence.rs +52 -13
- data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
- data/rust/src/kernel/expression_operators/sized.rs +24 -8
- data/rust/src/kernel/expression_operators/text.rs +120 -27
- data/rust/src/kernel/expression_operators/to_string.rs +10 -4
- data/rust/src/kernel/json.rs +230 -148
- data/rust/src/kernel/mod.rs +46 -33
- data/rust/src/kernel/named_query.rs +63 -15
- data/rust/src/kernel/needs.rs +112 -25
- data/rust/src/kernel/orchestrate.rs +980 -581
- data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
- data/rust/src/kernel/pattern.rs +107 -34
- data/rust/src/kernel/query_comparators.rs +118 -41
- data/rust/src/kernel/query_ordering.rs +13 -5
- data/rust/src/kernel/read_model.rs +479 -181
- data/rust/src/kernel/reference_lookup.rs +122 -26
- data/rust/src/kernel/repository.rs +293 -66
- data/rust/src/kernel/routing.rs +58 -42
- data/rust/src/kernel/ruby_float.rs +139 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
- data/rust/src/kernel/vocab/field_hint.rs +19 -25
- data/rust/src/kernel/vocab/mod.rs +6 -6
- data/rust/src/kernel/vocab/query_comparator.rs +23 -24
- data/rust/src/kernel/vocab/refusal_template.rs +501 -297
- data/rust/src/lib.rs +50 -0
- data/rust/src/main.rs +4 -1
- data/rust/web/Cargo.toml +36 -0
- data/rust/web/clippy.toml +4 -0
- metadata +85 -15
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
- data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
- data/lib/hecks/ports/authentication.port +0 -6
- data/lib/hecks/ports/authentication.rb +0 -67
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
- data/prebuilt/verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json +0 -1
- data/rust/host/src/approval.rs +0 -652
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
use super::keystore::MemoryStore;
|
|
5
5
|
use super::*;
|
|
6
6
|
use crate::dispatch::tests::{provision_lineage, scratch_db};
|
|
7
|
+
use crate::host::Host;
|
|
7
8
|
use crate::lambda_client::NeverInvoker;
|
|
8
9
|
use crate::web;
|
|
9
10
|
use axum::extract::State;
|
|
10
11
|
use axum::http::StatusCode;
|
|
11
12
|
use axum::response::IntoResponse;
|
|
13
|
+
use std::fmt::Write;
|
|
12
14
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
13
15
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
14
16
|
|
|
@@ -72,8 +74,12 @@ impl FakeStripe {
|
|
|
72
74
|
webhooks_created: AtomicUsize::new(0),
|
|
73
75
|
fail_deletes: AtomicBool::new(false),
|
|
74
76
|
});
|
|
75
|
-
let app = axum::Router::new()
|
|
76
|
-
|
|
77
|
+
let app = axum::Router::new()
|
|
78
|
+
.fallback(answer)
|
|
79
|
+
.with_state(state.clone());
|
|
80
|
+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
81
|
+
.await
|
|
82
|
+
.expect("bind a local port");
|
|
77
83
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
78
84
|
tokio::spawn(async move {
|
|
79
85
|
let _ = axum::serve(listener, app).await;
|
|
@@ -86,17 +92,27 @@ impl FakeStripe {
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
fn requests_to(&self, path: &str) -> Vec<Recorded> {
|
|
89
|
-
self.requests()
|
|
95
|
+
self.requests()
|
|
96
|
+
.into_iter()
|
|
97
|
+
.filter(|r| r.path == path)
|
|
98
|
+
.collect()
|
|
90
99
|
}
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
async fn answer(
|
|
102
|
+
async fn answer(
|
|
103
|
+
State(state): State<Arc<FakeState>>,
|
|
104
|
+
request: axum::extract::Request,
|
|
105
|
+
) -> axum::response::Response {
|
|
94
106
|
let (parts, body) = request.into_parts();
|
|
95
107
|
let bytes = axum::body::to_bytes(body, 1 << 20).await.unwrap();
|
|
96
108
|
let recorded = Recorded {
|
|
97
109
|
method: parts.method.to_string(),
|
|
98
110
|
path: parts.uri.path().to_string(),
|
|
99
|
-
headers: parts
|
|
111
|
+
headers: parts
|
|
112
|
+
.headers
|
|
113
|
+
.iter()
|
|
114
|
+
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
|
|
115
|
+
.collect(),
|
|
100
116
|
body: String::from_utf8_lossy(&bytes).to_string(),
|
|
101
117
|
};
|
|
102
118
|
let path = recorded.path.clone();
|
|
@@ -105,29 +121,58 @@ async fn answer(State(state): State<Arc<FakeState>>, request: axum::extract::Req
|
|
|
105
121
|
|
|
106
122
|
let (status, reply) = match (method.as_str(), path.as_str()) {
|
|
107
123
|
// 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/") => (
|
|
124
|
+
(_, p) if state.invalid_key.load(Ordering::SeqCst) && p.starts_with("/v1/") => (
|
|
125
|
+
StatusCode::UNAUTHORIZED,
|
|
126
|
+
json!({"error": {"message": "Invalid API Key provided: rk_test_****"}}),
|
|
127
|
+
),
|
|
109
128
|
// The webhook endpoints of the business's own account.
|
|
110
|
-
("POST", "/v1/webhook_endpoints") if state.refuse_webhooks.load(Ordering::SeqCst) =>
|
|
111
|
-
|
|
112
|
-
|
|
129
|
+
("POST", "/v1/webhook_endpoints") if state.refuse_webhooks.load(Ordering::SeqCst) => (
|
|
130
|
+
StatusCode::FORBIDDEN,
|
|
131
|
+
json!({"error": {"message": "The provided key does not have the required permissions"}}),
|
|
132
|
+
),
|
|
113
133
|
("POST", "/v1/webhook_endpoints") => {
|
|
114
134
|
let n = state.webhooks_created.fetch_add(1, Ordering::SeqCst) + 1;
|
|
115
|
-
(
|
|
135
|
+
(
|
|
136
|
+
StatusCode::OK,
|
|
137
|
+
json!({"id": format!("we_{n}"), "object": "webhook_endpoint", "secret": format!("whsec_SAVED_{n}")}),
|
|
138
|
+
)
|
|
116
139
|
}
|
|
117
|
-
("DELETE", p)
|
|
118
|
-
|
|
140
|
+
("DELETE", p)
|
|
141
|
+
if p.starts_with("/v1/webhook_endpoints/")
|
|
142
|
+
&& state.fail_deletes.load(Ordering::SeqCst) =>
|
|
143
|
+
{
|
|
144
|
+
(
|
|
145
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
146
|
+
json!({"error": {"message": "Stripe is having trouble"}}),
|
|
147
|
+
)
|
|
119
148
|
}
|
|
120
|
-
("DELETE", p) if p.starts_with("/v1/webhook_endpoints/") => (
|
|
149
|
+
("DELETE", p) if p.starts_with("/v1/webhook_endpoints/") => (
|
|
150
|
+
StatusCode::OK,
|
|
151
|
+
json!({"id": p.trim_start_matches("/v1/webhook_endpoints/"), "deleted": true}),
|
|
152
|
+
),
|
|
121
153
|
// The business's own account, read with its own key.
|
|
122
|
-
("GET", "/v1/account") if state.refuse_account.load(Ordering::SeqCst) => (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
154
|
+
("GET", "/v1/account") if state.refuse_account.load(Ordering::SeqCst) => (
|
|
155
|
+
StatusCode::FORBIDDEN,
|
|
156
|
+
json!({"error": {"message": "key lacks permission"}}),
|
|
157
|
+
),
|
|
158
|
+
("GET", "/v1/account") => (
|
|
159
|
+
StatusCode::OK,
|
|
160
|
+
json!({"id": "acct_own_1", "business_profile": {"name": "Own Studio"}}),
|
|
161
|
+
),
|
|
162
|
+
("POST", "/v1/checkout/sessions") if state.refuse_sessions.load(Ordering::SeqCst) => (
|
|
163
|
+
StatusCode::BAD_REQUEST,
|
|
164
|
+
json!({"error": {"message": "no such price"}}),
|
|
165
|
+
),
|
|
127
166
|
// An embedded session: Stripe answers with a client secret for the
|
|
128
167
|
// browser and no hosted `url`.
|
|
129
|
-
("POST", "/v1/checkout/sessions") => (
|
|
130
|
-
|
|
168
|
+
("POST", "/v1/checkout/sessions") => (
|
|
169
|
+
StatusCode::OK,
|
|
170
|
+
json!({"id": "cs_test_1", "ui_mode": "embedded_page", "client_secret": "cs_test_1_secret_CLIENT"}),
|
|
171
|
+
),
|
|
172
|
+
_ => (
|
|
173
|
+
StatusCode::NOT_FOUND,
|
|
174
|
+
json!({"error": {"message": "no such fake route"}}),
|
|
175
|
+
),
|
|
131
176
|
};
|
|
132
177
|
(status, axum::Json(reply)).into_response()
|
|
133
178
|
}
|
|
@@ -147,7 +192,13 @@ async fn tenant(name: &str) -> Tenant {
|
|
|
147
192
|
let client = scratch_db(name).await;
|
|
148
193
|
{
|
|
149
194
|
let guard = client.lock().await;
|
|
150
|
-
provision_lineage(
|
|
195
|
+
provision_lineage(
|
|
196
|
+
&guard,
|
|
197
|
+
"CheckoutFixture",
|
|
198
|
+
1,
|
|
199
|
+
&["Event", "Registration", "Payment", "PaymentConnection"],
|
|
200
|
+
)
|
|
201
|
+
.await;
|
|
151
202
|
guard
|
|
152
203
|
.batch_execute(
|
|
153
204
|
"CREATE TABLE acme_member_head_snapshot_1 (id text PRIMARY KEY, ordinal bigint NOT NULL, state jsonb NOT NULL);
|
|
@@ -197,12 +248,18 @@ async fn tenant(name: &str) -> Tenant {
|
|
|
197
248
|
direct_live_publishable_key: String::new(),
|
|
198
249
|
webhook_secret: Some(WEBHOOK_SECRET.to_string()),
|
|
199
250
|
operators: vec![OPERATOR.to_string()],
|
|
251
|
+
manager_roles: roles::default_roles(),
|
|
200
252
|
};
|
|
201
253
|
Tenant {
|
|
202
254
|
client,
|
|
203
255
|
domain_ir,
|
|
204
|
-
config: LineageConfig {
|
|
205
|
-
|
|
256
|
+
config: LineageConfig {
|
|
257
|
+
domain: "CheckoutFixture".to_string(),
|
|
258
|
+
era: Some(1),
|
|
259
|
+
mirrored: None,
|
|
260
|
+
},
|
|
261
|
+
wasm: std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
262
|
+
.join("../dist/checkout_fixture.wasm"),
|
|
206
263
|
fake,
|
|
207
264
|
platform,
|
|
208
265
|
raw: None,
|
|
@@ -220,7 +277,7 @@ impl Tenant {
|
|
|
220
277
|
|
|
221
278
|
// A tenant whose business can paste its keys into the Payments page: no
|
|
222
279
|
// environment keys, only a key store (in memory).
|
|
223
|
-
async fn saving(name: &str) ->
|
|
280
|
+
async fn saving(name: &str) -> Self {
|
|
224
281
|
let mut t = tenant(name).await;
|
|
225
282
|
t.platform.direct_test_key = String::new();
|
|
226
283
|
t.platform.direct_test_publishable_key = String::new();
|
|
@@ -231,49 +288,98 @@ impl Tenant {
|
|
|
231
288
|
}
|
|
232
289
|
|
|
233
290
|
// The Payments page's Save, as the given person.
|
|
234
|
-
async fn save_keys(
|
|
235
|
-
self
|
|
291
|
+
async fn save_keys(
|
|
292
|
+
&self,
|
|
293
|
+
secret_key: &str,
|
|
294
|
+
publishable_key: &str,
|
|
295
|
+
as_email: Option<&str>,
|
|
296
|
+
) -> (u64, Value) {
|
|
297
|
+
self.call(
|
|
298
|
+
"POST",
|
|
299
|
+
"/payments/connection/direct",
|
|
300
|
+
json!({"secret_key": secret_key, "publishable_key": publishable_key}),
|
|
301
|
+
as_email,
|
|
302
|
+
)
|
|
303
|
+
.await
|
|
236
304
|
}
|
|
237
305
|
|
|
238
306
|
// What the saved-keys secret holds right now (`Null` before the first save).
|
|
239
307
|
fn saved_document(&self) -> Value {
|
|
240
308
|
let raw = self.raw.as_ref().expect("a tenant with a key store");
|
|
241
309
|
let text = raw.document.lock().unwrap().clone();
|
|
242
|
-
text.
|
|
310
|
+
text.map_or(Value::Null, |text| serde_json::from_str(&text).unwrap())
|
|
243
311
|
}
|
|
244
312
|
|
|
245
313
|
// One request against the payments routes; `None` means no session cookie.
|
|
246
|
-
async fn call(
|
|
314
|
+
async fn call(
|
|
315
|
+
&self,
|
|
316
|
+
method: &str,
|
|
317
|
+
path: &str,
|
|
318
|
+
body: Value,
|
|
319
|
+
as_email: Option<&str>,
|
|
320
|
+
) -> (u64, Value) {
|
|
247
321
|
let mut cookies = HashMap::new();
|
|
248
322
|
if let Some(email) = as_email {
|
|
249
|
-
cookies.insert(
|
|
323
|
+
cookies.insert(
|
|
324
|
+
auth::account_cookie_name(),
|
|
325
|
+
auth::account_token(SESSION_SECRET, email, 60),
|
|
326
|
+
);
|
|
250
327
|
}
|
|
251
328
|
let platform = self.platform_now().await;
|
|
252
|
-
let response = route(
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
329
|
+
let response = route(
|
|
330
|
+
method,
|
|
331
|
+
path,
|
|
332
|
+
&body.to_string(),
|
|
333
|
+
&cookies,
|
|
334
|
+
SESSION_SECRET,
|
|
335
|
+
&self.domain_ir,
|
|
336
|
+
&platform,
|
|
337
|
+
Host::new(&self.client, &self.wasm, &self.config, &NeverInvoker),
|
|
338
|
+
)
|
|
339
|
+
.await
|
|
340
|
+
.expect("a payments path");
|
|
341
|
+
(
|
|
342
|
+
response["statusCode"].as_u64().unwrap(),
|
|
343
|
+
serde_json::from_str(response["body"].as_str().unwrap()).unwrap_or(Value::Null),
|
|
344
|
+
)
|
|
256
345
|
}
|
|
257
346
|
|
|
258
347
|
async fn status(&self) -> String {
|
|
259
348
|
let read = dispatch::read(&self.client, &self.wasm).await.unwrap();
|
|
260
|
-
connection(&read, &self.config.domain).
|
|
349
|
+
connection(&read, &self.config.domain).map_or_else(|| "none".to_string(), |c| c.status)
|
|
261
350
|
}
|
|
262
351
|
|
|
263
352
|
// Owner records the business's own test-mode account as the connection.
|
|
264
353
|
async fn connect(&self) {
|
|
265
|
-
let (status, body) = self
|
|
354
|
+
let (status, body) = self
|
|
355
|
+
.call(
|
|
356
|
+
"POST",
|
|
357
|
+
"/payments/connection/direct",
|
|
358
|
+
json!({"mode": "test"}),
|
|
359
|
+
Some(OWNER),
|
|
360
|
+
)
|
|
361
|
+
.await;
|
|
266
362
|
assert_eq!(status, 200, "{body}");
|
|
267
363
|
}
|
|
268
364
|
|
|
269
365
|
// Every row of every table in the scratch database, as text.
|
|
270
366
|
async fn dump_database(&self) -> String {
|
|
271
367
|
let guard = self.client.lock().await;
|
|
272
|
-
let tables = guard
|
|
368
|
+
let tables = guard
|
|
369
|
+
.query(
|
|
370
|
+
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'",
|
|
371
|
+
&[],
|
|
372
|
+
)
|
|
373
|
+
.await
|
|
374
|
+
.unwrap();
|
|
273
375
|
let mut dump = String::new();
|
|
274
376
|
for table in tables {
|
|
275
377
|
let name: String = table.get(0);
|
|
276
|
-
for row in guard
|
|
378
|
+
for row in guard
|
|
379
|
+
.query(&format!("SELECT t::text FROM \"{name}\" t"), &[])
|
|
380
|
+
.await
|
|
381
|
+
.unwrap()
|
|
382
|
+
{
|
|
277
383
|
dump.push_str(&row.get::<_, String>(0));
|
|
278
384
|
dump.push('\n');
|
|
279
385
|
}
|
|
@@ -282,7 +388,14 @@ impl Tenant {
|
|
|
282
388
|
}
|
|
283
389
|
|
|
284
390
|
async fn enable(&self) {
|
|
285
|
-
let (status, body) = self
|
|
391
|
+
let (status, body) = self
|
|
392
|
+
.call(
|
|
393
|
+
"POST",
|
|
394
|
+
"/payments/connection/enable",
|
|
395
|
+
json!({}),
|
|
396
|
+
Some(OPERATOR),
|
|
397
|
+
)
|
|
398
|
+
.await;
|
|
286
399
|
assert_eq!(status, 200, "{body}");
|
|
287
400
|
}
|
|
288
401
|
|
|
@@ -292,16 +405,33 @@ impl Tenant {
|
|
|
292
405
|
|
|
293
406
|
async fn schedule_event_with_capacity(&self, slug: &str, capacity: i64) {
|
|
294
407
|
let args = json!({"slug": {"value": slug}, "name": {"value": "Sample Studio"}, "price": {"cents": 4200}, "capacity": {"value": capacity}});
|
|
295
|
-
let outcome = dispatch::handle(
|
|
408
|
+
let outcome = dispatch::handle(
|
|
409
|
+
Host::new(&self.client, &self.wasm, &self.config, &NeverInvoker),
|
|
410
|
+
"CheckoutFixture::Event.Schedule",
|
|
411
|
+
args,
|
|
412
|
+
None,
|
|
413
|
+
)
|
|
414
|
+
.await
|
|
415
|
+
.unwrap();
|
|
296
416
|
assert!(outcome.accepted, "{:?}", outcome.result);
|
|
297
417
|
}
|
|
298
418
|
|
|
299
419
|
// POST /registrations for `slug`; the checkout URL and registration id on success.
|
|
300
420
|
async fn register(&self, slug: &str) -> (u64, Value) {
|
|
301
|
-
let body = json!({"event_slug": slug, "name": "Ada Lovelace", "email": "ada@example.com"})
|
|
421
|
+
let body = json!({"event_slug": slug, "name": "Ada Lovelace", "email": "ada@example.com"})
|
|
422
|
+
.to_string();
|
|
302
423
|
let platform = self.platform_now().await;
|
|
303
|
-
let response = web::registrations_route(
|
|
304
|
-
|
|
424
|
+
let response = web::registrations_route(
|
|
425
|
+
&body,
|
|
426
|
+
&platform,
|
|
427
|
+
Host::new(&self.client, &self.wasm, &self.config, &NeverInvoker),
|
|
428
|
+
&crate::commerce_ir::fixture_payments(),
|
|
429
|
+
)
|
|
430
|
+
.await;
|
|
431
|
+
(
|
|
432
|
+
response["statusCode"].as_u64().unwrap(),
|
|
433
|
+
serde_json::from_str(response["body"].as_str().unwrap()).unwrap_or(Value::Null),
|
|
434
|
+
)
|
|
305
435
|
}
|
|
306
436
|
|
|
307
437
|
async fn webhook(&self, event: Value) -> u64 {
|
|
@@ -311,23 +441,42 @@ impl Tenant {
|
|
|
311
441
|
// A webhook delivery signed with `secret`, the way Stripe would sign it.
|
|
312
442
|
async fn webhook_signed_with(&self, secret: &str, event: Value) -> u64 {
|
|
313
443
|
let payload = event.to_string();
|
|
314
|
-
let now = std::time::SystemTime::now()
|
|
444
|
+
let now = std::time::SystemTime::now()
|
|
445
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
446
|
+
.unwrap()
|
|
447
|
+
.as_secs() as i64;
|
|
315
448
|
let header = sign(secret, now, &payload);
|
|
316
449
|
let platform = self.platform_now().await;
|
|
317
|
-
let response = web::webhook_route(
|
|
450
|
+
let response = web::webhook_route(
|
|
451
|
+
&payload,
|
|
452
|
+
&header,
|
|
453
|
+
&platform,
|
|
454
|
+
Host::new(&self.client, &self.wasm, &self.config, &NeverInvoker),
|
|
455
|
+
&crate::commerce_ir::fixture_payments(),
|
|
456
|
+
)
|
|
457
|
+
.await;
|
|
318
458
|
response["statusCode"].as_u64().unwrap()
|
|
319
459
|
}
|
|
320
460
|
|
|
321
461
|
// POST /registrations/:id/complete with a successful outcome (the mock
|
|
322
462
|
// walkthrough's "Pay" button).
|
|
323
463
|
async fn settle(&self, reference: &str) -> u64 {
|
|
324
|
-
let response = web::registration_complete_route(
|
|
464
|
+
let response = web::registration_complete_route(
|
|
465
|
+
reference,
|
|
466
|
+
r#"{"outcome":"succeeded"}"#,
|
|
467
|
+
Host::new(&self.client, &self.wasm, &self.config, &NeverInvoker),
|
|
468
|
+
&crate::commerce_ir::fixture_payments(),
|
|
469
|
+
)
|
|
470
|
+
.await;
|
|
325
471
|
response["statusCode"].as_u64().unwrap()
|
|
326
472
|
}
|
|
327
473
|
|
|
328
474
|
async fn payment_status(&self, reference: &str) -> String {
|
|
329
475
|
let read = dispatch::read(&self.client, &self.wasm).await.unwrap();
|
|
330
|
-
read["instances"][format!("Payments::Payment#{reference}")]["status"]
|
|
476
|
+
read["instances"][format!("Payments::Payment#{reference}")]["status"]
|
|
477
|
+
.as_str()
|
|
478
|
+
.unwrap_or("none")
|
|
479
|
+
.to_string()
|
|
331
480
|
}
|
|
332
481
|
}
|
|
333
482
|
|
|
@@ -336,7 +485,15 @@ fn sign(secret: &str, now: i64, payload: &str) -> String {
|
|
|
336
485
|
use sha2::Sha256;
|
|
337
486
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
|
338
487
|
mac.update(format!("{now}.{payload}").as_bytes());
|
|
339
|
-
|
|
488
|
+
let digest = mac
|
|
489
|
+
.finalize()
|
|
490
|
+
.into_bytes()
|
|
491
|
+
.iter()
|
|
492
|
+
.fold(String::new(), |mut hex, b| {
|
|
493
|
+
let _ = write!(hex, "{b:02x}"); // writing to a String cannot fail
|
|
494
|
+
hex
|
|
495
|
+
});
|
|
496
|
+
format!("t={now},v1={digest}")
|
|
340
497
|
}
|
|
341
498
|
|
|
342
499
|
fn completed(reference: &str) -> Value {
|
|
@@ -358,9 +515,16 @@ async fn every_route_refuses_a_missing_session_with_a_json_401_and_touches_nothi
|
|
|
358
515
|
("POST", "/payments/connection/disable"),
|
|
359
516
|
] {
|
|
360
517
|
let (status, body) = t.call(method, path, json!({}), None).await;
|
|
361
|
-
assert_eq!(
|
|
518
|
+
assert_eq!(
|
|
519
|
+
(status, body["error"].as_str()),
|
|
520
|
+
(401, Some("not logged in")),
|
|
521
|
+
"{method} {path}"
|
|
522
|
+
);
|
|
362
523
|
}
|
|
363
|
-
assert!(
|
|
524
|
+
assert!(
|
|
525
|
+
t.fake.requests().is_empty(),
|
|
526
|
+
"no Stripe call without a session"
|
|
527
|
+
);
|
|
364
528
|
assert_eq!(t.status().await, "none");
|
|
365
529
|
}
|
|
366
530
|
|
|
@@ -368,14 +532,42 @@ async fn every_route_refuses_a_missing_session_with_a_json_401_and_touches_nothi
|
|
|
368
532
|
async fn a_tampered_or_disabled_session_is_a_401_not_an_owner() {
|
|
369
533
|
let t = tenant("hecks_pay_test_401_disabled").await;
|
|
370
534
|
let mut cookies = HashMap::new();
|
|
371
|
-
cookies.insert(
|
|
372
|
-
|
|
535
|
+
cookies.insert(
|
|
536
|
+
auth::account_cookie_name(),
|
|
537
|
+
"garbage.notasignature".to_string(),
|
|
538
|
+
);
|
|
539
|
+
let response = route(
|
|
540
|
+
"GET",
|
|
541
|
+
"/payments/connection",
|
|
542
|
+
"",
|
|
543
|
+
&cookies,
|
|
544
|
+
SESSION_SECRET,
|
|
545
|
+
&t.domain_ir,
|
|
546
|
+
&t.platform,
|
|
547
|
+
Host::new(&t.client, &t.wasm, &t.config, &NeverInvoker),
|
|
548
|
+
)
|
|
549
|
+
.await
|
|
550
|
+
.unwrap();
|
|
373
551
|
assert_eq!(response["statusCode"], 401);
|
|
374
552
|
|
|
375
553
|
// An Owner disabled after the cookie was signed: the signature is still valid.
|
|
376
|
-
let (status, _) = t
|
|
554
|
+
let (status, _) = t
|
|
555
|
+
.call(
|
|
556
|
+
"GET",
|
|
557
|
+
"/payments/connection",
|
|
558
|
+
json!({}),
|
|
559
|
+
Some(DISABLED_OWNER),
|
|
560
|
+
)
|
|
561
|
+
.await;
|
|
377
562
|
assert_eq!(status, 401);
|
|
378
|
-
let (status, _) = t
|
|
563
|
+
let (status, _) = t
|
|
564
|
+
.call(
|
|
565
|
+
"POST",
|
|
566
|
+
"/payments/connection/direct",
|
|
567
|
+
json!({"mode": "test"}),
|
|
568
|
+
Some(DISABLED_OWNER),
|
|
569
|
+
)
|
|
570
|
+
.await;
|
|
379
571
|
assert_eq!(status, 401);
|
|
380
572
|
}
|
|
381
573
|
|
|
@@ -384,112 +576,321 @@ async fn only_an_owner_may_connect_or_disconnect_and_only_the_operator_may_enabl
|
|
|
384
576
|
let t = tenant("hecks_pay_test_roles").await;
|
|
385
577
|
|
|
386
578
|
// A non-Owner admin: read refused, every owner action refused.
|
|
387
|
-
let (status, _) = t
|
|
579
|
+
let (status, _) = t
|
|
580
|
+
.call("GET", "/payments/connection", json!({}), Some(ADMIN))
|
|
581
|
+
.await;
|
|
388
582
|
assert_eq!(status, 403);
|
|
389
|
-
for path in [
|
|
390
|
-
|
|
391
|
-
|
|
583
|
+
for path in [
|
|
584
|
+
"/payments/connection/direct",
|
|
585
|
+
"/payments/connection/disconnect",
|
|
586
|
+
] {
|
|
587
|
+
let (status, body) = t
|
|
588
|
+
.call("POST", path, json!({"mode": "test"}), Some(ADMIN))
|
|
589
|
+
.await;
|
|
590
|
+
assert_eq!(
|
|
591
|
+
(status, body["error"].as_str()),
|
|
592
|
+
(403, Some("only an Owner can manage payments")),
|
|
593
|
+
"{path}"
|
|
594
|
+
);
|
|
392
595
|
}
|
|
393
596
|
|
|
394
597
|
t.connect().await;
|
|
395
598
|
assert_eq!(t.status().await, "connected");
|
|
396
599
|
|
|
397
600
|
// The Owner is not the operator: enable and disable are refused.
|
|
398
|
-
for path in [
|
|
601
|
+
for path in [
|
|
602
|
+
"/payments/connection/enable",
|
|
603
|
+
"/payments/connection/disable",
|
|
604
|
+
] {
|
|
399
605
|
let (status, _) = t.call("POST", path, json!({}), Some(OWNER)).await;
|
|
400
606
|
assert_eq!(status, 403, "{path}");
|
|
401
607
|
}
|
|
402
608
|
// The operator is not an Owner: cannot disconnect.
|
|
403
|
-
let (status, _) = t
|
|
609
|
+
let (status, _) = t
|
|
610
|
+
.call(
|
|
611
|
+
"POST",
|
|
612
|
+
"/payments/connection/disconnect",
|
|
613
|
+
json!({}),
|
|
614
|
+
Some(OPERATOR),
|
|
615
|
+
)
|
|
616
|
+
.await;
|
|
404
617
|
assert_eq!(status, 403);
|
|
405
|
-
assert_eq!(
|
|
618
|
+
assert_eq!(
|
|
619
|
+
t.status().await,
|
|
620
|
+
"connected",
|
|
621
|
+
"nothing above changed the connection"
|
|
622
|
+
);
|
|
406
623
|
|
|
407
624
|
// ...but the operator may read the page state, and enable.
|
|
408
|
-
let (status, body) = t
|
|
625
|
+
let (status, body) = t
|
|
626
|
+
.call("GET", "/payments/connection", json!({}), Some(OPERATOR))
|
|
627
|
+
.await;
|
|
409
628
|
assert_eq!(status, 200);
|
|
410
|
-
assert_eq!(
|
|
629
|
+
assert_eq!(
|
|
630
|
+
(body["can_manage"].clone(), body["can_enable"].clone()),
|
|
631
|
+
(json!(false), json!(true))
|
|
632
|
+
);
|
|
411
633
|
t.enable().await;
|
|
412
634
|
assert_eq!(t.status().await, "enabled");
|
|
413
635
|
}
|
|
414
636
|
|
|
637
|
+
#[tokio::test]
|
|
638
|
+
async fn a_site_that_lists_admin_as_a_payments_manager_lets_an_admin_manage() {
|
|
639
|
+
let mut t = tenant("hecks_pay_test_manager_roles").await;
|
|
640
|
+
t.platform.manager_roles = vec!["Owner".to_string(), "Admin".to_string()];
|
|
641
|
+
|
|
642
|
+
let (status, body) = t
|
|
643
|
+
.call("GET", "/payments/connection", json!({}), Some(ADMIN))
|
|
644
|
+
.await;
|
|
645
|
+
assert_eq!(
|
|
646
|
+
(status, body["can_manage"].clone()),
|
|
647
|
+
(200, json!(true)),
|
|
648
|
+
"{body}"
|
|
649
|
+
);
|
|
650
|
+
t.connect().await;
|
|
651
|
+
assert_eq!(t.status().await, "connected");
|
|
652
|
+
let (status, _) = t
|
|
653
|
+
.call(
|
|
654
|
+
"POST",
|
|
655
|
+
"/payments/connection/disconnect",
|
|
656
|
+
json!({}),
|
|
657
|
+
Some(ADMIN),
|
|
658
|
+
)
|
|
659
|
+
.await;
|
|
660
|
+
assert_eq!(status, 200);
|
|
661
|
+
assert_eq!(t.status().await, "disconnected");
|
|
662
|
+
|
|
663
|
+
// Enabling stays with the operator, and the Owner still manages.
|
|
664
|
+
let (status, body) = t
|
|
665
|
+
.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
666
|
+
.await;
|
|
667
|
+
assert_eq!(
|
|
668
|
+
(status, body["can_manage"].clone()),
|
|
669
|
+
(200, json!(true)),
|
|
670
|
+
"{body}"
|
|
671
|
+
);
|
|
672
|
+
// Someone signed out, or not listed, is refused with today's message.
|
|
673
|
+
let (status, _) = t.call("GET", "/payments/connection", json!({}), None).await;
|
|
674
|
+
assert_eq!(status, 401);
|
|
675
|
+
t.platform.manager_roles = vec!["Editor".to_string()];
|
|
676
|
+
let (status, body) = t
|
|
677
|
+
.call(
|
|
678
|
+
"POST",
|
|
679
|
+
"/payments/connection/direct",
|
|
680
|
+
json!({"mode": "test"}),
|
|
681
|
+
Some(OWNER),
|
|
682
|
+
)
|
|
683
|
+
.await;
|
|
684
|
+
assert_eq!(
|
|
685
|
+
(status, body["error"].as_str()),
|
|
686
|
+
(403, Some("only an Owner can manage payments"))
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
415
690
|
#[tokio::test]
|
|
416
691
|
async fn an_admin_granted_owner_passes_the_payments_gate_and_keeps_the_admin_gate() {
|
|
417
692
|
let t = tenant("hecks_pay_test_owner_bootstrap").await;
|
|
418
|
-
let members = LineageConfig {
|
|
419
|
-
|
|
693
|
+
let members = LineageConfig {
|
|
694
|
+
domain: "Acme".to_string(),
|
|
695
|
+
era: Some(1),
|
|
696
|
+
mirrored: None,
|
|
697
|
+
};
|
|
698
|
+
let cookies = |email: &str| {
|
|
699
|
+
HashMap::from([(
|
|
700
|
+
auth::account_cookie_name(),
|
|
701
|
+
auth::account_token(SESSION_SECRET, email, 60),
|
|
702
|
+
)])
|
|
703
|
+
};
|
|
420
704
|
|
|
421
705
|
// An Admin is not an Owner: the payments routes refuse them.
|
|
422
|
-
let (status, _) = t
|
|
706
|
+
let (status, _) = t
|
|
707
|
+
.call("GET", "/payments/connection", json!({}), Some(ADMIN))
|
|
708
|
+
.await;
|
|
423
709
|
assert_eq!(status, 403);
|
|
424
710
|
|
|
425
711
|
// An Admin grants Owner to a new person; that person passes the Payments
|
|
426
712
|
// Owner gate and is still admitted by the members admin gate.
|
|
427
713
|
let body = json!({"email": "boss@example.com", "name": "Boss", "role": "Owner"}).to_string();
|
|
428
|
-
let response = web::add_member_route(
|
|
714
|
+
let response = web::add_member_route(
|
|
715
|
+
&t.domain_ir,
|
|
716
|
+
&body,
|
|
717
|
+
&cookies(ADMIN),
|
|
718
|
+
SESSION_SECRET,
|
|
719
|
+
&t.client,
|
|
720
|
+
&t.wasm,
|
|
721
|
+
&members,
|
|
722
|
+
)
|
|
723
|
+
.await;
|
|
429
724
|
assert_eq!(response["statusCode"], 201, "{response:?}");
|
|
430
|
-
let (status, body) = t
|
|
431
|
-
|
|
432
|
-
|
|
725
|
+
let (status, body) = t
|
|
726
|
+
.call(
|
|
727
|
+
"GET",
|
|
728
|
+
"/payments/connection",
|
|
729
|
+
json!({}),
|
|
730
|
+
Some("boss@example.com"),
|
|
731
|
+
)
|
|
732
|
+
.await;
|
|
733
|
+
assert_eq!(
|
|
734
|
+
(status, body["can_manage"].clone()),
|
|
735
|
+
(200, json!(true)),
|
|
736
|
+
"{body}"
|
|
737
|
+
);
|
|
738
|
+
let response = web::add_member_route(
|
|
739
|
+
&t.domain_ir,
|
|
740
|
+
r#"{"email": "next@example.com", "name": "Next"}"#,
|
|
741
|
+
&cookies("boss@example.com"),
|
|
742
|
+
SESSION_SECRET,
|
|
743
|
+
&t.client,
|
|
744
|
+
&t.wasm,
|
|
745
|
+
&members,
|
|
746
|
+
)
|
|
747
|
+
.await;
|
|
433
748
|
assert_eq!(response["statusCode"], 201, "{response:?}");
|
|
434
749
|
|
|
435
750
|
// Granting Owner to an existing Admin turns them into an Owner too.
|
|
436
|
-
assert!(
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
751
|
+
assert!(
|
|
752
|
+
auth::grant_access(&t.client, &t.wasm, &members, &t.domain_ir, ADMIN, "Owner")
|
|
753
|
+
.await
|
|
754
|
+
.unwrap()
|
|
755
|
+
);
|
|
756
|
+
let (status, body) = t
|
|
757
|
+
.call("GET", "/payments/connection", json!({}), Some(ADMIN))
|
|
758
|
+
.await;
|
|
759
|
+
assert_eq!(
|
|
760
|
+
(status, body["can_manage"].clone()),
|
|
761
|
+
(200, json!(true)),
|
|
762
|
+
"{body}"
|
|
763
|
+
);
|
|
764
|
+
assert!(auth::caller_is_admin(&t.client, &t.domain_ir, ADMIN)
|
|
765
|
+
.await
|
|
766
|
+
.unwrap());
|
|
440
767
|
}
|
|
441
768
|
|
|
442
769
|
#[tokio::test]
|
|
443
770
|
async fn the_status_route_reports_not_connected_and_which_modes_have_keys() {
|
|
444
771
|
let t = tenant("hecks_pay_test_status_empty").await;
|
|
445
|
-
let (status, body) = t
|
|
772
|
+
let (status, body) = t
|
|
773
|
+
.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
774
|
+
.await;
|
|
446
775
|
assert_eq!(status, 200);
|
|
447
776
|
assert_eq!(body["status"], "not_connected");
|
|
448
777
|
assert_eq!(body["processor"], Value::Null);
|
|
449
|
-
assert_eq!(
|
|
450
|
-
|
|
778
|
+
assert_eq!(
|
|
779
|
+
(
|
|
780
|
+
body["direct_modes"].clone(),
|
|
781
|
+
body["direct"].clone(),
|
|
782
|
+
body["adapters"].clone()
|
|
783
|
+
),
|
|
784
|
+
(json!(["test"]), json!(false), json!([]))
|
|
785
|
+
);
|
|
786
|
+
assert_eq!(
|
|
787
|
+
(body["can_manage"].clone(), body["can_enable"].clone()),
|
|
788
|
+
(json!(true), json!(false))
|
|
789
|
+
);
|
|
451
790
|
|
|
452
791
|
// With no keys at all, no mode is offered.
|
|
453
792
|
let mut bare = tenant("hecks_pay_test_status_bare").await;
|
|
454
793
|
bare.platform.direct_test_key = String::new();
|
|
455
|
-
let (_, body) = bare
|
|
794
|
+
let (_, body) = bare
|
|
795
|
+
.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
796
|
+
.await;
|
|
456
797
|
assert_eq!(body["direct_modes"], json!([]));
|
|
457
798
|
}
|
|
458
799
|
|
|
459
800
|
#[tokio::test]
|
|
460
801
|
async fn enable_needs_a_connection_and_follows_the_lifecycle() {
|
|
461
802
|
let t = tenant("hecks_pay_test_enable").await;
|
|
462
|
-
let (status, body) = t
|
|
463
|
-
|
|
803
|
+
let (status, body) = t
|
|
804
|
+
.call(
|
|
805
|
+
"POST",
|
|
806
|
+
"/payments/connection/enable",
|
|
807
|
+
json!({}),
|
|
808
|
+
Some(OPERATOR),
|
|
809
|
+
)
|
|
810
|
+
.await;
|
|
811
|
+
assert_eq!(
|
|
812
|
+
(status, body["error"].as_str()),
|
|
813
|
+
(409, Some("connect an account first"))
|
|
814
|
+
);
|
|
464
815
|
|
|
465
816
|
t.connect().await;
|
|
466
|
-
let (status, body) = t
|
|
467
|
-
|
|
817
|
+
let (status, body) = t
|
|
818
|
+
.call(
|
|
819
|
+
"POST",
|
|
820
|
+
"/payments/connection/disable",
|
|
821
|
+
json!({}),
|
|
822
|
+
Some(OPERATOR),
|
|
823
|
+
)
|
|
824
|
+
.await;
|
|
825
|
+
assert_eq!(
|
|
826
|
+
status, 422,
|
|
827
|
+
"disable is refused unless payments are enabled: {body}"
|
|
828
|
+
);
|
|
468
829
|
|
|
469
830
|
t.enable().await;
|
|
470
|
-
let (status, body) = t
|
|
831
|
+
let (status, body) = t
|
|
832
|
+
.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
833
|
+
.await;
|
|
471
834
|
assert_eq!((status, body["status"].as_str()), (200, Some("enabled")));
|
|
472
|
-
let (status, _) = t
|
|
835
|
+
let (status, _) = t
|
|
836
|
+
.call(
|
|
837
|
+
"POST",
|
|
838
|
+
"/payments/connection/enable",
|
|
839
|
+
json!({}),
|
|
840
|
+
Some(OPERATOR),
|
|
841
|
+
)
|
|
842
|
+
.await;
|
|
473
843
|
assert_eq!(status, 422, "enable is refused when already enabled");
|
|
474
844
|
|
|
475
|
-
let (status, body) = t
|
|
845
|
+
let (status, body) = t
|
|
846
|
+
.call(
|
|
847
|
+
"POST",
|
|
848
|
+
"/payments/connection/disable",
|
|
849
|
+
json!({}),
|
|
850
|
+
Some(OPERATOR),
|
|
851
|
+
)
|
|
852
|
+
.await;
|
|
476
853
|
assert_eq!((status, body["status"].as_str()), (200, Some("connected")));
|
|
477
854
|
}
|
|
478
855
|
|
|
479
856
|
#[tokio::test]
|
|
480
857
|
async fn disconnecting_a_not_yet_enabled_account_unlinks_it_and_it_can_be_reconnected() {
|
|
481
858
|
let t = tenant("hecks_pay_test_disconnect").await;
|
|
482
|
-
let (status, body) = t
|
|
483
|
-
|
|
859
|
+
let (status, body) = t
|
|
860
|
+
.call(
|
|
861
|
+
"POST",
|
|
862
|
+
"/payments/connection/disconnect",
|
|
863
|
+
json!({}),
|
|
864
|
+
Some(OWNER),
|
|
865
|
+
)
|
|
866
|
+
.await;
|
|
867
|
+
assert_eq!(
|
|
868
|
+
(status, body["error"].as_str()),
|
|
869
|
+
(409, Some("nothing to disconnect"))
|
|
870
|
+
);
|
|
484
871
|
|
|
485
872
|
t.connect().await;
|
|
486
|
-
let (status, body) = t
|
|
873
|
+
let (status, body) = t
|
|
874
|
+
.call(
|
|
875
|
+
"POST",
|
|
876
|
+
"/payments/connection/disconnect",
|
|
877
|
+
json!({}),
|
|
878
|
+
Some(OWNER),
|
|
879
|
+
)
|
|
880
|
+
.await;
|
|
487
881
|
assert_eq!(status, 200, "{body}");
|
|
488
882
|
assert_eq!(body["status"], "disconnected");
|
|
489
|
-
assert!(
|
|
883
|
+
assert!(
|
|
884
|
+
t.fake.requests().iter().all(|r| r.method != "DELETE"),
|
|
885
|
+
"there is no webhook to remove for keys from the environment"
|
|
886
|
+
);
|
|
490
887
|
|
|
491
888
|
t.connect().await;
|
|
492
|
-
assert_eq!(
|
|
889
|
+
assert_eq!(
|
|
890
|
+
t.status().await,
|
|
891
|
+
"connected",
|
|
892
|
+
"a disconnected link reconnects"
|
|
893
|
+
);
|
|
493
894
|
}
|
|
494
895
|
|
|
495
896
|
#[tokio::test]
|
|
@@ -498,16 +899,34 @@ async fn disconnecting_while_payments_are_enabled_pauses_them() {
|
|
|
498
899
|
t.connect().await;
|
|
499
900
|
t.enable().await;
|
|
500
901
|
|
|
501
|
-
let (status, body) = t
|
|
902
|
+
let (status, body) = t
|
|
903
|
+
.call(
|
|
904
|
+
"POST",
|
|
905
|
+
"/payments/connection/disconnect",
|
|
906
|
+
json!({}),
|
|
907
|
+
Some(OWNER),
|
|
908
|
+
)
|
|
909
|
+
.await;
|
|
502
910
|
assert_eq!(status, 200, "{body}");
|
|
503
911
|
assert_eq!(body["status"], "paused");
|
|
504
912
|
|
|
505
913
|
// Paused is not disconnected: it cannot be disconnected again, and a new
|
|
506
914
|
// connection resumes it instead of starting over.
|
|
507
|
-
let (status, _) = t
|
|
915
|
+
let (status, _) = t
|
|
916
|
+
.call(
|
|
917
|
+
"POST",
|
|
918
|
+
"/payments/connection/disconnect",
|
|
919
|
+
json!({}),
|
|
920
|
+
Some(OWNER),
|
|
921
|
+
)
|
|
922
|
+
.await;
|
|
508
923
|
assert_eq!(status, 409);
|
|
509
924
|
t.connect().await;
|
|
510
|
-
assert_eq!(
|
|
925
|
+
assert_eq!(
|
|
926
|
+
t.status().await,
|
|
927
|
+
"enabled",
|
|
928
|
+
"reconnecting a paused connection resumes it"
|
|
929
|
+
);
|
|
511
930
|
}
|
|
512
931
|
|
|
513
932
|
#[tokio::test]
|
|
@@ -518,17 +937,35 @@ async fn checkout_stays_on_the_mock_until_payments_are_enabled() {
|
|
|
518
937
|
// No connection at all.
|
|
519
938
|
let (status, body) = t.register("mock-event").await;
|
|
520
939
|
assert_eq!(status, 200, "{body}");
|
|
521
|
-
assert!(
|
|
940
|
+
assert!(
|
|
941
|
+
body["checkout_url"]
|
|
942
|
+
.as_str()
|
|
943
|
+
.unwrap()
|
|
944
|
+
.starts_with("https://site.example/pay/"),
|
|
945
|
+
"{body}"
|
|
946
|
+
);
|
|
522
947
|
|
|
523
948
|
// Connected but not enabled: still the mock.
|
|
524
949
|
t.connect().await;
|
|
525
950
|
let (status, body) = t.register("mock-event").await;
|
|
526
951
|
assert_eq!(status, 200, "{body}");
|
|
527
|
-
assert!(
|
|
528
|
-
|
|
952
|
+
assert!(
|
|
953
|
+
body["checkout_url"]
|
|
954
|
+
.as_str()
|
|
955
|
+
.unwrap()
|
|
956
|
+
.starts_with("https://site.example/pay/"),
|
|
957
|
+
"{body}"
|
|
958
|
+
);
|
|
959
|
+
assert!(
|
|
960
|
+
t.fake.requests_to("/v1/checkout/sessions").is_empty(),
|
|
961
|
+
"the mock never calls Stripe"
|
|
962
|
+
);
|
|
529
963
|
|
|
530
964
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
531
|
-
let processors: Vec<String> = instances_for(&read, "Payments::Payment#")
|
|
965
|
+
let processors: Vec<String> = instances_for(&read, "Payments::Payment#")
|
|
966
|
+
.iter()
|
|
967
|
+
.map(|(_, p)| p["processor"]["value"].as_str().unwrap().to_string())
|
|
968
|
+
.collect();
|
|
532
969
|
assert_eq!(processors, vec!["mock_stripe", "mock_stripe"]);
|
|
533
970
|
}
|
|
534
971
|
|
|
@@ -543,7 +980,11 @@ async fn an_enabled_connection_charges_on_the_business_own_account() {
|
|
|
543
980
|
assert_eq!(status, 200, "{body}");
|
|
544
981
|
// The guest pays in a form embedded in the site: what the browser needs to
|
|
545
982
|
// mount it, and no hosted page to send them to.
|
|
546
|
-
assert_eq!(
|
|
983
|
+
assert_eq!(
|
|
984
|
+
body.get("checkout_url"),
|
|
985
|
+
None,
|
|
986
|
+
"nothing leaves the site: {body}"
|
|
987
|
+
);
|
|
547
988
|
assert_eq!(
|
|
548
989
|
body["embedded_checkout"],
|
|
549
990
|
json!({
|
|
@@ -556,17 +997,54 @@ async fn an_enabled_connection_charges_on_the_business_own_account() {
|
|
|
556
997
|
|
|
557
998
|
let sent = t.fake.requests_to("/v1/checkout/sessions");
|
|
558
999
|
assert_eq!(sent.len(), 1);
|
|
559
|
-
assert_eq!(
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1000
|
+
assert_eq!(
|
|
1001
|
+
sent[0].header("stripe-account"),
|
|
1002
|
+
None,
|
|
1003
|
+
"the business's own account is the default account"
|
|
1004
|
+
);
|
|
1005
|
+
assert_eq!(
|
|
1006
|
+
sent[0].header("authorization"),
|
|
1007
|
+
Some(format!("Bearer {DIRECT_KEY}").as_str()),
|
|
1008
|
+
"authenticated with the account's own key"
|
|
1009
|
+
);
|
|
1010
|
+
assert_eq!(
|
|
1011
|
+
sent[0].header("stripe-version"),
|
|
1012
|
+
Some("2026-04-22.dahlia"),
|
|
1013
|
+
"pinned, not the account's default"
|
|
1014
|
+
);
|
|
1015
|
+
assert!(
|
|
1016
|
+
sent[0]
|
|
1017
|
+
.body
|
|
1018
|
+
.contains(&format!("metadata%5Bregistration_id%5D={reference}")),
|
|
1019
|
+
"{}",
|
|
1020
|
+
sent[0].body
|
|
1021
|
+
);
|
|
1022
|
+
assert!(
|
|
1023
|
+
sent[0].body.contains("unit_amount%5D=4200"),
|
|
1024
|
+
"{}",
|
|
1025
|
+
sent[0].body
|
|
1026
|
+
);
|
|
1027
|
+
assert!(
|
|
1028
|
+
sent[0].body.contains("ui_mode=embedded_page"),
|
|
1029
|
+
"an embedded session, not a hosted one: {}",
|
|
1030
|
+
sent[0].body
|
|
1031
|
+
);
|
|
1032
|
+
assert!(
|
|
1033
|
+
sent[0].body.contains("redirect_on_completion=never"),
|
|
1034
|
+
"{}",
|
|
1035
|
+
sent[0].body
|
|
1036
|
+
);
|
|
1037
|
+
assert!(
|
|
1038
|
+
!sent[0].body.contains("success_url") && !sent[0].body.contains("cancel_url"),
|
|
1039
|
+
"no return pages exist: {}",
|
|
1040
|
+
sent[0].body
|
|
1041
|
+
);
|
|
567
1042
|
|
|
568
1043
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
569
|
-
assert_eq!(
|
|
1044
|
+
assert_eq!(
|
|
1045
|
+
read["instances"][format!("Payments::Payment#{reference}")]["processor"]["value"],
|
|
1046
|
+
"stripe"
|
|
1047
|
+
);
|
|
570
1048
|
}
|
|
571
1049
|
|
|
572
1050
|
#[tokio::test]
|
|
@@ -578,16 +1056,32 @@ async fn a_stripe_refusal_answers_502_and_leaves_no_payment_or_registration_behi
|
|
|
578
1056
|
t.fake.state.refuse_sessions.store(true, Ordering::SeqCst);
|
|
579
1057
|
|
|
580
1058
|
let (status, body) = t.register("refused-event").await;
|
|
581
|
-
assert_eq!(
|
|
1059
|
+
assert_eq!(
|
|
1060
|
+
(status, body["error"].as_str()),
|
|
1061
|
+
(502, Some("payments are temporarily unavailable"))
|
|
1062
|
+
);
|
|
582
1063
|
assert_eq!(body.get("checkout_url"), None);
|
|
583
1064
|
assert_eq!(body.get("embedded_checkout"), None);
|
|
584
|
-
assert!(
|
|
585
|
-
|
|
1065
|
+
assert!(
|
|
1066
|
+
!body.to_string().contains("no such price"),
|
|
1067
|
+
"Stripe's own wording stays in the log: {body}"
|
|
1068
|
+
);
|
|
1069
|
+
assert_eq!(
|
|
1070
|
+
t.fake.requests_to("/v1/checkout/sessions").len(),
|
|
1071
|
+
1,
|
|
1072
|
+
"the session was attempted"
|
|
1073
|
+
);
|
|
586
1074
|
|
|
587
1075
|
// The session is opened first, so nothing was recorded for the failed try.
|
|
588
1076
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
589
|
-
assert!(
|
|
590
|
-
|
|
1077
|
+
assert!(
|
|
1078
|
+
instances_for(&read, "Payments::Payment#").is_empty(),
|
|
1079
|
+
"no orphaned Payment"
|
|
1080
|
+
);
|
|
1081
|
+
assert!(
|
|
1082
|
+
instances_for(&read, "CheckoutFixture::Registration#").is_empty(),
|
|
1083
|
+
"no pending Registration"
|
|
1084
|
+
);
|
|
591
1085
|
|
|
592
1086
|
// Once Stripe recovers a retry registers normally.
|
|
593
1087
|
t.fake.state.refuse_sessions.store(false, Ordering::SeqCst);
|
|
@@ -595,11 +1089,17 @@ async fn a_stripe_refusal_answers_502_and_leaves_no_payment_or_registration_behi
|
|
|
595
1089
|
assert_eq!(status, 200, "{body}");
|
|
596
1090
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
597
1091
|
assert_eq!(instances_for(&read, "Payments::Payment#").len(), 1);
|
|
598
|
-
assert_eq!(
|
|
1092
|
+
assert_eq!(
|
|
1093
|
+
instances_for(&read, "CheckoutFixture::Registration#").len(),
|
|
1094
|
+
1
|
|
1095
|
+
);
|
|
599
1096
|
}
|
|
600
1097
|
|
|
601
1098
|
fn unix_secs() -> i64 {
|
|
602
|
-
std::time::SystemTime::now()
|
|
1099
|
+
std::time::SystemTime::now()
|
|
1100
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
1101
|
+
.unwrap()
|
|
1102
|
+
.as_secs() as i64
|
|
603
1103
|
}
|
|
604
1104
|
|
|
605
1105
|
#[tokio::test]
|
|
@@ -614,12 +1114,27 @@ async fn a_full_event_answers_409_and_writes_nothing_and_calls_no_stripe() {
|
|
|
614
1114
|
let sessions = t.fake.requests_to("/v1/checkout/sessions").len();
|
|
615
1115
|
|
|
616
1116
|
let (status, body) = t.register("one-seat").await;
|
|
617
|
-
assert_eq!(
|
|
1117
|
+
assert_eq!(
|
|
1118
|
+
(status, body["error"].as_str()),
|
|
1119
|
+
(409, Some("this event is full"))
|
|
1120
|
+
);
|
|
618
1121
|
assert_eq!(body.get("embedded_checkout"), None);
|
|
619
|
-
assert_eq!(
|
|
1122
|
+
assert_eq!(
|
|
1123
|
+
t.fake.requests_to("/v1/checkout/sessions").len(),
|
|
1124
|
+
sessions,
|
|
1125
|
+
"no session is opened for a full event"
|
|
1126
|
+
);
|
|
620
1127
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
621
|
-
assert_eq!(
|
|
622
|
-
|
|
1128
|
+
assert_eq!(
|
|
1129
|
+
instances_for(&read, "Payments::Payment#").len(),
|
|
1130
|
+
1,
|
|
1131
|
+
"no second Payment"
|
|
1132
|
+
);
|
|
1133
|
+
assert_eq!(
|
|
1134
|
+
instances_for(&read, "CheckoutFixture::Registration#").len(),
|
|
1135
|
+
1,
|
|
1136
|
+
"no second Registration"
|
|
1137
|
+
);
|
|
623
1138
|
}
|
|
624
1139
|
|
|
625
1140
|
#[tokio::test]
|
|
@@ -631,7 +1146,10 @@ async fn the_last_seat_goes_through_the_mock_walkthrough_and_the_next_attempt_is
|
|
|
631
1146
|
for _ in 0..2 {
|
|
632
1147
|
let (status, body) = t.register("two-seats").await;
|
|
633
1148
|
assert_eq!(status, 200, "{body}");
|
|
634
|
-
assert!(
|
|
1149
|
+
assert!(
|
|
1150
|
+
body["checkout_url"].is_string(),
|
|
1151
|
+
"the mock plan still answers a checkout_url: {body}"
|
|
1152
|
+
);
|
|
635
1153
|
references.push(body["registration_id"].as_str().unwrap().to_string());
|
|
636
1154
|
}
|
|
637
1155
|
for reference in &references {
|
|
@@ -640,8 +1158,14 @@ async fn the_last_seat_goes_through_the_mock_walkthrough_and_the_next_attempt_is
|
|
|
640
1158
|
}
|
|
641
1159
|
|
|
642
1160
|
let (status, body) = t.register("two-seats").await;
|
|
643
|
-
assert_eq!(
|
|
644
|
-
|
|
1161
|
+
assert_eq!(
|
|
1162
|
+
(status, body["error"].as_str()),
|
|
1163
|
+
(409, Some("this event is full"))
|
|
1164
|
+
);
|
|
1165
|
+
assert!(
|
|
1166
|
+
t.fake.requests().is_empty(),
|
|
1167
|
+
"the mock walkthrough never calls Stripe"
|
|
1168
|
+
);
|
|
645
1169
|
}
|
|
646
1170
|
|
|
647
1171
|
#[tokio::test]
|
|
@@ -662,7 +1186,10 @@ async fn an_unpaid_checkout_holds_the_seat_until_its_session_expires() {
|
|
|
662
1186
|
assert_eq!(t.webhook(expired(&reference)).await, 200);
|
|
663
1187
|
assert_eq!(t.payment_status(&reference).await, "failed");
|
|
664
1188
|
let (status, body) = t.register("held").await;
|
|
665
|
-
assert_eq!(
|
|
1189
|
+
assert_eq!(
|
|
1190
|
+
status, 200,
|
|
1191
|
+
"an expired checkout gives the seat back: {body}"
|
|
1192
|
+
);
|
|
666
1193
|
}
|
|
667
1194
|
|
|
668
1195
|
#[tokio::test]
|
|
@@ -672,12 +1199,21 @@ async fn a_declined_payment_frees_the_seat_and_a_paid_one_keeps_it() {
|
|
|
672
1199
|
|
|
673
1200
|
let (_, body) = t.register("declined").await;
|
|
674
1201
|
let first = body["registration_id"].as_str().unwrap().to_string();
|
|
675
|
-
let response = web::registration_complete_route(
|
|
1202
|
+
let response = web::registration_complete_route(
|
|
1203
|
+
&first,
|
|
1204
|
+
r#"{"outcome":"failed"}"#,
|
|
1205
|
+
Host::new(&t.client, &t.wasm, &t.config, &NeverInvoker),
|
|
1206
|
+
&crate::commerce_ir::fixture_payments(),
|
|
1207
|
+
)
|
|
1208
|
+
.await;
|
|
676
1209
|
assert_eq!(response["statusCode"].as_u64(), Some(200));
|
|
677
1210
|
assert_eq!(t.payment_status(&first).await, "failed");
|
|
678
1211
|
|
|
679
1212
|
let (status, body) = t.register("declined").await;
|
|
680
|
-
assert_eq!(
|
|
1213
|
+
assert_eq!(
|
|
1214
|
+
status, 200,
|
|
1215
|
+
"a declined payment gives the seat back: {body}"
|
|
1216
|
+
);
|
|
681
1217
|
let second = body["registration_id"].as_str().unwrap().to_string();
|
|
682
1218
|
assert_eq!(t.settle(&second).await, 200);
|
|
683
1219
|
|
|
@@ -689,12 +1225,22 @@ async fn a_declined_payment_frees_the_seat_and_a_paid_one_keeps_it() {
|
|
|
689
1225
|
// internal dispatch does; the outcome is the kernel's own accept/refuse.
|
|
690
1226
|
async fn registration_command(t: &Tenant, verb: &str, reference: &str) -> dispatch::Outcome {
|
|
691
1227
|
let verb = format!("CheckoutFixture::Registration.{verb}");
|
|
692
|
-
dispatch::handle(
|
|
1228
|
+
dispatch::handle(
|
|
1229
|
+
Host::new(&t.client, &t.wasm, &t.config, &NeverInvoker),
|
|
1230
|
+
&verb,
|
|
1231
|
+
json!({"id": reference}),
|
|
1232
|
+
None,
|
|
1233
|
+
)
|
|
1234
|
+
.await
|
|
1235
|
+
.unwrap()
|
|
693
1236
|
}
|
|
694
1237
|
|
|
695
1238
|
async fn registration_status(t: &Tenant, reference: &str) -> String {
|
|
696
1239
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
697
|
-
read["instances"][format!("CheckoutFixture::Registration#{reference}")]["status"]
|
|
1240
|
+
read["instances"][format!("CheckoutFixture::Registration#{reference}")]["status"]
|
|
1241
|
+
.as_str()
|
|
1242
|
+
.unwrap_or("none")
|
|
1243
|
+
.to_string()
|
|
698
1244
|
}
|
|
699
1245
|
|
|
700
1246
|
#[tokio::test]
|
|
@@ -705,19 +1251,38 @@ async fn archiving_a_paid_registration_frees_its_seat_and_restoring_it_takes_the
|
|
|
705
1251
|
let (_, body) = t.register("archive-me").await;
|
|
706
1252
|
let reference = body["registration_id"].as_str().unwrap().to_string();
|
|
707
1253
|
assert_eq!(t.settle(&reference).await, 200);
|
|
708
|
-
assert_eq!(
|
|
1254
|
+
assert_eq!(
|
|
1255
|
+
registration_status(&t, &reference).await,
|
|
1256
|
+
"active",
|
|
1257
|
+
"a new Registration starts active"
|
|
1258
|
+
);
|
|
709
1259
|
let (status, _) = t.register("archive-me").await;
|
|
710
1260
|
assert_eq!(status, 409, "the paid registration holds the only seat");
|
|
711
1261
|
|
|
712
1262
|
let archived = registration_command(&t, "Archive", &reference).await;
|
|
713
1263
|
assert!(archived.accepted, "{:?}", archived.result);
|
|
714
1264
|
assert_eq!(registration_status(&t, &reference).await, "archived");
|
|
715
|
-
assert_eq!(
|
|
1265
|
+
assert_eq!(
|
|
1266
|
+
t.payment_status(&reference).await,
|
|
1267
|
+
"succeeded",
|
|
1268
|
+
"archiving never touches the Payment"
|
|
1269
|
+
);
|
|
716
1270
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
717
|
-
assert_eq!(
|
|
1271
|
+
assert_eq!(
|
|
1272
|
+
web::seats_left(
|
|
1273
|
+
&read,
|
|
1274
|
+
"CheckoutFixture",
|
|
1275
|
+
&crate::commerce_ir::fixture_payments(),
|
|
1276
|
+
"archive-me"
|
|
1277
|
+
),
|
|
1278
|
+
Some(1)
|
|
1279
|
+
);
|
|
718
1280
|
|
|
719
1281
|
let (status, body) = t.register("archive-me").await;
|
|
720
|
-
assert_eq!(
|
|
1282
|
+
assert_eq!(
|
|
1283
|
+
status, 200,
|
|
1284
|
+
"the archived registration's seat is free again: {body}"
|
|
1285
|
+
);
|
|
721
1286
|
let second = body["registration_id"].as_str().unwrap().to_string();
|
|
722
1287
|
assert_eq!(t.settle(&second).await, 200);
|
|
723
1288
|
let (status, _) = t.register("archive-me").await;
|
|
@@ -732,7 +1297,15 @@ async fn archiving_a_paid_registration_frees_its_seat_and_restoring_it_takes_the
|
|
|
732
1297
|
2,
|
|
733
1298
|
"a restored registration counts again, even past capacity: the site refuses a restore with no seat"
|
|
734
1299
|
);
|
|
735
|
-
assert_eq!(
|
|
1300
|
+
assert_eq!(
|
|
1301
|
+
web::seats_left(
|
|
1302
|
+
&read,
|
|
1303
|
+
"CheckoutFixture",
|
|
1304
|
+
&crate::commerce_ir::fixture_payments(),
|
|
1305
|
+
"archive-me"
|
|
1306
|
+
),
|
|
1307
|
+
Some(0)
|
|
1308
|
+
);
|
|
736
1309
|
}
|
|
737
1310
|
|
|
738
1311
|
#[tokio::test]
|
|
@@ -746,8 +1319,13 @@ async fn cancelling_a_paid_registration_reaches_a_state_the_fixture_declares_as_
|
|
|
746
1319
|
let cancelled = registration_command(&t, "Cancel", &reference).await;
|
|
747
1320
|
assert!(cancelled.accepted, "{:?}", cancelled.result);
|
|
748
1321
|
let state = registration_status(&t, &reference).await;
|
|
749
|
-
let declared = crate::commerce_ir::registrations_provider(&crate::commerce_ir::fixture_ir())
|
|
750
|
-
|
|
1322
|
+
let declared = crate::commerce_ir::registrations_provider(&crate::commerce_ir::fixture_ir())
|
|
1323
|
+
.expect("registrations");
|
|
1324
|
+
assert!(
|
|
1325
|
+
declared.frees_seat.contains(&state),
|
|
1326
|
+
"{state} is one of {:?}",
|
|
1327
|
+
declared.frees_seat
|
|
1328
|
+
);
|
|
751
1329
|
}
|
|
752
1330
|
|
|
753
1331
|
#[tokio::test]
|
|
@@ -757,9 +1335,23 @@ async fn only_an_active_registration_can_be_archived_and_only_an_archived_one_re
|
|
|
757
1335
|
let (_, body) = t.register("guards").await;
|
|
758
1336
|
let reference = body["registration_id"].as_str().unwrap().to_string();
|
|
759
1337
|
|
|
760
|
-
assert!(
|
|
761
|
-
|
|
762
|
-
|
|
1338
|
+
assert!(
|
|
1339
|
+
!registration_command(&t, "Restore", &reference)
|
|
1340
|
+
.await
|
|
1341
|
+
.accepted,
|
|
1342
|
+
"an active registration cannot be restored"
|
|
1343
|
+
);
|
|
1344
|
+
assert!(
|
|
1345
|
+
registration_command(&t, "Archive", &reference)
|
|
1346
|
+
.await
|
|
1347
|
+
.accepted
|
|
1348
|
+
);
|
|
1349
|
+
assert!(
|
|
1350
|
+
!registration_command(&t, "Archive", &reference)
|
|
1351
|
+
.await
|
|
1352
|
+
.accepted,
|
|
1353
|
+
"an archived registration cannot be archived again"
|
|
1354
|
+
);
|
|
763
1355
|
assert_eq!(registration_status(&t, &reference).await, "archived");
|
|
764
1356
|
}
|
|
765
1357
|
|
|
@@ -777,10 +1369,24 @@ async fn a_stripe_session_expires_about_thirty_one_minutes_after_it_is_created()
|
|
|
777
1369
|
|
|
778
1370
|
let sent = t.fake.requests_to("/v1/checkout/sessions");
|
|
779
1371
|
assert_eq!(sent.len(), 1);
|
|
780
|
-
let expires_at: i64 = sent[0]
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1372
|
+
let expires_at: i64 = sent[0]
|
|
1373
|
+
.body
|
|
1374
|
+
.split('&')
|
|
1375
|
+
.find_map(|pair| pair.strip_prefix("expires_at="))
|
|
1376
|
+
.expect("an expires_at form field")
|
|
1377
|
+
.parse()
|
|
1378
|
+
.unwrap();
|
|
1379
|
+
let hold = crate::checkout::effective_hold_seconds(
|
|
1380
|
+
crate::commerce_ir::checkout_windows_binding().session_hold,
|
|
1381
|
+
);
|
|
1382
|
+
assert!(
|
|
1383
|
+
(before + hold..=after + hold).contains(&expires_at),
|
|
1384
|
+
"expires_at {expires_at} is not {hold}s after creation ({before}..{after})"
|
|
1385
|
+
);
|
|
1386
|
+
assert!(
|
|
1387
|
+
(30 * 60..=35 * 60).contains(&hold),
|
|
1388
|
+
"Stripe's minimum is 30 minutes, and the hold should stay close to it"
|
|
1389
|
+
);
|
|
784
1390
|
}
|
|
785
1391
|
|
|
786
1392
|
#[tokio::test]
|
|
@@ -794,14 +1400,23 @@ async fn an_enabled_connection_without_a_publishable_key_is_paused_before_anythi
|
|
|
794
1400
|
// answer must be neither the mock nor a hosted page.
|
|
795
1401
|
t.platform.direct_test_publishable_key = String::new();
|
|
796
1402
|
let (status, body) = t.register("nopk-event").await;
|
|
797
|
-
assert_eq!(
|
|
1403
|
+
assert_eq!(
|
|
1404
|
+
(status, body["error"].as_str()),
|
|
1405
|
+
(503, Some("payments are temporarily unavailable"))
|
|
1406
|
+
);
|
|
798
1407
|
assert_eq!(body.get("checkout_url"), None);
|
|
799
1408
|
assert_eq!(body.get("embedded_checkout"), None);
|
|
800
1409
|
|
|
801
1410
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
802
|
-
assert!(
|
|
1411
|
+
assert!(
|
|
1412
|
+
instances_for(&read, "Payments::Payment#").is_empty(),
|
|
1413
|
+
"no orphaned Payment"
|
|
1414
|
+
);
|
|
803
1415
|
assert!(instances_for(&read, "CheckoutFixture::Registration#").is_empty());
|
|
804
|
-
assert!(
|
|
1416
|
+
assert!(
|
|
1417
|
+
t.fake.requests_to("/v1/checkout/sessions").is_empty(),
|
|
1418
|
+
"Stripe is never called"
|
|
1419
|
+
);
|
|
805
1420
|
}
|
|
806
1421
|
|
|
807
1422
|
#[tokio::test]
|
|
@@ -810,12 +1425,22 @@ async fn a_paused_connection_refuses_registrations_with_a_503_and_never_falls_ba
|
|
|
810
1425
|
t.schedule_event("paused-event").await;
|
|
811
1426
|
t.connect().await;
|
|
812
1427
|
t.enable().await;
|
|
813
|
-
let (status, _) = t
|
|
1428
|
+
let (status, _) = t
|
|
1429
|
+
.call(
|
|
1430
|
+
"POST",
|
|
1431
|
+
"/payments/connection/disconnect",
|
|
1432
|
+
json!({}),
|
|
1433
|
+
Some(OWNER),
|
|
1434
|
+
)
|
|
1435
|
+
.await;
|
|
814
1436
|
assert_eq!(status, 200);
|
|
815
1437
|
assert_eq!(t.status().await, "paused");
|
|
816
1438
|
|
|
817
1439
|
let (status, body) = t.register("paused-event").await;
|
|
818
|
-
assert_eq!(
|
|
1440
|
+
assert_eq!(
|
|
1441
|
+
(status, body["error"].as_str()),
|
|
1442
|
+
(503, Some("payments are temporarily unavailable"))
|
|
1443
|
+
);
|
|
819
1444
|
assert_eq!(body.get("checkout_url"), None);
|
|
820
1445
|
|
|
821
1446
|
// Nothing was written: no Payment, no Registration, no Stripe call.
|
|
@@ -834,7 +1459,10 @@ async fn an_enabled_connection_whose_secret_key_is_missing_is_paused_not_mock()
|
|
|
834
1459
|
|
|
835
1460
|
t.platform.direct_test_key = String::new();
|
|
836
1461
|
let (status, body) = t.register("nokey-event").await;
|
|
837
|
-
assert_eq!(
|
|
1462
|
+
assert_eq!(
|
|
1463
|
+
(status, body["error"].as_str()),
|
|
1464
|
+
(503, Some("payments are temporarily unavailable"))
|
|
1465
|
+
);
|
|
838
1466
|
}
|
|
839
1467
|
|
|
840
1468
|
#[tokio::test]
|
|
@@ -849,7 +1477,11 @@ async fn only_a_mock_payment_can_be_settled_by_hand() {
|
|
|
849
1477
|
let (_, real) = t.register("hand-event").await;
|
|
850
1478
|
let real_ref = real["registration_id"].as_str().unwrap().to_string();
|
|
851
1479
|
|
|
852
|
-
assert_eq!(
|
|
1480
|
+
assert_eq!(
|
|
1481
|
+
t.settle(&real_ref).await,
|
|
1482
|
+
403,
|
|
1483
|
+
"a real processor's payment is settled only by its webhook"
|
|
1484
|
+
);
|
|
853
1485
|
assert_eq!(t.payment_status(&real_ref).await, "pending");
|
|
854
1486
|
assert_eq!(t.settle(&mock_ref).await, 200);
|
|
855
1487
|
assert_eq!(t.payment_status(&mock_ref).await, "succeeded");
|
|
@@ -889,16 +1521,34 @@ async fn an_event_verified_only_by_the_public_mock_secret_is_refused_for_a_real_
|
|
|
889
1521
|
t.platform.webhook_secret = None;
|
|
890
1522
|
let event = completed(&reference);
|
|
891
1523
|
let payload = event.to_string();
|
|
892
|
-
let now = std::time::SystemTime::now()
|
|
1524
|
+
let now = std::time::SystemTime::now()
|
|
1525
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
1526
|
+
.unwrap()
|
|
1527
|
+
.as_secs() as i64;
|
|
893
1528
|
let header = sign(web::MOCK_STRIPE_WEBHOOK_SECRET, now, &payload);
|
|
894
|
-
let response = web::webhook_route(
|
|
1529
|
+
let response = web::webhook_route(
|
|
1530
|
+
&payload,
|
|
1531
|
+
&header,
|
|
1532
|
+
&t.platform,
|
|
1533
|
+
Host::new(&t.client, &t.wasm, &t.config, &NeverInvoker),
|
|
1534
|
+
&crate::commerce_ir::fixture_payments(),
|
|
1535
|
+
)
|
|
1536
|
+
.await;
|
|
895
1537
|
assert_eq!(response["statusCode"], 500, "{event}");
|
|
896
|
-
assert_eq!(
|
|
1538
|
+
assert_eq!(
|
|
1539
|
+
t.payment_status(&reference).await,
|
|
1540
|
+
"pending",
|
|
1541
|
+
"a forged completion must not settle a real payment"
|
|
1542
|
+
);
|
|
897
1543
|
}
|
|
898
1544
|
|
|
899
1545
|
#[test]
|
|
900
1546
|
fn checkout_plan_is_decided_from_the_connection_lifecycle() {
|
|
901
|
-
let platform = PlatformConfig {
|
|
1547
|
+
let platform = PlatformConfig {
|
|
1548
|
+
direct_test_key: "sk_test_own".to_string(),
|
|
1549
|
+
direct_test_publishable_key: "pk_test_own".to_string(),
|
|
1550
|
+
..test_platform()
|
|
1551
|
+
};
|
|
902
1552
|
let with = |status: &str, processor: &str, mode: &str, account_ref: &str| Connection {
|
|
903
1553
|
status: status.to_string(),
|
|
904
1554
|
processor: processor.to_string(),
|
|
@@ -909,22 +1559,61 @@ fn checkout_plan_is_decided_from_the_connection_lifecycle() {
|
|
|
909
1559
|
let own = |status: &str, mode: &str| with(status, "stripe", mode, SELF_ACCOUNT);
|
|
910
1560
|
assert!(matches!(checkout_plan(None, &platform), CheckoutPlan::Mock));
|
|
911
1561
|
for status in ["connected", "disconnected"] {
|
|
912
|
-
assert!(
|
|
1562
|
+
assert!(
|
|
1563
|
+
matches!(
|
|
1564
|
+
checkout_plan(Some(&own(status, "test")), &platform),
|
|
1565
|
+
CheckoutPlan::Mock
|
|
1566
|
+
),
|
|
1567
|
+
"{status}"
|
|
1568
|
+
);
|
|
913
1569
|
}
|
|
914
|
-
assert!(matches!(
|
|
1570
|
+
assert!(matches!(
|
|
1571
|
+
checkout_plan(Some(&own("paused", "test")), &platform),
|
|
1572
|
+
CheckoutPlan::Paused
|
|
1573
|
+
));
|
|
915
1574
|
assert!(matches!(
|
|
916
1575
|
checkout_plan(Some(&own("enabled", "test")), &platform),
|
|
917
1576
|
CheckoutPlan::Stripe { ref api_key, ref publishable_key } if api_key == "sk_test_own" && publishable_key == "pk_test_own"
|
|
918
1577
|
));
|
|
919
|
-
assert!(matches!(checkout_plan(Some(&own("enabled", "live")), &platform), CheckoutPlan::Paused), "no live key configured");
|
|
920
|
-
assert!(matches!(checkout_plan(Some(&with("enabled", "paypal", "test", SELF_ACCOUNT)), &platform), CheckoutPlan::Paused), "a processor this host cannot charge");
|
|
921
1578
|
assert!(
|
|
922
|
-
matches!(
|
|
1579
|
+
matches!(
|
|
1580
|
+
checkout_plan(Some(&own("enabled", "live")), &platform),
|
|
1581
|
+
CheckoutPlan::Paused
|
|
1582
|
+
),
|
|
1583
|
+
"no live key configured"
|
|
1584
|
+
);
|
|
1585
|
+
assert!(
|
|
1586
|
+
matches!(
|
|
1587
|
+
checkout_plan(
|
|
1588
|
+
Some(&with("enabled", "paypal", "test", SELF_ACCOUNT)),
|
|
1589
|
+
&platform
|
|
1590
|
+
),
|
|
1591
|
+
CheckoutPlan::Paused
|
|
1592
|
+
),
|
|
1593
|
+
"a processor this host cannot charge"
|
|
1594
|
+
);
|
|
1595
|
+
assert!(
|
|
1596
|
+
matches!(
|
|
1597
|
+
checkout_plan(
|
|
1598
|
+
Some(&with("enabled", "stripe", "test", "acct_1")),
|
|
1599
|
+
&platform
|
|
1600
|
+
),
|
|
1601
|
+
CheckoutPlan::Paused
|
|
1602
|
+
),
|
|
923
1603
|
"a connection to any other account cannot be charged"
|
|
924
1604
|
);
|
|
925
1605
|
|
|
926
|
-
let secret_only = PlatformConfig {
|
|
927
|
-
|
|
1606
|
+
let secret_only = PlatformConfig {
|
|
1607
|
+
direct_test_key: "sk_test_own".to_string(),
|
|
1608
|
+
..test_platform()
|
|
1609
|
+
};
|
|
1610
|
+
assert!(
|
|
1611
|
+
matches!(
|
|
1612
|
+
checkout_plan(Some(&own("enabled", "test")), &secret_only),
|
|
1613
|
+
CheckoutPlan::Paused
|
|
1614
|
+
),
|
|
1615
|
+
"a key without its publishable key cannot mount the form"
|
|
1616
|
+
);
|
|
928
1617
|
assert_eq!(platform.direct_modes(), vec!["test"]);
|
|
929
1618
|
assert!(secret_only.direct_modes().is_empty());
|
|
930
1619
|
}
|
|
@@ -935,18 +1624,58 @@ async fn using_the_own_account_needs_an_owner_a_mode_and_configured_keys() {
|
|
|
935
1624
|
let mut t = tenant("hecks_pay_test_direct_refused").await;
|
|
936
1625
|
t.platform.direct_test_key = String::new();
|
|
937
1626
|
t.platform.direct_test_publishable_key = String::new();
|
|
938
|
-
let (status, body) = t
|
|
1627
|
+
let (status, body) = t
|
|
1628
|
+
.call(
|
|
1629
|
+
"POST",
|
|
1630
|
+
"/payments/connection/direct",
|
|
1631
|
+
json!({"mode": "test"}),
|
|
1632
|
+
Some(OWNER),
|
|
1633
|
+
)
|
|
1634
|
+
.await;
|
|
939
1635
|
assert_eq!(status, 422, "{body}");
|
|
940
|
-
assert!(
|
|
1636
|
+
assert!(
|
|
1637
|
+
body["error"]
|
|
1638
|
+
.as_str()
|
|
1639
|
+
.unwrap()
|
|
1640
|
+
.contains("not set up for test mode"),
|
|
1641
|
+
"{body}"
|
|
1642
|
+
);
|
|
941
1643
|
assert_eq!(t.status().await, "none");
|
|
942
|
-
assert!(
|
|
1644
|
+
assert!(
|
|
1645
|
+
t.fake.requests().is_empty(),
|
|
1646
|
+
"no Stripe call without configured keys"
|
|
1647
|
+
);
|
|
943
1648
|
|
|
944
1649
|
let t = tenant("hecks_pay_test_direct_access").await;
|
|
945
|
-
let (status, _) = t
|
|
1650
|
+
let (status, _) = t
|
|
1651
|
+
.call(
|
|
1652
|
+
"POST",
|
|
1653
|
+
"/payments/connection/direct",
|
|
1654
|
+
json!({"mode": "test"}),
|
|
1655
|
+
Some(ADMIN),
|
|
1656
|
+
)
|
|
1657
|
+
.await;
|
|
946
1658
|
assert_eq!(status, 403, "only an Owner may choose the account");
|
|
947
|
-
let (status, body) = t
|
|
948
|
-
|
|
949
|
-
|
|
1659
|
+
let (status, body) = t
|
|
1660
|
+
.call(
|
|
1661
|
+
"POST",
|
|
1662
|
+
"/payments/connection/direct",
|
|
1663
|
+
json!({}),
|
|
1664
|
+
Some(OWNER),
|
|
1665
|
+
)
|
|
1666
|
+
.await;
|
|
1667
|
+
assert_eq!(
|
|
1668
|
+
(status, body["error"].as_str()),
|
|
1669
|
+
(400, Some("missing mode"))
|
|
1670
|
+
);
|
|
1671
|
+
let (status, _) = t
|
|
1672
|
+
.call(
|
|
1673
|
+
"POST",
|
|
1674
|
+
"/payments/connection/direct",
|
|
1675
|
+
json!({"mode": "live"}),
|
|
1676
|
+
Some(OWNER),
|
|
1677
|
+
)
|
|
1678
|
+
.await;
|
|
950
1679
|
assert_eq!(status, 422, "live keys are not configured");
|
|
951
1680
|
assert_eq!(t.status().await, "none");
|
|
952
1681
|
}
|
|
@@ -954,33 +1683,93 @@ async fn using_the_own_account_needs_an_owner_a_mode_and_configured_keys() {
|
|
|
954
1683
|
#[tokio::test]
|
|
955
1684
|
async fn using_the_own_account_records_the_reserved_ref_and_only_public_facts() {
|
|
956
1685
|
let t = tenant("hecks_pay_test_direct_connect").await;
|
|
957
|
-
let (_, body) = t
|
|
958
|
-
|
|
1686
|
+
let (_, body) = t
|
|
1687
|
+
.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
1688
|
+
.await;
|
|
1689
|
+
assert_eq!(
|
|
1690
|
+
(
|
|
1691
|
+
body["direct_modes"].clone(),
|
|
1692
|
+
body["direct"].clone(),
|
|
1693
|
+
body["adapters"].clone()
|
|
1694
|
+
),
|
|
1695
|
+
(json!(["test"]), json!(false), json!([]))
|
|
1696
|
+
);
|
|
959
1697
|
|
|
960
|
-
let (status, body) = t
|
|
1698
|
+
let (status, body) = t
|
|
1699
|
+
.call(
|
|
1700
|
+
"POST",
|
|
1701
|
+
"/payments/connection/direct",
|
|
1702
|
+
json!({"mode": "test"}),
|
|
1703
|
+
Some(OWNER),
|
|
1704
|
+
)
|
|
1705
|
+
.await;
|
|
961
1706
|
assert_eq!(status, 200, "{body}");
|
|
962
1707
|
assert_eq!(
|
|
963
|
-
(
|
|
964
|
-
|
|
1708
|
+
(
|
|
1709
|
+
body["status"].as_str(),
|
|
1710
|
+
body["processor"].as_str(),
|
|
1711
|
+
body["account_ref"].as_str(),
|
|
1712
|
+
body["mode"].as_str(),
|
|
1713
|
+
body["display_name"].as_str(),
|
|
1714
|
+
body["direct"].clone()
|
|
1715
|
+
),
|
|
1716
|
+
(
|
|
1717
|
+
Some("connected"),
|
|
1718
|
+
Some("stripe"),
|
|
1719
|
+
Some("self"),
|
|
1720
|
+
Some("test"),
|
|
1721
|
+
Some("Own Studio"),
|
|
1722
|
+
json!(true)
|
|
1723
|
+
)
|
|
965
1724
|
);
|
|
966
1725
|
|
|
967
1726
|
let lookups = t.fake.requests_to("/v1/account");
|
|
968
1727
|
assert_eq!(lookups.len(), 1);
|
|
969
|
-
assert_eq!(
|
|
1728
|
+
assert_eq!(
|
|
1729
|
+
lookups[0].header("authorization"),
|
|
1730
|
+
Some(format!("Bearer {DIRECT_KEY}").as_str()),
|
|
1731
|
+
"read with the account's own key"
|
|
1732
|
+
);
|
|
970
1733
|
|
|
971
1734
|
let dump = t.dump_database().await;
|
|
972
|
-
assert!(
|
|
973
|
-
|
|
1735
|
+
assert!(
|
|
1736
|
+
!dump.contains(DIRECT_KEY) && !dump.contains(DIRECT_PUBLISHABLE_KEY),
|
|
1737
|
+
"no key is ever stored"
|
|
1738
|
+
);
|
|
1739
|
+
assert!(
|
|
1740
|
+
!body.to_string().contains(DIRECT_KEY),
|
|
1741
|
+
"no key is ever shown"
|
|
1742
|
+
);
|
|
974
1743
|
|
|
975
|
-
let (status, body) = t
|
|
976
|
-
|
|
1744
|
+
let (status, body) = t
|
|
1745
|
+
.call(
|
|
1746
|
+
"POST",
|
|
1747
|
+
"/payments/connection/direct",
|
|
1748
|
+
json!({"mode": "test"}),
|
|
1749
|
+
Some(OWNER),
|
|
1750
|
+
)
|
|
1751
|
+
.await;
|
|
1752
|
+
assert_eq!(
|
|
1753
|
+
(status, body["error"].as_str()),
|
|
1754
|
+
(
|
|
1755
|
+
409,
|
|
1756
|
+
Some("an account is already connected — disconnect it first")
|
|
1757
|
+
)
|
|
1758
|
+
);
|
|
977
1759
|
}
|
|
978
1760
|
|
|
979
1761
|
#[tokio::test]
|
|
980
1762
|
async fn the_own_account_falls_back_to_a_generic_name_when_stripe_will_not_say() {
|
|
981
1763
|
let t = tenant("hecks_pay_test_direct_name").await;
|
|
982
1764
|
t.fake.state.refuse_account.store(true, Ordering::SeqCst);
|
|
983
|
-
let (status, body) = t
|
|
1765
|
+
let (status, body) = t
|
|
1766
|
+
.call(
|
|
1767
|
+
"POST",
|
|
1768
|
+
"/payments/connection/direct",
|
|
1769
|
+
json!({"mode": "test"}),
|
|
1770
|
+
Some(OWNER),
|
|
1771
|
+
)
|
|
1772
|
+
.await;
|
|
984
1773
|
assert_eq!(status, 200, "{body}");
|
|
985
1774
|
assert_eq!(body["display_name"], "Your Stripe account");
|
|
986
1775
|
}
|
|
@@ -994,7 +1783,13 @@ async fn the_own_account_charges_with_its_own_key_and_names_no_other_account() {
|
|
|
994
1783
|
// Connected but not enabled: still the mock, no Stripe call.
|
|
995
1784
|
let (status, body) = t.register("own-event").await;
|
|
996
1785
|
assert_eq!(status, 200, "{body}");
|
|
997
|
-
assert!(
|
|
1786
|
+
assert!(
|
|
1787
|
+
body["checkout_url"]
|
|
1788
|
+
.as_str()
|
|
1789
|
+
.unwrap()
|
|
1790
|
+
.starts_with("https://site.example/pay/"),
|
|
1791
|
+
"{body}"
|
|
1792
|
+
);
|
|
998
1793
|
assert!(t.fake.requests_to("/v1/checkout/sessions").is_empty());
|
|
999
1794
|
|
|
1000
1795
|
t.enable().await;
|
|
@@ -1008,35 +1803,76 @@ async fn the_own_account_charges_with_its_own_key_and_names_no_other_account() {
|
|
|
1008
1803
|
|
|
1009
1804
|
let sent = t.fake.requests_to("/v1/checkout/sessions");
|
|
1010
1805
|
assert_eq!(sent.len(), 1);
|
|
1011
|
-
assert_eq!(
|
|
1012
|
-
|
|
1806
|
+
assert_eq!(
|
|
1807
|
+
sent[0].header("stripe-account"),
|
|
1808
|
+
None,
|
|
1809
|
+
"the business's own account is the default account"
|
|
1810
|
+
);
|
|
1811
|
+
assert_eq!(
|
|
1812
|
+
sent[0].header("authorization"),
|
|
1813
|
+
Some(format!("Bearer {DIRECT_KEY}").as_str())
|
|
1814
|
+
);
|
|
1013
1815
|
assert_eq!(sent[0].header("stripe-version"), Some("2026-04-22.dahlia"));
|
|
1014
|
-
assert!(
|
|
1816
|
+
assert!(
|
|
1817
|
+
sent[0].body.contains("ui_mode=embedded_page"),
|
|
1818
|
+
"{}",
|
|
1819
|
+
sent[0].body
|
|
1820
|
+
);
|
|
1015
1821
|
|
|
1016
1822
|
let reference = body["registration_id"].as_str().unwrap();
|
|
1017
1823
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
1018
|
-
assert_eq!(
|
|
1824
|
+
assert_eq!(
|
|
1825
|
+
read["instances"][format!("Payments::Payment#{reference}")]["processor"]["value"],
|
|
1826
|
+
"stripe"
|
|
1827
|
+
);
|
|
1019
1828
|
}
|
|
1020
1829
|
|
|
1021
1830
|
#[tokio::test]
|
|
1022
1831
|
async fn the_own_account_follows_enable_disable_pause_and_resume() {
|
|
1023
1832
|
let t = tenant("hecks_pay_test_direct_lifecycle").await;
|
|
1024
|
-
let (status, body) = t
|
|
1025
|
-
|
|
1833
|
+
let (status, body) = t
|
|
1834
|
+
.call(
|
|
1835
|
+
"POST",
|
|
1836
|
+
"/payments/connection/enable",
|
|
1837
|
+
json!({}),
|
|
1838
|
+
Some(OPERATOR),
|
|
1839
|
+
)
|
|
1840
|
+
.await;
|
|
1841
|
+
assert_eq!(
|
|
1842
|
+
(status, body["error"].as_str()),
|
|
1843
|
+
(409, Some("connect an account first"))
|
|
1844
|
+
);
|
|
1026
1845
|
|
|
1027
1846
|
t.connect().await;
|
|
1028
1847
|
t.enable().await;
|
|
1029
|
-
let (status, body) = t
|
|
1848
|
+
let (status, body) = t
|
|
1849
|
+
.call(
|
|
1850
|
+
"POST",
|
|
1851
|
+
"/payments/connection/disable",
|
|
1852
|
+
json!({}),
|
|
1853
|
+
Some(OPERATOR),
|
|
1854
|
+
)
|
|
1855
|
+
.await;
|
|
1030
1856
|
assert_eq!((status, body["status"].as_str()), (200, Some("connected")));
|
|
1031
1857
|
t.enable().await;
|
|
1032
1858
|
|
|
1033
1859
|
// Disconnecting while enabled pauses registrations.
|
|
1034
|
-
let (status, body) = t
|
|
1860
|
+
let (status, body) = t
|
|
1861
|
+
.call(
|
|
1862
|
+
"POST",
|
|
1863
|
+
"/payments/connection/disconnect",
|
|
1864
|
+
json!({}),
|
|
1865
|
+
Some(OWNER),
|
|
1866
|
+
)
|
|
1867
|
+
.await;
|
|
1035
1868
|
assert_eq!(status, 200, "{body}");
|
|
1036
1869
|
assert_eq!(body["status"], "paused");
|
|
1037
1870
|
t.schedule_event("paused-event").await;
|
|
1038
1871
|
let (status, body) = t.register("paused-event").await;
|
|
1039
|
-
assert_eq!(
|
|
1872
|
+
assert_eq!(
|
|
1873
|
+
(status, body["error"].as_str()),
|
|
1874
|
+
(503, Some("payments are temporarily unavailable"))
|
|
1875
|
+
);
|
|
1040
1876
|
|
|
1041
1877
|
// Choosing the account again resumes the paused connection.
|
|
1042
1878
|
t.connect().await;
|
|
@@ -1045,8 +1881,18 @@ async fn the_own_account_follows_enable_disable_pause_and_resume() {
|
|
|
1045
1881
|
// A connection that was never enabled unlinks and can be chosen again.
|
|
1046
1882
|
let fresh = tenant("hecks_pay_test_direct_unlink").await;
|
|
1047
1883
|
fresh.connect().await;
|
|
1048
|
-
let (status, body) = fresh
|
|
1049
|
-
|
|
1884
|
+
let (status, body) = fresh
|
|
1885
|
+
.call(
|
|
1886
|
+
"POST",
|
|
1887
|
+
"/payments/connection/disconnect",
|
|
1888
|
+
json!({}),
|
|
1889
|
+
Some(OWNER),
|
|
1890
|
+
)
|
|
1891
|
+
.await;
|
|
1892
|
+
assert_eq!(
|
|
1893
|
+
(status, body["status"].as_str()),
|
|
1894
|
+
(200, Some("disconnected"))
|
|
1895
|
+
);
|
|
1050
1896
|
fresh.connect().await;
|
|
1051
1897
|
assert_eq!(fresh.status().await, "connected");
|
|
1052
1898
|
}
|
|
@@ -1075,14 +1921,38 @@ fn assert_no_secret_in(text: &str) {
|
|
|
1075
1921
|
#[tokio::test]
|
|
1076
1922
|
async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_of_the_database() {
|
|
1077
1923
|
let t = Tenant::saving("hecks_pay_test_save_ok").await;
|
|
1078
|
-
let (_, before) = t
|
|
1079
|
-
|
|
1924
|
+
let (_, before) = t
|
|
1925
|
+
.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
1926
|
+
.await;
|
|
1927
|
+
assert_eq!(
|
|
1928
|
+
(
|
|
1929
|
+
before["can_save_keys"].clone(),
|
|
1930
|
+
before["direct_modes"].clone()
|
|
1931
|
+
),
|
|
1932
|
+
(json!(true), json!([]))
|
|
1933
|
+
);
|
|
1080
1934
|
|
|
1081
|
-
let (status, body) = t
|
|
1935
|
+
let (status, body) = t
|
|
1936
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
1937
|
+
.await;
|
|
1082
1938
|
assert_eq!(status, 200, "{body}");
|
|
1083
1939
|
assert_eq!(
|
|
1084
|
-
(
|
|
1085
|
-
|
|
1940
|
+
(
|
|
1941
|
+
body["status"].as_str(),
|
|
1942
|
+
body["processor"].as_str(),
|
|
1943
|
+
body["account_ref"].as_str(),
|
|
1944
|
+
body["mode"].as_str(),
|
|
1945
|
+
body["display_name"].as_str(),
|
|
1946
|
+
body["direct"].clone()
|
|
1947
|
+
),
|
|
1948
|
+
(
|
|
1949
|
+
Some("connected"),
|
|
1950
|
+
Some("stripe"),
|
|
1951
|
+
Some("self"),
|
|
1952
|
+
Some("test"),
|
|
1953
|
+
Some("Own Studio"),
|
|
1954
|
+
json!(true)
|
|
1955
|
+
)
|
|
1086
1956
|
);
|
|
1087
1957
|
assert_eq!(body["direct_modes"], json!(["test"]));
|
|
1088
1958
|
assert_no_secret_in(&body.to_string());
|
|
@@ -1090,13 +1960,25 @@ async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_
|
|
|
1090
1960
|
// Stripe was asked to check the key, then to deliver the three events to this site.
|
|
1091
1961
|
let lookups = t.fake.requests_to("/v1/account");
|
|
1092
1962
|
assert_eq!(lookups.len(), 1);
|
|
1093
|
-
assert_eq!(
|
|
1094
|
-
|
|
1963
|
+
assert_eq!(
|
|
1964
|
+
lookups[0].header("authorization"),
|
|
1965
|
+
Some(format!("Bearer {SAVED_KEY}").as_str())
|
|
1966
|
+
);
|
|
1967
|
+
assert_eq!(
|
|
1968
|
+
lookups[0].header("stripe-version"),
|
|
1969
|
+
Some("2026-04-22.dahlia")
|
|
1970
|
+
);
|
|
1095
1971
|
let created = t.fake.requests_to("/v1/webhook_endpoints");
|
|
1096
1972
|
assert_eq!(created.len(), 1);
|
|
1097
1973
|
assert_eq!(created[0].method, "POST");
|
|
1098
|
-
assert_eq!(
|
|
1099
|
-
|
|
1974
|
+
assert_eq!(
|
|
1975
|
+
created[0].header("authorization"),
|
|
1976
|
+
Some(format!("Bearer {SAVED_KEY}").as_str())
|
|
1977
|
+
);
|
|
1978
|
+
assert_eq!(
|
|
1979
|
+
created[0].header("stripe-version"),
|
|
1980
|
+
Some("2026-04-22.dahlia")
|
|
1981
|
+
);
|
|
1100
1982
|
assert_eq!(created[0].header("stripe-account"), None);
|
|
1101
1983
|
for expected in [
|
|
1102
1984
|
"url=https%3A%2F%2Fwebhooks.example.test%2Fwebhooks%2Fstripe",
|
|
@@ -1105,7 +1987,11 @@ async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_
|
|
|
1105
1987
|
"enabled_events%5B1%5D=checkout.session.expired",
|
|
1106
1988
|
"enabled_events%5B2%5D=charge.refunded",
|
|
1107
1989
|
] {
|
|
1108
|
-
assert!(
|
|
1990
|
+
assert!(
|
|
1991
|
+
created[0].body.contains(expected),
|
|
1992
|
+
"{expected} missing from {}",
|
|
1993
|
+
created[0].body
|
|
1994
|
+
);
|
|
1109
1995
|
}
|
|
1110
1996
|
|
|
1111
1997
|
// Saved in the secret store in the agreed shape...
|
|
@@ -1114,7 +2000,16 @@ async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_
|
|
|
1114
2000
|
let entry = saved["test"].as_object().unwrap();
|
|
1115
2001
|
let mut names: Vec<&str> = entry.keys().map(String::as_str).collect();
|
|
1116
2002
|
names.sort_unstable();
|
|
1117
|
-
assert_eq!(
|
|
2003
|
+
assert_eq!(
|
|
2004
|
+
names,
|
|
2005
|
+
[
|
|
2006
|
+
"publishable_key",
|
|
2007
|
+
"saved_at",
|
|
2008
|
+
"secret_key",
|
|
2009
|
+
"webhook_endpoint_id",
|
|
2010
|
+
"webhook_secret"
|
|
2011
|
+
]
|
|
2012
|
+
);
|
|
1118
2013
|
assert_eq!(entry["secret_key"], SAVED_KEY);
|
|
1119
2014
|
assert_eq!(entry["publishable_key"], SAVED_PUBLISHABLE_KEY);
|
|
1120
2015
|
assert_eq!(entry["webhook_secret"], "whsec_SAVED_1");
|
|
@@ -1123,15 +2018,23 @@ async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_
|
|
|
1123
2018
|
// ...and nowhere in the tenant's database.
|
|
1124
2019
|
let dump = t.dump_database().await;
|
|
1125
2020
|
assert_no_secret_in(&dump);
|
|
1126
|
-
assert!(
|
|
2021
|
+
assert!(
|
|
2022
|
+
!dump.contains(SAVED_PUBLISHABLE_KEY),
|
|
2023
|
+
"not even the publishable key is stored there"
|
|
2024
|
+
);
|
|
1127
2025
|
}
|
|
1128
2026
|
|
|
1129
2027
|
#[tokio::test]
|
|
1130
2028
|
async fn saving_live_keys_derives_the_live_mode_from_the_keys() {
|
|
1131
2029
|
let t = Tenant::saving("hecks_pay_test_save_live").await;
|
|
1132
|
-
let (status, body) = t
|
|
2030
|
+
let (status, body) = t
|
|
2031
|
+
.save_keys(LIVE_SAVED_KEY, LIVE_SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2032
|
+
.await;
|
|
1133
2033
|
assert_eq!(status, 200, "{body}");
|
|
1134
|
-
assert_eq!(
|
|
2034
|
+
assert_eq!(
|
|
2035
|
+
(body["mode"].as_str(), body["direct_modes"].clone()),
|
|
2036
|
+
(Some("live"), json!(["live"]))
|
|
2037
|
+
);
|
|
1135
2038
|
let saved = t.saved_document();
|
|
1136
2039
|
assert_eq!(saved["live"]["secret_key"], LIVE_SAVED_KEY);
|
|
1137
2040
|
assert!(saved.get("test").is_none());
|
|
@@ -1141,25 +2044,45 @@ async fn saving_live_keys_derives_the_live_mode_from_the_keys() {
|
|
|
1141
2044
|
async fn saved_keys_charge_the_business_account_and_its_saved_webhook_secret_settles_payments() {
|
|
1142
2045
|
let t = Tenant::saving("hecks_pay_test_save_charge").await;
|
|
1143
2046
|
t.schedule_event("saved-event").await;
|
|
1144
|
-
let (status, body) = t
|
|
2047
|
+
let (status, body) = t
|
|
2048
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2049
|
+
.await;
|
|
1145
2050
|
assert_eq!(status, 200, "{body}");
|
|
1146
2051
|
t.enable().await;
|
|
1147
2052
|
|
|
1148
2053
|
let (status, body) = t.register("saved-event").await;
|
|
1149
2054
|
assert_eq!(status, 200, "{body}");
|
|
1150
|
-
assert_eq!(
|
|
2055
|
+
assert_eq!(
|
|
2056
|
+
body["embedded_checkout"]["publishable_key"],
|
|
2057
|
+
SAVED_PUBLISHABLE_KEY
|
|
2058
|
+
);
|
|
1151
2059
|
let sent = t.fake.requests_to("/v1/checkout/sessions");
|
|
1152
2060
|
assert_eq!(sent.len(), 1);
|
|
1153
|
-
assert_eq!(
|
|
1154
|
-
|
|
2061
|
+
assert_eq!(
|
|
2062
|
+
sent[0].header("authorization"),
|
|
2063
|
+
Some(format!("Bearer {SAVED_KEY}").as_str())
|
|
2064
|
+
);
|
|
2065
|
+
assert_eq!(
|
|
2066
|
+
sent[0].header("stripe-account"),
|
|
2067
|
+
None,
|
|
2068
|
+
"the business's own account is the default account"
|
|
2069
|
+
);
|
|
1155
2070
|
assert_no_secret_in(&body.to_string());
|
|
1156
2071
|
|
|
1157
2072
|
let reference = body["registration_id"].as_str().unwrap().to_string();
|
|
1158
2073
|
// A delivery signed with anything else is refused.
|
|
1159
|
-
assert_eq!(
|
|
2074
|
+
assert_eq!(
|
|
2075
|
+
t.webhook_signed_with("whsec_someone_else", completed(&reference))
|
|
2076
|
+
.await,
|
|
2077
|
+
400
|
|
2078
|
+
);
|
|
1160
2079
|
assert_eq!(t.payment_status(&reference).await, "pending");
|
|
1161
2080
|
// The signing secret Stripe returned when the webhook was created settles it.
|
|
1162
|
-
assert_eq!(
|
|
2081
|
+
assert_eq!(
|
|
2082
|
+
t.webhook_signed_with("whsec_SAVED_1", completed(&reference))
|
|
2083
|
+
.await,
|
|
2084
|
+
200
|
|
2085
|
+
);
|
|
1163
2086
|
assert_eq!(t.payment_status(&reference).await, "succeeded");
|
|
1164
2087
|
// The environment's secret still works alongside it.
|
|
1165
2088
|
let (_, body) = t.register("saved-event").await;
|
|
@@ -1172,22 +2095,56 @@ async fn saved_keys_charge_the_business_account_and_its_saved_webhook_secret_set
|
|
|
1172
2095
|
async fn saving_keys_refuses_with_plain_messages_and_saves_nothing() {
|
|
1173
2096
|
// No key store on this deploy.
|
|
1174
2097
|
let t = tenant("hecks_pay_test_save_nostore").await;
|
|
1175
|
-
let (status, body) = t
|
|
1176
|
-
|
|
1177
|
-
|
|
2098
|
+
let (status, body) = t
|
|
2099
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2100
|
+
.await;
|
|
2101
|
+
assert_eq!(
|
|
2102
|
+
(status, body["error"].as_str()),
|
|
2103
|
+
(422, Some("saving keys is not set up on this site"))
|
|
2104
|
+
);
|
|
2105
|
+
let (_, shown) = t
|
|
2106
|
+
.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
2107
|
+
.await;
|
|
1178
2108
|
assert_eq!(shown["can_save_keys"], json!(false));
|
|
1179
2109
|
|
|
1180
2110
|
let t = Tenant::saving("hecks_pay_test_save_refuse").await;
|
|
1181
|
-
assert_eq!(
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
2111
|
+
assert_eq!(
|
|
2112
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(ADMIN))
|
|
2113
|
+
.await
|
|
2114
|
+
.0,
|
|
2115
|
+
403,
|
|
2116
|
+
"only an Owner may save keys"
|
|
2117
|
+
);
|
|
2118
|
+
assert_eq!(
|
|
2119
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, None).await.0,
|
|
2120
|
+
401
|
|
2121
|
+
);
|
|
2122
|
+
let (status, body) = t
|
|
2123
|
+
.call(
|
|
2124
|
+
"POST",
|
|
2125
|
+
"/payments/connection/direct",
|
|
2126
|
+
json!({"secret_key": SAVED_KEY}),
|
|
2127
|
+
Some(OWNER),
|
|
2128
|
+
)
|
|
2129
|
+
.await;
|
|
2130
|
+
assert_eq!(
|
|
2131
|
+
(status, body["error"].as_str()),
|
|
2132
|
+
(400, Some("missing publishable_key"))
|
|
2133
|
+
);
|
|
1185
2134
|
|
|
1186
2135
|
let cases = [
|
|
1187
2136
|
("nonsense", SAVED_PUBLISHABLE_KEY, "restricted key"),
|
|
1188
2137
|
(SAVED_KEY, "nonsense", "publishable key"),
|
|
1189
|
-
(
|
|
1190
|
-
|
|
2138
|
+
(
|
|
2139
|
+
SAVED_KEY,
|
|
2140
|
+
LIVE_SAVED_PUBLISHABLE_KEY,
|
|
2141
|
+
"test mode and the other is for live mode",
|
|
2142
|
+
),
|
|
2143
|
+
(
|
|
2144
|
+
LIVE_SAVED_KEY,
|
|
2145
|
+
SAVED_PUBLISHABLE_KEY,
|
|
2146
|
+
"test mode and the other is for live mode",
|
|
2147
|
+
),
|
|
1191
2148
|
];
|
|
1192
2149
|
for (secret, publishable, expected) in cases {
|
|
1193
2150
|
let (status, body) = t.save_keys(secret, publishable, Some(OWNER)).await;
|
|
@@ -1195,18 +2152,31 @@ async fn saving_keys_refuses_with_plain_messages_and_saves_nothing() {
|
|
|
1195
2152
|
assert!(body["error"].as_str().unwrap().contains(expected), "{body}");
|
|
1196
2153
|
assert_no_secret_in(&body.to_string());
|
|
1197
2154
|
}
|
|
1198
|
-
assert!(
|
|
2155
|
+
assert!(
|
|
2156
|
+
t.fake.requests().is_empty(),
|
|
2157
|
+
"nothing malformed reaches Stripe"
|
|
2158
|
+
);
|
|
1199
2159
|
|
|
1200
2160
|
// Stripe does not recognise the key.
|
|
1201
2161
|
t.fake.state.invalid_key.store(true, Ordering::SeqCst);
|
|
1202
|
-
let (status, body) = t
|
|
1203
|
-
|
|
1204
|
-
|
|
2162
|
+
let (status, body) = t
|
|
2163
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2164
|
+
.await;
|
|
2165
|
+
assert_eq!(
|
|
2166
|
+
(status, body["error"].as_str()),
|
|
2167
|
+
(422, Some("Stripe did not accept that key."))
|
|
2168
|
+
);
|
|
2169
|
+
assert!(
|
|
2170
|
+
t.fake.requests_to("/v1/webhook_endpoints").is_empty(),
|
|
2171
|
+
"no webhook for a key Stripe rejected"
|
|
2172
|
+
);
|
|
1205
2173
|
t.fake.state.invalid_key.store(false, Ordering::SeqCst);
|
|
1206
2174
|
|
|
1207
2175
|
// A restricted key that may not create webhooks.
|
|
1208
2176
|
t.fake.state.refuse_webhooks.store(true, Ordering::SeqCst);
|
|
1209
|
-
let (status, body) = t
|
|
2177
|
+
let (status, body) = t
|
|
2178
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2179
|
+
.await;
|
|
1210
2180
|
assert_eq!(
|
|
1211
2181
|
(status, body["error"].as_str()),
|
|
1212
2182
|
(422, Some("That key can't create the webhook. In Stripe, give it Webhook Endpoints → Write as well and try again."))
|
|
@@ -1214,7 +2184,11 @@ async fn saving_keys_refuses_with_plain_messages_and_saves_nothing() {
|
|
|
1214
2184
|
assert_no_secret_in(&body.to_string());
|
|
1215
2185
|
|
|
1216
2186
|
assert_eq!(t.status().await, "none");
|
|
1217
|
-
assert_eq!(
|
|
2187
|
+
assert_eq!(
|
|
2188
|
+
t.saved_document(),
|
|
2189
|
+
Value::Null,
|
|
2190
|
+
"nothing was written to the store"
|
|
2191
|
+
);
|
|
1218
2192
|
assert_eq!(t.raw.as_ref().unwrap().writes.load(Ordering::SeqCst), 0);
|
|
1219
2193
|
}
|
|
1220
2194
|
|
|
@@ -1222,16 +2196,29 @@ async fn saving_keys_refuses_with_plain_messages_and_saves_nothing() {
|
|
|
1222
2196
|
async fn a_restricted_key_that_cannot_read_the_account_still_saves_with_a_generic_name() {
|
|
1223
2197
|
let t = Tenant::saving("hecks_pay_test_save_name").await;
|
|
1224
2198
|
t.fake.state.refuse_account.store(true, Ordering::SeqCst);
|
|
1225
|
-
let (status, body) = t
|
|
1226
|
-
|
|
2199
|
+
let (status, body) = t
|
|
2200
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2201
|
+
.await;
|
|
2202
|
+
assert_eq!(
|
|
2203
|
+
(status, body["display_name"].as_str()),
|
|
2204
|
+
(200, Some("Your Stripe account")),
|
|
2205
|
+
"{body}"
|
|
2206
|
+
);
|
|
1227
2207
|
}
|
|
1228
2208
|
|
|
1229
2209
|
#[tokio::test]
|
|
1230
2210
|
async fn saving_again_replaces_the_webhook_and_the_keys_without_disturbing_the_connection() {
|
|
1231
2211
|
let t = Tenant::saving("hecks_pay_test_save_again").await;
|
|
1232
|
-
assert_eq!(
|
|
2212
|
+
assert_eq!(
|
|
2213
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2214
|
+
.await
|
|
2215
|
+
.0,
|
|
2216
|
+
200
|
|
2217
|
+
);
|
|
1233
2218
|
t.enable().await;
|
|
1234
|
-
let (status, body) = t
|
|
2219
|
+
let (status, body) = t
|
|
2220
|
+
.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2221
|
+
.await;
|
|
1235
2222
|
assert_eq!(status, 200, "{body}");
|
|
1236
2223
|
assert_eq!(body["status"], "enabled", "the connection stays as it was");
|
|
1237
2224
|
assert_no_secret_in(&body.to_string());
|
|
@@ -1239,48 +2226,114 @@ async fn saving_again_replaces_the_webhook_and_the_keys_without_disturbing_the_c
|
|
|
1239
2226
|
// The first save's webhook was removed (with the first key), a new one made (with the second).
|
|
1240
2227
|
let removed = t.fake.requests_to("/v1/webhook_endpoints/we_1");
|
|
1241
2228
|
assert_eq!(removed.len(), 1);
|
|
1242
|
-
assert_eq!(
|
|
2229
|
+
assert_eq!(
|
|
2230
|
+
(
|
|
2231
|
+
removed[0].method.as_str(),
|
|
2232
|
+
removed[0].header("authorization")
|
|
2233
|
+
),
|
|
2234
|
+
("DELETE", Some(format!("Bearer {SAVED_KEY}").as_str()))
|
|
2235
|
+
);
|
|
1243
2236
|
let created = t.fake.requests_to("/v1/webhook_endpoints");
|
|
1244
2237
|
assert_eq!(created.len(), 2);
|
|
1245
|
-
assert_eq!(
|
|
2238
|
+
assert_eq!(
|
|
2239
|
+
created[1].header("authorization"),
|
|
2240
|
+
Some(format!("Bearer {SAVED_KEY_TWO}").as_str())
|
|
2241
|
+
);
|
|
1246
2242
|
// The new webhook exists before the old one goes, so a failed save never leaves none.
|
|
1247
2243
|
let all = t.fake.requests();
|
|
1248
|
-
let second_create = all
|
|
1249
|
-
|
|
2244
|
+
let second_create = all
|
|
2245
|
+
.iter()
|
|
2246
|
+
.rposition(|r| r.method == "POST" && r.path == "/v1/webhook_endpoints")
|
|
2247
|
+
.unwrap();
|
|
2248
|
+
let delete = all
|
|
2249
|
+
.iter()
|
|
2250
|
+
.position(|r| r.method == "DELETE" && r.path == "/v1/webhook_endpoints/we_1")
|
|
2251
|
+
.unwrap();
|
|
1250
2252
|
assert!(second_create < delete, "the new webhook was made first");
|
|
1251
2253
|
let saved = t.saved_document();
|
|
1252
2254
|
assert_eq!(
|
|
1253
|
-
(
|
|
2255
|
+
(
|
|
2256
|
+
saved["test"]["secret_key"].as_str(),
|
|
2257
|
+
saved["test"]["webhook_secret"].as_str(),
|
|
2258
|
+
saved["test"]["webhook_endpoint_id"].as_str()
|
|
2259
|
+
),
|
|
1254
2260
|
(Some(SAVED_KEY_TWO), Some("whsec_SAVED_2"), Some("we_2"))
|
|
1255
2261
|
);
|
|
1256
2262
|
|
|
1257
2263
|
// Keys for the other mode cannot be saved over a connected account.
|
|
1258
|
-
let (status, body) = t
|
|
1259
|
-
|
|
2264
|
+
let (status, body) = t
|
|
2265
|
+
.save_keys(LIVE_SAVED_KEY, LIVE_SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2266
|
+
.await;
|
|
2267
|
+
assert_eq!(
|
|
2268
|
+
(status, body["error"].as_str()),
|
|
2269
|
+
(
|
|
2270
|
+
409,
|
|
2271
|
+
Some("an account is already connected — disconnect it first")
|
|
2272
|
+
)
|
|
2273
|
+
);
|
|
1260
2274
|
}
|
|
1261
2275
|
|
|
1262
2276
|
#[tokio::test]
|
|
1263
2277
|
async fn a_failed_re_save_leaves_the_old_webhook_and_keys_working() {
|
|
1264
2278
|
let t = Tenant::saving("hecks_pay_test_save_resave_fail").await;
|
|
1265
|
-
assert_eq!(
|
|
2279
|
+
assert_eq!(
|
|
2280
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2281
|
+
.await
|
|
2282
|
+
.0,
|
|
2283
|
+
200
|
|
2284
|
+
);
|
|
1266
2285
|
t.fake.state.refuse_webhooks.store(true, Ordering::SeqCst);
|
|
1267
|
-
let (status, body) = t
|
|
2286
|
+
let (status, body) = t
|
|
2287
|
+
.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2288
|
+
.await;
|
|
1268
2289
|
assert_eq!(status, 422, "{body}");
|
|
1269
|
-
assert!(
|
|
2290
|
+
assert!(
|
|
2291
|
+
t.fake.requests().iter().all(|r| r.method != "DELETE"),
|
|
2292
|
+
"the old webhook was not removed"
|
|
2293
|
+
);
|
|
1270
2294
|
let saved = t.saved_document();
|
|
1271
|
-
assert_eq!(
|
|
2295
|
+
assert_eq!(
|
|
2296
|
+
(
|
|
2297
|
+
saved["test"]["secret_key"].as_str(),
|
|
2298
|
+
saved["test"]["webhook_endpoint_id"].as_str()
|
|
2299
|
+
),
|
|
2300
|
+
(Some(SAVED_KEY), Some("we_1"))
|
|
2301
|
+
);
|
|
1272
2302
|
assert_eq!(t.status().await, "connected");
|
|
1273
2303
|
}
|
|
1274
2304
|
|
|
1275
2305
|
#[tokio::test]
|
|
1276
2306
|
async fn disconnecting_forgets_the_keys_even_when_stripe_cannot_remove_the_webhook() {
|
|
1277
2307
|
let t = Tenant::saving("hecks_pay_test_save_disc_down").await;
|
|
1278
|
-
assert_eq!(
|
|
2308
|
+
assert_eq!(
|
|
2309
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2310
|
+
.await
|
|
2311
|
+
.0,
|
|
2312
|
+
200
|
|
2313
|
+
);
|
|
1279
2314
|
t.fake.state.fail_deletes.store(true, Ordering::SeqCst);
|
|
1280
|
-
let (status, body) = t
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
2315
|
+
let (status, body) = t
|
|
2316
|
+
.call(
|
|
2317
|
+
"POST",
|
|
2318
|
+
"/payments/connection/disconnect",
|
|
2319
|
+
json!({}),
|
|
2320
|
+
Some(OWNER),
|
|
2321
|
+
)
|
|
2322
|
+
.await;
|
|
2323
|
+
assert_eq!(
|
|
2324
|
+
(status, body["status"].as_str()),
|
|
2325
|
+
(200, Some("disconnected")),
|
|
2326
|
+
"{body}"
|
|
2327
|
+
);
|
|
2328
|
+
assert_eq!(
|
|
2329
|
+
t.fake.requests_to("/v1/webhook_endpoints/we_1").len(),
|
|
2330
|
+
1,
|
|
2331
|
+
"removal was attempted"
|
|
2332
|
+
);
|
|
2333
|
+
assert!(
|
|
2334
|
+
t.saved_document().get("test").is_none(),
|
|
2335
|
+
"the keys are gone anyway"
|
|
2336
|
+
);
|
|
1284
2337
|
}
|
|
1285
2338
|
|
|
1286
2339
|
#[tokio::test]
|
|
@@ -1294,9 +2347,22 @@ async fn the_public_mock_secret_is_refused_whenever_a_real_credential_is_configu
|
|
|
1294
2347
|
// Keys saved from the Payments page count too: only the saved signing secret verifies.
|
|
1295
2348
|
let mut t = Tenant::saving("hecks_pay_test_mock_saved").await;
|
|
1296
2349
|
t.platform.webhook_secret = None;
|
|
1297
|
-
assert_eq!(
|
|
1298
|
-
|
|
1299
|
-
|
|
2350
|
+
assert_eq!(
|
|
2351
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2352
|
+
.await
|
|
2353
|
+
.0,
|
|
2354
|
+
200
|
|
2355
|
+
);
|
|
2356
|
+
assert_eq!(
|
|
2357
|
+
t.webhook_signed_with(mock, completed("ref-2")).await,
|
|
2358
|
+
400,
|
|
2359
|
+
"the mock secret does not verify"
|
|
2360
|
+
);
|
|
2361
|
+
assert_eq!(
|
|
2362
|
+
t.webhook_signed_with("whsec_SAVED_1", completed("ref-2"))
|
|
2363
|
+
.await,
|
|
2364
|
+
200
|
|
2365
|
+
);
|
|
1300
2366
|
|
|
1301
2367
|
// No saved signing secret at all: refused outright, not trusted on the mock secret.
|
|
1302
2368
|
let raw = t.raw.as_ref().unwrap();
|
|
@@ -1310,24 +2376,52 @@ async fn the_public_mock_secret_is_refused_whenever_a_real_credential_is_configu
|
|
|
1310
2376
|
#[tokio::test]
|
|
1311
2377
|
async fn disconnecting_removes_the_webhook_from_stripe_and_deletes_the_saved_keys() {
|
|
1312
2378
|
let t = Tenant::saving("hecks_pay_test_save_disconnect").await;
|
|
1313
|
-
assert_eq!(
|
|
2379
|
+
assert_eq!(
|
|
2380
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2381
|
+
.await
|
|
2382
|
+
.0,
|
|
2383
|
+
200
|
|
2384
|
+
);
|
|
1314
2385
|
t.enable().await;
|
|
1315
|
-
let (status, body) = t
|
|
2386
|
+
let (status, body) = t
|
|
2387
|
+
.call(
|
|
2388
|
+
"POST",
|
|
2389
|
+
"/payments/connection/disconnect",
|
|
2390
|
+
json!({}),
|
|
2391
|
+
Some(OWNER),
|
|
2392
|
+
)
|
|
2393
|
+
.await;
|
|
1316
2394
|
assert_eq!(status, 200, "{body}");
|
|
1317
2395
|
assert_eq!(body["status"], "paused");
|
|
1318
2396
|
assert_eq!(body["direct_modes"], json!([]), "the keys are gone");
|
|
1319
2397
|
let removed = t.fake.requests_to("/v1/webhook_endpoints/we_1");
|
|
1320
2398
|
assert_eq!(removed.len(), 1);
|
|
1321
|
-
assert_eq!(
|
|
1322
|
-
|
|
2399
|
+
assert_eq!(
|
|
2400
|
+
removed[0].header("authorization"),
|
|
2401
|
+
Some(format!("Bearer {SAVED_KEY}").as_str())
|
|
2402
|
+
);
|
|
2403
|
+
assert!(
|
|
2404
|
+
t.saved_document().get("test").is_none(),
|
|
2405
|
+
"{}",
|
|
2406
|
+
t.saved_document()
|
|
2407
|
+
);
|
|
1323
2408
|
|
|
1324
2409
|
// Registrations pause rather than fall back to the mock...
|
|
1325
2410
|
t.schedule_event("gone-event").await;
|
|
1326
2411
|
let (status, body) = t.register("gone-event").await;
|
|
1327
|
-
assert_eq!(
|
|
2412
|
+
assert_eq!(
|
|
2413
|
+
(status, body["error"].as_str()),
|
|
2414
|
+
(503, Some("payments are temporarily unavailable"))
|
|
2415
|
+
);
|
|
1328
2416
|
// ...and saving again resumes the paused connection with fresh keys.
|
|
1329
|
-
let (status, body) = t
|
|
1330
|
-
|
|
2417
|
+
let (status, body) = t
|
|
2418
|
+
.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2419
|
+
.await;
|
|
2420
|
+
assert_eq!(
|
|
2421
|
+
(status, body["status"].as_str()),
|
|
2422
|
+
(200, Some("enabled")),
|
|
2423
|
+
"{body}"
|
|
2424
|
+
);
|
|
1331
2425
|
assert_eq!(t.saved_document()["test"]["webhook_endpoint_id"], "we_2");
|
|
1332
2426
|
}
|
|
1333
2427
|
|
|
@@ -1337,41 +2431,77 @@ async fn keys_set_in_the_environment_win_over_saved_ones() {
|
|
|
1337
2431
|
t.platform.direct_test_key = DIRECT_KEY.to_string();
|
|
1338
2432
|
t.platform.direct_test_publishable_key = DIRECT_PUBLISHABLE_KEY.to_string();
|
|
1339
2433
|
t.schedule_event("env-event").await;
|
|
1340
|
-
let (status, body) = t
|
|
2434
|
+
let (status, body) = t
|
|
2435
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2436
|
+
.await;
|
|
1341
2437
|
assert_eq!(status, 200, "{body}");
|
|
1342
2438
|
t.enable().await;
|
|
1343
2439
|
|
|
1344
2440
|
let (_, body) = t.register("env-event").await;
|
|
1345
|
-
assert_eq!(
|
|
2441
|
+
assert_eq!(
|
|
2442
|
+
body["embedded_checkout"]["publishable_key"],
|
|
2443
|
+
DIRECT_PUBLISHABLE_KEY
|
|
2444
|
+
);
|
|
1346
2445
|
let sent = t.fake.requests_to("/v1/checkout/sessions");
|
|
1347
|
-
assert_eq!(
|
|
2446
|
+
assert_eq!(
|
|
2447
|
+
sent[0].header("authorization"),
|
|
2448
|
+
Some(format!("Bearer {DIRECT_KEY}").as_str())
|
|
2449
|
+
);
|
|
1348
2450
|
}
|
|
1349
2451
|
|
|
1350
2452
|
#[tokio::test]
|
|
1351
2453
|
async fn a_store_that_cannot_be_written_saves_nothing_and_takes_the_webhook_back() {
|
|
1352
2454
|
let t = Tenant::saving("hecks_pay_test_save_storefail").await;
|
|
1353
|
-
t.raw
|
|
1354
|
-
|
|
2455
|
+
t.raw
|
|
2456
|
+
.as_ref()
|
|
2457
|
+
.unwrap()
|
|
2458
|
+
.fail_writes
|
|
2459
|
+
.store(true, Ordering::SeqCst);
|
|
2460
|
+
let (status, body) = t
|
|
2461
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2462
|
+
.await;
|
|
1355
2463
|
assert_eq!(status, 500, "{body}");
|
|
1356
|
-
assert_eq!(
|
|
2464
|
+
assert_eq!(
|
|
2465
|
+
body["error"],
|
|
2466
|
+
"The keys could not be stored, so nothing was saved. Please try again."
|
|
2467
|
+
);
|
|
1357
2468
|
assert_no_secret_in(&body.to_string());
|
|
1358
|
-
assert_eq!(
|
|
2469
|
+
assert_eq!(
|
|
2470
|
+
t.fake.requests_to("/v1/webhook_endpoints/we_1").len(),
|
|
2471
|
+
1,
|
|
2472
|
+
"the webhook just made was removed again"
|
|
2473
|
+
);
|
|
1359
2474
|
assert_eq!(t.status().await, "none");
|
|
1360
2475
|
}
|
|
1361
2476
|
|
|
1362
2477
|
#[tokio::test]
|
|
1363
2478
|
async fn the_saved_keys_are_read_from_a_short_cache_and_a_save_refreshes_it() {
|
|
1364
2479
|
let t = Tenant::saving("hecks_pay_test_save_cache").await;
|
|
1365
|
-
assert_eq!(
|
|
2480
|
+
assert_eq!(
|
|
2481
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2482
|
+
.await
|
|
2483
|
+
.0,
|
|
2484
|
+
200
|
|
2485
|
+
);
|
|
1366
2486
|
let raw = t.raw.as_ref().unwrap();
|
|
1367
2487
|
let reads = raw.reads.load(Ordering::SeqCst);
|
|
1368
2488
|
for _ in 0..3 {
|
|
1369
|
-
t.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
2489
|
+
t.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
2490
|
+
.await;
|
|
1370
2491
|
}
|
|
1371
|
-
assert_eq!(
|
|
2492
|
+
assert_eq!(
|
|
2493
|
+
raw.reads.load(Ordering::SeqCst),
|
|
2494
|
+
reads,
|
|
2495
|
+
"reads are served from the cache"
|
|
2496
|
+
);
|
|
1372
2497
|
|
|
1373
2498
|
// Saving different keys shows up in the very next request, not a minute later.
|
|
1374
|
-
assert_eq!(
|
|
2499
|
+
assert_eq!(
|
|
2500
|
+
t.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2501
|
+
.await
|
|
2502
|
+
.0,
|
|
2503
|
+
200
|
|
2504
|
+
);
|
|
1375
2505
|
assert_eq!(t.platform_now().await.direct_key("test"), SAVED_KEY_TWO);
|
|
1376
2506
|
}
|
|
1377
2507
|
|
|
@@ -1380,9 +2510,16 @@ fn key_shapes_decide_the_mode_and_a_mismatch_is_refused_without_quoting_a_key()
|
|
|
1380
2510
|
assert_eq!(keys_mode("rk_test_a", "pk_test_a"), Ok("test"));
|
|
1381
2511
|
assert_eq!(keys_mode("sk_live_a", "pk_live_a"), Ok("live"));
|
|
1382
2512
|
assert!(keys_mode("rk_test_a", "pk_live_a").is_err());
|
|
1383
|
-
assert!(
|
|
2513
|
+
assert!(
|
|
2514
|
+
keys_mode("pk_test_a", "pk_test_a").is_err(),
|
|
2515
|
+
"a publishable key is not a secret key"
|
|
2516
|
+
);
|
|
1384
2517
|
assert!(keys_mode("rk_test_a", "sk_test_a").is_err());
|
|
1385
|
-
for message in [
|
|
2518
|
+
for message in [
|
|
2519
|
+
keys_mode("rk_test_KEYMATERIAL", "x").unwrap_err(),
|
|
2520
|
+
keys_mode("x", "pk_test_KEYMATERIAL").unwrap_err(),
|
|
2521
|
+
keys_mode("rk_test_KEYMATERIAL", "pk_live_KEYMATERIAL").unwrap_err(),
|
|
2522
|
+
] {
|
|
1386
2523
|
assert!(!message.contains("KEYMATERIAL"), "{message}");
|
|
1387
2524
|
}
|
|
1388
2525
|
}
|
|
@@ -1391,27 +2528,48 @@ fn key_shapes_decide_the_mode_and_a_mismatch_is_refused_without_quoting_a_key()
|
|
|
1391
2528
|
fn the_webhook_description_is_the_configured_text_else_the_domain_name_and_website() {
|
|
1392
2529
|
assert_eq!(webhook_description(None, "Atelier"), "Atelier website");
|
|
1393
2530
|
assert_eq!(webhook_description(Some(""), "Atelier"), "Atelier website");
|
|
1394
|
-
assert_eq!(
|
|
1395
|
-
|
|
2531
|
+
assert_eq!(
|
|
2532
|
+
webhook_description(Some(" "), "Atelier"),
|
|
2533
|
+
"Atelier website"
|
|
2534
|
+
);
|
|
2535
|
+
assert_eq!(
|
|
2536
|
+
webhook_description(Some(" Atelier bookings "), "Atelier"),
|
|
2537
|
+
"Atelier bookings"
|
|
2538
|
+
);
|
|
1396
2539
|
}
|
|
1397
2540
|
|
|
1398
2541
|
#[tokio::test]
|
|
1399
2542
|
async fn the_webhook_endpoint_is_created_with_the_platform_description() {
|
|
1400
2543
|
let mut t = Tenant::saving("hecks_pay_test_webhook_description").await;
|
|
1401
2544
|
t.platform.webhook_description = "Atelier bookings".to_string();
|
|
1402
|
-
assert_eq!(
|
|
2545
|
+
assert_eq!(
|
|
2546
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2547
|
+
.await
|
|
2548
|
+
.0,
|
|
2549
|
+
200
|
|
2550
|
+
);
|
|
1403
2551
|
let created = t.fake.requests_to("/v1/webhook_endpoints");
|
|
1404
2552
|
assert_eq!(created.len(), 1);
|
|
1405
|
-
assert!(
|
|
2553
|
+
assert!(
|
|
2554
|
+
created[0].body.contains("description=Atelier+bookings"),
|
|
2555
|
+
"{}",
|
|
2556
|
+
created[0].body
|
|
2557
|
+
);
|
|
1406
2558
|
}
|
|
1407
2559
|
|
|
1408
2560
|
#[test]
|
|
1409
2561
|
fn purpose_tokens_only_verify_for_their_own_purpose_and_secret() {
|
|
1410
2562
|
let token = auth::purpose_token("k", "one", json!({"email": "a@b.c"}), 60);
|
|
1411
|
-
assert_eq!(
|
|
2563
|
+
assert_eq!(
|
|
2564
|
+
auth::verify_purpose_token("k", "one", &token).unwrap()["email"],
|
|
2565
|
+
"a@b.c"
|
|
2566
|
+
);
|
|
1412
2567
|
assert!(auth::verify_purpose_token("k", "two", &token).is_none());
|
|
1413
2568
|
assert!(auth::verify_purpose_token("other", "one", &token).is_none());
|
|
1414
|
-
assert!(
|
|
2569
|
+
assert!(
|
|
2570
|
+
auth::verify_account_token("k", &token).is_none(),
|
|
2571
|
+
"not a session cookie"
|
|
2572
|
+
);
|
|
1415
2573
|
}
|
|
1416
2574
|
|
|
1417
2575
|
// ADR 0099: the processor and the modes are this adapter's own words, but the domain still has to
|
|
@@ -1419,8 +2577,20 @@ fn purpose_tokens_only_verify_for_their_own_purpose_and_secret() {
|
|
|
1419
2577
|
#[test]
|
|
1420
2578
|
fn the_adapter_words_are_ones_the_connection_domain_admits() {
|
|
1421
2579
|
let ir = crate::commerce_ir::fixture_ir();
|
|
1422
|
-
let source = ir["source_text"]
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
assert!(
|
|
2580
|
+
let source = ir["source_text"]
|
|
2581
|
+
.as_str()
|
|
2582
|
+
.expect("the fixture carries its bluebook source");
|
|
2583
|
+
assert!(
|
|
2584
|
+
source.contains(&format!("one_of: {MODES:?}")),
|
|
2585
|
+
"ConnectionMode must admit exactly {MODES:?}"
|
|
2586
|
+
);
|
|
2587
|
+
let payments = std::fs::read_to_string(
|
|
2588
|
+
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
2589
|
+
.join("../../spec/fixtures/rust_host/checkout_fixture/bluebook/payments.bluebook"),
|
|
2590
|
+
)
|
|
2591
|
+
.expect("payments bluebook");
|
|
2592
|
+
assert!(
|
|
2593
|
+
payments.contains(&format!("one_of: [\"mock_stripe\", \"{PROCESSOR}\"]")),
|
|
2594
|
+
"Payment's Processor must admit {PROCESSOR}"
|
|
2595
|
+
);
|
|
1426
2596
|
}
|