hecks 3.14.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.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/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/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 +2 -0
- data/lib/hecks/chapters.rb +3 -3
- 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/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/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- 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 +21 -0
- 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 +3 -80
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +7 -13
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- 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/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/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/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
- data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
- data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json} +1 -1
- 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 +39 -7
- data/rust/host/src/payments/tests.rs +1427 -282
- data/rust/host/src/payments.rs +391 -112
- 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 -1
- data/rust/parser/src/build/named_pattern.rs +4 -1
- 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 -516
- 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 +22 -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 +82 -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/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);
|
|
@@ -202,8 +253,13 @@ async fn tenant(name: &str) -> Tenant {
|
|
|
202
253
|
Tenant {
|
|
203
254
|
client,
|
|
204
255
|
domain_ir,
|
|
205
|
-
config: LineageConfig {
|
|
206
|
-
|
|
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"),
|
|
207
263
|
fake,
|
|
208
264
|
platform,
|
|
209
265
|
raw: None,
|
|
@@ -221,7 +277,7 @@ impl Tenant {
|
|
|
221
277
|
|
|
222
278
|
// A tenant whose business can paste its keys into the Payments page: no
|
|
223
279
|
// environment keys, only a key store (in memory).
|
|
224
|
-
async fn saving(name: &str) ->
|
|
280
|
+
async fn saving(name: &str) -> Self {
|
|
225
281
|
let mut t = tenant(name).await;
|
|
226
282
|
t.platform.direct_test_key = String::new();
|
|
227
283
|
t.platform.direct_test_publishable_key = String::new();
|
|
@@ -232,49 +288,98 @@ impl Tenant {
|
|
|
232
288
|
}
|
|
233
289
|
|
|
234
290
|
// The Payments page's Save, as the given person.
|
|
235
|
-
async fn save_keys(
|
|
236
|
-
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
|
|
237
304
|
}
|
|
238
305
|
|
|
239
306
|
// What the saved-keys secret holds right now (`Null` before the first save).
|
|
240
307
|
fn saved_document(&self) -> Value {
|
|
241
308
|
let raw = self.raw.as_ref().expect("a tenant with a key store");
|
|
242
309
|
let text = raw.document.lock().unwrap().clone();
|
|
243
|
-
text.
|
|
310
|
+
text.map_or(Value::Null, |text| serde_json::from_str(&text).unwrap())
|
|
244
311
|
}
|
|
245
312
|
|
|
246
313
|
// One request against the payments routes; `None` means no session cookie.
|
|
247
|
-
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) {
|
|
248
321
|
let mut cookies = HashMap::new();
|
|
249
322
|
if let Some(email) = as_email {
|
|
250
|
-
cookies.insert(
|
|
323
|
+
cookies.insert(
|
|
324
|
+
auth::account_cookie_name(),
|
|
325
|
+
auth::account_token(SESSION_SECRET, email, 60),
|
|
326
|
+
);
|
|
251
327
|
}
|
|
252
328
|
let platform = self.platform_now().await;
|
|
253
|
-
let response = route(
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
+
)
|
|
257
345
|
}
|
|
258
346
|
|
|
259
347
|
async fn status(&self) -> String {
|
|
260
348
|
let read = dispatch::read(&self.client, &self.wasm).await.unwrap();
|
|
261
|
-
connection(&read, &self.config.domain).
|
|
349
|
+
connection(&read, &self.config.domain).map_or_else(|| "none".to_string(), |c| c.status)
|
|
262
350
|
}
|
|
263
351
|
|
|
264
352
|
// Owner records the business's own test-mode account as the connection.
|
|
265
353
|
async fn connect(&self) {
|
|
266
|
-
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;
|
|
267
362
|
assert_eq!(status, 200, "{body}");
|
|
268
363
|
}
|
|
269
364
|
|
|
270
365
|
// Every row of every table in the scratch database, as text.
|
|
271
366
|
async fn dump_database(&self) -> String {
|
|
272
367
|
let guard = self.client.lock().await;
|
|
273
|
-
let tables = guard
|
|
368
|
+
let tables = guard
|
|
369
|
+
.query(
|
|
370
|
+
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'",
|
|
371
|
+
&[],
|
|
372
|
+
)
|
|
373
|
+
.await
|
|
374
|
+
.unwrap();
|
|
274
375
|
let mut dump = String::new();
|
|
275
376
|
for table in tables {
|
|
276
377
|
let name: String = table.get(0);
|
|
277
|
-
for row in guard
|
|
378
|
+
for row in guard
|
|
379
|
+
.query(&format!("SELECT t::text FROM \"{name}\" t"), &[])
|
|
380
|
+
.await
|
|
381
|
+
.unwrap()
|
|
382
|
+
{
|
|
278
383
|
dump.push_str(&row.get::<_, String>(0));
|
|
279
384
|
dump.push('\n');
|
|
280
385
|
}
|
|
@@ -283,7 +388,14 @@ impl Tenant {
|
|
|
283
388
|
}
|
|
284
389
|
|
|
285
390
|
async fn enable(&self) {
|
|
286
|
-
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;
|
|
287
399
|
assert_eq!(status, 200, "{body}");
|
|
288
400
|
}
|
|
289
401
|
|
|
@@ -293,16 +405,33 @@ impl Tenant {
|
|
|
293
405
|
|
|
294
406
|
async fn schedule_event_with_capacity(&self, slug: &str, capacity: i64) {
|
|
295
407
|
let args = json!({"slug": {"value": slug}, "name": {"value": "Sample Studio"}, "price": {"cents": 4200}, "capacity": {"value": capacity}});
|
|
296
|
-
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();
|
|
297
416
|
assert!(outcome.accepted, "{:?}", outcome.result);
|
|
298
417
|
}
|
|
299
418
|
|
|
300
419
|
// POST /registrations for `slug`; the checkout URL and registration id on success.
|
|
301
420
|
async fn register(&self, slug: &str) -> (u64, Value) {
|
|
302
|
-
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();
|
|
303
423
|
let platform = self.platform_now().await;
|
|
304
|
-
let response = web::registrations_route(
|
|
305
|
-
|
|
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
|
+
)
|
|
306
435
|
}
|
|
307
436
|
|
|
308
437
|
async fn webhook(&self, event: Value) -> u64 {
|
|
@@ -312,23 +441,42 @@ impl Tenant {
|
|
|
312
441
|
// A webhook delivery signed with `secret`, the way Stripe would sign it.
|
|
313
442
|
async fn webhook_signed_with(&self, secret: &str, event: Value) -> u64 {
|
|
314
443
|
let payload = event.to_string();
|
|
315
|
-
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;
|
|
316
448
|
let header = sign(secret, now, &payload);
|
|
317
449
|
let platform = self.platform_now().await;
|
|
318
|
-
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;
|
|
319
458
|
response["statusCode"].as_u64().unwrap()
|
|
320
459
|
}
|
|
321
460
|
|
|
322
461
|
// POST /registrations/:id/complete with a successful outcome (the mock
|
|
323
462
|
// walkthrough's "Pay" button).
|
|
324
463
|
async fn settle(&self, reference: &str) -> u64 {
|
|
325
|
-
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;
|
|
326
471
|
response["statusCode"].as_u64().unwrap()
|
|
327
472
|
}
|
|
328
473
|
|
|
329
474
|
async fn payment_status(&self, reference: &str) -> String {
|
|
330
475
|
let read = dispatch::read(&self.client, &self.wasm).await.unwrap();
|
|
331
|
-
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()
|
|
332
480
|
}
|
|
333
481
|
}
|
|
334
482
|
|
|
@@ -337,7 +485,15 @@ fn sign(secret: &str, now: i64, payload: &str) -> String {
|
|
|
337
485
|
use sha2::Sha256;
|
|
338
486
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
|
339
487
|
mac.update(format!("{now}.{payload}").as_bytes());
|
|
340
|
-
|
|
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}")
|
|
341
497
|
}
|
|
342
498
|
|
|
343
499
|
fn completed(reference: &str) -> Value {
|
|
@@ -359,9 +515,16 @@ async fn every_route_refuses_a_missing_session_with_a_json_401_and_touches_nothi
|
|
|
359
515
|
("POST", "/payments/connection/disable"),
|
|
360
516
|
] {
|
|
361
517
|
let (status, body) = t.call(method, path, json!({}), None).await;
|
|
362
|
-
assert_eq!(
|
|
518
|
+
assert_eq!(
|
|
519
|
+
(status, body["error"].as_str()),
|
|
520
|
+
(401, Some("not logged in")),
|
|
521
|
+
"{method} {path}"
|
|
522
|
+
);
|
|
363
523
|
}
|
|
364
|
-
assert!(
|
|
524
|
+
assert!(
|
|
525
|
+
t.fake.requests().is_empty(),
|
|
526
|
+
"no Stripe call without a session"
|
|
527
|
+
);
|
|
365
528
|
assert_eq!(t.status().await, "none");
|
|
366
529
|
}
|
|
367
530
|
|
|
@@ -369,14 +532,42 @@ async fn every_route_refuses_a_missing_session_with_a_json_401_and_touches_nothi
|
|
|
369
532
|
async fn a_tampered_or_disabled_session_is_a_401_not_an_owner() {
|
|
370
533
|
let t = tenant("hecks_pay_test_401_disabled").await;
|
|
371
534
|
let mut cookies = HashMap::new();
|
|
372
|
-
cookies.insert(
|
|
373
|
-
|
|
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();
|
|
374
551
|
assert_eq!(response["statusCode"], 401);
|
|
375
552
|
|
|
376
553
|
// An Owner disabled after the cookie was signed: the signature is still valid.
|
|
377
|
-
let (status, _) = t
|
|
554
|
+
let (status, _) = t
|
|
555
|
+
.call(
|
|
556
|
+
"GET",
|
|
557
|
+
"/payments/connection",
|
|
558
|
+
json!({}),
|
|
559
|
+
Some(DISABLED_OWNER),
|
|
560
|
+
)
|
|
561
|
+
.await;
|
|
378
562
|
assert_eq!(status, 401);
|
|
379
|
-
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;
|
|
380
571
|
assert_eq!(status, 401);
|
|
381
572
|
}
|
|
382
573
|
|
|
@@ -385,30 +576,60 @@ async fn only_an_owner_may_connect_or_disconnect_and_only_the_operator_may_enabl
|
|
|
385
576
|
let t = tenant("hecks_pay_test_roles").await;
|
|
386
577
|
|
|
387
578
|
// A non-Owner admin: read refused, every owner action refused.
|
|
388
|
-
let (status, _) = t
|
|
579
|
+
let (status, _) = t
|
|
580
|
+
.call("GET", "/payments/connection", json!({}), Some(ADMIN))
|
|
581
|
+
.await;
|
|
389
582
|
assert_eq!(status, 403);
|
|
390
|
-
for path in [
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
);
|
|
393
595
|
}
|
|
394
596
|
|
|
395
597
|
t.connect().await;
|
|
396
598
|
assert_eq!(t.status().await, "connected");
|
|
397
599
|
|
|
398
600
|
// The Owner is not the operator: enable and disable are refused.
|
|
399
|
-
for path in [
|
|
601
|
+
for path in [
|
|
602
|
+
"/payments/connection/enable",
|
|
603
|
+
"/payments/connection/disable",
|
|
604
|
+
] {
|
|
400
605
|
let (status, _) = t.call("POST", path, json!({}), Some(OWNER)).await;
|
|
401
606
|
assert_eq!(status, 403, "{path}");
|
|
402
607
|
}
|
|
403
608
|
// The operator is not an Owner: cannot disconnect.
|
|
404
|
-
let (status, _) = t
|
|
609
|
+
let (status, _) = t
|
|
610
|
+
.call(
|
|
611
|
+
"POST",
|
|
612
|
+
"/payments/connection/disconnect",
|
|
613
|
+
json!({}),
|
|
614
|
+
Some(OPERATOR),
|
|
615
|
+
)
|
|
616
|
+
.await;
|
|
405
617
|
assert_eq!(status, 403);
|
|
406
|
-
assert_eq!(
|
|
618
|
+
assert_eq!(
|
|
619
|
+
t.status().await,
|
|
620
|
+
"connected",
|
|
621
|
+
"nothing above changed the connection"
|
|
622
|
+
);
|
|
407
623
|
|
|
408
624
|
// ...but the operator may read the page state, and enable.
|
|
409
|
-
let (status, body) = t
|
|
625
|
+
let (status, body) = t
|
|
626
|
+
.call("GET", "/payments/connection", json!({}), Some(OPERATOR))
|
|
627
|
+
.await;
|
|
410
628
|
assert_eq!(status, 200);
|
|
411
|
-
assert_eq!(
|
|
629
|
+
assert_eq!(
|
|
630
|
+
(body["can_manage"].clone(), body["can_enable"].clone()),
|
|
631
|
+
(json!(false), json!(true))
|
|
632
|
+
);
|
|
412
633
|
t.enable().await;
|
|
413
634
|
assert_eq!(t.status().await, "enabled");
|
|
414
635
|
}
|
|
@@ -418,103 +639,258 @@ async fn a_site_that_lists_admin_as_a_payments_manager_lets_an_admin_manage() {
|
|
|
418
639
|
let mut t = tenant("hecks_pay_test_manager_roles").await;
|
|
419
640
|
t.platform.manager_roles = vec!["Owner".to_string(), "Admin".to_string()];
|
|
420
641
|
|
|
421
|
-
let (status, body) = t
|
|
422
|
-
|
|
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
|
+
);
|
|
423
650
|
t.connect().await;
|
|
424
651
|
assert_eq!(t.status().await, "connected");
|
|
425
|
-
let (status, _) = t
|
|
652
|
+
let (status, _) = t
|
|
653
|
+
.call(
|
|
654
|
+
"POST",
|
|
655
|
+
"/payments/connection/disconnect",
|
|
656
|
+
json!({}),
|
|
657
|
+
Some(ADMIN),
|
|
658
|
+
)
|
|
659
|
+
.await;
|
|
426
660
|
assert_eq!(status, 200);
|
|
427
661
|
assert_eq!(t.status().await, "disconnected");
|
|
428
662
|
|
|
429
663
|
// Enabling stays with the operator, and the Owner still manages.
|
|
430
|
-
let (status, body) = t
|
|
431
|
-
|
|
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
|
+
);
|
|
432
672
|
// Someone signed out, or not listed, is refused with today's message.
|
|
433
673
|
let (status, _) = t.call("GET", "/payments/connection", json!({}), None).await;
|
|
434
674
|
assert_eq!(status, 401);
|
|
435
675
|
t.platform.manager_roles = vec!["Editor".to_string()];
|
|
436
|
-
let (status, body) = t
|
|
437
|
-
|
|
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
|
+
);
|
|
438
688
|
}
|
|
439
689
|
|
|
440
690
|
#[tokio::test]
|
|
441
691
|
async fn an_admin_granted_owner_passes_the_payments_gate_and_keeps_the_admin_gate() {
|
|
442
692
|
let t = tenant("hecks_pay_test_owner_bootstrap").await;
|
|
443
|
-
let members = LineageConfig {
|
|
444
|
-
|
|
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
|
+
};
|
|
445
704
|
|
|
446
705
|
// An Admin is not an Owner: the payments routes refuse them.
|
|
447
|
-
let (status, _) = t
|
|
706
|
+
let (status, _) = t
|
|
707
|
+
.call("GET", "/payments/connection", json!({}), Some(ADMIN))
|
|
708
|
+
.await;
|
|
448
709
|
assert_eq!(status, 403);
|
|
449
710
|
|
|
450
711
|
// An Admin grants Owner to a new person; that person passes the Payments
|
|
451
712
|
// Owner gate and is still admitted by the members admin gate.
|
|
452
713
|
let body = json!({"email": "boss@example.com", "name": "Boss", "role": "Owner"}).to_string();
|
|
453
|
-
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;
|
|
454
724
|
assert_eq!(response["statusCode"], 201, "{response:?}");
|
|
455
|
-
let (status, body) = t
|
|
456
|
-
|
|
457
|
-
|
|
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;
|
|
458
748
|
assert_eq!(response["statusCode"], 201, "{response:?}");
|
|
459
749
|
|
|
460
750
|
// Granting Owner to an existing Admin turns them into an Owner too.
|
|
461
|
-
assert!(
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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());
|
|
465
767
|
}
|
|
466
768
|
|
|
467
769
|
#[tokio::test]
|
|
468
770
|
async fn the_status_route_reports_not_connected_and_which_modes_have_keys() {
|
|
469
771
|
let t = tenant("hecks_pay_test_status_empty").await;
|
|
470
|
-
let (status, body) = t
|
|
772
|
+
let (status, body) = t
|
|
773
|
+
.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
774
|
+
.await;
|
|
471
775
|
assert_eq!(status, 200);
|
|
472
776
|
assert_eq!(body["status"], "not_connected");
|
|
473
777
|
assert_eq!(body["processor"], Value::Null);
|
|
474
|
-
assert_eq!(
|
|
475
|
-
|
|
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
|
+
);
|
|
476
790
|
|
|
477
791
|
// With no keys at all, no mode is offered.
|
|
478
792
|
let mut bare = tenant("hecks_pay_test_status_bare").await;
|
|
479
793
|
bare.platform.direct_test_key = String::new();
|
|
480
|
-
let (_, body) = bare
|
|
794
|
+
let (_, body) = bare
|
|
795
|
+
.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
796
|
+
.await;
|
|
481
797
|
assert_eq!(body["direct_modes"], json!([]));
|
|
482
798
|
}
|
|
483
799
|
|
|
484
800
|
#[tokio::test]
|
|
485
801
|
async fn enable_needs_a_connection_and_follows_the_lifecycle() {
|
|
486
802
|
let t = tenant("hecks_pay_test_enable").await;
|
|
487
|
-
let (status, body) = t
|
|
488
|
-
|
|
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
|
+
);
|
|
489
815
|
|
|
490
816
|
t.connect().await;
|
|
491
|
-
let (status, body) = t
|
|
492
|
-
|
|
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
|
+
);
|
|
493
829
|
|
|
494
830
|
t.enable().await;
|
|
495
|
-
let (status, body) = t
|
|
831
|
+
let (status, body) = t
|
|
832
|
+
.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
833
|
+
.await;
|
|
496
834
|
assert_eq!((status, body["status"].as_str()), (200, Some("enabled")));
|
|
497
|
-
let (status, _) = t
|
|
835
|
+
let (status, _) = t
|
|
836
|
+
.call(
|
|
837
|
+
"POST",
|
|
838
|
+
"/payments/connection/enable",
|
|
839
|
+
json!({}),
|
|
840
|
+
Some(OPERATOR),
|
|
841
|
+
)
|
|
842
|
+
.await;
|
|
498
843
|
assert_eq!(status, 422, "enable is refused when already enabled");
|
|
499
844
|
|
|
500
|
-
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;
|
|
501
853
|
assert_eq!((status, body["status"].as_str()), (200, Some("connected")));
|
|
502
854
|
}
|
|
503
855
|
|
|
504
856
|
#[tokio::test]
|
|
505
857
|
async fn disconnecting_a_not_yet_enabled_account_unlinks_it_and_it_can_be_reconnected() {
|
|
506
858
|
let t = tenant("hecks_pay_test_disconnect").await;
|
|
507
|
-
let (status, body) = t
|
|
508
|
-
|
|
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
|
+
);
|
|
509
871
|
|
|
510
872
|
t.connect().await;
|
|
511
|
-
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;
|
|
512
881
|
assert_eq!(status, 200, "{body}");
|
|
513
882
|
assert_eq!(body["status"], "disconnected");
|
|
514
|
-
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
|
+
);
|
|
515
887
|
|
|
516
888
|
t.connect().await;
|
|
517
|
-
assert_eq!(
|
|
889
|
+
assert_eq!(
|
|
890
|
+
t.status().await,
|
|
891
|
+
"connected",
|
|
892
|
+
"a disconnected link reconnects"
|
|
893
|
+
);
|
|
518
894
|
}
|
|
519
895
|
|
|
520
896
|
#[tokio::test]
|
|
@@ -523,16 +899,34 @@ async fn disconnecting_while_payments_are_enabled_pauses_them() {
|
|
|
523
899
|
t.connect().await;
|
|
524
900
|
t.enable().await;
|
|
525
901
|
|
|
526
|
-
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;
|
|
527
910
|
assert_eq!(status, 200, "{body}");
|
|
528
911
|
assert_eq!(body["status"], "paused");
|
|
529
912
|
|
|
530
913
|
// Paused is not disconnected: it cannot be disconnected again, and a new
|
|
531
914
|
// connection resumes it instead of starting over.
|
|
532
|
-
let (status, _) = t
|
|
915
|
+
let (status, _) = t
|
|
916
|
+
.call(
|
|
917
|
+
"POST",
|
|
918
|
+
"/payments/connection/disconnect",
|
|
919
|
+
json!({}),
|
|
920
|
+
Some(OWNER),
|
|
921
|
+
)
|
|
922
|
+
.await;
|
|
533
923
|
assert_eq!(status, 409);
|
|
534
924
|
t.connect().await;
|
|
535
|
-
assert_eq!(
|
|
925
|
+
assert_eq!(
|
|
926
|
+
t.status().await,
|
|
927
|
+
"enabled",
|
|
928
|
+
"reconnecting a paused connection resumes it"
|
|
929
|
+
);
|
|
536
930
|
}
|
|
537
931
|
|
|
538
932
|
#[tokio::test]
|
|
@@ -543,17 +937,35 @@ async fn checkout_stays_on_the_mock_until_payments_are_enabled() {
|
|
|
543
937
|
// No connection at all.
|
|
544
938
|
let (status, body) = t.register("mock-event").await;
|
|
545
939
|
assert_eq!(status, 200, "{body}");
|
|
546
|
-
assert!(
|
|
940
|
+
assert!(
|
|
941
|
+
body["checkout_url"]
|
|
942
|
+
.as_str()
|
|
943
|
+
.unwrap()
|
|
944
|
+
.starts_with("https://site.example/pay/"),
|
|
945
|
+
"{body}"
|
|
946
|
+
);
|
|
547
947
|
|
|
548
948
|
// Connected but not enabled: still the mock.
|
|
549
949
|
t.connect().await;
|
|
550
950
|
let (status, body) = t.register("mock-event").await;
|
|
551
951
|
assert_eq!(status, 200, "{body}");
|
|
552
|
-
assert!(
|
|
553
|
-
|
|
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
|
+
);
|
|
554
963
|
|
|
555
964
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
556
|
-
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();
|
|
557
969
|
assert_eq!(processors, vec!["mock_stripe", "mock_stripe"]);
|
|
558
970
|
}
|
|
559
971
|
|
|
@@ -568,7 +980,11 @@ async fn an_enabled_connection_charges_on_the_business_own_account() {
|
|
|
568
980
|
assert_eq!(status, 200, "{body}");
|
|
569
981
|
// The guest pays in a form embedded in the site: what the browser needs to
|
|
570
982
|
// mount it, and no hosted page to send them to.
|
|
571
|
-
assert_eq!(
|
|
983
|
+
assert_eq!(
|
|
984
|
+
body.get("checkout_url"),
|
|
985
|
+
None,
|
|
986
|
+
"nothing leaves the site: {body}"
|
|
987
|
+
);
|
|
572
988
|
assert_eq!(
|
|
573
989
|
body["embedded_checkout"],
|
|
574
990
|
json!({
|
|
@@ -581,17 +997,54 @@ async fn an_enabled_connection_charges_on_the_business_own_account() {
|
|
|
581
997
|
|
|
582
998
|
let sent = t.fake.requests_to("/v1/checkout/sessions");
|
|
583
999
|
assert_eq!(sent.len(), 1);
|
|
584
|
-
assert_eq!(
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
+
);
|
|
592
1042
|
|
|
593
1043
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
594
|
-
assert_eq!(
|
|
1044
|
+
assert_eq!(
|
|
1045
|
+
read["instances"][format!("Payments::Payment#{reference}")]["processor"]["value"],
|
|
1046
|
+
"stripe"
|
|
1047
|
+
);
|
|
595
1048
|
}
|
|
596
1049
|
|
|
597
1050
|
#[tokio::test]
|
|
@@ -603,16 +1056,32 @@ async fn a_stripe_refusal_answers_502_and_leaves_no_payment_or_registration_behi
|
|
|
603
1056
|
t.fake.state.refuse_sessions.store(true, Ordering::SeqCst);
|
|
604
1057
|
|
|
605
1058
|
let (status, body) = t.register("refused-event").await;
|
|
606
|
-
assert_eq!(
|
|
1059
|
+
assert_eq!(
|
|
1060
|
+
(status, body["error"].as_str()),
|
|
1061
|
+
(502, Some("payments are temporarily unavailable"))
|
|
1062
|
+
);
|
|
607
1063
|
assert_eq!(body.get("checkout_url"), None);
|
|
608
1064
|
assert_eq!(body.get("embedded_checkout"), None);
|
|
609
|
-
assert!(
|
|
610
|
-
|
|
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
|
+
);
|
|
611
1074
|
|
|
612
1075
|
// The session is opened first, so nothing was recorded for the failed try.
|
|
613
1076
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
614
|
-
assert!(
|
|
615
|
-
|
|
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
|
+
);
|
|
616
1085
|
|
|
617
1086
|
// Once Stripe recovers a retry registers normally.
|
|
618
1087
|
t.fake.state.refuse_sessions.store(false, Ordering::SeqCst);
|
|
@@ -620,11 +1089,17 @@ async fn a_stripe_refusal_answers_502_and_leaves_no_payment_or_registration_behi
|
|
|
620
1089
|
assert_eq!(status, 200, "{body}");
|
|
621
1090
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
622
1091
|
assert_eq!(instances_for(&read, "Payments::Payment#").len(), 1);
|
|
623
|
-
assert_eq!(
|
|
1092
|
+
assert_eq!(
|
|
1093
|
+
instances_for(&read, "CheckoutFixture::Registration#").len(),
|
|
1094
|
+
1
|
|
1095
|
+
);
|
|
624
1096
|
}
|
|
625
1097
|
|
|
626
1098
|
fn unix_secs() -> i64 {
|
|
627
|
-
std::time::SystemTime::now()
|
|
1099
|
+
std::time::SystemTime::now()
|
|
1100
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
1101
|
+
.unwrap()
|
|
1102
|
+
.as_secs() as i64
|
|
628
1103
|
}
|
|
629
1104
|
|
|
630
1105
|
#[tokio::test]
|
|
@@ -639,12 +1114,27 @@ async fn a_full_event_answers_409_and_writes_nothing_and_calls_no_stripe() {
|
|
|
639
1114
|
let sessions = t.fake.requests_to("/v1/checkout/sessions").len();
|
|
640
1115
|
|
|
641
1116
|
let (status, body) = t.register("one-seat").await;
|
|
642
|
-
assert_eq!(
|
|
1117
|
+
assert_eq!(
|
|
1118
|
+
(status, body["error"].as_str()),
|
|
1119
|
+
(409, Some("this event is full"))
|
|
1120
|
+
);
|
|
643
1121
|
assert_eq!(body.get("embedded_checkout"), None);
|
|
644
|
-
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
|
+
);
|
|
645
1127
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
646
|
-
assert_eq!(
|
|
647
|
-
|
|
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
|
+
);
|
|
648
1138
|
}
|
|
649
1139
|
|
|
650
1140
|
#[tokio::test]
|
|
@@ -656,7 +1146,10 @@ async fn the_last_seat_goes_through_the_mock_walkthrough_and_the_next_attempt_is
|
|
|
656
1146
|
for _ in 0..2 {
|
|
657
1147
|
let (status, body) = t.register("two-seats").await;
|
|
658
1148
|
assert_eq!(status, 200, "{body}");
|
|
659
|
-
assert!(
|
|
1149
|
+
assert!(
|
|
1150
|
+
body["checkout_url"].is_string(),
|
|
1151
|
+
"the mock plan still answers a checkout_url: {body}"
|
|
1152
|
+
);
|
|
660
1153
|
references.push(body["registration_id"].as_str().unwrap().to_string());
|
|
661
1154
|
}
|
|
662
1155
|
for reference in &references {
|
|
@@ -665,8 +1158,14 @@ async fn the_last_seat_goes_through_the_mock_walkthrough_and_the_next_attempt_is
|
|
|
665
1158
|
}
|
|
666
1159
|
|
|
667
1160
|
let (status, body) = t.register("two-seats").await;
|
|
668
|
-
assert_eq!(
|
|
669
|
-
|
|
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
|
+
);
|
|
670
1169
|
}
|
|
671
1170
|
|
|
672
1171
|
#[tokio::test]
|
|
@@ -687,7 +1186,10 @@ async fn an_unpaid_checkout_holds_the_seat_until_its_session_expires() {
|
|
|
687
1186
|
assert_eq!(t.webhook(expired(&reference)).await, 200);
|
|
688
1187
|
assert_eq!(t.payment_status(&reference).await, "failed");
|
|
689
1188
|
let (status, body) = t.register("held").await;
|
|
690
|
-
assert_eq!(
|
|
1189
|
+
assert_eq!(
|
|
1190
|
+
status, 200,
|
|
1191
|
+
"an expired checkout gives the seat back: {body}"
|
|
1192
|
+
);
|
|
691
1193
|
}
|
|
692
1194
|
|
|
693
1195
|
#[tokio::test]
|
|
@@ -697,12 +1199,21 @@ async fn a_declined_payment_frees_the_seat_and_a_paid_one_keeps_it() {
|
|
|
697
1199
|
|
|
698
1200
|
let (_, body) = t.register("declined").await;
|
|
699
1201
|
let first = body["registration_id"].as_str().unwrap().to_string();
|
|
700
|
-
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;
|
|
701
1209
|
assert_eq!(response["statusCode"].as_u64(), Some(200));
|
|
702
1210
|
assert_eq!(t.payment_status(&first).await, "failed");
|
|
703
1211
|
|
|
704
1212
|
let (status, body) = t.register("declined").await;
|
|
705
|
-
assert_eq!(
|
|
1213
|
+
assert_eq!(
|
|
1214
|
+
status, 200,
|
|
1215
|
+
"a declined payment gives the seat back: {body}"
|
|
1216
|
+
);
|
|
706
1217
|
let second = body["registration_id"].as_str().unwrap().to_string();
|
|
707
1218
|
assert_eq!(t.settle(&second).await, 200);
|
|
708
1219
|
|
|
@@ -714,12 +1225,22 @@ async fn a_declined_payment_frees_the_seat_and_a_paid_one_keeps_it() {
|
|
|
714
1225
|
// internal dispatch does; the outcome is the kernel's own accept/refuse.
|
|
715
1226
|
async fn registration_command(t: &Tenant, verb: &str, reference: &str) -> dispatch::Outcome {
|
|
716
1227
|
let verb = format!("CheckoutFixture::Registration.{verb}");
|
|
717
|
-
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()
|
|
718
1236
|
}
|
|
719
1237
|
|
|
720
1238
|
async fn registration_status(t: &Tenant, reference: &str) -> String {
|
|
721
1239
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
722
|
-
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()
|
|
723
1244
|
}
|
|
724
1245
|
|
|
725
1246
|
#[tokio::test]
|
|
@@ -730,19 +1251,38 @@ async fn archiving_a_paid_registration_frees_its_seat_and_restoring_it_takes_the
|
|
|
730
1251
|
let (_, body) = t.register("archive-me").await;
|
|
731
1252
|
let reference = body["registration_id"].as_str().unwrap().to_string();
|
|
732
1253
|
assert_eq!(t.settle(&reference).await, 200);
|
|
733
|
-
assert_eq!(
|
|
1254
|
+
assert_eq!(
|
|
1255
|
+
registration_status(&t, &reference).await,
|
|
1256
|
+
"active",
|
|
1257
|
+
"a new Registration starts active"
|
|
1258
|
+
);
|
|
734
1259
|
let (status, _) = t.register("archive-me").await;
|
|
735
1260
|
assert_eq!(status, 409, "the paid registration holds the only seat");
|
|
736
1261
|
|
|
737
1262
|
let archived = registration_command(&t, "Archive", &reference).await;
|
|
738
1263
|
assert!(archived.accepted, "{:?}", archived.result);
|
|
739
1264
|
assert_eq!(registration_status(&t, &reference).await, "archived");
|
|
740
|
-
assert_eq!(
|
|
1265
|
+
assert_eq!(
|
|
1266
|
+
t.payment_status(&reference).await,
|
|
1267
|
+
"succeeded",
|
|
1268
|
+
"archiving never touches the Payment"
|
|
1269
|
+
);
|
|
741
1270
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
742
|
-
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
|
+
);
|
|
743
1280
|
|
|
744
1281
|
let (status, body) = t.register("archive-me").await;
|
|
745
|
-
assert_eq!(
|
|
1282
|
+
assert_eq!(
|
|
1283
|
+
status, 200,
|
|
1284
|
+
"the archived registration's seat is free again: {body}"
|
|
1285
|
+
);
|
|
746
1286
|
let second = body["registration_id"].as_str().unwrap().to_string();
|
|
747
1287
|
assert_eq!(t.settle(&second).await, 200);
|
|
748
1288
|
let (status, _) = t.register("archive-me").await;
|
|
@@ -757,7 +1297,15 @@ async fn archiving_a_paid_registration_frees_its_seat_and_restoring_it_takes_the
|
|
|
757
1297
|
2,
|
|
758
1298
|
"a restored registration counts again, even past capacity: the site refuses a restore with no seat"
|
|
759
1299
|
);
|
|
760
|
-
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
|
+
);
|
|
761
1309
|
}
|
|
762
1310
|
|
|
763
1311
|
#[tokio::test]
|
|
@@ -771,8 +1319,13 @@ async fn cancelling_a_paid_registration_reaches_a_state_the_fixture_declares_as_
|
|
|
771
1319
|
let cancelled = registration_command(&t, "Cancel", &reference).await;
|
|
772
1320
|
assert!(cancelled.accepted, "{:?}", cancelled.result);
|
|
773
1321
|
let state = registration_status(&t, &reference).await;
|
|
774
|
-
let declared = crate::commerce_ir::registrations_provider(&crate::commerce_ir::fixture_ir())
|
|
775
|
-
|
|
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
|
+
);
|
|
776
1329
|
}
|
|
777
1330
|
|
|
778
1331
|
#[tokio::test]
|
|
@@ -782,9 +1335,23 @@ async fn only_an_active_registration_can_be_archived_and_only_an_archived_one_re
|
|
|
782
1335
|
let (_, body) = t.register("guards").await;
|
|
783
1336
|
let reference = body["registration_id"].as_str().unwrap().to_string();
|
|
784
1337
|
|
|
785
|
-
assert!(
|
|
786
|
-
|
|
787
|
-
|
|
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
|
+
);
|
|
788
1355
|
assert_eq!(registration_status(&t, &reference).await, "archived");
|
|
789
1356
|
}
|
|
790
1357
|
|
|
@@ -802,10 +1369,24 @@ async fn a_stripe_session_expires_about_thirty_one_minutes_after_it_is_created()
|
|
|
802
1369
|
|
|
803
1370
|
let sent = t.fake.requests_to("/v1/checkout/sessions");
|
|
804
1371
|
assert_eq!(sent.len(), 1);
|
|
805
|
-
let expires_at: i64 = sent[0]
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
+
);
|
|
809
1390
|
}
|
|
810
1391
|
|
|
811
1392
|
#[tokio::test]
|
|
@@ -819,14 +1400,23 @@ async fn an_enabled_connection_without_a_publishable_key_is_paused_before_anythi
|
|
|
819
1400
|
// answer must be neither the mock nor a hosted page.
|
|
820
1401
|
t.platform.direct_test_publishable_key = String::new();
|
|
821
1402
|
let (status, body) = t.register("nopk-event").await;
|
|
822
|
-
assert_eq!(
|
|
1403
|
+
assert_eq!(
|
|
1404
|
+
(status, body["error"].as_str()),
|
|
1405
|
+
(503, Some("payments are temporarily unavailable"))
|
|
1406
|
+
);
|
|
823
1407
|
assert_eq!(body.get("checkout_url"), None);
|
|
824
1408
|
assert_eq!(body.get("embedded_checkout"), None);
|
|
825
1409
|
|
|
826
1410
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
827
|
-
assert!(
|
|
1411
|
+
assert!(
|
|
1412
|
+
instances_for(&read, "Payments::Payment#").is_empty(),
|
|
1413
|
+
"no orphaned Payment"
|
|
1414
|
+
);
|
|
828
1415
|
assert!(instances_for(&read, "CheckoutFixture::Registration#").is_empty());
|
|
829
|
-
assert!(
|
|
1416
|
+
assert!(
|
|
1417
|
+
t.fake.requests_to("/v1/checkout/sessions").is_empty(),
|
|
1418
|
+
"Stripe is never called"
|
|
1419
|
+
);
|
|
830
1420
|
}
|
|
831
1421
|
|
|
832
1422
|
#[tokio::test]
|
|
@@ -835,12 +1425,22 @@ async fn a_paused_connection_refuses_registrations_with_a_503_and_never_falls_ba
|
|
|
835
1425
|
t.schedule_event("paused-event").await;
|
|
836
1426
|
t.connect().await;
|
|
837
1427
|
t.enable().await;
|
|
838
|
-
let (status, _) = t
|
|
1428
|
+
let (status, _) = t
|
|
1429
|
+
.call(
|
|
1430
|
+
"POST",
|
|
1431
|
+
"/payments/connection/disconnect",
|
|
1432
|
+
json!({}),
|
|
1433
|
+
Some(OWNER),
|
|
1434
|
+
)
|
|
1435
|
+
.await;
|
|
839
1436
|
assert_eq!(status, 200);
|
|
840
1437
|
assert_eq!(t.status().await, "paused");
|
|
841
1438
|
|
|
842
1439
|
let (status, body) = t.register("paused-event").await;
|
|
843
|
-
assert_eq!(
|
|
1440
|
+
assert_eq!(
|
|
1441
|
+
(status, body["error"].as_str()),
|
|
1442
|
+
(503, Some("payments are temporarily unavailable"))
|
|
1443
|
+
);
|
|
844
1444
|
assert_eq!(body.get("checkout_url"), None);
|
|
845
1445
|
|
|
846
1446
|
// Nothing was written: no Payment, no Registration, no Stripe call.
|
|
@@ -859,7 +1459,10 @@ async fn an_enabled_connection_whose_secret_key_is_missing_is_paused_not_mock()
|
|
|
859
1459
|
|
|
860
1460
|
t.platform.direct_test_key = String::new();
|
|
861
1461
|
let (status, body) = t.register("nokey-event").await;
|
|
862
|
-
assert_eq!(
|
|
1462
|
+
assert_eq!(
|
|
1463
|
+
(status, body["error"].as_str()),
|
|
1464
|
+
(503, Some("payments are temporarily unavailable"))
|
|
1465
|
+
);
|
|
863
1466
|
}
|
|
864
1467
|
|
|
865
1468
|
#[tokio::test]
|
|
@@ -874,7 +1477,11 @@ async fn only_a_mock_payment_can_be_settled_by_hand() {
|
|
|
874
1477
|
let (_, real) = t.register("hand-event").await;
|
|
875
1478
|
let real_ref = real["registration_id"].as_str().unwrap().to_string();
|
|
876
1479
|
|
|
877
|
-
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
|
+
);
|
|
878
1485
|
assert_eq!(t.payment_status(&real_ref).await, "pending");
|
|
879
1486
|
assert_eq!(t.settle(&mock_ref).await, 200);
|
|
880
1487
|
assert_eq!(t.payment_status(&mock_ref).await, "succeeded");
|
|
@@ -914,16 +1521,34 @@ async fn an_event_verified_only_by_the_public_mock_secret_is_refused_for_a_real_
|
|
|
914
1521
|
t.platform.webhook_secret = None;
|
|
915
1522
|
let event = completed(&reference);
|
|
916
1523
|
let payload = event.to_string();
|
|
917
|
-
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;
|
|
918
1528
|
let header = sign(web::MOCK_STRIPE_WEBHOOK_SECRET, now, &payload);
|
|
919
|
-
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;
|
|
920
1537
|
assert_eq!(response["statusCode"], 500, "{event}");
|
|
921
|
-
assert_eq!(
|
|
1538
|
+
assert_eq!(
|
|
1539
|
+
t.payment_status(&reference).await,
|
|
1540
|
+
"pending",
|
|
1541
|
+
"a forged completion must not settle a real payment"
|
|
1542
|
+
);
|
|
922
1543
|
}
|
|
923
1544
|
|
|
924
1545
|
#[test]
|
|
925
1546
|
fn checkout_plan_is_decided_from_the_connection_lifecycle() {
|
|
926
|
-
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
|
+
};
|
|
927
1552
|
let with = |status: &str, processor: &str, mode: &str, account_ref: &str| Connection {
|
|
928
1553
|
status: status.to_string(),
|
|
929
1554
|
processor: processor.to_string(),
|
|
@@ -934,22 +1559,61 @@ fn checkout_plan_is_decided_from_the_connection_lifecycle() {
|
|
|
934
1559
|
let own = |status: &str, mode: &str| with(status, "stripe", mode, SELF_ACCOUNT);
|
|
935
1560
|
assert!(matches!(checkout_plan(None, &platform), CheckoutPlan::Mock));
|
|
936
1561
|
for status in ["connected", "disconnected"] {
|
|
937
|
-
assert!(
|
|
1562
|
+
assert!(
|
|
1563
|
+
matches!(
|
|
1564
|
+
checkout_plan(Some(&own(status, "test")), &platform),
|
|
1565
|
+
CheckoutPlan::Mock
|
|
1566
|
+
),
|
|
1567
|
+
"{status}"
|
|
1568
|
+
);
|
|
938
1569
|
}
|
|
939
|
-
assert!(matches!(
|
|
1570
|
+
assert!(matches!(
|
|
1571
|
+
checkout_plan(Some(&own("paused", "test")), &platform),
|
|
1572
|
+
CheckoutPlan::Paused
|
|
1573
|
+
));
|
|
940
1574
|
assert!(matches!(
|
|
941
1575
|
checkout_plan(Some(&own("enabled", "test")), &platform),
|
|
942
1576
|
CheckoutPlan::Stripe { ref api_key, ref publishable_key } if api_key == "sk_test_own" && publishable_key == "pk_test_own"
|
|
943
1577
|
));
|
|
944
|
-
assert!(matches!(checkout_plan(Some(&own("enabled", "live")), &platform), CheckoutPlan::Paused), "no live key configured");
|
|
945
|
-
assert!(matches!(checkout_plan(Some(&with("enabled", "paypal", "test", SELF_ACCOUNT)), &platform), CheckoutPlan::Paused), "a processor this host cannot charge");
|
|
946
1578
|
assert!(
|
|
947
|
-
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
|
+
),
|
|
948
1603
|
"a connection to any other account cannot be charged"
|
|
949
1604
|
);
|
|
950
1605
|
|
|
951
|
-
let secret_only = PlatformConfig {
|
|
952
|
-
|
|
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
|
+
);
|
|
953
1617
|
assert_eq!(platform.direct_modes(), vec!["test"]);
|
|
954
1618
|
assert!(secret_only.direct_modes().is_empty());
|
|
955
1619
|
}
|
|
@@ -960,18 +1624,58 @@ async fn using_the_own_account_needs_an_owner_a_mode_and_configured_keys() {
|
|
|
960
1624
|
let mut t = tenant("hecks_pay_test_direct_refused").await;
|
|
961
1625
|
t.platform.direct_test_key = String::new();
|
|
962
1626
|
t.platform.direct_test_publishable_key = String::new();
|
|
963
|
-
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;
|
|
964
1635
|
assert_eq!(status, 422, "{body}");
|
|
965
|
-
assert!(
|
|
1636
|
+
assert!(
|
|
1637
|
+
body["error"]
|
|
1638
|
+
.as_str()
|
|
1639
|
+
.unwrap()
|
|
1640
|
+
.contains("not set up for test mode"),
|
|
1641
|
+
"{body}"
|
|
1642
|
+
);
|
|
966
1643
|
assert_eq!(t.status().await, "none");
|
|
967
|
-
assert!(
|
|
1644
|
+
assert!(
|
|
1645
|
+
t.fake.requests().is_empty(),
|
|
1646
|
+
"no Stripe call without configured keys"
|
|
1647
|
+
);
|
|
968
1648
|
|
|
969
1649
|
let t = tenant("hecks_pay_test_direct_access").await;
|
|
970
|
-
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;
|
|
971
1658
|
assert_eq!(status, 403, "only an Owner may choose the account");
|
|
972
|
-
let (status, body) = t
|
|
973
|
-
|
|
974
|
-
|
|
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;
|
|
975
1679
|
assert_eq!(status, 422, "live keys are not configured");
|
|
976
1680
|
assert_eq!(t.status().await, "none");
|
|
977
1681
|
}
|
|
@@ -979,33 +1683,93 @@ async fn using_the_own_account_needs_an_owner_a_mode_and_configured_keys() {
|
|
|
979
1683
|
#[tokio::test]
|
|
980
1684
|
async fn using_the_own_account_records_the_reserved_ref_and_only_public_facts() {
|
|
981
1685
|
let t = tenant("hecks_pay_test_direct_connect").await;
|
|
982
|
-
let (_, body) = t
|
|
983
|
-
|
|
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
|
+
);
|
|
984
1697
|
|
|
985
|
-
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;
|
|
986
1706
|
assert_eq!(status, 200, "{body}");
|
|
987
1707
|
assert_eq!(
|
|
988
|
-
(
|
|
989
|
-
|
|
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
|
+
)
|
|
990
1724
|
);
|
|
991
1725
|
|
|
992
1726
|
let lookups = t.fake.requests_to("/v1/account");
|
|
993
1727
|
assert_eq!(lookups.len(), 1);
|
|
994
|
-
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
|
+
);
|
|
995
1733
|
|
|
996
1734
|
let dump = t.dump_database().await;
|
|
997
|
-
assert!(
|
|
998
|
-
|
|
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
|
+
);
|
|
999
1743
|
|
|
1000
|
-
let (status, body) = t
|
|
1001
|
-
|
|
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
|
+
);
|
|
1002
1759
|
}
|
|
1003
1760
|
|
|
1004
1761
|
#[tokio::test]
|
|
1005
1762
|
async fn the_own_account_falls_back_to_a_generic_name_when_stripe_will_not_say() {
|
|
1006
1763
|
let t = tenant("hecks_pay_test_direct_name").await;
|
|
1007
1764
|
t.fake.state.refuse_account.store(true, Ordering::SeqCst);
|
|
1008
|
-
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;
|
|
1009
1773
|
assert_eq!(status, 200, "{body}");
|
|
1010
1774
|
assert_eq!(body["display_name"], "Your Stripe account");
|
|
1011
1775
|
}
|
|
@@ -1019,7 +1783,13 @@ async fn the_own_account_charges_with_its_own_key_and_names_no_other_account() {
|
|
|
1019
1783
|
// Connected but not enabled: still the mock, no Stripe call.
|
|
1020
1784
|
let (status, body) = t.register("own-event").await;
|
|
1021
1785
|
assert_eq!(status, 200, "{body}");
|
|
1022
|
-
assert!(
|
|
1786
|
+
assert!(
|
|
1787
|
+
body["checkout_url"]
|
|
1788
|
+
.as_str()
|
|
1789
|
+
.unwrap()
|
|
1790
|
+
.starts_with("https://site.example/pay/"),
|
|
1791
|
+
"{body}"
|
|
1792
|
+
);
|
|
1023
1793
|
assert!(t.fake.requests_to("/v1/checkout/sessions").is_empty());
|
|
1024
1794
|
|
|
1025
1795
|
t.enable().await;
|
|
@@ -1033,35 +1803,76 @@ async fn the_own_account_charges_with_its_own_key_and_names_no_other_account() {
|
|
|
1033
1803
|
|
|
1034
1804
|
let sent = t.fake.requests_to("/v1/checkout/sessions");
|
|
1035
1805
|
assert_eq!(sent.len(), 1);
|
|
1036
|
-
assert_eq!(
|
|
1037
|
-
|
|
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
|
+
);
|
|
1038
1815
|
assert_eq!(sent[0].header("stripe-version"), Some("2026-04-22.dahlia"));
|
|
1039
|
-
assert!(
|
|
1816
|
+
assert!(
|
|
1817
|
+
sent[0].body.contains("ui_mode=embedded_page"),
|
|
1818
|
+
"{}",
|
|
1819
|
+
sent[0].body
|
|
1820
|
+
);
|
|
1040
1821
|
|
|
1041
1822
|
let reference = body["registration_id"].as_str().unwrap();
|
|
1042
1823
|
let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
|
|
1043
|
-
assert_eq!(
|
|
1824
|
+
assert_eq!(
|
|
1825
|
+
read["instances"][format!("Payments::Payment#{reference}")]["processor"]["value"],
|
|
1826
|
+
"stripe"
|
|
1827
|
+
);
|
|
1044
1828
|
}
|
|
1045
1829
|
|
|
1046
1830
|
#[tokio::test]
|
|
1047
1831
|
async fn the_own_account_follows_enable_disable_pause_and_resume() {
|
|
1048
1832
|
let t = tenant("hecks_pay_test_direct_lifecycle").await;
|
|
1049
|
-
let (status, body) = t
|
|
1050
|
-
|
|
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
|
+
);
|
|
1051
1845
|
|
|
1052
1846
|
t.connect().await;
|
|
1053
1847
|
t.enable().await;
|
|
1054
|
-
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;
|
|
1055
1856
|
assert_eq!((status, body["status"].as_str()), (200, Some("connected")));
|
|
1056
1857
|
t.enable().await;
|
|
1057
1858
|
|
|
1058
1859
|
// Disconnecting while enabled pauses registrations.
|
|
1059
|
-
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;
|
|
1060
1868
|
assert_eq!(status, 200, "{body}");
|
|
1061
1869
|
assert_eq!(body["status"], "paused");
|
|
1062
1870
|
t.schedule_event("paused-event").await;
|
|
1063
1871
|
let (status, body) = t.register("paused-event").await;
|
|
1064
|
-
assert_eq!(
|
|
1872
|
+
assert_eq!(
|
|
1873
|
+
(status, body["error"].as_str()),
|
|
1874
|
+
(503, Some("payments are temporarily unavailable"))
|
|
1875
|
+
);
|
|
1065
1876
|
|
|
1066
1877
|
// Choosing the account again resumes the paused connection.
|
|
1067
1878
|
t.connect().await;
|
|
@@ -1070,8 +1881,18 @@ async fn the_own_account_follows_enable_disable_pause_and_resume() {
|
|
|
1070
1881
|
// A connection that was never enabled unlinks and can be chosen again.
|
|
1071
1882
|
let fresh = tenant("hecks_pay_test_direct_unlink").await;
|
|
1072
1883
|
fresh.connect().await;
|
|
1073
|
-
let (status, body) = fresh
|
|
1074
|
-
|
|
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
|
+
);
|
|
1075
1896
|
fresh.connect().await;
|
|
1076
1897
|
assert_eq!(fresh.status().await, "connected");
|
|
1077
1898
|
}
|
|
@@ -1100,14 +1921,38 @@ fn assert_no_secret_in(text: &str) {
|
|
|
1100
1921
|
#[tokio::test]
|
|
1101
1922
|
async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_of_the_database() {
|
|
1102
1923
|
let t = Tenant::saving("hecks_pay_test_save_ok").await;
|
|
1103
|
-
let (_, before) = t
|
|
1104
|
-
|
|
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
|
+
);
|
|
1105
1934
|
|
|
1106
|
-
let (status, body) = t
|
|
1935
|
+
let (status, body) = t
|
|
1936
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
1937
|
+
.await;
|
|
1107
1938
|
assert_eq!(status, 200, "{body}");
|
|
1108
1939
|
assert_eq!(
|
|
1109
|
-
(
|
|
1110
|
-
|
|
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
|
+
)
|
|
1111
1956
|
);
|
|
1112
1957
|
assert_eq!(body["direct_modes"], json!(["test"]));
|
|
1113
1958
|
assert_no_secret_in(&body.to_string());
|
|
@@ -1115,13 +1960,25 @@ async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_
|
|
|
1115
1960
|
// Stripe was asked to check the key, then to deliver the three events to this site.
|
|
1116
1961
|
let lookups = t.fake.requests_to("/v1/account");
|
|
1117
1962
|
assert_eq!(lookups.len(), 1);
|
|
1118
|
-
assert_eq!(
|
|
1119
|
-
|
|
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
|
+
);
|
|
1120
1971
|
let created = t.fake.requests_to("/v1/webhook_endpoints");
|
|
1121
1972
|
assert_eq!(created.len(), 1);
|
|
1122
1973
|
assert_eq!(created[0].method, "POST");
|
|
1123
|
-
assert_eq!(
|
|
1124
|
-
|
|
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
|
+
);
|
|
1125
1982
|
assert_eq!(created[0].header("stripe-account"), None);
|
|
1126
1983
|
for expected in [
|
|
1127
1984
|
"url=https%3A%2F%2Fwebhooks.example.test%2Fwebhooks%2Fstripe",
|
|
@@ -1130,7 +1987,11 @@ async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_
|
|
|
1130
1987
|
"enabled_events%5B1%5D=checkout.session.expired",
|
|
1131
1988
|
"enabled_events%5B2%5D=charge.refunded",
|
|
1132
1989
|
] {
|
|
1133
|
-
assert!(
|
|
1990
|
+
assert!(
|
|
1991
|
+
created[0].body.contains(expected),
|
|
1992
|
+
"{expected} missing from {}",
|
|
1993
|
+
created[0].body
|
|
1994
|
+
);
|
|
1134
1995
|
}
|
|
1135
1996
|
|
|
1136
1997
|
// Saved in the secret store in the agreed shape...
|
|
@@ -1139,7 +2000,16 @@ async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_
|
|
|
1139
2000
|
let entry = saved["test"].as_object().unwrap();
|
|
1140
2001
|
let mut names: Vec<&str> = entry.keys().map(String::as_str).collect();
|
|
1141
2002
|
names.sort_unstable();
|
|
1142
|
-
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
|
+
);
|
|
1143
2013
|
assert_eq!(entry["secret_key"], SAVED_KEY);
|
|
1144
2014
|
assert_eq!(entry["publishable_key"], SAVED_PUBLISHABLE_KEY);
|
|
1145
2015
|
assert_eq!(entry["webhook_secret"], "whsec_SAVED_1");
|
|
@@ -1148,15 +2018,23 @@ async fn saving_keys_checks_them_creates_the_webhook_and_keeps_every_secret_out_
|
|
|
1148
2018
|
// ...and nowhere in the tenant's database.
|
|
1149
2019
|
let dump = t.dump_database().await;
|
|
1150
2020
|
assert_no_secret_in(&dump);
|
|
1151
|
-
assert!(
|
|
2021
|
+
assert!(
|
|
2022
|
+
!dump.contains(SAVED_PUBLISHABLE_KEY),
|
|
2023
|
+
"not even the publishable key is stored there"
|
|
2024
|
+
);
|
|
1152
2025
|
}
|
|
1153
2026
|
|
|
1154
2027
|
#[tokio::test]
|
|
1155
2028
|
async fn saving_live_keys_derives_the_live_mode_from_the_keys() {
|
|
1156
2029
|
let t = Tenant::saving("hecks_pay_test_save_live").await;
|
|
1157
|
-
let (status, body) = t
|
|
2030
|
+
let (status, body) = t
|
|
2031
|
+
.save_keys(LIVE_SAVED_KEY, LIVE_SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2032
|
+
.await;
|
|
1158
2033
|
assert_eq!(status, 200, "{body}");
|
|
1159
|
-
assert_eq!(
|
|
2034
|
+
assert_eq!(
|
|
2035
|
+
(body["mode"].as_str(), body["direct_modes"].clone()),
|
|
2036
|
+
(Some("live"), json!(["live"]))
|
|
2037
|
+
);
|
|
1160
2038
|
let saved = t.saved_document();
|
|
1161
2039
|
assert_eq!(saved["live"]["secret_key"], LIVE_SAVED_KEY);
|
|
1162
2040
|
assert!(saved.get("test").is_none());
|
|
@@ -1166,25 +2044,45 @@ async fn saving_live_keys_derives_the_live_mode_from_the_keys() {
|
|
|
1166
2044
|
async fn saved_keys_charge_the_business_account_and_its_saved_webhook_secret_settles_payments() {
|
|
1167
2045
|
let t = Tenant::saving("hecks_pay_test_save_charge").await;
|
|
1168
2046
|
t.schedule_event("saved-event").await;
|
|
1169
|
-
let (status, body) = t
|
|
2047
|
+
let (status, body) = t
|
|
2048
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2049
|
+
.await;
|
|
1170
2050
|
assert_eq!(status, 200, "{body}");
|
|
1171
2051
|
t.enable().await;
|
|
1172
2052
|
|
|
1173
2053
|
let (status, body) = t.register("saved-event").await;
|
|
1174
2054
|
assert_eq!(status, 200, "{body}");
|
|
1175
|
-
assert_eq!(
|
|
2055
|
+
assert_eq!(
|
|
2056
|
+
body["embedded_checkout"]["publishable_key"],
|
|
2057
|
+
SAVED_PUBLISHABLE_KEY
|
|
2058
|
+
);
|
|
1176
2059
|
let sent = t.fake.requests_to("/v1/checkout/sessions");
|
|
1177
2060
|
assert_eq!(sent.len(), 1);
|
|
1178
|
-
assert_eq!(
|
|
1179
|
-
|
|
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
|
+
);
|
|
1180
2070
|
assert_no_secret_in(&body.to_string());
|
|
1181
2071
|
|
|
1182
2072
|
let reference = body["registration_id"].as_str().unwrap().to_string();
|
|
1183
2073
|
// A delivery signed with anything else is refused.
|
|
1184
|
-
assert_eq!(
|
|
2074
|
+
assert_eq!(
|
|
2075
|
+
t.webhook_signed_with("whsec_someone_else", completed(&reference))
|
|
2076
|
+
.await,
|
|
2077
|
+
400
|
|
2078
|
+
);
|
|
1185
2079
|
assert_eq!(t.payment_status(&reference).await, "pending");
|
|
1186
2080
|
// The signing secret Stripe returned when the webhook was created settles it.
|
|
1187
|
-
assert_eq!(
|
|
2081
|
+
assert_eq!(
|
|
2082
|
+
t.webhook_signed_with("whsec_SAVED_1", completed(&reference))
|
|
2083
|
+
.await,
|
|
2084
|
+
200
|
|
2085
|
+
);
|
|
1188
2086
|
assert_eq!(t.payment_status(&reference).await, "succeeded");
|
|
1189
2087
|
// The environment's secret still works alongside it.
|
|
1190
2088
|
let (_, body) = t.register("saved-event").await;
|
|
@@ -1197,22 +2095,56 @@ async fn saved_keys_charge_the_business_account_and_its_saved_webhook_secret_set
|
|
|
1197
2095
|
async fn saving_keys_refuses_with_plain_messages_and_saves_nothing() {
|
|
1198
2096
|
// No key store on this deploy.
|
|
1199
2097
|
let t = tenant("hecks_pay_test_save_nostore").await;
|
|
1200
|
-
let (status, body) = t
|
|
1201
|
-
|
|
1202
|
-
|
|
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;
|
|
1203
2108
|
assert_eq!(shown["can_save_keys"], json!(false));
|
|
1204
2109
|
|
|
1205
2110
|
let t = Tenant::saving("hecks_pay_test_save_refuse").await;
|
|
1206
|
-
assert_eq!(
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
+
);
|
|
1210
2134
|
|
|
1211
2135
|
let cases = [
|
|
1212
2136
|
("nonsense", SAVED_PUBLISHABLE_KEY, "restricted key"),
|
|
1213
2137
|
(SAVED_KEY, "nonsense", "publishable key"),
|
|
1214
|
-
(
|
|
1215
|
-
|
|
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
|
+
),
|
|
1216
2148
|
];
|
|
1217
2149
|
for (secret, publishable, expected) in cases {
|
|
1218
2150
|
let (status, body) = t.save_keys(secret, publishable, Some(OWNER)).await;
|
|
@@ -1220,18 +2152,31 @@ async fn saving_keys_refuses_with_plain_messages_and_saves_nothing() {
|
|
|
1220
2152
|
assert!(body["error"].as_str().unwrap().contains(expected), "{body}");
|
|
1221
2153
|
assert_no_secret_in(&body.to_string());
|
|
1222
2154
|
}
|
|
1223
|
-
assert!(
|
|
2155
|
+
assert!(
|
|
2156
|
+
t.fake.requests().is_empty(),
|
|
2157
|
+
"nothing malformed reaches Stripe"
|
|
2158
|
+
);
|
|
1224
2159
|
|
|
1225
2160
|
// Stripe does not recognise the key.
|
|
1226
2161
|
t.fake.state.invalid_key.store(true, Ordering::SeqCst);
|
|
1227
|
-
let (status, body) = t
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
+
);
|
|
1230
2173
|
t.fake.state.invalid_key.store(false, Ordering::SeqCst);
|
|
1231
2174
|
|
|
1232
2175
|
// A restricted key that may not create webhooks.
|
|
1233
2176
|
t.fake.state.refuse_webhooks.store(true, Ordering::SeqCst);
|
|
1234
|
-
let (status, body) = t
|
|
2177
|
+
let (status, body) = t
|
|
2178
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2179
|
+
.await;
|
|
1235
2180
|
assert_eq!(
|
|
1236
2181
|
(status, body["error"].as_str()),
|
|
1237
2182
|
(422, Some("That key can't create the webhook. In Stripe, give it Webhook Endpoints → Write as well and try again."))
|
|
@@ -1239,7 +2184,11 @@ async fn saving_keys_refuses_with_plain_messages_and_saves_nothing() {
|
|
|
1239
2184
|
assert_no_secret_in(&body.to_string());
|
|
1240
2185
|
|
|
1241
2186
|
assert_eq!(t.status().await, "none");
|
|
1242
|
-
assert_eq!(
|
|
2187
|
+
assert_eq!(
|
|
2188
|
+
t.saved_document(),
|
|
2189
|
+
Value::Null,
|
|
2190
|
+
"nothing was written to the store"
|
|
2191
|
+
);
|
|
1243
2192
|
assert_eq!(t.raw.as_ref().unwrap().writes.load(Ordering::SeqCst), 0);
|
|
1244
2193
|
}
|
|
1245
2194
|
|
|
@@ -1247,16 +2196,29 @@ async fn saving_keys_refuses_with_plain_messages_and_saves_nothing() {
|
|
|
1247
2196
|
async fn a_restricted_key_that_cannot_read_the_account_still_saves_with_a_generic_name() {
|
|
1248
2197
|
let t = Tenant::saving("hecks_pay_test_save_name").await;
|
|
1249
2198
|
t.fake.state.refuse_account.store(true, Ordering::SeqCst);
|
|
1250
|
-
let (status, body) = t
|
|
1251
|
-
|
|
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
|
+
);
|
|
1252
2207
|
}
|
|
1253
2208
|
|
|
1254
2209
|
#[tokio::test]
|
|
1255
2210
|
async fn saving_again_replaces_the_webhook_and_the_keys_without_disturbing_the_connection() {
|
|
1256
2211
|
let t = Tenant::saving("hecks_pay_test_save_again").await;
|
|
1257
|
-
assert_eq!(
|
|
2212
|
+
assert_eq!(
|
|
2213
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2214
|
+
.await
|
|
2215
|
+
.0,
|
|
2216
|
+
200
|
|
2217
|
+
);
|
|
1258
2218
|
t.enable().await;
|
|
1259
|
-
let (status, body) = t
|
|
2219
|
+
let (status, body) = t
|
|
2220
|
+
.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2221
|
+
.await;
|
|
1260
2222
|
assert_eq!(status, 200, "{body}");
|
|
1261
2223
|
assert_eq!(body["status"], "enabled", "the connection stays as it was");
|
|
1262
2224
|
assert_no_secret_in(&body.to_string());
|
|
@@ -1264,48 +2226,114 @@ async fn saving_again_replaces_the_webhook_and_the_keys_without_disturbing_the_c
|
|
|
1264
2226
|
// The first save's webhook was removed (with the first key), a new one made (with the second).
|
|
1265
2227
|
let removed = t.fake.requests_to("/v1/webhook_endpoints/we_1");
|
|
1266
2228
|
assert_eq!(removed.len(), 1);
|
|
1267
|
-
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
|
+
);
|
|
1268
2236
|
let created = t.fake.requests_to("/v1/webhook_endpoints");
|
|
1269
2237
|
assert_eq!(created.len(), 2);
|
|
1270
|
-
assert_eq!(
|
|
2238
|
+
assert_eq!(
|
|
2239
|
+
created[1].header("authorization"),
|
|
2240
|
+
Some(format!("Bearer {SAVED_KEY_TWO}").as_str())
|
|
2241
|
+
);
|
|
1271
2242
|
// The new webhook exists before the old one goes, so a failed save never leaves none.
|
|
1272
2243
|
let all = t.fake.requests();
|
|
1273
|
-
let second_create = all
|
|
1274
|
-
|
|
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();
|
|
1275
2252
|
assert!(second_create < delete, "the new webhook was made first");
|
|
1276
2253
|
let saved = t.saved_document();
|
|
1277
2254
|
assert_eq!(
|
|
1278
|
-
(
|
|
2255
|
+
(
|
|
2256
|
+
saved["test"]["secret_key"].as_str(),
|
|
2257
|
+
saved["test"]["webhook_secret"].as_str(),
|
|
2258
|
+
saved["test"]["webhook_endpoint_id"].as_str()
|
|
2259
|
+
),
|
|
1279
2260
|
(Some(SAVED_KEY_TWO), Some("whsec_SAVED_2"), Some("we_2"))
|
|
1280
2261
|
);
|
|
1281
2262
|
|
|
1282
2263
|
// Keys for the other mode cannot be saved over a connected account.
|
|
1283
|
-
let (status, body) = t
|
|
1284
|
-
|
|
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
|
+
);
|
|
1285
2274
|
}
|
|
1286
2275
|
|
|
1287
2276
|
#[tokio::test]
|
|
1288
2277
|
async fn a_failed_re_save_leaves_the_old_webhook_and_keys_working() {
|
|
1289
2278
|
let t = Tenant::saving("hecks_pay_test_save_resave_fail").await;
|
|
1290
|
-
assert_eq!(
|
|
2279
|
+
assert_eq!(
|
|
2280
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2281
|
+
.await
|
|
2282
|
+
.0,
|
|
2283
|
+
200
|
|
2284
|
+
);
|
|
1291
2285
|
t.fake.state.refuse_webhooks.store(true, Ordering::SeqCst);
|
|
1292
|
-
let (status, body) = t
|
|
2286
|
+
let (status, body) = t
|
|
2287
|
+
.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2288
|
+
.await;
|
|
1293
2289
|
assert_eq!(status, 422, "{body}");
|
|
1294
|
-
assert!(
|
|
2290
|
+
assert!(
|
|
2291
|
+
t.fake.requests().iter().all(|r| r.method != "DELETE"),
|
|
2292
|
+
"the old webhook was not removed"
|
|
2293
|
+
);
|
|
1295
2294
|
let saved = t.saved_document();
|
|
1296
|
-
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
|
+
);
|
|
1297
2302
|
assert_eq!(t.status().await, "connected");
|
|
1298
2303
|
}
|
|
1299
2304
|
|
|
1300
2305
|
#[tokio::test]
|
|
1301
2306
|
async fn disconnecting_forgets_the_keys_even_when_stripe_cannot_remove_the_webhook() {
|
|
1302
2307
|
let t = Tenant::saving("hecks_pay_test_save_disc_down").await;
|
|
1303
|
-
assert_eq!(
|
|
2308
|
+
assert_eq!(
|
|
2309
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2310
|
+
.await
|
|
2311
|
+
.0,
|
|
2312
|
+
200
|
|
2313
|
+
);
|
|
1304
2314
|
t.fake.state.fail_deletes.store(true, Ordering::SeqCst);
|
|
1305
|
-
let (status, body) = t
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
+
);
|
|
1309
2337
|
}
|
|
1310
2338
|
|
|
1311
2339
|
#[tokio::test]
|
|
@@ -1319,9 +2347,22 @@ async fn the_public_mock_secret_is_refused_whenever_a_real_credential_is_configu
|
|
|
1319
2347
|
// Keys saved from the Payments page count too: only the saved signing secret verifies.
|
|
1320
2348
|
let mut t = Tenant::saving("hecks_pay_test_mock_saved").await;
|
|
1321
2349
|
t.platform.webhook_secret = None;
|
|
1322
|
-
assert_eq!(
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
+
);
|
|
1325
2366
|
|
|
1326
2367
|
// No saved signing secret at all: refused outright, not trusted on the mock secret.
|
|
1327
2368
|
let raw = t.raw.as_ref().unwrap();
|
|
@@ -1335,24 +2376,52 @@ async fn the_public_mock_secret_is_refused_whenever_a_real_credential_is_configu
|
|
|
1335
2376
|
#[tokio::test]
|
|
1336
2377
|
async fn disconnecting_removes_the_webhook_from_stripe_and_deletes_the_saved_keys() {
|
|
1337
2378
|
let t = Tenant::saving("hecks_pay_test_save_disconnect").await;
|
|
1338
|
-
assert_eq!(
|
|
2379
|
+
assert_eq!(
|
|
2380
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2381
|
+
.await
|
|
2382
|
+
.0,
|
|
2383
|
+
200
|
|
2384
|
+
);
|
|
1339
2385
|
t.enable().await;
|
|
1340
|
-
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;
|
|
1341
2394
|
assert_eq!(status, 200, "{body}");
|
|
1342
2395
|
assert_eq!(body["status"], "paused");
|
|
1343
2396
|
assert_eq!(body["direct_modes"], json!([]), "the keys are gone");
|
|
1344
2397
|
let removed = t.fake.requests_to("/v1/webhook_endpoints/we_1");
|
|
1345
2398
|
assert_eq!(removed.len(), 1);
|
|
1346
|
-
assert_eq!(
|
|
1347
|
-
|
|
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
|
+
);
|
|
1348
2408
|
|
|
1349
2409
|
// Registrations pause rather than fall back to the mock...
|
|
1350
2410
|
t.schedule_event("gone-event").await;
|
|
1351
2411
|
let (status, body) = t.register("gone-event").await;
|
|
1352
|
-
assert_eq!(
|
|
2412
|
+
assert_eq!(
|
|
2413
|
+
(status, body["error"].as_str()),
|
|
2414
|
+
(503, Some("payments are temporarily unavailable"))
|
|
2415
|
+
);
|
|
1353
2416
|
// ...and saving again resumes the paused connection with fresh keys.
|
|
1354
|
-
let (status, body) = t
|
|
1355
|
-
|
|
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
|
+
);
|
|
1356
2425
|
assert_eq!(t.saved_document()["test"]["webhook_endpoint_id"], "we_2");
|
|
1357
2426
|
}
|
|
1358
2427
|
|
|
@@ -1362,41 +2431,77 @@ async fn keys_set_in_the_environment_win_over_saved_ones() {
|
|
|
1362
2431
|
t.platform.direct_test_key = DIRECT_KEY.to_string();
|
|
1363
2432
|
t.platform.direct_test_publishable_key = DIRECT_PUBLISHABLE_KEY.to_string();
|
|
1364
2433
|
t.schedule_event("env-event").await;
|
|
1365
|
-
let (status, body) = t
|
|
2434
|
+
let (status, body) = t
|
|
2435
|
+
.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2436
|
+
.await;
|
|
1366
2437
|
assert_eq!(status, 200, "{body}");
|
|
1367
2438
|
t.enable().await;
|
|
1368
2439
|
|
|
1369
2440
|
let (_, body) = t.register("env-event").await;
|
|
1370
|
-
assert_eq!(
|
|
2441
|
+
assert_eq!(
|
|
2442
|
+
body["embedded_checkout"]["publishable_key"],
|
|
2443
|
+
DIRECT_PUBLISHABLE_KEY
|
|
2444
|
+
);
|
|
1371
2445
|
let sent = t.fake.requests_to("/v1/checkout/sessions");
|
|
1372
|
-
assert_eq!(
|
|
2446
|
+
assert_eq!(
|
|
2447
|
+
sent[0].header("authorization"),
|
|
2448
|
+
Some(format!("Bearer {DIRECT_KEY}").as_str())
|
|
2449
|
+
);
|
|
1373
2450
|
}
|
|
1374
2451
|
|
|
1375
2452
|
#[tokio::test]
|
|
1376
2453
|
async fn a_store_that_cannot_be_written_saves_nothing_and_takes_the_webhook_back() {
|
|
1377
2454
|
let t = Tenant::saving("hecks_pay_test_save_storefail").await;
|
|
1378
|
-
t.raw
|
|
1379
|
-
|
|
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;
|
|
1380
2463
|
assert_eq!(status, 500, "{body}");
|
|
1381
|
-
assert_eq!(
|
|
2464
|
+
assert_eq!(
|
|
2465
|
+
body["error"],
|
|
2466
|
+
"The keys could not be stored, so nothing was saved. Please try again."
|
|
2467
|
+
);
|
|
1382
2468
|
assert_no_secret_in(&body.to_string());
|
|
1383
|
-
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
|
+
);
|
|
1384
2474
|
assert_eq!(t.status().await, "none");
|
|
1385
2475
|
}
|
|
1386
2476
|
|
|
1387
2477
|
#[tokio::test]
|
|
1388
2478
|
async fn the_saved_keys_are_read_from_a_short_cache_and_a_save_refreshes_it() {
|
|
1389
2479
|
let t = Tenant::saving("hecks_pay_test_save_cache").await;
|
|
1390
|
-
assert_eq!(
|
|
2480
|
+
assert_eq!(
|
|
2481
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2482
|
+
.await
|
|
2483
|
+
.0,
|
|
2484
|
+
200
|
|
2485
|
+
);
|
|
1391
2486
|
let raw = t.raw.as_ref().unwrap();
|
|
1392
2487
|
let reads = raw.reads.load(Ordering::SeqCst);
|
|
1393
2488
|
for _ in 0..3 {
|
|
1394
|
-
t.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
2489
|
+
t.call("GET", "/payments/connection", json!({}), Some(OWNER))
|
|
2490
|
+
.await;
|
|
1395
2491
|
}
|
|
1396
|
-
assert_eq!(
|
|
2492
|
+
assert_eq!(
|
|
2493
|
+
raw.reads.load(Ordering::SeqCst),
|
|
2494
|
+
reads,
|
|
2495
|
+
"reads are served from the cache"
|
|
2496
|
+
);
|
|
1397
2497
|
|
|
1398
2498
|
// Saving different keys shows up in the very next request, not a minute later.
|
|
1399
|
-
assert_eq!(
|
|
2499
|
+
assert_eq!(
|
|
2500
|
+
t.save_keys(SAVED_KEY_TWO, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2501
|
+
.await
|
|
2502
|
+
.0,
|
|
2503
|
+
200
|
|
2504
|
+
);
|
|
1400
2505
|
assert_eq!(t.platform_now().await.direct_key("test"), SAVED_KEY_TWO);
|
|
1401
2506
|
}
|
|
1402
2507
|
|
|
@@ -1405,9 +2510,16 @@ fn key_shapes_decide_the_mode_and_a_mismatch_is_refused_without_quoting_a_key()
|
|
|
1405
2510
|
assert_eq!(keys_mode("rk_test_a", "pk_test_a"), Ok("test"));
|
|
1406
2511
|
assert_eq!(keys_mode("sk_live_a", "pk_live_a"), Ok("live"));
|
|
1407
2512
|
assert!(keys_mode("rk_test_a", "pk_live_a").is_err());
|
|
1408
|
-
assert!(
|
|
2513
|
+
assert!(
|
|
2514
|
+
keys_mode("pk_test_a", "pk_test_a").is_err(),
|
|
2515
|
+
"a publishable key is not a secret key"
|
|
2516
|
+
);
|
|
1409
2517
|
assert!(keys_mode("rk_test_a", "sk_test_a").is_err());
|
|
1410
|
-
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
|
+
] {
|
|
1411
2523
|
assert!(!message.contains("KEYMATERIAL"), "{message}");
|
|
1412
2524
|
}
|
|
1413
2525
|
}
|
|
@@ -1416,27 +2528,48 @@ fn key_shapes_decide_the_mode_and_a_mismatch_is_refused_without_quoting_a_key()
|
|
|
1416
2528
|
fn the_webhook_description_is_the_configured_text_else_the_domain_name_and_website() {
|
|
1417
2529
|
assert_eq!(webhook_description(None, "Atelier"), "Atelier website");
|
|
1418
2530
|
assert_eq!(webhook_description(Some(""), "Atelier"), "Atelier website");
|
|
1419
|
-
assert_eq!(
|
|
1420
|
-
|
|
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
|
+
);
|
|
1421
2539
|
}
|
|
1422
2540
|
|
|
1423
2541
|
#[tokio::test]
|
|
1424
2542
|
async fn the_webhook_endpoint_is_created_with_the_platform_description() {
|
|
1425
2543
|
let mut t = Tenant::saving("hecks_pay_test_webhook_description").await;
|
|
1426
2544
|
t.platform.webhook_description = "Atelier bookings".to_string();
|
|
1427
|
-
assert_eq!(
|
|
2545
|
+
assert_eq!(
|
|
2546
|
+
t.save_keys(SAVED_KEY, SAVED_PUBLISHABLE_KEY, Some(OWNER))
|
|
2547
|
+
.await
|
|
2548
|
+
.0,
|
|
2549
|
+
200
|
|
2550
|
+
);
|
|
1428
2551
|
let created = t.fake.requests_to("/v1/webhook_endpoints");
|
|
1429
2552
|
assert_eq!(created.len(), 1);
|
|
1430
|
-
assert!(
|
|
2553
|
+
assert!(
|
|
2554
|
+
created[0].body.contains("description=Atelier+bookings"),
|
|
2555
|
+
"{}",
|
|
2556
|
+
created[0].body
|
|
2557
|
+
);
|
|
1431
2558
|
}
|
|
1432
2559
|
|
|
1433
2560
|
#[test]
|
|
1434
2561
|
fn purpose_tokens_only_verify_for_their_own_purpose_and_secret() {
|
|
1435
2562
|
let token = auth::purpose_token("k", "one", json!({"email": "a@b.c"}), 60);
|
|
1436
|
-
assert_eq!(
|
|
2563
|
+
assert_eq!(
|
|
2564
|
+
auth::verify_purpose_token("k", "one", &token).unwrap()["email"],
|
|
2565
|
+
"a@b.c"
|
|
2566
|
+
);
|
|
1437
2567
|
assert!(auth::verify_purpose_token("k", "two", &token).is_none());
|
|
1438
2568
|
assert!(auth::verify_purpose_token("other", "one", &token).is_none());
|
|
1439
|
-
assert!(
|
|
2569
|
+
assert!(
|
|
2570
|
+
auth::verify_account_token("k", &token).is_none(),
|
|
2571
|
+
"not a session cookie"
|
|
2572
|
+
);
|
|
1440
2573
|
}
|
|
1441
2574
|
|
|
1442
2575
|
// ADR 0099: the processor and the modes are this adapter's own words, but the domain still has to
|
|
@@ -1444,8 +2577,20 @@ fn purpose_tokens_only_verify_for_their_own_purpose_and_secret() {
|
|
|
1444
2577
|
#[test]
|
|
1445
2578
|
fn the_adapter_words_are_ones_the_connection_domain_admits() {
|
|
1446
2579
|
let ir = crate::commerce_ir::fixture_ir();
|
|
1447
|
-
let source = ir["source_text"]
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
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
|
+
);
|
|
1451
2596
|
}
|