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 cdab66638..2fc586115 100644 --- a/examples/bank/CMakeLists.txt +++ b/examples/bank/CMakeLists.txt @@ -100,6 +100,59 @@ 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. 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"), +# 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. 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 +# 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..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). @@ -158,6 +187,100 @@ 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. + +#### 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. + +#### This server authenticates nobody — do not copy it as an authentication example + +**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). 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 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/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 af9e0c837..1d8805dd3 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,25 @@ 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, 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 | |---|---|---| @@ -84,6 +122,23 @@ 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 | + +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 @@ -293,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 @@ -330,8 +387,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..9aec1abee 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 -- + # 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 + # 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..e465aa648 100755 --- a/scripts/scenario/scenario_coverage.py +++ b/scripts/scenario/scenario_coverage.py @@ -250,11 +250,22 @@ 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, 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") # 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 +480,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..a3a5cd0c5 --- /dev/null +++ b/scripts/scenario/scenarios/bank/an-owner-named-outright-is-not-checked-against-the-session.scenario @@ -0,0 +1,173 @@ +# 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. 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: +# +# * 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 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 + +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..f6d43db5f --- /dev/null +++ b/scripts/scenario/scenarios/bank/banking-without-a-session-is-refused.scenario @@ -0,0 +1,169 @@ +# 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. 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 +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..aacc6d573 100755 --- a/scripts/scenario/test_morph_scenario.py +++ b/scripts/scenario/test_morph_scenario.py @@ -997,9 +997,18 @@ 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 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 + # 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 = '''