From f1111b9ae5d71c5a5bafdb8c7af0906f1bd2d172 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sun, 6 Sep 2026 22:05:58 +0200 Subject: [PATCH 1/2] bank: give it a server, and a scenario corpus over all 41 actions Bank had the largest model surface in the tree and no way to drive it as a real client: eleven models and 41 registered actions, reachable only in process. This adds the server binary and 22 scenario files that exercise every one of those actions over a WebSocket. `examples/bank/src/server/main.cpp` is a standalone `ladder_bank_server`: `bank::db::setup()`, a process-wide `FileActionLog` (the one bank's CLI already installs), a `RemoteServer` over a worker pool, and a `QtWebSocketServer` in front. It reads `BANK_DB`/`BANK_PORT` and prints the `listening on ws://...` line `run_scenarios.py` parses. `main()` owns the server rather than `bank::app::App` because that class is the *client* side context the GUI and CLI build on -- a bridge over a LocalBackend and the login/logout pair -- and giving it a RemoteServer would put a listener into every desktop process to serve one consumer. The CMake target is written out locally rather than obtained by calling `morph_add_rung()`. The macro links `morph::ladder__lib`, which only it creates, and -- decisively -- `examples/rungs.txt` documents that calling the macro without being listed there is a hard configure error. That would force bank into ladder CI, the sanitizer and WASM matrices, codecov components and coverage.sh, which is the numbering decision #87 reserves for the maintainer. `scripts/check_rung_filters.sh` still passes untouched: bank is invisible to it precisely because it never calls the macro. Bank is added to `SERVER_RUNGS` and `run_scenarios.py`'s `RUNGS` without touching `examples/rungs.txt`. That tuple has always selected on "has a src/server/main.cpp" -- the property that decides whether a scenario can reach a thing -- and being a rung and having scenarios were already independent in the other direction, since `lims` and `crm` are rungs with no scenarios. The server installs an authorizer that vouches for a non-empty asserted principal. This is demo-grade and is commented as such: bank's `AuthModel` mints no bearer token, so there is no signed artefact to verify, and morph's default `allowAllAuthorizer()` does not authenticate -- `RemoteServer` then *clears* the principal, leaving every bank action failing with "no session principal". Per-row ownership is still enforced by the models, so cross-customer isolation is genuinely testable; credential proof is not, because bank has none. Coverage: 41/41 bank actions dispatched, 0 allowlist exemptions, 22/22 files qualifying as workflows. Corpus-wide the action total goes 72 -> 113. Three things the corpus records because they are true, not because they are right, and each would otherwise change unnoticed: * Actions resolving an owner *by name* (`ListAccounts`, `ListPayees`, `ListPayments`, `ListLoans`, `ListBudgets`, `ListNotifications`, `MarkAllRead`, `GenerateStatement`) never compare that name with the session principal, so any signed-in customer can read -- and via `MarkAllRead`, write -- another's data by typing their username. `SpendingByKind` consults no owner at all and answers an anonymous caller. Actions addressed by row id do enforce ownership. * Every DTO carrying a `validate()` predicate has its model's matching `ValidationError` shadowed: morph refuses first, with "action failed validation: /", so those model messages are unreachable over the wire. * `GenerateStatement`'s `closingBalanceMinor` is the account's current balance, not its balance as at the window's end. Verified: `run_scenarios.py --rung bank --twice` passes (44 file runs, exit 0), `scenario_coverage.py` exits 0, `test_morph_scenario.py` runs 132 tests OK, and `check_rung_filters.sh` passes all 37 checks. Refs #87. The one question #87 still leaves open is step 1, bank's slot in the ladder: nothing here numbers it, adds it to `examples/rungs.txt`, or renumbers anything. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF --- examples/bank/CMakeLists.txt | 52 ++++ examples/bank/README.md | 43 +++ examples/bank/src/server/main.cpp | 250 ++++++++++++++++++ scripts/scenario/README.md | 30 ++- scripts/scenario/run_scenarios.py | 16 ++ scripts/scenario/scenario_coverage.py | 29 +- ...-card-through-its-whole-lifecycle.scenario | 150 +++++++++++ .../bank/a-loan-disbursed-and-repaid.scenario | 120 +++++++++ ...a-loan-schedule-amortises-to-zero.scenario | 89 +++++++ ...a-payee-and-the-bill-paid-to-them.scenario | 108 ++++++++ ...yee-removed-can-no-longer-be-paid.scenario | 98 +++++++ ...ayment-waits-and-can-be-cancelled.scenario | 100 +++++++ ...anding-order-recurs-on-paper-only.scenario | 98 +++++++ ...covers-every-account-an-owner-has.scenario | 106 ++++++++ .../a-transfer-moves-both-balances.scenario | 82 ++++++ ...draft-is-a-floor-not-a-suggestion.scenario | 67 +++++ ...s-not-checked-against-the-session.scenario | 169 ++++++++++++ ...ustomer-cannot-touch-your-account.scenario | 157 +++++++++++ ...king-without-a-session-is-refused.scenario | 166 ++++++++++++ .../budgets-are-upserted-by-category.scenario | 96 +++++++ ...ange-a-password-and-sign-in-again.scenario | 75 ++++++ ...g-an-account-needs-a-zero-balance.scenario | 86 ++++++ ...ss-currency-transfers-are-refused.scenario | 79 ++++++ ...osit-withdraw-and-read-the-ledger.scenario | 101 +++++++ ...ifications-are-raised-and-cleared.scenario | 111 ++++++++ ...unt-two-clients-and-a-stale-cache.scenario | 139 ++++++++++ ...ister-sign-in-and-open-an-account.scenario | 113 ++++++++ .../bank/what-was-spent-and-on-what.scenario | 113 ++++++++ scripts/scenario/test_morph_scenario.py | 10 +- 29 files changed, 2844 insertions(+), 9 deletions(-) create mode 100644 examples/bank/src/server/main.cpp create mode 100644 scripts/scenario/scenarios/bank/a-card-through-its-whole-lifecycle.scenario create mode 100644 scripts/scenario/scenarios/bank/a-loan-disbursed-and-repaid.scenario create mode 100644 scripts/scenario/scenarios/bank/a-loan-schedule-amortises-to-zero.scenario create mode 100644 scripts/scenario/scenarios/bank/a-payee-and-the-bill-paid-to-them.scenario create mode 100644 scripts/scenario/scenarios/bank/a-payee-removed-can-no-longer-be-paid.scenario create mode 100644 scripts/scenario/scenarios/bank/a-scheduled-payment-waits-and-can-be-cancelled.scenario create mode 100644 scripts/scenario/scenarios/bank/a-standing-order-recurs-on-paper-only.scenario create mode 100644 scripts/scenario/scenarios/bank/a-statement-covers-every-account-an-owner-has.scenario create mode 100644 scripts/scenario/scenarios/bank/a-transfer-moves-both-balances.scenario create mode 100644 scripts/scenario/scenarios/bank/an-overdraft-is-a-floor-not-a-suggestion.scenario create mode 100644 scripts/scenario/scenarios/bank/an-owner-named-outright-is-not-checked-against-the-session.scenario create mode 100644 scripts/scenario/scenarios/bank/another-customer-cannot-touch-your-account.scenario create mode 100644 scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario create mode 100644 scripts/scenario/scenarios/bank/budgets-are-upserted-by-category.scenario create mode 100644 scripts/scenario/scenarios/bank/change-a-password-and-sign-in-again.scenario create mode 100644 scripts/scenario/scenarios/bank/closing-an-account-needs-a-zero-balance.scenario create mode 100644 scripts/scenario/scenarios/bank/cross-currency-transfers-are-refused.scenario create mode 100644 scripts/scenario/scenarios/bank/deposit-withdraw-and-read-the-ledger.scenario create mode 100644 scripts/scenario/scenarios/bank/notifications-are-raised-and-cleared.scenario create mode 100644 scripts/scenario/scenarios/bank/one-account-two-clients-and-a-stale-cache.scenario create mode 100644 scripts/scenario/scenarios/bank/register-sign-in-and-open-an-account.scenario create mode 100644 scripts/scenario/scenarios/bank/what-was-spent-and-on-what.scenario diff --git a/examples/bank/CMakeLists.txt b/examples/bank/CMakeLists.txt index cdab66638..d1d3eae48 100644 --- a/examples/bank/CMakeLists.txt +++ b/examples/bank/CMakeLists.txt @@ -100,6 +100,58 @@ target_link_libraries(bank_cli PRIVATE bank_lib) target_compile_features(bank_cli PRIVATE cxx_std_23) apply_bigobj(bank_cli) +# ── ladder_bank_server: standalone server binary ───────────────────────────── +# Named `ladder_bank_server` because that is the name the scenario tooling +# derives from a rung name (`run_scenarios.py`'s `RungSpec.binary`, +# `find_server()`); the prefix is that tool's convention, not a claim to a rung +# number, which issue #87 leaves to the maintainer. +# +# Written out here rather than obtained by calling morph_add_rung(). That macro +# emits this exact block (cmake/morph_add_rung.cmake, "ladder__server"), +# but it links `morph::ladder__lib` -- a target only the macro itself +# creates -- and bank's library is `bank_lib`, built above against a different +# dependency set (Lightweight/ODBC, and deliberately without apply_warnings()). +# More decisively, calling the macro is not available to us: examples/rungs.txt +# documents that "omitting a rung here while examples//CMakeLists.txt +# calls morph_add_rung() is a hard configure error, by design", so the call +# would force bank into that file -- and with it into ladder CI, the sanitizer +# and WASM matrices, codecov components and coverage.sh. That is the numbering +# decision #87 reserves for the maintainer, so this stays a local target: it +# gives bank a server without asserting anything about bank's slot in the +# ladder. Everything else here mirrors the macro's block line for line. +# +# Gated on Qt because the transport is morph::qt's QtWebSocketServer, exactly +# as the macro's version is; MORPH_BUILD_BANK_GUI is *not* the gate, since this +# is a headless server that needs Qt6::Core and no GUI. +# +# The gate is the *option*, not `TARGET morph::qt`: the root CMakeLists adds +# examples/bank (line ~523) well before it creates morph::qt (line ~596), so a +# TARGET check here is false even when Qt is on. Naming the target in +# target_link_libraries is fine regardless -- CMake resolves link names at +# generate time, by which point the alias exists. bank/gui/CMakeLists.txt +# depends on the same ordering. +if(MORPH_BUILD_QT) + # Qt's imported targets are directory-scoped, so naming Qt6::Core below + # needs a find_package in *this* directory even though the root already ran + # one -- the same call bank_gui_qml_tests makes further down for the same + # reason. WebSockets comes along because morph::qt's INTERFACE link + # requirements name it. + find_package(Qt6 REQUIRED COMPONENTS Core WebSockets) + add_executable(ladder_bank_server src/server/main.cpp) + target_link_libraries(ladder_bank_server PRIVATE bank_lib morph::qt morph_qt_impl Qt6::Core) + target_compile_features(ladder_bank_server PRIVATE cxx_std_23) + apply_bigobj(ladder_bank_server) + # No apply_warnings(), for the same reason bank_lib skips it: this target + # links bank_lib and pulls the ORM's headers in through its model headers, + # which are not -Werror clean. + if(AF_COVERAGE) + apply_coverage(ladder_bank_server) + endif() + if(DEFINED AF_SANITIZER) + apply_sanitizers(ladder_bank_server ${AF_SANITIZER}) + endif() +endif() + # ── Qt 6 GUI (opt-in) ──────────────────────────────────────────────────────── if(MORPH_BUILD_BANK_GUI) add_subdirectory(gui) diff --git a/examples/bank/README.md b/examples/bank/README.md index 66c0d1f36..6433e36d2 100644 --- a/examples/bank/README.md +++ b/examples/bank/README.md @@ -158,6 +158,49 @@ registered with unixODBC (the connection string is `DRIVER=SQLite3;Database=…` ./build/examples/bank/bank_cli ``` +### Standalone server (`ladder_bank_server`) + +`src/server/main.cpp` builds a headless WebSocket server that hosts every bank +model over `morph::wire`, so a real out-of-process client can drive them. It +needs `-DMORPH_BUILD_QT=ON` (the transport is `morph::qt`'s `QtWebSocketServer`; +no GUI is involved): + +```sh +cmake -G Ninja -B build -S . -DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_QT=ON +cmake --build build --target ladder_bank_server + +BANK_DB="DRIVER=SQLite3;Database=$PWD/bank.db;Timeout=5000" BANK_PORT=0 \ + ./build/examples/bank/ladder_bank_server +# bank-server: listening on ws://127.0.0.1:54321 +``` + +`BANK_PORT=0` lets the OS pick a free port, which the server prints. It writes +an audit trail to `bank_actions.jsonl` in its working directory, the same +`morph::journal::FileActionLog` the CLI installs — the models' read-only actions +carry `Loggable::No`, so what lands there is the mutating half of the surface. + +The scenario corpus in `scripts/scenario/scenarios/bank/` drives this binary; +see [`scripts/scenario/README.md`](../../scripts/scenario/README.md). + +**The name is the scenario tooling's convention, not a rung claim.** +`run_scenarios.py` derives a binary name of `ladder__server` from a +directory name. Bank is *not* a ladder rung — it is absent from +`examples/rungs.txt`, never calls `morph_add_rung()`, and this target is written +out locally in `CMakeLists.txt` instead. Bank's slot in the ladder is +[morph#87](https://github.com/LASTRADA-Software/morph/issues/87), which is still +open and is the maintainer's decision. + +**Authentication is demo-grade, deliberately.** Bank's `AuthModel` mints no +bearer token: `LoginRequest` verifies a password and returns the *principal* for +the client to install (which is what `App::login()` does with it). The server +therefore installs an authorizer that vouches for whatever non-empty principal a +client asserts, because the alternative — morph's default `allowAllAuthorizer()` +— does not authenticate, and `RemoteServer` *clears* an unvouched-for principal, +leaving every action to fail with "no session principal". Per-row ownership is +still enforced by the models (`db::loadOwned`), so one customer cannot reach +another's account by id; what is not enforced is proof that the caller is who +they say. A real deployment swaps in `morph::session::SigningAuthorizer`. + ### Qt 6 QML GUI A **QML (Qt Quick)** desktop GUI (`gui/`) is built when `-DMORPH_BUILD_BANK_GUI=ON` diff --git a/examples/bank/src/server/main.cpp b/examples/bank/src/server/main.cpp new file mode 100644 index 000000000..9d4d0485e --- /dev/null +++ b/examples/bank/src/server/main.cpp @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// bank's standalone server process: `bank::db::setup()` once, one process-wide +/// `morph::journal::FileActionLog`, one `morph::backend::RemoteServer` over a +/// worker pool, and one `morph::qt::QtWebSocketServer` in front of it. Speaks +/// the same `morph::wire` envelope protocol every `ladder__server` does, +/// so `scripts/scenario/run_scenarios.py --rung bank` can drive bank's models +/// as a real out-of-process client. +/// +/// Usage: +/// @code +/// BANK_DB="DRIVER=SQLite3;Database=bank.db;Timeout=5000" BANK_PORT=0 \ +/// ladder_bank_server +/// @endcode +/// +/// @par Why this `main()` owns the server rather than `bank::app::App` +/// Every ladder rung puts its `RemoteServer` in an `App` and lets `main()` name +/// only that class. Bank cannot follow that shape without changing what +/// `bank::app::App` *is*: that class is the **client**-side context the GUI and +/// the CLI build on — a `ThreadPoolExecutor`, a `MainThreadExecutor` for +/// callbacks, and one `Bridge` over a `LocalBackend`, plus the +/// `login()`/`logout()` pair that sets that bridge's default session +/// (`examples/bank/src/app/app.cpp`). It hosts no `RemoteServer` and needs +/// none; adding one would put a listener's worth of machinery into every +/// desktop and CLI process that constructs an `App` purely so a fourth +/// consumer could reach it. The server side bank actually needs is small +/// enough to live here in full, so it does. +/// +/// @par Why every model header is included +/// `BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` place their registrars in +/// the *header*, so a translation unit that includes one both registers the +/// type with the process-wide registry and emits a reference to that model's +/// `execute` bodies — which is what pulls the model's object file out of +/// `bank_lib` for a binary whose own code names none of them. Without these +/// includes this server would link and then come up serving nothing. The rung +/// servers get the same effect from their `App` translation unit; bank has no +/// such file on the server side, so the includes are here. Any model added to +/// `bank_lib` must be added to this list too, or it is silently unreachable +/// over the wire. +/// +/// @par No `--seed` +/// Every bank action is scoped to `session::current()->principal`, and the +/// principal is only meaningful once a `users` row exists for it — which is +/// `RegisterUser`'s job. Seeding by calling models directly would therefore +/// have to install a thread-local session itself, reaching into +/// `morph::session::detail::ScopedContext`; `bookmarks`' server declines to do +/// that for the same reason and so does this one. Demo data is created through +/// the client, which is also the path a real user takes. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/db/database.hpp" + +// Every model bank_lib hosts — see this file's "Why every model header is +// included" note. Sorted, so a missing one is visible by reading. +#include "bank/models/account_model.hpp" +#include "bank/models/auth_model.hpp" +#include "bank/models/budget_model.hpp" +#include "bank/models/card_model.hpp" +#include "bank/models/customer_model.hpp" +#include "bank/models/loan_model.hpp" +#include "bank/models/notification_model.hpp" +#include "bank/models/payee_model.hpp" +#include "bank/models/payment_model.hpp" +#include "bank/models/statement_model.hpp" +#include "bank/models/transaction_model.hpp" + +namespace { + +/// @brief Set from the `SIGINT`/`SIGTERM` handler, polled by a `QTimer`. +/// +/// A signal handler may not call into Qt (nothing in `QCoreApplication` is +/// async-signal-safe), so it does the one thing it is allowed to do — assign to +/// a `volatile std::sig_atomic_t` — and a timer on the Qt thread turns that +/// into a real `quit()`. Same shape, and the same reason, as +/// `examples/pastebin/src/server/main.cpp`: without it the default `SIGINT` +/// disposition would terminate the process outright and the shutdown path below +/// would never run. +volatile std::sig_atomic_t gStopRequested = 0; + +extern "C" void onStopSignal(int /*signum*/) { gStopRequested = 1; } + +/// @brief Vouches for a client's asserted principal, and refuses an empty one. +/// +/// **This is a demo authorizer and is not authentication.** It exists because +/// of a real property of bank's domain, not as a shortcut: bank's `AuthModel` +/// mints no bearer token. `LoginRequest` verifies a password and returns the +/// principal to install (`dto::AuthResult::principal`), and the *client* is +/// what installs it — `bank::app::App::login()` calls +/// `Bridge::setDefaultSession` with it. There is no signed artefact for a +/// server to verify, so a `SigningAuthorizer` (what `bookmarks`, `kanban` and +/// `ledger` install) would refuse every client bank ships, and inventing a +/// token for the server alone would mean changing `AuthModel`'s wire results — +/// a change to bank's GUI and CLI, made to suit a test transport. +/// +/// The default `allowAllAuthorizer()` is not an option either, and this is the +/// subtle part: `RemoteServer::stampVerifiedPrincipal` *clears* +/// `session.principal` whenever `authenticate()` returns `nullopt` +/// (`include/morph/core/remote.hpp`), so under the default authorizer every +/// bank action would run with an empty principal and fail with +/// `"no session principal"`. Bank would be registered, reachable, and unable +/// to do anything. Vouching for the claim is what makes the wire behave the +/// way bank's own in-process client already does. +/// +/// What this deliberately keeps: an *empty* principal is not vouched for, so +/// `RemoteServer` clears it and unauthenticated calls are refused by the models +/// exactly as they are in-process. And it grants no ownership: every bank model +/// still resolves ownership per row (`db::loadOwned`, `db::loadOwnedOpenAccount` +/// in `examples/bank/include/bank/db/ledger_ops.hpp`), so naming another user's +/// principal is refused by the model even though the authorizer accepted the +/// name. Cross-principal isolation is therefore genuinely testable over this +/// transport; credential *proof* is not, because bank has none to prove. +/// +/// A production deployment replaces this with `morph::session::SigningAuthorizer` +/// over a token `AuthModel` issues. See `docs/spec/security.md`. +struct BankDemoAuthorizer : ::morph::session::IAuthorizer { + /// @brief Accepts any action; ownership is enforced per row by the models. + /// @return Always `true`. + [[nodiscard]] bool authorize(const ::morph::session::Context& /*ctx*/, std::string_view /*model*/, + std::string_view /*actionType*/) const override { + return true; + } + + /// @brief Vouches for a non-empty asserted principal; refuses an empty one. + /// @param ctx The client's claimed session. + /// @return The claimed principal, or `std::nullopt` when it is empty. + [[nodiscard]] std::optional authenticate(const ::morph::session::Context& ctx) const override { + if (ctx.principal.empty()) { + return std::nullopt; + } + return ctx.principal; + } +}; + +/// @brief Live-instance cap this server installs. +/// +/// Bank hosts eleven models and this authorizer installs no `authorizeRegister` +/// override, so an unauthenticated client can still make the server create +/// instances even though it can execute nothing on them. Generous on purpose: +/// a client registering all eleven models still fits many times over, so this +/// is a bound on abuse rather than a limit a real session meets. Mirrors +/// `bookmarks::app::App`'s identical constant and rationale. +constexpr std::size_t kMaxLiveModels = 256; + +/// @brief Reads a port from the environment, defaulting to `0` (OS picks). +/// @param name Environment variable to read. +/// @return The parsed port, or `0` when unset or unparseable. +[[nodiscard]] quint16 portFromEnvironment(const char* name) { + const char* value = std::getenv(name); + if (value == nullptr) { + return 0; + } + return static_cast(std::atoi(value)); +} + +} // namespace + +int main(int argc, char** argv) { + QCoreApplication qtApp{argc, argv}; + + for (int i = 1; i < argc; ++i) { + std::cerr << "bank-server: unknown argument '" << argv[i] << "' (usage: BANK_DB=... BANK_PORT=... " + << "ladder_bank_server)\n"; + return 2; + } + + const char* connectionString = std::getenv("BANK_DB"); + try { + bank::db::setup(connectionString != nullptr ? connectionString + : "DRIVER=SQLite3;Database=bank.db;Timeout=5000"); + } catch (const std::exception& e) { + std::cerr << "bank-server: could not open the database: " << e.what() << '\n'; + return 1; + } + + // Bank's CLI driver installs exactly this (examples/bank/src/cli/main.cpp), + // and the models are already annotated for it: the read-only actions carry + // `Loggable::No`, so what lands in the journal is the mutating half of the + // surface and not every list call. Installed process-wide, because a + // registry-constructed model is always default-constructed and so has no + // DI seam to receive it through. + auto actionLog = + std::make_shared<::morph::journal::FileActionLog>(std::filesystem::current_path() / "bank_actions.jsonl"); + ::morph::journal::setActionLog(actionLog); + + int exitCode = 0; + { + ::morph::exec::ThreadPoolExecutor pool{4}; + auto server = std::make_shared<::morph::backend::RemoteServer>(pool, std::make_shared()); + + ::morph::backend::LimitPolicy limits; + limits.maxLiveModels = kMaxLiveModels; + server->setLimitPolicy(limits); + + ::morph::qt::QtWebSocketServer wsServer{*server, portFromEnvironment("BANK_PORT")}; + if (!wsServer.listen()) { + std::cerr << "bank-server: failed to listen\n"; + ::morph::journal::setActionLog(nullptr); + return 1; + } + // The one line scripts/scenario/run_scenarios.py parses to learn the + // port (its `PORT_LINE`); `std::endl` because that tool reads this + // pipe line by line and a buffered announcement would hang it. + std::cout << "bank-server: listening on ws://127.0.0.1:" << wsServer.port() << std::endl; + + std::signal(SIGINT, onStopSignal); + std::signal(SIGTERM, onStopSignal); + QTimer stopPoll; + QObject::connect(&stopPoll, &QTimer::timeout, &qtApp, [] { + if (gStopRequested != 0) { + QCoreApplication::quit(); + } + }); + stopPoll.start(std::chrono::milliseconds{200}); + + exitCode = QCoreApplication::exec(); + + // Let connected clients' in-flight executes reply and close cleanly + // before `server` and `pool` leave this scope. Bank runs no background + // job, so there is no sweep to drain afterwards — the rung servers that + // do (pastebin's expiry sweep, ledger's report runner) need a second + // step here and bank does not. + static_cast(wsServer.closeGracefully(std::chrono::seconds{2})); + } + + // Matches the clear-on-shutdown discipline every rung App follows: nothing + // should observe a live log belonging to a process that has stopped. + ::morph::journal::setActionLog(nullptr); + + std::cout << "bank-server: stopped\n"; + return exitCode; +} diff --git a/scripts/scenario/README.md b/scripts/scenario/README.md index af9e0c837..c059d4069 100644 --- a/scripts/scenario/README.md +++ b/scripts/scenario/README.md @@ -46,6 +46,26 @@ python3 scripts/scenario/run_scenarios.py --rung pastebin python3 scripts/scenario/run_scenarios.py --rung ledger --twice --mutate ``` +**bank is built separately**, because it is not a ladder rung: it is absent +from `examples/rungs.txt`, never calls `morph_add_rung()`, and its server is a +local target in `examples/bank/CMakeLists.txt`. It also pulls a heavy +dependency tree (the Lightweight ORM over ODBC), which is why it sits behind +its own option and off by default: + +```bash +cmake --preset clang-release -B build/bank-srv -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_NET=ON -DMORPH_BUILD_QT=ON -DMORPH_BUILD_TESTS=ON +cmake --build build/bank-srv --target ladder_bank_server + +python3 scripts/scenario/run_scenarios.py --rung bank --build-dir build/bank-srv --twice +``` + +Note that `--build-dir` applies to *every* rung in the run, so it is for +single-rung runs like the one above. A whole-corpus `run_scenarios.py` with no +`--build-dir` still works across the two build directories: it globs +`build/*/examples//`, and each server binary exists under exactly one of +them. + | Flag | Meaning | |---|---| | `--rung NAME` | Restrict to one rung; repeatable. Default: every rung with a directory. | @@ -75,7 +95,10 @@ They are no longer a statement that a book cannot be created over the wire: it would pass against a database the driver never touched. Every other rung creates its own root entity over the wire and is seeded with nothing. -`scenarios/` holds one directory per rung, and one file per workflow: +`scenarios/` holds one directory per server, and one file per workflow. Five of +the six are ladder rungs; `bank` is not one (see `SERVER_RUNGS` in +`scenario_coverage.py`), which is why the column below says "server" rather +than "rung": | Directory | Server | What it covers | |---|---|---| @@ -84,6 +107,7 @@ creates its own root entity over the wire and is seeded with nothing. | `polls/` | `ladder_polls_server` | the shared-instance showcase: create/open/vote/finalize, two participants converging, principal-scoped undo, the event cursor and instance rebirth | | `kanban/` | `ladder_kanban_server` | projects and boards, moves and WIP limits, per-project RBAC across three roles, rules and their cascades, comments, attachments, both event streams | | `ledger/` | `ladder_ledger_server` | bootstrapping a book from nothing, per-currency zero-sum bookkeeping, categories and budgets, rules and version conflicts, CSV import, submit-then-poll reporting, two books | +| `bank/` | `ladder_bank_server` | registration and password-only sign-in, accounts and the ledger behind them, overdraft boundaries, transfers, bills and scheduled/standing instructions, cards, loans and amortisation, budgets, notifications, statements; two customers, the anonymous session, and the stateful account cache | The rung a scenario belongs to is its parent directory name — that is how per-rung action coverage is attributed, so a file loose in `scenarios/` is @@ -330,8 +354,8 @@ nothing asserts, an allowlist entry left behind after the thing it exempted became coverable, or a scenario edited until it no longer qualifies as a workflow. -What CI does **not** do is *run* the corpus. `run_scenarios.py` needs the five -`ladder__server` binaries built, which no workflow has today; running it +What CI does **not** do is *run* the corpus. `run_scenarios.py` needs the six +`ladder__server` binaries built, which no workflow has today; running it is its own change. So a scenario can currently drift from a server's real behaviour without CI noticing — only from its *surface*. diff --git a/scripts/scenario/run_scenarios.py b/scripts/scenario/run_scenarios.py index 8bba43f18..0d4a63635 100755 --- a/scripts/scenario/run_scenarios.py +++ b/scripts/scenario/run_scenarios.py @@ -174,6 +174,22 @@ def seed(self, db_path: pathlib.Path) -> None: token_secret_var="KANBAN_TOKEN_SECRET", extra_zero_ports=("KANBAN_ATTACHMENT_PORT",), ), + # bank is not in examples/rungs.txt and does not call morph_add_rung(); its + # server is a local target in examples/bank/CMakeLists.txt. Being a rung and + # having scenarios were always independent -- `lims` and `crm` are rungs + # with no scenarios -- and this is the same independence from the other + # side: a scenario corpus over a server, with no claim to a rung number + # (morph#87 reserves that decision). Everything this table needs is a + # binary name and two environment variables, all three of which + # examples/bank/src/server/main.cpp defines. + # + # No `token_secret_var`: bank's AuthModel mints no bearer token. It verifies + # a password and returns the principal for the *client* to install, so a + # bank scenario signs in with `session principal=...` and no token, and the + # server vouches for that claim (see BankDemoAuthorizer in bank's server + # main.cpp). pastebin and polls take this same `None` for the simpler reason + # that they have no sign-in at all. + "bank": RungSpec(binary="ladder_bank_server", port_var="BANK_PORT", db_var="BANK_DB"), "ledger": RungSpec( binary="ladder_ledger_server", port_var="LEDGER_PORT", diff --git a/scripts/scenario/scenario_coverage.py b/scripts/scenario/scenario_coverage.py index c8873823d..b25756f8b 100755 --- a/scripts/scenario/scenario_coverage.py +++ b/scripts/scenario/scenario_coverage.py @@ -250,11 +250,21 @@ def floor_violations(surface: Surface) -> list[str]: return problems -# Rungs that ship a `ladder__server`, and so can be driven by a scenario. -# `lims` and `crm` are deliberately absent: neither ships a server (crm ships no -# client either -- see its README's "What is not built"), so no scenario can -# reach them. lims's missing server is filed separately. -SERVER_RUNGS = ("pastebin", "bookmarks", "polls", "kanban", "ledger") +# Directories under `examples/` that ship a `ladder__server`, and so can +# be driven by a scenario. `lims` and `crm` are deliberately absent: neither +# ships a server (crm ships no client either -- see its README's "What is not +# built"), so no scenario can reach them. lims's missing server is filed +# separately. +# +# `bank` is here and is *not* a ladder rung: it is absent from +# examples/rungs.txt and never calls morph_add_rung(), and morph#87 leaves the +# question of its slot in the ladder to the maintainer. What this tuple has +# always actually meant is "the set with a src/server/main.cpp" -- the property +# that decides whether a scenario can reach the thing -- and bank has had a +# model surface worth driving for far longer than it has lacked a server. Being +# a rung and having scenarios were already independent in the other direction: +# `lims` and `crm` are rungs with no scenarios. +SERVER_RUNGS = ("pastebin", "bookmarks", "polls", "kanban", "ledger", "bank") # Plausibility floor for the whole action universe, measured at 72. Same # purpose as MIN_KINDS/MIN_MESSAGES: if the macro is renamed, this extractor @@ -469,6 +479,15 @@ def _referenced_captures(token: str) -> list[str]: # what it exists to keep from happening again. "ledger": 16, "kanban": 20, + # Bank registers 41 actions across eleven models -- nearly twice kanban's + # surface, and the largest in the tree -- so its floor is the highest here. + # Scaled the same way as the rest: not a quota, but the number of genuinely + # distinct journeys the domain supports, which for a retail bank is roughly + # one per money-moving shape (deposit/withdraw, transfer, bill, scheduled + # payment, standing order, loan, card) plus the cross-cutting ones + # (authorization between two customers, the anonymous session, the stateful + # account cache). + "bank": 22, } diff --git a/scripts/scenario/scenarios/bank/a-card-through-its-whole-lifecycle.scenario b/scripts/scenario/scenarios/bank/a-card-through-its-whole-lifecycle.scenario new file mode 100644 index 000000000..5f3e6ac5d --- /dev/null +++ b/scripts/scenario/scenarios/bank/a-card-through-its-whole-lifecycle.scenario @@ -0,0 +1,150 @@ +# bank — issuing a card and every state it can be put into, ending in the one +# it cannot come back from. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/a-card-through-its-whole-lifecycle.scenario +# +# Freezing is reversible and cancelling is not, and `CardModel` is where that +# asymmetry lives: `UnfreezeCard` checks for `Cancelled` and refuses, while +# `CancelCard` checks nothing and will happily cancel a frozen card. Walking the +# whole lifecycle in one file is what makes the one-way door visible. +# +# The card's PAN is also worth noting for what is *absent*: bank stores only the +# last four digits (`panLast4`) and there is no action anywhere on this model +# that returns a full number. That is a property of the schema, not a masking +# step some client remembered to apply — see morph#86, where the proposed +# `QQmlPropertyMap` projection was rejected partly for undoing it. + +model AuthModel +client front +do RegisterUser username=thompson password=thompson-pass displayName="K. Thompson" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=thompson password=thompson-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id + +# ── Issuing ───────────────────────────────────────────────────────────────── +client wallet model=CardModel principal=$who + +# kind 0 is CardKind::Debit, status 0 is CardStatus::Active. +do IssueCard accountId=$acct kind=0 dailyLimitMinor=50000 +expect ok capture card=$.id +expect ok field accountId == $acct +expect ok field kind == 0 +expect ok field status == 0 +expect ok field dailyLimitMinor == 50000 +expect ok field owner == "thompson" +# Four digits and no more: there is no field here that could carry a full PAN. +expect ok field panLast4 ~ "^[0-9]{4}$" + +# kind 1 is CardKind::Credit; anything outside 0..1 fails `IssueCard::validate()` +# and is refused by morph before `CardModel` runs. +do IssueCard accountId=$acct kind=9 dailyLimitMinor=1000 +expect err message == "action failed validation: CardModel/IssueCard" + +# A card can only be issued against an account the caller owns. +do IssueCard accountId=999999 kind=0 dailyLimitMinor=1000 +expect err message == "account not found" + +do ListCards owner=$who +expect ok field cards ~ "\"id\":$card," + +# ── Frozen, and back ──────────────────────────────────────────────────────── +# status 1 is CardStatus::Frozen. +do FreezeCard id=$card +expect ok field ok == true +expect ok field message == "card frozen" + +do ListCards owner=$who +expect ok field cards ~ "\"status\":1" + +do UnfreezeCard id=$card +expect ok field ok == true +expect ok field message == "card active" + +do ListCards owner=$who +expect ok field cards ~ "\"status\":0" + +# ── Limits and PINs ───────────────────────────────────────────────────────── +# `SetCardLimit` carries no `validate()` predicate, so its bound is checked by +# the model and the message is bank's own rather than the framework's. That is +# the visible difference between a DTO that declares `validate()` and one that +# does not, and both spellings appear in this one file. +do SetCardLimit id=$card dailyLimitMinor=-1 +expect err message == "limit must be non-negative" + +do SetCardLimit id=$card dailyLimitMinor=125000 +expect ok field ok == true +expect ok field message == "limit updated" + +do ListCards owner=$who +expect ok field cards ~ "\"dailyLimitMinor\":125000" + +# A zero limit is a limit, not an absence of one. +do SetCardLimit id=$card dailyLimitMinor=0 +expect ok field ok == true + +# `ChangePin` *does* declare `validate()` (exactly four characters), so a short +# PIN is refused by morph and the model's own message never reaches a client. +# +# The quotes are load-bearing: `newPin` is a `std::string`, and an unquoted +# `1234` in this runner is a JSON *number*, which the server rejects at decode +# time with a parser diagnostic rather than as a validation failure. A PIN that +# is all digits but must travel as text is exactly the field where that bites. +do ChangePin id=$card newPin="12" +expect err message == "action failed validation: CardModel/ChangePin" + +# The same PIN sent unquoted never reaches `validate()` at all: it fails to +# decode into the action's `std::string`. +do ChangePin id=$card newPin=4821 +expect err message ~ "^1:[0-9]+: expected_quote" + +do ChangePin id=$card newPin="4821" +expect ok field ok == true +expect ok field message == "PIN changed" + +# Nothing on this model ever hands a PIN back — only the acknowledgement above. +do ListCards owner=$who +expect ok field cards !~ "4821" +expect ok field cards !~ "pin" + +# ── Cancelled, and that is the end ────────────────────────────────────────── +# A frozen card can still be cancelled: `CancelCard` gates on nothing. +do FreezeCard id=$card +expect ok field ok == true + +# status 2 is CardStatus::Cancelled. +do CancelCard id=$card +expect ok field ok == true +expect ok field message == "card cancelled" + +do ListCards owner=$who +expect ok field cards ~ "\"status\":2" + +# The one-way door: this is the only refusal `CardModel` raises about state. +do UnfreezeCard id=$card +expect err message == "cancelled cards cannot be reactivated" + +# Freezing a cancelled card is *not* refused — `FreezeCard` checks no state at +# all, so it moves a cancelled card back to Frozen. Asserted because it is a +# real asymmetry in the model, not because it is the behaviour one would want. +do FreezeCard id=$card +expect ok field ok == true + +# And from Frozen it can be unfrozen again, so the "one-way door" is only shut +# for as long as nothing reopens it this way. +do UnfreezeCard id=$card +expect ok field ok == true +expect ok field message == "card active" + +# Left cancelled, which is where a finished lifecycle should end. +do CancelCard id=$card +expect ok field ok == true + +# A card that does not exist is a lookup failure like any other. +do FreezeCard id=999999 +expect err message == "card not found" diff --git a/scripts/scenario/scenarios/bank/a-loan-disbursed-and-repaid.scenario b/scripts/scenario/scenarios/bank/a-loan-disbursed-and-repaid.scenario new file mode 100644 index 000000000..91b64c17d --- /dev/null +++ b/scripts/scenario/scenarios/bank/a-loan-disbursed-and-repaid.scenario @@ -0,0 +1,120 @@ +# bank — borrowing money: the loan is credited into an account, repaid out of +# one, and closes itself the moment nothing is left owing. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/a-loan-disbursed-and-repaid.scenario +# +# `ApplyLoan` is the clearest case in bank of an action whose result is only +# half the story: it returns the loan, but its real effect is a *credit* on the +# account, journalled as `LoanDisbursement`. Reading the loan alone would miss +# it entirely, so this file checks both sides of every step. +# +# `RepayLoan` clamps rather than refuses: paying more than is outstanding debits +# only what was owed. That is the kind of behaviour an in-process test tends to +# assert with the number it just computed; here it is checked against the +# account balance, which is the only place the clamp is observable. + +model AuthModel +client front +do RegisterUser username=liskov password=liskov-pass displayName="B. Liskov" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=liskov password=liskov-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id +expect ok field balanceMinor == 0 + +# ── Applying ──────────────────────────────────────────────────────────────── +client desk model=LoanModel principal=$who + +# status 0 is LoanStatus::Active. Outstanding starts equal to the principal: +# no interest is capitalised at disbursement. +do ApplyLoan accountId=$acct principalMinor=1000000 rateBps=500 termMonths=12 +expect ok capture loan=$.id +expect ok field accountId == $acct +expect ok field principalMinor == 1000000 +expect ok field outstandingMinor == 1000000 +expect ok field rateBps == 500 +expect ok field termMonths == 12 +expect ok field status == 0 +expect ok field owner == "liskov" +expect ok field currency == 0 + +# A term is required: `ApplyLoan::validate()` demands `termMonths > 0`. +do ApplyLoan accountId=$acct principalMinor=1000 rateBps=0 termMonths=0 +expect err message == "action failed validation: LoanModel/ApplyLoan" + +# ── The money actually arrived ────────────────────────────────────────────── +client single model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == 1000000 + +# kind 7 is TxnKind::LoanDisbursement, direction 0 is Credit. +client teller model=TransactionModel principal=$who +do History accountId=$acct limit=1 offset=0 +expect ok field entries[0].kind == 7 +expect ok field entries[0].direction == 0 +expect ok field entries[0].amountMinor == 1000000 +expect ok field entries[0].description == "loan disbursement" + +# ── Reading it back ───────────────────────────────────────────────────────── +use desk +do GetLoan id=$loan +expect ok field id == $loan +expect ok field outstandingMinor == 1000000 +expect ok field status == 0 + +do ListLoans owner=$who +expect ok field loans ~ "\"id\":$loan," + +# ── A partial repayment ───────────────────────────────────────────────────── +do RepayLoan loanId=$loan fromAccountId=$acct amountMinor=400000 +expect ok field outstandingMinor == 600000 +expect ok field status == 0 + +use single +do GetAccount id=$acct +expect ok field balanceMinor == 600000 + +# kind 8 is TxnKind::LoanRepayment, direction 1 is Debit. +use teller +do History accountId=$acct limit=1 offset=0 +expect ok field entries[0].kind == 8 +expect ok field entries[0].direction == 1 +expect ok field entries[0].amountMinor == 400000 + +# ── Overpaying is clamped, not refused ────────────────────────────────────── +# 600000 is outstanding and 999999 is offered; only 600000 is taken, which is +# visible in the account balance and nowhere else. +use desk +do RepayLoan loanId=$loan fromAccountId=$acct amountMinor=999999 +expect ok field outstandingMinor == 0 +# status 1 is LoanStatus::PaidOff — reached by the repayment, not by a separate +# close action. +expect ok field status == 1 + +use single +do GetAccount id=$acct +expect ok field balanceMinor == 0 + +use teller +do History accountId=$acct limit=1 offset=0 +expect ok field entries[0].kind == 8 +expect ok field entries[0].amountMinor == 600000 + +# ── A settled loan is settled ─────────────────────────────────────────────── +use desk +do RepayLoan loanId=$loan fromAccountId=$acct amountMinor=1000 +expect err message == "loan is not active" + +do GetLoan id=$loan +expect ok field status == 1 +expect ok field outstandingMinor == 0 + +# A loan that does not exist, and one belonging to somebody else, are refused +# by the same guard. +do GetLoan id=999999 +expect err message == "loan not found" diff --git a/scripts/scenario/scenarios/bank/a-loan-schedule-amortises-to-zero.scenario b/scripts/scenario/scenarios/bank/a-loan-schedule-amortises-to-zero.scenario new file mode 100644 index 000000000..b87783db6 --- /dev/null +++ b/scripts/scenario/scenarios/bank/a-loan-schedule-amortises-to-zero.scenario @@ -0,0 +1,89 @@ +# bank — the amortisation schedule: a pure computation over a loan, which must +# end at exactly nothing outstanding. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/a-loan-schedule-amortises-to-zero.scenario +# +# `LoanScheduleRequest` writes nothing and moves nothing; it is the one bank +# action that is entirely derivation. What makes it worth a scenario is the last +# row: an amortisation built from a rounded monthly payment does not naturally +# land on zero, so `LoanModel` forces the final instalment's principal share to +# clear whatever remains. A rounding change that left a few minor units behind +# would be invisible to any assertion that only checked the first row. +# +# The zero-interest loan is here for the same reason from the other side: with +# `rateBps=0` every instalment is pure principal, so the arithmetic is exactly +# predictable and a schedule that quietly charged interest anyway would show up. + +model AuthModel +client front +do RegisterUser username=hamilton password=hamilton-pass displayName="M. Hamilton" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=hamilton password=hamilton-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id + +client desk model=LoanModel principal=$who + +# ── An interest-free loan divides exactly ─────────────────────────────────── +# 12000 minor units over 12 months at 0bps: 1000 a month, no interest anywhere. +do ApplyLoan accountId=$acct principalMinor=12000 rateBps=0 termMonths=12 +expect ok capture free=$.id +expect ok field outstandingMinor == 12000 + +do LoanScheduleRequest loanId=$free +expect ok field loanId == $free +expect ok field monthlyPaymentMinor == 1000 +expect ok field installments[0].month == 1 +expect ok field installments[0].paymentMinor == 1000 +expect ok field installments[0].principalMinor == 1000 +expect ok field installments[0].interestMinor == 0 +expect ok field installments[0].remainingMinor == 11000 +expect ok field installments[1].month == 2 +expect ok field installments[1].remainingMinor == 10000 +# The last row is month 12 and it clears the balance exactly. +expect ok field installments[11].month == 12 +expect ok field installments[11].interestMinor == 0 +expect ok field installments[11].remainingMinor == 0 + +# ── An interest-bearing loan still ends at zero ───────────────────────────── +# The point of this one is the final `remainingMinor`, which is forced rather +# than arrived at. +do ApplyLoan accountId=$acct principalMinor=1000000 rateBps=1200 termMonths=24 +expect ok capture bearing=$.id + +do LoanScheduleRequest loanId=$bearing +expect ok capture monthly=$.monthlyPaymentMinor +expect ok field loanId == $bearing +expect ok field installments[0].month == 1 +# Each instalment pays the same total; only the principal/interest split moves. +expect ok field installments[0].paymentMinor == $monthly +expect ok field installments[1].paymentMinor == $monthly +# Interest is charged on the outstanding balance, so month 1 charges a full +# month at 1200bps on the whole principal: 1000000 * 1200 / 10000 / 12 = 10000. +expect ok field installments[0].interestMinor == 10000 +expect ok field installments[23].month == 24 +expect ok field installments[23].remainingMinor == 0 + +# ── It is a projection, not a payment ─────────────────────────────────────── +# Asking for a schedule twice gives the same answer and changes nothing: the +# loan is still fully outstanding and the account still holds both disbursements. +do LoanScheduleRequest loanId=$bearing +expect ok field installments[23].remainingMinor == 0 + +do GetLoan id=$bearing +expect ok field outstandingMinor == 1000000 +expect ok field status == 0 + +client single model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == 1012000 + +# A schedule for a loan that does not exist. +use desk +do LoanScheduleRequest loanId=999999 +expect err message == "loan not found" diff --git a/scripts/scenario/scenarios/bank/a-payee-and-the-bill-paid-to-them.scenario b/scripts/scenario/scenarios/bank/a-payee-and-the-bill-paid-to-them.scenario new file mode 100644 index 000000000..fa6e383a5 --- /dev/null +++ b/scripts/scenario/scenarios/bank/a-payee-and-the-bill-paid-to-them.scenario @@ -0,0 +1,108 @@ +# bank — saving a beneficiary and paying them: one instruction that settles +# immediately and shows up in three places at once. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/a-payee-and-the-bill-paid-to-them.scenario +# +# `PayBill` is the only one of bank's three payment actions that *moves money*. +# It debits the account and creates the payment row inside a single +# `SqlTransaction`, comes back already `Completed`, and posts a ledger entry +# whose counterparty is the payee. The other two (`SchedulePayment`, +# `CreateStandingOrder`) only write an instruction — see +# a-scheduled-payment-waits-and-can-be-cancelled.scenario for the contrast. + +model AuthModel +client front +do RegisterUser username=babbage password=babbage-pass displayName="C. Babbage" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=babbage password=babbage-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id + +client teller model=TransactionModel principal=$who +do Deposit accountId=$acct amountMinor=200000 description="funding" +expect ok field balanceAfterMinor == 200000 + +# ── The beneficiary ───────────────────────────────────────────────────────── +client book model=PayeeModel principal=$who + +# `AddPayee::validate()` wants a name and something IBAN-shaped: two letters, +# then alphanumerics, 15..34 characters. It is a shape check, not a mod-97 +# checksum, and this file asserts only what that check actually promises. +do AddPayee name="City Power" iban=DE89370400440532013000 bankName="Stadtbank" +expect ok capture payee=$.id +expect ok field name == "City Power" +expect ok field iban == "DE89370400440532013000" +expect ok field bankName == "Stadtbank" +expect ok field owner == "babbage" + +# Too short to be an IBAN, so morph refuses it before `PayeeModel` runs. +do AddPayee name="Dodgy Ltd" iban=DE89 bankName="Nowhere" +expect err message == "action failed validation: PayeeModel/AddPayee" + +# A name is required too — a plausible IBAN alone is not a beneficiary. +do AddPayee name="" iban=DE89370400440532013000 bankName="Stadtbank" +expect err message == "action failed validation: PayeeModel/AddPayee" + +do ListPayees owner=$who +expect ok field payees ~ "\"id\":$payee," +expect ok field payees ~ "City Power" + +# ── Paying them ───────────────────────────────────────────────────────────── +client bills model=PaymentModel principal=$who + +# schedule 0 is PaymentSchedule::OneOff and status 1 is +# PaymentStatus::Completed: this settled during the call, not afterwards. +do PayBill fromAccountId=$acct payeeId=$payee amountMinor=45000 description="March electricity" +expect ok capture payment=$.id +expect ok field fromAccountId == $acct +expect ok field payeeId == $payee +expect ok field amountMinor == 45000 +expect ok field schedule == 0 +expect ok field status == 1 +expect ok field owner == "babbage" +expect ok field intervalDays == 0 + +# ── It moved real money ───────────────────────────────────────────────────── +client single model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == 155000 + +# kind 4 is TxnKind::Payment, direction 1 is Debit, and the counterparty on the +# ledger row is the payee — not the destination account, of which there is none. +use teller +do History accountId=$acct limit=1 offset=0 +expect ok field entries[0].kind == 4 +expect ok field entries[0].direction == 1 +expect ok field entries[0].amountMinor == 45000 +expect ok field entries[0].balanceAfterMinor == 155000 +expect ok field entries[0].counterpartyId == $payee +expect ok field entries[0].description == "March electricity" + +# ── And it is on the payment list ─────────────────────────────────────────── +use bills +do ListPayments owner=$who +expect ok field payments ~ "\"id\":$payment," + +# ── What a bill payment will not do ───────────────────────────────────────── +# More than the account holds, with no overdraft: refused by the same guard +# every other debit goes through. +do PayBill fromAccountId=$acct payeeId=$payee amountMinor=99999999 description="too much" +expect err message == "amount exceeds available balance plus overdraft" + +# A beneficiary that does not exist. +do PayBill fromAccountId=$acct payeeId=999999 amountMinor=100 description="to nobody" +expect err message == "payee not found" + +# A completed payment is terminal: there is nothing left to cancel. +do CancelPayment id=$payment +expect err message == "only pending payments can be cancelled" + +# The refusals moved nothing. +use single +do GetAccount id=$acct +expect ok field balanceMinor == 155000 diff --git a/scripts/scenario/scenarios/bank/a-payee-removed-can-no-longer-be-paid.scenario b/scripts/scenario/scenarios/bank/a-payee-removed-can-no-longer-be-paid.scenario new file mode 100644 index 000000000..5216e8f3c --- /dev/null +++ b/scripts/scenario/scenarios/bank/a-payee-removed-can-no-longer-be-paid.scenario @@ -0,0 +1,98 @@ +# bank — removing a beneficiary: they leave the address book, the bills already +# paid to them stay, and nothing new can be sent. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/a-payee-removed-can-no-longer-be-paid.scenario +# +# The interesting half of a delete is what it does *not* take with it. A payee +# row is referenced by every payment made to it and by the counterparty on each +# of those ledger entries, so removing one is a chance to break history. This +# file pays a bill, removes the payee, and then reads the history back to show +# the record survived the beneficiary. + +model AuthModel +client front +do RegisterUser username=ritchie password=ritchie-pass displayName="D. Ritchie" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=ritchie password=ritchie-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id + +client teller model=TransactionModel principal=$who +do Deposit accountId=$acct amountMinor=90000 description="funding" +expect ok field balanceAfterMinor == 90000 + +# ── Two beneficiaries, so removing one is visibly not removing both ───────── +client book model=PayeeModel principal=$who +do AddPayee name="Water Board" iban=FR1420041010050500013M02606 bankName="Banque Postale" +expect ok capture doomed=$.id +expect ok field name == "Water Board" + +do AddPayee name="Broadband Co" iban=IE29AIBK93115212345678 bankName="AIB" +expect ok capture keeper=$.id + +do ListPayees owner=$who +expect ok field payees ~ "\"id\":$doomed," +expect ok field payees ~ "\"id\":$keeper," + +# ── Pay one of them, so there is history to preserve ──────────────────────── +client bills model=PaymentModel principal=$who +do PayBill fromAccountId=$acct payeeId=$doomed amountMinor=12000 description="Q1 water" +expect ok capture payment=$.id +expect ok field status == 1 + +use teller +do History accountId=$acct limit=1 offset=0 +expect ok field entries[0].counterpartyId == $doomed +expect ok field entries[0].kind == 4 + +# ── Remove them ───────────────────────────────────────────────────────────── +use book +do RemovePayee id=$doomed +expect ok field ok == true +expect ok field message == "payee removed" + +# Gone from the address book; the other one is not. +do ListPayees owner=$who +expect ok field payees !~ "\"id\":$doomed," +expect ok field payees ~ "\"id\":$keeper," + +# Removing them twice is a lookup failure the second time. +do RemovePayee id=$doomed +expect err message == "payee not found" + +# ── Nothing new can be sent to them ───────────────────────────────────────── +use bills +do PayBill fromAccountId=$acct payeeId=$doomed amountMinor=1000 description="one more" +expect err message == "payee not found" + +do SchedulePayment fromAccountId=$acct payeeId=$doomed amountMinor=1000 dueAtMs=2000000000000 description="one more, later" +expect err message == "payee not found" + +# ── But what was already sent is still on the record ──────────────────────── +# The ledger row still names the removed payee as its counterparty, and the +# payment row is still listed. A delete that had cascaded would have taken the +# customer's own history with it. +use teller +do History accountId=$acct limit=1 offset=0 +expect ok field entries[0].counterpartyId == $doomed +expect ok field entries[0].amountMinor == 12000 + +use bills +do ListPayments owner=$who +expect ok field payments ~ "\"id\":$payment," + +# And the money is still where the payment left it. +client single model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == 78000 + +# The surviving payee still works. +use bills +do PayBill fromAccountId=$acct payeeId=$keeper amountMinor=3000 description="broadband" +expect ok field status == 1 +expect ok field payeeId == $keeper diff --git a/scripts/scenario/scenarios/bank/a-scheduled-payment-waits-and-can-be-cancelled.scenario b/scripts/scenario/scenarios/bank/a-scheduled-payment-waits-and-can-be-cancelled.scenario new file mode 100644 index 000000000..2cdf19929 --- /dev/null +++ b/scripts/scenario/scenarios/bank/a-scheduled-payment-waits-and-can-be-cancelled.scenario @@ -0,0 +1,100 @@ +# bank — a future-dated payment is written down and nothing moves; cancelling +# it is the only thing that can still happen to it. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/a-scheduled-payment-waits-and-can-be-cancelled.scenario +# +# The contrast with `PayBill` is the whole point. `PayBill` debits inside a +# transaction and comes back `Completed`; `SchedulePayment` writes one row and +# touches no balance and no ledger, coming back `Pending`. Bank ships no runner +# that later settles it — there is no background job in this rung — so +# `Pending` is where a scheduled payment stays until somebody cancels it. This +# file asserts that standstill rather than pretending a due date makes something +# happen, because a scenario that waited for one would be asserting on a clock. + +model AuthModel +client front +do RegisterUser username=wilkes password=wilkes-pass displayName="M. Wilkes" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=wilkes password=wilkes-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id + +client teller model=TransactionModel principal=$who +do Deposit accountId=$acct amountMinor=80000 description="funding" +expect ok field balanceAfterMinor == 80000 + +client book model=PayeeModel principal=$who +do AddPayee name="Landlord" iban=GB29NWBK60161331926819 bankName="NatWest" +expect ok capture payee=$.id +expect ok field name == "Landlord" + +# ── Scheduling ────────────────────────────────────────────────────────────── +client bills model=PaymentModel principal=$who + +# A fixed epoch-ms far in the future (2033-05-18T03:33:20Z). A literal rather +# than "now plus something": nothing in this runner reads a clock, and a due +# date that drifted with the wall clock would make the file's meaning depend on +# when it ran. +do SchedulePayment fromAccountId=$acct payeeId=$payee amountMinor=30000 dueAtMs=2000000000000 description="June rent" +expect ok capture scheduled=$.id +expect ok field amountMinor == 30000 +expect ok field dueAtMs == 2000000000000 +# schedule 1 is PaymentSchedule::Scheduled, status 0 is PaymentStatus::Pending. +expect ok field schedule == 1 +expect ok field status == 0 +expect ok field intervalDays == 0 + +# A due date is required: `SchedulePayment::validate()` demands `dueAtMs > 0`, +# which is what separates it from a `PayBill`. +do SchedulePayment fromAccountId=$acct payeeId=$payee amountMinor=30000 dueAtMs=0 description="no date" +expect err message == "action failed validation: PaymentModel/SchedulePayment" + +# ── Nothing moved ─────────────────────────────────────────────────────────── +client single model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == 80000 + +# No ledger entry either: the only row on this account is the funding deposit. +use teller +do History accountId=$acct limit=10 offset=0 +expect ok field entries[0].kind == 0 +expect ok field entries[0].amountMinor == 80000 +expect ok field entries !~ "[}],[{]" + +# Scheduling more than the account holds is *not* refused, because nothing is +# debited yet — the funds check belongs to settlement, which bank has no runner +# for. Asserted because it is surprising, not because it is desirable. +use bills +do SchedulePayment fromAccountId=$acct payeeId=$payee amountMinor=99999999 dueAtMs=2000000000000 description="more than exists" +expect ok capture overdrawn=$.id +expect ok field status == 0 + +use single +do GetAccount id=$acct +expect ok field balanceMinor == 80000 + +# ── Cancelling ────────────────────────────────────────────────────────────── +use bills +do CancelPayment id=$scheduled +expect ok field ok == true +expect ok field message == "payment cancelled" + +# status 2 is PaymentStatus::Cancelled, and cancelling is once-only. +do CancelPayment id=$scheduled +expect err message == "only pending payments can be cancelled" + +do CancelPayment id=$overdrawn +expect ok field ok == true + +# A payment that does not exist, and one that never belonged to this owner, +# are refused by the same ownership guard every id-addressed action shares. +do CancelPayment id=999999 +expect err message == "payment not found" + +do ListPayments owner=$who +expect ok field payments ~ "\"id\":$scheduled," diff --git a/scripts/scenario/scenarios/bank/a-standing-order-recurs-on-paper-only.scenario b/scripts/scenario/scenarios/bank/a-standing-order-recurs-on-paper-only.scenario new file mode 100644 index 000000000..0d7d5abca --- /dev/null +++ b/scripts/scenario/scenarios/bank/a-standing-order-recurs-on-paper-only.scenario @@ -0,0 +1,98 @@ +# bank — a recurring instruction: an interval, a first due date, and no money +# moving on any of them. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/a-standing-order-recurs-on-paper-only.scenario +# +# `CreateStandingOrder` is the third of bank's payment shapes and the only one +# carrying an `intervalDays`. Like `SchedulePayment` it writes an instruction +# and nothing else; unlike it, the instruction claims to repeat. Bank has no +# component that acts on either claim, so what is genuinely testable here is +# the record and its lifecycle, and that is all this file asserts. + +model AuthModel +client front +do RegisterUser username=dijkstra password=dijkstra-pass displayName="E. Dijkstra" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=dijkstra password=dijkstra-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id + +client teller model=TransactionModel principal=$who +do Deposit accountId=$acct amountMinor=150000 description="funding" +expect ok field balanceAfterMinor == 150000 + +client book model=PayeeModel principal=$who +do AddPayee name="Gym Membership" iban=NL91ABNA0417164300 bankName="ABN AMRO" +expect ok capture payee=$.id +expect ok field name == "Gym Membership" + +# ── The order ─────────────────────────────────────────────────────────────── +client bills model=PaymentModel principal=$who + +# schedule 2 is PaymentSchedule::Standing, status 0 is Pending. `firstDueAtMs` +# lands in the record's `dueAtMs` — the field is named for when it was set, not +# for what it holds. +do CreateStandingOrder fromAccountId=$acct payeeId=$payee amountMinor=5000 intervalDays=30 firstDueAtMs=2000000000000 description="monthly gym" +expect ok capture order=$.id +expect ok field amountMinor == 5000 +expect ok field intervalDays == 30 +expect ok field dueAtMs == 2000000000000 +expect ok field schedule == 2 +expect ok field status == 0 + +# An interval is what makes it standing rather than scheduled, so +# `CreateStandingOrder::validate()` demands a positive one. +do CreateStandingOrder fromAccountId=$acct payeeId=$payee amountMinor=5000 intervalDays=0 firstDueAtMs=2000000000000 description="no interval" +expect err message == "action failed validation: PaymentModel/CreateStandingOrder" + +# ── Nothing has been collected ────────────────────────────────────────────── +client single model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == 150000 + +use teller +do History accountId=$acct limit=10 offset=0 +expect ok field entries !~ "[}],[{]" +expect ok field entries[0].kind == 0 + +# ── The three shapes side by side ─────────────────────────────────────────── +# One account, one payee, one list, and three rows that differ only in the two +# fields that decide what each one is. +use bills +do PayBill fromAccountId=$acct payeeId=$payee amountMinor=5000 description="this month, by hand" +expect ok capture paid=$.id +expect ok field schedule == 0 +expect ok field status == 1 + +do SchedulePayment fromAccountId=$acct payeeId=$payee amountMinor=5000 dueAtMs=2000000000000 description="next month, once" +expect ok capture once=$.id +expect ok field schedule == 1 +expect ok field status == 0 + +do ListPayments owner=$who +expect ok field payments ~ "\"id\":$order," +expect ok field payments ~ "\"id\":$paid," +expect ok field payments ~ "\"id\":$once," + +# Only the hand-made payment actually took money. +use single +do GetAccount id=$acct +expect ok field balanceMinor == 145000 + +# ── Cancelling the pending two ────────────────────────────────────────────── +use bills +do CancelPayment id=$order +expect ok field ok == true +expect ok field message == "payment cancelled" + +do CancelPayment id=$once +expect ok field ok == true + +# The completed one is untouchable, then and now. +do CancelPayment id=$paid +expect err message == "only pending payments can be cancelled" diff --git a/scripts/scenario/scenarios/bank/a-statement-covers-every-account-an-owner-has.scenario b/scripts/scenario/scenarios/bank/a-statement-covers-every-account-an-owner-has.scenario new file mode 100644 index 000000000..009ce00b3 --- /dev/null +++ b/scripts/scenario/scenarios/bank/a-statement-covers-every-account-an-owner-has.scenario @@ -0,0 +1,106 @@ +# bank — the statement: one line per account the owner holds, including the +# accounts nothing happened on. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/a-statement-covers-every-account-an-owner-has.scenario +# +# `GenerateStatement` is the only bank action that fans out across all of an +# owner's accounts at once, and it has one property worth pinning that a +# per-account report would not: an account with no entries in the window still +# gets a line, carrying its balance and three zeroes. A statement that silently +# omitted a dormant account would look identical to one that had never been +# opened. +# +# The second property is a genuine oddity, asserted because it is real rather +# than because it is right: `closingBalanceMinor` is the account's *current* +# balance, not its balance as at `toMs`. Narrowing the window changes the +# credits and debits and leaves the closing balance alone. + +model AuthModel +client front +do RegisterUser username=perlis password=perlis-pass displayName="A. Perlis" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=perlis password=perlis-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture busy=$.id +do OpenAccount owner=$who kind=1 currency=0 +expect ok capture dormant=$.id +expect ok field balanceMinor == 0 + +# ── Activity on one of them only ──────────────────────────────────────────── +client teller model=TransactionModel principal=$who +do Deposit accountId=$busy amountMinor=400000 description="salary" +expect ok field balanceAfterMinor == 400000 +do Deposit accountId=$busy amountMinor=50000 description="refund" +expect ok field balanceAfterMinor == 450000 +do Withdraw accountId=$busy amountMinor=120000 description="rent" +expect ok field balanceAfterMinor == 330000 + +# ── The statement ─────────────────────────────────────────────────────────── +client paper model=StatementModel principal=$who + +# fromMs 0 and toMs 0 means "everything, with no upper bound". +do GenerateStatement owner=$who fromMs=0 toMs=0 +expect ok field owner == "perlis" +expect ok field fromMs == 0 +expect ok field toMs == 0 + +# Both accounts appear. +# +# Matched by captured account id rather than by list position, and asserted +# without the all-time totals, because a statement is the one bank result that +# fans out over *every* account its owner has ever opened. This file opens two +# more on each pass, so `lines[0]` and `totalCreditsMinor` mean something +# different every run, while the line belonging to a given account does not. +# `[^}]*` keeps each match inside one line object rather than letting it run on +# into the next. +# +# StatementLine's field order is accountId, number, currency, creditsMinor, +# debitsMinor, closingBalanceMinor, entryCount. +expect ok field lines ~ "\"accountId\":$busy,[^}]*\"creditsMinor\":450000,\"debitsMinor\":120000,\"closingBalanceMinor\":330000,\"entryCount\":3" +expect ok field lines ~ "\"accountId\":$busy,\"number\":\"DE[0-9]{20}\",\"currency\":0," + +# The dormant one is present with a line of zeroes, not absent. This is the +# assertion the file exists for: an account with no entries in the window is +# still reported, rather than looking like an account that was never opened. +expect ok field lines ~ "\"accountId\":$dormant,[^}]*\"creditsMinor\":0,\"debitsMinor\":0,\"closingBalanceMinor\":0,\"entryCount\":0" + +# ── A window that excludes everything ─────────────────────────────────────── +# Every entry above predates this instant, so the credits and debits fall to +# zero — but the lines stay, and so does each account's live balance. That is +# the `closingBalanceMinor` oddity, visible only when the window moves. +# These totals *are* pass-invariant: no entry in this database is ever written +# after this instant, whatever previous passes left behind. +do GenerateStatement owner=$who fromMs=2000000000000 toMs=0 +expect ok field totalCreditsMinor == 0 +expect ok field totalDebitsMinor == 0 +expect ok field lines ~ "\"accountId\":$busy,[^}]*\"creditsMinor\":0,\"debitsMinor\":0,\"closingBalanceMinor\":330000,\"entryCount\":0" + +# An upper bound below every entry does the same thing, so both ends of the +# window are genuinely applied. +do GenerateStatement owner=$who fromMs=0 toMs=1 +expect ok field totalCreditsMinor == 0 +expect ok field totalDebitsMinor == 0 +expect ok field lines ~ "\"accountId\":$busy,[^}]*\"entryCount\":0" + +# ── An owner with no accounts at all ──────────────────────────────────────── +# A statement with no lines, rather than a refusal: the owner exists and simply +# holds nothing. +client other model=AuthModel +do RegisterUser username=perlis-no-accounts password=none-pass displayName="No Accounts" +expect ok field message ~ "^(registered|username already taken)$" + +use paper +do GenerateStatement owner=perlis-no-accounts fromMs=0 toMs=0 +expect ok field owner == "perlis-no-accounts" +expect ok field lines == [] +expect ok field totalCreditsMinor == 0 + +# An owner who was never registered has no `users` row to resolve, and that is +# reported as an authorization failure rather than as an empty statement. +do GenerateStatement owner=nobody-ever fromMs=0 toMs=0 +expect err message == "unknown user: nobody-ever" diff --git a/scripts/scenario/scenarios/bank/a-transfer-moves-both-balances.scenario b/scripts/scenario/scenarios/bank/a-transfer-moves-both-balances.scenario new file mode 100644 index 000000000..118883c46 --- /dev/null +++ b/scripts/scenario/scenarios/bank/a-transfer-moves-both-balances.scenario @@ -0,0 +1,82 @@ +# bank — a transfer between two of one customer's accounts: one action, two +# balances, two ledger rows, and a counterparty on each. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/a-transfer-moves-both-balances.scenario +# +# `Transfer` is the smallest action in bank that has to be atomic: it debits one +# account and credits another inside a single `SqlTransaction`, and posts a +# `TransferOut` entry on one side and a `TransferIn` on the other, each naming +# the other account as its counterparty. Driving it from outside the process is +# the only way to see that as one call rather than as two the caller sequenced. + +model AuthModel +client front +do RegisterUser username=hopper password=hopper-pass displayName="Hopper" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=hopper password=hopper-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +# kind 0 Checking, kind 1 Savings — both in USD, because bank refuses to move +# money between currencies (see cross-currency-transfers-are-refused.scenario). +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture current=$.id +do OpenAccount owner=$who kind=1 currency=0 +expect ok capture savings=$.id +# Savings accounts are opened at 150bps; the checking account above earns +# nothing. The rate is a property of the kind, not of the request. +expect ok field interestBps == 150 + +client teller model=TransactionModel principal=$who +do Deposit accountId=$current amountMinor=300000 description="salary" +expect ok field balanceAfterMinor == 300000 + +# ── The transfer ──────────────────────────────────────────────────────────── +# One call, and the result is both resulting balances — not an id to go and +# read twice. +do Transfer fromAccountId=$current toAccountId=$savings amountMinor=120000 description="to savings" +expect ok field fromBalanceMinor == 180000 +expect ok field toBalanceMinor == 120000 + +# ── Both sides really moved ───────────────────────────────────────────────── +client single model=AccountModel principal=$who +do GetAccount id=$current +expect ok field balanceMinor == 180000 +do GetAccount id=$savings +expect ok field balanceMinor == 120000 + +# ── And both sides were journalled, each naming the other ─────────────────── +use teller + +# kind 3 is TxnKind::TransferOut, direction 1 is Debit. +do History accountId=$current limit=1 offset=0 +expect ok field entries[0].kind == 3 +expect ok field entries[0].direction == 1 +expect ok field entries[0].amountMinor == 120000 +expect ok field entries[0].balanceAfterMinor == 180000 +expect ok field entries[0].counterpartyId == $savings +expect ok field entries[0].description == "to savings" + +# kind 2 is TxnKind::TransferIn, direction 0 is Credit. +do History accountId=$savings limit=1 offset=0 +expect ok field entries[0].kind == 2 +expect ok field entries[0].direction == 0 +expect ok field entries[0].amountMinor == 120000 +expect ok field entries[0].balanceAfterMinor == 120000 +expect ok field entries[0].counterpartyId == $current + +# ── What a transfer will not do ───────────────────────────────────────────── +# An account cannot pay itself. `Transfer::validate()` demands the two differ, +# so morph refuses this before the model runs. +do Transfer fromAccountId=$current toAccountId=$current amountMinor=100 description="to myself" +expect err message == "action failed validation: TransactionModel/Transfer" + +# More than the source holds, with no overdraft agreed on it. +do Transfer fromAccountId=$current toAccountId=$savings amountMinor=99999999 description="everything" +expect err message == "amount exceeds available balance plus overdraft" + +# And the failed transfer moved nothing on either side. +do History accountId=$current limit=1 offset=0 +expect ok field entries[0].amountMinor == 120000 diff --git a/scripts/scenario/scenarios/bank/an-overdraft-is-a-floor-not-a-suggestion.scenario b/scripts/scenario/scenarios/bank/an-overdraft-is-a-floor-not-a-suggestion.scenario new file mode 100644 index 000000000..b3a109362 --- /dev/null +++ b/scripts/scenario/scenarios/bank/an-overdraft-is-a-floor-not-a-suggestion.scenario @@ -0,0 +1,67 @@ +# bank — how far an account may go negative, and what happens at exactly the +# limit. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/an-overdraft-is-a-floor-not-a-suggestion.scenario +# +# Every debit in bank goes through one guard (`db::applyDebit` in +# bank/db/ledger_ops.hpp): it refuses when the balance the debit *would* leave +# is below `-overdraftMinor`. That makes the limit inclusive — a withdrawal +# landing exactly on the floor is allowed and the next penny is not — and an +# off-by-one there is invisible to any test that only tries obviously-too-much. +# This file walks up to the boundary and steps over it. + +model AuthModel +client front +do RegisterUser username=turing password=turing-pass displayName="Alan Turing" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=turing password=turing-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +# A 50.00 overdraft, in minor units. +do OpenAccount owner=$who kind=0 currency=0 overdraftMinor=5000 +expect ok capture acct=$.id +expect ok field overdraftMinor == 5000 +expect ok field balanceMinor == 0 + +client teller model=TransactionModel principal=$who +do Deposit accountId=$acct amountMinor=10000 description="float" +expect ok field balanceAfterMinor == 10000 + +# ── Down to zero: ordinary ────────────────────────────────────────────────── +do Withdraw accountId=$acct amountMinor=10000 description="down to zero" +expect ok field balanceAfterMinor == 0 + +# ── Into the overdraft, but not through it ────────────────────────────────── +do Withdraw accountId=$acct amountMinor=3000 description="into the overdraft" +expect ok field balanceAfterMinor == -3000 + +# Exactly onto the floor. `projected < -overdraftMinor` is the refusal test, so +# `projected == -overdraftMinor` is permitted — this is the boundary case. +do Withdraw accountId=$acct amountMinor=2000 description="exactly to the limit" +expect ok field balanceAfterMinor == -5000 + +# One minor unit past it is refused. +do Withdraw accountId=$acct amountMinor=1 description="one past the limit" +expect err message == "amount exceeds available balance plus overdraft" + +# ── The refusal changed nothing ───────────────────────────────────────────── +client single model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == -5000 + +# And no ledger row was written for the refused debit: the newest entry is +# still the one that took the account to the floor. +use teller +do History accountId=$acct limit=1 offset=0 +expect ok field entries[0].amountMinor == 2000 +expect ok field entries[0].balanceAfterMinor == -5000 +expect ok field entries[0].description == "exactly to the limit" + +# ── Back to zero, so the account is left as it was found ──────────────────── +# Not tidiness: the account is opened fresh each pass, but leaving it overdrawn +# would make every balance above depend on this file never having crashed. +do Deposit accountId=$acct amountMinor=5000 description="clearing the overdraft" +expect ok field balanceAfterMinor == 0 diff --git a/scripts/scenario/scenarios/bank/an-owner-named-outright-is-not-checked-against-the-session.scenario b/scripts/scenario/scenarios/bank/an-owner-named-outright-is-not-checked-against-the-session.scenario new file mode 100644 index 000000000..0c1ec35bf --- /dev/null +++ b/scripts/scenario/scenarios/bank/an-owner-named-outright-is-not-checked-against-the-session.scenario @@ -0,0 +1,169 @@ +# bank — the gap between the actions that check who is asking and the ones that +# take an owner's name at face value. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/an-owner-named-outright-is-not-checked-against-the-session.scenario +# +# This file asserts behaviour that is **wrong**, deliberately, because it is the +# behaviour bank has and a corpus that only recorded the parts that work would +# be worth less than nothing on the day somebody changed them. +# +# Bank has two ways of deciding whose data an action touches: +# +# * By **row id**, through `db::loadOwned` — which navigates the row to its +# owner and compares that with the session principal. That is genuine +# enforcement, and another-customer-cannot-touch-your-account.scenario is +# the file that pins it. +# +# * By **owner name**, through `bank::resolveOwner(action.owner)` — which +# returns whatever string the caller put in the `owner` field and only falls +# back to the session principal when that field is empty. Nothing compares +# the two. The resolved name is used solely to look a `users` row up. +# +# So every list-shaped action carrying an `owner` field will read another +# customer's data for anyone who types their username, and `MarkAllRead` will +# *write* to it. `SpendingByKind` is worse again: it takes a bare `accountId`, +# consults no owner at all, and reports on any account in the database. +# +# When this is fixed, this file will fail, and it should: the fix is to compare +# the resolved owner with `sessionPrincipal()` and refuse when they differ. The +# `expect ok`s below are the inventory of everything that change has to cover. + +model AuthModel + +client mallory-auth +do RegisterUser username=snooper password=snooper-pass displayName="Snooper" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=snooper password=snooper-pass +expect ok capture snoop=$.principal +expect ok field ok == true + +client target-auth +do RegisterUser username=quarry password=quarry-pass displayName="Quarry" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=quarry password=quarry-pass +expect ok capture prey=$.principal +expect ok field ok == true + +# ── The quarry's private banking ──────────────────────────────────────────── +client q-vault model=CustomerModel principal=$prey +do OpenAccount owner=$prey kind=0 currency=0 +expect ok capture qAcct=$.id + +client q-teller model=TransactionModel principal=$prey +do Deposit accountId=$qAcct amountMinor=750000 description="quarry's salary" +expect ok field balanceAfterMinor == 750000 +do Withdraw accountId=$qAcct amountMinor=25000 description="quarry's rent" +expect ok field balanceAfterMinor == 725000 + +client q-book model=PayeeModel principal=$prey +do AddPayee name="Quarry's Therapist" iban=CH9300762011623852957 bankName="UBS" +expect ok capture qPayee=$.id + +client q-bills model=PaymentModel principal=$prey +do PayBill fromAccountId=$qAcct payeeId=$qPayee amountMinor=15000 description="a private matter" +expect ok capture qPayment=$.id + +client q-desk model=LoanModel principal=$prey +do ApplyLoan accountId=$qAcct principalMinor=50000 rateBps=0 termMonths=6 +expect ok capture qLoan=$.id + +client q-plan model=BudgetModel principal=$prey +do SetBudget category=quarry-secret monthlyLimitMinor=99000 currency=0 +expect ok capture qBudget=$.id + +client q-alerts model=NotificationModel principal=$prey +do Notify message="Quarry's confidential alert" severity=2 +expect ok capture qNote=$.id + +# ── Everything the snooper can read by simply naming them ─────────────────── +# The session below is `snooper` throughout. Every one of these succeeds. + +client s-vault model=CustomerModel principal=$snoop +do ListAccounts owner=$prey +expect ok field accounts ~ "\"id\":$qAcct," +# 750000 in, 25000 rent out, 15000 bill out, 50000 loan disbursed in. +expect ok field accounts ~ "\"balanceMinor\":760000" + +client s-book model=PayeeModel principal=$snoop +do ListPayees owner=$prey +expect ok field payees ~ "\"id\":$qPayee," +expect ok field payees ~ "Quarry's Therapist" + +client s-bills model=PaymentModel principal=$snoop +do ListPayments owner=$prey +expect ok field payments ~ "\"id\":$qPayment," +expect ok field payments ~ "a private matter" + +client s-desk model=LoanModel principal=$snoop +do ListLoans owner=$prey +expect ok field loans ~ "\"id\":$qLoan," + +client s-plan model=BudgetModel principal=$snoop +do ListBudgets owner=$prey +expect ok field budgets ~ "\"id\":$qBudget," +expect ok field budgets ~ "quarry-secret" + +client s-alerts model=NotificationModel principal=$snoop +do ListNotifications owner=$prey unreadOnly=false +expect ok field notifications ~ "Quarry's confidential alert" +expect ok field unreadCount == 1 + +client s-paper model=StatementModel principal=$snoop +# Pinned to this pass's account rather than to the statement's totals: a +# statement fans out over every account its owner has ever held, and this file +# opens one more on each pass. 25000 rent plus a 15000 bill. +do GenerateStatement owner=$prey fromMs=0 toMs=0 +expect ok field owner == "quarry" +expect ok field lines ~ "\"accountId\":$qAcct,[^}]*\"debitsMinor\":40000," + +# `SpendingByKind` needs no owner at all — just an account id, which +# `ListAccounts` above has already handed over. It lives on `BudgetModel`. +use s-plan +do SpendingByKind accountId=$qAcct sinceMs=0 +expect ok field accountId == $qAcct +expect ok field totalDebitsMinor == 40000 + +# ── And the one that lets a stranger *write* ──────────────────────────────── +# `MarkAllRead` resolves its owner the same way, so this marks the quarry's +# notification read from the snooper's session. +use s-alerts +do MarkAllRead owner=$prey +expect ok field ok == true +expect ok field message == "1 marked read" + +use s-alerts +do ListNotifications owner=$prey unreadOnly=false +expect ok field unreadCount == 0 + +# ── Where the line actually holds ─────────────────────────────────────────── +# The moment an action names a *row* instead of an owner, the guard bites — and +# the two spellings sit side by side on the same model. +client s-single model=AccountModel principal=$snoop +do GetAccount id=$qAcct +expect err message == "account belongs to a different owner" + +use s-alerts +do MarkRead id=$qNote +expect err message == "notification belongs to a different owner" + +use s-plan +do DeleteBudget id=$qBudget +expect err message == "budget belongs to a different owner" + +use s-desk +do GetLoan id=$qLoan +expect err message == "loan belongs to a different owner" + +# ── An owner that was never registered is still refused ───────────────────── +# The resolved name has to reach a `users` row, so this is a lookup failure +# rather than an authorization one — which is itself a way of asking the server +# whether a username exists. +use s-vault +do ListAccounts owner=no-such-customer +expect err message == "unknown user: no-such-customer" + +# Left as found. +use q-plan +do DeleteBudget id=$qBudget +expect ok field ok == true diff --git a/scripts/scenario/scenarios/bank/another-customer-cannot-touch-your-account.scenario b/scripts/scenario/scenarios/bank/another-customer-cannot-touch-your-account.scenario new file mode 100644 index 000000000..544c3ad51 --- /dev/null +++ b/scripts/scenario/scenarios/bank/another-customer-cannot-touch-your-account.scenario @@ -0,0 +1,157 @@ +# bank — two customers on one server: everything addressed by id is checked +# against the principal that asked for it. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/another-customer-cannot-touch-your-account.scenario +# +# Two clients, two sessions, one database. This is the case an in-process rig +# assumes away — it arrives holding one session and never has a second one to +# confuse it with — and it is the case that matters most in a bank. +# +# Every ownership refusal below comes from the same place: `db::loadOwned` and +# `db::loadOwnedOpenAccount` in bank/db/ledger_ops.hpp navigate the row's +# `BelongsTo` relation to its user and compare that username with the caller's +# principal. One guard, reused by accounts, payees, payments, cards, loans, +# budgets and notifications, which is why the messages below differ only in the +# noun. +# +# Note what the guard checks *first*: `loadOwnedOpenAccount` looks up the row, +# then ownership, then whether it is open. So a stranger asking about somebody's +# closed account is told it belongs to another owner, and never learns that it +# is closed. + +model AuthModel + +# ── Two customers ─────────────────────────────────────────────────────────── +client mallory-auth +do RegisterUser username=mallory password=mallory-pass displayName="Mallory" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=mallory password=mallory-pass +expect ok capture mal=$.principal +expect ok field ok == true + +client victim-auth +do RegisterUser username=victoria password=victoria-pass displayName="Victoria" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=victoria password=victoria-pass +expect ok capture vic=$.principal +expect ok field ok == true +expect ok field principal == "victoria" + +# ── Victoria banks ────────────────────────────────────────────────────────── +client vic-vault model=CustomerModel principal=$vic +do OpenAccount owner=$vic kind=0 currency=0 +expect ok capture vicAcct=$.id +expect ok field owner == "victoria" + +client vic-teller model=TransactionModel principal=$vic +do Deposit accountId=$vicAcct amountMinor=250000 description="victoria's money" +expect ok field balanceAfterMinor == 250000 + +client vic-book model=PayeeModel principal=$vic +do AddPayee name="Victoria's Landlord" iban=GB33BUKB20201555555555 bankName="Barclays" +expect ok capture vicPayee=$.id + +client vic-wallet model=CardModel principal=$vic +do IssueCard accountId=$vicAcct kind=0 dailyLimitMinor=10000 +expect ok capture vicCard=$.id + +client vic-desk model=LoanModel principal=$vic +do ApplyLoan accountId=$vicAcct principalMinor=100000 rateBps=0 termMonths=6 +expect ok capture vicLoan=$.id + +client vic-alerts model=NotificationModel principal=$vic +do Notify message="Victoria's private alert" severity=1 +expect ok capture vicNote=$.id + +client vic-plan model=BudgetModel principal=$vic +do SetBudget category=victoria-only monthlyLimitMinor=10000 currency=0 +expect ok capture vicBudget=$.id + +# ── Mallory cannot read the account ───────────────────────────────────────── +client mal-single model=AccountModel principal=$mal +do GetAccount id=$vicAcct +expect err message == "account belongs to a different owner" + +do CloseAccount id=$vicAcct +expect err message == "account belongs to a different owner" + +# ── Nor move money in or out of it ────────────────────────────────────────── +client mal-teller model=TransactionModel principal=$mal +do Withdraw accountId=$vicAcct amountMinor=1000 description="helping myself" +expect err message == "account belongs to a different owner" + +# Not even a *deposit*: the guard is on the account, not on the direction. +do Deposit accountId=$vicAcct amountMinor=1000 description="a gift, allegedly" +expect err message == "account belongs to a different owner" + +# Mallory's own account exists, so this transfer is refused for the source +# rather than for want of a destination. +client mal-vault model=CustomerModel principal=$mal +do OpenAccount owner=$mal kind=0 currency=0 +expect ok capture malAcct=$.id + +use mal-teller +do Transfer fromAccountId=$vicAcct toAccountId=$malAcct amountMinor=1000 description="draining" +expect err message == "account belongs to a different owner" + +# And the other way round: Mallory owns the source but not the destination. +do Transfer fromAccountId=$malAcct toAccountId=$vicAcct amountMinor=1000 description="pushing" +expect err message == "account belongs to a different owner" + +# ── Nor reach anything else of Victoria's ─────────────────────────────────── +client mal-book model=PayeeModel principal=$mal +do RemovePayee id=$vicPayee +expect err message == "payee belongs to a different owner" + +client mal-wallet model=CardModel principal=$mal +do FreezeCard id=$vicCard +expect err message == "card belongs to a different owner" + +do ChangePin id=$vicCard newPin="0000" +expect err message == "card belongs to a different owner" + +client mal-desk model=LoanModel principal=$mal +do GetLoan id=$vicLoan +expect err message == "loan belongs to a different owner" + +do LoanScheduleRequest loanId=$vicLoan +expect err message == "loan belongs to a different owner" + +client mal-alerts model=NotificationModel principal=$mal +do MarkRead id=$vicNote +expect err message == "notification belongs to a different owner" + +client mal-plan model=BudgetModel principal=$mal +do DeleteBudget id=$vicBudget +expect err message == "budget belongs to a different owner" + +# ── Paying a bill needs to own both ends ──────────────────────────────────── +client mal-bills model=PaymentModel principal=$mal +do PayBill fromAccountId=$malAcct payeeId=$vicPayee amountMinor=100 description="to her landlord" +expect err message == "payee belongs to a different owner" + +# ── Nothing Mallory tried left a mark ─────────────────────────────────────── +client vic-single model=AccountModel principal=$vic +do GetAccount id=$vicAcct +expect ok field balanceMinor == 350000 +expect ok field status == 0 + +use vic-alerts +do ListNotifications owner=$vic unreadOnly=true +expect ok field notifications ~ "\"id\":$vicNote," + +# ── And Victoria still has what is hers ───────────────────────────────────── +use vic-teller +do History accountId=$vicAcct limit=1 offset=0 +expect ok field entries[0].kind == 7 +expect ok field entries[0].amountMinor == 100000 + +# Left as found. +use vic-alerts +do MarkAllRead owner=$vic +expect ok field ok == true + +use vic-plan +do DeleteBudget id=$vicBudget +expect ok field ok == true diff --git a/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario b/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario new file mode 100644 index 000000000..707fb4de8 --- /dev/null +++ b/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario @@ -0,0 +1,166 @@ +# bank — what an anonymous connection can and cannot do. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario +# +# A client may connect and register a model without any session at all — bank's +# server installs no `authorizeRegister` override, so the instances get created +# — and it can then execute exactly two things: `RegisterUser` and +# `LoginRequest`, the pair that exist to get it a principal. Everything else is +# refused for want of one. +# +# The mechanism is worth stating because it is not bank's. `RemoteServer` +# consults the authorizer's `authenticate()` and, when it declines to vouch for +# the caller, *clears* `session.principal` rather than passing the client's +# claim through (`stampVerifiedPrincipal`, include/morph/core/remote.hpp). +# Bank's server declines exactly when the claim is empty. So the model does not +# see "no session" as a special case — it sees an empty principal, and every +# owner-resolving action refuses on that. +# +# The refusals come in two shapes, and which one an action gives away is a +# property of how it resolves its owner, not of how important it is: +# +# * "no session principal" — the action checked for an empty owner itself. +# * "... belongs to a different owner" — the action did not check, and the +# empty string simply failed to match the row's real owner further down. +# +# `SchedulePayment` and `CreateStandingOrder` are the clearest case of the +# second shape: unlike their sibling `PayBill` they have no empty-owner guard at +# all, so an anonymous caller is turned away by the ownership comparison instead. + +model AuthModel + +# ── An anonymous client can get itself a principal, and nothing else ──────── +client nobody + +# Registering is open by necessity: it is how a principal comes to exist. +do RegisterUser username=newcomer password=newcomer-pass displayName="Newcomer" +expect ok field message ~ "^(registered|username already taken)$" + +# So is signing in. +do LoginRequest username=newcomer password=newcomer-pass +expect ok capture who=$.principal +expect ok field ok == true +expect ok field principal == "newcomer" + +# But the reply is only a *claim* until the client installs it. This connection +# has not, so it is still anonymous. +do WhoAmI +expect ok field authenticated == false +expect ok field principal == "" + +# ── Set the scene with a real session, on a different connection ──────────── +client member model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id + +client member-teller model=TransactionModel principal=$who +do Deposit accountId=$acct amountMinor=100000 description="funding" +expect ok field balanceAfterMinor == 100000 + +client member-book model=PayeeModel principal=$who +do AddPayee name="Somebody" iban=BE68539007547034 bankName="KBC" +expect ok capture payee=$.id + +# ── Anonymous: refused with "no session principal" ────────────────────────── +client anon-vault model=CustomerModel +do OpenAccount kind=0 currency=0 +expect err message == "no session principal to own the account" + +do ListAccounts +expect err message == "no session principal to list accounts for" + +client anon-book model=PayeeModel +do AddPayee name="Anonymous Payee" iban=BE68539007547034 bankName="KBC" +expect err message == "no session principal" + +do ListPayees +expect err message == "no session principal" + +client anon-wallet model=CardModel +do IssueCard accountId=$acct kind=0 dailyLimitMinor=1000 +expect err message == "no session principal" + +do ListCards +expect err message == "no session principal" + +client anon-desk model=LoanModel +do ApplyLoan accountId=$acct principalMinor=1000 rateBps=0 termMonths=6 +expect err message == "no session principal" + +do ListLoans +expect err message == "no session principal" + +client anon-plan model=BudgetModel +do SetBudget category=anonymous monthlyLimitMinor=100 currency=0 +expect err message == "no session principal" + +do ListBudgets +expect err message == "no session principal" + +client anon-alerts model=NotificationModel +do Notify message="from nobody" severity=0 +expect err message == "no session principal" + +do ListNotifications unreadOnly=false +expect err message == "no session principal" + +do MarkAllRead +expect err message == "no session principal" + +client anon-paper model=StatementModel +do GenerateStatement fromMs=0 toMs=0 +expect err message == "no session principal" + +client anon-bills model=PaymentModel +do PayBill fromAccountId=$acct payeeId=$payee amountMinor=100 description="anonymously" +expect err message == "no session principal" + +# ── Anonymous: refused by the ownership guard instead ─────────────────────── +# These actions never check for an empty owner, so the empty principal simply +# fails to match the row's real one. Same outcome, different message — and the +# difference is visible only from outside the process. +client anon-teller model=TransactionModel +do Deposit accountId=$acct amountMinor=100 description="anonymously" +expect err message == "account belongs to a different owner" + +do Withdraw accountId=$acct amountMinor=100 description="anonymously" +expect err message == "account belongs to a different owner" + +use anon-bills +do SchedulePayment fromAccountId=$acct payeeId=$payee amountMinor=100 dueAtMs=2000000000000 description="anonymously" +expect err message == "account belongs to a different owner" + +do CreateStandingOrder fromAccountId=$acct payeeId=$payee amountMinor=100 intervalDays=30 firstDueAtMs=2000000000000 description="anonymously" +expect err message == "account belongs to a different owner" + +client anon-single model=AccountModel +do GetAccount id=$acct +expect err message == "account belongs to a different owner" + +do CloseAccount id=$acct +expect err message == "account belongs to a different owner" + +# ── One action an anonymous client can still reach ────────────────────────── +# `SpendingByKind` consults no owner and no session at all, so it answers +# anybody. Asserted here for the same reason as in +# an-owner-named-outright-is-not-checked-against-the-session.scenario: it is the +# behaviour bank has, and a corpus that quietly omitted it would let the day it +# changes pass unnoticed. +use anon-plan +do SpendingByKind accountId=$acct sinceMs=0 +expect ok field accountId == $acct +expect ok field totalDebitsMinor == 0 + +# ── Nothing anonymous left a mark ─────────────────────────────────────────── +client check model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == 100000 +expect ok field status == 0 + +# ── Installing the principal is the whole difference ──────────────────────── +# Same connection as the anonymous vault above, one `session` step later. +use anon-vault +session principal=$who +do ListAccounts +expect ok field accounts ~ "\"id\":$acct," diff --git a/scripts/scenario/scenarios/bank/budgets-are-upserted-by-category.scenario b/scripts/scenario/scenarios/bank/budgets-are-upserted-by-category.scenario new file mode 100644 index 000000000..baffd08a1 --- /dev/null +++ b/scripts/scenario/scenarios/bank/budgets-are-upserted-by-category.scenario @@ -0,0 +1,96 @@ +# bank — setting a monthly limit twice for the same category updates it rather +# than adding a second one. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/budgets-are-upserted-by-category.scenario +# +# `SetBudget` is an upsert keyed on (owner, category), and there is nothing in +# its name or its result to say so — it returns a `BudgetInfo` either way, and +# the second call returns the *same* id as the first. That identity is the whole +# assertion: a client that assumed "set" meant "create" would accumulate a +# duplicate row per save, and only a caller who kept the first id could tell. +# +# Being an upsert is also what makes this file re-runnable: a second pass over +# the same database re-sets the same categories rather than colliding with them. + +model AuthModel +client front +do RegisterUser username=wing password=wing-pass displayName="J. Wing" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=wing password=wing-pass +expect ok capture who=$.principal +expect ok field ok == true + +client plan model=BudgetModel principal=$who + +# ── Setting one ───────────────────────────────────────────────────────────── +do SetBudget category=groceries monthlyLimitMinor=60000 currency=0 +expect ok capture groceries=$.id +expect ok field category == "groceries" +expect ok field monthlyLimitMinor == 60000 +expect ok field currency == 0 +expect ok field owner == "wing" + +# ── Setting it again is an update, and the id proves it ───────────────────── +do SetBudget category=groceries monthlyLimitMinor=75000 currency=0 +expect ok field id == $groceries +expect ok field monthlyLimitMinor == 75000 +expect ok field category == "groceries" + +# A different category is a different budget. +do SetBudget category=transport monthlyLimitMinor=20000 currency=0 +expect ok capture transport=$.id +expect ok field monthlyLimitMinor == 20000 +expect ok field id != $groceries + +# ── Both are listed, at their current limits ──────────────────────────────── +do ListBudgets owner=$who +expect ok field budgets ~ "\"id\":$groceries," +expect ok field budgets ~ "\"id\":$transport," +expect ok field budgets ~ "\"monthlyLimitMinor\":75000" +expect ok field budgets ~ "\"monthlyLimitMinor\":20000" +# The superseded limit is gone rather than sitting beside its replacement. +expect ok field budgets !~ "\"monthlyLimitMinor\":60000" + +# ── A zero limit is a budget; a negative one is not ───────────────────────── +# `SetBudget::validate()` allows `monthlyLimitMinor >= 0`, so "spend nothing on +# this" is expressible, and morph refuses the negative case before the model. +do SetBudget category=takeaway monthlyLimitMinor=0 currency=0 +expect ok capture takeaway=$.id +expect ok field monthlyLimitMinor == 0 + +do SetBudget category=impossible monthlyLimitMinor=-1 currency=0 +expect err message == "action failed validation: BudgetModel/SetBudget" + +# A category is required — an unnamed limit belongs to nothing. +do SetBudget category="" monthlyLimitMinor=1000 currency=0 +expect err message == "action failed validation: BudgetModel/SetBudget" + +# The refused pair added nothing. +do ListBudgets owner=$who +expect ok field budgets !~ "impossible" + +# ── Deleting ──────────────────────────────────────────────────────────────── +do DeleteBudget id=$takeaway +expect ok field ok == true +expect ok field message == "budget deleted" + +do ListBudgets owner=$who +expect ok field budgets !~ "\"id\":$takeaway," +expect ok field budgets ~ "\"id\":$groceries," + +# Deleting is once-only, and a hard delete: there is no tombstone to find. +do DeleteBudget id=$takeaway +expect err message == "budget not found" + +do DeleteBudget id=999999 +expect err message == "budget not found" + +# ── Left as found, so the next pass sees the same starting point ──────────── +do DeleteBudget id=$groceries +expect ok field ok == true +do DeleteBudget id=$transport +expect ok field ok == true + +do ListBudgets owner=$who +expect ok field budgets == [] diff --git a/scripts/scenario/scenarios/bank/change-a-password-and-sign-in-again.scenario b/scripts/scenario/scenarios/bank/change-a-password-and-sign-in-again.scenario new file mode 100644 index 000000000..79d96ed16 --- /dev/null +++ b/scripts/scenario/scenarios/bank/change-a-password-and-sign-in-again.scenario @@ -0,0 +1,75 @@ +# bank — changing a password: the old one stops working, the new one starts, +# and the wrong current password changes nothing. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/change-a-password-and-sign-in-again.scenario +# +# `ChangePassword` carries no `validate()` predicate, so unlike most of bank's +# mutating actions it is the *model* that refuses a bad request here, with its +# own messages. That is why the refusals below read like domain errors and the +# ones in register-sign-in-and-open-an-account.scenario read like framework +# ones — the difference is which half of the stack got to the request first. +# +# The file ends by putting the original password back. That is not tidiness: a +# scenario must be re-runnable against a database it has already run on, and a +# file that left `grace` on a rotated password would sign in with the wrong one +# on its second pass. + +model AuthModel +client grace + +do RegisterUser username=grace password=first-pass displayName="Grace Hopper" +expect ok field message ~ "^(registered|username already taken)$" + +do LoginRequest username=grace password=first-pass +expect ok capture who=$.principal +expect ok field ok == true +expect ok field displayName == "Grace Hopper" + +session principal=$who +do WhoAmI +expect ok field authenticated == true +expect ok field principal == "grace" + +# ── The current password is what authorises the change ────────────────────── +do ChangePassword username=$who oldPassword=not-the-password newPassword=second-pass +expect err message == "current password does not match" + +# And the refusal really did change nothing: the original still signs in. +do LoginRequest username=$who password=first-pass +expect ok field ok == true +expect ok field message == "welcome" + +# A new password too short to be usable is refused on its own merits, after the +# current one has already been accepted. +do ChangePassword username=$who oldPassword=first-pass newPassword=abc +expect err message == "new password must be at least 4 characters" + +# Changing a password for somebody who does not exist is a lookup failure, and +# it is reported as one rather than as a credential mismatch. +do ChangePassword username=nobody-at-all oldPassword=x newPassword=abcd +expect err message == "no such user" + +# ── The real rotation ─────────────────────────────────────────────────────── +do ChangePassword username=$who oldPassword=first-pass newPassword=second-pass +expect ok field ok == true +expect ok field message == "password changed" + +# The old one is dead. Note this is a *failed sign-in*, not an error: bank +# reports bad credentials in the result rather than by throwing. +do LoginRequest username=$who password=first-pass +expect ok field ok == false +expect ok field message == "invalid credentials" +expect ok field principal == "" + +do LoginRequest username=$who password=second-pass +expect ok field ok == true +expect ok field principal == "grace" + +# ── Put it back, so the next pass over this database starts where this one did +do ChangePassword username=$who oldPassword=second-pass newPassword=first-pass +expect ok field ok == true + +do LoginRequest username=$who password=first-pass +expect ok field ok == true +expect ok field principal == "grace" diff --git a/scripts/scenario/scenarios/bank/closing-an-account-needs-a-zero-balance.scenario b/scripts/scenario/scenarios/bank/closing-an-account-needs-a-zero-balance.scenario new file mode 100644 index 000000000..00bb38d89 --- /dev/null +++ b/scripts/scenario/scenarios/bank/closing-an-account-needs-a-zero-balance.scenario @@ -0,0 +1,86 @@ +# bank — an account is emptied before it is closed, and a closed account stops +# accepting money. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/closing-an-account-needs-a-zero-balance.scenario +# +# `CloseAccount` is the one bank action that reports a refusal as an ordinary +# result rather than by throwing: a non-zero balance comes back as +# `CommandResult{ok:false}` on an `ok` envelope, not as an `err`. A client that +# only checked the envelope would read "closed" from a refusal, which is +# precisely the confusion this file pins down. +# +# It also exercises the seam `AccountModel` exists for. That model is *stateful* +# and keyed by account id: it holds the row in memory and re-reads it only when +# the row version it captured has moved (bank/db/row_versions.hpp). Closing +# writes through and bumps that version, so the `GetAccount` afterwards is +# reading a cache that had to notice it was stale. + +model AuthModel +client front +do RegisterUser username=lovelace password=lovelace-pass displayName="A. Lovelace" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=lovelace password=lovelace-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id +expect ok field status == 0 + +client teller model=TransactionModel principal=$who +do Deposit accountId=$acct amountMinor=7500 description="something to clear" +expect ok field balanceAfterMinor == 7500 + +# ── A funded account will not close ───────────────────────────────────────── +client single model=AccountModel principal=$who + +# Note the envelope: this is `ok`, and the refusal is in the body. +do CloseAccount id=$acct +expect ok field ok == false +expect ok field message == "account balance must be zero before closing" + +# Still open, and still holding the money. +do GetAccount id=$acct +expect ok field status == 0 +expect ok field balanceMinor == 7500 + +# ── Empty it, then close it ───────────────────────────────────────────────── +use teller +do Withdraw accountId=$acct amountMinor=7500 description="closing balance out" +expect ok field balanceAfterMinor == 0 + +use single +do CloseAccount id=$acct +expect ok field ok == true +expect ok field message == "account closed" + +# status 2 is AccountStatus::Closed. This read goes through the cached row that +# `CloseAccount` just wrote and re-versioned. +do GetAccount id=$acct +expect ok field status == 2 +expect ok field balanceMinor == 0 + +# ── A closed account is out of service ────────────────────────────────────── +# Every money-movement path shares one "owned and open" guard, so all of them +# refuse with the same message once the account is closed. +use teller +do Deposit accountId=$acct amountMinor=100 description="after closing" +expect err message == "account is not open" + +do Withdraw accountId=$acct amountMinor=100 description="after closing" +expect err message == "account is not open" + +# ── But its history survives ──────────────────────────────────────────────── +# Closing an account is not deleting it: the ledger is still readable, which is +# what a customer asking for last year's statement depends on. +do History accountId=$acct limit=10 offset=0 +expect ok field entries[0].kind == 1 +expect ok field entries[0].amountMinor == 7500 +expect ok field entries[1].kind == 0 + +# Closing an account that does not exist names no other row. +use single +do CloseAccount id=999999 +expect err message == "account not found" diff --git a/scripts/scenario/scenarios/bank/cross-currency-transfers-are-refused.scenario b/scripts/scenario/scenarios/bank/cross-currency-transfers-are-refused.scenario new file mode 100644 index 000000000..ad2636136 --- /dev/null +++ b/scripts/scenario/scenarios/bank/cross-currency-transfers-are-refused.scenario @@ -0,0 +1,79 @@ +# bank — accounts of different currencies exist side by side, and money does +# not cross between them. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/cross-currency-transfers-are-refused.scenario +# +# Bank carries no exchange rate anywhere, so a transfer between two currencies +# has no defined amount to credit. `TransactionModel` refuses it rather than +# inventing a rate — and refuses it *after* loading and authorising both +# accounts, which is why this is the model's own error and not a validation +# one: `Transfer::validate()` cannot see a currency, only ids and an amount. +# +# The currency is also the one enum bank stores that has a non-uniform minor +# unit: JPY has no fractional digits (`currencyDecimals`), so the same integer +# means a different amount of money in a JPY account than in a USD one. That is +# exactly why the two must not be added together. + +model AuthModel +client front +do RegisterUser username=nakamoto password=nakamoto-pass displayName="S. Nakamoto" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=nakamoto password=nakamoto-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who + +# currency 0 USD, 1 EUR, 4 JPY — the numeric values are the wire encoding and +# bank's own header says to append to them, never to renumber. +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture usd=$.id +expect ok field currency == 0 + +do OpenAccount owner=$who kind=0 currency=1 +expect ok capture eur=$.id +expect ok field currency == 1 + +do OpenAccount owner=$who kind=0 currency=4 +expect ok capture jpy=$.id +expect ok field currency == 4 + +client teller model=TransactionModel principal=$who +do Deposit accountId=$usd amountMinor=500000 description="funding the dollar account" +expect ok field balanceAfterMinor == 500000 +expect ok field currency == 0 + +# ── The refusal ───────────────────────────────────────────────────────────── +do Transfer fromAccountId=$usd toAccountId=$eur amountMinor=100000 description="dollars to euros" +expect err message == "cross-currency transfers are not supported" + +do Transfer fromAccountId=$usd toAccountId=$jpy amountMinor=100000 description="dollars to yen" +expect err message == "cross-currency transfers are not supported" + +# ── Nothing moved on either side ──────────────────────────────────────────── +client single model=AccountModel principal=$who +do GetAccount id=$usd +expect ok field balanceMinor == 500000 +do GetAccount id=$eur +expect ok field balanceMinor == 0 + +# The euro account has no ledger at all — the refused transfer wrote no entry +# to the destination any more than it did to the source. +use teller +do History accountId=$eur limit=10 offset=0 +expect ok field entries == [] + +# ── Within one currency it goes through ───────────────────────────────────── +use vault +do OpenAccount owner=$who kind=1 currency=0 +expect ok capture usd2=$.id + +use teller +do Transfer fromAccountId=$usd toAccountId=$usd2 amountMinor=100000 description="dollars to dollars" +expect ok field fromBalanceMinor == 400000 +expect ok field toBalanceMinor == 100000 + +# And each currency keeps its own books: the yen account was never touched. +do History accountId=$jpy limit=10 offset=0 +expect ok field entries == [] diff --git a/scripts/scenario/scenarios/bank/deposit-withdraw-and-read-the-ledger.scenario b/scripts/scenario/scenarios/bank/deposit-withdraw-and-read-the-ledger.scenario new file mode 100644 index 000000000..f68a28522 --- /dev/null +++ b/scripts/scenario/scenarios/bank/deposit-withdraw-and-read-the-ledger.scenario @@ -0,0 +1,101 @@ +# bank — money in, money out, and the ledger that records both. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/deposit-withdraw-and-read-the-ledger.scenario +# +# Every amount in bank is an integer in minor units — cents for USD — and every +# ledger row carries the balance the account held *after* that entry. Those two +# facts together are what make a history readable without re-deriving anything, +# and this file pins them. +# +# The account is opened fresh on every pass rather than reused, which is what +# makes the balances below exact numbers instead of ranges: a file that banked +# into an account a previous run had left money in could only ever assert +# "more than before". + +model AuthModel +client front +do RegisterUser username=knuth password=knuth-pass displayName="Don Knuth" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=knuth password=knuth-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id +expect ok field balanceMinor == 0 + +# ── Money in ──────────────────────────────────────────────────────────────── +client teller model=TransactionModel principal=$who + +# The returned entry is the ledger row itself, not an acknowledgement: it +# carries the amount, the kind, and the balance the account was left at. +do Deposit accountId=$acct amountMinor=125000 description="opening deposit" +expect ok capture firstEntry=$.id +expect ok field accountId == $acct +expect ok field amountMinor == 125000 +expect ok field balanceAfterMinor == 125000 +# direction 0 is TxnDirection::Credit, kind 0 is TxnKind::Deposit. +expect ok field direction == 0 +expect ok field kind == 0 +expect ok field currency == 0 +expect ok field description == "opening deposit" +# A deposit has no counterparty, and the nullable relation surfaces as 0. +expect ok field counterpartyId == 0 + +# ── Money out ─────────────────────────────────────────────────────────────── +# direction 1 is TxnDirection::Debit, kind 1 is TxnKind::Withdrawal. +do Withdraw accountId=$acct amountMinor=45000 description="rent" +expect ok field amountMinor == 45000 +expect ok field balanceAfterMinor == 80000 +expect ok field direction == 1 +expect ok field kind == 1 + +# A zero-amount movement is not a movement. `Deposit::validate()` demands a +# positive amount, so morph refuses it before `TransactionModel` sees it. +do Deposit accountId=$acct amountMinor=0 description="nothing at all" +expect err message == "action failed validation: TransactionModel/Deposit" + +do Withdraw accountId=$acct amountMinor=-500 description="negative" +expect err message == "action failed validation: TransactionModel/Withdraw" + +# An account that does not exist is a lookup failure, and the refusal names the +# account rather than leaking whether some other row has that id. +do Deposit accountId=999999 amountMinor=100 description="into the void" +expect err message == "account not found" + +# ── What the account says now ─────────────────────────────────────────────── +client single model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == 80000 +expect ok field status == 0 + +# ── What the ledger says ──────────────────────────────────────────────────── +use teller + +# Newest first: the withdrawal is entry 0 and the deposit is entry 1. +do History accountId=$acct limit=10 offset=0 +expect ok field accountId == $acct +expect ok field entries[0].kind == 1 +expect ok field entries[0].amountMinor == 45000 +expect ok field entries[0].balanceAfterMinor == 80000 +expect ok field entries[1].id == $firstEntry +expect ok field entries[1].kind == 0 +expect ok field entries[1].balanceAfterMinor == 125000 + +# Paging is done in the database, not by trimming a full read in memory, and +# the offset skips the newest entry rather than the oldest. +do History accountId=$acct limit=1 offset=1 +expect ok field entries[0].id == $firstEntry +# Exactly one entry came back: the compact JSON of a multi-element array of +# objects necessarily contains `},{`, and this one does not. Written as a +# pattern rather than as `entries[1] == null` because an absent path fails +# every operator in this runner, `==` included, so that spelling would assert +# nothing about the length. +expect ok field entries !~ "[}],[{]" + +# A page of zero entries is a page, not an error. +do History accountId=$acct limit=0 offset=0 +expect ok field accountId == $acct +expect ok field entries == [] diff --git a/scripts/scenario/scenarios/bank/notifications-are-raised-and-cleared.scenario b/scripts/scenario/scenarios/bank/notifications-are-raised-and-cleared.scenario new file mode 100644 index 000000000..2a9184670 --- /dev/null +++ b/scripts/scenario/scenarios/bank/notifications-are-raised-and-cleared.scenario @@ -0,0 +1,111 @@ +# bank — alerts: raised unread, cleared one at a time or all at once, and +# counted in a way that does not depend on how the list was filtered. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/notifications-are-raised-and-cleared.scenario +# +# The subtle part of `ListNotifications` is that `unreadOnly` filters the list +# but *not* the count: `unreadCount` is always the owner's true unread total, +# even on a call that asked for only unread rows and could therefore have +# computed it by taking the length. Those two numbers agree in the filtered case +# and differ in the unfiltered one, so a single call can never tell them apart — +# which is why this file makes the same assertion both ways round. +# +# The file finishes by marking everything read, so a second pass over the same +# database starts from the same clean slate this one did. + +model AuthModel +client front +do RegisterUser username=clarke password=clarke-pass displayName="E. Clarke" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=clarke password=clarke-pass +expect ok capture who=$.principal +expect ok field ok == true + +client alerts model=NotificationModel principal=$who + +# Nothing outstanding to begin with — guaranteed by this file's own final step +# on every previous pass. +do ListNotifications owner=$who unreadOnly=false +expect ok field unreadCount == 0 + +# ── Raising them ──────────────────────────────────────────────────────────── +# Severity 0..2; a notification is always created unread, whatever is asked. +do Notify message="Your statement is ready" severity=0 +expect ok capture first=$.id +expect ok field message == "Your statement is ready" +expect ok field severity == 0 +expect ok field read == false +expect ok field owner == "clarke" + +do Notify message="Unusual card activity" severity=2 +expect ok capture second=$.id +expect ok field severity == 2 +expect ok field read == false + +# `Notify::validate()` bounds the severity at 0..2 and demands a message, so +# both refusals come from morph rather than from `NotificationModel`. +do Notify message="Off the scale" severity=9 +expect err message == "action failed validation: NotificationModel/Notify" + +do Notify message="" severity=1 +expect err message == "action failed validation: NotificationModel/Notify" + +# ── Counting ──────────────────────────────────────────────────────────────── +do ListNotifications owner=$who unreadOnly=false +expect ok field unreadCount == 2 +expect ok field notifications ~ "\"id\":$first," +expect ok field notifications ~ "\"id\":$second," + +# Same count, filtered list. Here the two happen to agree. +do ListNotifications owner=$who unreadOnly=true +expect ok field unreadCount == 2 +expect ok field notifications ~ "\"id\":$first," + +# ── Clearing one ──────────────────────────────────────────────────────────── +do MarkRead id=$first +expect ok field ok == true +expect ok field message == "marked read" + +# Unfiltered: both rows are still listed, but only one is still unread. This is +# the call where the count and the list length disagree. +do ListNotifications owner=$who unreadOnly=false +expect ok field unreadCount == 1 +expect ok field notifications ~ "\"id\":$first," +expect ok field notifications ~ "\"id\":$second," + +# Filtered: the read one is gone from the list, and the count is the same 1. +do ListNotifications owner=$who unreadOnly=true +expect ok field unreadCount == 1 +expect ok field notifications !~ "\"id\":$first," +expect ok field notifications ~ "\"id\":$second," + +# Marking a read notification read again is accepted rather than refused — it +# sets the flag without checking it first. +do MarkRead id=$first +expect ok field ok == true + +do MarkRead id=999999 +expect err message == "notification not found" + +# ── Clearing the rest ─────────────────────────────────────────────────────── +# The acknowledgement counts the rows it actually changed, so the already-read +# one is not counted again. +do MarkAllRead owner=$who +expect ok field ok == true +expect ok field message == "1 marked read" + +do ListNotifications owner=$who unreadOnly=false +expect ok field unreadCount == 0 +expect ok field notifications ~ "\"id\":$second," + +# Nothing left to clear, and that is reported rather than refused. +do MarkAllRead owner=$who +expect ok field ok == true +expect ok field message == "0 marked read" + +# The unread-only view of a fully-read inbox is empty, and the rows are still +# there — marking read is not deleting. +do ListNotifications owner=$who unreadOnly=true +expect ok field notifications == [] +expect ok field unreadCount == 0 diff --git a/scripts/scenario/scenarios/bank/one-account-two-clients-and-a-stale-cache.scenario b/scripts/scenario/scenarios/bank/one-account-two-clients-and-a-stale-cache.scenario new file mode 100644 index 000000000..97df8201d --- /dev/null +++ b/scripts/scenario/scenarios/bank/one-account-two-clients-and-a-stale-cache.scenario @@ -0,0 +1,139 @@ +# bank — the stateful model: two clients holding the same account in memory, +# and a third writing to it behind both of their backs. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/one-account-two-clients-and-a-stale-cache.scenario +# +# `AccountModel` is the one model in this tree that is deliberately *stateful*: +# it keeps the account row in memory for the life of the instance and serves +# `GetAccount` from there, which is the reason morph runs each model on its own +# strand at all (bank/include/bank/models/account_model.hpp). +# +# The hazard that creates is the point of this file. Money moves through +# `TransactionModel`, `PaymentModel` and `LoanModel`, each on its own database +# connection, so a balance change lands in SQLite without the cached row hearing +# about it. Bank's answer is a process-wide version counter per account +# (bank/db/row_versions.hpp): every writer bumps it, and `AccountModel::hydrate` +# re-reads whenever the version it captured has moved. +# +# Only an out-of-process client can show this. An in-process test holds one +# `AccountModel` instance and a `SimulatedRemoteBackend` in one process; here +# there are two independent connections, each with its own registered instance, +# each having already cached the row, and a third connection doing the writing. +# If the version check were dropped, both readers below would keep answering +# with the balance they first saw and every assertion after the first deposit +# would fail. + +model AuthModel +client front +do RegisterUser username=cache-holder password=cache-pass displayName="Cache Holder" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=cache-holder password=cache-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id +expect ok field balanceMinor == 0 + +# ── Two readers, each caching the row ─────────────────────────────────────── +# Separate connections and separate registered instances. Both read the account +# once here, which is what puts it in each instance's memory — a later read that +# never re-hydrated would answer 0 forever. +client reader-one model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == 0 +expect ok field status == 0 + +client reader-two model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field balanceMinor == 0 + +# ── A third connection writes ─────────────────────────────────────────────── +client writer model=TransactionModel principal=$who +do Deposit accountId=$acct amountMinor=300000 description="landing behind the cache" +expect ok field balanceAfterMinor == 300000 + +# ── Both readers notice ───────────────────────────────────────────────────── +use reader-one +do GetAccount id=$acct +expect ok field balanceMinor == 300000 + +use reader-two +do GetAccount id=$acct +expect ok field balanceMinor == 300000 + +# ── A write through a different model again ───────────────────────────────── +# `PayBill` debits on `PaymentModel`'s own connection, so this is a second +# distinct writer, not the same one twice. +client book model=PayeeModel principal=$who +do AddPayee name="Cache Test Payee" iban=AT611904300234573201 bankName="Erste" +expect ok capture payee=$.id + +client bills model=PaymentModel principal=$who +do PayBill fromAccountId=$acct payeeId=$payee amountMinor=50000 description="through another connection" +expect ok field status == 1 + +use reader-one +do GetAccount id=$acct +expect ok field balanceMinor == 250000 + +use reader-two +do GetAccount id=$acct +expect ok field balanceMinor == 250000 + +# ── And a third writer: the loan desk ─────────────────────────────────────── +client desk model=LoanModel principal=$who +do ApplyLoan accountId=$acct principalMinor=100000 rateBps=0 termMonths=12 +expect ok capture loan=$.id +expect ok field outstandingMinor == 100000 + +use reader-two +do GetAccount id=$acct +expect ok field balanceMinor == 350000 + +use desk +do RepayLoan loanId=$loan fromAccountId=$acct amountMinor=100000 +expect ok field outstandingMinor == 0 +expect ok field status == 1 + +use reader-one +do GetAccount id=$acct +expect ok field balanceMinor == 250000 + +# ── A status change propagates the same way ───────────────────────────────── +# `CloseAccount` runs on reader-one's instance and bumps the version, so +# reader-two — which has been holding this row as Open all along — has to +# notice. Empty it first: closing is refused while a balance remains. +use writer +do Withdraw accountId=$acct amountMinor=250000 description="emptying to close" +expect ok field balanceAfterMinor == 0 + +use reader-one +do CloseAccount id=$acct +expect ok field ok == true +expect ok field message == "account closed" + +# status 2 is AccountStatus::Closed, read from the *other* client's cache. +use reader-two +do GetAccount id=$acct +expect ok field status == 2 +expect ok field balanceMinor == 0 + +# And the writer agrees, through the shared "owned and open" guard. +use writer +do Deposit accountId=$acct amountMinor=100 description="after closing" +expect err message == "account is not open" + +# ── Deregistering an instance does not lose the row ───────────────────────── +# The cache is a cache: SQLite stayed authoritative throughout, so a brand-new +# instance sees exactly what the two long-lived ones ended on. +use reader-one +deregister +expect ok + +client reader-three model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field status == 2 +expect ok field balanceMinor == 0 diff --git a/scripts/scenario/scenarios/bank/register-sign-in-and-open-an-account.scenario b/scripts/scenario/scenarios/bank/register-sign-in-and-open-an-account.scenario new file mode 100644 index 000000000..e6b60392d --- /dev/null +++ b/scripts/scenario/scenarios/bank/register-sign-in-and-open-an-account.scenario @@ -0,0 +1,113 @@ +# bank — the first thing a customer ever does: register, sign in, open an +# account, and see it in their own list. +# +# BANK_DB=... BANK_PORT=0 ladder_bank_server +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/register-sign-in-and-open-an-account.scenario +# +# Bank signs in differently from every other rung that has a sign-in, and this +# file is where that difference is written down. `bookmarks`, `kanban` and +# `ledger` mint a bearer token and the server verifies its signature; bank's +# `AuthModel` mints nothing. `LoginRequest` verifies a password and hands back +# the *principal* for the client to install, which is exactly what +# `bank::app::App::login()` does with it in the shipped GUI and CLI. So a bank +# scenario signs in by capturing that principal and putting it on the session, +# with no token anywhere. + +model AuthModel +client front + +# Registration is the only way a `users` row comes into being over the wire, +# and every owned record in this schema keys off one. +# +# The corpus must be re-runnable against a database it has already run on, and +# registration is the one bank action that is inherently once-only: the second +# pass finds the row and answers "username already taken". So this step asserts +# the pair of outcomes that are *both* correct — and the `LoginRequest` below, +# which behaves identically on either pass, is what actually proves the account +# exists with these credentials. +do RegisterUser username=ada password=ada-secret displayName="Ada Lovelace" +expect ok field message ~ "^(registered|username already taken)$" + +# A registration that cannot produce a usable login is refused outright — and +# it is refused by *morph*, not by bank. `RegisterUser` declares a `validate()` +# predicate, and the framework evaluates it before the action ever reaches +# `AuthModel::execute`, so what a client sees is the generic +# "action failed validation: /" and never the model's own +# "username required and password must be at least 4 characters". That message +# is real code on a branch the wire cannot reach: every bank DTO carrying a +# `validate()` shadows its model's matching throw the same way. Asserted here +# because it is the refusal a client actually has to handle. +do RegisterUser username=ada-too-short password=abc +expect err message == "action failed validation: AuthModel/RegisterUser" + +# The wrong password is a failed sign-in, not an error: `AuthResult.ok` is +# false and nothing is handed back to install. +do LoginRequest username=ada password=not-the-password +expect ok field ok == false +expect ok field message == "invalid credentials" +expect ok field principal == "" + +# ── The principal is what the client installs ─────────────────────────────── +do LoginRequest username=ada password=ada-secret +expect ok capture who=$.principal +expect ok field ok == true +expect ok field principal == "ada" +expect ok field displayName == "Ada Lovelace" +expect ok field message == "welcome" + +# Until it is installed, the session is anonymous. `RemoteServer` clears a +# principal its authorizer will not vouch for, and an empty claim is one bank's +# server refuses (`BankDemoAuthorizer::authenticate`), so this is the state a +# client is in before it signs in. +do WhoAmI +expect ok field authenticated == false +expect ok field principal == "" + +session principal=$who +do WhoAmI +expect ok field authenticated == true +expect ok field principal == "ada" + +# ── Opening the account ───────────────────────────────────────────────────── +client vault model=CustomerModel principal=$who + +# kind 0 is AccountKind::Checking and currency 0 is Currency::USD. Bank carries +# both as plain `int` on the wire — its DTOs declare no enum-class members at +# all, naming the enum only in a doc comment (bank/dto/account_dto.hpp) — so a +# bare integer here is the encoding, not a shortcut around one. +do OpenAccount owner=$who kind=0 currency=0 overdraftMinor=25000 +expect ok capture acct=$.id +expect ok field owner == "ada" +expect ok field kind == 0 +expect ok field currency == 0 +expect ok field balanceMinor == 0 +expect ok field overdraftMinor == 25000 +expect ok field status == 0 +# Savings earn 150bps and everything else earns nothing, so a checking account +# is opened at zero. +expect ok field interestBps == 0 +# A generated IBAN-ish number, not an id echoed back under another name. +expect ok field number ~ "^DE[0-9]{20}$" + +# A currency outside the enum's range never reaches the database — refused by +# `OpenAccount::validate()`'s `currency >= 0 && currency <= 4` bound, and so +# again by morph rather than by `CustomerModel`. +do OpenAccount owner=$who kind=0 currency=99 +expect err message == "action failed validation: CustomerModel/OpenAccount" + +# ── Reading it back ───────────────────────────────────────────────────────── +# A different model, a different connection, and the account is there. +client single model=AccountModel principal=$who +do GetAccount id=$acct +expect ok field id == $acct +expect ok field owner == "ada" +expect ok field overdraftMinor == 25000 +expect ok field status == 0 + +use vault +# Pinned to the captured id rather than to a count: this file has opened one +# more account every time it has ever run against this database. +do ListAccounts owner=$who +expect ok field accounts ~ "\"id\":$acct," +expect ok field accounts[0].owner == "ada" diff --git a/scripts/scenario/scenarios/bank/what-was-spent-and-on-what.scenario b/scripts/scenario/scenarios/bank/what-was-spent-and-on-what.scenario new file mode 100644 index 000000000..4362db17c --- /dev/null +++ b/scripts/scenario/scenarios/bank/what-was-spent-and-on-what.scenario @@ -0,0 +1,113 @@ +# bank — the spending report: debits only, grouped by what kind of debit they +# were, and a budget to read them against. +# +# morph_scenario.py --server ws://127.0.0.1: \ +# scripts/scenario/scenarios/bank/what-was-spent-and-on-what.scenario +# +# `SpendingByKind` answers "where did the money go", and the two things it is +# easy to get wrong are both asserted here: it counts *debits only*, so a +# deposit of the same size must not cancel a withdrawal out; and it groups by +# `TxnKind`, so a withdrawal, a card payment and a loan repayment out of one +# account are three lines and not one total. +# +# The account is opened fresh each pass, which is what makes these exact +# figures rather than "at least": a report over an account a previous run had +# also spent from could only ever be asserted loosely. + +model AuthModel +client front +do RegisterUser username=goldberg password=goldberg-pass displayName="A. Goldberg" +expect ok field message ~ "^(registered|username already taken)$" +do LoginRequest username=goldberg password=goldberg-pass +expect ok capture who=$.principal +expect ok field ok == true + +client vault model=CustomerModel principal=$who +do OpenAccount owner=$who kind=0 currency=0 +expect ok capture acct=$.id + +client teller model=TransactionModel principal=$who +do Deposit accountId=$acct amountMinor=500000 description="salary" +expect ok field balanceAfterMinor == 500000 + +# A second credit, so a report that summed everything instead of only debits +# would be visibly wrong rather than coincidentally right. +do Deposit accountId=$acct amountMinor=100000 description="refund" +expect ok field balanceAfterMinor == 600000 + +# ── Three different kinds of spending ─────────────────────────────────────── +# kind 1, TxnKind::Withdrawal — twice, so one line has a count above one. +do Withdraw accountId=$acct amountMinor=30000 description="cash" +expect ok field balanceAfterMinor == 570000 +do Withdraw accountId=$acct amountMinor=20000 description="more cash" +expect ok field balanceAfterMinor == 550000 + +# kind 4, TxnKind::Payment. +client book model=PayeeModel principal=$who +do AddPayee name="Bookshop" iban=ES9121000418450200051332 bankName="Santander" +expect ok capture payee=$.id + +client bills model=PaymentModel principal=$who +do PayBill fromAccountId=$acct payeeId=$payee amountMinor=15000 description="books" +expect ok field status == 1 + +# kind 8, TxnKind::LoanRepayment. The disbursement that precedes it is a +# *credit*, so it must not appear in the report at all. +client desk model=LoanModel principal=$who +do ApplyLoan accountId=$acct principalMinor=200000 rateBps=0 termMonths=6 +expect ok capture loan=$.id +expect ok field outstandingMinor == 200000 + +do RepayLoan loanId=$loan fromAccountId=$acct amountMinor=50000 +expect ok field outstandingMinor == 150000 + +# ── The budget the report is read against ─────────────────────────────────── +client plan model=BudgetModel principal=$who +do SetBudget category=everything monthlyLimitMinor=100000 currency=0 +expect ok capture budget=$.id +expect ok field monthlyLimitMinor == 100000 + +# ── The report ────────────────────────────────────────────────────────────── +# sinceMs 0 means all time. Total debits: 30000 + 20000 + 15000 + 50000. +do SpendingByKind accountId=$acct sinceMs=0 +expect ok field accountId == $acct +expect ok field totalDebitsMinor == 115000 + +# Grouped by kind, ordered by the kind's numeric value: 1 Withdrawal, +# 4 Payment, 8 LoanRepayment. +expect ok field byKind[0].kind == 1 +expect ok field byKind[0].totalMinor == 50000 +expect ok field byKind[0].count == 2 +expect ok field byKind[1].kind == 4 +expect ok field byKind[1].totalMinor == 15000 +expect ok field byKind[1].count == 1 +expect ok field byKind[2].kind == 8 +expect ok field byKind[2].totalMinor == 50000 +expect ok field byKind[2].count == 1 + +# Neither credit is anywhere in the report: no kind 0 (Deposit) and no kind 7 +# (LoanDisbursement) line, and the 600000 that came in is not in the total. +expect ok field byKind !~ "\"kind\":0" +expect ok field byKind !~ "\"kind\":7" + +# ── A window that excludes everything ─────────────────────────────────────── +# Every entry above was written before this far-future instant, so the report +# is empty rather than absent — the shape is the same and only the figures move. +do SpendingByKind accountId=$acct sinceMs=2000000000000 +expect ok field accountId == $acct +expect ok field totalDebitsMinor == 0 +expect ok field byKind == [] + +# ── An account with nothing spent from it ─────────────────────────────────── +use vault +do OpenAccount owner=$who kind=1 currency=0 +expect ok capture untouched=$.id + +use plan +do SpendingByKind accountId=$untouched sinceMs=0 +expect ok field totalDebitsMinor == 0 +expect ok field byKind == [] + +# Left as found. +do DeleteBudget id=$budget +expect ok field ok == true diff --git a/scripts/scenario/test_morph_scenario.py b/scripts/scenario/test_morph_scenario.py index f57dec56f..b2c2952f0 100755 --- a/scripts/scenario/test_morph_scenario.py +++ b/scripts/scenario/test_morph_scenario.py @@ -997,9 +997,17 @@ def test_real_tree_pins_the_known_action_names(self) -> None: # Undo control the rung's GUI ships -- could only be handed a guess. self.assertEqual(actions["ledger"], frozenset({"CreateBudget", "CreateCategory", "CreateLedger", "CreateRule", "GetBudgetReport", "GetLedger", "GetReportStatus", "ImportLedgerChunk", "LinkAccountToCategory", "ListTransactions", "Login", "OpenAccount", "RunReportJob", "SetBudgetLimit", "SetCategory", "StoreTransaction", "SubmitReport", "UndoTransaction", "UpdateRule"})) self.assertEqual(actions["kanban"], frozenset({"AddAttachment", "AddComment", "ApplyTagMutation", "CreateColumn", "CreateProject", "CreateRule", "CreateSwimlane", "CreateTask", "DeleteRule", "GetActivity", "GetAttachments", "GetBoardState", "GetEventsSince", "GetMyProjects", "GetProjectRoles", "GetRules", "Login", "MoveTaskPosition", "OpenBoard", "RemoveAttachment", "RemoveMember", "SetMemberRole"})) + # bank is not a ladder rung (absent from examples/rungs.txt, never calls + # morph_add_rung) but ships a src/server/main.cpp, which is what + # SERVER_RUNGS has always actually selected on. Its 41 registrations are + # the largest surface in the tree. Note the wasm client under + # examples/bank/gui_wasm/ re-registers a subset of these same wire + # names, and `extract_actions` returns a set, so the duplicates collapse + # rather than inflating the count. + self.assertEqual(actions["bank"], frozenset({"AddPayee", "ApplyLoan", "CancelCard", "CancelPayment", "ChangePassword", "ChangePin", "CloseAccount", "CreateStandingOrder", "DeleteBudget", "Deposit", "FreezeCard", "GenerateStatement", "GetAccount", "GetLoan", "History", "IssueCard", "ListAccounts", "ListBudgets", "ListCards", "ListLoans", "ListNotifications", "ListPayees", "ListPayments", "LoanScheduleRequest", "LoginRequest", "MarkAllRead", "MarkRead", "Notify", "OpenAccount", "PayBill", "RegisterUser", "RemovePayee", "RepayLoan", "SchedulePayment", "SetBudget", "SetCardLimit", "SpendingByKind", "Transfer", "UnfreezeCard", "WhoAmI", "Withdraw"})) # Total count provides a quick sanity check tied to MIN_ACTIONS. total = sum(len(names) for names in actions.values()) - self.assertEqual(total, 73) + self.assertEqual(total, 114) _FLAT_LIST = ''' From 8756d9c2870c15e8d5b0950817ef8cf199bddff7 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sun, 6 Sep 2026 22:46:54 +0200 Subject: [PATCH 2/2] docs: record that bank takes no rung number, and what its server does not do PR #470 gave bank a server and a 22-file scenario corpus without touching `examples/rungs.txt`, which left a reader finding a `ladder_bank_server` and `scripts/scenario/scenarios/bank/` while bank is absent from the file that calls itself the single authority. These docs say why, in the places that absence is visible from. Bank takes no rung number. It predates the ladder, `LADDER.md` already cites it as prior art and measures rung effort in "bank-equivalents", and the numbers in that table are load-bearing for the rungs that consume each other's answers -- so bank stays unnumbered prior art rather than forcing a renumbering. `examples/rungs.txt` now explains its own omission: bank ships a server built by a plain `add_executable()` and never calls `morph_add_rung()`, so the hard configure error the file documents never fires for it, and adding the name would not describe bank but enrol it in `ci.yml`'s ladder jobs, `wasm-ladder.yml`, `coverage.sh` and `codecov.yml`. `LADDER.md` records the same status beside its numbered table, with the conventions bank does and does not follow. Nothing is renumbered and no list gains an entry. `scripts/scenario/README.md` explains why `bank` is in `run_scenarios.py`'s `RUNGS` and `scenario_coverage.py`'s `SERVER_RUNGS` while absent from `rungs.txt`: those lists answer different questions, and the one here has always selected on "ships a src/server/main.cpp". Its `WORKFLOW_FLOORS` listing is brought up to what the code holds (`ledger` 16, `bank` 22). The caveats PR #470 found are now in the docs rather than only in its body. `examples/bank/README.md` carries them: the server authenticates nobody -- it vouches for whatever principal a client asserts, because bank's `AuthModel` mints no token to verify -- so it must not be read as an authentication example, though per-row ownership is enforced and cross-customer isolation is genuinely testable. Beside it, the three defects the corpus pins as `expect ok`: morph#471 (`resolveOwner()` prefers the caller-supplied owner over the session principal, so ten actions across eight models serve another customer's data and `MarkAllRead` writes to it), the fifteen actions whose DTO `validate()` shadows the model's own `ValidationError` message, and `GenerateStatement`'s `closingBalanceMinor` reporting a live balance. morph#471 is cited from the two scenario files that pin it, so a passing run is not read as an endorsement. Comments in `examples/bank/CMakeLists.txt`, `run_scenarios.py`, `scenario_coverage.py` and `test_morph_scenario.py` that said the numbering question was reserved for the maintainer now say what the answer is. No behaviour changes: every edit outside the four documents is a comment. Verified: `ladder_rungs.sh` and `ladder_rungs.sh ci-path-regex` produce output byte-identical to before, `rungs.txt` is still pure ASCII, `check_rung_filters.sh` passes all 37 checks, `check_spec_citations.sh` passes, `scenario_coverage.py` exits 0, `test_morph_scenario.py` runs 132 tests OK, and `run_scenarios.py --rung bank --twice` passes all 44 file runs against a freshly built `ladder_bank_server`. Refs #87, #471. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF --- examples/LADDER.md | 29 +++++ examples/bank/CMakeLists.txt | 11 +- examples/bank/README.md | 116 +++++++++++++++--- examples/rungs.txt | 15 +++ scripts/scenario/README.md | 45 ++++++- scripts/scenario/run_scenarios.py | 8 +- scripts/scenario/scenario_coverage.py | 11 +- ...s-not-checked-against-the-session.scenario | 12 +- ...king-without-a-session-is-refused.scenario | 5 +- scripts/scenario/test_morph_scenario.py | 7 +- 10 files changed, 213 insertions(+), 46 deletions(-) diff --git a/examples/LADDER.md b/examples/LADDER.md index ad5612aac..b5d27d209 100644 --- a/examples/LADDER.md +++ b/examples/LADDER.md @@ -91,6 +91,35 @@ built server-side, no client — its own defining framework question was independently resolved by the extension-bag spike, which is why it carries no `*` here; 7b's go/no-go gate was passed on that answer). +**`bank` is not on that table and has no rung number.** +[`examples/bank`](bank) predates the ladder — the intro above cites it as +prior art, and the effort accounting below still measures rungs in +"bank-equivalents" — and it stays *unnumbered prior art* rather than taking a +slot: numbering it would mean renumbering everything from rung 4 down, and the +numbers here are load-bearing (rung 7 reuses rung 4's board pieces, rung 8 +reuses rung 2's job pattern). Concretely, bank is absent from +[`rungs.txt`](rungs.txt) and never calls `morph_add_rung()`, which is what +keeps it out of `ci.yml`'s `ladder-tests` and `ladder-sanitizers`, +`wasm-ladder.yml`'s build loop, `coverage.sh` and `codecov.yml`'s +per-rung components. It is nonetheless the largest +model surface in the tree — eleven models, 41 registered actions — and it has +the two things a rung has downstream of a number: a `ladder_bank_server`, +built by a local `add_executable` in its own `CMakeLists.txt`, and a 22-file +scenario corpus under `scripts/scenario/scenarios/bank/` that dispatches all +41 of those actions. What it does not have is this document's conventions: +no schema-driven forms, no `examples/common/gui` presenter, and no dual-mode +[`TESTING.md`](TESTING.md) rig (it does use the shared testkit's QML surface +audit). [`bank/README.md`](bank/README.md) sets out which conventions it +shares and which it does not, and carries the two warnings that go with the +server: it authenticates nobody — it trusts whatever principal a client +asserts, because bank's `AuthModel` mints no token — and its corpus pins +several known defects as `expect ok` rather than hiding them, chief among +them +[morph#471](https://github.com/LASTRADA-Software/morph/issues/471). Bank's +relationship to the ladder is +[morph#87](https://github.com/LASTRADA-Software/morph/issues/87); the rest of +that issue — bringing bank's *conventions* into line — is open. + ## Cross-cutting stress map Every subsystem is hit by at least two rungs: diff --git a/examples/bank/CMakeLists.txt b/examples/bank/CMakeLists.txt index d1d3eae48..2fc586115 100644 --- a/examples/bank/CMakeLists.txt +++ b/examples/bank/CMakeLists.txt @@ -104,7 +104,8 @@ apply_bigobj(bank_cli) # Named `ladder_bank_server` because that is the name the scenario tooling # derives from a rung name (`run_scenarios.py`'s `RungSpec.binary`, # `find_server()`); the prefix is that tool's convention, not a claim to a rung -# number, which issue #87 leaves to the maintainer. +# number. Bank takes no rung number -- see examples/rungs.txt and +# examples/LADDER.md. # # Written out here rather than obtained by calling morph_add_rung(). That macro # emits this exact block (cmake/morph_add_rung.cmake, "ladder__server"), @@ -115,10 +116,10 @@ apply_bigobj(bank_cli) # documents that "omitting a rung here while examples//CMakeLists.txt # calls morph_add_rung() is a hard configure error, by design", so the call # would force bank into that file -- and with it into ladder CI, the sanitizer -# and WASM matrices, codecov components and coverage.sh. That is the numbering -# decision #87 reserves for the maintainer, so this stays a local target: it -# gives bank a server without asserting anything about bank's slot in the -# ladder. Everything else here mirrors the macro's block line for line. +# and WASM matrices, codecov components and coverage.sh. Bank is deliberately +# outside all of that, so this stays a local target: it gives bank a server +# without making it a rung. Everything else here mirrors the macro's block line +# for line. # # Gated on Qt because the transport is morph::qt's QtWebSocketServer, exactly # as the macro's version is; MORPH_BUILD_BANK_GUI is *not* the gate, since this diff --git a/examples/bank/README.md b/examples/bank/README.md index 6433e36d2..8921acaae 100644 --- a/examples/bank/README.md +++ b/examples/bank/README.md @@ -18,6 +18,35 @@ desktop GUI** — which also builds to **WebAssembly** and runs entirely in the > Everything runs client-side — no server. First load fetches a ~31 MB `.wasm`, so > give it a few seconds. (See [WebAssembly demo](#webassembly-demo-self-contained-github-pages).) +## Bank and the application ladder + +Bank predates [the application ladder](../LADDER.md) and is **unnumbered prior +art**: `LADDER.md` cites it in its intro, measures rung effort in +"bank-equivalents", and gives it no rung number. That is deliberate — the +numbers in `LADDER.md`'s table are load-bearing for the rungs that consume each +other's answers, and bank has nothing to take but a renumbering. Concretely, +bank is absent from [`examples/rungs.txt`](../rungs.txt) and never calls +`morph_add_rung()`, which is what keeps it out of `ci.yml`'s `ladder-tests` +and `ladder-sanitizers`, `wasm-ladder.yml`'s build loop, `coverage.sh` and +`codecov.yml`'s per-rung components — the only workflow that builds bank at all +is `wasm-demo.yml`, and only its WebAssembly GUI. `ladder_bank_server` below is a local `add_executable` for the same +reason; the `ladder_` prefix is the scenario tooling's naming convention, not a +rung claim. + +Conventions bank **shares** with the rungs: persistence exclusively through the +Lightweight ORM, with the schema owned by `LIGHTWEIGHT_SQL_MIGRATION` +definitions — [`LADDER.md`](../LADDER.md) calls that "bank's pattern" and binds +every rung to it — models as the application, plain aggregates on the wire, and +the shared testkit's `QmlSurfaceAudit`. Conventions it does **not** follow: no +schema-driven forms (its GUI hand-rolls one QObject controller per domain +rather than using `morph::qt::forms::FormsControllerCore`), no +`examples/common/gui` presenter architecture, and no +[`TESTING.md`](../TESTING.md) dual-deployment-mode rig — its remote coverage is +`SimulatedRemoteBackend` in `test_remote.cpp` plus the scenario corpus below. +Bringing those conventions into line is +[morph#87](https://github.com/LASTRADA-Software/morph/issues/87), which remains +open; the numbering question that issue also raises is the part that is settled. + ## Architecture: two type layers morph actions/results must be plain aggregates (Glaze serialises them onto the wire). @@ -179,27 +208,78 @@ an audit trail to `bank_actions.jsonl` in its working directory, the same `morph::journal::FileActionLog` the CLI installs — the models' read-only actions carry `Loggable::No`, so what lands there is the mutating half of the surface. -The scenario corpus in `scripts/scenario/scenarios/bank/` drives this binary; -see [`scripts/scenario/README.md`](../../scripts/scenario/README.md). +#### Scenarios + +`scripts/scenario/scenarios/bank/` holds 22 scenario files that drive this +binary as a real out-of-process WebSocket client, between them dispatching all +41 of bank's registered actions. `run_scenarios.py` starts the server, runs the +directory against it on a throwaway SQLite database, and tears it down: + +```sh +python3 scripts/scenario/run_scenarios.py --rung bank --build-dir build +``` + +`--build-dir` names the directory `ladder_bank_server` was built into; add +`--twice` to rerun the directory against the database the first pass left +behind. The format, the flags and what the corpus is measured against are in +[`scripts/scenario/README.md`](../../scripts/scenario/README.md). Note that +`--rung bank` is the runner's spelling for "the `bank` directory" — bank is not +a rung, per the section above. -**The name is the scenario tooling's convention, not a rung claim.** -`run_scenarios.py` derives a binary name of `ladder__server` from a -directory name. Bank is *not* a ladder rung — it is absent from -`examples/rungs.txt`, never calls `morph_add_rung()`, and this target is written -out locally in `CMakeLists.txt` instead. Bank's slot in the ladder is -[morph#87](https://github.com/LASTRADA-Software/morph/issues/87), which is still -open and is the maintainer's decision. +#### This server authenticates nobody — do not copy it as an authentication example -**Authentication is demo-grade, deliberately.** Bank's `AuthModel` mints no +**It trusts the principal the client asserts.** Bank's `AuthModel` mints no bearer token: `LoginRequest` verifies a password and returns the *principal* for -the client to install (which is what `App::login()` does with it). The server -therefore installs an authorizer that vouches for whatever non-empty principal a -client asserts, because the alternative — morph's default `allowAllAuthorizer()` -— does not authenticate, and `RemoteServer` *clears* an unvouched-for principal, -leaving every action to fail with "no session principal". Per-row ownership is -still enforced by the models (`db::loadOwned`), so one customer cannot reach -another's account by id; what is not enforced is proof that the caller is who -they say. A real deployment swaps in `morph::session::SigningAuthorizer`. +the client to install (which is what `App::login()` does with it). There is +therefore no signed artefact for a server to verify, and morph's default +`allowAllAuthorizer()` does not authenticate at all — it leaves the principal +unvouched-for, `RemoteServer` *clears* it, and every bank action then fails with +"no session principal". So `main.cpp` installs an authorizer that vouches for +whatever non-empty principal arrives, and anyone who can open a socket can claim +to be any customer. + +What that does and does not leave standing: per-row ownership *is* enforced by +the models (`db::loadOwned` navigates a row to its owner and compares that with +the session principal), so cross-customer isolation is real and testable — it is +what `another-customer-cannot-touch-your-account.scenario` pins. What is absent +is credential *proof*. A server that needs it swaps in +`morph::session::SigningAuthorizer` and issues a token to verify against; bank +has none to issue. + +#### Defects the scenario corpus pins on purpose + +Three things the corpus asserts as current behaviour because they are true, not +because they are right. A scenario passing over any of them is a record, not an +endorsement; when one is fixed, the file that pins it is meant to fail. + +- **[morph#471](https://github.com/LASTRADA-Software/morph/issues/471) — a + caller-supplied owner beats the session principal.** + `bank::resolveOwner()` ([`include/bank/core/principal.hpp:24`](include/bank/core/principal.hpp)) + returns `action.owner` whenever it is non-empty and only falls back to the + session principal when it is not; nothing compares the two. Ten actions across + eight models resolve their scope through it — `ListAccounts`, `ListCards`, + `ListPayees`, `ListPayments`, `ListLoans`, `ListBudgets`, + `ListNotifications`, `GenerateStatement`, `OpenAccount` and `MarkAllRead` — + so a signed-in customer who types another customer's username is served that + customer's data, and `MarkAllRead` *writes* to it. `SpendingByKind` consults + no owner at all and answers a caller with no session. Actions addressed by + row id are unaffected: they load the row and check its owner, which is the + pattern the ten should follow. + `an-owner-named-outright-is-not-checked-against-the-session.scenario` is the + inventory: every cross-owner read and the `MarkAllRead` write are `expect + ok` there, beside the id-addressed calls that are correctly refused. +- **A DTO's `validate()` shadows the model's own `ValidationError`.** Fifteen + bank actions carry a `validate()` predicate on the wire DTO *and* open their + `execute()` with `if (!action.validate()) throw ValidationError{"…"}`. Over + the wire morph's dispatch path runs `validate()` first and refuses with + `"action failed validation: /"`, so those fifteen hand-written + messages are unreachable from a remote client and the scenarios assert the + generic string instead. +- **`GenerateStatement`'s `closingBalanceMinor` is not a closing balance.** It + reports each account's *current* balance + (`src/models/statement_model.cpp:48`), not the balance as at `toMs`, so a + statement over a past window still moves when the account does. The debit and + credit totals beside it are windowed correctly. ### Qt 6 QML GUI diff --git a/examples/rungs.txt b/examples/rungs.txt index 3d05c3d86..c28c39629 100644 --- a/examples/rungs.txt +++ b/examples/rungs.txt @@ -34,6 +34,21 @@ # forget. Omitting a rung here while examples//CMakeLists.txt calls # morph_add_rung() is a hard configure error, by design: this list is the one # whose staleness must fail loudly, because everything else derives from it. +# +# Not listed here, deliberately: examples/bank. Bank predates the ladder -- +# examples/LADDER.md's intro cites it as prior art and its effort accounting +# measures rungs in "bank-equivalents" -- and it carries no rung number; that +# is the ladder-slot question morph#87 raises, answered by leaving bank +# outside the sequence, and examples/LADDER.md records the answer next to the +# numbered table. Bank still ships a server (examples/bank/src/server/main.cpp, +# built by a plain add_executable() in examples/bank/CMakeLists.txt) and a +# scenario corpus (scripts/scenario/scenarios/bank/), because neither needs +# this file. What needs this file is morph_add_rung(), which bank does not +# call, so the hard error above never fires for it. Adding the name here would +# not describe bank; it would enrol bank in every consumer above -- ci.yml's +# ladder-tests and ladder-sanitizers, wasm-ladder.yml's build loop, +# coverage.sh and codecov.yml's per-rung components -- which is a scope +# decision rather than a naming one. pastebin bookmarks diff --git a/scripts/scenario/README.md b/scripts/scenario/README.md index c059d4069..1d8805dd3 100644 --- a/scripts/scenario/README.md +++ b/scripts/scenario/README.md @@ -96,9 +96,24 @@ it would pass against a database the driver never touched. Every other rung creates its own root entity over the wire and is seeded with nothing. `scenarios/` holds one directory per server, and one file per workflow. Five of -the six are ladder rungs; `bank` is not one (see `SERVER_RUNGS` in -`scenario_coverage.py`), which is why the column below says "server" rather -than "rung": +the six are ladder rungs; `bank` is not one, which is why the column below says +"server" rather than "rung". + +That asymmetry is deliberate and is not drift to be tidied away. `bank` appears +in `run_scenarios.py`'s `RUNGS` and in `scenario_coverage.py`'s `SERVER_RUNGS`, +and is absent from [`examples/rungs.txt`](../../examples/rungs.txt) — the +ladder's authoritative rung list — because those lists answer different +questions. `rungs.txt` decides what the ladder *builds and gates on*: every +entry is pulled into `ci.yml`'s `ladder-tests` and `ladder-sanitizers`, +`wasm-ladder.yml`'s build loop, `coverage.sh` and `codecov.yml`'s per-rung +components. The two tuples here decide only +what a scenario can *reach*, and have always selected on "ships a +`src/server/main.cpp`". Bank ships one — built by a local `add_executable`, not +by `morph_add_rung()` — and is unnumbered prior art that predates the ladder +(`examples/LADDER.md` records that status next to its numbered table). The +independence already ran the other way too: `lims` and `crm` are rungs with no +scenarios. `--rung bank` and the `bank` column below are therefore spellings of +"the `bank` directory", not a rung claim. | Directory | Server | What it covers | |---|---|---| @@ -109,6 +124,22 @@ than "rung": | `ledger/` | `ladder_ledger_server` | bootstrapping a book from nothing, per-currency zero-sum bookkeeping, categories and budgets, rules and version conflicts, CSV import, submit-then-poll reporting, two books | | `bank/` | `ladder_bank_server` | registration and password-only sign-in, accounts and the ledger behind them, overdraft boundaries, transfers, bills and scheduled/standing instructions, cards, loans and amortisation, budgets, notifications, statements; two customers, the anonymous session, and the stateful account cache | +Some of the bank files assert behaviour that is **wrong**, deliberately, and +say so in their own header comments. The largest is +[morph#471](https://github.com/LASTRADA-Software/morph/issues/471): +`bank::resolveOwner()` prefers a caller-supplied owner name over the session +principal, so ten actions serve a signed-in customer another customer's data +and `MarkAllRead` writes to it — +`bank/an-owner-named-outright-is-not-checked-against-the-session.scenario` is +the inventory, its cross-owner steps all `expect ok`. `SpendingByKind` +answering a caller +with no session at all, and `GenerateStatement` reporting a live balance in a +field named `closingBalanceMinor`, are pinned the same way. An `expect ok` over +a defect records what the server does today so the day it changes is not a +silent one; it is not an endorsement, and the fix is meant to turn those +assertions red. That is the opposite arrangement from +`broken-on-purpose.scenario`, which fails today by design. + The rung a scenario belongs to is its parent directory name — that is how per-rung action coverage is attributed, so a file loose in `scenarios/` is counted against no rung and is refused by the self-test. @@ -317,9 +348,11 @@ Two conditions must hold per rung, from `scenarios//`: - every registered action is dispatched (appears in some scenario's `do` step) by some file in that rung's directory; - the rung has at least as many qualifying workflow files as its floor in - `WORKFLOW_FLOORS` (`pastebin` 8, `polls` 10, `bookmarks` 12, `ledger` 15, - `kanban` 20) — floors scaled to how finite that rung's space of meaningful - journeys is, not quotas: a rung may carry more. + `WORKFLOW_FLOORS` (`pastebin` 8, `polls` 10, `bookmarks` 12, `ledger` 16, + `kanban` 20, `bank` 22) — floors scaled to how finite that directory's space + of meaningful journeys is, not quotas: it may carry more. `bank`'s is the + highest because its 41 registered actions across eleven models are the + largest surface measured here. An action that genuinely cannot be driven by any WebSocket client — because the server refuses every principal but its own internal caller, or because no diff --git a/scripts/scenario/run_scenarios.py b/scripts/scenario/run_scenarios.py index 0d4a63635..9aec1abee 100755 --- a/scripts/scenario/run_scenarios.py +++ b/scripts/scenario/run_scenarios.py @@ -178,10 +178,10 @@ def seed(self, db_path: pathlib.Path) -> None: # server is a local target in examples/bank/CMakeLists.txt. Being a rung and # having scenarios were always independent -- `lims` and `crm` are rungs # with no scenarios -- and this is the same independence from the other - # side: a scenario corpus over a server, with no claim to a rung number - # (morph#87 reserves that decision). Everything this table needs is a - # binary name and two environment variables, all three of which - # examples/bank/src/server/main.cpp defines. + # side: a scenario corpus over a server, with no claim to a rung number -- + # bank takes none, per examples/rungs.txt and examples/LADDER.md. + # Everything this table needs is a binary name and two environment + # variables, all three of which examples/bank/src/server/main.cpp defines. # # No `token_secret_var`: bank's AuthModel mints no bearer token. It verifies # a password and returns the principal for the *client* to install, so a diff --git a/scripts/scenario/scenario_coverage.py b/scripts/scenario/scenario_coverage.py index b25756f8b..e465aa648 100755 --- a/scripts/scenario/scenario_coverage.py +++ b/scripts/scenario/scenario_coverage.py @@ -257,11 +257,12 @@ def floor_violations(surface: Surface) -> list[str]: # separately. # # `bank` is here and is *not* a ladder rung: it is absent from -# examples/rungs.txt and never calls morph_add_rung(), and morph#87 leaves the -# question of its slot in the ladder to the maintainer. What this tuple has -# always actually meant is "the set with a src/server/main.cpp" -- the property -# that decides whether a scenario can reach the thing -- and bank has had a -# model surface worth driving for far longer than it has lacked a server. Being +# examples/rungs.txt, never calls morph_add_rung(), and takes no rung number -- +# it is unnumbered prior art that predates the ladder, which examples/LADDER.md +# records beside its numbered table. What this tuple has always actually meant +# is "the set with a src/server/main.cpp" -- the property that decides whether a +# scenario can reach the thing -- and bank has had a model surface worth driving +# for far longer than it has lacked a server. Being # a rung and having scenarios were already independent in the other direction: # `lims` and `crm` are rungs with no scenarios. SERVER_RUNGS = ("pastebin", "bookmarks", "polls", "kanban", "ledger", "bank") diff --git a/scripts/scenario/scenarios/bank/an-owner-named-outright-is-not-checked-against-the-session.scenario b/scripts/scenario/scenarios/bank/an-owner-named-outright-is-not-checked-against-the-session.scenario index 0c1ec35bf..a3a5cd0c5 100644 --- a/scripts/scenario/scenarios/bank/an-owner-named-outright-is-not-checked-against-the-session.scenario +++ b/scripts/scenario/scenarios/bank/an-owner-named-outright-is-not-checked-against-the-session.scenario @@ -6,7 +6,10 @@ # # This file asserts behaviour that is **wrong**, deliberately, because it is the # behaviour bank has and a corpus that only recorded the parts that work would -# be worth less than nothing on the day somebody changed them. +# be worth less than nothing on the day somebody changed them. It is filed as +# morph#471 (https://github.com/LASTRADA-Software/morph/issues/471); the +# `expect ok`s below are that issue's acceptance criteria in reverse, so a +# reader must not take this file passing for an endorsement. # # Bank has two ways of deciding whose data an action touches: # @@ -25,9 +28,10 @@ # *write* to it. `SpendingByKind` is worse again: it takes a bare `accountId`, # consults no owner at all, and reports on any account in the database. # -# When this is fixed, this file will fail, and it should: the fix is to compare -# the resolved owner with `sessionPrincipal()` and refuse when they differ. The -# `expect ok`s below are the inventory of everything that change has to cover. +# When morph#471 is fixed, this file will fail, and it should: the fix is to +# compare the resolved owner with `sessionPrincipal()` and refuse when they +# differ. The `expect ok`s below are the inventory of everything that change +# has to cover. model AuthModel diff --git a/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario b/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario index 707fb4de8..f6d43db5f 100644 --- a/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario +++ b/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario @@ -146,7 +146,10 @@ expect err message == "account belongs to a different owner" # anybody. Asserted here for the same reason as in # an-owner-named-outright-is-not-checked-against-the-session.scenario: it is the # behaviour bank has, and a corpus that quietly omitted it would let the day it -# changes pass unnoticed. +# changes pass unnoticed. It is recorded under morph#471 +# (https://github.com/LASTRADA-Software/morph/issues/471) as an aggravating +# detail beside that issue's ten owner-resolving actions, so this `expect ok` +# is a record rather than an endorsement. use anon-plan do SpendingByKind accountId=$acct sinceMs=0 expect ok field accountId == $acct diff --git a/scripts/scenario/test_morph_scenario.py b/scripts/scenario/test_morph_scenario.py index b2c2952f0..aacc6d573 100755 --- a/scripts/scenario/test_morph_scenario.py +++ b/scripts/scenario/test_morph_scenario.py @@ -997,9 +997,10 @@ def test_real_tree_pins_the_known_action_names(self) -> None: # Undo control the rung's GUI ships -- could only be handed a guess. self.assertEqual(actions["ledger"], frozenset({"CreateBudget", "CreateCategory", "CreateLedger", "CreateRule", "GetBudgetReport", "GetLedger", "GetReportStatus", "ImportLedgerChunk", "LinkAccountToCategory", "ListTransactions", "Login", "OpenAccount", "RunReportJob", "SetBudgetLimit", "SetCategory", "StoreTransaction", "SubmitReport", "UndoTransaction", "UpdateRule"})) self.assertEqual(actions["kanban"], frozenset({"AddAttachment", "AddComment", "ApplyTagMutation", "CreateColumn", "CreateProject", "CreateRule", "CreateSwimlane", "CreateTask", "DeleteRule", "GetActivity", "GetAttachments", "GetBoardState", "GetEventsSince", "GetMyProjects", "GetProjectRoles", "GetRules", "Login", "MoveTaskPosition", "OpenBoard", "RemoveAttachment", "RemoveMember", "SetMemberRole"})) - # bank is not a ladder rung (absent from examples/rungs.txt, never calls - # morph_add_rung) but ships a src/server/main.cpp, which is what - # SERVER_RUNGS has always actually selected on. Its 41 registrations are + # bank is not a ladder rung and takes no rung number (absent from + # examples/rungs.txt, never calls morph_add_rung) but ships a + # src/server/main.cpp, which is what SERVER_RUNGS has always actually + # selected on. Its 41 registrations are # the largest surface in the tree. Note the wasm client under # examples/bank/gui_wasm/ re-registers a subset of these same wire # names, and `extract_actions` returns a set, so the duplicates collapse