hecks 3.13.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/examples/pizzas/adapters/fake_processor.rb +34 -0
- data/examples/pizzas/adapters/payment_signature.rb +66 -0
- data/examples/pizzas/adapters/payment_webhook.rb +87 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
- data/examples/pizzas/bluebook/pizzas.bluebook +4 -4
- data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
- data/examples/pizzas/config.ru +22 -0
- data/lib/hecks/adapters/driven/prism.rb +0 -2
- data/lib/hecks/adapters/driven.rb +0 -2
- data/lib/hecks/adapters/driving/json.rb +1 -7
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +15 -5
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
- data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
- data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
- data/lib/hecks/bluebook/hecksagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
- data/lib/hecks/bluebook/meta_validator.rb +0 -11
- data/lib/hecks/bluebook/named_pattern.rb +33 -0
- data/lib/hecks/bluebook/translation.rb +18 -10
- data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
- data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
- data/lib/hecks/bluebook/translation_expression.rb +39 -0
- data/lib/hecks/bluebook/translation_plan.rb +70 -0
- data/lib/hecks/bluebook.rb +3 -0
- data/lib/hecks/chapters.rb +3 -3
- data/lib/hecks/cli/domain_stub.rb +1 -1
- data/lib/hecks/cli/interview_aggregate.rb +4 -4
- data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
- data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
- data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
- data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
- data/lib/hecks/composer/adapters/jev.adapter +3 -0
- data/lib/hecks/composer/adapters/jev.rb +66 -0
- data/lib/hecks/composer/agent.rb +113 -0
- data/lib/hecks/composer/assistant.rb +110 -0
- data/lib/hecks/composer/behavior_run.rb +53 -0
- data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
- data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
- data/lib/hecks/composer/board.rb +65 -0
- data/lib/hecks/composer/board_edit/command_text.rb +39 -0
- data/lib/hecks/composer/board_edit/source.rb +84 -0
- data/lib/hecks/composer/board_edit.rb +144 -0
- data/lib/hecks/composer/check.rb +89 -0
- data/lib/hecks/composer/domains.rb +81 -0
- data/lib/hecks/composer/jobs.rb +134 -0
- data/lib/hecks/composer/layout.rb +37 -0
- data/lib/hecks/composer/live.rb +150 -0
- data/lib/hecks/composer/outline.rb +48 -0
- data/lib/hecks/composer/page.html +989 -0
- data/lib/hecks/composer/panels.rb +142 -0
- data/lib/hecks/composer/parse.rb +83 -0
- data/lib/hecks/composer/plain.rb +28 -0
- data/lib/hecks/composer/ports/composer_agent.port +7 -0
- data/lib/hecks/composer/session.rb +141 -0
- data/lib/hecks/composer/toolbox.rb +166 -0
- data/lib/hecks/composer/web.rb +156 -0
- data/lib/hecks/composer/workbench.rb +51 -0
- data/lib/hecks/composer/workspace.rb +81 -0
- data/lib/hecks/composer.rb +17 -0
- data/lib/hecks/corpus/adapters.rb +22 -0
- data/lib/hecks/corpus/rust.rb +1 -1
- data/lib/hecks/corpus/worlds.rb +19 -0
- data/lib/hecks/corpus.rb +5 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -34
- data/lib/hecks/forms/app/commands.rb +2 -3
- data/lib/hecks/forms/app/queries.rb +2 -3
- data/lib/hecks/forms/app/records.rb +17 -5
- data/lib/hecks/forms/command_form_renderer.rb +42 -16
- data/lib/hecks/forms/field_controls.rb +2 -2
- data/lib/hecks/forms/field_renderer.rb +1 -1
- data/lib/hecks/forms/field_shape.rb +36 -17
- data/lib/hecks/forms/index_renderer.rb +2 -1
- data/lib/hecks/forms/page.rb +1 -1
- data/lib/hecks/forms/params.rb +0 -13
- data/lib/hecks/forms/query_form_renderer.rb +7 -6
- data/lib/hecks/forms/record_renderer.rb +11 -7
- data/lib/hecks/forms/rule_words.rb +100 -0
- data/lib/hecks/forms/value_object_shape.rb +2 -1
- data/lib/hecks/framework/bluebook/change.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- data/lib/hecks/fuzzing/domain_generator/block_rendering.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
- data/lib/hecks/fuzzing/replay/filters.rb +2 -6
- data/lib/hecks/gate/stages.yml +25 -0
- data/lib/hecks/grammar/evolve/seeds.rb +0 -6
- data/lib/hecks/grammar/evolve.rb +0 -3
- data/lib/hecks/grammar/translation.bluebook +1 -1
- data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
- data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
- data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
- data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
- data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +58 -37
- data/lib/hecks/hecks/composer.behaviors +68 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.behaviors +0 -6
- data/lib/hecks/hecks/custodian.bluebook +15 -92
- data/lib/hecks/hecks/hecks.bluebook +2 -2
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +10 -16
- data/lib/hecks/hecks/tooling.bluebook +22 -22
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +1 -0
- data/lib/hecks/language/bluebook/command.bluebook +1 -0
- data/lib/hecks/language/bluebook/entity.bluebook +1 -0
- data/lib/hecks/language/bluebook/query.bluebook +1 -0
- data/lib/hecks/language/bluebook/shape.bluebook +8 -7
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- data/lib/hecks/language/hecksagon/port_operation.bluebook +1 -0
- data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
- data/lib/hecks/ports/access_control.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
- data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
- data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
- data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
- data/lib/hecks/ports.rb +0 -1
- data/lib/hecks/projection_files.rb +3 -1
- data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
- data/lib/hecks/projections/glossary/entries.rb +10 -4
- data/lib/hecks/projections/glossary/groups.rb +42 -0
- data/lib/hecks/projections/glossary/html/page.rb +7 -2
- data/lib/hecks/projections/glossary/html/parser.rb +1 -1
- data/lib/hecks/projections/glossary/html/sections.rb +31 -11
- data/lib/hecks/projections/glossary/html.rb +1 -2
- data/lib/hecks/projections/glossary/markdown.rb +31 -28
- data/lib/hecks/projections/glossary/mermaid.rb +3 -0
- data/lib/hecks/projections/glossary/page.css +143 -153
- data/lib/hecks/projections/glossary/page.js +63 -3
- data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
- data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
- data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
- data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
- data/lib/hecks/projections/glossary/sentences.rb +13 -3
- data/lib/hecks/projections/glossary/slugs.rb +4 -1
- data/lib/hecks/projections/glossary.rb +2 -1
- data/lib/hecks/projections/plain_english.rb +97 -0
- data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
- data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary.rb +14 -6
- data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
- data/lib/hecks/projections/statements/actions.rb +99 -0
- data/lib/hecks/projections/statements/relationships.rb +86 -0
- data/lib/hecks/projections/statements.rb +15 -24
- data/lib/hecks/projector/exporter/adapters.rb +112 -0
- data/lib/hecks/projector/exporter/translations.rb +18 -35
- data/lib/hecks/projector/exporter/worlds.rb +103 -0
- data/lib/hecks/projector/exporter.rb +5 -1
- data/lib/hecks/quality_control/adapters/agent.rb +6 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
- data/lib/hecks/quality_control/quality_control.bluebook +51 -51
- data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
- data/lib/hecks/runtime/outbox.rb +2 -9
- data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
- data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
- data/lib/hecks/runtime/value.rb +13 -0
- data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
- data/lib/hecks/rust_build/wasm.rb +0 -12
- data/lib/hecks/rust_build/write_if_changed.rb +12 -12
- data/lib/hecks/rust_format.rb +77 -0
- data/lib/hecks/site/bluebook/site.bluebook +5 -5
- data/lib/hecks/sme/bluebook/sme.bluebook +12 -12
- data/lib/hecks/tickets/bluebook/tickets.bluebook +3 -3
- data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
- data/lib/hecks/tools/fuzz_sweep.rb +1 -3
- data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
- data/lib/hecks/tools/translation_audit.rb +8 -10
- data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- data/lib/hecks.rb +3 -0
- data/prebuilt/{58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
- data/prebuilt/{2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
- data/prebuilt/verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json +1 -0
- data/rust/Cargo.toml +40 -0
- data/rust/build/Cargo.toml +36 -0
- data/rust/build/clippy.toml +4 -0
- data/rust/build/src/adapters_pass.rs +196 -0
- data/rust/build/src/binding_pass.rs +54 -13
- data/rust/build/src/build_artifact.rs +23 -5
- data/rust/build/src/cargo_sync.rs +69 -26
- data/rust/build/src/lineage_pass.rs +101 -87
- data/rust/build/src/main.rs +11 -2
- data/rust/build/src/pipeline.rs +80 -19
- data/rust/build/src/reserved_names.rs +6 -50
- data/rust/build/src/resolve.rs +82 -35
- data/rust/build/src/rustfmt.rs +100 -0
- data/rust/build/src/seams_pass.rs +85 -16
- data/rust/build/src/subprocess.rs +30 -6
- data/rust/build/src/tmp.rs +5 -2
- data/rust/build/src/translation_pass.rs +72 -22
- data/rust/build/src/world_pass.rs +109 -0
- data/rust/clippy.toml +4 -0
- data/rust/codegen/Cargo.toml +36 -0
- data/rust/codegen/clippy.toml +4 -0
- data/rust/codegen/src/asks.rs +40 -14
- data/rust/codegen/src/attr.rs +2 -2
- data/rust/codegen/src/bridging.rs +676 -117
- data/rust/codegen/src/commands.rs +979 -373
- data/rust/codegen/src/constraints.rs +88 -25
- data/rust/codegen/src/dependency_planning.rs +128 -34
- data/rust/codegen/src/domain_generator.rs +300 -170
- data/rust/codegen/src/exemplar.rs +87 -32
- data/rust/codegen/src/expr_emitter.rs +54 -6
- data/rust/codegen/src/fielded.rs +193 -41
- data/rust/codegen/src/hecks_naming.rs +124 -16
- data/rust/codegen/src/hecks_naming_tests.rs +81 -0
- data/rust/codegen/src/json.rs +80 -44
- data/rust/codegen/src/json_codec.rs +585 -149
- data/rust/codegen/src/literal.rs +104 -138
- data/rust/codegen/src/main.rs +145 -52
- data/rust/codegen/src/manifest.rs +76 -14
- data/rust/codegen/src/mutations.rs +1212 -239
- data/rust/codegen/src/naming.rs +111 -60
- data/rust/codegen/src/optional_pass.rs +90 -28
- data/rust/codegen/src/ports.rs +5 -4
- data/rust/codegen/src/prelude.rs +139 -49
- data/rust/codegen/src/queries.rs +601 -226
- data/rust/codegen/src/reactions.rs +268 -74
- data/rust/codegen/src/read_models.rs +606 -205
- data/rust/codegen/src/reference_specs.rs +16 -3
- data/rust/codegen/src/registry.rs +128 -79
- data/rust/codegen/src/reserved_names.rs +6 -50
- data/rust/codegen/src/shared.rs +71 -22
- data/rust/codegen/src/sidecars.rs +15 -5
- data/rust/codegen/src/skip_reason.rs +8 -2
- data/rust/codegen/src/types.rs +306 -108
- data/rust/host/Cargo.lock +1 -0
- data/rust/host/Cargo.toml +40 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/clippy.toml +4 -0
- data/rust/host/src/api.rs +698 -218
- data/rust/host/src/auth.rs +491 -214
- data/rust/host/src/b64.rs +91 -0
- data/rust/host/src/bin/expr_harness.rs +6 -2
- data/rust/host/src/bin/lineage_harness.rs +47 -11
- data/rust/host/src/bin/mint_harness.rs +70 -37
- data/rust/host/src/bin/validate_harness.rs +16 -8
- data/rust/host/src/boot.rs +144 -68
- data/rust/host/src/boundary_fuzz/api.rs +130 -32
- data/rust/host/src/boundary_fuzz/auth.rs +108 -23
- data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
- data/rust/host/src/boundary_fuzz/ir.rs +164 -12
- data/rust/host/src/boundary_fuzz/server.rs +283 -36
- data/rust/host/src/boundary_fuzz/web.rs +56 -8
- data/rust/host/src/checkout.rs +78 -32
- data/rust/host/src/clock.rs +8 -0
- data/rust/host/src/commerce_ir.rs +333 -95
- data/rust/host/src/crypto.rs +33 -0
- data/rust/host/src/dispatch.rs +990 -331
- data/rust/host/src/expr_json.rs +1021 -273
- data/rust/host/src/extension.rs +24 -4
- data/rust/host/src/field_hints.rs +11 -7
- data/rust/host/src/fuzz_support.rs +85 -13
- data/rust/host/src/host.rs +100 -0
- data/rust/host/src/http.rs +22 -0
- data/rust/host/src/ir.rs +326 -16
- data/rust/host/src/journal.rs +277 -176
- data/rust/host/src/lambda_client.rs +219 -53
- data/rust/host/src/lib.rs +16 -4
- data/rust/host/src/log.rs +62 -11
- data/rust/host/src/mint.rs +1153 -326
- data/rust/host/src/needs.rs +185 -44
- data/rust/host/src/payments/keystore.rs +197 -45
- data/rust/host/src/payments/roles.rs +191 -0
- data/rust/host/src/payments/tests.rs +1445 -275
- data/rust/host/src/payments.rs +409 -121
- data/rust/host/src/platform_signin/attack_tests.rs +5 -5
- data/rust/host/src/platform_signin/config.rs +11 -11
- data/rust/host/src/platform_signin/cookie.rs +2 -2
- data/rust/host/src/platform_signin/extension.rs +2 -1
- data/rust/host/src/platform_signin/handler.rs +9 -8
- data/rust/host/src/platform_signin/jti.rs +4 -2
- data/rust/host/src/platform_signin/jwks.rs +2 -1
- data/rust/host/src/platform_signin/jws.rs +2 -37
- data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
- data/rust/host/src/platform_signin/mode_tests.rs +5 -3
- data/rust/host/src/platform_signin/pages.rs +20 -20
- data/rust/host/src/platform_signin/session.rs +8 -1
- data/rust/host/src/platform_signin/settings.rs +6 -6
- data/rust/host/src/platform_signin/strict_json.rs +12 -9
- data/rust/host/src/platform_signin/verify.rs +41 -41
- data/rust/host/src/platform_signin/wiring_support.rs +13 -18
- data/rust/host/src/presentation.rs +207 -63
- data/rust/host/src/presentation_write.rs +502 -187
- data/rust/host/src/query_step.rs +69 -18
- data/rust/host/src/rate_limit.rs +541 -142
- data/rust/host/src/reference_transform.rs +77 -25
- data/rust/host/src/reference_validate.rs +443 -104
- data/rust/host/src/resend.rs +190 -55
- data/rust/host/src/secrets.rs +116 -21
- data/rust/host/src/server.rs +483 -104
- data/rust/host/src/storage_shape.rs +88 -21
- data/rust/host/src/test_pg.rs +17 -7
- data/rust/host/src/ui_schema.rs +495 -207
- data/rust/host/src/wasm_runner.rs +21 -6
- data/rust/host/src/web/commerce.rs +101 -23
- data/rust/host/src/web/newsletter/cooldown.rs +10 -3
- data/rust/host/src/web/newsletter/template.rs +26 -7
- data/rust/host/src/web/newsletter/tests.rs +112 -23
- data/rust/host/src/web/newsletter.rs +209 -59
- data/rust/host/src/web/newsletter_send.rs +251 -79
- data/rust/host/src/web/registration_receipt/tests.rs +16 -4
- data/rust/host/src/web/registration_receipt.rs +100 -24
- data/rust/host/src/web/registrations.rs +1384 -332
- data/rust/host/src/web/resend_webhook.rs +138 -80
- data/rust/host/src/web.rs +2416 -508
- data/rust/lsp/Cargo.toml +36 -0
- data/rust/lsp/clippy.toml +4 -0
- data/rust/lsp/src/json.rs +29 -22
- data/rust/lsp/src/main.rs +81 -31
- data/rust/lsp/src/outline.rs +23 -7
- data/rust/lsp/src/rpc.rs +17 -4
- data/rust/parser/Cargo.toml +36 -0
- data/rust/parser/clippy.toml +4 -0
- data/rust/parser/src/build/closed_sets.rs +6 -1
- data/rust/parser/src/build/identity.rs +4 -3
- data/rust/parser/src/build/mod.rs +1 -0
- data/rust/parser/src/build/named_pattern.rs +26 -0
- data/rust/parser/src/build/naming.rs +2 -80
- data/rust/parser/src/build/pattern_subset.rs +6 -8
- data/rust/parser/src/build/query_derive.rs +3 -1
- data/rust/parser/src/build/query_inference.rs +14 -5
- data/rust/parser/src/build/read_model.rs +4 -1
- data/rust/parser/src/build/references.rs +4 -3
- data/rust/parser/src/canonical.rs +34 -15
- data/rust/parser/src/emit.rs +187 -105
- data/rust/parser/src/expr/ast_json.rs +134 -42
- data/rust/parser/src/expr/evaluator.rs +27 -9
- data/rust/parser/src/expr/mod.rs +54 -13
- data/rust/parser/src/expr/resolver.rs +299 -92
- data/rust/parser/src/ir.rs +35 -3
- data/rust/parser/src/keywords.rs +7168 -510
- data/rust/parser/src/lex.rs +11 -25
- data/rust/parser/src/lib.rs +1 -0
- data/rust/parser/src/main.rs +65 -6
- data/rust/parser/src/parse/adapter.rs +157 -0
- data/rust/parser/src/parse/aggregate.rs +19 -10
- data/rust/parser/src/parse/chapter.rs +16 -17
- data/rust/parser/src/parse/command.rs +35 -16
- data/rust/parser/src/parse/domain_port.rs +4 -7
- data/rust/parser/src/parse/entity.rs +21 -11
- data/rust/parser/src/parse/hecksagon.rs +10 -4
- data/rust/parser/src/parse/lifecycle.rs +9 -5
- data/rust/parser/src/parse/mod.rs +43 -91
- data/rust/parser/src/parse/needs.rs +21 -12
- data/rust/parser/src/parse/policy.rs +2 -2
- data/rust/parser/src/parse/process_manager.rs +9 -11
- data/rust/parser/src/parse/query.rs +8 -4
- data/rust/parser/src/parse/read_model.rs +57 -16
- data/rust/parser/src/parse/value_object.rs +7 -2
- data/rust/parser/src/parse/world.rs +322 -0
- data/rust/parser/src/ruby_value.rs +7 -6
- data/rust/src/exemplar/json.rs +4 -4
- data/rust/src/exemplar/mutations.rs +18 -0
- data/rust/src/exemplar/read_models.rs +1 -10
- data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
- data/rust/src/kernel/attribute_shapes/list.rs +2 -2
- data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
- data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
- data/rust/src/kernel/cli.rs +229 -112
- data/rust/src/kernel/dispatch.rs +323 -115
- data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
- data/rust/src/kernel/expr.rs +203 -86
- data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
- data/rust/src/kernel/expression_operators/comparison.rs +87 -2
- data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
- data/rust/src/kernel/expression_operators/logical.rs +9 -3
- data/rust/src/kernel/expression_operators/membership.rs +3 -1
- data/rust/src/kernel/expression_operators/mod.rs +34 -21
- data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
- data/rust/src/kernel/expression_operators/positional.rs +84 -30
- data/rust/src/kernel/expression_operators/presence.rs +52 -13
- data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
- data/rust/src/kernel/expression_operators/sized.rs +24 -8
- data/rust/src/kernel/expression_operators/text.rs +120 -27
- data/rust/src/kernel/expression_operators/to_string.rs +10 -4
- data/rust/src/kernel/json.rs +230 -148
- data/rust/src/kernel/mod.rs +46 -33
- data/rust/src/kernel/named_query.rs +63 -15
- data/rust/src/kernel/needs.rs +112 -25
- data/rust/src/kernel/orchestrate.rs +980 -581
- data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
- data/rust/src/kernel/pattern.rs +107 -34
- data/rust/src/kernel/query_comparators.rs +118 -41
- data/rust/src/kernel/query_ordering.rs +13 -5
- data/rust/src/kernel/read_model.rs +479 -181
- data/rust/src/kernel/reference_lookup.rs +122 -26
- data/rust/src/kernel/repository.rs +293 -66
- data/rust/src/kernel/routing.rs +58 -42
- data/rust/src/kernel/ruby_float.rs +139 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
- data/rust/src/kernel/vocab/field_hint.rs +19 -25
- data/rust/src/kernel/vocab/mod.rs +6 -6
- data/rust/src/kernel/vocab/query_comparator.rs +23 -24
- data/rust/src/kernel/vocab/refusal_template.rs +501 -297
- data/rust/src/lib.rs +50 -0
- data/rust/src/main.rs +4 -1
- data/rust/web/Cargo.toml +36 -0
- data/rust/web/clippy.toml +4 -0
- metadata +85 -15
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
- data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
- data/lib/hecks/ports/authentication.port +0 -6
- data/lib/hecks/ports/authentication.rb +0 -67
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
- data/prebuilt/verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json +0 -1
- data/rust/host/src/approval.rs +0 -652
data/rust/host/src/web.rs
CHANGED
|
@@ -5,11 +5,12 @@ use crate::api;
|
|
|
5
5
|
use crate::auth;
|
|
6
6
|
use crate::auth::Session;
|
|
7
7
|
use crate::dispatch;
|
|
8
|
+
use crate::extension::{self, Ctx};
|
|
8
9
|
use crate::field_hints::{EMAIL_HINT, TEL_HINT, TEXTAREA_HINT, URL_HINT};
|
|
10
|
+
use crate::host::Host;
|
|
9
11
|
use crate::ir::ir;
|
|
10
12
|
use crate::journal::LineageConfig;
|
|
11
13
|
use crate::lambda_client::LambdaInvoker;
|
|
12
|
-
use crate::extension::{self, Ctx};
|
|
13
14
|
use serde_json::{json, Value};
|
|
14
15
|
use std::collections::HashMap;
|
|
15
16
|
use std::path::Path;
|
|
@@ -28,26 +29,27 @@ use registrations::payments_routes;
|
|
|
28
29
|
// payments.rs, its tests and the boot check reach these through `web::`.
|
|
29
30
|
pub use registrations::checkout_enabled;
|
|
30
31
|
#[cfg(test)]
|
|
31
|
-
pub(crate) use registrations::{
|
|
32
|
+
pub(crate) use registrations::{
|
|
33
|
+
registration_complete_route, registrations_route, webhook_route, MOCK_STRIPE_WEBHOOK_SECRET,
|
|
34
|
+
};
|
|
32
35
|
#[cfg(test)]
|
|
33
36
|
pub(crate) use registrations::{seats_left, seats_taken};
|
|
34
37
|
|
|
35
38
|
const UNGATED_PATHS: &[&str] = &["/login", "/logout", "/auth/google", "/auth/google/callback"];
|
|
36
|
-
|
|
37
|
-
#[allow(clippy::too_many_arguments)]
|
|
38
|
-
pub async fn render(
|
|
39
|
-
body: &Value,
|
|
40
|
-
client: &Mutex<Client>,
|
|
41
|
-
wasm_path: &Path,
|
|
42
|
-
config: &LineageConfig,
|
|
43
|
-
invoker: &dyn LambdaInvoker,
|
|
44
|
-
) -> Option<Value> {
|
|
39
|
+
pub async fn render(body: &Value, host: Host<'_>) -> Option<Value> {
|
|
45
40
|
let http_event = body.get("requestContext")?.get("http")?;
|
|
46
41
|
|
|
47
|
-
let method = http_event
|
|
42
|
+
let method = http_event
|
|
43
|
+
.get("method")
|
|
44
|
+
.and_then(|v| v.as_str())
|
|
45
|
+
.unwrap_or("GET");
|
|
48
46
|
let path = body.get("rawPath").and_then(|v| v.as_str()).unwrap_or("/");
|
|
49
47
|
let raw_body = body.get("body").and_then(|v| v.as_str()).unwrap_or("");
|
|
50
|
-
let raw_body = if body
|
|
48
|
+
let raw_body = if body
|
|
49
|
+
.get("isBase64Encoded")
|
|
50
|
+
.and_then(serde_json::Value::as_bool)
|
|
51
|
+
== Some(true)
|
|
52
|
+
{
|
|
51
53
|
String::from_utf8(base64_decode(raw_body)).unwrap_or_default()
|
|
52
54
|
} else {
|
|
53
55
|
raw_body.to_string()
|
|
@@ -56,7 +58,11 @@ pub async fn render(
|
|
|
56
58
|
// Parsed before `checkout_enabled` because newsletter's email-link
|
|
57
59
|
// routes need it there. Query strings are RFC 3986 — `+` is literal,
|
|
58
60
|
// not a space (unlike form bodies, which still use parse_form).
|
|
59
|
-
let query = parse_query(
|
|
61
|
+
let query = parse_query(
|
|
62
|
+
body.get("rawQueryString")
|
|
63
|
+
.and_then(|v| v.as_str())
|
|
64
|
+
.unwrap_or(""),
|
|
65
|
+
);
|
|
60
66
|
|
|
61
67
|
let cookies = extract_cookies(body);
|
|
62
68
|
let ctx = Ctx {
|
|
@@ -67,10 +73,11 @@ pub async fn render(
|
|
|
67
73
|
raw_body: &raw_body,
|
|
68
74
|
cookies: &cookies,
|
|
69
75
|
domain_ir: ir(),
|
|
70
|
-
client,
|
|
71
|
-
wasm_path,
|
|
72
|
-
config,
|
|
73
|
-
invoker,
|
|
76
|
+
client: host.client,
|
|
77
|
+
wasm_path: host.wasm_path,
|
|
78
|
+
config: host.config,
|
|
79
|
+
invoker: host.invoker,
|
|
80
|
+
host,
|
|
74
81
|
};
|
|
75
82
|
for extension in extension::installed() {
|
|
76
83
|
if let Some(response) = extension.guest_route(&ctx).await {
|
|
@@ -79,10 +86,14 @@ pub async fn render(
|
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
let Some(domain_ir) = ir() else {
|
|
82
|
-
return Some(respond(
|
|
89
|
+
return Some(respond(
|
|
90
|
+
500,
|
|
91
|
+
"text/plain",
|
|
92
|
+
"HECKS_IR_PATH not set or unreadable — this domain has no web layer configured",
|
|
93
|
+
));
|
|
83
94
|
};
|
|
84
95
|
|
|
85
|
-
Some(route(domain_ir, method, path, &query, &raw_body, &cookies,
|
|
96
|
+
Some(route(domain_ir, method, path, &query, &raw_body, &cookies, host).await)
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
fn extract_cookies(body: &Value) -> HashMap<String, String> {
|
|
@@ -95,7 +106,11 @@ fn extract_cookies(body: &Value) -> HashMap<String, String> {
|
|
|
95
106
|
map.insert(k.trim().to_string(), v.trim().to_string());
|
|
96
107
|
}
|
|
97
108
|
}
|
|
98
|
-
} else if let Some(header) = body
|
|
109
|
+
} else if let Some(header) = body
|
|
110
|
+
.get("headers")
|
|
111
|
+
.and_then(|h| h.get("cookie"))
|
|
112
|
+
.and_then(|v| v.as_str())
|
|
113
|
+
{
|
|
99
114
|
for pair in header.split(';') {
|
|
100
115
|
if let Some((k, v)) = pair.split_once('=') {
|
|
101
116
|
map.insert(k.trim().to_string(), v.trim().to_string());
|
|
@@ -108,8 +123,9 @@ fn extract_cookies(body: &Value) -> HashMap<String, String> {
|
|
|
108
123
|
// Refuses on an empty/unset secret rather than defaulting to "" and
|
|
109
124
|
// signing every cookie with a publicly-known key. Checked here, not
|
|
110
125
|
// at boot, since a domain with no web layer never sets it at all.
|
|
126
|
+
#[allow(clippy::panic)] // reason: refusing to boot beats signing cookies with a publicly-known key
|
|
111
127
|
fn session_secret() -> String {
|
|
112
|
-
let secret =
|
|
128
|
+
let secret = crate::secrets::var("SESSION_SECRET").unwrap_or_default();
|
|
113
129
|
if let Err(e) = validate_session_secret(&secret) {
|
|
114
130
|
panic!("{e}");
|
|
115
131
|
}
|
|
@@ -119,9 +135,11 @@ fn session_secret() -> String {
|
|
|
119
135
|
// Kept pure and separately testable; only `session_secret` panics.
|
|
120
136
|
fn validate_session_secret(secret: &str) -> Result<(), String> {
|
|
121
137
|
if secret.is_empty() {
|
|
122
|
-
Err(
|
|
138
|
+
Err(
|
|
139
|
+
"SESSION_SECRET is required and must not be empty -- refusing to sign/verify session \
|
|
123
140
|
cookies and OAuth state with a publicly-known empty-string HMAC key"
|
|
124
|
-
|
|
141
|
+
.to_string(),
|
|
142
|
+
)
|
|
125
143
|
} else {
|
|
126
144
|
Ok(())
|
|
127
145
|
}
|
|
@@ -140,7 +158,11 @@ fn auth_gate(path: &str, authenticated: bool) -> Option<Value> {
|
|
|
140
158
|
Some(if json_shaped(path) {
|
|
141
159
|
// The Ruby engine's own refusal body, key for key, so a client
|
|
142
160
|
// can branch on it without caring which runtime answered.
|
|
143
|
-
respond(
|
|
161
|
+
respond(
|
|
162
|
+
401,
|
|
163
|
+
"application/json",
|
|
164
|
+
&json!({"error": "Unauthenticated", "message": "sign in first"}).to_string(),
|
|
165
|
+
)
|
|
144
166
|
} else {
|
|
145
167
|
redirect("/login")
|
|
146
168
|
})
|
|
@@ -157,8 +179,6 @@ fn json_shaped(path: &str) -> bool {
|
|
|
157
179
|
_ => false,
|
|
158
180
|
}
|
|
159
181
|
}
|
|
160
|
-
|
|
161
|
-
#[allow(clippy::too_many_arguments)]
|
|
162
182
|
async fn route(
|
|
163
183
|
domain_ir: &Value,
|
|
164
184
|
method: &str,
|
|
@@ -166,16 +186,30 @@ async fn route(
|
|
|
166
186
|
query: &HashMap<String, String>,
|
|
167
187
|
raw_body: &str,
|
|
168
188
|
cookies: &HashMap<String, String>,
|
|
169
|
-
|
|
170
|
-
wasm_path: &Path,
|
|
171
|
-
config: &LineageConfig,
|
|
172
|
-
invoker: &dyn LambdaInvoker,
|
|
189
|
+
host: Host<'_>,
|
|
173
190
|
) -> Value {
|
|
191
|
+
let Host {
|
|
192
|
+
client, wasm_path, ..
|
|
193
|
+
} = host;
|
|
174
194
|
let domain_name = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
175
195
|
let secret = session_secret();
|
|
176
|
-
let session = cookies
|
|
196
|
+
let session = cookies
|
|
197
|
+
.get("session")
|
|
198
|
+
.and_then(|c| auth::parse_session_cookie(&secret, c));
|
|
177
199
|
|
|
178
|
-
if let Some(response) = auth_route(
|
|
200
|
+
if let Some(response) = auth_route(
|
|
201
|
+
domain_ir,
|
|
202
|
+
path,
|
|
203
|
+
method,
|
|
204
|
+
query,
|
|
205
|
+
raw_body,
|
|
206
|
+
cookies,
|
|
207
|
+
session.as_ref(),
|
|
208
|
+
&secret,
|
|
209
|
+
host,
|
|
210
|
+
)
|
|
211
|
+
.await
|
|
212
|
+
{
|
|
179
213
|
return response;
|
|
180
214
|
}
|
|
181
215
|
|
|
@@ -187,7 +221,16 @@ async fn route(
|
|
|
187
221
|
// 404s itself — falling through would 404 here as "no domain \"api\""
|
|
188
222
|
// instead, for a request the Ruby engine answers with data.
|
|
189
223
|
if path.starts_with("/api/") {
|
|
190
|
-
return api::route(
|
|
224
|
+
return api::route(
|
|
225
|
+
domain_ir,
|
|
226
|
+
method,
|
|
227
|
+
path,
|
|
228
|
+
query,
|
|
229
|
+
raw_body,
|
|
230
|
+
session.as_ref(),
|
|
231
|
+
host,
|
|
232
|
+
)
|
|
233
|
+
.await;
|
|
191
234
|
}
|
|
192
235
|
|
|
193
236
|
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
|
@@ -197,14 +240,22 @@ async fn route(
|
|
|
197
240
|
}
|
|
198
241
|
|
|
199
242
|
if segments[0] != domain_name {
|
|
200
|
-
return respond(
|
|
243
|
+
return respond(
|
|
244
|
+
404,
|
|
245
|
+
"text/plain",
|
|
246
|
+
&format!("no domain {:?} loaded", segments[0]),
|
|
247
|
+
);
|
|
201
248
|
}
|
|
202
249
|
|
|
203
250
|
let Some((aggregate_seg, format)) = segments.get(1).map(|s| split_format(s)) else {
|
|
204
251
|
return respond(404, "text/plain", "no route");
|
|
205
252
|
};
|
|
206
253
|
let Some(aggregate) = find_aggregate(domain_ir, &aggregate_seg) else {
|
|
207
|
-
return respond(
|
|
254
|
+
return respond(
|
|
255
|
+
404,
|
|
256
|
+
"text/plain",
|
|
257
|
+
&format!("{domain_name} has no aggregate {aggregate_seg:?}"),
|
|
258
|
+
);
|
|
208
259
|
};
|
|
209
260
|
|
|
210
261
|
if segments.len() == 2 {
|
|
@@ -216,12 +267,28 @@ async fn route(
|
|
|
216
267
|
if let Some(command) = find_command(aggregate, &verb_or_id) {
|
|
217
268
|
let action = format!("/{domain_name}/{}/{}.html", agg_name(aggregate), verb_or_id);
|
|
218
269
|
return command_route(
|
|
219
|
-
domain_ir,
|
|
220
|
-
|
|
270
|
+
domain_ir,
|
|
271
|
+
domain_name,
|
|
272
|
+
aggregate,
|
|
273
|
+
command,
|
|
274
|
+
&action,
|
|
275
|
+
&format,
|
|
276
|
+
method,
|
|
277
|
+
query,
|
|
278
|
+
raw_body,
|
|
279
|
+
host,
|
|
221
280
|
)
|
|
222
281
|
.await;
|
|
223
282
|
}
|
|
224
|
-
return record_show(
|
|
283
|
+
return record_show(
|
|
284
|
+
domain_name,
|
|
285
|
+
aggregate,
|
|
286
|
+
&verb_or_id,
|
|
287
|
+
&format,
|
|
288
|
+
client,
|
|
289
|
+
wasm_path,
|
|
290
|
+
)
|
|
291
|
+
.await;
|
|
225
292
|
}
|
|
226
293
|
|
|
227
294
|
respond(404, "text/plain", &format!("no route for {path}"))
|
|
@@ -239,11 +306,15 @@ async fn auth_route(
|
|
|
239
306
|
cookies: &HashMap<String, String>,
|
|
240
307
|
session: Option<&Session>,
|
|
241
308
|
secret: &str,
|
|
242
|
-
|
|
243
|
-
wasm_path: &Path,
|
|
244
|
-
config: &LineageConfig,
|
|
245
|
-
invoker: &dyn LambdaInvoker,
|
|
309
|
+
host: Host<'_>,
|
|
246
310
|
) -> Option<Value> {
|
|
311
|
+
let Host {
|
|
312
|
+
client,
|
|
313
|
+
wasm_path,
|
|
314
|
+
config,
|
|
315
|
+
invoker,
|
|
316
|
+
..
|
|
317
|
+
} = host;
|
|
247
318
|
let ctx = Ctx {
|
|
248
319
|
event: None,
|
|
249
320
|
method,
|
|
@@ -252,10 +323,11 @@ async fn auth_route(
|
|
|
252
323
|
raw_body,
|
|
253
324
|
cookies,
|
|
254
325
|
domain_ir: Some(domain_ir),
|
|
255
|
-
client,
|
|
256
|
-
wasm_path,
|
|
257
|
-
config,
|
|
258
|
-
invoker,
|
|
326
|
+
client: host.client,
|
|
327
|
+
wasm_path: host.wasm_path,
|
|
328
|
+
config: host.config,
|
|
329
|
+
invoker: host.invoker,
|
|
330
|
+
host,
|
|
259
331
|
};
|
|
260
332
|
for extension in extension::installed() {
|
|
261
333
|
if let Some(response) = extension.account_route(&ctx).await {
|
|
@@ -264,9 +336,15 @@ async fn auth_route(
|
|
|
264
336
|
}
|
|
265
337
|
|
|
266
338
|
match (method, path) {
|
|
267
|
-
("GET", "/login") => Some(html(
|
|
339
|
+
("GET", "/login") => Some(html(
|
|
340
|
+
200,
|
|
341
|
+
&login_page(query.get("error").map(std::string::String::as_str)),
|
|
342
|
+
)),
|
|
268
343
|
|
|
269
|
-
("POST", "/logout") => Some(redirect_with_cookie(
|
|
344
|
+
("POST", "/logout") => Some(redirect_with_cookie(
|
|
345
|
+
"/login",
|
|
346
|
+
&format!("session=; Max-Age=0{}", cookie_flags()),
|
|
347
|
+
)),
|
|
270
348
|
|
|
271
349
|
// No password login: Google OAuth is the only sign-in path, and the
|
|
272
350
|
// /accounts/* routes here are just the session-cookie family that
|
|
@@ -275,73 +353,186 @@ async fn auth_route(
|
|
|
275
353
|
200,
|
|
276
354
|
"application/json",
|
|
277
355
|
r#"{"ok":true}"#,
|
|
278
|
-
&format!(
|
|
356
|
+
&format!(
|
|
357
|
+
"{}=; Max-Age=0{}",
|
|
358
|
+
auth::account_cookie_name(),
|
|
359
|
+
cookie_flags()
|
|
360
|
+
),
|
|
279
361
|
)),
|
|
280
362
|
|
|
281
|
-
("GET", "/accounts/me") =>
|
|
363
|
+
("GET", "/accounts/me") => {
|
|
364
|
+
Some(accounts_me_route(domain_ir, cookies, secret, client).await)
|
|
365
|
+
}
|
|
282
366
|
|
|
283
367
|
("GET", "/members") => Some(members_route(domain_ir, cookies, secret, client).await),
|
|
284
368
|
|
|
285
|
-
("POST", "/members") => Some(
|
|
369
|
+
("POST", "/members") => Some(
|
|
370
|
+
add_member_route(
|
|
371
|
+
domain_ir, raw_body, cookies, secret, client, wasm_path, config,
|
|
372
|
+
)
|
|
373
|
+
.await,
|
|
374
|
+
),
|
|
286
375
|
|
|
287
|
-
("POST", "/signups") => Some(signup_route(raw_body, secret,
|
|
376
|
+
("POST", "/signups") => Some(signup_route(raw_body, secret, host).await),
|
|
288
377
|
|
|
289
|
-
("POST", "/members/disable") => Some(
|
|
378
|
+
("POST", "/members/disable") => Some(
|
|
379
|
+
set_member_disabled_route(domain_ir, raw_body, cookies, secret, client, config, true)
|
|
380
|
+
.await,
|
|
381
|
+
),
|
|
290
382
|
|
|
291
|
-
("POST", "/members/enable") => Some(
|
|
383
|
+
("POST", "/members/enable") => Some(
|
|
384
|
+
set_member_disabled_route(domain_ir, raw_body, cookies, secret, client, config, false)
|
|
385
|
+
.await,
|
|
386
|
+
),
|
|
292
387
|
|
|
293
|
-
("POST", "/members/role") =>
|
|
388
|
+
("POST", "/members/role") => {
|
|
389
|
+
Some(set_member_role_route(domain_ir, raw_body, cookies, secret, client, config).await)
|
|
390
|
+
}
|
|
294
391
|
|
|
295
|
-
("POST", "/members/delete") => Some(
|
|
392
|
+
("POST", "/members/delete") => Some(
|
|
393
|
+
set_member_deleted_route(domain_ir, raw_body, cookies, secret, client, config).await,
|
|
394
|
+
),
|
|
296
395
|
|
|
297
396
|
// cms/src/endpoints/sso.ts verifies this with the same account_token
|
|
298
397
|
// wire format; deploy-aws/platform/template.yaml shares SessionSecret
|
|
299
398
|
// with the cms container as AUTH_SECRET so both sides agree on it.
|
|
300
|
-
("GET", "/accounts/sso-token") =>
|
|
399
|
+
("GET", "/accounts/sso-token") => {
|
|
400
|
+
Some(accounts_sso_token_route(domain_ir, cookies, secret, client).await)
|
|
401
|
+
}
|
|
301
402
|
|
|
302
403
|
("GET", "/auth/google") => match auth::authorization_url(&redirect_uri(), secret) {
|
|
303
404
|
Ok(url) => Some(redirect(&url)),
|
|
304
|
-
Err(e) => Some(respond(
|
|
405
|
+
Err(e) => Some(respond(
|
|
406
|
+
500,
|
|
407
|
+
"text/plain",
|
|
408
|
+
&format!("Google sign-in isn't configured: {e}"),
|
|
409
|
+
)),
|
|
305
410
|
},
|
|
306
411
|
|
|
307
|
-
("GET", "/auth/google/callback") => Some(
|
|
412
|
+
("GET", "/auth/google/callback") => Some(
|
|
413
|
+
google_callback(domain_ir, query, client, wasm_path, config, secret, invoker).await,
|
|
414
|
+
),
|
|
308
415
|
|
|
309
|
-
("GET", "/admin/members") =>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
416
|
+
("GET" | "POST", "/admin/members") => Some(
|
|
417
|
+
admin_members_route(
|
|
418
|
+
method, domain_ir, raw_body, cookies, session, secret, client, wasm_path, config,
|
|
419
|
+
)
|
|
420
|
+
.await,
|
|
421
|
+
),
|
|
316
422
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
423
|
+
_ => None,
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Whether the caller may use /admin/members: an admin by the Governance
|
|
428
|
+
// session (the Google callback's `session` cookie) or by the account cookie
|
|
429
|
+
// (what platform sign-in and every other admin route carry). A site whose
|
|
430
|
+
// sign-in only sets the account cookie would otherwise loop back to /login.
|
|
431
|
+
async fn members_page_caller(
|
|
432
|
+
domain_ir: &Value,
|
|
433
|
+
cookies: &HashMap<String, String>,
|
|
434
|
+
session: Option<&Session>,
|
|
435
|
+
secret: &str,
|
|
436
|
+
client: &Mutex<Client>,
|
|
437
|
+
wasm_path: &Path,
|
|
438
|
+
) -> Result<(), Value> {
|
|
439
|
+
if let Some(session) = session {
|
|
440
|
+
if is_admin(client, wasm_path, domain_ir, &session.identity_id).await {
|
|
441
|
+
return Ok(());
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
let account = cookies
|
|
445
|
+
.get(&auth::account_cookie_name())
|
|
446
|
+
.and_then(|token| auth::verify_account_token(secret, token));
|
|
447
|
+
if let Some(email) = &account {
|
|
448
|
+
if auth::caller_is_admin(client, domain_ir, email)
|
|
449
|
+
.await
|
|
450
|
+
.unwrap_or(false)
|
|
451
|
+
{
|
|
452
|
+
return Ok(());
|
|
333
453
|
}
|
|
454
|
+
}
|
|
455
|
+
if session.is_none() && account.is_none() {
|
|
456
|
+
return Err(redirect("/login"));
|
|
457
|
+
}
|
|
458
|
+
Err(html(403, "<p>Admins only.</p>"))
|
|
459
|
+
}
|
|
334
460
|
|
|
335
|
-
|
|
461
|
+
// GET /admin/members lists everyone and offers a form; POST from that form
|
|
462
|
+
// admits the person when they are new (named as typed, or by the part of the
|
|
463
|
+
// email before the @) and grants the chosen role.
|
|
464
|
+
#[allow(clippy::too_many_arguments)]
|
|
465
|
+
async fn admin_members_route(
|
|
466
|
+
method: &str,
|
|
467
|
+
domain_ir: &Value,
|
|
468
|
+
raw_body: &str,
|
|
469
|
+
cookies: &HashMap<String, String>,
|
|
470
|
+
session: Option<&Session>,
|
|
471
|
+
secret: &str,
|
|
472
|
+
client: &Mutex<Client>,
|
|
473
|
+
wasm_path: &Path,
|
|
474
|
+
config: &LineageConfig,
|
|
475
|
+
) -> Value {
|
|
476
|
+
if let Err(response) =
|
|
477
|
+
members_page_caller(domain_ir, cookies, session, secret, client, wasm_path).await
|
|
478
|
+
{
|
|
479
|
+
return response;
|
|
480
|
+
}
|
|
481
|
+
if method == "GET" {
|
|
482
|
+
return html(200, &admin_members_page(client, domain_ir).await);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let form = parse_form(raw_body);
|
|
486
|
+
let email = form
|
|
487
|
+
.get("email")
|
|
488
|
+
.map(|s| s.trim().to_lowercase())
|
|
489
|
+
.unwrap_or_default();
|
|
490
|
+
let role = form.get("role").cloned().unwrap_or_default();
|
|
491
|
+
if !auth::GRANTABLE_ROLES.contains(&role.as_str()) {
|
|
492
|
+
return html(
|
|
493
|
+
400,
|
|
494
|
+
&format!(
|
|
495
|
+
"<p>Role must be one of {}.</p>",
|
|
496
|
+
auth::GRANTABLE_ROLES.join(", ")
|
|
497
|
+
),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if !email
|
|
501
|
+
.split_once('@')
|
|
502
|
+
.is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
|
|
503
|
+
{
|
|
504
|
+
return html(400, "<p>Give a valid email.</p>");
|
|
505
|
+
}
|
|
506
|
+
let name = form
|
|
507
|
+
.get("name")
|
|
508
|
+
.map(|s| s.trim().to_string())
|
|
509
|
+
.filter(|s| !s.is_empty())
|
|
510
|
+
.unwrap_or_else(|| email.split('@').next().unwrap_or_default().to_string());
|
|
511
|
+
// Ok(false) is "already admitted": the grant below is then all there is to do.
|
|
512
|
+
if let Err(e) = auth::admit_person(client, config, domain_ir, &email, &name).await {
|
|
513
|
+
return html(500, &format!("<p>{}</p>", esc(&e.to_string())));
|
|
514
|
+
}
|
|
515
|
+
match auth::grant_access(client, wasm_path, config, domain_ir, &email, &role).await {
|
|
516
|
+
Ok(true) => redirect("/admin/members"),
|
|
517
|
+
Ok(false) => html(404, "<p>No member with that email.</p>"),
|
|
518
|
+
Err(e) => html(500, &format!("<p>{}</p>", esc(&e.to_string()))),
|
|
336
519
|
}
|
|
337
520
|
}
|
|
338
521
|
|
|
339
|
-
async fn is_admin(
|
|
522
|
+
async fn is_admin(
|
|
523
|
+
client: &Mutex<Client>,
|
|
524
|
+
wasm_path: &Path,
|
|
525
|
+
domain_ir: &Value,
|
|
526
|
+
identity_id: &str,
|
|
527
|
+
) -> bool {
|
|
340
528
|
let provider = crate::ir::authorization_provider(domain_ir);
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
529
|
+
dispatch::read(client, wasm_path).await.is_ok_and(|read| {
|
|
530
|
+
auth::holds_admin(
|
|
531
|
+
read.get("instances").unwrap_or(&json!({})),
|
|
532
|
+
identity_id,
|
|
533
|
+
provider.as_ref(),
|
|
534
|
+
)
|
|
535
|
+
})
|
|
345
536
|
}
|
|
346
537
|
|
|
347
538
|
// Verifies the token AND that the person still has access, so a
|
|
@@ -352,22 +543,48 @@ async fn active_session_email(
|
|
|
352
543
|
secret: &str,
|
|
353
544
|
client: &Mutex<Client>,
|
|
354
545
|
) -> Result<String, Value> {
|
|
355
|
-
let not_logged_in = ||
|
|
356
|
-
|
|
546
|
+
let not_logged_in = || {
|
|
547
|
+
respond(
|
|
548
|
+
401,
|
|
549
|
+
"application/json",
|
|
550
|
+
&json!({"error": "not logged in"}).to_string(),
|
|
551
|
+
)
|
|
552
|
+
};
|
|
553
|
+
let Some(email) = cookies
|
|
554
|
+
.get(&auth::account_cookie_name())
|
|
555
|
+
.and_then(|token| auth::verify_account_token(secret, token))
|
|
556
|
+
else {
|
|
357
557
|
return Err(not_logged_in());
|
|
358
558
|
};
|
|
359
559
|
match auth::has_access(client, domain_ir, &email).await {
|
|
360
560
|
Ok(true) => Ok(email),
|
|
361
561
|
Ok(false) => Err(not_logged_in()),
|
|
362
|
-
Err(e) => Err(respond(
|
|
562
|
+
Err(e) => Err(respond(
|
|
563
|
+
500,
|
|
564
|
+
"application/json",
|
|
565
|
+
&json!({"error": format!("members lookup failed: {e}")}).to_string(),
|
|
566
|
+
)),
|
|
363
567
|
}
|
|
364
568
|
}
|
|
365
569
|
|
|
366
|
-
async fn accounts_me_route(
|
|
570
|
+
async fn accounts_me_route(
|
|
571
|
+
domain_ir: &Value,
|
|
572
|
+
cookies: &HashMap<String, String>,
|
|
573
|
+
secret: &str,
|
|
574
|
+
client: &Mutex<Client>,
|
|
575
|
+
) -> Value {
|
|
367
576
|
match active_session_email(domain_ir, cookies, secret, client).await {
|
|
368
577
|
Ok(email) => match auth::member_identity_id(client, domain_ir, &email).await {
|
|
369
|
-
Ok(identity_id) => respond(
|
|
370
|
-
|
|
578
|
+
Ok(identity_id) => respond(
|
|
579
|
+
200,
|
|
580
|
+
"application/json",
|
|
581
|
+
&json!({"email": email, "identity_id": identity_id}).to_string(),
|
|
582
|
+
),
|
|
583
|
+
Err(e) => respond(
|
|
584
|
+
500,
|
|
585
|
+
"application/json",
|
|
586
|
+
&json!({"error": format!("members lookup failed: {e}")}).to_string(),
|
|
587
|
+
),
|
|
371
588
|
},
|
|
372
589
|
Err(response) => response,
|
|
373
590
|
}
|
|
@@ -376,13 +593,27 @@ async fn accounts_me_route(domain_ir: &Value, cookies: &HashMap<String, String>,
|
|
|
376
593
|
// GET /members: admitted people as JSON, for an Admin or Owner holding the
|
|
377
594
|
// account cookie but not the Governance session /admin/members needs. The list
|
|
378
595
|
// is every person's name, email and role, so a plain member does not get it.
|
|
379
|
-
async fn members_route(
|
|
380
|
-
|
|
596
|
+
async fn members_route(
|
|
597
|
+
domain_ir: &Value,
|
|
598
|
+
cookies: &HashMap<String, String>,
|
|
599
|
+
secret: &str,
|
|
600
|
+
client: &Mutex<Client>,
|
|
601
|
+
) -> Value {
|
|
602
|
+
if let Err(response) = newsletter_send::require_admin(domain_ir, cookies, secret, client).await
|
|
603
|
+
{
|
|
381
604
|
return response;
|
|
382
605
|
}
|
|
383
606
|
match auth::all_people(client, domain_ir).await {
|
|
384
|
-
Ok(people) => respond(
|
|
385
|
-
|
|
607
|
+
Ok(people) => respond(
|
|
608
|
+
200,
|
|
609
|
+
"application/json",
|
|
610
|
+
&Value::Array(sorted_by_name(people)).to_string(),
|
|
611
|
+
),
|
|
612
|
+
Err(e) => respond(
|
|
613
|
+
500,
|
|
614
|
+
"application/json",
|
|
615
|
+
&json!({"error": format!("members lookup failed: {e}")}).to_string(),
|
|
616
|
+
),
|
|
386
617
|
}
|
|
387
618
|
}
|
|
388
619
|
|
|
@@ -397,9 +628,10 @@ pub(crate) async fn add_member_route(
|
|
|
397
628
|
wasm_path: &Path,
|
|
398
629
|
config: &LineageConfig,
|
|
399
630
|
) -> Value {
|
|
400
|
-
let
|
|
401
|
-
|
|
402
|
-
|
|
631
|
+
let Some(caller) = cookies
|
|
632
|
+
.get(&auth::account_cookie_name())
|
|
633
|
+
.and_then(|token| auth::verify_account_token(secret, token))
|
|
634
|
+
else {
|
|
403
635
|
return json_error(401, "not logged in");
|
|
404
636
|
};
|
|
405
637
|
match auth::caller_is_admin(client, domain_ir, &caller).await {
|
|
@@ -409,18 +641,31 @@ pub(crate) async fn add_member_route(
|
|
|
409
641
|
}
|
|
410
642
|
|
|
411
643
|
let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
|
|
412
|
-
let field = |key: &str|
|
|
644
|
+
let field = |key: &str| {
|
|
645
|
+
body.get(key)
|
|
646
|
+
.and_then(|v| v.as_str())
|
|
647
|
+
.map(|s| s.trim().to_string())
|
|
648
|
+
.unwrap_or_default()
|
|
649
|
+
};
|
|
413
650
|
let (email, name) = (field("email"), field("name"));
|
|
414
|
-
let looks_like_email = email.split_once('@').is_some_and(|(local, domain)|
|
|
651
|
+
let looks_like_email = email.split_once('@').is_some_and(|(local, domain)| {
|
|
652
|
+
!local.is_empty() && domain.contains('.') && !email.contains(char::is_whitespace)
|
|
653
|
+
});
|
|
415
654
|
if name.is_empty() || !looks_like_email {
|
|
416
655
|
return json_error(400, "a name and a valid email are required");
|
|
417
656
|
}
|
|
418
657
|
let role = match body.get("role") {
|
|
419
658
|
None | Some(Value::Null) => "Admin".to_string(),
|
|
420
|
-
Some(value) => value
|
|
659
|
+
Some(value) => value
|
|
660
|
+
.as_str()
|
|
661
|
+
.map(|s| s.trim().to_string())
|
|
662
|
+
.unwrap_or_default(),
|
|
421
663
|
};
|
|
422
664
|
if !auth::GRANTABLE_ROLES.contains(&role.as_str()) {
|
|
423
|
-
return json_error(
|
|
665
|
+
return json_error(
|
|
666
|
+
400,
|
|
667
|
+
&format!("role must be one of {}", auth::GRANTABLE_ROLES.join(", ")),
|
|
668
|
+
);
|
|
424
669
|
}
|
|
425
670
|
|
|
426
671
|
match auth::admit_person(client, config, domain_ir, &email, &name).await {
|
|
@@ -445,32 +690,46 @@ const SIGNUP_PURPOSE: &str = "signup";
|
|
|
445
690
|
|
|
446
691
|
// POST /signups: dispatches Signups::Signup.SignUp as System, gated by
|
|
447
692
|
// a signup-purpose token rather than a session cookie (nobody is signed in yet).
|
|
448
|
-
async fn signup_route(raw_body: &str, secret: &str,
|
|
449
|
-
let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
|
|
450
|
-
|
|
693
|
+
async fn signup_route(raw_body: &str, secret: &str, host: Host<'_>) -> Value {
|
|
451
694
|
let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
|
|
452
|
-
let field = |key: &str|
|
|
695
|
+
let field = |key: &str| {
|
|
696
|
+
body.get(key)
|
|
697
|
+
.and_then(|v| v.as_str())
|
|
698
|
+
.map(|s| s.trim().to_string())
|
|
699
|
+
.unwrap_or_default()
|
|
700
|
+
};
|
|
453
701
|
|
|
454
702
|
if auth::verify_purpose_token(secret, SIGNUP_PURPOSE, &field("token")).is_none() {
|
|
455
703
|
return json_error(401, "not authorized");
|
|
456
704
|
}
|
|
457
705
|
|
|
458
706
|
let (email, name) = (field("email").to_lowercase(), field("name"));
|
|
459
|
-
let looks_like_email = email.split_once('@').is_some_and(|(local, domain)|
|
|
707
|
+
let looks_like_email = email.split_once('@').is_some_and(|(local, domain)| {
|
|
708
|
+
!local.is_empty() && domain.contains('.') && !email.contains(char::is_whitespace)
|
|
709
|
+
});
|
|
460
710
|
if name.is_empty() || !looks_like_email {
|
|
461
711
|
return json_error(400, "a name and a valid email are required");
|
|
462
712
|
}
|
|
463
713
|
|
|
464
714
|
let args = json!({"email": {"value": email}, "name": {"value": name}});
|
|
465
|
-
let outcome = match dispatch::handle(
|
|
715
|
+
let outcome = match dispatch::handle(host, "Signups::Signup.SignUp", args, Some("System")).await
|
|
716
|
+
{
|
|
466
717
|
Ok(o) => o,
|
|
467
718
|
Err(e) => return json_error(500, &format!("{e:#}")),
|
|
468
719
|
};
|
|
469
720
|
if !outcome.accepted {
|
|
470
|
-
return respond(
|
|
721
|
+
return respond(
|
|
722
|
+
422,
|
|
723
|
+
"application/json",
|
|
724
|
+
&last_refusal(&outcome.result).to_string(),
|
|
725
|
+
);
|
|
471
726
|
}
|
|
472
727
|
|
|
473
|
-
respond(
|
|
728
|
+
respond(
|
|
729
|
+
201,
|
|
730
|
+
"application/json",
|
|
731
|
+
&json!({"email": email, "name": name}).to_string(),
|
|
732
|
+
)
|
|
474
733
|
}
|
|
475
734
|
|
|
476
735
|
// POST /members/disable|enable: turns access off/on without deleting
|
|
@@ -484,14 +743,19 @@ async fn set_member_disabled_route(
|
|
|
484
743
|
config: &LineageConfig,
|
|
485
744
|
disable: bool,
|
|
486
745
|
) -> Value {
|
|
487
|
-
let
|
|
488
|
-
|
|
489
|
-
|
|
746
|
+
let Some(caller) = cookies
|
|
747
|
+
.get(&auth::account_cookie_name())
|
|
748
|
+
.and_then(|token| auth::verify_account_token(secret, token))
|
|
749
|
+
else {
|
|
490
750
|
return json_error(401, "not logged in");
|
|
491
751
|
};
|
|
492
752
|
|
|
493
753
|
let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
|
|
494
|
-
let email = body
|
|
754
|
+
let email = body
|
|
755
|
+
.get("email")
|
|
756
|
+
.and_then(|v| v.as_str())
|
|
757
|
+
.map(|s| s.trim().to_string())
|
|
758
|
+
.unwrap_or_default();
|
|
495
759
|
if email.is_empty() {
|
|
496
760
|
return json_error(400, "an email is required");
|
|
497
761
|
}
|
|
@@ -499,12 +763,20 @@ async fn set_member_disabled_route(
|
|
|
499
763
|
match auth::set_person_disabled(client, config, domain_ir, &caller, &email, disable).await {
|
|
500
764
|
Ok(auth::DisableOutcome::Done) => {
|
|
501
765
|
let key = if disable { "disabled" } else { "enabled" };
|
|
502
|
-
respond(
|
|
766
|
+
respond(
|
|
767
|
+
200,
|
|
768
|
+
"application/json",
|
|
769
|
+
&json!({key: email.to_lowercase()}).to_string(),
|
|
770
|
+
)
|
|
503
771
|
}
|
|
504
772
|
Ok(auth::DisableOutcome::CallerNotAdmin) => json_error(403, "admins only"),
|
|
505
|
-
Ok(auth::DisableOutcome::SelfDisable) =>
|
|
773
|
+
Ok(auth::DisableOutcome::SelfDisable) => {
|
|
774
|
+
json_error(403, "you can't disable your own admin access")
|
|
775
|
+
}
|
|
506
776
|
Ok(auth::DisableOutcome::UnknownPerson) => json_error(404, "no member with that email"),
|
|
507
|
-
Ok(auth::DisableOutcome::LastAdmin) =>
|
|
777
|
+
Ok(auth::DisableOutcome::LastAdmin) => {
|
|
778
|
+
json_error(409, "there must always be at least one admin")
|
|
779
|
+
}
|
|
508
780
|
Err(e) => json_error(500, &format!("members update failed: {e}")),
|
|
509
781
|
}
|
|
510
782
|
}
|
|
@@ -521,20 +793,29 @@ async fn set_member_deleted_route(
|
|
|
521
793
|
client: &Mutex<Client>,
|
|
522
794
|
config: &LineageConfig,
|
|
523
795
|
) -> Value {
|
|
524
|
-
let
|
|
525
|
-
|
|
526
|
-
|
|
796
|
+
let Some(caller) = cookies
|
|
797
|
+
.get(&auth::account_cookie_name())
|
|
798
|
+
.and_then(|token| auth::verify_account_token(secret, token))
|
|
799
|
+
else {
|
|
527
800
|
return json_error(401, "not logged in");
|
|
528
801
|
};
|
|
529
802
|
|
|
530
803
|
let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
|
|
531
|
-
let email = body
|
|
804
|
+
let email = body
|
|
805
|
+
.get("email")
|
|
806
|
+
.and_then(|v| v.as_str())
|
|
807
|
+
.map(|s| s.trim().to_string())
|
|
808
|
+
.unwrap_or_default();
|
|
532
809
|
if email.is_empty() {
|
|
533
810
|
return json_error(400, "an email is required");
|
|
534
811
|
}
|
|
535
812
|
|
|
536
813
|
match auth::set_person_deleted(client, config, domain_ir, &caller, &email).await {
|
|
537
|
-
Ok(auth::DeleteOutcome::Done) => respond(
|
|
814
|
+
Ok(auth::DeleteOutcome::Done) => respond(
|
|
815
|
+
200,
|
|
816
|
+
"application/json",
|
|
817
|
+
&json!({"deleted": email.to_lowercase()}).to_string(),
|
|
818
|
+
),
|
|
538
819
|
Ok(auth::DeleteOutcome::CallerNotAdmin) => json_error(403, "admins only"),
|
|
539
820
|
Ok(auth::DeleteOutcome::UnknownPerson) => json_error(404, "no member with that email"),
|
|
540
821
|
Ok(auth::DeleteOutcome::NotDisabled) => json_error(409, "disable this admin first"),
|
|
@@ -552,27 +833,42 @@ async fn set_member_role_route(
|
|
|
552
833
|
client: &Mutex<Client>,
|
|
553
834
|
config: &LineageConfig,
|
|
554
835
|
) -> Value {
|
|
555
|
-
let
|
|
556
|
-
|
|
557
|
-
|
|
836
|
+
let Some(caller) = cookies
|
|
837
|
+
.get(&auth::account_cookie_name())
|
|
838
|
+
.and_then(|token| auth::verify_account_token(secret, token))
|
|
839
|
+
else {
|
|
558
840
|
return json_error(401, "not logged in");
|
|
559
841
|
};
|
|
560
842
|
|
|
561
843
|
let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
|
|
562
|
-
let field = |key: &str|
|
|
844
|
+
let field = |key: &str| {
|
|
845
|
+
body.get(key)
|
|
846
|
+
.and_then(|v| v.as_str())
|
|
847
|
+
.map(|s| s.trim().to_string())
|
|
848
|
+
.unwrap_or_default()
|
|
849
|
+
};
|
|
563
850
|
let (email, role) = (field("email"), field("role"));
|
|
564
851
|
if email.is_empty() {
|
|
565
852
|
return json_error(400, "an email is required");
|
|
566
853
|
}
|
|
567
854
|
if !auth::GRANTABLE_ROLES.contains(&role.as_str()) {
|
|
568
|
-
return json_error(
|
|
855
|
+
return json_error(
|
|
856
|
+
400,
|
|
857
|
+
&format!("role must be one of {}", auth::GRANTABLE_ROLES.join(", ")),
|
|
858
|
+
);
|
|
569
859
|
}
|
|
570
860
|
|
|
571
861
|
match auth::set_person_role(client, config, domain_ir, &caller, &email, &role).await {
|
|
572
|
-
Ok(auth::RoleOutcome::Done) => respond(
|
|
862
|
+
Ok(auth::RoleOutcome::Done) => respond(
|
|
863
|
+
200,
|
|
864
|
+
"application/json",
|
|
865
|
+
&json!({"email": email.to_lowercase(), "role": role}).to_string(),
|
|
866
|
+
),
|
|
573
867
|
Ok(auth::RoleOutcome::CallerNotAdmin) => json_error(403, "admins only"),
|
|
574
868
|
Ok(auth::RoleOutcome::UnknownPerson) => json_error(404, "no member with that email"),
|
|
575
|
-
Ok(auth::RoleOutcome::LastAdmin) =>
|
|
869
|
+
Ok(auth::RoleOutcome::LastAdmin) => {
|
|
870
|
+
json_error(409, "there must always be at least one admin")
|
|
871
|
+
}
|
|
576
872
|
Err(e) => json_error(500, &format!("members update failed: {e}")),
|
|
577
873
|
}
|
|
578
874
|
}
|
|
@@ -587,9 +883,10 @@ async fn registrations_list_route(
|
|
|
587
883
|
wasm_path: &Path,
|
|
588
884
|
config: &LineageConfig,
|
|
589
885
|
) -> Value {
|
|
590
|
-
let
|
|
591
|
-
|
|
592
|
-
|
|
886
|
+
let Some(caller) = cookies
|
|
887
|
+
.get(&auth::account_cookie_name())
|
|
888
|
+
.and_then(|token| auth::verify_account_token(secret, token))
|
|
889
|
+
else {
|
|
593
890
|
return json_error(401, "not logged in");
|
|
594
891
|
};
|
|
595
892
|
match auth::caller_is_admin(client, domain_ir, &caller).await {
|
|
@@ -602,7 +899,11 @@ async fn registrations_list_route(
|
|
|
602
899
|
Ok(read) => read,
|
|
603
900
|
Err(e) => return json_error(500, &format!("registrations lookup failed: {e:#}")),
|
|
604
901
|
};
|
|
605
|
-
respond(
|
|
902
|
+
respond(
|
|
903
|
+
200,
|
|
904
|
+
"application/json",
|
|
905
|
+
&Value::Array(registration_list_rows(&read, &config.domain)).to_string(),
|
|
906
|
+
)
|
|
606
907
|
}
|
|
607
908
|
|
|
608
909
|
// Built field-by-field from an allowlist so intake answers (health,
|
|
@@ -613,7 +914,8 @@ fn registration_list_rows(read: &Value, domain: &str) -> Vec<Value> {
|
|
|
613
914
|
let value = value?;
|
|
614
915
|
Some(value.get("value").cloned().unwrap_or_else(|| value.clone()))
|
|
615
916
|
};
|
|
616
|
-
let text =
|
|
917
|
+
let text =
|
|
918
|
+
|value: Option<&Value>| plain(value).and_then(|v| v.as_str().map(|s| s.trim().to_string()));
|
|
617
919
|
|
|
618
920
|
let mut rows: Vec<(Option<String>, Value)> = instances_for(read, &binding.registration_prefix())
|
|
619
921
|
.into_iter()
|
|
@@ -642,24 +944,36 @@ fn registration_list_rows(read: &Value, domain: &str) -> Vec<Value> {
|
|
|
642
944
|
}
|
|
643
945
|
|
|
644
946
|
fn sorted_by_name(mut people: Vec<Value>) -> Vec<Value> {
|
|
645
|
-
let name_of = |p: &Value|
|
|
947
|
+
let name_of = |p: &Value| {
|
|
948
|
+
p.get("name")
|
|
949
|
+
.and_then(|v| v.as_str())
|
|
950
|
+
.unwrap_or("")
|
|
951
|
+
.to_lowercase()
|
|
952
|
+
};
|
|
646
953
|
people.sort_by_key(name_of);
|
|
647
954
|
people
|
|
648
955
|
}
|
|
649
956
|
|
|
650
957
|
// Mints a 60s handoff token cms/src/endpoints/sso.ts exchanges for a
|
|
651
958
|
// real Payload session — one-shot, so it's returned as JSON, not a cookie.
|
|
652
|
-
async fn accounts_sso_token_route(
|
|
959
|
+
async fn accounts_sso_token_route(
|
|
960
|
+
domain_ir: &Value,
|
|
961
|
+
cookies: &HashMap<String, String>,
|
|
962
|
+
secret: &str,
|
|
963
|
+
client: &Mutex<Client>,
|
|
964
|
+
) -> Value {
|
|
653
965
|
const SSO_TOKEN_TTL_SECS: u64 = 60;
|
|
654
966
|
let email = match active_session_email(domain_ir, cookies, secret, client).await {
|
|
655
967
|
Ok(email) => email,
|
|
656
968
|
Err(response) => return response,
|
|
657
969
|
};
|
|
658
970
|
let sso_token = auth::account_token(secret, &email, SSO_TOKEN_TTL_SECS);
|
|
659
|
-
respond(
|
|
971
|
+
respond(
|
|
972
|
+
200,
|
|
973
|
+
"application/json",
|
|
974
|
+
&json!({"token": sso_token}).to_string(),
|
|
975
|
+
)
|
|
660
976
|
}
|
|
661
|
-
|
|
662
|
-
#[allow(clippy::too_many_arguments)]
|
|
663
977
|
async fn google_callback(
|
|
664
978
|
domain_ir: &Value,
|
|
665
979
|
query: &HashMap<String, String>,
|
|
@@ -701,30 +1015,47 @@ async fn google_callback(
|
|
|
701
1015
|
|
|
702
1016
|
// PostgresEra head first — Identity is not in the WASM journal.
|
|
703
1017
|
// Journal `instances` is only a fallback for Lambda-era rows.
|
|
704
|
-
let identity_id =
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
710
|
-
};
|
|
711
|
-
let identity_id = identity_id.or_else(|| auth::resolve_identity(&instances, &claims.issuer, &claims.subject));
|
|
712
|
-
|
|
713
|
-
let session = match identity_id {
|
|
714
|
-
Some(ref identity_id) => match auth::session_for_member_by_identity(client, domain_ir, identity_id).await {
|
|
715
|
-
Ok(s) => s,
|
|
1018
|
+
let identity_id =
|
|
1019
|
+
match auth::resolve_identity_from_head(client, domain_ir, &claims.issuer, &claims.subject)
|
|
1020
|
+
.await
|
|
1021
|
+
{
|
|
1022
|
+
Ok(found) => found,
|
|
716
1023
|
Err(e) => {
|
|
717
|
-
eprintln!("google_callback:
|
|
1024
|
+
eprintln!("google_callback: identity head: {e:#}");
|
|
718
1025
|
None
|
|
719
1026
|
}
|
|
720
|
-
}
|
|
1027
|
+
};
|
|
1028
|
+
let identity_id =
|
|
1029
|
+
identity_id.or_else(|| auth::resolve_identity(&instances, &claims.issuer, &claims.subject));
|
|
1030
|
+
|
|
1031
|
+
let session = match identity_id {
|
|
1032
|
+
Some(ref identity_id) => {
|
|
1033
|
+
match auth::session_for_member_by_identity(client, domain_ir, identity_id).await {
|
|
1034
|
+
Ok(s) => s,
|
|
1035
|
+
Err(e) => {
|
|
1036
|
+
eprintln!("google_callback: member by identity: {e:#}");
|
|
1037
|
+
None
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
721
1041
|
None => None,
|
|
722
1042
|
};
|
|
723
1043
|
let session = match session {
|
|
724
1044
|
Some(s) => Some(s),
|
|
725
1045
|
None if claims.email_verified => {
|
|
726
1046
|
let email = claims.email.clone().unwrap_or_default();
|
|
727
|
-
match auth::provision(
|
|
1047
|
+
match auth::provision(
|
|
1048
|
+
client,
|
|
1049
|
+
wasm_path,
|
|
1050
|
+
config,
|
|
1051
|
+
domain_ir,
|
|
1052
|
+
&email,
|
|
1053
|
+
&claims.issuer,
|
|
1054
|
+
&claims.subject,
|
|
1055
|
+
invoker,
|
|
1056
|
+
)
|
|
1057
|
+
.await
|
|
1058
|
+
{
|
|
728
1059
|
Ok(s) => s,
|
|
729
1060
|
Err(e) => {
|
|
730
1061
|
eprintln!("google_callback: provision: {e:#}");
|
|
@@ -735,20 +1066,31 @@ async fn google_callback(
|
|
|
735
1066
|
None => None,
|
|
736
1067
|
};
|
|
737
1068
|
|
|
738
|
-
let Some(session) = session else {
|
|
1069
|
+
let Some(session) = session else {
|
|
1070
|
+
return redirect("/login?error=google_unlinked");
|
|
1071
|
+
};
|
|
739
1072
|
|
|
740
1073
|
// The site's admin authenticates on the account cookie, not this
|
|
741
1074
|
// host's own session cookie. Cross-origin, a cookie here would never
|
|
742
1075
|
// reach the site, so the token is handed across via /api/google-handoff.
|
|
743
|
-
let site =
|
|
1076
|
+
let site = site_url();
|
|
744
1077
|
let site = site.trim_end_matches('/');
|
|
745
1078
|
let token = auth::account_token(secret, &session.email, auth::SESSION_TTL_SECS);
|
|
746
1079
|
if same_origin(site, &redirect_uri()) {
|
|
747
|
-
let cookie = format!(
|
|
1080
|
+
let cookie = format!(
|
|
1081
|
+
"{}={token}{}; Max-Age={}",
|
|
1082
|
+
auth::account_cookie_name(),
|
|
1083
|
+
cookie_flags(),
|
|
1084
|
+
auth::SESSION_TTL_SECS
|
|
1085
|
+
);
|
|
748
1086
|
redirect_with_cookie("/admin.html", &cookie)
|
|
749
1087
|
} else {
|
|
750
1088
|
// One-redirect URL onto the Astro origin (local rust/host :4567 vs site :4321).
|
|
751
|
-
redirect(&format!(
|
|
1089
|
+
redirect(&format!(
|
|
1090
|
+
"{}/api/google-handoff?token={}",
|
|
1091
|
+
site,
|
|
1092
|
+
auth::urlencode(&token)
|
|
1093
|
+
))
|
|
752
1094
|
}
|
|
753
1095
|
}
|
|
754
1096
|
|
|
@@ -757,18 +1099,28 @@ fn same_origin(a: &str, b: &str) -> bool {
|
|
|
757
1099
|
}
|
|
758
1100
|
|
|
759
1101
|
fn origin_of(url: &str) -> String {
|
|
760
|
-
let rest = url.split_once("://").
|
|
1102
|
+
let rest = url.split_once("://").map_or(url, |(_, r)| r);
|
|
761
1103
|
rest.split('/').next().unwrap_or(rest).to_string()
|
|
762
1104
|
}
|
|
763
1105
|
|
|
764
1106
|
const ERROR_MESSAGES: &[(&str, &str)] = &[
|
|
765
|
-
(
|
|
766
|
-
|
|
1107
|
+
(
|
|
1108
|
+
"google_unlinked",
|
|
1109
|
+
"That Google account isn't recognized here.",
|
|
1110
|
+
),
|
|
1111
|
+
(
|
|
1112
|
+
"google_failed",
|
|
1113
|
+
"Google sign-in didn't go through. Try again.",
|
|
1114
|
+
),
|
|
767
1115
|
];
|
|
768
1116
|
|
|
769
1117
|
fn login_page(error: Option<&str>) -> String {
|
|
770
|
-
let message = error
|
|
771
|
-
|
|
1118
|
+
let message = error
|
|
1119
|
+
.and_then(|e| ERROR_MESSAGES.iter().find(|(k, _)| *k == e))
|
|
1120
|
+
.map(|(_, m)| *m);
|
|
1121
|
+
let error_html = message
|
|
1122
|
+
.map(|m| format!(r#"<p class="text-red-600 text-sm mb-4">{}</p>"#, esc(m)))
|
|
1123
|
+
.unwrap_or_default();
|
|
772
1124
|
format!(
|
|
773
1125
|
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sign in</title><script src="https://cdn.tailwindcss.com"></script></head>
|
|
774
1126
|
<body class="bg-slate-50 text-slate-900 min-h-screen flex items-center justify-center">
|
|
@@ -782,16 +1134,18 @@ fn login_page(error: Option<&str>) -> String {
|
|
|
782
1134
|
}
|
|
783
1135
|
|
|
784
1136
|
async fn admin_members_page(client: &Mutex<Client>, domain_ir: &Value) -> String {
|
|
785
|
-
let people = auth::all_people(client, domain_ir)
|
|
1137
|
+
let people = auth::all_people(client, domain_ir)
|
|
1138
|
+
.await
|
|
1139
|
+
.unwrap_or_default();
|
|
786
1140
|
let rows: String = people
|
|
787
1141
|
.iter()
|
|
788
1142
|
.map(|p| {
|
|
789
1143
|
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
790
1144
|
let email = p.get("email").and_then(|v| v.as_str()).unwrap_or("");
|
|
791
1145
|
let role = p.get("role").and_then(|v| v.as_str()).unwrap_or("—");
|
|
792
|
-
let access = if p.get("linked").and_then(
|
|
1146
|
+
let access = if p.get("linked").and_then(serde_json::Value::as_bool) == Some(true) {
|
|
793
1147
|
"linked"
|
|
794
|
-
} else if p.get("granted").and_then(
|
|
1148
|
+
} else if p.get("granted").and_then(serde_json::Value::as_bool) == Some(true) {
|
|
795
1149
|
"granted, not yet signed in"
|
|
796
1150
|
} else {
|
|
797
1151
|
"no access"
|
|
@@ -801,7 +1155,8 @@ async fn admin_members_page(client: &Mutex<Client>, domain_ir: &Value) -> String
|
|
|
801
1155
|
esc(name), esc(email), esc(role), esc(access)
|
|
802
1156
|
)
|
|
803
1157
|
})
|
|
804
|
-
.collect()
|
|
1158
|
+
.collect::<Vec<_>>()
|
|
1159
|
+
.concat();
|
|
805
1160
|
|
|
806
1161
|
format!(
|
|
807
1162
|
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Members</title><script src="https://cdn.tailwindcss.com"></script></head>
|
|
@@ -810,7 +1165,8 @@ async fn admin_members_page(client: &Mutex<Client>, domain_ir: &Value) -> String
|
|
|
810
1165
|
<h1 class="text-xl font-semibold mb-4">Members</h1>
|
|
811
1166
|
<table class="w-full text-sm mb-6"><thead><tr class="text-left text-slate-500 text-xs uppercase"><th class="py-2 pr-4">Name</th><th class="py-2 pr-4">Email</th><th class="py-2 pr-4">Role</th><th class="py-2">Access</th></tr></thead><tbody>{rows}</tbody></table>
|
|
812
1167
|
<form method="post" action="/admin/members" class="flex gap-2 items-center">
|
|
813
|
-
<input type="
|
|
1168
|
+
<input type="text" name="name" placeholder="Name" class="w-40 border border-slate-300 rounded-md px-3 py-2 text-sm" />
|
|
1169
|
+
<input type="email" name="email" placeholder="Email (their Google account)" required class="flex-1 border border-slate-300 rounded-md px-3 py-2 text-sm" />
|
|
814
1170
|
<select name="role" class="border border-slate-300 rounded-md px-3 py-2 text-sm"><option value="Admin">Admin</option><option value="Owner">Owner</option><option value="Member">Member</option></select>
|
|
815
1171
|
<button type="submit" class="px-4 py-2 bg-slate-900 text-white rounded-md text-sm">Grant access</button>
|
|
816
1172
|
</form></main></body></html>"#
|
|
@@ -818,15 +1174,27 @@ async fn admin_members_page(client: &Mutex<Client>, domain_ir: &Value) -> String
|
|
|
818
1174
|
}
|
|
819
1175
|
|
|
820
1176
|
fn find_aggregate<'a>(domain_ir: &'a Value, name: &str) -> Option<&'a Value> {
|
|
821
|
-
domain_ir
|
|
1177
|
+
domain_ir
|
|
1178
|
+
.get("aggregates")?
|
|
1179
|
+
.as_array()?
|
|
1180
|
+
.iter()
|
|
1181
|
+
.find(|a| a.get("name").and_then(|v| v.as_str()) == Some(name))
|
|
822
1182
|
}
|
|
823
1183
|
|
|
824
1184
|
fn find_command<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
|
|
825
|
-
aggregate
|
|
1185
|
+
aggregate
|
|
1186
|
+
.get("commands")?
|
|
1187
|
+
.as_array()?
|
|
1188
|
+
.iter()
|
|
1189
|
+
.find(|c| c.get("name").and_then(|v| v.as_str()) == Some(name))
|
|
826
1190
|
}
|
|
827
1191
|
|
|
828
1192
|
fn find_value_object<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
|
|
829
|
-
aggregate
|
|
1193
|
+
aggregate
|
|
1194
|
+
.get("value_objects")?
|
|
1195
|
+
.as_array()?
|
|
1196
|
+
.iter()
|
|
1197
|
+
.find(|v| v.get("name").and_then(|v| v.as_str()) == Some(name))
|
|
830
1198
|
}
|
|
831
1199
|
|
|
832
1200
|
fn agg_name(aggregate: &Value) -> &str {
|
|
@@ -837,7 +1205,11 @@ fn identity_paths(aggregate: &Value) -> Vec<String> {
|
|
|
837
1205
|
aggregate
|
|
838
1206
|
.get("identified_by")
|
|
839
1207
|
.and_then(|v| v.as_array())
|
|
840
|
-
.map(|a|
|
|
1208
|
+
.map(|a| {
|
|
1209
|
+
a.iter()
|
|
1210
|
+
.filter_map(|p| p.as_str().map(String::from))
|
|
1211
|
+
.collect()
|
|
1212
|
+
})
|
|
841
1213
|
.unwrap_or_default()
|
|
842
1214
|
}
|
|
843
1215
|
|
|
@@ -853,9 +1225,13 @@ struct Field {
|
|
|
853
1225
|
|
|
854
1226
|
#[derive(Clone, Debug)]
|
|
855
1227
|
enum FieldKind {
|
|
856
|
-
Text {
|
|
1228
|
+
Text {
|
|
1229
|
+
html_type: &'static str,
|
|
1230
|
+
},
|
|
857
1231
|
Textarea,
|
|
858
|
-
Number {
|
|
1232
|
+
Number {
|
|
1233
|
+
step: &'static str,
|
|
1234
|
+
},
|
|
859
1235
|
Boolean,
|
|
860
1236
|
Radio(Vec<String>),
|
|
861
1237
|
Select(Vec<String>),
|
|
@@ -902,9 +1278,18 @@ const PRIMITIVES: &[&str] = &["String", "Integer", "Float", "TrueClass", "FalseC
|
|
|
902
1278
|
// Dispatch order: list? -> reference? -> admits -> value object ->
|
|
903
1279
|
// primitive. `domain_ir` is the whole chapter, needed for cross-aggregate lookups.
|
|
904
1280
|
fn resolve_field(domain_ir: &Value, attribute: &Value, aggregate: &Value, path: &str) -> Field {
|
|
905
|
-
let is_list = attribute
|
|
906
|
-
|
|
907
|
-
|
|
1281
|
+
let is_list = attribute
|
|
1282
|
+
.get("list")
|
|
1283
|
+
.and_then(serde_json::Value::as_bool)
|
|
1284
|
+
.unwrap_or(false);
|
|
1285
|
+
let optional = attribute
|
|
1286
|
+
.get("optional")
|
|
1287
|
+
.and_then(serde_json::Value::as_bool)
|
|
1288
|
+
.unwrap_or(false);
|
|
1289
|
+
let ty = attribute
|
|
1290
|
+
.get("type")
|
|
1291
|
+
.and_then(|v| v.as_str())
|
|
1292
|
+
.unwrap_or("String");
|
|
908
1293
|
|
|
909
1294
|
if is_list {
|
|
910
1295
|
let scalar = json!({
|
|
@@ -912,11 +1297,23 @@ fn resolve_field(domain_ir: &Value, attribute: &Value, aggregate: &Value, path:
|
|
|
912
1297
|
"pattern": attribute.get("pattern"), "admits": attribute.get("admits"),
|
|
913
1298
|
});
|
|
914
1299
|
let item = resolve_field(domain_ir, &scalar, aggregate, path);
|
|
915
|
-
return Field {
|
|
1300
|
+
return Field {
|
|
1301
|
+
path: path.to_string(),
|
|
1302
|
+
label: humanize(path),
|
|
1303
|
+
kind: FieldKind::List(Box::new(item)),
|
|
1304
|
+
optional,
|
|
1305
|
+
};
|
|
916
1306
|
}
|
|
917
1307
|
|
|
918
1308
|
if let Some(target) = reference_target(ty) {
|
|
919
|
-
return Field {
|
|
1309
|
+
return Field {
|
|
1310
|
+
path: path.to_string(),
|
|
1311
|
+
label: humanize(path),
|
|
1312
|
+
kind: FieldKind::Reference {
|
|
1313
|
+
target: Some(target),
|
|
1314
|
+
},
|
|
1315
|
+
optional,
|
|
1316
|
+
};
|
|
920
1317
|
}
|
|
921
1318
|
|
|
922
1319
|
if let Some(admits) = attribute.get("admits").and_then(|v| v.as_str()) {
|
|
@@ -936,25 +1333,40 @@ fn resolve_field(domain_ir: &Value, attribute: &Value, aggregate: &Value, path:
|
|
|
936
1333
|
// `Reference<X>` -> `Some("X")`. `Reference<...>` is the IR's pinned
|
|
937
1334
|
// export spelling (never a nested object) — mirrors naming.rb.
|
|
938
1335
|
fn reference_target(ty: &str) -> Option<String> {
|
|
939
|
-
ty.strip_prefix("Reference<")
|
|
1336
|
+
ty.strip_prefix("Reference<")?
|
|
1337
|
+
.strip_suffix('>')
|
|
1338
|
+
.map(String::from)
|
|
940
1339
|
}
|
|
941
1340
|
|
|
942
1341
|
// Checked on the owning aggregate first, then across the whole domain.
|
|
943
|
-
fn value_object_shape<'a>(
|
|
1342
|
+
fn value_object_shape<'a>(
|
|
1343
|
+
domain_ir: &'a Value,
|
|
1344
|
+
aggregate: &'a Value,
|
|
1345
|
+
ty: &str,
|
|
1346
|
+
) -> Option<&'a Value> {
|
|
944
1347
|
find_value_object(aggregate, ty).or_else(|| cross_aggregate_value_object(domain_ir, ty))
|
|
945
1348
|
}
|
|
946
1349
|
|
|
947
1350
|
// Walks the whole domain, not just siblings — a value object's shape
|
|
948
1351
|
// may be declared on a different aggregate than the one referencing it.
|
|
949
1352
|
fn cross_aggregate_value_object<'a>(domain_ir: &'a Value, type_name: &str) -> Option<&'a Value> {
|
|
950
|
-
domain_ir
|
|
1353
|
+
domain_ir
|
|
1354
|
+
.get("aggregates")?
|
|
1355
|
+
.as_array()?
|
|
1356
|
+
.iter()
|
|
1357
|
+
.find_map(|sibling| find_value_object(sibling, type_name))
|
|
951
1358
|
}
|
|
952
1359
|
|
|
953
1360
|
// Shared by the native one_of branch and the admits: branch below, so
|
|
954
1361
|
// neither carries its own drifting copy of this reading.
|
|
955
1362
|
fn closed_set_members(vo: &Value) -> (String, Vec<String>) {
|
|
956
1363
|
let attrs = vo.get("attributes").and_then(|v| v.as_array());
|
|
957
|
-
let discriminant = attrs
|
|
1364
|
+
let discriminant = attrs
|
|
1365
|
+
.and_then(|a| a.first())
|
|
1366
|
+
.and_then(|a| a.get("name"))
|
|
1367
|
+
.and_then(|v| v.as_str())
|
|
1368
|
+
.unwrap_or("value")
|
|
1369
|
+
.to_string();
|
|
958
1370
|
let members = vo
|
|
959
1371
|
.get("members")
|
|
960
1372
|
.and_then(|v| v.as_array())
|
|
@@ -981,21 +1393,42 @@ fn closed_set_members(vo: &Value) -> (String, Vec<String>) {
|
|
|
981
1393
|
// to `.path` afterward without touching `label`, which is set here.
|
|
982
1394
|
fn select_or_radio(path: &str, optional: bool, members: Vec<String>) -> Field {
|
|
983
1395
|
let label = humanize(path);
|
|
984
|
-
let kind = if members.len() <= 4 {
|
|
985
|
-
|
|
1396
|
+
let kind = if members.len() <= 4 {
|
|
1397
|
+
FieldKind::Radio(members)
|
|
1398
|
+
} else {
|
|
1399
|
+
FieldKind::Select(members)
|
|
1400
|
+
};
|
|
1401
|
+
Field {
|
|
1402
|
+
path: path.to_string(),
|
|
1403
|
+
label,
|
|
1404
|
+
kind,
|
|
1405
|
+
optional,
|
|
1406
|
+
}
|
|
986
1407
|
}
|
|
987
1408
|
|
|
988
1409
|
// A closed set declared elsewhere (e.g. "Account::LedgerDirection"),
|
|
989
1410
|
// rendered the same way the native one_of branch renders its own.
|
|
990
|
-
fn admitted_field(
|
|
1411
|
+
fn admitted_field(
|
|
1412
|
+
domain_ir: &Value,
|
|
1413
|
+
aggregate: &Value,
|
|
1414
|
+
attribute: &Value,
|
|
1415
|
+
admits: &str,
|
|
1416
|
+
ty: &str,
|
|
1417
|
+
path: &str,
|
|
1418
|
+
optional: bool,
|
|
1419
|
+
) -> Field {
|
|
991
1420
|
let mut parts = admits.splitn(2, "::");
|
|
992
1421
|
let set_aggregate_name = parts.next().unwrap_or("");
|
|
993
1422
|
let set_name = parts.next();
|
|
994
1423
|
|
|
995
|
-
let set = set_name.and_then(|name|
|
|
1424
|
+
let set = set_name.and_then(|name| {
|
|
1425
|
+
find_aggregate(domain_ir, set_aggregate_name).and_then(|agg| find_value_object(agg, name))
|
|
1426
|
+
});
|
|
996
1427
|
// Undeclared set: render as a plain scalar (using the real attribute
|
|
997
1428
|
// so its name/pattern still drive Tier B's hints) — dispatch refuses.
|
|
998
|
-
let Some(set) = set else {
|
|
1429
|
+
let Some(set) = set else {
|
|
1430
|
+
return primitive_field(attribute, ty, path, optional);
|
|
1431
|
+
};
|
|
999
1432
|
|
|
1000
1433
|
let (_, members) = closed_set_members(set);
|
|
1001
1434
|
let mut field = select_or_radio(path, optional, members);
|
|
@@ -1003,19 +1436,39 @@ fn admitted_field(domain_ir: &Value, aggregate: &Value, attribute: &Value, admit
|
|
|
1003
1436
|
// The attribute's own value object still needs its ".value" hop
|
|
1004
1437
|
// (same unwrap `value_object_field` does) even though the admitted
|
|
1005
1438
|
// set lives on a different aggregate — it only changes the options.
|
|
1006
|
-
let Some(own_shape) = value_object_shape(domain_ir, aggregate, ty) else {
|
|
1439
|
+
let Some(own_shape) = value_object_shape(domain_ir, aggregate, ty) else {
|
|
1440
|
+
return field;
|
|
1441
|
+
};
|
|
1007
1442
|
let attrs = own_shape.get("attributes").and_then(|v| v.as_array());
|
|
1008
|
-
let Some(attrs) = attrs.filter(|a| a.len() == 1) else {
|
|
1443
|
+
let Some(attrs) = attrs.filter(|a| a.len() == 1) else {
|
|
1444
|
+
return field;
|
|
1445
|
+
};
|
|
1009
1446
|
|
|
1010
|
-
let inner_name = attrs[0]
|
|
1447
|
+
let inner_name = attrs[0]
|
|
1448
|
+
.get("name")
|
|
1449
|
+
.and_then(|v| v.as_str())
|
|
1450
|
+
.unwrap_or("value");
|
|
1011
1451
|
field.path = format!("{path}.{inner_name}");
|
|
1012
1452
|
field
|
|
1013
1453
|
}
|
|
1014
1454
|
|
|
1015
1455
|
// Order: closed_set? -> money_shaped? -> single-attribute-unwrap -> group.
|
|
1016
|
-
fn value_object_field(
|
|
1017
|
-
|
|
1018
|
-
|
|
1456
|
+
fn value_object_field(
|
|
1457
|
+
domain_ir: &Value,
|
|
1458
|
+
aggregate: &Value,
|
|
1459
|
+
shape: &Value,
|
|
1460
|
+
path: &str,
|
|
1461
|
+
optional: bool,
|
|
1462
|
+
) -> Field {
|
|
1463
|
+
let closed_set = shape
|
|
1464
|
+
.get("closed_set")
|
|
1465
|
+
.and_then(serde_json::Value::as_bool)
|
|
1466
|
+
.unwrap_or(false);
|
|
1467
|
+
let attrs = shape
|
|
1468
|
+
.get("attributes")
|
|
1469
|
+
.and_then(|v| v.as_array())
|
|
1470
|
+
.cloned()
|
|
1471
|
+
.unwrap_or_default();
|
|
1019
1472
|
|
|
1020
1473
|
if closed_set {
|
|
1021
1474
|
let (discriminant, members) = closed_set_members(shape);
|
|
@@ -1033,7 +1486,10 @@ fn value_object_field(domain_ir: &Value, aggregate: &Value, shape: &Value, path:
|
|
|
1033
1486
|
// `primitive_field`) keeps the inner name/pattern driving Tier B.
|
|
1034
1487
|
if attrs.len() == 1 {
|
|
1035
1488
|
let inner = &attrs[0];
|
|
1036
|
-
let inner_name = inner
|
|
1489
|
+
let inner_name = inner
|
|
1490
|
+
.get("name")
|
|
1491
|
+
.and_then(|v| v.as_str())
|
|
1492
|
+
.unwrap_or("value");
|
|
1037
1493
|
let inner_path = format!("{path}.{inner_name}");
|
|
1038
1494
|
let mut field = resolve_field(domain_ir, inner, aggregate, &inner_path);
|
|
1039
1495
|
field.label = humanize(path);
|
|
@@ -1048,12 +1504,20 @@ fn value_object_field(domain_ir: &Value, aggregate: &Value, shape: &Value, path:
|
|
|
1048
1504
|
resolve_field(domain_ir, inner, aggregate, &format!("{path}.{inner_name}"))
|
|
1049
1505
|
})
|
|
1050
1506
|
.collect();
|
|
1051
|
-
Field {
|
|
1507
|
+
Field {
|
|
1508
|
+
path: path.to_string(),
|
|
1509
|
+
label: humanize(path),
|
|
1510
|
+
kind: FieldKind::Group(children),
|
|
1511
|
+
optional,
|
|
1512
|
+
}
|
|
1052
1513
|
}
|
|
1053
1514
|
|
|
1054
1515
|
// Exactly `{cents, currency}`, sorted, and nothing else.
|
|
1055
1516
|
fn money_shaped(attrs: &[Value]) -> bool {
|
|
1056
|
-
let mut names: Vec<&str> = attrs
|
|
1517
|
+
let mut names: Vec<&str> = attrs
|
|
1518
|
+
.iter()
|
|
1519
|
+
.filter_map(|a| a.get("name").and_then(|v| v.as_str()))
|
|
1520
|
+
.collect();
|
|
1057
1521
|
names.sort_unstable();
|
|
1058
1522
|
names == ["cents", "currency"]
|
|
1059
1523
|
}
|
|
@@ -1061,9 +1525,24 @@ fn money_shaped(attrs: &[Value]) -> bool {
|
|
|
1061
1525
|
// Cents as a whole-integer Number; currency as free Text, always
|
|
1062
1526
|
// optional (it defaults to "USD" regardless of the outer field).
|
|
1063
1527
|
fn money_field(path: &str, optional: bool) -> Field {
|
|
1064
|
-
let cents = Field {
|
|
1065
|
-
|
|
1066
|
-
|
|
1528
|
+
let cents = Field {
|
|
1529
|
+
path: format!("{path}.cents"),
|
|
1530
|
+
label: "Amount (cents)".to_string(),
|
|
1531
|
+
kind: FieldKind::Number { step: "1" },
|
|
1532
|
+
optional,
|
|
1533
|
+
};
|
|
1534
|
+
let currency = Field {
|
|
1535
|
+
path: format!("{path}.currency"),
|
|
1536
|
+
label: "Currency".to_string(),
|
|
1537
|
+
kind: FieldKind::Text { html_type: "text" },
|
|
1538
|
+
optional: true,
|
|
1539
|
+
};
|
|
1540
|
+
Field {
|
|
1541
|
+
path: path.to_string(),
|
|
1542
|
+
label: humanize(path),
|
|
1543
|
+
kind: FieldKind::Money(vec![cents, currency]),
|
|
1544
|
+
optional,
|
|
1545
|
+
}
|
|
1067
1546
|
}
|
|
1068
1547
|
|
|
1069
1548
|
fn primitive_field(attribute: &Value, ty: &str, path: &str, optional: bool) -> Field {
|
|
@@ -1074,17 +1553,30 @@ fn primitive_field(attribute: &Value, ty: &str, path: &str, optional: bool) -> F
|
|
|
1074
1553
|
"TrueClass" | "FalseClass" => FieldKind::Boolean,
|
|
1075
1554
|
_ => return text_field(attribute, path, optional),
|
|
1076
1555
|
};
|
|
1077
|
-
Field {
|
|
1556
|
+
Field {
|
|
1557
|
+
path: path.to_string(),
|
|
1558
|
+
label,
|
|
1559
|
+
kind,
|
|
1560
|
+
optional,
|
|
1561
|
+
}
|
|
1078
1562
|
}
|
|
1079
1563
|
|
|
1080
1564
|
// Tier B's hints (field_hints.rs) match on `attribute`'s own name/
|
|
1081
1565
|
// pattern, never derived from `path` — matters after the unwrap above.
|
|
1082
1566
|
fn text_field(attribute: &Value, path: &str, optional: bool) -> Field {
|
|
1083
1567
|
let name = attribute.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
1084
|
-
let pattern = attribute
|
|
1568
|
+
let pattern = attribute
|
|
1569
|
+
.get("pattern")
|
|
1570
|
+
.and_then(|v| v.as_str())
|
|
1571
|
+
.unwrap_or("");
|
|
1085
1572
|
let html_type = text_html_type(name, pattern);
|
|
1086
1573
|
let kind = text_kind(html_type, name);
|
|
1087
|
-
Field {
|
|
1574
|
+
Field {
|
|
1575
|
+
path: path.to_string(),
|
|
1576
|
+
label: humanize(path),
|
|
1577
|
+
kind,
|
|
1578
|
+
optional,
|
|
1579
|
+
}
|
|
1088
1580
|
}
|
|
1089
1581
|
|
|
1090
1582
|
// email, then url, then tel — first match wins. Case-insensitive
|
|
@@ -1111,7 +1603,9 @@ fn text_kind(html_type: &'static str, name: &str) -> FieldKind {
|
|
|
1111
1603
|
}
|
|
1112
1604
|
|
|
1113
1605
|
fn command_fields(domain_ir: &Value, aggregate: &Value, command: &Value) -> Vec<Field> {
|
|
1114
|
-
let creates = command
|
|
1606
|
+
let creates = command
|
|
1607
|
+
.get("references")
|
|
1608
|
+
.is_none_or(serde_json::Value::is_null);
|
|
1115
1609
|
let mut fields = Vec::new();
|
|
1116
1610
|
if !creates {
|
|
1117
1611
|
let paths = identity_paths(aggregate).join(", ");
|
|
@@ -1165,7 +1659,9 @@ fn collect_field(field: &Field, raw: &HashMap<String, String>, pairs: &mut Vec<(
|
|
|
1165
1659
|
}
|
|
1166
1660
|
}
|
|
1167
1661
|
FieldKind::Boolean => {
|
|
1168
|
-
let checked = raw
|
|
1662
|
+
let checked = raw
|
|
1663
|
+
.get(&field.path)
|
|
1664
|
+
.is_some_and(|v| matches!(v.as_str(), "on" | "1" | "true"));
|
|
1169
1665
|
pairs.push((field.path.clone(), Value::Bool(checked)));
|
|
1170
1666
|
}
|
|
1171
1667
|
_ => {
|
|
@@ -1180,8 +1676,10 @@ fn collect_field(field: &Field, raw: &HashMap<String, String>, pairs: &mut Vec<(
|
|
|
1180
1676
|
|
|
1181
1677
|
fn cast_scalar(field: &Field, text: &str) -> Value {
|
|
1182
1678
|
match &field.kind {
|
|
1183
|
-
FieldKind::Number { step } if *step == "1" =>
|
|
1184
|
-
|
|
1679
|
+
FieldKind::Number { step } if *step == "1" => {
|
|
1680
|
+
text.parse::<i64>().map_or(Value::Null, Value::from)
|
|
1681
|
+
}
|
|
1682
|
+
FieldKind::Number { .. } => text.parse::<f64>().map_or(Value::Null, Value::from),
|
|
1185
1683
|
_ => Value::String(text.to_string()),
|
|
1186
1684
|
}
|
|
1187
1685
|
}
|
|
@@ -1222,25 +1720,41 @@ fn collision_error(path: &str, existing_prefix: &[&str]) -> String {
|
|
|
1222
1720
|
|
|
1223
1721
|
fn home_body(domain_ir: &Value) -> String {
|
|
1224
1722
|
let name = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
1225
|
-
let vision = domain_ir
|
|
1226
|
-
|
|
1723
|
+
let vision = domain_ir
|
|
1724
|
+
.get("vision")
|
|
1725
|
+
.and_then(|v| v.as_str())
|
|
1726
|
+
.unwrap_or("");
|
|
1727
|
+
let aggregates = domain_ir
|
|
1728
|
+
.get("aggregates")
|
|
1729
|
+
.and_then(|v| v.as_array())
|
|
1730
|
+
.cloned()
|
|
1731
|
+
.unwrap_or_default();
|
|
1227
1732
|
let items: String = aggregates
|
|
1228
1733
|
.iter()
|
|
1229
1734
|
.map(|a| {
|
|
1230
1735
|
let n = agg_name(a);
|
|
1231
1736
|
format!(r#"<li><a href="/{name}/{n}.html" class="text-indigo-600 hover:underline">{n}</a></li>"#)
|
|
1232
1737
|
})
|
|
1233
|
-
.collect()
|
|
1738
|
+
.collect::<Vec<_>>()
|
|
1739
|
+
.concat();
|
|
1234
1740
|
format!(
|
|
1235
1741
|
r#"<h1 class="text-2xl font-bold mb-4">Loaded domains</h1>
|
|
1236
1742
|
<ul class="space-y-4"><li><h2 class="font-semibold text-slate-800">{}</h2>
|
|
1237
1743
|
<p class="text-sm text-slate-500">{}</p>
|
|
1238
1744
|
<ul class="ml-4 mt-1 list-disc list-inside">{}</ul></li></ul>"#,
|
|
1239
|
-
esc(name),
|
|
1745
|
+
esc(name),
|
|
1746
|
+
esc(vision),
|
|
1747
|
+
items
|
|
1240
1748
|
)
|
|
1241
1749
|
}
|
|
1242
1750
|
|
|
1243
|
-
async fn aggregate_index(
|
|
1751
|
+
async fn aggregate_index(
|
|
1752
|
+
domain_name: &str,
|
|
1753
|
+
aggregate: &Value,
|
|
1754
|
+
format: &str,
|
|
1755
|
+
client: &Mutex<Client>,
|
|
1756
|
+
wasm_path: &Path,
|
|
1757
|
+
) -> Value {
|
|
1244
1758
|
let agg = agg_name(aggregate);
|
|
1245
1759
|
let prefix = format!("{domain_name}::{agg}#");
|
|
1246
1760
|
let records = match dispatch::read(client, wasm_path).await {
|
|
@@ -1249,19 +1763,34 @@ async fn aggregate_index(domain_name: &str, aggregate: &Value, format: &str, cli
|
|
|
1249
1763
|
};
|
|
1250
1764
|
|
|
1251
1765
|
if format != "html" {
|
|
1252
|
-
let list: Vec<Value> = records
|
|
1253
|
-
|
|
1766
|
+
let list: Vec<Value> = records
|
|
1767
|
+
.iter()
|
|
1768
|
+
.map(|(id, state)| with_id(id, state))
|
|
1769
|
+
.collect();
|
|
1770
|
+
return respond(
|
|
1771
|
+
200,
|
|
1772
|
+
"application/json",
|
|
1773
|
+
&serde_json::to_string_pretty(&list).unwrap_or_default(),
|
|
1774
|
+
);
|
|
1254
1775
|
}
|
|
1255
1776
|
|
|
1256
1777
|
let creating: Vec<&Value> = aggregate
|
|
1257
1778
|
.get("commands")
|
|
1258
1779
|
.and_then(|v| v.as_array())
|
|
1259
|
-
.map(|cs|
|
|
1780
|
+
.map(|cs| {
|
|
1781
|
+
cs.iter()
|
|
1782
|
+
.filter(|c| c.get("references").is_none_or(serde_json::Value::is_null))
|
|
1783
|
+
.collect()
|
|
1784
|
+
})
|
|
1260
1785
|
.unwrap_or_default();
|
|
1261
1786
|
let acting: Vec<&Value> = aggregate
|
|
1262
1787
|
.get("commands")
|
|
1263
1788
|
.and_then(|v| v.as_array())
|
|
1264
|
-
.map(|cs|
|
|
1789
|
+
.map(|cs| {
|
|
1790
|
+
cs.iter()
|
|
1791
|
+
.filter(|c| !c.get("references").is_none_or(serde_json::Value::is_null))
|
|
1792
|
+
.collect()
|
|
1793
|
+
})
|
|
1265
1794
|
.unwrap_or_default();
|
|
1266
1795
|
|
|
1267
1796
|
let new_links: String = creating
|
|
@@ -1270,7 +1799,8 @@ async fn aggregate_index(domain_name: &str, aggregate: &Value, format: &str, cli
|
|
|
1270
1799
|
let cn = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
1271
1800
|
format!(r#"<a href="/{domain_name}/{agg}/{cn}.html" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm text-white hover:bg-indigo-500">{cn}</a>"#)
|
|
1272
1801
|
})
|
|
1273
|
-
.collect()
|
|
1802
|
+
.collect::<Vec<_>>()
|
|
1803
|
+
.concat();
|
|
1274
1804
|
|
|
1275
1805
|
let rows: String = records
|
|
1276
1806
|
.iter()
|
|
@@ -1279,7 +1809,13 @@ async fn aggregate_index(domain_name: &str, aggregate: &Value, format: &str, cli
|
|
|
1279
1809
|
.iter()
|
|
1280
1810
|
.map(|c| {
|
|
1281
1811
|
let cn = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
1282
|
-
action_link(
|
|
1812
|
+
action_link(
|
|
1813
|
+
domain_name,
|
|
1814
|
+
agg,
|
|
1815
|
+
cn,
|
|
1816
|
+
id,
|
|
1817
|
+
"text-indigo-600 hover:underline mr-3",
|
|
1818
|
+
)
|
|
1283
1819
|
})
|
|
1284
1820
|
.collect();
|
|
1285
1821
|
index_row_html(domain_name, agg, id, &actions)
|
|
@@ -1300,7 +1836,14 @@ async fn aggregate_index(domain_name: &str, aggregate: &Value, format: &str, cli
|
|
|
1300
1836
|
html(200, &page(&format!("{domain_name}::{agg}"), &body))
|
|
1301
1837
|
}
|
|
1302
1838
|
|
|
1303
|
-
async fn record_show(
|
|
1839
|
+
async fn record_show(
|
|
1840
|
+
domain_name: &str,
|
|
1841
|
+
aggregate: &Value,
|
|
1842
|
+
id: &str,
|
|
1843
|
+
format: &str,
|
|
1844
|
+
client: &Mutex<Client>,
|
|
1845
|
+
wasm_path: &Path,
|
|
1846
|
+
) -> Value {
|
|
1304
1847
|
let agg = agg_name(aggregate);
|
|
1305
1848
|
let prefix = format!("{domain_name}::{agg}#");
|
|
1306
1849
|
let result = match dispatch::read(client, wasm_path).await {
|
|
@@ -1310,15 +1853,32 @@ async fn record_show(domain_name: &str, aggregate: &Value, id: &str, format: &st
|
|
|
1310
1853
|
let records = instances_for(&result, &prefix);
|
|
1311
1854
|
let Some((_, state)) = records.iter().find(|(rid, _)| rid == id) else {
|
|
1312
1855
|
let message = format!("No {agg} {id}.");
|
|
1313
|
-
return if format
|
|
1314
|
-
|
|
1856
|
+
return if format == "html" {
|
|
1857
|
+
html(
|
|
1858
|
+
404,
|
|
1859
|
+
&page(
|
|
1860
|
+
"Not found",
|
|
1861
|
+
&format!(
|
|
1862
|
+
r#"<h1 class="text-2xl font-bold mb-4">Not found</h1><p class="text-slate-600">{}</p>"#,
|
|
1863
|
+
esc(&message)
|
|
1864
|
+
),
|
|
1865
|
+
),
|
|
1866
|
+
)
|
|
1315
1867
|
} else {
|
|
1316
|
-
|
|
1868
|
+
respond(
|
|
1869
|
+
404,
|
|
1870
|
+
"application/json",
|
|
1871
|
+
&json!({"error":"NotFound","message":message}).to_string(),
|
|
1872
|
+
)
|
|
1317
1873
|
};
|
|
1318
1874
|
};
|
|
1319
1875
|
|
|
1320
1876
|
if format != "html" {
|
|
1321
|
-
return respond(
|
|
1877
|
+
return respond(
|
|
1878
|
+
200,
|
|
1879
|
+
"application/json",
|
|
1880
|
+
&serde_json::to_string_pretty(&with_id(id, state)).unwrap_or_default(),
|
|
1881
|
+
);
|
|
1322
1882
|
}
|
|
1323
1883
|
|
|
1324
1884
|
let rows: String = state
|
|
@@ -1326,7 +1886,8 @@ async fn record_show(domain_name: &str, aggregate: &Value, id: &str, format: &st
|
|
|
1326
1886
|
.map(|o| {
|
|
1327
1887
|
o.iter()
|
|
1328
1888
|
.map(|(k, v)| format!(r#"<div class="px-4 py-2 grid grid-cols-3 gap-2"><dt class="text-sm font-medium text-slate-500">{}</dt><dd class="col-span-2 text-sm text-slate-900 font-mono">{}</dd></div>"#, esc(k), esc(&v.to_string())))
|
|
1329
|
-
.collect()
|
|
1889
|
+
.collect::<Vec<_>>()
|
|
1890
|
+
.concat()
|
|
1330
1891
|
})
|
|
1331
1892
|
.unwrap_or_default();
|
|
1332
1893
|
let actions: String = aggregate
|
|
@@ -1334,7 +1895,7 @@ async fn record_show(domain_name: &str, aggregate: &Value, id: &str, format: &st
|
|
|
1334
1895
|
.and_then(|v| v.as_array())
|
|
1335
1896
|
.map(|cs| {
|
|
1336
1897
|
cs.iter()
|
|
1337
|
-
.filter(|c| !c.get("references").
|
|
1898
|
+
.filter(|c| !c.get("references").is_none_or(serde_json::Value::is_null))
|
|
1338
1899
|
.map(|c| {
|
|
1339
1900
|
let cn = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
1340
1901
|
action_link(domain_name, agg, cn, id, "rounded-md bg-indigo-600 px-3 py-1.5 text-sm text-white hover:bg-indigo-500 mr-3")
|
|
@@ -1346,17 +1907,31 @@ async fn record_show(domain_name: &str, aggregate: &Value, id: &str, format: &st
|
|
|
1346
1907
|
r#"<h1 class="text-2xl font-bold">{} <span class="font-mono text-lg text-slate-500">{}</span></h1>
|
|
1347
1908
|
<dl class="mt-4 divide-y divide-slate-200 border border-slate-200 rounded-md bg-white">{rows}</dl>
|
|
1348
1909
|
<div class="mt-6">{actions}<a href="/{domain_name}/{agg}.html" class="text-indigo-600 hover:underline">← {agg} list</a></div>"#,
|
|
1349
|
-
esc(agg),
|
|
1910
|
+
esc(agg),
|
|
1911
|
+
esc(id)
|
|
1350
1912
|
);
|
|
1351
1913
|
html(200, &page(&format!("{agg} {id}"), &body))
|
|
1352
1914
|
}
|
|
1353
|
-
|
|
1354
1915
|
#[allow(clippy::too_many_arguments)]
|
|
1355
1916
|
async fn command_route(
|
|
1356
|
-
domain_ir: &Value,
|
|
1357
|
-
|
|
1358
|
-
|
|
1917
|
+
domain_ir: &Value,
|
|
1918
|
+
domain_name: &str,
|
|
1919
|
+
aggregate: &Value,
|
|
1920
|
+
command: &Value,
|
|
1921
|
+
action: &str,
|
|
1922
|
+
format: &str,
|
|
1923
|
+
method: &str,
|
|
1924
|
+
query: &HashMap<String, String>,
|
|
1925
|
+
raw_body: &str,
|
|
1926
|
+
host: Host<'_>,
|
|
1359
1927
|
) -> Value {
|
|
1928
|
+
let Host {
|
|
1929
|
+
client,
|
|
1930
|
+
wasm_path,
|
|
1931
|
+
config,
|
|
1932
|
+
invoker,
|
|
1933
|
+
..
|
|
1934
|
+
} = host;
|
|
1360
1935
|
let fields = command_fields(domain_ir, aggregate, command);
|
|
1361
1936
|
let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
1362
1937
|
|
|
@@ -1365,21 +1940,66 @@ async fn command_route(
|
|
|
1365
1940
|
return respond(200, "application/json", &command.to_string());
|
|
1366
1941
|
}
|
|
1367
1942
|
let raw = parse_form(raw_body);
|
|
1368
|
-
return submit(
|
|
1943
|
+
return submit(
|
|
1944
|
+
domain_name,
|
|
1945
|
+
aggregate,
|
|
1946
|
+
command,
|
|
1947
|
+
&fields,
|
|
1948
|
+
&raw,
|
|
1949
|
+
client,
|
|
1950
|
+
wasm_path,
|
|
1951
|
+
config,
|
|
1952
|
+
true,
|
|
1953
|
+
invoker,
|
|
1954
|
+
)
|
|
1955
|
+
.await;
|
|
1369
1956
|
}
|
|
1370
1957
|
|
|
1371
1958
|
if method == "GET" {
|
|
1372
|
-
return html(
|
|
1959
|
+
return html(
|
|
1960
|
+
200,
|
|
1961
|
+
&page(
|
|
1962
|
+
&format!("{domain_name}::{}.{cname}", agg_name(aggregate)),
|
|
1963
|
+
&form_body(
|
|
1964
|
+
domain_name,
|
|
1965
|
+
aggregate,
|
|
1966
|
+
command,
|
|
1967
|
+
action,
|
|
1968
|
+
&fields,
|
|
1969
|
+
query,
|
|
1970
|
+
None,
|
|
1971
|
+
),
|
|
1972
|
+
),
|
|
1973
|
+
);
|
|
1373
1974
|
}
|
|
1374
1975
|
|
|
1375
1976
|
let raw = parse_form(raw_body);
|
|
1376
|
-
submit(
|
|
1977
|
+
submit(
|
|
1978
|
+
domain_name,
|
|
1979
|
+
aggregate,
|
|
1980
|
+
command,
|
|
1981
|
+
&fields,
|
|
1982
|
+
&raw,
|
|
1983
|
+
client,
|
|
1984
|
+
wasm_path,
|
|
1985
|
+
config,
|
|
1986
|
+
false,
|
|
1987
|
+
invoker,
|
|
1988
|
+
)
|
|
1989
|
+
.await
|
|
1377
1990
|
}
|
|
1378
|
-
|
|
1379
1991
|
#[allow(clippy::too_many_arguments)]
|
|
1380
1992
|
async fn submit(
|
|
1381
|
-
domain_name: &str,
|
|
1382
|
-
|
|
1993
|
+
domain_name: &str,
|
|
1994
|
+
aggregate: &Value,
|
|
1995
|
+
command: &Value,
|
|
1996
|
+
fields: &[Field],
|
|
1997
|
+
raw: &HashMap<String, String>,
|
|
1998
|
+
client: &Mutex<Client>,
|
|
1999
|
+
wasm_path: &Path,
|
|
2000
|
+
config: &LineageConfig,
|
|
2001
|
+
json_mode: bool,
|
|
2002
|
+
invoker: &dyn LambdaInvoker,
|
|
1383
2003
|
) -> Value {
|
|
1384
2004
|
let agg = agg_name(aggregate);
|
|
1385
2005
|
let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
@@ -1394,7 +2014,14 @@ async fn submit(
|
|
|
1394
2014
|
|
|
1395
2015
|
// `None`: this web UI has no concept of an authenticated caller's
|
|
1396
2016
|
// role yet (see auth.rs's own session handling for that).
|
|
1397
|
-
let outcome = match dispatch::handle(
|
|
2017
|
+
let outcome = match dispatch::handle(
|
|
2018
|
+
Host::new(client, wasm_path, config, invoker),
|
|
2019
|
+
&verb,
|
|
2020
|
+
args,
|
|
2021
|
+
None,
|
|
2022
|
+
)
|
|
2023
|
+
.await
|
|
2024
|
+
{
|
|
1398
2025
|
Ok(o) => o,
|
|
1399
2026
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
1400
2027
|
};
|
|
@@ -1414,13 +2041,34 @@ async fn submit(
|
|
|
1414
2041
|
}
|
|
1415
2042
|
|
|
1416
2043
|
let action = format!("/{domain_name}/{agg}/{cname}.html");
|
|
1417
|
-
html(
|
|
2044
|
+
html(
|
|
2045
|
+
422,
|
|
2046
|
+
&page(
|
|
2047
|
+
&format!("{domain_name}::{agg}.{cname}"),
|
|
2048
|
+
&form_body(
|
|
2049
|
+
domain_name,
|
|
2050
|
+
aggregate,
|
|
2051
|
+
command,
|
|
2052
|
+
&action,
|
|
2053
|
+
fields,
|
|
2054
|
+
raw,
|
|
2055
|
+
Some(&refusal),
|
|
2056
|
+
),
|
|
2057
|
+
),
|
|
2058
|
+
)
|
|
1418
2059
|
}
|
|
1419
2060
|
|
|
1420
2061
|
// Mirrors the 422-refusal rendering below (same json/html split) but at
|
|
1421
2062
|
// 400 — this is a malformed submission `nest` caught, not a domain refusal.
|
|
1422
|
-
|
|
1423
|
-
|
|
2063
|
+
fn bad_request(
|
|
2064
|
+
domain_name: &str,
|
|
2065
|
+
aggregate: &Value,
|
|
2066
|
+
command: &Value,
|
|
2067
|
+
fields: &[Field],
|
|
2068
|
+
raw: &HashMap<String, String>,
|
|
2069
|
+
json_mode: bool,
|
|
2070
|
+
message: &str,
|
|
2071
|
+
) -> Value {
|
|
1424
2072
|
let refusal = json!({"error": message});
|
|
1425
2073
|
if json_mode {
|
|
1426
2074
|
return respond(400, "application/json", &refusal.to_string());
|
|
@@ -1428,7 +2076,21 @@ fn bad_request(domain_name: &str, aggregate: &Value, command: &Value, fields: &[
|
|
|
1428
2076
|
let agg = agg_name(aggregate);
|
|
1429
2077
|
let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
1430
2078
|
let action = format!("/{domain_name}/{agg}/{cname}.html");
|
|
1431
|
-
html(
|
|
2079
|
+
html(
|
|
2080
|
+
400,
|
|
2081
|
+
&page(
|
|
2082
|
+
&format!("{domain_name}::{agg}.{cname}"),
|
|
2083
|
+
&form_body(
|
|
2084
|
+
domain_name,
|
|
2085
|
+
aggregate,
|
|
2086
|
+
command,
|
|
2087
|
+
&action,
|
|
2088
|
+
fields,
|
|
2089
|
+
raw,
|
|
2090
|
+
Some(&refusal),
|
|
2091
|
+
),
|
|
2092
|
+
),
|
|
2093
|
+
)
|
|
1432
2094
|
}
|
|
1433
2095
|
|
|
1434
2096
|
// The first mutation in the last step is always the command's own
|
|
@@ -1455,7 +2117,15 @@ pub(crate) fn last_refusal(result: &Value) -> Value {
|
|
|
1455
2117
|
.unwrap_or_else(|| json!({"error": "Refused"}))
|
|
1456
2118
|
}
|
|
1457
2119
|
|
|
1458
|
-
fn form_body(
|
|
2120
|
+
fn form_body(
|
|
2121
|
+
domain_name: &str,
|
|
2122
|
+
aggregate: &Value,
|
|
2123
|
+
command: &Value,
|
|
2124
|
+
action: &str,
|
|
2125
|
+
fields: &[Field],
|
|
2126
|
+
values: &HashMap<String, String>,
|
|
2127
|
+
error: Option<&Value>,
|
|
2128
|
+
) -> String {
|
|
1459
2129
|
let agg = agg_name(aggregate);
|
|
1460
2130
|
let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
1461
2131
|
let role = command.get("role").and_then(|v| v.as_str());
|
|
@@ -1463,7 +2133,13 @@ fn form_body(domain_name: &str, aggregate: &Value, command: &Value, action: &str
|
|
|
1463
2133
|
let givens: String = command
|
|
1464
2134
|
.get("givens")
|
|
1465
2135
|
.and_then(|v| v.as_array())
|
|
1466
|
-
.map(|gs|
|
|
2136
|
+
.map(|gs| {
|
|
2137
|
+
gs.iter()
|
|
2138
|
+
.filter_map(|g| g.get("description").and_then(|d| d.as_str()))
|
|
2139
|
+
.map(|d| format!("<li>{}</li>", esc(d)))
|
|
2140
|
+
.collect::<Vec<_>>()
|
|
2141
|
+
.concat()
|
|
2142
|
+
})
|
|
1467
2143
|
.unwrap_or_default();
|
|
1468
2144
|
|
|
1469
2145
|
let error_banner = error
|
|
@@ -1493,7 +2169,11 @@ fn render_field(field: &Field, values: &HashMap<String, String>) -> String {
|
|
|
1493
2169
|
let label = format!(
|
|
1494
2170
|
r#"<label class="block text-sm font-medium text-slate-700 mt-4">{}{}</label>"#,
|
|
1495
2171
|
esc(&field.label),
|
|
1496
|
-
if field.optional {
|
|
2172
|
+
if field.optional {
|
|
2173
|
+
""
|
|
2174
|
+
} else {
|
|
2175
|
+
r#" <span class="text-red-500">*</span>"#
|
|
2176
|
+
}
|
|
1497
2177
|
);
|
|
1498
2178
|
let input_class = "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm";
|
|
1499
2179
|
let current = values.get(&field.path).cloned().unwrap_or_default();
|
|
@@ -1504,41 +2184,62 @@ fn render_field(field: &Field, values: &HashMap<String, String>) -> String {
|
|
|
1504
2184
|
// inside it.
|
|
1505
2185
|
FieldKind::Group(children) | FieldKind::Money(children) => {
|
|
1506
2186
|
let inner: String = children.iter().map(|c| render_field(c, values)).collect();
|
|
1507
|
-
format!(
|
|
2187
|
+
format!(
|
|
2188
|
+
r#"<fieldset class="mt-4 border border-slate-200 rounded-md p-4"><legend class="text-sm font-semibold text-slate-700 px-1">{}</legend>{}</fieldset>"#,
|
|
2189
|
+
esc(&field.label),
|
|
2190
|
+
inner
|
|
2191
|
+
)
|
|
1508
2192
|
}
|
|
1509
2193
|
FieldKind::List(_) => format!(
|
|
1510
2194
|
r#"{label}<textarea name="{}" rows="4" placeholder="one per line" class="{input_class}">{}</textarea>"#,
|
|
1511
|
-
esc(&field.path),
|
|
2195
|
+
esc(&field.path),
|
|
2196
|
+
esc(¤t)
|
|
2197
|
+
),
|
|
2198
|
+
FieldKind::Textarea => format!(
|
|
2199
|
+
r#"{label}<textarea name="{}" class="{input_class}">{}</textarea>"#,
|
|
2200
|
+
esc(&field.path),
|
|
2201
|
+
esc(¤t)
|
|
1512
2202
|
),
|
|
1513
|
-
FieldKind::Textarea => format!(r#"{label}<textarea name="{}" class="{input_class}">{}</textarea>"#, esc(&field.path), esc(¤t)),
|
|
1514
2203
|
FieldKind::Boolean => format!(
|
|
1515
2204
|
r#"<div class="mt-4 flex items-center gap-2"><input type="checkbox" name="{}" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"><label class="text-sm text-slate-700">{}</label></div>"#,
|
|
1516
|
-
esc(&field.path),
|
|
2205
|
+
esc(&field.path),
|
|
2206
|
+
esc(&field.label)
|
|
1517
2207
|
),
|
|
1518
2208
|
FieldKind::Radio(options) => {
|
|
1519
2209
|
let opts: String = options
|
|
1520
2210
|
.iter()
|
|
1521
2211
|
.map(|o| format!(r#"<label class="inline-flex items-center gap-1 text-sm text-slate-700"><input type="radio" name="{}" value="{}" class="text-indigo-600 focus:ring-indigo-500">{}</label>"#, esc(&field.path), esc(o), esc(o)))
|
|
1522
|
-
.collect()
|
|
2212
|
+
.collect::<Vec<_>>()
|
|
2213
|
+
.concat();
|
|
1523
2214
|
format!(r#"{label}<div class="mt-1 flex gap-4">{opts}</div>"#)
|
|
1524
2215
|
}
|
|
1525
2216
|
FieldKind::Select(options) => {
|
|
1526
|
-
let opts: String = options
|
|
1527
|
-
|
|
2217
|
+
let opts: String = options
|
|
2218
|
+
.iter()
|
|
2219
|
+
.map(|o| format!(r#"<option value="{}">{}</option>"#, esc(o), esc(o)))
|
|
2220
|
+
.collect::<Vec<_>>()
|
|
2221
|
+
.concat();
|
|
2222
|
+
format!(
|
|
2223
|
+
r#"{label}<select name="{}" class="{input_class}">{opts}</select>"#,
|
|
2224
|
+
esc(&field.path)
|
|
2225
|
+
)
|
|
1528
2226
|
}
|
|
1529
2227
|
FieldKind::Number { step } => format!(
|
|
1530
2228
|
r#"{label}<input type="number" name="{}" value="{}" step="{step}" class="{input_class}">"#,
|
|
1531
|
-
esc(&field.path),
|
|
2229
|
+
esc(&field.path),
|
|
2230
|
+
esc(¤t)
|
|
1532
2231
|
),
|
|
1533
2232
|
FieldKind::Text { html_type } => format!(
|
|
1534
2233
|
r#"{label}<input type="{html_type}" name="{}" value="{}" class="{input_class}">"#,
|
|
1535
|
-
esc(&field.path),
|
|
2234
|
+
esc(&field.path),
|
|
2235
|
+
esc(¤t)
|
|
1536
2236
|
),
|
|
1537
2237
|
// No reference-picker dropdown: no candidate-fetching scaffolding
|
|
1538
2238
|
// exists yet, so this is a plain text id input, like Ruby's own fallback.
|
|
1539
2239
|
FieldKind::Reference { .. } => format!(
|
|
1540
2240
|
r#"{label}<input type="text" name="{}" value="{}" class="{input_class}">"#,
|
|
1541
|
-
esc(&field.path),
|
|
2241
|
+
esc(&field.path),
|
|
2242
|
+
esc(¤t)
|
|
1542
2243
|
),
|
|
1543
2244
|
}
|
|
1544
2245
|
}
|
|
@@ -1583,7 +2284,10 @@ fn parse_urlencoded(text: &str, plus_as_space: bool) -> HashMap<String, String>
|
|
|
1583
2284
|
.filter(|s| !s.is_empty())
|
|
1584
2285
|
.map(|pair| {
|
|
1585
2286
|
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
|
|
1586
|
-
(
|
|
2287
|
+
(
|
|
2288
|
+
percent_decode_impl(k, plus_as_space),
|
|
2289
|
+
percent_decode_impl(v, plus_as_space),
|
|
2290
|
+
)
|
|
1587
2291
|
})
|
|
1588
2292
|
.collect()
|
|
1589
2293
|
}
|
|
@@ -1605,7 +2309,8 @@ fn percent_decode_impl(s: &str, plus_as_space: bool) -> String {
|
|
|
1605
2309
|
b'%' if i + 2 < bytes.len() => {
|
|
1606
2310
|
// Byte-wise, not `s[i + 1..i + 3]`: that slice panics when it lands inside a
|
|
1607
2311
|
// multi-byte character (`%a🍕`), and `from_str_radix` would read a `+` sign as a digit.
|
|
1608
|
-
if let (Some(high), Some(low)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2]))
|
|
2312
|
+
if let (Some(high), Some(low)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2]))
|
|
2313
|
+
{
|
|
1609
2314
|
out.push(high << 4 | low);
|
|
1610
2315
|
i += 3;
|
|
1611
2316
|
} else {
|
|
@@ -1626,40 +2331,31 @@ fn hex_digit(byte: u8) -> Option<u8> {
|
|
|
1626
2331
|
(byte as char).to_digit(16).map(|digit| digit as u8)
|
|
1627
2332
|
}
|
|
1628
2333
|
|
|
1629
|
-
const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
1630
|
-
|
|
1631
2334
|
// pub(crate) so server.rs's own Function URL response handling can
|
|
1632
2335
|
// decode the same way, without a second copy of this decoder.
|
|
1633
2336
|
pub(crate) fn base64_decode(input: &str) -> Vec<u8> {
|
|
1634
|
-
let
|
|
1635
|
-
|
|
1636
|
-
table[c as usize] = i as u8;
|
|
1637
|
-
}
|
|
1638
|
-
let clean: Vec<u8> = input.bytes().filter(|&b| b != b'=' && !b.is_ascii_whitespace()).collect();
|
|
1639
|
-
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
|
|
1640
|
-
for chunk in clean.chunks(4) {
|
|
1641
|
-
let vals: Vec<u32> = chunk.iter().map(|&b| table[b as usize] as u32).collect();
|
|
1642
|
-
let n = vals.len();
|
|
1643
|
-
let combined = vals.iter().enumerate().fold(0u32, |acc, (i, &v)| acc | (v << (18 - 6 * i)));
|
|
1644
|
-
out.push((combined >> 16) as u8);
|
|
1645
|
-
if n > 2 {
|
|
1646
|
-
out.push((combined >> 8) as u8);
|
|
1647
|
-
}
|
|
1648
|
-
if n > 3 {
|
|
1649
|
-
out.push(combined as u8);
|
|
1650
|
-
}
|
|
1651
|
-
}
|
|
1652
|
-
out
|
|
2337
|
+
let compact: String = input.split_ascii_whitespace().collect();
|
|
2338
|
+
crate::b64::standard_decode(&compact).unwrap_or_default()
|
|
1653
2339
|
}
|
|
1654
2340
|
|
|
1655
2341
|
fn esc(value: &str) -> String {
|
|
1656
|
-
value
|
|
2342
|
+
value
|
|
2343
|
+
.replace('&', "&")
|
|
2344
|
+
.replace('<', "<")
|
|
2345
|
+
.replace('>', ">")
|
|
2346
|
+
.replace('"', """)
|
|
2347
|
+
.replace('\'', "'")
|
|
1657
2348
|
}
|
|
1658
2349
|
|
|
1659
2350
|
// `id` is user-supplied and sits in a query-string value here, so
|
|
1660
2351
|
// `esc()` alone isn't enough — percent-encode via `auth::urlencode`.
|
|
1661
2352
|
fn action_link(domain_name: &str, agg: &str, cn: &str, id: &str, class: &str) -> String {
|
|
1662
|
-
format!(
|
|
2353
|
+
format!(
|
|
2354
|
+
r#"<a href="/{domain_name}/{agg}/{}.html?id={}" class="{class}">{}</a>"#,
|
|
2355
|
+
esc(cn),
|
|
2356
|
+
auth::urlencode(id),
|
|
2357
|
+
esc(cn)
|
|
2358
|
+
)
|
|
1663
2359
|
}
|
|
1664
2360
|
|
|
1665
2361
|
// `id` sits in a path segment here, not a query value, so `esc()`
|
|
@@ -1674,7 +2370,8 @@ fn index_row_html(domain_name: &str, agg: &str, id: &str, actions: &str) -> Stri
|
|
|
1674
2370
|
fn page(title: &str, body: &str) -> String {
|
|
1675
2371
|
format!(
|
|
1676
2372
|
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{}</title><script src="https://cdn.tailwindcss.com"></script></head><body class="bg-slate-50 text-slate-900 min-h-screen"><header class="bg-white border-b border-slate-200"><div class="max-w-3xl mx-auto px-4 py-3"><a href="/" class="font-semibold text-slate-900">rust/host — served straight off the bluebook IR</a></div></header><main class="max-w-3xl mx-auto px-4 py-8">{}</main></body></html>"#,
|
|
1677
|
-
esc(title),
|
|
2373
|
+
esc(title),
|
|
2374
|
+
body
|
|
1678
2375
|
)
|
|
1679
2376
|
}
|
|
1680
2377
|
|
|
@@ -1698,7 +2395,11 @@ fn cookie_flags() -> &'static str {
|
|
|
1698
2395
|
// A host that sets the redirect URI is judged by it, as ever; a host that has stopped setting it
|
|
1699
2396
|
// is judged by SITE_URL, and with neither set the cookie is Secure.
|
|
1700
2397
|
pub(crate) fn account_cookie_flags(redirect_uri: &str, site_url: &str) -> &'static str {
|
|
1701
|
-
let judged_by = if redirect_uri.is_empty() {
|
|
2398
|
+
let judged_by = if redirect_uri.is_empty() {
|
|
2399
|
+
site_url
|
|
2400
|
+
} else {
|
|
2401
|
+
redirect_uri
|
|
2402
|
+
};
|
|
1702
2403
|
if judged_by.starts_with("http://") {
|
|
1703
2404
|
COOKIE_FLAGS_PLAIN
|
|
1704
2405
|
} else {
|
|
@@ -1713,6 +2414,22 @@ fn redirect_with_cookie(location: &str, cookie: &str) -> Value {
|
|
|
1713
2414
|
json!({"statusCode": 302, "headers": {"location": location}, "cookies": [cookie], "body": "", "isBase64Encoded": false})
|
|
1714
2415
|
}
|
|
1715
2416
|
|
|
2417
|
+
const DEFAULT_SITE_URL: &str = "http://localhost:4321";
|
|
2418
|
+
|
|
2419
|
+
/// The public site's origin: `SITE_URL`, else the local development address.
|
|
2420
|
+
pub(crate) fn site_url() -> String {
|
|
2421
|
+
std::env::var("SITE_URL").unwrap_or_else(|_| DEFAULT_SITE_URL.to_string())
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/// A JSON `{"error": message}` reply with `status`.
|
|
2425
|
+
pub(crate) fn json_error(status: u16, message: &str) -> Value {
|
|
2426
|
+
respond(
|
|
2427
|
+
status,
|
|
2428
|
+
"application/json",
|
|
2429
|
+
&json!({"error": message}).to_string(),
|
|
2430
|
+
)
|
|
2431
|
+
}
|
|
2432
|
+
|
|
1716
2433
|
pub(crate) fn respond(status: u16, content_type: &str, body: &str) -> Value {
|
|
1717
2434
|
json!({"statusCode": status, "headers": {"content-type": content_type}, "body": body, "isBase64Encoded": false})
|
|
1718
2435
|
}
|
|
@@ -1741,19 +2458,29 @@ mod tests {
|
|
|
1741
2458
|
// JSON request would get a redirect instead of a 401 it can branch on.
|
|
1742
2459
|
|
|
1743
2460
|
fn status(response: &Value) -> u64 {
|
|
1744
|
-
response
|
|
2461
|
+
response
|
|
2462
|
+
.get("statusCode")
|
|
2463
|
+
.and_then(serde_json::Value::as_u64)
|
|
2464
|
+
.expect("a response always carries a statusCode")
|
|
1745
2465
|
}
|
|
1746
2466
|
|
|
1747
2467
|
#[test]
|
|
1748
2468
|
fn an_unauthenticated_api_request_is_refused_with_the_ruby_engines_own_401_json() {
|
|
1749
|
-
let refusal = auth_gate("/api/clients", false)
|
|
2469
|
+
let refusal = auth_gate("/api/clients", false)
|
|
2470
|
+
.expect("an unauthenticated /api/ request must be refused");
|
|
1750
2471
|
|
|
1751
2472
|
assert_eq!(status(&refusal), 401);
|
|
1752
2473
|
assert_eq!(refusal["headers"]["content-type"], "application/json");
|
|
1753
2474
|
// Byte-for-byte the console app's own web/app.rb
|
|
1754
2475
|
// `halt 401, json({ error: "Unauthenticated", message: "sign in first" })`.
|
|
1755
|
-
assert_eq!(
|
|
1756
|
-
|
|
2476
|
+
assert_eq!(
|
|
2477
|
+
refusal["body"],
|
|
2478
|
+
r#"{"error":"Unauthenticated","message":"sign in first"}"#
|
|
2479
|
+
);
|
|
2480
|
+
assert!(
|
|
2481
|
+
refusal["headers"].get("location").is_none(),
|
|
2482
|
+
"a JSON caller must not be redirected: {refusal}"
|
|
2483
|
+
);
|
|
1757
2484
|
}
|
|
1758
2485
|
|
|
1759
2486
|
// Ruby keys off the `/api/` prefix alone and doesn't look at a
|
|
@@ -1769,9 +2496,18 @@ mod tests {
|
|
|
1769
2496
|
// lives in the path), so it must be 401'd, never redirected.
|
|
1770
2497
|
#[test]
|
|
1771
2498
|
fn an_unauthenticated_host_route_is_401_unless_it_asks_for_html() {
|
|
1772
|
-
assert_eq!(
|
|
1773
|
-
|
|
1774
|
-
|
|
2499
|
+
assert_eq!(
|
|
2500
|
+
status(&auth_gate("/Pizzas/Order.json", false).expect("refused")),
|
|
2501
|
+
401
|
|
2502
|
+
);
|
|
2503
|
+
assert_eq!(
|
|
2504
|
+
status(&auth_gate("/Pizzas/Order", false).expect("refused")),
|
|
2505
|
+
401
|
|
2506
|
+
);
|
|
2507
|
+
assert_eq!(
|
|
2508
|
+
status(&auth_gate("/Pizzas/Order/p1.json", false).expect("refused")),
|
|
2509
|
+
401
|
|
2510
|
+
);
|
|
1775
2511
|
|
|
1776
2512
|
let page = auth_gate("/Pizzas/Order.html", false).expect("refused");
|
|
1777
2513
|
assert_eq!(status(&page), 302);
|
|
@@ -1790,14 +2526,20 @@ mod tests {
|
|
|
1790
2526
|
assert_eq!(status(&home), 302);
|
|
1791
2527
|
assert_eq!(home["headers"]["location"], "/login");
|
|
1792
2528
|
|
|
1793
|
-
assert_eq!(
|
|
2529
|
+
assert_eq!(
|
|
2530
|
+
status(&auth_gate("/favicon.ico", false).expect("refused")),
|
|
2531
|
+
302
|
|
2532
|
+
);
|
|
1794
2533
|
assert_eq!(status(&auth_gate("/Pizzas", false).expect("refused")), 302);
|
|
1795
2534
|
}
|
|
1796
2535
|
|
|
1797
2536
|
#[test]
|
|
1798
2537
|
fn the_ungated_paths_are_still_ungated_without_a_session() {
|
|
1799
2538
|
for path in UNGATED_PATHS {
|
|
1800
|
-
assert!(
|
|
2539
|
+
assert!(
|
|
2540
|
+
auth_gate(path, false).is_none(),
|
|
2541
|
+
"{path} must reach its own handler with no session"
|
|
2542
|
+
);
|
|
1801
2543
|
}
|
|
1802
2544
|
}
|
|
1803
2545
|
|
|
@@ -1816,16 +2558,25 @@ mod tests {
|
|
|
1816
2558
|
let link = action_link("Pizzas", "Order", "Purchase", MALICIOUS_ID, "mr-3");
|
|
1817
2559
|
|
|
1818
2560
|
assert!(!link.contains("<script"), "{link}");
|
|
1819
|
-
assert!(
|
|
2561
|
+
assert!(
|
|
2562
|
+
!link.contains(MALICIOUS_ID),
|
|
2563
|
+
"the raw id must not survive into the rendered link: {link}"
|
|
2564
|
+
);
|
|
1820
2565
|
// Percent-encoded, not HTML-escaped — esc() alone would leave a
|
|
1821
2566
|
// raw `&` that terminates the `id=` param early.
|
|
1822
|
-
assert!(
|
|
2567
|
+
assert!(
|
|
2568
|
+
link.contains("id=x%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E"),
|
|
2569
|
+
"{link}"
|
|
2570
|
+
);
|
|
1823
2571
|
}
|
|
1824
2572
|
|
|
1825
2573
|
#[test]
|
|
1826
2574
|
fn action_link_renders_an_ordinary_id_unchanged() {
|
|
1827
2575
|
let link = action_link("Pizzas", "Order", "Purchase", "p1", "mr-3");
|
|
1828
|
-
assert_eq!(
|
|
2576
|
+
assert_eq!(
|
|
2577
|
+
link,
|
|
2578
|
+
r#"<a href="/Pizzas/Order/Purchase.html?id=p1" class="mr-3">Purchase</a>"#
|
|
2579
|
+
);
|
|
1829
2580
|
}
|
|
1830
2581
|
|
|
1831
2582
|
#[test]
|
|
@@ -1858,7 +2609,7 @@ mod tests {
|
|
|
1858
2609
|
"invariants": [], "closed_set": false, "members": []
|
|
1859
2610
|
});
|
|
1860
2611
|
let customer = json!({"name": "Customer", "value_objects": [email_address_vo]});
|
|
1861
|
-
let domain_ir = json!({"aggregates": [customer
|
|
2612
|
+
let domain_ir = json!({"aggregates": [customer]});
|
|
1862
2613
|
let attribute = json!({"name": "email", "type": "EmailAddress", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
|
|
1863
2614
|
|
|
1864
2615
|
let field = resolve_field(&domain_ir, &attribute, &customer, "email");
|
|
@@ -1880,7 +2631,7 @@ mod tests {
|
|
|
1880
2631
|
"invariants": [], "closed_set": false, "members": []
|
|
1881
2632
|
});
|
|
1882
2633
|
let account = json!({"name": "Account", "value_objects": [money_vo]});
|
|
1883
|
-
let domain_ir = json!({"aggregates": [account
|
|
2634
|
+
let domain_ir = json!({"aggregates": [account]});
|
|
1884
2635
|
let attribute = json!({"name": "balance", "type": "Money", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
|
|
1885
2636
|
|
|
1886
2637
|
let field = resolve_field(&domain_ir, &attribute, &account, "balance");
|
|
@@ -1888,8 +2639,14 @@ mod tests {
|
|
|
1888
2639
|
FieldKind::Money(children) => {
|
|
1889
2640
|
let paths: Vec<&str> = children.iter().map(|c| c.path.as_str()).collect();
|
|
1890
2641
|
assert_eq!(paths, vec!["balance.cents", "balance.currency"]);
|
|
1891
|
-
assert!(
|
|
1892
|
-
|
|
2642
|
+
assert!(
|
|
2643
|
+
!children[0].optional,
|
|
2644
|
+
"cents follows the outer attribute's own optionality"
|
|
2645
|
+
);
|
|
2646
|
+
assert!(
|
|
2647
|
+
children[1].optional,
|
|
2648
|
+
"currency is always optional, defaulting to USD, regardless of the outer field"
|
|
2649
|
+
);
|
|
1893
2650
|
}
|
|
1894
2651
|
other => panic!("expected Money, got {other:?}"),
|
|
1895
2652
|
}
|
|
@@ -1898,7 +2655,7 @@ mod tests {
|
|
|
1898
2655
|
#[test]
|
|
1899
2656
|
fn account_open_customer_id_renders_as_a_reference_carrying_its_target() {
|
|
1900
2657
|
let account = json!({"name": "Account", "value_objects": []});
|
|
1901
|
-
let domain_ir = json!({"aggregates": [account
|
|
2658
|
+
let domain_ir = json!({"aggregates": [account]});
|
|
1902
2659
|
let attribute = json!({"name": "customer_id", "type": "Reference<Customer>", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
|
|
1903
2660
|
|
|
1904
2661
|
let field = resolve_field(&domain_ir, &attribute, &account, "customer_id");
|
|
@@ -1912,7 +2669,8 @@ mod tests {
|
|
|
1912
2669
|
// lookup and Tier B's textarea hint together, the one combination
|
|
1913
2670
|
// that silently breaks if either regresses alone.
|
|
1914
2671
|
#[test]
|
|
1915
|
-
fn narrative_used_from_a_different_aggregate_renders_as_a_textarea_via_the_cross_aggregate_lookup(
|
|
2672
|
+
fn narrative_used_from_a_different_aggregate_renders_as_a_textarea_via_the_cross_aggregate_lookup(
|
|
2673
|
+
) {
|
|
1916
2674
|
// Narrative is declared on Account, never CardPayment — scoped
|
|
1917
2675
|
// here against CardPayment specifically to prove the fallback
|
|
1918
2676
|
// walks the whole domain, not just declaring-aggregate siblings.
|
|
@@ -1927,7 +2685,7 @@ mod tests {
|
|
|
1927
2685
|
"name": "CardPayment",
|
|
1928
2686
|
"value_objects": [{"name": "AuthorisationCode", "attributes": [], "invariants": [], "closed_set": false, "members": []}]
|
|
1929
2687
|
});
|
|
1930
|
-
let domain_ir = json!({"aggregates": [account, card_payment
|
|
2688
|
+
let domain_ir = json!({"aggregates": [account, card_payment]});
|
|
1931
2689
|
let attribute = json!({"name": "narrative", "type": "Narrative", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
|
|
1932
2690
|
|
|
1933
2691
|
let field = resolve_field(&domain_ir, &attribute, &card_payment, "narrative");
|
|
@@ -1954,14 +2712,17 @@ mod tests {
|
|
|
1954
2712
|
"attributes": [{"name": "value", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null}],
|
|
1955
2713
|
"invariants": [], "closed_set": false, "members": []
|
|
1956
2714
|
});
|
|
1957
|
-
let external_transfer =
|
|
1958
|
-
|
|
2715
|
+
let external_transfer =
|
|
2716
|
+
json!({"name": "ExternalTransfer", "value_objects": [movement_direction_vo]});
|
|
2717
|
+
let domain_ir = json!({"aggregates": [account, external_transfer]});
|
|
1959
2718
|
let attribute = json!({"name": "direction", "type": "MovementDirection", "list": false, "default": null, "optional": false, "pattern": null, "admits": "Account::LedgerDirection"});
|
|
1960
2719
|
|
|
1961
2720
|
let field = resolve_field(&domain_ir, &attribute, &external_transfer, "direction");
|
|
1962
2721
|
assert_eq!(field.path, "direction.value");
|
|
1963
2722
|
match field.kind {
|
|
1964
|
-
FieldKind::Radio(members) =>
|
|
2723
|
+
FieldKind::Radio(members) => {
|
|
2724
|
+
assert_eq!(members, vec!["credit".to_string(), "debit".to_string()]);
|
|
2725
|
+
}
|
|
1965
2726
|
other => panic!("expected Radio with exactly 2 members, got {other:?}"),
|
|
1966
2727
|
}
|
|
1967
2728
|
}
|
|
@@ -1971,7 +2732,7 @@ mod tests {
|
|
|
1971
2732
|
// No `pattern:` at all (synthetic — every real email attribute
|
|
1972
2733
|
// also carries one) — proves the name-based match alone is enough.
|
|
1973
2734
|
let aggregate = json!({"name": "Whatever", "value_objects": []});
|
|
1974
|
-
let domain_ir = json!({"aggregates": [aggregate
|
|
2735
|
+
let domain_ir = json!({"aggregates": [aggregate]});
|
|
1975
2736
|
let attribute = json!({"name": "contact_email", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
|
|
1976
2737
|
|
|
1977
2738
|
let field = resolve_field(&domain_ir, &attribute, &aggregate, "contact_email");
|
|
@@ -1986,7 +2747,7 @@ mod tests {
|
|
|
1986
2747
|
// No real corpus example of either shape exists; built minimal
|
|
1987
2748
|
// here, same as auth.rs's own fixtures with no real counterpart.
|
|
1988
2749
|
let aggregate = json!({"name": "Whatever", "value_objects": []});
|
|
1989
|
-
let domain_ir = json!({"aggregates": [aggregate
|
|
2750
|
+
let domain_ir = json!({"aggregates": [aggregate]});
|
|
1990
2751
|
|
|
1991
2752
|
let website = json!({"name": "website", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
|
|
1992
2753
|
match resolve_field(&domain_ir, &website, &aggregate, "website").kind {
|
|
@@ -2011,7 +2772,7 @@ mod tests {
|
|
|
2011
2772
|
"closed_set": false, "members": []
|
|
2012
2773
|
});
|
|
2013
2774
|
let order = json!({"name": "Order", "value_objects": [price_vo]});
|
|
2014
|
-
let domain_ir = json!({"aggregates": [order
|
|
2775
|
+
let domain_ir = json!({"aggregates": [order]});
|
|
2015
2776
|
let attribute = json!({"name": "price_cents", "type": "Price", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
|
|
2016
2777
|
|
|
2017
2778
|
match resolve_field(&domain_ir, &attribute, &order, "price_cents").kind {
|
|
@@ -2034,15 +2795,24 @@ mod tests {
|
|
|
2034
2795
|
|
|
2035
2796
|
#[test]
|
|
2036
2797
|
fn nest_builds_an_ordinary_dotted_path_into_a_nested_object() {
|
|
2037
|
-
let result = nest(vec![
|
|
2798
|
+
let result = nest(vec![
|
|
2799
|
+
("price.cents".to_string(), json!(500)),
|
|
2800
|
+
("price.currency".to_string(), json!("USD")),
|
|
2801
|
+
])
|
|
2802
|
+
.unwrap();
|
|
2038
2803
|
assert_eq!(result, json!({"price": {"cents": 500, "currency": "USD"}}));
|
|
2039
2804
|
}
|
|
2040
2805
|
|
|
2041
2806
|
#[test]
|
|
2042
|
-
fn nest_refuses_rather_than_panics_when_a_scalar_is_inserted_before_a_deeper_path_sharing_its_prefix(
|
|
2807
|
+
fn nest_refuses_rather_than_panics_when_a_scalar_is_inserted_before_a_deeper_path_sharing_its_prefix(
|
|
2808
|
+
) {
|
|
2043
2809
|
// `price` lands as a scalar first, then `price.cents` tries to
|
|
2044
2810
|
// descend into it — the shape that would otherwise panic instead of refusing cleanly.
|
|
2045
|
-
let err = nest(vec![
|
|
2811
|
+
let err = nest(vec![
|
|
2812
|
+
("price".to_string(), json!(500)),
|
|
2813
|
+
("price.cents".to_string(), json!(500)),
|
|
2814
|
+
])
|
|
2815
|
+
.unwrap_err();
|
|
2046
2816
|
assert!(err.contains("price"), "{err}");
|
|
2047
2817
|
}
|
|
2048
2818
|
|
|
@@ -2050,13 +2820,21 @@ mod tests {
|
|
|
2050
2820
|
fn nest_refuses_rather_than_silently_dropping_data_when_the_deeper_path_is_inserted_first() {
|
|
2051
2821
|
// The reverse insertion order clobbers a nested object with a
|
|
2052
2822
|
// bare scalar instead of panicking — must refuse here too.
|
|
2053
|
-
let err = nest(vec![
|
|
2823
|
+
let err = nest(vec![
|
|
2824
|
+
("price.cents".to_string(), json!(500)),
|
|
2825
|
+
("price".to_string(), json!(500)),
|
|
2826
|
+
])
|
|
2827
|
+
.unwrap_err();
|
|
2054
2828
|
assert!(err.contains("price"), "{err}");
|
|
2055
2829
|
}
|
|
2056
2830
|
|
|
2057
2831
|
#[test]
|
|
2058
2832
|
fn nest_is_unaffected_by_an_unrelated_sibling_sharing_no_prefix() {
|
|
2059
|
-
let result = nest(vec![
|
|
2833
|
+
let result = nest(vec![
|
|
2834
|
+
("price.cents".to_string(), json!(500)),
|
|
2835
|
+
("name".to_string(), json!("Widget")),
|
|
2836
|
+
])
|
|
2837
|
+
.unwrap();
|
|
2060
2838
|
assert_eq!(result, json!({"price": {"cents": 500}, "name": "Widget"}));
|
|
2061
2839
|
}
|
|
2062
2840
|
|
|
@@ -2066,16 +2844,30 @@ mod tests {
|
|
|
2066
2844
|
assert_eq!(q.get("code").map(String::as_str), Some("abc+def/gh"));
|
|
2067
2845
|
assert_eq!(q.get("state").map(String::as_str), Some("1.sig"));
|
|
2068
2846
|
let form = parse_form("code=abc+def");
|
|
2069
|
-
assert_eq!(
|
|
2847
|
+
assert_eq!(
|
|
2848
|
+
form.get("code").map(String::as_str),
|
|
2849
|
+
Some("abc def"),
|
|
2850
|
+
"HTML forms still treat + as space"
|
|
2851
|
+
);
|
|
2070
2852
|
}
|
|
2071
2853
|
|
|
2072
2854
|
#[test]
|
|
2073
2855
|
fn extract_args_surfaces_a_collision_as_an_error_rather_than_panicking() {
|
|
2074
|
-
let scalar_field = Field {
|
|
2856
|
+
let scalar_field = Field {
|
|
2857
|
+
path: "price".to_string(),
|
|
2858
|
+
label: "Price".to_string(),
|
|
2859
|
+
kind: FieldKind::Number { step: "1" },
|
|
2860
|
+
optional: false,
|
|
2861
|
+
};
|
|
2075
2862
|
let nested_field = Field {
|
|
2076
2863
|
path: "price".to_string(),
|
|
2077
2864
|
label: "Price".to_string(),
|
|
2078
|
-
kind: FieldKind::Group(vec![Field {
|
|
2865
|
+
kind: FieldKind::Group(vec![Field {
|
|
2866
|
+
path: "price.cents".to_string(),
|
|
2867
|
+
label: "Cents".to_string(),
|
|
2868
|
+
kind: FieldKind::Number { step: "1" },
|
|
2869
|
+
optional: false,
|
|
2870
|
+
}]),
|
|
2079
2871
|
optional: false,
|
|
2080
2872
|
};
|
|
2081
2873
|
let mut raw = HashMap::new();
|
|
@@ -2083,7 +2875,10 @@ mod tests {
|
|
|
2083
2875
|
raw.insert("price.cents".to_string(), "500".to_string());
|
|
2084
2876
|
|
|
2085
2877
|
let result = extract_args(&[scalar_field, nested_field], &raw);
|
|
2086
|
-
assert!(
|
|
2878
|
+
assert!(
|
|
2879
|
+
result.is_err(),
|
|
2880
|
+
"expected a collision error, got {result:?}"
|
|
2881
|
+
);
|
|
2087
2882
|
}
|
|
2088
2883
|
|
|
2089
2884
|
#[test]
|
|
@@ -2103,7 +2898,11 @@ mod tests {
|
|
|
2103
2898
|
{"aggregate": "Pizzas::LoyaltyAccount", "id": "loyalty-9", "operation": "save"}
|
|
2104
2899
|
]]
|
|
2105
2900
|
});
|
|
2106
|
-
assert_eq!(
|
|
2901
|
+
assert_eq!(
|
|
2902
|
+
own_command_target_id(&result),
|
|
2903
|
+
Some("order-1"),
|
|
2904
|
+
"must redirect to the command's OWN aggregate, not the cascaded reaction's"
|
|
2905
|
+
);
|
|
2107
2906
|
}
|
|
2108
2907
|
|
|
2109
2908
|
#[test]
|
|
@@ -2129,14 +2928,24 @@ mod tests {
|
|
|
2129
2928
|
use tokio_postgres::NoTls;
|
|
2130
2929
|
|
|
2131
2930
|
pub(super) async fn scratch_db(name: &str) -> Mutex<Client> {
|
|
2132
|
-
let (admin, conn) = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls)
|
|
2931
|
+
let (admin, conn) = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls)
|
|
2932
|
+
.await
|
|
2933
|
+
.expect("connect to postgres");
|
|
2133
2934
|
tokio::spawn(async move {
|
|
2134
2935
|
let _ = conn.await;
|
|
2135
2936
|
});
|
|
2136
|
-
admin
|
|
2137
|
-
|
|
2937
|
+
admin
|
|
2938
|
+
.batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
|
|
2939
|
+
.await
|
|
2940
|
+
.unwrap();
|
|
2941
|
+
admin
|
|
2942
|
+
.batch_execute(&format!("CREATE DATABASE {name}"))
|
|
2943
|
+
.await
|
|
2944
|
+
.unwrap();
|
|
2138
2945
|
|
|
2139
|
-
let (client, conn) = tokio_postgres::connect(&crate::test_pg::conninfo(
|
|
2946
|
+
let (client, conn) = tokio_postgres::connect(&crate::test_pg::conninfo(name), NoTls)
|
|
2947
|
+
.await
|
|
2948
|
+
.expect("connect to scratch db");
|
|
2140
2949
|
tokio::spawn(async move {
|
|
2141
2950
|
let _ = conn.await;
|
|
2142
2951
|
});
|
|
@@ -2146,10 +2955,23 @@ mod tests {
|
|
|
2146
2955
|
|
|
2147
2956
|
// Duplicates dispatch.rs's own provision_lineage shape rather than
|
|
2148
2957
|
// sharing it, matching this crate's existing per-module test fixtures.
|
|
2149
|
-
pub(super) async fn provision_lineage(
|
|
2150
|
-
client
|
|
2958
|
+
pub(super) async fn provision_lineage(
|
|
2959
|
+
client: &Client,
|
|
2960
|
+
domain: &str,
|
|
2961
|
+
era: i32,
|
|
2962
|
+
aggregate_storage_names: &[&str],
|
|
2963
|
+
) {
|
|
2151
2964
|
client
|
|
2152
|
-
.
|
|
2965
|
+
.batch_execute(
|
|
2966
|
+
"CREATE TABLE IF NOT EXISTS hecks_eras (domain text, ordinal int, held_text text)",
|
|
2967
|
+
)
|
|
2968
|
+
.await
|
|
2969
|
+
.unwrap();
|
|
2970
|
+
client
|
|
2971
|
+
.execute(
|
|
2972
|
+
"INSERT INTO hecks_eras (domain, ordinal, held_text) VALUES ($1, $2, 'test')",
|
|
2973
|
+
&[&domain, &era],
|
|
2974
|
+
)
|
|
2153
2975
|
.await
|
|
2154
2976
|
.unwrap();
|
|
2155
2977
|
|
|
@@ -2171,7 +2993,10 @@ mod tests {
|
|
|
2171
2993
|
for name in aggregate_storage_names {
|
|
2172
2994
|
// domain-qualified (docs/decisions/0059) — matches what a real
|
|
2173
2995
|
// `journal::append_lineage_mutation` write actually targets now.
|
|
2174
|
-
let snapshot_table = crate::journal::qualified_name(
|
|
2996
|
+
let snapshot_table = crate::journal::qualified_name(
|
|
2997
|
+
domain,
|
|
2998
|
+
&format!("{}_head_snapshot_{era}", crate::journal::snake(name)),
|
|
2999
|
+
);
|
|
2175
3000
|
client
|
|
2176
3001
|
.batch_execute(&format!("CREATE TABLE IF NOT EXISTS \"{snapshot_table}\" (id text PRIMARY KEY, ordinal bigint NOT NULL, state jsonb NOT NULL)"))
|
|
2177
3002
|
.await
|
|
@@ -2189,19 +3014,33 @@ mod tests {
|
|
|
2189
3014
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
2190
3015
|
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
2191
3016
|
assert_eq!(body["email"], "zed@example.com");
|
|
2192
|
-
assert_eq!(
|
|
3017
|
+
assert_eq!(
|
|
3018
|
+
body["identity_id"], "id-1",
|
|
3019
|
+
"the signed-in account's Governance identity id"
|
|
3020
|
+
);
|
|
2193
3021
|
|
|
2194
3022
|
let empty = HashMap::new();
|
|
2195
|
-
assert_eq!(
|
|
3023
|
+
assert_eq!(
|
|
3024
|
+
accounts_me_route(&domain_ir, &empty, secret, &client).await["statusCode"],
|
|
3025
|
+
401
|
|
3026
|
+
);
|
|
2196
3027
|
|
|
2197
3028
|
let mut tampered = HashMap::new();
|
|
2198
|
-
tampered.insert(
|
|
2199
|
-
|
|
3029
|
+
tampered.insert(
|
|
3030
|
+
auth::account_cookie_name(),
|
|
3031
|
+
"garbage.notasignature".to_string(),
|
|
3032
|
+
);
|
|
3033
|
+
assert_eq!(
|
|
3034
|
+
accounts_me_route(&domain_ir, &tampered, secret, &client).await["statusCode"],
|
|
3035
|
+
401
|
|
3036
|
+
);
|
|
2200
3037
|
|
|
2201
3038
|
// A validly signed token for someone with no granted role, or nobody
|
|
2202
3039
|
// admitted at all, is no session.
|
|
2203
3040
|
for email in ["amy@example.com", "stranger@example.com"] {
|
|
2204
|
-
let response =
|
|
3041
|
+
let response =
|
|
3042
|
+
accounts_me_route(&domain_ir, &session_cookies(secret, email), secret, &client)
|
|
3043
|
+
.await;
|
|
2205
3044
|
assert_eq!(response["statusCode"], 401, "{email}: {response:?}");
|
|
2206
3045
|
}
|
|
2207
3046
|
}
|
|
@@ -2209,10 +3048,14 @@ mod tests {
|
|
|
2209
3048
|
#[tokio::test]
|
|
2210
3049
|
async fn accounts_sso_token_route_mints_a_short_lived_token_verifiable_by_the_same_secret() {
|
|
2211
3050
|
let secret = "s3cret";
|
|
2212
|
-
let (client, domain_ir) =
|
|
3051
|
+
let (client, domain_ir) =
|
|
3052
|
+
scratch_members_db("hecks_host_web_test_accounts_sso_token").await;
|
|
2213
3053
|
|
|
2214
3054
|
let mut cookies = HashMap::new();
|
|
2215
|
-
cookies.insert(
|
|
3055
|
+
cookies.insert(
|
|
3056
|
+
auth::account_cookie_name(),
|
|
3057
|
+
auth::account_token(secret, "zed@example.com", 60 * 60 * 24 * 14),
|
|
3058
|
+
);
|
|
2216
3059
|
let response = accounts_sso_token_route(&domain_ir, &cookies, secret, &client).await;
|
|
2217
3060
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
2218
3061
|
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
@@ -2220,17 +3063,35 @@ mod tests {
|
|
|
2220
3063
|
|
|
2221
3064
|
// The point: cms/src/endpoints/sso.ts verifies this exact wire
|
|
2222
3065
|
// format — a real, decodable token, not just a 200 with any string.
|
|
2223
|
-
assert_eq!(
|
|
3066
|
+
assert_eq!(
|
|
3067
|
+
auth::verify_account_token(secret, sso_token).as_deref(),
|
|
3068
|
+
Some("zed@example.com")
|
|
3069
|
+
);
|
|
2224
3070
|
|
|
2225
3071
|
// Missing or invalid session cookie -- refused, no token minted.
|
|
2226
3072
|
let empty = HashMap::new();
|
|
2227
|
-
assert_eq!(
|
|
3073
|
+
assert_eq!(
|
|
3074
|
+
accounts_sso_token_route(&domain_ir, &empty, secret, &client).await["statusCode"],
|
|
3075
|
+
401
|
|
3076
|
+
);
|
|
2228
3077
|
|
|
2229
3078
|
let mut tampered = HashMap::new();
|
|
2230
|
-
tampered.insert(
|
|
2231
|
-
|
|
3079
|
+
tampered.insert(
|
|
3080
|
+
auth::account_cookie_name(),
|
|
3081
|
+
"garbage.notasignature".to_string(),
|
|
3082
|
+
);
|
|
3083
|
+
assert_eq!(
|
|
3084
|
+
accounts_sso_token_route(&domain_ir, &tampered, secret, &client).await["statusCode"],
|
|
3085
|
+
401
|
|
3086
|
+
);
|
|
2232
3087
|
|
|
2233
|
-
let no_role = accounts_sso_token_route(
|
|
3088
|
+
let no_role = accounts_sso_token_route(
|
|
3089
|
+
&domain_ir,
|
|
3090
|
+
&session_cookies(secret, "amy@example.com"),
|
|
3091
|
+
secret,
|
|
3092
|
+
&client,
|
|
3093
|
+
)
|
|
3094
|
+
.await;
|
|
2234
3095
|
assert_eq!(no_role["statusCode"], 401, "{no_role:?}");
|
|
2235
3096
|
}
|
|
2236
3097
|
|
|
@@ -2281,7 +3142,10 @@ mod tests {
|
|
|
2281
3142
|
let (client, domain_ir) = scratch_members_db("hecks_host_web_test_members_route").await;
|
|
2282
3143
|
|
|
2283
3144
|
let mut cookies = HashMap::new();
|
|
2284
|
-
cookies.insert(
|
|
3145
|
+
cookies.insert(
|
|
3146
|
+
auth::account_cookie_name(),
|
|
3147
|
+
auth::account_token(secret, "zed@example.com", 60),
|
|
3148
|
+
);
|
|
2285
3149
|
let response = members_route(&domain_ir, &cookies, secret, &client).await;
|
|
2286
3150
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
2287
3151
|
|
|
@@ -2304,19 +3168,34 @@ mod tests {
|
|
|
2304
3168
|
let (client, domain_ir) = scratch_members_db("hecks_host_web_test_members_route_401").await;
|
|
2305
3169
|
|
|
2306
3170
|
let mut tampered = HashMap::new();
|
|
2307
|
-
tampered.insert(
|
|
3171
|
+
tampered.insert(
|
|
3172
|
+
auth::account_cookie_name(),
|
|
3173
|
+
"garbage.notasignature".to_string(),
|
|
3174
|
+
);
|
|
2308
3175
|
let mut wrong_secret = HashMap::new();
|
|
2309
|
-
wrong_secret.insert(
|
|
3176
|
+
wrong_secret.insert(
|
|
3177
|
+
auth::account_cookie_name(),
|
|
3178
|
+
auth::account_token("another", "zed@example.com", 60),
|
|
3179
|
+
);
|
|
2310
3180
|
let mut governance_only = HashMap::new();
|
|
2311
3181
|
governance_only.insert("session".to_string(), "anything".to_string());
|
|
2312
3182
|
|
|
2313
3183
|
for cookies in [HashMap::new(), tampered, wrong_secret, governance_only] {
|
|
2314
3184
|
let response = members_route(&domain_ir, &cookies, secret, &client).await;
|
|
2315
3185
|
assert_eq!(response["statusCode"], 401, "{response:?}");
|
|
2316
|
-
assert!(
|
|
3186
|
+
assert!(
|
|
3187
|
+
response
|
|
3188
|
+
.get("headers")
|
|
3189
|
+
.and_then(|h| h.get("location"))
|
|
3190
|
+
.is_none(),
|
|
3191
|
+
"never a redirect: {response:?}"
|
|
3192
|
+
);
|
|
2317
3193
|
let body = response["body"].as_str().unwrap();
|
|
2318
3194
|
for leaked in ["zed@example.com", "amy@example.com", "Zed", "Admin"] {
|
|
2319
|
-
assert!(
|
|
3195
|
+
assert!(
|
|
3196
|
+
!body.contains(leaked),
|
|
3197
|
+
"{leaked:?} leaked to an unauthenticated caller: {body}"
|
|
3198
|
+
);
|
|
2320
3199
|
}
|
|
2321
3200
|
}
|
|
2322
3201
|
}
|
|
@@ -2339,29 +3218,48 @@ mod tests {
|
|
|
2339
3218
|
.await
|
|
2340
3219
|
.unwrap();
|
|
2341
3220
|
|
|
2342
|
-
let response = members_route(
|
|
3221
|
+
let response = members_route(
|
|
3222
|
+
&domain_ir,
|
|
3223
|
+
&session_cookies(secret, "max@example.com"),
|
|
3224
|
+
secret,
|
|
3225
|
+
&client,
|
|
3226
|
+
)
|
|
3227
|
+
.await;
|
|
2343
3228
|
assert_eq!(response["statusCode"], 403, "{response:?}");
|
|
2344
3229
|
let body = response["body"].as_str().unwrap();
|
|
2345
3230
|
for leaked in ["zed@example.com", "amy@example.com", "Zed"] {
|
|
2346
|
-
assert!(
|
|
3231
|
+
assert!(
|
|
3232
|
+
!body.contains(leaked),
|
|
3233
|
+
"{leaked:?} leaked to a plain member: {body}"
|
|
3234
|
+
);
|
|
2347
3235
|
}
|
|
2348
3236
|
}
|
|
2349
3237
|
|
|
2350
3238
|
fn session_cookies(secret: &str, email: &str) -> HashMap<String, String> {
|
|
2351
3239
|
let mut cookies = HashMap::new();
|
|
2352
|
-
cookies.insert(
|
|
3240
|
+
cookies.insert(
|
|
3241
|
+
auth::account_cookie_name(),
|
|
3242
|
+
auth::account_token(secret, email, 60),
|
|
3243
|
+
);
|
|
2353
3244
|
cookies
|
|
2354
3245
|
}
|
|
2355
3246
|
|
|
2356
3247
|
fn members_config() -> LineageConfig {
|
|
2357
|
-
LineageConfig {
|
|
3248
|
+
LineageConfig {
|
|
3249
|
+
domain: "Acme".to_string(),
|
|
3250
|
+
era: Some(1),
|
|
3251
|
+
mirrored: None,
|
|
3252
|
+
}
|
|
2358
3253
|
}
|
|
2359
3254
|
|
|
2360
3255
|
#[tokio::test]
|
|
2361
|
-
async fn signup_route_refuses_anything_but_a_valid_signup_token_with_a_json_401_and_writes_nothing(
|
|
3256
|
+
async fn signup_route_refuses_anything_but_a_valid_signup_token_with_a_json_401_and_writes_nothing(
|
|
3257
|
+
) {
|
|
2362
3258
|
let secret = "s3cret";
|
|
2363
3259
|
let (client, domain_ir) = scratch_members_db("hecks_host_web_test_signup_401").await;
|
|
2364
|
-
let body = |token: &str|
|
|
3260
|
+
let body = |token: &str| {
|
|
3261
|
+
json!({"token": token, "email": "new@example.com", "name": "New Person"}).to_string()
|
|
3262
|
+
};
|
|
2365
3263
|
|
|
2366
3264
|
// A session token and a token minted for another purpose both verify
|
|
2367
3265
|
// as tokens under some key, but never as a signup token.
|
|
@@ -2369,14 +3267,50 @@ mod tests {
|
|
|
2369
3267
|
let other_purpose = auth::purpose_token(secret, "another_purpose", json!({}), 60);
|
|
2370
3268
|
let wrong_secret = auth::purpose_token("another", SIGNUP_PURPOSE, json!({}), 60);
|
|
2371
3269
|
|
|
2372
|
-
for token in [
|
|
2373
|
-
|
|
3270
|
+
for token in [
|
|
3271
|
+
"",
|
|
3272
|
+
"garbage.notasignature",
|
|
3273
|
+
session_token.as_str(),
|
|
3274
|
+
other_purpose.as_str(),
|
|
3275
|
+
wrong_secret.as_str(),
|
|
3276
|
+
] {
|
|
3277
|
+
let response = signup_route(
|
|
3278
|
+
&body(token),
|
|
3279
|
+
secret,
|
|
3280
|
+
Host::new(
|
|
3281
|
+
&client,
|
|
3282
|
+
Path::new("unused"),
|
|
3283
|
+
&members_config(),
|
|
3284
|
+
&crate::lambda_client::NeverInvoker,
|
|
3285
|
+
),
|
|
3286
|
+
)
|
|
3287
|
+
.await;
|
|
2374
3288
|
assert_eq!(response["statusCode"], 401, "{response:?}");
|
|
2375
|
-
assert!(
|
|
3289
|
+
assert!(
|
|
3290
|
+
response
|
|
3291
|
+
.get("headers")
|
|
3292
|
+
.and_then(|h| h.get("location"))
|
|
3293
|
+
.is_none(),
|
|
3294
|
+
"never a redirect: {response:?}"
|
|
3295
|
+
);
|
|
2376
3296
|
}
|
|
2377
|
-
let unparseable = signup_route(
|
|
3297
|
+
let unparseable = signup_route(
|
|
3298
|
+
"not json",
|
|
3299
|
+
secret,
|
|
3300
|
+
Host::new(
|
|
3301
|
+
&client,
|
|
3302
|
+
Path::new("unused"),
|
|
3303
|
+
&members_config(),
|
|
3304
|
+
&crate::lambda_client::NeverInvoker,
|
|
3305
|
+
),
|
|
3306
|
+
)
|
|
3307
|
+
.await;
|
|
2378
3308
|
assert_eq!(unparseable["statusCode"], 401, "{unparseable:?}");
|
|
2379
|
-
assert_eq!(
|
|
3309
|
+
assert_eq!(
|
|
3310
|
+
auth::all_people(&client, &domain_ir).await.unwrap().len(),
|
|
3311
|
+
2,
|
|
3312
|
+
"nothing was written"
|
|
3313
|
+
);
|
|
2380
3314
|
}
|
|
2381
3315
|
|
|
2382
3316
|
#[tokio::test]
|
|
@@ -2385,34 +3319,83 @@ mod tests {
|
|
|
2385
3319
|
let (client, _) = scratch_members_db("hecks_host_web_test_signup_400").await;
|
|
2386
3320
|
let token = auth::purpose_token(secret, SIGNUP_PURPOSE, json!({}), 60);
|
|
2387
3321
|
|
|
2388
|
-
for (email, name) in [
|
|
3322
|
+
for (email, name) in [
|
|
3323
|
+
("", "Ada"),
|
|
3324
|
+
("no-at-sign", "Ada"),
|
|
3325
|
+
("a@nodot", "Ada"),
|
|
3326
|
+
("has space@x.io", "Ada"),
|
|
3327
|
+
("ada@x.io", ""),
|
|
3328
|
+
("ada@x.io", " "),
|
|
3329
|
+
] {
|
|
2389
3330
|
let body = json!({"token": token, "email": email, "name": name}).to_string();
|
|
2390
|
-
let response = signup_route(
|
|
2391
|
-
|
|
3331
|
+
let response = signup_route(
|
|
3332
|
+
&body,
|
|
3333
|
+
secret,
|
|
3334
|
+
Host::new(
|
|
3335
|
+
&client,
|
|
3336
|
+
Path::new("unused"),
|
|
3337
|
+
&members_config(),
|
|
3338
|
+
&crate::lambda_client::NeverInvoker,
|
|
3339
|
+
),
|
|
3340
|
+
)
|
|
3341
|
+
.await;
|
|
3342
|
+
assert_eq!(
|
|
3343
|
+
response["statusCode"], 400,
|
|
3344
|
+
"{email:?} / {name:?}: {response:?}"
|
|
3345
|
+
);
|
|
2392
3346
|
}
|
|
2393
3347
|
}
|
|
2394
3348
|
|
|
2395
3349
|
#[tokio::test]
|
|
2396
|
-
async fn add_member_route_refuses_a_missing_or_invalid_session_with_a_json_401_and_writes_nothing(
|
|
3350
|
+
async fn add_member_route_refuses_a_missing_or_invalid_session_with_a_json_401_and_writes_nothing(
|
|
3351
|
+
) {
|
|
2397
3352
|
let secret = "s3cret";
|
|
2398
3353
|
let (client, domain_ir) = scratch_members_db("hecks_host_web_test_add_member_401").await;
|
|
2399
3354
|
let body = r#"{"email": "new@example.com", "name": "New Person"}"#;
|
|
2400
3355
|
|
|
2401
3356
|
let mut tampered = HashMap::new();
|
|
2402
|
-
tampered.insert(
|
|
3357
|
+
tampered.insert(
|
|
3358
|
+
auth::account_cookie_name(),
|
|
3359
|
+
"garbage.notasignature".to_string(),
|
|
3360
|
+
);
|
|
2403
3361
|
let mut wrong_secret = HashMap::new();
|
|
2404
|
-
wrong_secret.insert(
|
|
3362
|
+
wrong_secret.insert(
|
|
3363
|
+
auth::account_cookie_name(),
|
|
3364
|
+
auth::account_token("another", "zed@example.com", 60),
|
|
3365
|
+
);
|
|
2405
3366
|
|
|
2406
3367
|
for cookies in [HashMap::new(), tampered, wrong_secret] {
|
|
2407
|
-
let response = add_member_route(
|
|
3368
|
+
let response = add_member_route(
|
|
3369
|
+
&domain_ir,
|
|
3370
|
+
body,
|
|
3371
|
+
&cookies,
|
|
3372
|
+
secret,
|
|
3373
|
+
&client,
|
|
3374
|
+
Path::new("unused"),
|
|
3375
|
+
&members_config(),
|
|
3376
|
+
)
|
|
3377
|
+
.await;
|
|
2408
3378
|
assert_eq!(response["statusCode"], 401, "{response:?}");
|
|
2409
|
-
assert!(
|
|
3379
|
+
assert!(
|
|
3380
|
+
response
|
|
3381
|
+
.get("headers")
|
|
3382
|
+
.and_then(|h| h.get("location"))
|
|
3383
|
+
.is_none(),
|
|
3384
|
+
"never a redirect: {response:?}"
|
|
3385
|
+
);
|
|
2410
3386
|
let text = response["body"].as_str().unwrap();
|
|
2411
3387
|
for leaked in ["zed@example.com", "amy@example.com", "Zed", "Admin"] {
|
|
2412
|
-
assert!(
|
|
3388
|
+
assert!(
|
|
3389
|
+
!text.contains(leaked),
|
|
3390
|
+
"{leaked:?} leaked to an unauthenticated caller: {text}"
|
|
3391
|
+
);
|
|
2413
3392
|
}
|
|
2414
3393
|
}
|
|
2415
|
-
assert_eq!(
|
|
3394
|
+
assert_eq!(
|
|
3395
|
+
auth::all_people(&client, &domain_ir).await.unwrap().len(),
|
|
3396
|
+
2,
|
|
3397
|
+
"nothing was written"
|
|
3398
|
+
);
|
|
2416
3399
|
}
|
|
2417
3400
|
|
|
2418
3401
|
#[tokio::test]
|
|
@@ -2424,10 +3407,23 @@ mod tests {
|
|
|
2424
3407
|
// amy is admitted but has no role; a stranger is not admitted at all.
|
|
2425
3408
|
for caller in ["amy@example.com", "stranger@example.com"] {
|
|
2426
3409
|
let cookies = session_cookies(secret, caller);
|
|
2427
|
-
let response = add_member_route(
|
|
3410
|
+
let response = add_member_route(
|
|
3411
|
+
&domain_ir,
|
|
3412
|
+
body,
|
|
3413
|
+
&cookies,
|
|
3414
|
+
secret,
|
|
3415
|
+
&client,
|
|
3416
|
+
Path::new("unused"),
|
|
3417
|
+
&members_config(),
|
|
3418
|
+
)
|
|
3419
|
+
.await;
|
|
2428
3420
|
assert_eq!(response["statusCode"], 403, "{caller}: {response:?}");
|
|
2429
3421
|
}
|
|
2430
|
-
assert_eq!(
|
|
3422
|
+
assert_eq!(
|
|
3423
|
+
auth::all_people(&client, &domain_ir).await.unwrap().len(),
|
|
3424
|
+
2,
|
|
3425
|
+
"nothing was written"
|
|
3426
|
+
);
|
|
2431
3427
|
}
|
|
2432
3428
|
|
|
2433
3429
|
#[tokio::test]
|
|
@@ -2450,10 +3446,23 @@ mod tests {
|
|
|
2450
3446
|
r#"{"email": "two words@example.com", "name": "New Person"}"#,
|
|
2451
3447
|
r#"{"email": 42, "name": "New Person"}"#,
|
|
2452
3448
|
] {
|
|
2453
|
-
let response = add_member_route(
|
|
3449
|
+
let response = add_member_route(
|
|
3450
|
+
&domain_ir,
|
|
3451
|
+
body,
|
|
3452
|
+
&cookies,
|
|
3453
|
+
secret,
|
|
3454
|
+
&client,
|
|
3455
|
+
Path::new("unused"),
|
|
3456
|
+
&members_config(),
|
|
3457
|
+
)
|
|
3458
|
+
.await;
|
|
2454
3459
|
assert_eq!(response["statusCode"], 400, "{body:?}: {response:?}");
|
|
2455
3460
|
}
|
|
2456
|
-
assert_eq!(
|
|
3461
|
+
assert_eq!(
|
|
3462
|
+
auth::all_people(&client, &domain_ir).await.unwrap().len(),
|
|
3463
|
+
2,
|
|
3464
|
+
"nothing was written"
|
|
3465
|
+
);
|
|
2457
3466
|
}
|
|
2458
3467
|
|
|
2459
3468
|
#[tokio::test]
|
|
@@ -2464,10 +3473,152 @@ mod tests {
|
|
|
2464
3473
|
|
|
2465
3474
|
for email in ["amy@example.com", "AMY@Example.com"] {
|
|
2466
3475
|
let body = json!({"email": email, "name": "Another Amy"}).to_string();
|
|
2467
|
-
let response = add_member_route(
|
|
3476
|
+
let response = add_member_route(
|
|
3477
|
+
&domain_ir,
|
|
3478
|
+
&body,
|
|
3479
|
+
&cookies,
|
|
3480
|
+
secret,
|
|
3481
|
+
&client,
|
|
3482
|
+
Path::new("unused"),
|
|
3483
|
+
&members_config(),
|
|
3484
|
+
)
|
|
3485
|
+
.await;
|
|
2468
3486
|
assert_eq!(response["statusCode"], 409, "{email}: {response:?}");
|
|
2469
3487
|
}
|
|
2470
|
-
assert_eq!(
|
|
3488
|
+
assert_eq!(
|
|
3489
|
+
auth::all_people(&client, &domain_ir).await.unwrap().len(),
|
|
3490
|
+
2,
|
|
3491
|
+
"nothing was written"
|
|
3492
|
+
);
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
// /admin/members by the account cookie alone, as a site whose sign-in sets only that cookie.
|
|
3496
|
+
async fn members_page(
|
|
3497
|
+
method: &str,
|
|
3498
|
+
body: &str,
|
|
3499
|
+
cookies: &HashMap<String, String>,
|
|
3500
|
+
secret: &str,
|
|
3501
|
+
client: &Mutex<Client>,
|
|
3502
|
+
domain_ir: &Value,
|
|
3503
|
+
) -> Value {
|
|
3504
|
+
admin_members_route(
|
|
3505
|
+
method,
|
|
3506
|
+
domain_ir,
|
|
3507
|
+
body,
|
|
3508
|
+
cookies,
|
|
3509
|
+
None,
|
|
3510
|
+
secret,
|
|
3511
|
+
client,
|
|
3512
|
+
Path::new("unused"),
|
|
3513
|
+
&members_config(),
|
|
3514
|
+
)
|
|
3515
|
+
.await
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
#[tokio::test]
|
|
3519
|
+
async fn members_page_takes_an_admin_by_the_account_cookie_and_turns_away_anyone_else() {
|
|
3520
|
+
let secret = "s3cret";
|
|
3521
|
+
let (client, domain_ir) =
|
|
3522
|
+
scratch_members_db("hecks_host_web_test_members_page_cookie").await;
|
|
3523
|
+
|
|
3524
|
+
let page = members_page(
|
|
3525
|
+
"GET",
|
|
3526
|
+
"",
|
|
3527
|
+
&session_cookies(secret, "zed@example.com"),
|
|
3528
|
+
secret,
|
|
3529
|
+
&client,
|
|
3530
|
+
&domain_ir,
|
|
3531
|
+
)
|
|
3532
|
+
.await;
|
|
3533
|
+
assert_eq!(page["statusCode"], 200, "{page:?}");
|
|
3534
|
+
assert!(
|
|
3535
|
+
page["body"].as_str().unwrap().contains("amy@example.com"),
|
|
3536
|
+
"the page lists the members"
|
|
3537
|
+
);
|
|
3538
|
+
|
|
3539
|
+
let not_admin = members_page(
|
|
3540
|
+
"GET",
|
|
3541
|
+
"",
|
|
3542
|
+
&session_cookies(secret, "amy@example.com"),
|
|
3543
|
+
secret,
|
|
3544
|
+
&client,
|
|
3545
|
+
&domain_ir,
|
|
3546
|
+
)
|
|
3547
|
+
.await;
|
|
3548
|
+
assert_eq!(not_admin["statusCode"], 403);
|
|
3549
|
+
|
|
3550
|
+
let nobody = members_page("GET", "", &HashMap::new(), secret, &client, &domain_ir).await;
|
|
3551
|
+
assert_eq!(nobody["statusCode"], 302);
|
|
3552
|
+
assert_eq!(nobody["headers"]["location"], "/login");
|
|
3553
|
+
}
|
|
3554
|
+
|
|
3555
|
+
#[tokio::test]
|
|
3556
|
+
async fn members_page_form_admits_a_new_person_and_grants_the_role_or_grants_an_existing_one() {
|
|
3557
|
+
let secret = "s3cret";
|
|
3558
|
+
let (client, domain_ir) = scratch_members_db("hecks_host_web_test_members_page_form").await;
|
|
3559
|
+
let admin = session_cookies(secret, "zed@example.com");
|
|
3560
|
+
|
|
3561
|
+
let added = members_page(
|
|
3562
|
+
"POST",
|
|
3563
|
+
"name=Lou+Ann&email=Lou%40Example.org&role=Admin",
|
|
3564
|
+
&admin,
|
|
3565
|
+
secret,
|
|
3566
|
+
&client,
|
|
3567
|
+
&domain_ir,
|
|
3568
|
+
)
|
|
3569
|
+
.await;
|
|
3570
|
+
assert_eq!(added["statusCode"], 302, "{added:?}");
|
|
3571
|
+
assert_eq!(added["headers"]["location"], "/admin/members");
|
|
3572
|
+
let granted = members_page(
|
|
3573
|
+
"POST",
|
|
3574
|
+
"email=amy%40example.com&role=Owner",
|
|
3575
|
+
&admin,
|
|
3576
|
+
secret,
|
|
3577
|
+
&client,
|
|
3578
|
+
&domain_ir,
|
|
3579
|
+
)
|
|
3580
|
+
.await;
|
|
3581
|
+
assert_eq!(granted["statusCode"], 302, "{granted:?}");
|
|
3582
|
+
|
|
3583
|
+
let people = auth::all_people(&client, &domain_ir).await.unwrap();
|
|
3584
|
+
let lou = people
|
|
3585
|
+
.iter()
|
|
3586
|
+
.find(|p| p["email"] == "lou@example.org")
|
|
3587
|
+
.expect("Lou Ann is admitted");
|
|
3588
|
+
assert_eq!(
|
|
3589
|
+
(
|
|
3590
|
+
lou["name"].as_str(),
|
|
3591
|
+
lou["role"].as_str(),
|
|
3592
|
+
lou["granted"].as_bool()
|
|
3593
|
+
),
|
|
3594
|
+
(Some("Lou Ann"), Some("Admin"), Some(true))
|
|
3595
|
+
);
|
|
3596
|
+
let amy = people
|
|
3597
|
+
.iter()
|
|
3598
|
+
.find(|p| p["email"] == "amy@example.com")
|
|
3599
|
+
.unwrap();
|
|
3600
|
+
assert_eq!(amy["role"], "Owner");
|
|
3601
|
+
|
|
3602
|
+
let bad = members_page(
|
|
3603
|
+
"POST",
|
|
3604
|
+
"email=not-an-email&role=Admin",
|
|
3605
|
+
&admin,
|
|
3606
|
+
secret,
|
|
3607
|
+
&client,
|
|
3608
|
+
&domain_ir,
|
|
3609
|
+
)
|
|
3610
|
+
.await;
|
|
3611
|
+
assert_eq!(bad["statusCode"], 400);
|
|
3612
|
+
let stranger = members_page(
|
|
3613
|
+
"POST",
|
|
3614
|
+
"email=x%40example.com&role=Admin",
|
|
3615
|
+
&session_cookies(secret, "amy-not@example.com"),
|
|
3616
|
+
secret,
|
|
3617
|
+
&client,
|
|
3618
|
+
&domain_ir,
|
|
3619
|
+
)
|
|
3620
|
+
.await;
|
|
3621
|
+
assert_eq!(stranger["statusCode"], 403);
|
|
2471
3622
|
}
|
|
2472
3623
|
|
|
2473
3624
|
#[tokio::test]
|
|
@@ -2477,7 +3628,16 @@ mod tests {
|
|
|
2477
3628
|
let cookies = session_cookies(secret, "Zed@Example.com");
|
|
2478
3629
|
|
|
2479
3630
|
let body = r#"{"email": " New@Example.com ", "name": " New Person "}"#;
|
|
2480
|
-
let response = add_member_route(
|
|
3631
|
+
let response = add_member_route(
|
|
3632
|
+
&domain_ir,
|
|
3633
|
+
body,
|
|
3634
|
+
&cookies,
|
|
3635
|
+
secret,
|
|
3636
|
+
&client,
|
|
3637
|
+
Path::new("unused"),
|
|
3638
|
+
&members_config(),
|
|
3639
|
+
)
|
|
3640
|
+
.await;
|
|
2481
3641
|
assert_eq!(response["statusCode"], 201, "{response:?}");
|
|
2482
3642
|
let created: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
2483
3643
|
assert_eq!(
|
|
@@ -2488,31 +3648,64 @@ mod tests {
|
|
|
2488
3648
|
let listing = members_route(&domain_ir, &cookies, secret, &client).await;
|
|
2489
3649
|
let people: Vec<Value> = serde_json::from_str(listing["body"].as_str().unwrap()).unwrap();
|
|
2490
3650
|
assert_eq!(people.len(), 3);
|
|
2491
|
-
let added = people
|
|
3651
|
+
let added = people
|
|
3652
|
+
.iter()
|
|
3653
|
+
.find(|p| p["email"] == "new@example.com")
|
|
3654
|
+
.expect("the new person is listed");
|
|
2492
3655
|
assert_eq!(*added, created);
|
|
2493
3656
|
|
|
2494
3657
|
let guard = client.lock().await;
|
|
2495
3658
|
let journalled: i64 = guard
|
|
2496
|
-
.query_one(
|
|
3659
|
+
.query_one(
|
|
3660
|
+
"SELECT count(*) FROM hecks_journal_acme WHERE aggregate_id = 'new@example.com'",
|
|
3661
|
+
&[],
|
|
3662
|
+
)
|
|
2497
3663
|
.await
|
|
2498
3664
|
.unwrap()
|
|
2499
3665
|
.get(0);
|
|
2500
|
-
assert_eq!(
|
|
3666
|
+
assert_eq!(
|
|
3667
|
+
journalled, 2,
|
|
3668
|
+
"one journal row for Admit and one for GrantAccess"
|
|
3669
|
+
);
|
|
2501
3670
|
}
|
|
2502
3671
|
|
|
2503
3672
|
// The scratch members database with amy also granted Admin, so two
|
|
2504
3673
|
// active admins exist and one can act on the other.
|
|
2505
3674
|
async fn scratch_two_admins(name: &str) -> (Mutex<Client>, Value) {
|
|
2506
3675
|
let (client, domain_ir) = scratch_members_db(name).await;
|
|
2507
|
-
let granted = auth::grant_access(
|
|
3676
|
+
let granted = auth::grant_access(
|
|
3677
|
+
&client,
|
|
3678
|
+
Path::new("unused"),
|
|
3679
|
+
&members_config(),
|
|
3680
|
+
&domain_ir,
|
|
3681
|
+
"amy@example.com",
|
|
3682
|
+
"Admin",
|
|
3683
|
+
)
|
|
3684
|
+
.await
|
|
3685
|
+
.unwrap();
|
|
2508
3686
|
assert!(granted);
|
|
2509
3687
|
(client, domain_ir)
|
|
2510
3688
|
}
|
|
2511
3689
|
|
|
2512
|
-
async fn switch_access(
|
|
3690
|
+
async fn switch_access(
|
|
3691
|
+
client: &Mutex<Client>,
|
|
3692
|
+
domain_ir: &Value,
|
|
3693
|
+
caller: &str,
|
|
3694
|
+
email: &str,
|
|
3695
|
+
disable: bool,
|
|
3696
|
+
) -> Value {
|
|
2513
3697
|
let secret = "s3cret";
|
|
2514
3698
|
let body = json!({"email": email}).to_string();
|
|
2515
|
-
set_member_disabled_route(
|
|
3699
|
+
set_member_disabled_route(
|
|
3700
|
+
domain_ir,
|
|
3701
|
+
&body,
|
|
3702
|
+
&session_cookies(secret, caller),
|
|
3703
|
+
secret,
|
|
3704
|
+
client,
|
|
3705
|
+
&members_config(),
|
|
3706
|
+
disable,
|
|
3707
|
+
)
|
|
3708
|
+
.await
|
|
2516
3709
|
}
|
|
2517
3710
|
|
|
2518
3711
|
fn error_of(response: &Value) -> String {
|
|
@@ -2522,13 +3715,19 @@ mod tests {
|
|
|
2522
3715
|
|
|
2523
3716
|
async fn person(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> Value {
|
|
2524
3717
|
let people = auth::all_people(client, domain_ir).await.unwrap();
|
|
2525
|
-
people
|
|
3718
|
+
people
|
|
3719
|
+
.into_iter()
|
|
3720
|
+
.find(|p| p["email"] == email)
|
|
3721
|
+
.unwrap_or_else(|| panic!("{email} is not listed"))
|
|
2526
3722
|
}
|
|
2527
3723
|
|
|
2528
3724
|
async fn journal_rows(client: &Mutex<Client>, id: &str) -> i64 {
|
|
2529
3725
|
let guard = client.lock().await;
|
|
2530
3726
|
guard
|
|
2531
|
-
.query_one(
|
|
3727
|
+
.query_one(
|
|
3728
|
+
"SELECT count(*) FROM hecks_journal_acme WHERE aggregate_id = $1",
|
|
3729
|
+
&[&id],
|
|
3730
|
+
)
|
|
2532
3731
|
.await
|
|
2533
3732
|
.unwrap()
|
|
2534
3733
|
.get(0)
|
|
@@ -2540,20 +3739,43 @@ mod tests {
|
|
|
2540
3739
|
let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_401").await;
|
|
2541
3740
|
|
|
2542
3741
|
let mut tampered = HashMap::new();
|
|
2543
|
-
tampered.insert(
|
|
3742
|
+
tampered.insert(
|
|
3743
|
+
auth::account_cookie_name(),
|
|
3744
|
+
"garbage.notasignature".to_string(),
|
|
3745
|
+
);
|
|
2544
3746
|
let body = json!({"email": "amy@example.com"}).to_string();
|
|
2545
3747
|
for disable in [true, false] {
|
|
2546
3748
|
for cookies in [HashMap::new(), tampered.clone()] {
|
|
2547
|
-
let response = set_member_disabled_route(
|
|
3749
|
+
let response = set_member_disabled_route(
|
|
3750
|
+
&domain_ir,
|
|
3751
|
+
&body,
|
|
3752
|
+
&cookies,
|
|
3753
|
+
secret,
|
|
3754
|
+
&client,
|
|
3755
|
+
&members_config(),
|
|
3756
|
+
disable,
|
|
3757
|
+
)
|
|
3758
|
+
.await;
|
|
2548
3759
|
assert_eq!(response["statusCode"], 401, "{response:?}");
|
|
2549
|
-
assert!(
|
|
3760
|
+
assert!(
|
|
3761
|
+
response
|
|
3762
|
+
.get("headers")
|
|
3763
|
+
.and_then(|h| h.get("location"))
|
|
3764
|
+
.is_none(),
|
|
3765
|
+
"never a redirect: {response:?}"
|
|
3766
|
+
);
|
|
2550
3767
|
}
|
|
2551
3768
|
for blank in ["", " "] {
|
|
2552
|
-
let response =
|
|
3769
|
+
let response =
|
|
3770
|
+
switch_access(&client, &domain_ir, "zed@example.com", blank, disable).await;
|
|
2553
3771
|
assert_eq!(response["statusCode"], 400, "{blank:?}: {response:?}");
|
|
2554
3772
|
}
|
|
2555
3773
|
}
|
|
2556
|
-
assert_eq!(
|
|
3774
|
+
assert_eq!(
|
|
3775
|
+
person(&client, &domain_ir, "amy@example.com").await["disabled"],
|
|
3776
|
+
false,
|
|
3777
|
+
"nothing was written"
|
|
3778
|
+
);
|
|
2557
3779
|
}
|
|
2558
3780
|
|
|
2559
3781
|
#[tokio::test]
|
|
@@ -2563,12 +3785,17 @@ mod tests {
|
|
|
2563
3785
|
// amy is admitted with no role; the stranger was never admitted.
|
|
2564
3786
|
for caller in ["amy@example.com", "stranger@example.com"] {
|
|
2565
3787
|
for disable in [true, false] {
|
|
2566
|
-
let response =
|
|
3788
|
+
let response =
|
|
3789
|
+
switch_access(&client, &domain_ir, caller, "zed@example.com", disable).await;
|
|
2567
3790
|
assert_eq!(response["statusCode"], 403, "{caller}: {response:?}");
|
|
2568
3791
|
assert_eq!(error_of(&response), "admins only");
|
|
2569
3792
|
}
|
|
2570
3793
|
}
|
|
2571
|
-
assert_eq!(
|
|
3794
|
+
assert_eq!(
|
|
3795
|
+
person(&client, &domain_ir, "zed@example.com").await["granted"],
|
|
3796
|
+
true,
|
|
3797
|
+
"nothing was written"
|
|
3798
|
+
);
|
|
2572
3799
|
}
|
|
2573
3800
|
|
|
2574
3801
|
#[tokio::test]
|
|
@@ -2579,9 +3806,15 @@ mod tests {
|
|
|
2579
3806
|
for typed in ["zed@example.com", " ZED@Example.com "] {
|
|
2580
3807
|
let response = switch_access(&client, &domain_ir, "zed@example.com", typed, true).await;
|
|
2581
3808
|
assert_eq!(response["statusCode"], 403, "{typed:?}: {response:?}");
|
|
2582
|
-
assert_eq!(
|
|
3809
|
+
assert_eq!(
|
|
3810
|
+
error_of(&response),
|
|
3811
|
+
"you can't disable your own admin access"
|
|
3812
|
+
);
|
|
2583
3813
|
}
|
|
2584
|
-
assert_eq!(
|
|
3814
|
+
assert_eq!(
|
|
3815
|
+
person(&client, &domain_ir, "zed@example.com").await["granted"],
|
|
3816
|
+
true
|
|
3817
|
+
);
|
|
2585
3818
|
assert_eq!(journal_rows(&client, "zed@example.com").await, before);
|
|
2586
3819
|
}
|
|
2587
3820
|
|
|
@@ -2589,46 +3822,100 @@ mod tests {
|
|
|
2589
3822
|
async fn disable_and_enable_routes_answer_404_for_an_unknown_email() {
|
|
2590
3823
|
let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_404").await;
|
|
2591
3824
|
for disable in [true, false] {
|
|
2592
|
-
let response = switch_access(
|
|
3825
|
+
let response = switch_access(
|
|
3826
|
+
&client,
|
|
3827
|
+
&domain_ir,
|
|
3828
|
+
"zed@example.com",
|
|
3829
|
+
"nobody@example.com",
|
|
3830
|
+
disable,
|
|
3831
|
+
)
|
|
3832
|
+
.await;
|
|
2593
3833
|
assert_eq!(response["statusCode"], 404, "{response:?}");
|
|
2594
3834
|
}
|
|
2595
3835
|
}
|
|
2596
3836
|
|
|
2597
|
-
async fn set_role(
|
|
3837
|
+
async fn set_role(
|
|
3838
|
+
client: &Mutex<Client>,
|
|
3839
|
+
domain_ir: &Value,
|
|
3840
|
+
caller: &str,
|
|
3841
|
+
email: &str,
|
|
3842
|
+
role: &str,
|
|
3843
|
+
) -> Value {
|
|
2598
3844
|
let secret = "s3cret";
|
|
2599
3845
|
let body = json!({"email": email, "role": role}).to_string();
|
|
2600
|
-
set_member_role_route(
|
|
3846
|
+
set_member_role_route(
|
|
3847
|
+
domain_ir,
|
|
3848
|
+
&body,
|
|
3849
|
+
&session_cookies(secret, caller),
|
|
3850
|
+
secret,
|
|
3851
|
+
client,
|
|
3852
|
+
&members_config(),
|
|
3853
|
+
)
|
|
3854
|
+
.await
|
|
2601
3855
|
}
|
|
2602
3856
|
|
|
2603
3857
|
#[tokio::test]
|
|
2604
|
-
async fn role_route_refuses_a_missing_session_a_blank_email_and_an_unknown_role_and_writes_nothing(
|
|
3858
|
+
async fn role_route_refuses_a_missing_session_a_blank_email_and_an_unknown_role_and_writes_nothing(
|
|
3859
|
+
) {
|
|
2605
3860
|
let secret = "s3cret";
|
|
2606
3861
|
let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_400").await;
|
|
2607
3862
|
let before = journal_rows(&client, "amy@example.com").await;
|
|
2608
3863
|
|
|
2609
3864
|
let mut tampered = HashMap::new();
|
|
2610
|
-
tampered.insert(
|
|
3865
|
+
tampered.insert(
|
|
3866
|
+
auth::account_cookie_name(),
|
|
3867
|
+
"garbage.notasignature".to_string(),
|
|
3868
|
+
);
|
|
2611
3869
|
let body = json!({"email": "amy@example.com", "role": "Owner"}).to_string();
|
|
2612
3870
|
for cookies in [HashMap::new(), tampered] {
|
|
2613
|
-
let response = set_member_role_route(
|
|
3871
|
+
let response = set_member_role_route(
|
|
3872
|
+
&domain_ir,
|
|
3873
|
+
&body,
|
|
3874
|
+
&cookies,
|
|
3875
|
+
secret,
|
|
3876
|
+
&client,
|
|
3877
|
+
&members_config(),
|
|
3878
|
+
)
|
|
3879
|
+
.await;
|
|
2614
3880
|
assert_eq!(response["statusCode"], 401, "{response:?}");
|
|
2615
|
-
assert!(
|
|
3881
|
+
assert!(
|
|
3882
|
+
response
|
|
3883
|
+
.get("headers")
|
|
3884
|
+
.and_then(|h| h.get("location"))
|
|
3885
|
+
.is_none(),
|
|
3886
|
+
"never a redirect: {response:?}"
|
|
3887
|
+
);
|
|
2616
3888
|
}
|
|
2617
3889
|
for blank in ["", " "] {
|
|
2618
3890
|
let response = set_role(&client, &domain_ir, "zed@example.com", blank, "Owner").await;
|
|
2619
3891
|
assert_eq!(response["statusCode"], 400, "{blank:?}: {response:?}");
|
|
2620
3892
|
}
|
|
2621
3893
|
for role in ["", "Root", "owner", "Owner Admin"] {
|
|
2622
|
-
let response = set_role(
|
|
3894
|
+
let response = set_role(
|
|
3895
|
+
&client,
|
|
3896
|
+
&domain_ir,
|
|
3897
|
+
"zed@example.com",
|
|
3898
|
+
"amy@example.com",
|
|
3899
|
+
role,
|
|
3900
|
+
)
|
|
3901
|
+
.await;
|
|
2623
3902
|
assert_eq!(response["statusCode"], 400, "{role:?}: {response:?}");
|
|
2624
|
-
assert_eq!(
|
|
3903
|
+
assert_eq!(
|
|
3904
|
+
error_of(&response),
|
|
3905
|
+
"role must be one of Admin, Owner, Member"
|
|
3906
|
+
);
|
|
2625
3907
|
}
|
|
2626
|
-
assert_eq!(
|
|
3908
|
+
assert_eq!(
|
|
3909
|
+
person(&client, &domain_ir, "amy@example.com").await["role"],
|
|
3910
|
+
Value::Null,
|
|
3911
|
+
"nothing was written"
|
|
3912
|
+
);
|
|
2627
3913
|
assert_eq!(journal_rows(&client, "amy@example.com").await, before);
|
|
2628
3914
|
}
|
|
2629
3915
|
|
|
2630
3916
|
#[tokio::test]
|
|
2631
|
-
async fn role_route_refuses_a_caller_who_is_not_an_active_admin_with_a_403_and_answers_404_for_an_unknown_email(
|
|
3917
|
+
async fn role_route_refuses_a_caller_who_is_not_an_active_admin_with_a_403_and_answers_404_for_an_unknown_email(
|
|
3918
|
+
) {
|
|
2632
3919
|
let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_403").await;
|
|
2633
3920
|
|
|
2634
3921
|
// amy is admitted with no role; the stranger was never admitted.
|
|
@@ -2639,43 +3926,120 @@ mod tests {
|
|
|
2639
3926
|
}
|
|
2640
3927
|
|
|
2641
3928
|
// A plain Member cannot promote themselves either.
|
|
2642
|
-
assert!(auth::grant_access(
|
|
2643
|
-
|
|
3929
|
+
assert!(auth::grant_access(
|
|
3930
|
+
&client,
|
|
3931
|
+
Path::new("unused"),
|
|
3932
|
+
&members_config(),
|
|
3933
|
+
&domain_ir,
|
|
3934
|
+
"amy@example.com",
|
|
3935
|
+
"Member"
|
|
3936
|
+
)
|
|
3937
|
+
.await
|
|
3938
|
+
.unwrap());
|
|
3939
|
+
let response = set_role(
|
|
3940
|
+
&client,
|
|
3941
|
+
&domain_ir,
|
|
3942
|
+
"amy@example.com",
|
|
3943
|
+
"amy@example.com",
|
|
3944
|
+
"Owner",
|
|
3945
|
+
)
|
|
3946
|
+
.await;
|
|
2644
3947
|
assert_eq!(response["statusCode"], 403, "{response:?}");
|
|
2645
|
-
assert_eq!(
|
|
3948
|
+
assert_eq!(
|
|
3949
|
+
person(&client, &domain_ir, "amy@example.com").await["role"],
|
|
3950
|
+
"Member",
|
|
3951
|
+
"nothing was written"
|
|
3952
|
+
);
|
|
2646
3953
|
|
|
2647
|
-
let response = set_role(
|
|
3954
|
+
let response = set_role(
|
|
3955
|
+
&client,
|
|
3956
|
+
&domain_ir,
|
|
3957
|
+
"zed@example.com",
|
|
3958
|
+
"nobody@example.com",
|
|
3959
|
+
"Owner",
|
|
3960
|
+
)
|
|
3961
|
+
.await;
|
|
2648
3962
|
assert_eq!(response["statusCode"], 404, "{response:?}");
|
|
2649
3963
|
}
|
|
2650
3964
|
|
|
2651
3965
|
#[tokio::test]
|
|
2652
|
-
async fn role_route_lets_an_admin_grant_owner_to_an_already_admitted_person_and_repeating_it_writes_nothing(
|
|
3966
|
+
async fn role_route_lets_an_admin_grant_owner_to_an_already_admitted_person_and_repeating_it_writes_nothing(
|
|
3967
|
+
) {
|
|
2653
3968
|
let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_grant").await;
|
|
2654
3969
|
let status = |response: &Value| response["statusCode"].as_i64().unwrap();
|
|
2655
3970
|
|
|
2656
3971
|
// amy is admitted but holds no role, so she is not an admin yet.
|
|
2657
|
-
assert!(
|
|
3972
|
+
assert!(
|
|
3973
|
+
!auth::caller_is_admin(&client, &domain_ir, "amy@example.com")
|
|
3974
|
+
.await
|
|
3975
|
+
.unwrap()
|
|
3976
|
+
);
|
|
2658
3977
|
|
|
2659
3978
|
// Case and surrounding spaces in the email do not matter.
|
|
2660
|
-
let response = set_role(
|
|
3979
|
+
let response = set_role(
|
|
3980
|
+
&client,
|
|
3981
|
+
&domain_ir,
|
|
3982
|
+
"Zed@Example.com",
|
|
3983
|
+
" Amy@Example.com ",
|
|
3984
|
+
"Owner",
|
|
3985
|
+
)
|
|
3986
|
+
.await;
|
|
2661
3987
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
2662
3988
|
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
2663
3989
|
assert_eq!(body, json!({"email": "amy@example.com", "role": "Owner"}));
|
|
2664
3990
|
|
|
2665
3991
|
// She now passes the admin gate and the Owner check payments makes.
|
|
2666
|
-
assert!(
|
|
2667
|
-
|
|
3992
|
+
assert!(
|
|
3993
|
+
auth::caller_is_admin(&client, &domain_ir, "amy@example.com")
|
|
3994
|
+
.await
|
|
3995
|
+
.unwrap()
|
|
3996
|
+
);
|
|
3997
|
+
assert_eq!(
|
|
3998
|
+
auth::active_role(&client, &domain_ir, "amy@example.com")
|
|
3999
|
+
.await
|
|
4000
|
+
.unwrap()
|
|
4001
|
+
.as_deref(),
|
|
4002
|
+
Some("Owner")
|
|
4003
|
+
);
|
|
2668
4004
|
let amy = person(&client, &domain_ir, "amy@example.com").await;
|
|
2669
|
-
assert_eq!(
|
|
4005
|
+
assert_eq!(
|
|
4006
|
+
(amy["role"].as_str(), amy["granted"].clone()),
|
|
4007
|
+
(Some("Owner"), json!(true))
|
|
4008
|
+
);
|
|
2670
4009
|
|
|
2671
4010
|
// The new Owner can act as an admin, including granting Owner in turn.
|
|
2672
|
-
let response = set_role(
|
|
4011
|
+
let response = set_role(
|
|
4012
|
+
&client,
|
|
4013
|
+
&domain_ir,
|
|
4014
|
+
"amy@example.com",
|
|
4015
|
+
"zed@example.com",
|
|
4016
|
+
"Owner",
|
|
4017
|
+
)
|
|
4018
|
+
.await;
|
|
2673
4019
|
assert_eq!(status(&response), 200, "{response:?}");
|
|
2674
|
-
assert_eq!(
|
|
4020
|
+
assert_eq!(
|
|
4021
|
+
auth::active_role(&client, &domain_ir, "zed@example.com")
|
|
4022
|
+
.await
|
|
4023
|
+
.unwrap()
|
|
4024
|
+
.as_deref(),
|
|
4025
|
+
Some("Owner")
|
|
4026
|
+
);
|
|
2675
4027
|
|
|
2676
4028
|
// Asking for the role someone already holds is a 200 and writes nothing.
|
|
2677
4029
|
let rows = journal_rows(&client, "amy@example.com").await;
|
|
2678
|
-
assert_eq!(
|
|
4030
|
+
assert_eq!(
|
|
4031
|
+
status(
|
|
4032
|
+
&set_role(
|
|
4033
|
+
&client,
|
|
4034
|
+
&domain_ir,
|
|
4035
|
+
"zed@example.com",
|
|
4036
|
+
"amy@example.com",
|
|
4037
|
+
"Owner"
|
|
4038
|
+
)
|
|
4039
|
+
.await
|
|
4040
|
+
),
|
|
4041
|
+
200
|
|
4042
|
+
);
|
|
2679
4043
|
assert_eq!(journal_rows(&client, "amy@example.com").await, rows);
|
|
2680
4044
|
}
|
|
2681
4045
|
|
|
@@ -2684,19 +4048,85 @@ mod tests {
|
|
|
2684
4048
|
let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_role_keeps").await;
|
|
2685
4049
|
|
|
2686
4050
|
// zed is linked (id-1 in the seed); amy is disabled while holding Admin.
|
|
2687
|
-
assert_eq!(
|
|
2688
|
-
|
|
2689
|
-
|
|
4051
|
+
assert_eq!(
|
|
4052
|
+
switch_access(
|
|
4053
|
+
&client,
|
|
4054
|
+
&domain_ir,
|
|
4055
|
+
"zed@example.com",
|
|
4056
|
+
"amy@example.com",
|
|
4057
|
+
true
|
|
4058
|
+
)
|
|
4059
|
+
.await["statusCode"],
|
|
4060
|
+
200
|
|
4061
|
+
);
|
|
4062
|
+
assert_eq!(
|
|
4063
|
+
set_role(
|
|
4064
|
+
&client,
|
|
4065
|
+
&domain_ir,
|
|
4066
|
+
"zed@example.com",
|
|
4067
|
+
"amy@example.com",
|
|
4068
|
+
"Owner"
|
|
4069
|
+
)
|
|
4070
|
+
.await["statusCode"],
|
|
4071
|
+
200
|
|
4072
|
+
);
|
|
4073
|
+
assert_eq!(
|
|
4074
|
+
set_role(
|
|
4075
|
+
&client,
|
|
4076
|
+
&domain_ir,
|
|
4077
|
+
"zed@example.com",
|
|
4078
|
+
"zed@example.com",
|
|
4079
|
+
"Owner"
|
|
4080
|
+
)
|
|
4081
|
+
.await["statusCode"],
|
|
4082
|
+
200
|
|
4083
|
+
);
|
|
2690
4084
|
|
|
2691
4085
|
let amy = person(&client, &domain_ir, "amy@example.com").await;
|
|
2692
|
-
assert_eq!(
|
|
4086
|
+
assert_eq!(
|
|
4087
|
+
(
|
|
4088
|
+
amy["role"].as_str(),
|
|
4089
|
+
amy["disabled"].clone(),
|
|
4090
|
+
amy["granted"].clone()
|
|
4091
|
+
),
|
|
4092
|
+
(Some("Owner"), json!(true), json!(false))
|
|
4093
|
+
);
|
|
2693
4094
|
let zed = person(&client, &domain_ir, "zed@example.com").await;
|
|
2694
|
-
assert_eq!(
|
|
2695
|
-
|
|
4095
|
+
assert_eq!(
|
|
4096
|
+
(
|
|
4097
|
+
zed["role"].as_str(),
|
|
4098
|
+
zed["linked"].clone(),
|
|
4099
|
+
zed["name"].as_str()
|
|
4100
|
+
),
|
|
4101
|
+
(Some("Owner"), json!(true), Some("Zed"))
|
|
4102
|
+
);
|
|
4103
|
+
assert!(
|
|
4104
|
+
auth::session_for_member_by_identity(&client, &domain_ir, "id-1")
|
|
4105
|
+
.await
|
|
4106
|
+
.unwrap()
|
|
4107
|
+
.is_some(),
|
|
4108
|
+
"zed can still sign in"
|
|
4109
|
+
);
|
|
2696
4110
|
|
|
2697
4111
|
// Enabling amy restores her, now as an Owner.
|
|
2698
|
-
assert_eq!(
|
|
2699
|
-
|
|
4112
|
+
assert_eq!(
|
|
4113
|
+
switch_access(
|
|
4114
|
+
&client,
|
|
4115
|
+
&domain_ir,
|
|
4116
|
+
"zed@example.com",
|
|
4117
|
+
"amy@example.com",
|
|
4118
|
+
false
|
|
4119
|
+
)
|
|
4120
|
+
.await["statusCode"],
|
|
4121
|
+
200
|
|
4122
|
+
);
|
|
4123
|
+
assert_eq!(
|
|
4124
|
+
auth::active_role(&client, &domain_ir, "amy@example.com")
|
|
4125
|
+
.await
|
|
4126
|
+
.unwrap()
|
|
4127
|
+
.as_deref(),
|
|
4128
|
+
Some("Owner")
|
|
4129
|
+
);
|
|
2700
4130
|
}
|
|
2701
4131
|
|
|
2702
4132
|
#[tokio::test]
|
|
@@ -2704,18 +4134,75 @@ mod tests {
|
|
|
2704
4134
|
let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_last_admin").await;
|
|
2705
4135
|
|
|
2706
4136
|
// zed is the only active admin: demoting themselves would lock everyone out.
|
|
2707
|
-
let response = set_role(
|
|
4137
|
+
let response = set_role(
|
|
4138
|
+
&client,
|
|
4139
|
+
&domain_ir,
|
|
4140
|
+
"zed@example.com",
|
|
4141
|
+
"zed@example.com",
|
|
4142
|
+
"Member",
|
|
4143
|
+
)
|
|
4144
|
+
.await;
|
|
2708
4145
|
assert_eq!(response["statusCode"], 409, "{response:?}");
|
|
2709
|
-
assert_eq!(
|
|
2710
|
-
|
|
4146
|
+
assert_eq!(
|
|
4147
|
+
error_of(&response),
|
|
4148
|
+
"there must always be at least one admin"
|
|
4149
|
+
);
|
|
4150
|
+
assert_eq!(
|
|
4151
|
+
person(&client, &domain_ir, "zed@example.com").await["role"],
|
|
4152
|
+
"Admin",
|
|
4153
|
+
"nothing was written"
|
|
4154
|
+
);
|
|
2711
4155
|
// Moving between admin roles never strands anyone.
|
|
2712
|
-
assert_eq!(
|
|
4156
|
+
assert_eq!(
|
|
4157
|
+
set_role(
|
|
4158
|
+
&client,
|
|
4159
|
+
&domain_ir,
|
|
4160
|
+
"zed@example.com",
|
|
4161
|
+
"zed@example.com",
|
|
4162
|
+
"Owner"
|
|
4163
|
+
)
|
|
4164
|
+
.await["statusCode"],
|
|
4165
|
+
200
|
|
4166
|
+
);
|
|
2713
4167
|
|
|
2714
4168
|
// With a second admin, one may demote the other, and then the last one is protected again.
|
|
2715
|
-
assert_eq!(
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
4169
|
+
assert_eq!(
|
|
4170
|
+
set_role(
|
|
4171
|
+
&client,
|
|
4172
|
+
&domain_ir,
|
|
4173
|
+
"zed@example.com",
|
|
4174
|
+
"amy@example.com",
|
|
4175
|
+
"Admin"
|
|
4176
|
+
)
|
|
4177
|
+
.await["statusCode"],
|
|
4178
|
+
200
|
|
4179
|
+
);
|
|
4180
|
+
assert_eq!(
|
|
4181
|
+
set_role(
|
|
4182
|
+
&client,
|
|
4183
|
+
&domain_ir,
|
|
4184
|
+
"zed@example.com",
|
|
4185
|
+
"amy@example.com",
|
|
4186
|
+
"Member"
|
|
4187
|
+
)
|
|
4188
|
+
.await["statusCode"],
|
|
4189
|
+
200
|
|
4190
|
+
);
|
|
4191
|
+
assert_eq!(
|
|
4192
|
+
set_role(
|
|
4193
|
+
&client,
|
|
4194
|
+
&domain_ir,
|
|
4195
|
+
"zed@example.com",
|
|
4196
|
+
"zed@example.com",
|
|
4197
|
+
"Member"
|
|
4198
|
+
)
|
|
4199
|
+
.await["statusCode"],
|
|
4200
|
+
409
|
|
4201
|
+
);
|
|
4202
|
+
assert_eq!(
|
|
4203
|
+
person(&client, &domain_ir, "zed@example.com").await["role"],
|
|
4204
|
+
"Owner"
|
|
4205
|
+
);
|
|
2719
4206
|
}
|
|
2720
4207
|
|
|
2721
4208
|
#[tokio::test]
|
|
@@ -2724,9 +4211,19 @@ mod tests {
|
|
|
2724
4211
|
let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_enable").await;
|
|
2725
4212
|
let amy_cookies = session_cookies(secret, "amy@example.com");
|
|
2726
4213
|
let status = |response: &Value| response["statusCode"].as_i64().unwrap();
|
|
2727
|
-
assert_eq!(
|
|
4214
|
+
assert_eq!(
|
|
4215
|
+
status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await),
|
|
4216
|
+
200
|
|
4217
|
+
);
|
|
2728
4218
|
|
|
2729
|
-
let response = switch_access(
|
|
4219
|
+
let response = switch_access(
|
|
4220
|
+
&client,
|
|
4221
|
+
&domain_ir,
|
|
4222
|
+
"Zed@Example.com",
|
|
4223
|
+
" Amy@Example.com ",
|
|
4224
|
+
true,
|
|
4225
|
+
)
|
|
4226
|
+
.await;
|
|
2730
4227
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
2731
4228
|
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
2732
4229
|
assert_eq!(body, json!({"disabled": "amy@example.com"}));
|
|
@@ -2738,30 +4235,105 @@ mod tests {
|
|
|
2738
4235
|
assert_eq!(amy["disabled"], true);
|
|
2739
4236
|
|
|
2740
4237
|
// amy's cookie is still validly signed and unexpired, yet stops working now.
|
|
2741
|
-
assert_eq!(
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
4238
|
+
assert_eq!(
|
|
4239
|
+
status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await),
|
|
4240
|
+
401
|
|
4241
|
+
);
|
|
4242
|
+
assert_eq!(
|
|
4243
|
+
status(&accounts_sso_token_route(&domain_ir, &amy_cookies, secret, &client).await),
|
|
4244
|
+
401
|
|
4245
|
+
);
|
|
4246
|
+
assert_eq!(
|
|
4247
|
+
status(&members_route(&domain_ir, &amy_cookies, secret, &client).await),
|
|
4248
|
+
401
|
|
4249
|
+
);
|
|
4250
|
+
let add = add_member_route(
|
|
4251
|
+
&domain_ir,
|
|
4252
|
+
r#"{"email":"x@example.com","name":"X"}"#,
|
|
4253
|
+
&amy_cookies,
|
|
4254
|
+
secret,
|
|
4255
|
+
&client,
|
|
4256
|
+
Path::new("unused"),
|
|
4257
|
+
&members_config(),
|
|
4258
|
+
)
|
|
4259
|
+
.await;
|
|
2745
4260
|
assert_eq!(status(&add), 403);
|
|
2746
|
-
assert_eq!(
|
|
2747
|
-
|
|
4261
|
+
assert_eq!(
|
|
4262
|
+
status(
|
|
4263
|
+
&switch_access(
|
|
4264
|
+
&client,
|
|
4265
|
+
&domain_ir,
|
|
4266
|
+
"amy@example.com",
|
|
4267
|
+
"zed@example.com",
|
|
4268
|
+
true
|
|
4269
|
+
)
|
|
4270
|
+
.await
|
|
4271
|
+
),
|
|
4272
|
+
403
|
|
4273
|
+
);
|
|
4274
|
+
assert_eq!(
|
|
4275
|
+
person(&client, &domain_ir, "zed@example.com").await["granted"],
|
|
4276
|
+
true,
|
|
4277
|
+
"a disabled caller changed nothing"
|
|
4278
|
+
);
|
|
2748
4279
|
|
|
2749
4280
|
// Disabling again is a 200 and writes nothing.
|
|
2750
4281
|
let rows = journal_rows(&client, "amy@example.com").await;
|
|
2751
|
-
assert_eq!(
|
|
4282
|
+
assert_eq!(
|
|
4283
|
+
status(
|
|
4284
|
+
&switch_access(
|
|
4285
|
+
&client,
|
|
4286
|
+
&domain_ir,
|
|
4287
|
+
"zed@example.com",
|
|
4288
|
+
"amy@example.com",
|
|
4289
|
+
true
|
|
4290
|
+
)
|
|
4291
|
+
.await
|
|
4292
|
+
),
|
|
4293
|
+
200
|
|
4294
|
+
);
|
|
2752
4295
|
assert_eq!(journal_rows(&client, "amy@example.com").await, rows);
|
|
2753
4296
|
|
|
2754
4297
|
// Enabling restores exactly the earlier access, and is idempotent too.
|
|
2755
|
-
let response = switch_access(
|
|
4298
|
+
let response = switch_access(
|
|
4299
|
+
&client,
|
|
4300
|
+
&domain_ir,
|
|
4301
|
+
"zed@example.com",
|
|
4302
|
+
"amy@example.com",
|
|
4303
|
+
false,
|
|
4304
|
+
)
|
|
4305
|
+
.await;
|
|
2756
4306
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
2757
4307
|
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
2758
4308
|
assert_eq!(body, json!({"enabled": "amy@example.com"}));
|
|
2759
4309
|
let amy = person(&client, &domain_ir, "amy@example.com").await;
|
|
2760
|
-
assert_eq!(
|
|
2761
|
-
|
|
4310
|
+
assert_eq!(
|
|
4311
|
+
(
|
|
4312
|
+
amy["role"].as_str(),
|
|
4313
|
+
amy["granted"].clone(),
|
|
4314
|
+
amy["disabled"].clone()
|
|
4315
|
+
),
|
|
4316
|
+
(Some("Admin"), json!(true), json!(false))
|
|
4317
|
+
);
|
|
4318
|
+
assert_eq!(
|
|
4319
|
+
status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await),
|
|
4320
|
+
200
|
|
4321
|
+
);
|
|
2762
4322
|
|
|
2763
4323
|
let rows = journal_rows(&client, "amy@example.com").await;
|
|
2764
|
-
assert_eq!(
|
|
4324
|
+
assert_eq!(
|
|
4325
|
+
status(
|
|
4326
|
+
&switch_access(
|
|
4327
|
+
&client,
|
|
4328
|
+
&domain_ir,
|
|
4329
|
+
"zed@example.com",
|
|
4330
|
+
"amy@example.com",
|
|
4331
|
+
false
|
|
4332
|
+
)
|
|
4333
|
+
.await
|
|
4334
|
+
),
|
|
4335
|
+
200
|
|
4336
|
+
);
|
|
2765
4337
|
assert_eq!(journal_rows(&client, "amy@example.com").await, rows);
|
|
2766
4338
|
}
|
|
2767
4339
|
|
|
@@ -2770,12 +4342,49 @@ mod tests {
|
|
|
2770
4342
|
let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_signin").await;
|
|
2771
4343
|
|
|
2772
4344
|
// zed holds identity id-1 in the seed; a fresh Google sign-in resolves through it.
|
|
2773
|
-
assert!(
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
4345
|
+
assert!(
|
|
4346
|
+
auth::session_for_member_by_identity(&client, &domain_ir, "id-1")
|
|
4347
|
+
.await
|
|
4348
|
+
.unwrap()
|
|
4349
|
+
.is_some()
|
|
4350
|
+
);
|
|
4351
|
+
let disabled = switch_access(
|
|
4352
|
+
&client,
|
|
4353
|
+
&domain_ir,
|
|
4354
|
+
"amy@example.com",
|
|
4355
|
+
"zed@example.com",
|
|
4356
|
+
true,
|
|
4357
|
+
)
|
|
4358
|
+
.await;
|
|
4359
|
+
assert_eq!(
|
|
4360
|
+
disabled["statusCode"],
|
|
4361
|
+
200,
|
|
4362
|
+
"{disabled:?} amy={:?}",
|
|
4363
|
+
person(&client, &domain_ir, "amy@example.com").await
|
|
4364
|
+
);
|
|
4365
|
+
assert!(
|
|
4366
|
+
auth::session_for_member_by_identity(&client, &domain_ir, "id-1")
|
|
4367
|
+
.await
|
|
4368
|
+
.unwrap()
|
|
4369
|
+
.is_none()
|
|
4370
|
+
);
|
|
4371
|
+
assert_eq!(
|
|
4372
|
+
switch_access(
|
|
4373
|
+
&client,
|
|
4374
|
+
&domain_ir,
|
|
4375
|
+
"amy@example.com",
|
|
4376
|
+
"zed@example.com",
|
|
4377
|
+
false
|
|
4378
|
+
)
|
|
4379
|
+
.await["statusCode"],
|
|
4380
|
+
200
|
|
4381
|
+
);
|
|
4382
|
+
assert!(
|
|
4383
|
+
auth::session_for_member_by_identity(&client, &domain_ir, "id-1")
|
|
4384
|
+
.await
|
|
4385
|
+
.unwrap()
|
|
4386
|
+
.is_some()
|
|
4387
|
+
);
|
|
2779
4388
|
}
|
|
2780
4389
|
|
|
2781
4390
|
#[tokio::test]
|
|
@@ -2784,78 +4393,206 @@ mod tests {
|
|
|
2784
4393
|
let config = members_config();
|
|
2785
4394
|
|
|
2786
4395
|
let (a, b) = tokio::join!(
|
|
2787
|
-
auth::set_person_disabled(
|
|
2788
|
-
|
|
4396
|
+
auth::set_person_disabled(
|
|
4397
|
+
&client,
|
|
4398
|
+
&config,
|
|
4399
|
+
&domain_ir,
|
|
4400
|
+
"zed@example.com",
|
|
4401
|
+
"amy@example.com",
|
|
4402
|
+
true
|
|
4403
|
+
),
|
|
4404
|
+
auth::set_person_disabled(
|
|
4405
|
+
&client,
|
|
4406
|
+
&config,
|
|
4407
|
+
&domain_ir,
|
|
4408
|
+
"amy@example.com",
|
|
4409
|
+
"zed@example.com",
|
|
4410
|
+
true
|
|
4411
|
+
),
|
|
2789
4412
|
);
|
|
2790
4413
|
let mut outcomes = [a.unwrap(), b.unwrap()];
|
|
2791
4414
|
outcomes.sort_by_key(|o| format!("{o:?}"));
|
|
2792
|
-
assert_eq!(
|
|
4415
|
+
assert_eq!(
|
|
4416
|
+
outcomes,
|
|
4417
|
+
[
|
|
4418
|
+
auth::DisableOutcome::CallerNotAdmin,
|
|
4419
|
+
auth::DisableOutcome::Done
|
|
4420
|
+
]
|
|
4421
|
+
);
|
|
2793
4422
|
|
|
2794
|
-
let active = auth::all_people(&client, &domain_ir)
|
|
4423
|
+
let active = auth::all_people(&client, &domain_ir)
|
|
4424
|
+
.await
|
|
4425
|
+
.unwrap()
|
|
4426
|
+
.iter()
|
|
4427
|
+
.filter(|p| p["role"] == "Admin" && p["granted"] == true)
|
|
4428
|
+
.count();
|
|
2795
4429
|
assert_eq!(active, 1, "there must always be one active admin");
|
|
2796
4430
|
}
|
|
2797
4431
|
|
|
2798
4432
|
#[tokio::test]
|
|
2799
|
-
async fn registrations_list_route_refuses_anyone_but_an_active_admin_before_reading_any_registration(
|
|
4433
|
+
async fn registrations_list_route_refuses_anyone_but_an_active_admin_before_reading_any_registration(
|
|
4434
|
+
) {
|
|
2800
4435
|
let secret = "s3cret";
|
|
2801
|
-
let (client, domain_ir) =
|
|
2802
|
-
|
|
4436
|
+
let (client, domain_ir) =
|
|
4437
|
+
scratch_two_admins("hecks_host_web_test_registrations_list_auth").await;
|
|
4438
|
+
let config = LineageConfig {
|
|
4439
|
+
domain: "Studio".to_string(),
|
|
4440
|
+
era: Some(1),
|
|
4441
|
+
mirrored: None,
|
|
4442
|
+
};
|
|
2803
4443
|
// The wasm path doesn't exist: every refusal below must come before any registration read.
|
|
2804
4444
|
let wasm_path = Path::new("does-not-exist.wasm");
|
|
2805
4445
|
|
|
2806
4446
|
let mut tampered = HashMap::new();
|
|
2807
|
-
tampered.insert(
|
|
4447
|
+
tampered.insert(
|
|
4448
|
+
auth::account_cookie_name(),
|
|
4449
|
+
"garbage.notasignature".to_string(),
|
|
4450
|
+
);
|
|
2808
4451
|
for cookies in [HashMap::new(), tampered] {
|
|
2809
|
-
let response =
|
|
4452
|
+
let response =
|
|
4453
|
+
registrations_list_route(&domain_ir, &cookies, secret, &client, wasm_path, &config)
|
|
4454
|
+
.await;
|
|
2810
4455
|
assert_eq!(response["statusCode"], 401, "{response:?}");
|
|
2811
|
-
assert!(
|
|
4456
|
+
assert!(
|
|
4457
|
+
response
|
|
4458
|
+
.get("headers")
|
|
4459
|
+
.and_then(|h| h.get("location"))
|
|
4460
|
+
.is_none(),
|
|
4461
|
+
"never a redirect: {response:?}"
|
|
4462
|
+
);
|
|
2812
4463
|
}
|
|
2813
4464
|
|
|
2814
4465
|
// amy is a disabled admin now; the stranger was never admitted at all.
|
|
2815
|
-
assert_eq!(
|
|
4466
|
+
assert_eq!(
|
|
4467
|
+
switch_access(
|
|
4468
|
+
&client,
|
|
4469
|
+
&domain_ir,
|
|
4470
|
+
"zed@example.com",
|
|
4471
|
+
"amy@example.com",
|
|
4472
|
+
true
|
|
4473
|
+
)
|
|
4474
|
+
.await["statusCode"],
|
|
4475
|
+
200
|
|
4476
|
+
);
|
|
2816
4477
|
for caller in ["amy@example.com", "stranger@example.com"] {
|
|
2817
|
-
let response = registrations_list_route(
|
|
4478
|
+
let response = registrations_list_route(
|
|
4479
|
+
&domain_ir,
|
|
4480
|
+
&session_cookies(secret, caller),
|
|
4481
|
+
secret,
|
|
4482
|
+
&client,
|
|
4483
|
+
wasm_path,
|
|
4484
|
+
&config,
|
|
4485
|
+
)
|
|
4486
|
+
.await;
|
|
2818
4487
|
assert_eq!(response["statusCode"], 403, "{caller}: {response:?}");
|
|
2819
4488
|
assert_eq!(error_of(&response), "admins only");
|
|
2820
4489
|
}
|
|
2821
4490
|
}
|
|
2822
4491
|
|
|
4492
|
+
#[allow(clippy::unused_async)] // reason: call sites chain it straight after the async route calls
|
|
2823
4493
|
async fn status_of(response: Value) -> u64 {
|
|
2824
4494
|
response["statusCode"].as_u64().unwrap()
|
|
2825
4495
|
}
|
|
2826
4496
|
|
|
2827
4497
|
#[tokio::test]
|
|
2828
|
-
async fn every_admin_gate_accepts_an_owner_as_well_as_an_admin_and_refuses_a_member_and_anonymous(
|
|
4498
|
+
async fn every_admin_gate_accepts_an_owner_as_well_as_an_admin_and_refuses_a_member_and_anonymous(
|
|
4499
|
+
) {
|
|
2829
4500
|
let secret = "s3cret";
|
|
2830
4501
|
let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_owner_gates").await;
|
|
2831
4502
|
let config = members_config();
|
|
2832
4503
|
// amy becomes Owner (replacing her Admin role), bob is a plain Member.
|
|
2833
|
-
assert!(auth::grant_access(
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
4504
|
+
assert!(auth::grant_access(
|
|
4505
|
+
&client,
|
|
4506
|
+
Path::new("unused"),
|
|
4507
|
+
&config,
|
|
4508
|
+
&domain_ir,
|
|
4509
|
+
"amy@example.com",
|
|
4510
|
+
"Owner"
|
|
4511
|
+
)
|
|
4512
|
+
.await
|
|
4513
|
+
.unwrap());
|
|
4514
|
+
assert!(
|
|
4515
|
+
auth::admit_person(&client, &config, &domain_ir, "bob@example.com", "Bob")
|
|
4516
|
+
.await
|
|
4517
|
+
.unwrap()
|
|
4518
|
+
);
|
|
4519
|
+
assert!(auth::grant_access(
|
|
4520
|
+
&client,
|
|
4521
|
+
Path::new("unused"),
|
|
4522
|
+
&config,
|
|
4523
|
+
&domain_ir,
|
|
4524
|
+
"bob@example.com",
|
|
4525
|
+
"Member"
|
|
4526
|
+
)
|
|
4527
|
+
.await
|
|
4528
|
+
.unwrap());
|
|
4529
|
+
let studio = LineageConfig {
|
|
4530
|
+
domain: "Studio".to_string(),
|
|
4531
|
+
era: Some(1),
|
|
4532
|
+
mirrored: None,
|
|
4533
|
+
};
|
|
2837
4534
|
// No wasm at all: clearing the admin gate then fails with a 500, never 401/403.
|
|
2838
4535
|
let missing_wasm = Path::new("does-not-exist.wasm");
|
|
2839
4536
|
|
|
2840
4537
|
// Every gate, as one closure per route: list, add, disable, enable, registrations.
|
|
2841
|
-
for (caller, admits) in [
|
|
4538
|
+
for (caller, admits) in [
|
|
4539
|
+
("amy@example.com", true),
|
|
4540
|
+
("zed@example.com", true),
|
|
4541
|
+
("bob@example.com", false),
|
|
4542
|
+
("stranger@example.com", false),
|
|
4543
|
+
] {
|
|
2842
4544
|
let cookies = session_cookies(secret, caller);
|
|
2843
|
-
let add = add_member_route(
|
|
4545
|
+
let add = add_member_route(
|
|
4546
|
+
&domain_ir,
|
|
4547
|
+
&json!({"email": "not an email", "name": "N"}).to_string(),
|
|
4548
|
+
&cookies,
|
|
4549
|
+
secret,
|
|
4550
|
+
&client,
|
|
4551
|
+
Path::new("unused"),
|
|
4552
|
+
&config,
|
|
4553
|
+
)
|
|
4554
|
+
.await;
|
|
2844
4555
|
let add = status_of(add).await;
|
|
2845
|
-
let registrations = status_of(
|
|
2846
|
-
|
|
2847
|
-
|
|
4556
|
+
let registrations = status_of(
|
|
4557
|
+
registrations_list_route(
|
|
4558
|
+
&domain_ir,
|
|
4559
|
+
&cookies,
|
|
4560
|
+
secret,
|
|
4561
|
+
&client,
|
|
4562
|
+
missing_wasm,
|
|
4563
|
+
&studio,
|
|
4564
|
+
)
|
|
4565
|
+
.await,
|
|
4566
|
+
)
|
|
4567
|
+
.await;
|
|
4568
|
+
let disable = status_of(
|
|
4569
|
+
switch_access(&client, &domain_ir, caller, "bob@example.com", true).await,
|
|
4570
|
+
)
|
|
4571
|
+
.await;
|
|
4572
|
+
let enable = status_of(
|
|
4573
|
+
switch_access(&client, &domain_ir, caller, "bob@example.com", false).await,
|
|
4574
|
+
)
|
|
4575
|
+
.await;
|
|
2848
4576
|
if admits {
|
|
2849
4577
|
// The email is deliberately malformed: the gate passed, validation refused it.
|
|
2850
4578
|
assert_eq!(add, 400, "{caller} passes the add gate");
|
|
2851
4579
|
assert_eq!(registrations, 500, "{caller} passes the registrations gate");
|
|
2852
|
-
assert_eq!(
|
|
4580
|
+
assert_eq!(
|
|
4581
|
+
(disable, enable),
|
|
4582
|
+
(200, 200),
|
|
4583
|
+
"{caller} passes disable and enable"
|
|
4584
|
+
);
|
|
2853
4585
|
} else {
|
|
2854
|
-
assert_eq!(
|
|
4586
|
+
assert_eq!(
|
|
4587
|
+
(add, registrations, disable, enable),
|
|
4588
|
+
(403, 403, 403, 403),
|
|
4589
|
+
"{caller} is refused everywhere"
|
|
4590
|
+
);
|
|
2855
4591
|
}
|
|
2856
4592
|
// Listing is an admin gate too: it carries every person's email and role, so an
|
|
2857
4593
|
// Admin or Owner lists, a Member is refused, and a stranger has no session at all.
|
|
2858
|
-
let listing =
|
|
4594
|
+
let listing =
|
|
4595
|
+
status_of(members_route(&domain_ir, &cookies, secret, &client).await).await;
|
|
2859
4596
|
let expected = match caller {
|
|
2860
4597
|
"stranger@example.com" => 401,
|
|
2861
4598
|
_ if admits => 200,
|
|
@@ -2866,10 +4603,50 @@ mod tests {
|
|
|
2866
4603
|
|
|
2867
4604
|
// Anonymous: 401 on every route, before any role is looked at.
|
|
2868
4605
|
let none = HashMap::new();
|
|
2869
|
-
assert_eq!(
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
4606
|
+
assert_eq!(
|
|
4607
|
+
status_of(
|
|
4608
|
+
add_member_route(
|
|
4609
|
+
&domain_ir,
|
|
4610
|
+
"{}",
|
|
4611
|
+
&none,
|
|
4612
|
+
secret,
|
|
4613
|
+
&client,
|
|
4614
|
+
Path::new("unused"),
|
|
4615
|
+
&config
|
|
4616
|
+
)
|
|
4617
|
+
.await
|
|
4618
|
+
)
|
|
4619
|
+
.await,
|
|
4620
|
+
401
|
|
4621
|
+
);
|
|
4622
|
+
assert_eq!(
|
|
4623
|
+
status_of(members_route(&domain_ir, &none, secret, &client).await).await,
|
|
4624
|
+
401
|
|
4625
|
+
);
|
|
4626
|
+
assert_eq!(
|
|
4627
|
+
status_of(
|
|
4628
|
+
registrations_list_route(&domain_ir, &none, secret, &client, missing_wasm, &studio)
|
|
4629
|
+
.await
|
|
4630
|
+
)
|
|
4631
|
+
.await,
|
|
4632
|
+
401
|
|
4633
|
+
);
|
|
4634
|
+
assert_eq!(
|
|
4635
|
+
status_of(
|
|
4636
|
+
set_member_disabled_route(
|
|
4637
|
+
&domain_ir,
|
|
4638
|
+
r#"{"email":"bob@example.com"}"#,
|
|
4639
|
+
&none,
|
|
4640
|
+
secret,
|
|
4641
|
+
&client,
|
|
4642
|
+
&config,
|
|
4643
|
+
true
|
|
4644
|
+
)
|
|
4645
|
+
.await
|
|
4646
|
+
)
|
|
4647
|
+
.await,
|
|
4648
|
+
401
|
|
4649
|
+
);
|
|
2873
4650
|
}
|
|
2874
4651
|
|
|
2875
4652
|
#[tokio::test]
|
|
@@ -2879,38 +4656,107 @@ mod tests {
|
|
|
2879
4656
|
let config = members_config();
|
|
2880
4657
|
|
|
2881
4658
|
let body = r#"{"email": "boss@example.com", "name": "Boss", "role": "Owner"}"#;
|
|
2882
|
-
let response = add_member_route(
|
|
4659
|
+
let response = add_member_route(
|
|
4660
|
+
&domain_ir,
|
|
4661
|
+
body,
|
|
4662
|
+
&session_cookies(secret, "zed@example.com"),
|
|
4663
|
+
secret,
|
|
4664
|
+
&client,
|
|
4665
|
+
Path::new("unused"),
|
|
4666
|
+
&config,
|
|
4667
|
+
)
|
|
4668
|
+
.await;
|
|
2883
4669
|
assert_eq!(response["statusCode"], 201, "{response:?}");
|
|
2884
4670
|
let created: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
2885
4671
|
assert_eq!(created["role"], "Owner");
|
|
2886
|
-
assert_eq!(
|
|
4672
|
+
assert_eq!(
|
|
4673
|
+
person(&client, &domain_ir, "boss@example.com").await["role"],
|
|
4674
|
+
"Owner"
|
|
4675
|
+
);
|
|
2887
4676
|
|
|
2888
4677
|
// The Owner isn't locked out: lists, admits, disables and enables.
|
|
2889
4678
|
let boss = session_cookies(secret, "boss@example.com");
|
|
2890
|
-
assert_eq!(
|
|
2891
|
-
|
|
4679
|
+
assert_eq!(
|
|
4680
|
+
status_of(members_route(&domain_ir, &boss, secret, &client).await).await,
|
|
4681
|
+
200
|
|
4682
|
+
);
|
|
4683
|
+
for (email, role) in [
|
|
4684
|
+
("second@example.com", "Owner"),
|
|
4685
|
+
("plain@example.com", "Member"),
|
|
4686
|
+
] {
|
|
2892
4687
|
let body = json!({"email": email, "name": "X", "role": role}).to_string();
|
|
2893
|
-
let response = add_member_route(
|
|
4688
|
+
let response = add_member_route(
|
|
4689
|
+
&domain_ir,
|
|
4690
|
+
&body,
|
|
4691
|
+
&boss,
|
|
4692
|
+
secret,
|
|
4693
|
+
&client,
|
|
4694
|
+
Path::new("unused"),
|
|
4695
|
+
&config,
|
|
4696
|
+
)
|
|
4697
|
+
.await;
|
|
2894
4698
|
assert_eq!(response["statusCode"], 201, "{response:?}");
|
|
2895
4699
|
assert_eq!(person(&client, &domain_ir, email).await["role"], role);
|
|
2896
4700
|
}
|
|
2897
|
-
assert_eq!(
|
|
2898
|
-
|
|
4701
|
+
assert_eq!(
|
|
4702
|
+
switch_access(
|
|
4703
|
+
&client,
|
|
4704
|
+
&domain_ir,
|
|
4705
|
+
"boss@example.com",
|
|
4706
|
+
"zed@example.com",
|
|
4707
|
+
true
|
|
4708
|
+
)
|
|
4709
|
+
.await["statusCode"],
|
|
4710
|
+
200
|
|
4711
|
+
);
|
|
4712
|
+
assert_eq!(
|
|
4713
|
+
switch_access(
|
|
4714
|
+
&client,
|
|
4715
|
+
&domain_ir,
|
|
4716
|
+
"boss@example.com",
|
|
4717
|
+
"zed@example.com",
|
|
4718
|
+
false
|
|
4719
|
+
)
|
|
4720
|
+
.await["statusCode"],
|
|
4721
|
+
200
|
|
4722
|
+
);
|
|
2899
4723
|
|
|
2900
4724
|
// A person granted only Member cannot grant anything, Owner included.
|
|
2901
|
-
let response = add_member_route(
|
|
4725
|
+
let response = add_member_route(
|
|
4726
|
+
&domain_ir,
|
|
4727
|
+
r#"{"email": "x@example.com", "name": "X", "role": "Owner"}"#,
|
|
4728
|
+
&session_cookies(secret, "plain@example.com"),
|
|
4729
|
+
secret,
|
|
4730
|
+
&client,
|
|
4731
|
+
Path::new("unused"),
|
|
4732
|
+
&config,
|
|
4733
|
+
)
|
|
4734
|
+
.await;
|
|
2902
4735
|
assert_eq!(response["statusCode"], 403, "{response:?}");
|
|
2903
4736
|
}
|
|
2904
4737
|
|
|
2905
4738
|
#[tokio::test]
|
|
2906
4739
|
async fn the_add_route_refuses_an_unknown_role_and_writes_nothing() {
|
|
2907
4740
|
let secret = "s3cret";
|
|
2908
|
-
let (client, domain_ir) =
|
|
4741
|
+
let (client, domain_ir) =
|
|
4742
|
+
scratch_members_db("hecks_host_web_test_grant_unknown_role").await;
|
|
2909
4743
|
for role in [json!("Root"), json!("admin"), json!(""), json!(7)] {
|
|
2910
4744
|
let body = json!({"email": "new@example.com", "name": "New", "role": role}).to_string();
|
|
2911
|
-
let response = add_member_route(
|
|
4745
|
+
let response = add_member_route(
|
|
4746
|
+
&domain_ir,
|
|
4747
|
+
&body,
|
|
4748
|
+
&session_cookies(secret, "zed@example.com"),
|
|
4749
|
+
secret,
|
|
4750
|
+
&client,
|
|
4751
|
+
Path::new("unused"),
|
|
4752
|
+
&members_config(),
|
|
4753
|
+
)
|
|
4754
|
+
.await;
|
|
2912
4755
|
assert_eq!(response["statusCode"], 400, "{role}: {response:?}");
|
|
2913
|
-
assert_eq!(
|
|
4756
|
+
assert_eq!(
|
|
4757
|
+
error_of(&response),
|
|
4758
|
+
"role must be one of Admin, Owner, Member"
|
|
4759
|
+
);
|
|
2914
4760
|
}
|
|
2915
4761
|
assert_eq!(journal_rows(&client, "new@example.com").await, 0);
|
|
2916
4762
|
}
|
|
@@ -2918,12 +4764,17 @@ mod tests {
|
|
|
2918
4764
|
#[test]
|
|
2919
4765
|
fn the_admin_gate_role_set_is_exactly_admin_and_owner() {
|
|
2920
4766
|
assert!(auth::is_admin_role("Admin") && auth::is_admin_role("Owner"));
|
|
2921
|
-
assert!(
|
|
4767
|
+
assert!(
|
|
4768
|
+
!auth::is_admin_role("Member")
|
|
4769
|
+
&& !auth::is_admin_role("owner")
|
|
4770
|
+
&& !auth::is_admin_role("")
|
|
4771
|
+
);
|
|
2922
4772
|
assert_eq!(auth::GRANTABLE_ROLES, ["Admin", "Owner", "Member"]);
|
|
2923
4773
|
}
|
|
2924
4774
|
|
|
2925
4775
|
#[test]
|
|
2926
|
-
fn registration_list_rows_have_exactly_the_admin_list_shape_and_never_carry_health_or_payment_fields(
|
|
4776
|
+
fn registration_list_rows_have_exactly_the_admin_list_shape_and_never_carry_health_or_payment_fields(
|
|
4777
|
+
) {
|
|
2927
4778
|
let read = json!({"instances": {
|
|
2928
4779
|
"Studio::Registration#reg-old": {
|
|
2929
4780
|
"event_slug": "yoga-aug",
|
|
@@ -2952,8 +4803,21 @@ mod tests {
|
|
|
2952
4803
|
);
|
|
2953
4804
|
|
|
2954
4805
|
let body = Value::Array(rows).to_string();
|
|
2955
|
-
for leaked in [
|
|
2956
|
-
|
|
4806
|
+
for leaked in [
|
|
4807
|
+
"SECRET-MEDICATION",
|
|
4808
|
+
"SECRET-CONCERN",
|
|
4809
|
+
"medications",
|
|
4810
|
+
"health_concerns",
|
|
4811
|
+
"555-0100",
|
|
4812
|
+
"12345",
|
|
4813
|
+
"9900",
|
|
4814
|
+
"succeeded",
|
|
4815
|
+
"amount",
|
|
4816
|
+
] {
|
|
4817
|
+
assert!(
|
|
4818
|
+
!body.contains(leaked),
|
|
4819
|
+
"{leaked:?} leaked into the registration list: {body}"
|
|
4820
|
+
);
|
|
2957
4821
|
}
|
|
2958
4822
|
}
|
|
2959
4823
|
|
|
@@ -2964,9 +4828,18 @@ mod tests {
|
|
|
2964
4828
|
"Studio::Registration#without": {"event_slug": "e", "attendee": {"name": "No Status", "email": "without@example.com"}},
|
|
2965
4829
|
}});
|
|
2966
4830
|
let rows = registration_list_rows(&read, "Studio");
|
|
2967
|
-
let by_id = |id: &str|
|
|
4831
|
+
let by_id = |id: &str| {
|
|
4832
|
+
rows.iter()
|
|
4833
|
+
.find(|row| row["registration_id"] == id)
|
|
4834
|
+
.unwrap()
|
|
4835
|
+
.clone()
|
|
4836
|
+
};
|
|
2968
4837
|
assert_eq!(by_id("with")["status"], "archived");
|
|
2969
|
-
assert!(
|
|
4838
|
+
assert!(
|
|
4839
|
+
by_id("without").get("status").is_none(),
|
|
4840
|
+
"no status key at all when the registration carries none: {:?}",
|
|
4841
|
+
by_id("without")
|
|
4842
|
+
);
|
|
2970
4843
|
assert_eq!(
|
|
2971
4844
|
by_id("without"),
|
|
2972
4845
|
json!({"registration_id": "without", "email": "without@example.com", "name": "No Status", "event_slug": "e", "news_signup": false})
|
|
@@ -2974,27 +4847,50 @@ mod tests {
|
|
|
2974
4847
|
}
|
|
2975
4848
|
|
|
2976
4849
|
#[test]
|
|
2977
|
-
fn registration_list_rows_keep_read_order_without_timestamps_and_are_empty_with_no_registrations(
|
|
4850
|
+
fn registration_list_rows_keep_read_order_without_timestamps_and_are_empty_with_no_registrations(
|
|
4851
|
+
) {
|
|
2978
4852
|
let read = json!({"instances": {
|
|
2979
4853
|
"Studio::Registration#a": {"event_slug": "e", "attendee": {"name": "Flat Name", "email": "flat@example.com"}},
|
|
2980
4854
|
}});
|
|
2981
4855
|
let rows = registration_list_rows(&read, "Studio");
|
|
2982
4856
|
assert_eq!(rows.len(), 1);
|
|
2983
|
-
assert_eq!(
|
|
4857
|
+
assert_eq!(
|
|
4858
|
+
rows[0]["name"], "Flat Name",
|
|
4859
|
+
"falls back to a flat name when there is no first and last name"
|
|
4860
|
+
);
|
|
2984
4861
|
|
|
2985
|
-
assert_eq!(
|
|
2986
|
-
|
|
4862
|
+
assert_eq!(
|
|
4863
|
+
registration_list_rows(&json!({"instances": {}}), "Studio"),
|
|
4864
|
+
Vec::<Value>::new()
|
|
4865
|
+
);
|
|
4866
|
+
assert_eq!(
|
|
4867
|
+
registration_list_rows(&json!({}), "Studio"),
|
|
4868
|
+
Vec::<Value>::new()
|
|
4869
|
+
);
|
|
2987
4870
|
let other_domain = json!({"instances": {"Elsewhere::Registration#x": {"attendee": {"email": "x@example.com"}}}});
|
|
2988
|
-
assert!(
|
|
4871
|
+
assert!(
|
|
4872
|
+
registration_list_rows(&other_domain, "Studio").is_empty(),
|
|
4873
|
+
"another domain's registrations are not listed"
|
|
4874
|
+
);
|
|
2989
4875
|
}
|
|
2990
4876
|
|
|
2991
4877
|
#[test]
|
|
2992
4878
|
fn sorted_by_name_orders_case_insensitively_and_puts_a_missing_name_first() {
|
|
2993
|
-
let people = vec![
|
|
2994
|
-
|
|
2995
|
-
|
|
4879
|
+
let people = vec![
|
|
4880
|
+
json!({"name": "bob"}),
|
|
4881
|
+
json!({"name": "Ada"}),
|
|
4882
|
+
json!({"name": null}),
|
|
4883
|
+
json!({"name": "Cy"}),
|
|
4884
|
+
];
|
|
4885
|
+
let names: Vec<Value> = sorted_by_name(people)
|
|
4886
|
+
.into_iter()
|
|
4887
|
+
.map(|p| p["name"].clone())
|
|
4888
|
+
.collect();
|
|
4889
|
+
assert_eq!(
|
|
4890
|
+
names,
|
|
4891
|
+
[json!(null), json!("Ada"), json!("bob"), json!("Cy")]
|
|
4892
|
+
);
|
|
2996
4893
|
}
|
|
2997
|
-
|
|
2998
4894
|
}
|
|
2999
4895
|
|
|
3000
4896
|
#[cfg(test)]
|
|
@@ -3006,10 +4902,22 @@ mod cookie_flag_tests {
|
|
|
3006
4902
|
|
|
3007
4903
|
#[test]
|
|
3008
4904
|
fn a_host_that_sets_the_redirect_uri_is_judged_by_it_as_before() {
|
|
3009
|
-
assert_eq!(
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
4905
|
+
assert_eq!(
|
|
4906
|
+
account_cookie_flags("https://site.example/auth/google/callback", ""),
|
|
4907
|
+
SECURE
|
|
4908
|
+
);
|
|
4909
|
+
assert_eq!(
|
|
4910
|
+
account_cookie_flags("http://localhost:4567/auth/google/callback", ""),
|
|
4911
|
+
PLAIN
|
|
4912
|
+
);
|
|
4913
|
+
assert_eq!(
|
|
4914
|
+
account_cookie_flags("https://site.example/cb", "http://localhost:4321"),
|
|
4915
|
+
SECURE
|
|
4916
|
+
);
|
|
4917
|
+
assert_eq!(
|
|
4918
|
+
account_cookie_flags("http://localhost:4567/cb", "https://site.example"),
|
|
4919
|
+
PLAIN
|
|
4920
|
+
);
|
|
3013
4921
|
}
|
|
3014
4922
|
|
|
3015
4923
|
#[test]
|