diff --git a/examples/bookmarks/CMakeLists.txt b/examples/bookmarks/CMakeLists.txt new file mode 100644 index 00000000..8a33ee31 --- /dev/null +++ b/examples/bookmarks/CMakeLists.txt @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# bookmarks — rung 2 of the application ladder (examples/bookmarks/README.md). +# All target wiring lives in morph_add_rung() (cmake/morph_add_rung.cmake); +# this file only pulls in bookmarks-specific dependencies it doesn't know +# about, then calls it. + +cmake_minimum_required(VERSION 3.25) + +morph_add_rung(NAME bookmarks) + +# morph_add_rung() only globs src/models/*.cpp, src/db/*.cpp and +# src/app/*.cpp into ladder_bookmarks_lib (cmake/morph_add_rung.cmake:91-92) +# — it does not know about this rung's src/import/ (Task 11's Netscape +# bookmarks importer) or src/dto/ (Task 12's auth DTO validation), so +# without an explicit target_sources() call the rung fails to link with +# undefined bookmarks::import::parseNetscapeChunk / bookmarks::Login::validate. +# Confirmed against Task 12's independent build (task-12-report.md, +# "Verification" section). +if(TARGET ladder_bookmarks_lib) + target_sources(ladder_bookmarks_lib PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src/import/netscape_bookmarks.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/dto/auth_dto.cpp") +endif() + +# ladder_bookmarks_lib is native-only (morph_add_rung.cmake's own comment: +# "ladder__gui_wasm never links ladder__lib — so this target +# genuinely never needs to build under Emscripten at all"), so the +# target_sources() call above silently no-ops under EMSCRIPTEN and +# auth_dto.cpp is never compiled into anything the WASM GUI links — +# undefined bookmarks::Login::validate() at the ladder_bookmarks_gui_wasm +# link step. auth_dto.cpp has no persistence dependency (pure DTO +# validation), so it is equally at home in ladder_bookmarks_gui_lib, which +# does build under Emscripten and is what ladder_bookmarks_gui_wasm links. +if(TARGET ladder_bookmarks_gui_lib AND NOT TARGET ladder_bookmarks_lib) + target_sources(ladder_bookmarks_gui_lib PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src/dto/auth_dto.cpp") +endif() + +# ── The WASM client's server url ──────────────────────────────────────────── +# Same mechanism as pastebin's own CMakeLists.txt — see that file's comment. +if(TARGET ladder_bookmarks_gui_wasm) + if(NOT DEFINED MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL) + set(MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL "ws://127.0.0.1:8766" CACHE STRING + "URL bookmarks' WASM client connects to; must be a reachable ladder_bookmarks_server.") + endif() + target_compile_definitions(ladder_bookmarks_gui_wasm PRIVATE + MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL="${MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL}" + ) +endif() diff --git a/examples/bookmarks/README.md b/examples/bookmarks/README.md new file mode 100644 index 00000000..79b7d84e --- /dev/null +++ b/examples/bookmarks/README.md @@ -0,0 +1,501 @@ +# bookmarks — rung 2 of the [application ladder](../LADDER.md) + +**Status: shipped** — every rung-2 task is complete; see +[Definition of done](#definition-of-done) for what that does and does not +mean, and ["The client, and its known gaps"](#the-client-and-its-known-gaps--stated-rather-than-smoothed-over) +for what the shipped client cannot reach (tagging and pagination are not +reachable from the GUI; the native stack is verified end to end, the WASM +client is written and CI-gated but has never been compiled here). A +multi-user bookmark manager: save URLs, tag them, search, bulk-edit, +archive, share with other users. The first "small but real" app: several +related entities, real authorization, and the first background jobs. + +## Running it + +```bash +# One-time configure (Qt 6.5+, an ODBC SQLite3 driver, MORPH_BUILD_FORMS_QML +# for the schema-driven forms): +cmake -S . -B build -G Ninja \ + -DMORPH_BUILD_QT=ON -DMORPH_BUILD_FORMS_QML=ON \ + -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=bookmarks + +# Server (owns the database, the signing secret, the action journal, the +# metadata-fetch worker and the outbox relay). The secret is required and has +# no default: it signs every token the server mints and verifies every token +# it is shown, so a built-in fallback would be a published signing key. +BOOKMARKS_TOKEN_SECRET="pick-something-real" \ +BOOKMARKS_DB="DRIVER=SQLite3;Database=bookmarks.db;Timeout=5000" \ +BOOKMARKS_PORT=8766 ./build/examples/bookmarks/ladder_bookmarks_server + +# Desktop client, either deployment mode: +./build/examples/bookmarks/ladder_bookmarks_gui # in-process +./build/examples/bookmarks/ladder_bookmarks_gui --server ws://127.0.0.1:8766 +``` + +Sign in with any username (dev-mode login, no password — see +`include/bookmarks/dto/auth_dto.hpp` for exactly what that does and does not +mean). Run two clients with two usernames against one server to see the +isolated collections and the shared feed. + +`Local` mode is deliberately the smaller deployment: it hosts the models in +the client process, so it journals nothing, runs no metadata worker and no +outbox relay, and — because `LocalBackend` runs no authorizer at all — is +single-user by construction. The two-user isolation this rung is *about* is +only meaningful against the server. + +## Reference implementations + +- **[linkding](https://github.com/sissbruecker/linkding)** (Python/Django, + MIT, SQLite by default, ~11k LOC app + ~23k LOC tests) — the anchor. + Probably the cleanest small schema in its class (9 Django models in + `bookmarks/models.py`), a complete REST API, and an exceptional test suite + to steal test cases from. +- [Shaarli](https://github.com/shaarli/Shaarli) (PHP, flat-file, no DB) — + secondary reference: proof that single-user bookmarking needs no database + at all; its whole-datastore-in-memory design is literally morph's + in-process model. Good for the local-backend-only variant. + +## What to implement + +Models: `BookmarkModel` (per-user collection), `TagModel`, later +`SharedFeedModel`. Follow linkding's schema: `Bookmark` (url, title, +description, notes, unread, archived, timestamps), `Tag`, many-to-many +bookmark↔tag, `UserProfile`. + +Actions, in build order: + +1. Bookmark CRUD + archive/unarchive + tag assignment. +2. Search/list with filters (tag, unread, archived, text) and pagination. +3. **Bulk operations** — `BulkEdit { ids, addTags, removeTags, archive }`: + the first multi-entity atomic action; all-or-nothing against SQLite. +4. Tag rename/merge (cascades across bookmarks). +5. Netscape HTML import/export — large payload through the wire protocol; + measure where message-size bounds (`docs/spec/security.md`) bite. +6. **Sharing**: mark bookmarks shared, other users read a merged shared feed. + +## morph subsystems exercised + +- **Sessions & authorization** for the first time: every action carries a + `session::Context`; an `IAuthorizer` scopes users to their own collections; + shared feeds are the first cross-principal read. Per review, adopt **real + signed-token authentication here**, not hand-waved principals: the shipped + `SigningAuthorizer` + `authenticate()` hook + (`include/morph/session/session_auth.hpp`, `docs/spec/session/session.md`) + are essentially untested at app scale — more precisely than originally + framed: `examples/bank/tests/test_remote.cpp`'s `NoCloseAuthorizer` + authenticates by trusting `ctx.principal` outright with **no signature + verification at all**, and says so in its own comment. **Bookmarks is the + first rung to wire real signed-token auth end-to-end**, not merely the + first to touch `IAuthorizer`. This rung's server mints and verifies tokens + with `SigningAuthorizer`'s default `hmacSha256` MAC (not + `MORPH_REQUIRE_VETTED_HMAC`'s stricter injected-MAC mode — that flag is a + hardened-deployment concern for a later rung to pick up; this one exercises + the ordinary path). `authorizeRegister`/`authorizeInstance` were intended + to be exercised for real (see "Design decisions" below), with + `tests/test_policy_hardening.cpp`'s `OwnershipAuthorizer` as the framework + precedent for per-user instance ownership. `authorizeInstance` is now + genuinely reachable and enforcing — see the "Instance-level ownership is + now real, but is not the layer that protects a user's data" bullet under + "Design decisions" for exactly what it does and does not catch. + `authorizeRegister` remains unconditionally permissive by choice. What is + wired end-to-end and genuinely exercised regardless is the part that + matters most: signed tokens minted by the + server, verified on every single `execute`, with the verified principal + made authoritative before any model runs. The local backend genuinely + never authorizes (`LocalBackend::registerModel`/`registerModelShared` + consult no `IAuthorizer` anywhere in `backend.hpp`) — models re-check + `Context::principal` themselves regardless of backend, per rule 1. +- **The background-job pattern** (this rung's framework-level deliverable): + linkding auto-fetches title/favicon/preview after save + (`bookmarks/services/tasks.py`) — work *triggered* by an action that + completes later and mutates the model outside any client request. + **Resolved: internal-client pattern, no new framework seam.** A typed + in-process path already exists and is sufficient — + `SimulatedRemoteBackend` is a shipped public backend routing through the + complete server pipeline (authorizer, journal log provider, per-instance + strand). `examples/pastebin/src/app/app.cpp`'s `App`/`_sweepBridge` + already proves the pattern working end-to-end (a `shared_ptr`-captured + `BridgeHandler` kept alive across every dispatched call's + `.then()`/`.onError()`, closing the real race a plain local handler would + hit against `RemoteServer`'s async dispatch); this rung's metadata-fetch + worker reuses that shape unchanged. One part of the original framing was + overstated and is corrected here: `handleInline` does reject `"execute"` + (a real, documented restriction — its reply would write into a stack + buffer already gone by the time the async reply lands), but + `SimulatedRemoteBackend::execute()` never calls `handleInline` — it calls + the async 2-argument `handle()`, so the rejection never fires for the + internal-client path; it was never actually a blocker. + **Service-principal convention (defined here, for every later rung that + reuses this pattern):** the worker mints its own signed token via a + `TokenIssuer` sharing the server's `SigningAuthorizer` secret, with + `principal = "system:metadata-fetcher"`, and attaches it to every call via + `Bridge::setDefaultSession()`. Its calls then authenticate and authorize + exactly like a real user's — fully auditable in the journal via + `session::current()->principal` inside the model — with zero framework + changes. `ConnectionId 0` (`SimulatedRemoteBackend`'s calls are always + connection-unscoped, so nothing it registers is ever reclaimed by + `closeConnection`) is not a new problem: it is the same manual + lifetime-ownership discipline `App`'s shutdown-drain contract already + established in rung 1, reused verbatim. The GUI sees results on a later + poll: this rung's DoD includes a **minimal `GetChangesSince` poll action** + as the event-pattern preview (rung 3 formalizes the full event-queue + design) — there is no existing polling/event-sequencing precedent + anywhere in the framework to reuse; this rung builds it from a + `ChangesCursor` query (a millisecond timestamp paired with a same-instant + id tie-break, not a bare `Timestamp` — issue #43's fix for the boundary + case a timestamp-only cursor can silently drop), deliberately minimal + otherwise. +- **Journal**: tag renames and bulk edits give the first multi-row entries. + Two separate decisions, both resolved: + (a) **store/log atomicity — split by blast radius.** `BulkEdit` and tag + rename/merge (the actions that touch more than one row) opt into + `IModelHolder::setOutboxManaged(true)` + `journal::OutboxRelay`, following + `examples/concepts/journal_and_outbox.cpp`'s worked pattern (the only + existing consumer of this mechanism anywhere in the repo — rung 0/1 and + bank never use it): the model writes its own outbox row inside the same + `SqlTransaction` as the multi-row mutation, and a relay pass drains it into + the durable `IActionLog` separately, so a crash mid-mutation can never + leave the store *and* the journal disagreeing about a partially-applied + bulk change. Plain single-row bookmark CRUD (create/edit/archive/delete) + keeps the framework's default two-independent-write behavior — the same + choice rung 1 made for `PasteModel`, but only ever *implicitly*; here it is + explicit: a crash between the store commit and the journal append can lose + that one action's journal entry, but can never corrupt the store, and a + single-row loss carries none of a partially-applied bulk edit's ambiguity. + (b) **Undo: no generic undo**, consistent with the ladder-wide position + [`LADDER.md`](../LADDER.md)'s "Journal honesty" section already recorded at + rung 1 — `journal::undoLast()` returns a *detached* holder with no API to + reinstall it into a live server registry, so in-place undo of a shared + instance is not possible today, full stop. `DeleteBookmark` is a hard + delete with no compensating action (mirroring rung 1's `DeletePaste`); + `unarchive` is an ordinary domain action that happens to reverse `archive` + in effect, not journal-level undo, and needed no special framework + support to write. + +## Design decisions + +Three further decisions this rung's README named or implied but didn't yet +resolve in writing: + +- **Model topology and the shared feed — corrected after deeper research + (see below), superseding the paragraph this bullet originally had.** + `BookmarkModel`, `TagModel`, and `SharedFeedModel` are **all registered + plain** — no `BRIDGE_MODEL_KEY`/`AllowShared` anywhere in this rung. + The original plan was framework-`shared` instances "keyed by principal," + with ownership enforced through `authorizeInstance`; that design does not + work. `include/morph/core/remote.hpp:800` — + `_owners[fresh] = std::string{}; // shared instances are ownerless, by + design` — inside `RemoteServer::acquireSharedInstance()`, with the + surrounding doc comment (`remote.hpp:714-722`) explaining why: a shared + instance's owner is *always* recorded empty, specifically so + `authorizeInstance`'s `ownerPrincipal == ctx.principal` check does not + reject the second, third, ... client who attaches to it. That makes the + ownership check a **no-op** for any `AllowShared` model — exactly + backwards from what per-user ownership needs. The mechanism that actually + records a real owner is *plain* (non-shared) registration: + `remote.hpp:962-966,1011` stamps `_owners[mid] = + std::move(env.session.principal)` from the verified, authenticated caller. + So `BookmarkModel`/`TagModel` are registered plain, exactly like + `pastebin::PasteModel` — each client's own `register` call gets its own + fresh instance, and `authorizeInstance` genuinely denies a different + principal from touching that specific instance. Nothing about "one + collection per user" is lost by dropping the shared-instance framing: a + model instance carries no meaningful in-memory state here — all real + state is the database, partitioned by an `ownerPrincipal` column — so + every registration by the same user, from any device, reads and writes + the identical rows regardless of how many separate instances exist for + them. `SharedFeedModel` is *also* registered plain, for a different + reason: `AllowShared` requires a keyed action + (`BRIDGE_MODEL_KEY`/`ActionKeyTraits`) to converge multiple clients onto + the *same* instance, machinery built for genuine multi-client convergence + that buys nothing here — every `SharedFeedModel` instance reads the + identical `WHERE shared = 1` rows regardless of how many instances exist, + so there is nothing to converge. One `BookmarksAuthorizer` + (`ownerPrincipal.empty() || ownerPrincipal == ctx.principal`, the + `OwnershipAuthorizer` shape from `tests/test_policy_hardening.cpp`) covers + all three model types without branching: plain-registered + `BookmarkModel`/`TagModel` get a real, non-empty owner check; + `SharedFeedModel`'s own `execute()` never uses `ownerPrincipal` to filter + anything, so the same check being trivially permissive there is harmless + — its actual protection is `authorizeRegister`'s "must be authenticated" + gate. Ownership is enforced twice regardless, per rule 1: server-side via + the authorizer, and again inside the model itself against + `Context::principal`, since the local backend enforces neither. +- **Instance-level ownership is now real, but is not the layer that + protects a user's data.** `register`/`attach`/`assign`/`deregister` + envelopes carry the caller's authenticated session, so `RemoteServer` + records a real, non-empty owner for each of `BookmarkModel`/`TagModel`'s + plain-registered instances, and `authorizeInstance`'s ownership comparison + genuinely denies a different principal's `execute`/`deregister` naming + that instance's `modelId` directly — confirmed empirically (a test + authorizer logged `ctx.principal`/`ownerPrincipal` for both alice's and + mallory's own instances during development). `authorizeRegister` stays + unconditionally permissive, by choice rather than necessity (see its own + doc comment). + **What this does not do is protect one user's row from another's**, and it + never could, fixed or not: `BridgeHandler` (this rung's only + shipped client) never names another connection's `modelId` — each client + only ever dispatches through its own registered instance — so a normal + client's cross-user access attempt (`GetBookmark{id}` naming another + user's row through the caller's *own*, legitimately-owned instance) never + touches `authorizeInstance`'s check at all; it would pass regardless. That + is caught only by the model's own row-level re-check + (`tests/test_bookmark_model.cpp`'s "denied by the model's own ownership + re-check ... not by authorizeInstance" case, confirmed by the propagated + error message: `"bookmark belongs to a different principal"`, not + `authorizeInstance`'s `"unauthorized"`). Every `execute` also still goes + through `SigningAuthorizer::authorize()` (a real signature and expiry + check, on a token an unauthenticated caller cannot produce), and + `RemoteServer` still overwrites `Context::principal` with the verified + identity before the model runs. Three layers in total, each catching a + different thing: token validity (`authorize`), instance ownership + (`authorizeInstance`, real but narrow), and row ownership (the model + itself, the one that actually matters for user isolation). The one action + that deliberately does not scope by row owner, `RecordMetadata`, checks in + its own body that the caller *is* the metadata-fetch service principal — + `authorizeInstance` cannot express that either, since the worker's own + instance is exactly what it is authorized to use — and `AuthModel` refuses + to mint a token in the reserved `system:` namespace, so that authority + cannot be requested from outside. +- **Bookmark↔tag many-to-many.** Lightweight's `DataMapper` ships + `HasManyThrough` + (`.../DataMapper/HasManyThrough.hpp`), but it cannot be used as an embedded + member on `BookmarkRecord`/`TagRecord` here: `DataMapper::Update()`'s + non-reflection path calls `IsModified()` on every record member via + `EnumerateRecordMembers`, and neither `HasMany` nor + `HasManyThrough` declares that method — a record type that embeds + either fails to compile the moment `Update()` is instantiated for it + (verified directly against Lightweight's vendored + `DataMapper.hpp`/`Description.hpp`; independently confirmed by + `examples/bank/include/bank/db/account_entity.hpp`'s own doc comment + making the identical argument for `HasMany`). So: `BookmarkRecord`/ + `TagRecord` carry **zero** relation-typed members. The many-to-many is + still a real junction entity, `BookmarkTagRecord` (`BelongsTo` the + bookmark, `BelongsTo` the tag, its own surrogate primary key) — but tag + reads go through a plain `Query().Where(...)` call in + the model, never an embedded relation field. `BookmarkTagRecord` itself + never needs `Update()` (only `Create`/delete), so this doesn't affect it. + Tag assignment/removal is a direct `Create`/delete of `BookmarkTagRecord` + rows by the model — this was always true regardless of the + `HasManyThrough` question, since its own `Loader` is read-only + (`count`/`all`/`each`, no `Add`/`Remove`) — consistent with `HasMany`'s + own documented limitations elsewhere in the ladder (rule 4's "Lightweight's + own documented idioms" clause). No new sanctioned-escape-tier entry is + needed: a plain `Query<>()` call is ordinary `DataMapper` usage, not an + escape. +- **Bulk-write mechanics.** `BulkEdit`'s per-item mutations are heterogeneous + (some ids get tags added, others removed, some archived) — `SqlStatement:: + ExecuteBatch` only fits a homogeneous single-statement batch, so it is not + the right tool here. `BulkEdit` (and tag rename/merge) use N individual + statements inside one `Lightweight::SqlTransaction{mapper().Connection(), + SqlTransactionMode::ROLLBACK}`, the same all-or-nothing pattern + `PasteModel::execute(GetPaste)`/`execute(EditPaste)` already proved out in + rung 1 — any unhandled throw mid-batch rolls back automatically, and + `transaction.Commit()` is reached only once every item in the batch has + applied. + +Every decision above was verified against real source before being written +here, not assumed from a doc comment: `SigningAuthorizer`, +`SimulatedRemoteBackend`, `OutboxRelay`, and `OwnershipAuthorizer` were all +read in `include/morph/` and `tests/` directly, and `HasManyThrough`'s +read-only `Loader` shape was confirmed against Lightweight's own vendored +source and test entities, alongside the `examples/pastebin`/ +`examples/concepts` precedents cited inline above. + +## Expected strain points + +- Background fetches racing user edits on the same bookmark — strand + serialization should make this safe; write the test that proves it. +- **Cross-model rename race**: `TagModel` renames a tag while a + `BookmarkModel` `BulkEdit` adds the old name — two strands, no + cross-instance transactions, and the strand *cannot* fix it. The test + documents where consistency becomes app responsibility. +- **Local mode has no authorization at all** (the local backend never + authorizes): the first multi-user rung must demonstrate this with a test + and document the mitigation — models re-checking `Context::principal` + themselves, per `docs/spec/security.md`. +- **Unicode tags**: NFC/NFD and case — SQLite `NOCASE` is ASCII-only, so + the C++ comparison, the SQLite unique index, and the GUI display can + disagree; pick a normalization point and test it. +- Favicon/preview blobs: store paths in SQLite, bytes on disk; do not send + them through the action protocol. +- Import of thousands of bookmarks: chunked actions; a connection drop + between chunks must resume without duplicating (idempotency keys) and + without a phantom half-import in the journal. + +## Definition of done + +- Two users on the remote backend with isolated collections and a working + shared feed; authorization enforced server-side, not by the client. This + originally read "specifically via the shipped `authorizeRegister` and + `authorizeInstance` hooks … not only model-level checks", on the reasoning + that leaving them untested here means they stay untested forever. Task 12 + exercised them against a real `RemoteServer` and found that neither hook + could see a caller's identity, because `register` envelopes carried no + session — filed as a finding, since fixed: envelopes now carry the + caller's authenticated session, and `authorizeInstance` is genuinely + enforcing for plain-registered instances (see "Instance-level ownership is + now real" above). The criterion reads: server-side enforcement via + `SigningAuthorizer::authorize()` on every action, `authorizeInstance`'s + now-real instance-ownership check, and the models' own verified-principal, + row-level scoping — three layers, with the last doing the work that + actually protects one user's data from another's, since instance-level + ownership alone was never the layer that could. +- Metadata auto-fetch demonstrably running as a background job: bookmark + appears immediately; title/favicon arrive via the minimal + `GetChangesSince` poll (the rung-3 preview). +- Bulk edit is atomic under injected mid-batch failure. +- The background-job design record (internal-client vs. framework seam, + service principal, journaling of job mutations) written in this README. + +## Known gaps this rung ships with + +Everything below is a real gap, stated here rather than left for a reader to +discover. Gaps in the *client* specifically have their own list further down; +these are the domain- and test-coverage ones. + +- **Unicode tag normalization is unaddressed.** "Expected strain points" + above asks this rung to pick a normalization point (NFC/NFD, case) and + test it. It does not: tag names are compared and indexed as raw bytes, so + a `café` typed as NFC and one typed as NFD are two different tags, and + SQLite's ASCII-only `NOCASE` does not close it. No test covers this. +- **Chunked import is correct but never tested at scale.** Idempotency per + `opId` is tested, and a chunk over `kMaxImportChunkBytes` is refused with + `TooLarge` — deliberately not by `ImportBookmarks::validate()` itself, + since every real dispatch path (`Bridge::executeVia`, `RemoteServer`) + consults `validate()` before `BookmarkModel::execute` is ever reached, so + a `validate()`-level rejection would always surface as the untyped + `ValidationError`, never as `TooLarge`. The distinction is only + observable in-process (a direct call, or `Local`/`LocalSingleThread` + dispatch through `Bridge`): over `Socket`/remote transport, + `RemoteServer` encodes every server-side exception as an opaque + `wire::makeErr(exc.what())` string and the client reconstructs a generic + `std::runtime_error`, discarding the original type — a framework-wide + property of every model's typed errors, not specific to this rung. + Nothing here imports thousands of bookmarks across many chunks, and no + test drops a connection mid-sequence. +- **The transport's own message-size bound is not measured by this rung.** + `kMaxImportChunkBytes` is set "well under" it, but that relationship is + asserted, not verified: there is no bookmarks equivalent of pastebin's + "An oversized `CreatePaste` is refused by the transport" test. If the + transport bound ever drops below 64 KiB, this rung's own chunk limit stops + being the one that bites and nothing here would notice. +- **`is_unread` is write-once at creation — nothing ever clears it.** Every + bookmark is created unread and no action (there is no `MarkRead`/ + `MarkUnread`) ever flips the column. So `ReadFilter::ReadOnly` always + returns an empty page, and `ReadFilter::UnreadOnly` is behaviorally + identical to `ReadFilter::Any`. The column, the enum and the filter are all + wired end to end and would work the moment a mutating action exists; there + simply isn't one. +- **The GUI never leaves the first page.** `BookmarkBridge::refresh()` + discards the `nextCursor` every list/feed response carries, and no QML + binding asks for a further page. The shipped client therefore shows at most + the first ~20 bookmarks (and the first ~20 shared-feed entries) with no way + to reach the rest. Pagination is fully implemented and tested at the model + level — the keyset cursor works — it is only the client that does not use + it. + +## The client, and its known gaps — stated rather than smoothed over + +The desktop client (`gui/`, `gui_lib/`) is schema-driven throughout +(`../IMPLEMENTATION.md` rule 2): `Login`, `CreateBookmark`, `EditBookmark`, +`ImportBookmarks`, `RenameTag` and `MergeTags` all render from +`morph::forms::schemaJson()` through the shipped `MorphForms` +`DynamicForm`, including the login screen — there is **no hand-built username +field**, and no hand-built input widget anywhere. The one non-form input on +the whole screen is the per-row selection checkbox, which types nothing. + +Two pieces of glue carry their own written justification, per rule 2's "(b) +pure glue with no domain logic" clause: + +- `gui::BookmarkFormsController` — this rung's copy of + `morph::qt::forms::FormsControllerCore`, composed over an injected + `Bridge&`/`IExecutor*` rather than constructing its own `LocalBackend`. The + shipped core's own composing constructor now supports this directly (the + same justification `pastebin::gui::PasteFormsController` carries), plus + one genuinely new part this rung's own controller still owns — routing an + action-type string to whichever of the three form-serving models owns it, + which the shipped core (templated over a single model) has no equivalent + for. +- `gui::FormsBridge::onLoginSucceeded` — installs the token the server + returned as the shared `Bridge`'s default session, so every subsequent + action carries it. Infrastructure wiring, not business logic: it decides + nothing, and both the token and the principal it announces are the + server's, never the client's claim. + +Known gaps: + +- ~~`DynamicForm` has no control for a JSON `array` field.`~~ **Fixed + framework-side.** `CreateBookmark::tags`/`EditBookmark::tags` are + `std::vector`, reaching the renderer as + `{"type":"array","items":{"type":"string"}}`. `DynamicForm` now renders a + dedicated comma-separated-with-validation control for exactly this shape + (`src/qt/forms/qml/DynamicForm.qml`'s `isArray` field descriptor and + `fieldJsonLiteral`/`arrayJsonLiteral`, covered by + `src/qt/forms/tests/tst_DynamicFormArrayField.qml`) and encodes it as a + genuine JSON array literal, not a stringified one — the server-rejection + failure mode this bullet used to describe no longer applies. Neither + `createForm` nor `editForm` in `BookmarkListView.qml` special-cases `tags` + (both render every field the schema declares), so tagging from the create + and edit forms works without any change on this rung's side — the fix + landed transparently underneath it. Not independently re-verified end to + end against this rung's own `MORPH_BUILD_FORMS_QML` build (not enabled in + every configuration), but the schema shape is identical to the one the + framework test above exercises and this rung's forms apply no exclusion. +- **`BulkEdit` is not a form**, for that reason: its one required member is + `std::vector`. The GUI drives it from the list's own + multi-selection through `BookmarkBridge::bulkArchive` instead, where no + typing is involved. +- **Six model instances per client, not four.** `app.cpp`'s `kMaxLiveModels` + comment budgets "roughly one instance per model type it uses (four in this + rung)". The shipped client registers six: the forms controller owns an + `AuthModel`, a `BookmarkModel` and a `TagModel` handler, and the three + presenters own a `BookmarkModel`, a `TagModel` and a `SharedFeedModel` + handler. `BridgeHandler` is a template over one model type and both + classes take `(Bridge&, IExecutor*)` by presenter rule 2, so sharing one + handler between them is not expressible today. At the 256 cap that is ~42 + concurrent clients rather than ~64. +- **Registration timing.** `BookmarkListView`'s three list controllers each + expose a `bound` signal (`Presenter::trackBound()`, backed by + `Bridge::whenBound()`) that settles once their registration round trip + lands; the view gates its bootstrap `refresh()` calls on it instead of + retrying on a timer. The login submit has no such gate, because it is + user-initiated: a click that lands before registration settles reports + "handler not bound" and the next click works. Measured against a real + server, registration settles well inside the time it takes to type a + username, so this was never observed in practice — but it is reachable, and + a server that never answers leaves the login button failing forever, since + `Remote` mode has no connect timeout at all. +- **No `--seed`.** `LADDER.md` asks every rung for one; this rung's server + ships none, deliberately — see `src/server/main.cpp`'s file comment for the + argument (seeding by direct model call would need + `morph::session::detail::ScopedContext`, the exact detail-namespace reach + [finding 019](../../docs/findings/019-testkit-reaches-into-four-detail-namespaces.md) + objects to, and the internal-client alternative is rung 4's `action_driver` + work). Demo data is created through the client. +- **The offscreen QML smoke test proves loading, not behavior** — see + `tests/test_gui_qml_smoke.cpp`'s own header comment for exactly what it + does and does not cover. The behavioral half is the presenter suites plus + the manual end-to-end run. + +### Two bugs the first real client run found + +Both were invisible to every test that existed, because every test drove the +models or the presenters directly and none drove *the client*: + +1. **Login was unreachable over a real server.** + `SigningAuthorizer::authorize()` verifies `Context::token` on every + `execute` and rejects when there is none — including for `Login`, the only + way to obtain a token. A fresh client got `err "unauthorized"` for + everything it could possibly send. `BookmarksAuthorizer::authorize` now + carves out exactly `AuthModel`/`Login` and nothing else; see its doc + comment for why that gives nothing away, and + `tests/test_bookmarks_authorizer.cpp` for the unit-level and + over-the-wire regression tests. +2. **`CreateBookmark::title` was schema-`required`.** It was missing from + `optionalFields`, so the generated create form refused to submit without a + title — making it impossible to create from the GUI the very title-less + bookmark the background metadata fetch exists to complete, which is one of + this rung's own definition-of-done items. `title` is now optional in both + `CreateBookmark` and `EditBookmark`, matching what `validate()` and the + member's own doc comment always said. diff --git a/examples/bookmarks/gui/main.cpp b/examples/bookmarks/gui/main.cpp new file mode 100644 index 00000000..d983c4d3 --- /dev/null +++ b/examples/bookmarks/gui/main.cpp @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// bookmarks' desktop client shell: one `AppContext` (deployment mode from +/// `--server`), the four QML adapters `gui_lib/bookmark_qml_bridges.hpp` +/// defines built inside `ctx.onReady()`, and a `QQmlApplicationEngine` +/// loading this rung's own QML module (`Bookmarks`, see +/// `cmake/morph_add_rung.cmake`). +/// +/// Usage: +/// @code +/// ladder_bookmarks_gui # in-process backend +/// ladder_bookmarks_gui --server ws://127.0.0.1:8766 # standalone server +/// @endcode +/// +/// Everything below the deployment-mode choice is intended to be shared +/// verbatim with a future `gui_wasm/main_wasm.cpp` — the adapters, the schema +/// document and the QML module all live outside this file precisely so the +/// two clients can be one program with two `main()`s (`examples/TESTING.md`, +/// "same client code"). + +#include +#include +#include +#include +#include +#include + +#include "bookmark_qml_bridges.hpp" +#include "bookmarks/auth/bookmarks_authorizer.hpp" +#include "bookmarks/db/database.hpp" +#include "gui/app_context.hpp" + +#include + +#include +#include +#include +#include + +namespace { + +/// @brief `--server ` if present, otherwise no url (in-process mode). +/// @param args The application's argument list. +/// @return The parsed url, or `std::nullopt` for in-process mode. +[[nodiscard]] std::optional serverUrlFromArgs(const QStringList& args) { + const auto index = args.indexOf(QStringLiteral("--server")); + if (index < 0 || index + 1 >= args.size()) { + return std::nullopt; + } + return QUrl{args.at(index + 1)}; +} + +} // namespace + +int main(int argc, char** argv) { + QGuiApplication qtApp{argc, argv}; + + const auto serverUrl = serverUrlFromArgs(QCoreApplication::arguments()); + + // Local mode hosts every model in this very process, so this process is + // also the one that has to point Lightweight at a database, apply the + // migrations, and install the `TokenIssuer` `AuthModel` mints from — + // the same bootstrap `src/server/main.cpp` performs, for the same + // reasons. `Remote` mode must *not* do any of it: the server owns the + // store and the signing secret, and a client opening the same SQLite file + // behind the server's back is a second writer. + // + // Local mode is deliberately the *smaller* deployment, not an equivalent + // one, exactly as in rung 1: `bookmarks::app::App` (the durable action + // log, the metadata-fetch worker, the outbox relay and the real + // `BookmarksAuthorizer`) lives only in the server binary. A Local-mode + // client therefore journals nothing, never fetches a title, never relays + // an outbox row, and — because `LocalBackend` runs no authorizer at all — + // is authenticated only in the sense that each model re-reads + // `session::current()->principal` and scopes its own queries to it + // (`docs/spec/security.md`; `examples/bookmarks/README.md`'s "Local mode + // has no authorization at all" strain point). It is a single-user + // developer convenience; the two-user isolation this rung is *about* is + // only meaningful against the server. + // + // The Local-mode secret is a fixed literal on purpose: it is used to sign + // and immediately verify a token inside one process that also owns the + // database file, so it protects nothing and pretending otherwise (an + // env var, a keyring) would suggest it does. + if (!serverUrl) { + const char* connectionString = std::getenv("BOOKMARKS_DB"); + bookmarks::db::setup(connectionString != nullptr + ? connectionString + : "DRIVER=SQLite3;Database=bookmarks.db;Timeout=5000"); + // hmacSha256 named explicitly -- see the identical note at + // bookmarks/src/app/app.cpp's setTokenIssuer() call: TokenIssuer's + // default is dropped entirely under MORPH_REQUIRE_VETTED_HMAC. + bookmarks::auth::setTokenIssuer(std::make_shared<::morph::session::TokenIssuer>( + std::string{"local-mode-development-secret"}, ::morph::session::hmacSha256)); + } + + // Mirrors AppContext's own doc-comment construction pattern: pick the + // mode, then build every handler from inside onReady() — a Remote context + // is *not* usable the line after its constructor returns + // (docs/findings/017). + ::morph::ladder::gui::AppContext ctx{ + serverUrl ? ::morph::ladder::gui::AppContext::Mode{::morph::ladder::gui::Remote{.url = *serverUrl}} + : ::morph::ladder::gui::AppContext::Mode{::morph::ladder::gui::Local{.workers = 4}}}; + + QQmlApplicationEngine engine; + std::unique_ptr formsBridge; + std::unique_ptr bookmarkBridge; + std::unique_ptr tagBridge; + std::unique_ptr feedBridge; + + ctx.onReady([&] { + // All four adapters — and therefore all six `BridgeHandler`s they own + // between them — are built here, once, and live until the process + // exits. Nothing is torn down and rebuilt around login: login only + // installs a session on the shared `Bridge`. That is deliberate: + // `QtWebSocketBackend::deregisterModel` now assigns its fire-and- + // forget `deregister` envelope a real, tracked callId rather than + // sharing the `callId == 0` sentinel a subsequent synchronous + // register/attach/assign call also used to, closing a race that used + // to be able to permanently zero a freshly constructed handler's + // model id if it was built on the same connection right after an + // older one was torn down — but this shape (build once, never rebuild) + // was never the shape that race needed in the first place, so it + // stays regardless. + formsBridge = std::make_unique(ctx.bridge(), ctx.executor()); + bookmarkBridge = std::make_unique(ctx.bridge(), ctx.executor()); + tagBridge = std::make_unique(ctx.bridge(), ctx.executor()); + feedBridge = std::make_unique(ctx.bridge(), ctx.executor()); + // Initial properties rather than context properties: the root object + // then declares what it needs, so the same Main.qml also loads with + // nothing wired up — which is exactly what the offscreen engine-load + // smoke test (tests/test_gui_qml_smoke.cpp) does. + engine.setInitialProperties({ + {QStringLiteral("formsController"), QVariant::fromValue(formsBridge.get())}, + {QStringLiteral("bookmarkController"), QVariant::fromValue(bookmarkBridge.get())}, + {QStringLiteral("tagController"), QVariant::fromValue(tagBridge.get())}, + {QStringLiteral("feedController"), QVariant::fromValue(feedBridge.get())}, + }); + engine.loadFromModule(MORPH_LADDER_QML_URI, "Main"); + if (engine.rootObjects().isEmpty()) { + qWarning("ladder_bookmarks_gui: QML engine produced no root object"); + QCoreApplication::exit(1); + } + }); + + if (serverUrl) { + qInfo("ladder_bookmarks_gui: connecting to %s ...", qUtf8Printable(serverUrl->toString())); + } + return QGuiApplication::exec(); +} diff --git a/examples/bookmarks/gui/qml/BookmarkListView.qml b/examples/bookmarks/gui/qml/BookmarkListView.qml new file mode 100644 index 00000000..69a07d20 --- /dev/null +++ b/examples/bookmarks/gui/qml/BookmarkListView.qml @@ -0,0 +1,517 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bookmarks' main screen: the signed-in user's collection, their tags, and +// the cross-user shared feed. Three panes' worth of behavior, none of it +// domain logic (examples/TESTING.md presenter rule 6, "QML is bindings-only"): +// +// * every form here is the shipped MorphForms renderer (DynamicForm) driven +// entirely by schemaJson() — nothing in this file knows CreateBookmark +// has a `visibility`, or that MergeTags takes two ids; +// * every list and every detail line is a read-only display of +// server-computed state relayed by the Task 17 presenters (via +// gui_lib/bookmark_qml_bridges.hpp); +// * every error string shown is the model's own `what()`; +// * the one non-form input is the per-row selection checkbox, which types +// nothing — it feeds BulkEdit's id list, and BulkEdit cannot be a +// schema-driven form because its required `ids` member is a JSON array +// the shipped renderer has no control for (README, known gaps). +// +// The three lists below are plain Qt Quick `ListView`s, not morph::forms' +// own `CollectionView`, and that is a deliberate choice rather than an +// oversight: `CollectionView` renders columns from a view schema — +// `morph::views::viewSchemaJson()` — and this rung defines no such +// document for any of its three row types. Adding one purely to satisfy the +// list widget would be more schema surface than the three read-only lists +// here justify. Whoever adds view schemas to this rung should revisit it. +// +// Every controller property defaults to null so this same file also loads +// with nothing wired up, which is what the offscreen engine-load smoke test +// (tests/test_gui_qml_smoke.cpp) does. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MorphForms + +Item { + id: page + + property var formsController: null + property var bookmarkController: null + property var tagController: null + property var feedController: null + + /// The whole `{actionType: schema}` document, parsed once by Main.qml. + property var schemas: ({}) + + property var rows: [] + property var tagRows: [] + property var feedRows: [] + property var currentBookmark: null + property var selectedIds: [] + property bool includeArchived: false + + property string status: "" + property bool statusIsError: false + + function report(message, isError) { + page.status = message + page.statusIsError = isError + } + + function refreshBookmarks() { + if (!page.bookmarkController) + return + if (page.includeArchived) + page.bookmarkController.refreshIncludingArchived() + else + page.bookmarkController.refresh() + } + + function refreshAll() { + page.refreshBookmarks() + if (page.tagController) + page.tagController.refresh() + if (page.feedController) + page.feedController.refresh() + } + + function isSelected(id) { + return page.selectedIds.indexOf(id) !== -1 + } + + function setSelected(id, on) { + const next = page.selectedIds.filter(function (each) { return each !== id }) + if (on) + next.push(id) + page.selectedIds = next + } + + // The first listing cannot simply be requested once on completion. In + // Remote mode AppContext::onReady() fires when the *socket* connects, + // which is when gui/main.cpp builds the adapters — but a BridgeHandler's + // registration is a round trip, and until its reply lands every dispatch + // through it fails fast with "handler not bound" (morph/core/bridge.hpp). + // `bound` (backed by `Bridge::whenBound()`) is each controller's own + // settlement signal for that round trip — Local mode's handlers are + // already bound by construction, so all three fire synchronously there. + // This is the identical mitigation pastebin's own Main.qml carries, for + // the identical reason. + Connections { + target: page.bookmarkController + + function onBound() { + page.refreshBookmarks() + } + + function onListed(rows) { + page.rows = rows + page.report("", false) + } + + function onLoaded(bookmark) { + page.currentBookmark = bookmark + page.report("opened " + bookmark.url, false) + } + + function onArchived() { + page.report("archived", false) + page.refreshBookmarks() + } + + function onUnarchived() { + page.report("unarchived", false) + page.refreshBookmarks() + } + + function onRemoved() { + page.currentBookmark = null + page.report("deleted", false) + page.refreshBookmarks() + } + + function onBulkEdited(affected) { + page.report("bulk edit affected " + affected + " bookmark(s)", false) + page.selectedIds = [] + page.refreshBookmarks() + } + + function onFailed(message) { + page.report(message, true) + } + } + + Connections { + target: page.tagController + + function onBound() { + page.tagController.refresh() + } + + function onListed(rows) { + page.tagRows = rows + } + + function onFailed(message) { + page.report(message, true) + } + } + + Connections { + target: page.feedController + + function onBound() { + page.feedController.refresh() + } + + function onListed(rows) { + page.feedRows = rows + } + + function onFailed(message) { + page.report(message, true) + } + } + + Connections { + target: page.formsController + + // Every form on this screen submits through FormsBridge, so this — + // not the presenters' own signals — is where a create/edit/rename/ + // merge/import outcome arrives. + function onReplyReceived(actionType, ok, payload) { + if (actionType === "Login") + return + if (!ok) { + page.report(actionType + ": " + payload, true) + return + } + page.report(actionType + " ok: " + payload, false) + if (actionType === "CreateBookmark") + createForm.resetFields() + else if (actionType === "EditBookmark") + editForm.resetFields() + else if (actionType === "ImportBookmarks") + importForm.resetFields() + else if (actionType === "RenameTag") + renameForm.resetFields() + else if (actionType === "MergeTags") + mergeForm.resetFields() + page.refreshAll() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Label { + Layout.fillWidth: true + visible: page.status !== "" + wrapMode: Text.Wrap + color: page.statusIsError ? "#d33" : palette.text + text: page.status + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 8 + + // ── Pane 1: create + the caller's own collection ─────────────── + ColumnLayout { + Layout.preferredWidth: 400 + Layout.fillHeight: true + spacing: 6 + + DynamicForm { + id: createForm + Layout.fillWidth: true + actionType: "CreateBookmark" + schema: page.schemas["CreateBookmark"] || ({}) + // Unbound on purpose — see LoginView.qml's identical note. + controller: null + } + + Button { + Layout.fillWidth: true + text: "Create bookmark" + enabled: page.formsController !== null && createForm.ready + onClicked: page.formsController.submitIfValid("CreateBookmark", createForm.previewLine) + } + + RowLayout { + Layout.fillWidth: true + + Button { + text: "Refresh" + enabled: page.bookmarkController !== null + onClicked: page.refreshAll() + } + + CheckBox { + text: "show archived" + checked: page.includeArchived + onToggled: { + page.includeArchived = checked + page.refreshBookmarks() + } + } + + Label { + Layout.fillWidth: true + opacity: 0.7 + horizontalAlignment: Text.AlignRight + text: page.rows.length + " bookmark(s)" + } + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: page.rows + + delegate: RowLayout { + id: row + required property var modelData + width: ListView.view ? ListView.view.width : 0 + + CheckBox { + checked: page.isSelected(row.modelData.id) + onToggled: page.setSelected(row.modelData.id, checked) + } + + ItemDelegate { + Layout.fillWidth: true + text: row.modelData.title !== "" + ? row.modelData.title + " · " + row.modelData.url + : row.modelData.url + onClicked: { + if (page.bookmarkController) + page.bookmarkController.open(row.modelData.id) + } + } + + Label { + opacity: 0.6 + text: row.modelData.visibility + " · " + row.modelData.archiveState + } + } + } + + RowLayout { + Layout.fillWidth: true + + Label { + opacity: 0.7 + text: page.selectedIds.length + " selected" + } + + Button { + text: "Bulk archive" + enabled: page.bookmarkController !== null && page.selectedIds.length > 0 + onClicked: page.bookmarkController.bulkArchive(page.selectedIds, true) + } + + Button { + text: "Bulk unarchive" + enabled: page.bookmarkController !== null && page.selectedIds.length > 0 + onClicked: page.bookmarkController.bulkArchive(page.selectedIds, false) + } + } + } + + // ── Pane 2: the open bookmark, and the edit form for it ──────── + ColumnLayout { + Layout.preferredWidth: 400 + Layout.fillHeight: true + spacing: 6 + + Label { + Layout.fillWidth: true + font.bold: true + elide: Text.ElideRight + text: page.currentBookmark + ? (page.currentBookmark.title !== "" ? page.currentBookmark.title + : page.currentBookmark.url) + : "no bookmark open — pick one from the list" + } + + Repeater { + model: page.currentBookmark ? [ + { key: "url", value: page.currentBookmark.url }, + { key: "description", value: page.currentBookmark.description }, + { key: "notes", value: page.currentBookmark.notes }, + { key: "tags", value: page.currentBookmark.tags.join(", ") }, + { key: "visibility", value: page.currentBookmark.visibility }, + { key: "read", value: page.currentBookmark.readState }, + { key: "archive", value: page.currentBookmark.archiveState }, + { key: "created", value: page.currentBookmark.createdAt }, + { key: "updated", value: page.currentBookmark.updatedAt } + ] : [] + + delegate: Label { + required property var modelData + Layout.fillWidth: true + elide: Text.ElideRight + text: modelData.key + ": " + modelData.value + } + } + + RowLayout { + Layout.fillWidth: true + + Button { + text: "Archive" + enabled: page.bookmarkController !== null && page.currentBookmark !== null + onClicked: page.bookmarkController.archive(page.currentBookmark.id) + } + + Button { + text: "Unarchive" + enabled: page.bookmarkController !== null && page.currentBookmark !== null + onClicked: page.bookmarkController.unarchive(page.currentBookmark.id) + } + + Button { + text: "Delete" + enabled: page.bookmarkController !== null && page.currentBookmark !== null + onClicked: page.bookmarkController.remove(page.currentBookmark.id) + } + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + ColumnLayout { + width: parent.width + + DynamicForm { + id: editForm + Layout.fillWidth: true + actionType: "EditBookmark" + schema: page.schemas["EditBookmark"] || ({}) + controller: null + } + + Button { + Layout.fillWidth: true + text: "Apply edit" + enabled: page.formsController !== null && editForm.ready + onClicked: page.formsController.submitIfValid("EditBookmark", editForm.previewLine) + } + + DynamicForm { + id: importForm + Layout.fillWidth: true + actionType: "ImportBookmarks" + schema: page.schemas["ImportBookmarks"] || ({}) + controller: null + } + + Button { + Layout.fillWidth: true + text: "Import chunk" + enabled: page.formsController !== null && importForm.ready + onClicked: page.formsController.submitIfValid("ImportBookmarks", importForm.previewLine) + } + } + } + } + + // ── Pane 3: tags, and the cross-user shared feed ─────────────── + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 6 + + Label { + font.bold: true + text: "Tags (" + page.tagRows.length + ")" + } + + ListView { + Layout.fillWidth: true + Layout.preferredHeight: 120 + clip: true + model: page.tagRows + + delegate: Label { + required property var modelData + width: ListView.view ? ListView.view.width : 0 + elide: Text.ElideRight + text: "#" + modelData.id + " " + modelData.name + " · " + + modelData.bookmarkCount + " bookmark(s)" + } + } + + ScrollView { + Layout.fillWidth: true + Layout.preferredHeight: 260 + clip: true + + ColumnLayout { + width: parent.width + + DynamicForm { + id: renameForm + Layout.fillWidth: true + actionType: "RenameTag" + schema: page.schemas["RenameTag"] || ({}) + controller: null + } + + Button { + Layout.fillWidth: true + text: "Rename tag" + enabled: page.formsController !== null && renameForm.ready + onClicked: page.formsController.submitIfValid("RenameTag", renameForm.previewLine) + } + + DynamicForm { + id: mergeForm + Layout.fillWidth: true + actionType: "MergeTags" + schema: page.schemas["MergeTags"] || ({}) + controller: null + } + + Button { + Layout.fillWidth: true + text: "Merge tags" + enabled: page.formsController !== null && mergeForm.ready + onClicked: page.formsController.submitIfValid("MergeTags", mergeForm.previewLine) + } + } + } + + Label { + font.bold: true + text: "Shared feed (" + page.feedRows.length + ")" + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: page.feedRows + + delegate: Label { + required property var modelData + width: ListView.view ? ListView.view.width : 0 + elide: Text.ElideRight + wrapMode: Text.NoWrap + text: (modelData.title !== "" ? modelData.title : modelData.url) + + " · " + modelData.createdAt + } + } + } + } + } +} diff --git a/examples/bookmarks/gui/qml/LoginView.qml b/examples/bookmarks/gui/qml/LoginView.qml new file mode 100644 index 00000000..5aa214b9 --- /dev/null +++ b/examples/bookmarks/gui/qml/LoginView.qml @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bookmarks' first screen. One schema-driven form and one button — there is +// no hand-built username field here, because there does not need to be: the +// generated form already renders Login's single `std::string username` +// member, complete with its required-gate (examples/IMPLEMENTATION.md rule 2, +// "schema-driven forms only"). If Login ever grows a second field, this file +// does not change. +// +// `formsController` defaults to null so this same file also loads with +// nothing wired up, which is exactly what the offscreen engine-load smoke +// test (tests/test_gui_qml_smoke.cpp) does. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MorphForms + +Item { + id: page + + /// The FormsBridge gui/main.cpp builds, or null when unwired. + property var formsController: null + + /// schemaJson(), already parsed out of the controller's document. + property var loginSchema: ({}) + + /// Whatever the last submission reported, shown verbatim. + property string status: "" + property bool statusIsError: false + + Connections { + target: page.formsController + + // Login's outcome arrives here like every other form's. The + // *successful* case is handled by Main.qml, which navigates on + // `loggedIn` — this only has to show a failure ("username is not a + // valid principal", "handler not bound", ...) rather than leave the + // user staring at a button that seemed to do nothing. + function onReplyReceived(actionType, ok, payload) { + if (actionType !== "Login") + return + page.status = ok ? "" : payload + page.statusIsError = !ok + } + } + + ColumnLayout { + anchors.centerIn: parent + width: Math.min(page.width - 32, 460) + spacing: 8 + + Label { + Layout.fillWidth: true + font.bold: true + font.pixelSize: 18 + text: "Sign in" + } + + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + opacity: 0.7 + text: "Dev-mode login: a username, no password. The token the server mints for it " + + "is real, server-signed and checked on every subsequent action — see " + + "bookmarks/dto/auth_dto.hpp for exactly what that does and does not mean." + } + + DynamicForm { + id: loginForm + Layout.fillWidth: true + actionType: "Login" + schema: page.loginSchema + // Deliberately not `controller: page.formsController`: a bound + // DynamicForm auto-submits on every keystroke once its required + // fields are engaged, which for Login would mint a token per typed + // character. Left unbound it is a pure renderer/validator — + // `ready` is the submit gate and `previewLine` is the exact JSON + // body the button below hands over. Same reasoning, verbatim, as + // pastebin's create form. + controller: null + } + + Button { + Layout.fillWidth: true + text: "Sign in" + enabled: page.formsController !== null && loginForm.ready + onClicked: page.formsController.submitIfValid("Login", loginForm.previewLine) + } + + Label { + Layout.fillWidth: true + visible: page.status !== "" + wrapMode: Text.Wrap + color: page.statusIsError ? "#d33" : palette.text + text: page.status + } + } +} diff --git a/examples/bookmarks/gui/qml/Main.qml b/examples/bookmarks/gui/qml/Main.qml new file mode 100644 index 00000000..335ab05e --- /dev/null +++ b/examples/bookmarks/gui/qml/Main.qml @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// bookmarks' desktop shell: a StackView holding exactly two screens, and the +// one navigation rule between them — LoginView until FormsBridge says a token +// is installed, BookmarkListView afterwards. Everything else is in those two +// files; this one owns the window, the parsed schema document, and the +// transition. +// +// The four controller properties are supplied by gui/main.cpp through +// QQmlApplicationEngine::setInitialProperties. They default to null so this +// same file also loads with nothing wired up, which is exactly what the +// offscreen engine-load smoke test (tests/test_gui_qml_smoke.cpp) does. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + id: root + width: 1280 + height: 860 + visible: true + title: "bookmarks — morph application ladder, rung 2" + + property var formsController: null + property var bookmarkController: null + property var tagController: null + property var feedController: null + + /// The whole `{actionType: schema}` document, parsed once here rather + /// than per form: it is a CONSTANT property on the controller, so one + /// parse is all it can ever need. + property var schemas: root.formsController ? JSON.parse(root.formsController.schemasJson) : ({}) + + /// The signed-in identity, as the *server* echoed it back — never the + /// username the user typed (bookmarks/dto/auth_dto.hpp's trust note). + property string principal: "" + + Connections { + target: root.formsController + + // Emitted by FormsBridge only after the returned token is already + // installed as the bridge's default session, so the screen this + // pushes may dispatch immediately. + function onLoggedIn(principal) { + root.principal = principal + stack.replace(listPage) + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + + Label { + font.bold: true + text: "bookmarks" + } + + Label { + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + opacity: 0.7 + text: root.principal !== "" ? "signed in as " + root.principal : "not signed in" + } + } + + StackView { + id: stack + Layout.fillWidth: true + Layout.fillHeight: true + initialItem: loginPage + } + } + + Component { + id: loginPage + + LoginView { + formsController: root.formsController + loginSchema: root.schemas["Login"] || ({}) + } + } + + Component { + id: listPage + + BookmarkListView { + formsController: root.formsController + bookmarkController: root.bookmarkController + tagController: root.tagController + feedController: root.feedController + schemas: root.schemas + } + } +} diff --git a/examples/bookmarks/gui_lib/bookmark_forms_controller.cpp b/examples/bookmarks/gui_lib/bookmark_forms_controller.cpp new file mode 100644 index 00000000..6f03288b --- /dev/null +++ b/examples/bookmarks/gui_lib/bookmark_forms_controller.cpp @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmark_forms_controller.hpp" + +#include +#include + +// submitIfValid() is a template (OnReply/OnError deduced per call site, +// exactly like FormsControllerCore's own) and so stays fully defined in the +// header; this translation unit holds the two things that need exactly one +// non-inline definition — the constructor and the action-type routing table. + +namespace bookmarks::gui { + +BookmarkFormsController::BookmarkFormsController(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, + std::string schemasJson) + : _authHandler{bridge, executor}, + _bookmarkHandler{bridge, executor}, + _tagHandler{bridge, executor}, + _schemasJson{std::move(schemasJson)} {} + +::morph::async::Completion BookmarkFormsController::dispatch(const std::string& actionType, + const std::string& bodyJson) { + if (actionType == "Login") { + return _authHandler.executeJson(actionType, bodyJson); + } + if (actionType == "CreateBookmark" || actionType == "EditBookmark" || actionType == "ImportBookmarks") { + return _bookmarkHandler.executeJson(actionType, bodyJson); + } + if (actionType == "RenameTag" || actionType == "MergeTags") { + return _tagHandler.executeJson(actionType, bodyJson); + } + // Reported, never silently dropped: the QML side names action types as + // strings, so a typo has to arrive somewhere a human can read it. + throw std::runtime_error{"no model in this client serves action '" + actionType + "'"}; +} + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_lib/bookmark_forms_controller.hpp b/examples/bookmarks/gui_lib/bookmark_forms_controller.hpp new file mode 100644 index 00000000..39e4576a --- /dev/null +++ b/examples/bookmarks/gui_lib/bookmark_forms_controller.hpp @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "bookmarks/models/auth_model.hpp" +#include "bookmarks/models/bookmark_model.hpp" +#include "bookmarks/models/tag_model.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace bookmarks::gui { + +/// @brief Same schema-driven surface as the shipped +/// `morph::qt::forms::FormsControllerCore` +/// (`schemasJson()`/`submitIfValid()`), composed over an injected +/// `Bridge&`/`IExecutor*` instead of constructing its own +/// `LocalBackend`. The shipped core's own `(Bridge&, IExecutor*, +/// schemasJson)` constructor now supports this directly, but this +/// rung still owns a thin controller of its own: it is templated +/// over a *single* model, and this rung's forms span three +/// (`AuthModel`/`BookmarkModel`/`TagModel`, see "The one thing that +/// is genuinely new here" below) — `dispatch()`'s routing has no +/// equivalent on the shipped core. Pure glue, no domain logic +/// (`examples/IMPLEMENTATION.md` rule 2 justification (b)) — the +/// schema/validation/rendering machinery is untouched; only the +/// backend-wiring seam differs. Verbatim in shape from +/// `pastebin::gui::PasteFormsController`, which established it. +/// +/// @par The one thing that is genuinely new here: routing +/// The shipped core, and pastebin's copy of it, are templates over a *single* +/// model, because rung 1 had exactly one. This rung's forms span three +/// (`Login` on `AuthModel`, `CreateBookmark`/`EditBookmark`/`ImportBookmarks` +/// on `BookmarkModel`, `RenameTag`/`MergeTags` on `TagModel`), and +/// `BridgeHandler::executeJson` dispatches against the model type it +/// is instantiated for — so something has to map an action-type string to the +/// right handler. `dispatch()` below is that map and nothing else: a +/// six-entry lookup with no conditionals about *what* an action means. An +/// unrouted action type is reported through the caller's own error callback +/// rather than thrown, so a typo in QML surfaces as a message in the status +/// line like every other failure. +/// +/// @par Handler lifetime, and why all three are constructed together +/// All three `BridgeHandler`s are members, so they are constructed together +/// (three registrations, no deregistrations) and destroyed together at +/// shutdown. That is deliberate: `QtWebSocketBackend::deregisterModel` now +/// assigns its fire-and-forget `deregister` envelope a real, tracked callId +/// rather than the `callId == 0` sentinel a subsequent synchronous +/// register/attach/assign call also used to use — closing a race that used +/// to be able to corrupt a freshly constructed handler's binding if it was +/// built on the same connection right after an older one was torn down. This +/// rung's handler-lifetime shape (all three built together, never rebuilt +/// mid-session) predates that fix and was never the shape the race needed +/// anyway: nothing in this rung's client destroys one handler and +/// constructs a different one on the same connection — the whole handler +/// set outlives login, and login only installs a session on the shared +/// `Bridge`. +/// +/// @par No `fetchOptions()` +/// Deliberately absent, exactly as in `PasteFormsController`: it exists on +/// the shipped core to serve a `morph::forms::Choice` field's combo-box +/// options, and none of this rung's DTOs declare a `Choice` field — +/// `CreateBookmark::visibility` is a plain reflected enum, not a +/// server-fetched choice. Adding an unused `fetchOptions()` would be a stub +/// with nothing to call it. +/// +/// @par Known renderer limitation: array-typed members +/// `CreateBookmark::tags`/`EditBookmark::tags` are `std::vector` +/// and reach `DynamicForm` as JSON-Schema `array` fields, for which the +/// shipped renderer has no control — it falls back to a plain text field +/// whose contents encode as a JSON *string*, which the server then rejects. +/// Both are optional members, so leaving them blank is well-defined and the +/// rest of each form works; typing into one produces a decode error in the +/// status line rather than silent corruption. Stated here rather than +/// smoothed over — see `examples/bookmarks/README.md`'s known-gaps entry. +class BookmarkFormsController { + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param schemasJson Pre-assembled `{actionType: schemaJson()}` map, + /// matching `FormsControllerCore`'s own constructor contract — + /// `bookmark_schemas.hpp`'s `bookmarkSchemasJson()` builds the one + /// every shell passes. + BookmarkFormsController(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, + std::string schemasJson); + + /// @brief The `{actionType: schema}` JSON supplied at construction. + /// @return A reference to the cached schema-set JSON. + [[nodiscard]] const std::string& schemasJson() const noexcept { return _schemasJson; } + + /// @brief Dispatches @p bodyJson as @p actionType's body via the generic + /// `executeJson` path on whichever model serves @p actionType, + /// invoking @p onReply / @p onError on the GUI thread once the + /// reply arrives. + /// + /// Same body as `FormsControllerCore::submitIfValid` + /// (`include/morph/qt/forms/forms_controller_core.hpp`), with the single + /// handler replaced by `dispatch()`'s routing and a `try`/`catch` around + /// it — `dispatch()` is the only step that can fail synchronously (an + /// unrouted or unregistered action type), and this turns that into the + /// same asynchronous failure shape every other error takes. The + /// `dispatch()` call is sequenced before either lambda is constructed, so + /// @p onError is still intact in the handler. + /// + /// @tparam OnReply Callable invoked with the result JSON (`std::string`) on success. + /// @tparam OnError Callable invoked with the `std::exception_ptr` on failure. + /// @param actionType Registered action type id. + /// @param bodyJson Fully-assembled JSON body for the action. + /// @param onReply Success callback. + /// @param onError Failure callback. + template + void submitIfValid(std::string actionType, std::string bodyJson, OnReply onReply, OnError onError) { + try { + dispatch(actionType, bodyJson) + .then([onReply = std::move(onReply)](std::string resultJson) mutable { + onReply(std::move(resultJson)); + }) + .onError([onError](const std::exception_ptr& err) mutable { onError(err); }); + } catch (...) { + onError(std::current_exception()); + } + } + + private: + /// @brief Routes @p actionType to the handler for the model that serves + /// it and starts the dispatch. + /// @param actionType Registered action type id. + /// @param bodyJson Fully-assembled JSON body for the action. + /// @return The in-flight completion carrying the result JSON. + /// @throws std::runtime_error if no model in this controller serves + /// @p actionType (or if the action is unknown to the one that + /// does — `BridgeHandler::executeJson`'s own contract). + [[nodiscard]] ::morph::async::Completion dispatch(const std::string& actionType, + const std::string& bodyJson); + + ::morph::bridge::BridgeHandler _authHandler; + ::morph::bridge::BridgeHandler _bookmarkHandler; + ::morph::bridge::BridgeHandler _tagHandler; + std::string _schemasJson; +}; + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_lib/bookmark_presenter.cpp b/examples/bookmarks/gui_lib/bookmark_presenter.cpp new file mode 100644 index 00000000..5455f285 --- /dev/null +++ b/examples/bookmarks/gui_lib/bookmark_presenter.cpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmark_presenter.hpp" + +namespace bookmarks::gui { + +BookmarkPresenter::BookmarkPresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, + QObject* parent) + : Presenter{parent}, _handler{bridge, executor} { + trackBound(_handler.whenBound()); +} + +void BookmarkPresenter::reportError(const std::exception_ptr& err) { + try { + std::rethrow_exception(err); + } catch (const std::exception& ex) { + emit failed(QString::fromStdString(ex.what())); + } +} + +void BookmarkPresenter::create(CreateBookmark action) { + track( + _handler.execute(std::move(action)), [this](CreateBookmarkResult result) { emit created(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void BookmarkPresenter::edit(EditBookmark action) { + track( + _handler.execute(std::move(action)), [this](BookmarkView view) { emit edited(std::move(view)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void BookmarkPresenter::archive(ArchiveBookmark action) { + track( + _handler.execute(std::move(action)), [this](Ack) { emit archived(); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void BookmarkPresenter::unarchive(UnarchiveBookmark action) { + track( + _handler.execute(std::move(action)), [this](Ack) { emit unarchived(); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void BookmarkPresenter::remove(DeleteBookmark action) { + track( + _handler.execute(std::move(action)), [this](Ack) { emit removed(); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void BookmarkPresenter::get(GetBookmark action) { + track( + _handler.execute(std::move(action)), [this](BookmarkView view) { emit loaded(std::move(view)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void BookmarkPresenter::list(ListBookmarks action) { + track( + _handler.execute(std::move(action)), [this](ListBookmarksResult result) { emit listed(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void BookmarkPresenter::getChangesSince(GetChangesSince action) { + track( + _handler.execute(std::move(action)), + [this](GetChangesSinceResult result) { emit changesSince(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void BookmarkPresenter::bulkEdit(BulkEdit action) { + track( + _handler.execute(std::move(action)), [this](BulkEditResult result) { emit bulkEdited(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void BookmarkPresenter::importChunk(ImportBookmarks action) { + track( + _handler.execute(std::move(action)), + [this](ImportBookmarksResult result) { emit imported(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void BookmarkPresenter::exportAll(ExportBookmarks action) { + track( + _handler.execute(std::move(action)), + [this](ExportBookmarksResult result) { emit exported(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_lib/bookmark_presenter.hpp b/examples/bookmarks/gui_lib/bookmark_presenter.hpp new file mode 100644 index 00000000..7f583ad4 --- /dev/null +++ b/examples/bookmarks/gui_lib/bookmark_presenter.hpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "gui/presenter.hpp" +#include "bookmarks/dto/bookmark_dto.hpp" +#include "bookmarks/dto/bulk_dto.hpp" +#include "bookmarks/dto/import_export_dto.hpp" + +#include + +// See pastebin::gui::PastePresenter's identical guard and doc comment +// (examples/pastebin/gui_lib/paste_presenter.hpp) for why moc must never see +// morph/core/bridge.hpp: its template machinery produces bogus moc output +// the same way bookmark_model.hpp historically did when it transitively +// pulled in Lightweight's DataMapper machinery through the since-removed +// bookmarks/db/db_model.hpp -- bookmark_model.hpp itself no longer has any +// Lightweight/ODBC dependency at all, now that BookmarkModel acquires a +// connection per execute() call from Lightweight::GlobalDataMapperPool() +// instead of owning one, but this guard stays for bridge.hpp's own sake. +#ifndef Q_MOC_RUN +#include "bookmarks/models/bookmark_model.hpp" + +#include +#include +#endif + +namespace bookmarks::gui { + +/// @brief Routes every `BookmarkModel` action through a +/// `BridgeHandler`. Translates and routes only — no +/// domain logic (`IMPLEMENTATION.md` rule 2). +/// +/// `RecordMetadata` is deliberately absent: it is dispatched exclusively by +/// the app-layer metadata-fetch worker's internal client, authenticated as +/// `bookmarks::auth::kMetadataFetcherPrincipal`, never by a GUI client +/// (`bookmark_dto.hpp`'s own `@file` comment) — so it gets no presenter +/// method, mirroring `pastebin::ExpirePaste`'s identical "internal-only" +/// exclusion. +class BookmarkPresenter : public ::morph::ladder::gui::Presenter { + Q_OBJECT + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param parent Optional `QObject` parent. + BookmarkPresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief Stores a new bookmark. Emits `created` on success, `failed` on error. + /// @param action The bookmark to store. + void create(CreateBookmark action); + + /// @brief Replaces an editable bookmark's fields with a full replace-set. + /// Emits `edited` on success, `failed` on error. + /// @param action The edit to apply. + void edit(EditBookmark action); + + /// @brief Archives a bookmark. Emits `archived` on success, `failed` on error. + /// @param action The bookmark to archive. + void archive(ArchiveBookmark action); + + /// @brief Unarchives a bookmark. Emits `unarchived` on success, `failed` on error. + /// @param action The bookmark to unarchive. + void unarchive(UnarchiveBookmark action); + + /// @brief Deletes a bookmark. Emits `removed` on success, `failed` on error. + /// @param action The bookmark to delete. + void remove(DeleteBookmark action); + + /// @brief Reads one bookmark. Emits `loaded` on success, `failed` on error. + /// @param action The bookmark to read. + void get(GetBookmark action); + + /// @brief Fetches one page of the caller's own bookmarks. Emits `listed` + /// on success, `failed` on error. + /// @param action The page/filter request. + void list(ListBookmarks action); + + /// @brief Polls every bookmark the caller touched since a given instant. + /// Emits `changesSince` on success, `failed` on error. + /// @param action The poll request. + void getChangesSince(GetChangesSince action); + + /// @brief Applies one atomic edit across several bookmarks. Emits + /// `bulkEdited` on success, `failed` on error. + /// @param action The batch edit to apply. + void bulkEdit(BulkEdit action); + + /// @brief Imports one chunk of a Netscape Bookmark HTML import. Emits + /// `imported` on success, `failed` on error. + /// @param action The chunk to import. + void importChunk(ImportBookmarks action); + + /// @brief Exports every one of the caller's bookmarks. Emits `exported` + /// on success, `failed` on error. + /// @param action The export request. + void exportAll(ExportBookmarks action); + + signals: + void created(CreateBookmarkResult result); + void edited(BookmarkView view); + void archived(); + void unarchived(); + void removed(); + void loaded(BookmarkView view); + void listed(ListBookmarksResult result); + void changesSince(GetChangesSinceResult result); + void bulkEdited(BulkEditResult result); + void imported(ImportBookmarksResult result); + void exported(ExportBookmarksResult result); + /// @brief Emitted for any action's typed error — @p message is + /// `std::exception::what()`, ready for direct display. + void failed(QString message); + + private: + /// @brief Shared error-display body passed as every `track()` call's + /// third argument below — see `Presenter::track()`'s doc comment + /// (`examples/common/gui/presenter.hpp`) for why it is passed as + /// `track()`'s `onErr` parameter rather than attached via a + /// separate `.onError()` call beforehand. + void reportError(const std::exception_ptr& err); + + ::morph::bridge::BridgeHandler _handler; +}; + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_lib/bookmark_qml_bridges.cpp b/examples/bookmarks/gui_lib/bookmark_qml_bridges.cpp new file mode 100644 index 00000000..bbf01478 --- /dev/null +++ b/examples/bookmarks/gui_lib/bookmark_qml_bridges.cpp @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmark_qml_bridges.hpp" + +#include "bookmark_schemas.hpp" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace bookmarks::gui { + +namespace { + +/// @brief Renders an optional instant as ISO-8601, or an empty string. +[[nodiscard]] QString isoOrEmpty(const ::morph::time::Timestamp& instant) { + return instant.hasValue() ? QString::fromStdString((*instant).toIso8601()) : QString{}; +} + +/// @brief A count rendered via `morph::units::toString` (`"N/A"` when empty). +/// +/// `morph::units::toString`, not `std::format("{}", count)`: see +/// `pastebin::gui::readsText`'s identical note (`paste_qml_bridges.cpp`) — +/// Emscripten's bundled libc++ fails to compile the `std::format` call for +/// this `Quantity`-family type outright. +[[nodiscard]] QString countText(const Count& count) { + return QString::fromStdString(morph::units::toString(count)); +} + +/// @brief A `BookmarkId` as the plain number QML rows carry, or `-1` when +/// unengaged. `-1` is never a real surrogate key (Lightweight's +/// `ServerSideAutoIncrement` starts at 1), so it is unambiguous, and a +/// number — not a string — is what `open`/`archive`/`remove` take. +[[nodiscard]] qlonglong idNumber(const BookmarkId& id) { + return id.hasValue() ? static_cast(*id) : -1; +} + +/// @brief A `TagId` as the plain number tag rows carry. See `idNumber`. +[[nodiscard]] qlonglong idNumber(const TagId& id) { + return id.hasValue() ? static_cast(*id) : -1; +} + +/// @brief Tag names as a QML string list. +[[nodiscard]] QVariantList tagList(const std::vector& tags) { + QVariantList out; + out.reserve(static_cast(tags.size())); + for (const auto& tag : tags) { + out.append(QString::fromStdString(tag)); + } + return out; +} + +[[nodiscard]] QString visibilityText(Visibility visibility) { + return visibility == Visibility::Shared ? QStringLiteral("Shared") : QStringLiteral("Private"); +} + +[[nodiscard]] QString readStateText(ReadState state) { + return state == ReadState::Read ? QStringLiteral("Read") : QStringLiteral("Unread"); +} + +[[nodiscard]] QString archiveStateText(ArchiveState state) { + return state == ArchiveState::Archived ? QStringLiteral("Archived") : QStringLiteral("Active"); +} + +/// @brief A `BookmarkView` as the property bag the detail pane binds against. +[[nodiscard]] QVariantMap toVariantMap(const BookmarkView& view) { + return QVariantMap{ + {"id", idNumber(view.id)}, + {"url", QString::fromStdString(view.url)}, + {"title", QString::fromStdString(view.title)}, + {"description", QString::fromStdString(view.description)}, + {"notes", QString::fromStdString(view.notes)}, + {"tags", tagList(view.tags)}, + {"createdAt", isoOrEmpty(view.createdAt)}, + {"updatedAt", isoOrEmpty(view.updatedAt)}, + {"readState", readStateText(view.readState)}, + {"archiveState", archiveStateText(view.archiveState)}, + {"visibility", visibilityText(view.visibility)}, + }; +} + +/// @brief One listing row as the property bag a list delegate binds against. +/// Narrower than `toVariantMap(const BookmarkView&)` because +/// `BookmarkSummary` is narrower than `BookmarkView` on purpose — a +/// listing must not leak `notes` (`bookmarks/dto/bookmark_dto.hpp`). +[[nodiscard]] QVariantMap toVariantMap(const BookmarkSummary& summary) { + return QVariantMap{ + {"id", idNumber(summary.id)}, + {"url", QString::fromStdString(summary.url)}, + {"title", QString::fromStdString(summary.title)}, + {"tags", tagList(summary.tags)}, + {"createdAt", isoOrEmpty(summary.createdAt)}, + {"updatedAt", isoOrEmpty(summary.updatedAt)}, + {"readState", readStateText(summary.readState)}, + {"archiveState", archiveStateText(summary.archiveState)}, + {"visibility", visibilityText(summary.visibility)}, + }; +} + +/// @brief One `ListTags` row as the property bag the tag list binds against. +[[nodiscard]] QVariantMap toVariantMap(const TagSummary& summary) { + return QVariantMap{ + {"id", idNumber(summary.id)}, + {"name", QString::fromStdString(summary.name)}, + {"bookmarkCount", countText(summary.bookmarkCount)}, + }; +} + +/// @brief Every summary in @p rows as a `QVariantList` of property bags. +template +[[nodiscard]] QVariantList toVariantList(const Summaries& rows) { + QVariantList out; + out.reserve(static_cast(rows.size())); + for (const auto& row : rows) { + out.append(toVariantMap(row)); + } + return out; +} + +} // namespace + +std::optional decodeLoginResult(const std::string& resultJson) { + // The same glaze reflection the wire used, so nothing here parses JSON by + // hand. `read_json` returns a truthy error context on failure. + LoginResult result; + if (glz::read_json(result, resultJson)) { + return std::nullopt; + } + return result; +} + +// ── FormsBridge ───────────────────────────────────────────────────────────── + +FormsBridge::FormsBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent) + : QObject{parent}, _bridge{bridge}, _controller{bridge, executor, bookmarkSchemasJson()} {} + +QString FormsBridge::schemasJson() const { + return QString::fromStdString(_controller.schemasJson()); +} + +void FormsBridge::onLoginSucceeded(const LoginResult& result) { + ::morph::session::Context session; + session.principal = result.principal; + session.token = result.token.hasValue() ? *result.token : std::string{}; + _bridge.setDefaultSession(session); + emit loggedIn(QString::fromStdString(result.principal)); +} + +void FormsBridge::submitIfValid(const QString& actionType, const QString& bodyJson) { + _controller.submitIfValid( + actionType.toStdString(), bodyJson.toStdString(), + [this, actionType](std::string resultJson) { + // A successful Login is the one reply this client reads rather + // than merely displays: the token has to be installed before + // anything else dispatches. See `decodeLoginResult` for why the + // decode is a named function. + if (actionType == QLatin1String("Login")) { + const auto result = decodeLoginResult(resultJson); + if (!result) { + emit replyReceived(actionType, false, + QStringLiteral("login succeeded but its reply could not be decoded")); + return; + } + onLoginSucceeded(*result); + // The token has already done its one job -- installed onto + // the session above -- so it has no further reason to leave + // this function. `replyReceived` is broadcast to *every* + // bound QML handler, and a future handler that renders + // `payload` unconditionally would otherwise put a live + // bearer credential on screen (and into any screenshot or + // recording of it). Re-encoding a redacted copy keeps the + // signal's shape unchanged (still `(actionType, ok, + // payload)`) — only `Login`'s own payload stops carrying the + // token, rather than a public QML surface change. + LoginResult redacted = *result; + redacted.token = AuthToken{}; + emit replyReceived(actionType, true, QString::fromStdString(glz::write_json(redacted).value_or("{}"))); + return; + } + emit replyReceived(actionType, true, QString::fromStdString(resultJson)); + }, + [this, actionType](const std::exception_ptr& err) { + try { + std::rethrow_exception(err); + } catch (const std::exception& e) { + emit replyReceived(actionType, false, QString::fromUtf8(e.what())); + } + }); +} + +// ── BookmarkBridge ────────────────────────────────────────────────────────── + +BookmarkBridge::BookmarkBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent) + : QObject{parent}, _presenter{bridge, executor} { + // Direct (same-thread) connections throughout — see + // paste_qml_bridges.hpp's "Threading" note for why no meta-type + // registration is involved. + connect(&_presenter, &BookmarkPresenter::bound, this, &BookmarkBridge::bound); + connect(&_presenter, &BookmarkPresenter::listed, this, + [this](const ListBookmarksResult& result) { emit listed(toVariantList(result.bookmarks)); }); + connect(&_presenter, &BookmarkPresenter::loaded, this, + [this](const BookmarkView& view) { emit loaded(toVariantMap(view)); }); + connect(&_presenter, &BookmarkPresenter::archived, this, &BookmarkBridge::archived); + connect(&_presenter, &BookmarkPresenter::unarchived, this, &BookmarkBridge::unarchived); + connect(&_presenter, &BookmarkPresenter::removed, this, &BookmarkBridge::removed); + connect(&_presenter, &BookmarkPresenter::bulkEdited, this, + [this](const BulkEditResult& result) { emit bulkEdited(countText(result.affected)); }); + connect(&_presenter, &BookmarkPresenter::failed, this, &BookmarkBridge::failed); +} + +void BookmarkBridge::refresh() { + _presenter.list(ListBookmarks{}); +} + +void BookmarkBridge::refreshIncludingArchived() { + // Every member without a default initializer is named explicitly: + // -Wmissing-designated-field-initializers is on under + // MORPH_ENABLE_STRICT_COMPILATION. `.cursor = {}` is an empty cursor, + // i.e. the first page; empty `tag`/`searchText` mean "no filter". + _presenter.list( + ListBookmarks{.cursor = {}, .archiveFilter = ArchiveFilter::Any, .tag = {}, .searchText = {}}); +} + +void BookmarkBridge::open(qlonglong id) { + _presenter.get(GetBookmark{.id = BookmarkId{static_cast(id)}}); +} + +void BookmarkBridge::archive(qlonglong id) { + _presenter.archive(ArchiveBookmark{.id = BookmarkId{static_cast(id)}}); +} + +void BookmarkBridge::unarchive(qlonglong id) { + _presenter.unarchive(UnarchiveBookmark{.id = BookmarkId{static_cast(id)}}); +} + +void BookmarkBridge::remove(qlonglong id) { + _presenter.remove(DeleteBookmark{.id = BookmarkId{static_cast(id)}}); +} + +void BookmarkBridge::bulkArchive(const QVariantList& ids, bool archive) { + BulkEdit action; + action.ids.reserve(static_cast(ids.size())); + for (const auto& id : ids) { + action.ids.emplace_back(static_cast(id.toLongLong())); + } + action.archive = archive ? BulkArchiveOp::Archive : BulkArchiveOp::Unarchive; + _presenter.bulkEdit(std::move(action)); +} + +// ── TagBridge ─────────────────────────────────────────────────────────────── + +TagBridge::TagBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent) + : QObject{parent}, _presenter{bridge, executor} { + connect(&_presenter, &TagPresenter::bound, this, &TagBridge::bound); + connect(&_presenter, &TagPresenter::listed, this, + [this](const ListTagsResult& result) { emit listed(toVariantList(result.tags)); }); + connect(&_presenter, &TagPresenter::failed, this, &TagBridge::failed); +} + +void TagBridge::refresh() { + _presenter.list(ListTags{}); +} + +// ── SharedFeedBridge ──────────────────────────────────────────────────────── + +SharedFeedBridge::SharedFeedBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, + QObject* parent) + : QObject{parent}, _presenter{bridge, executor} { + connect(&_presenter, &SharedFeedPresenter::bound, this, &SharedFeedBridge::bound); + connect(&_presenter, &SharedFeedPresenter::listed, this, + [this](const ListSharedFeedResult& result) { emit listed(toVariantList(result.bookmarks)); }); + connect(&_presenter, &SharedFeedPresenter::failed, this, &SharedFeedBridge::failed); +} + +void SharedFeedBridge::refresh() { + _presenter.list(ListSharedFeed{}); +} + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_lib/bookmark_qml_bridges.hpp b/examples/bookmarks/gui_lib/bookmark_qml_bridges.hpp new file mode 100644 index 00000000..99b21fba --- /dev/null +++ b/examples/bookmarks/gui_lib/bookmark_qml_bridges.hpp @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +#include +#include + +// Guarded exactly like bookmark_presenter.hpp's own includes: AUTOMOC runs +// moc over this header, and moc must not be pointed at morph's template-heavy +// bridge.hpp or at the model headers, which pull in Lightweight's DataMapper +// machinery — moc is not a C++ front end and mis-parses it, emitting the rest +// of the file inside a namespace it wrongly believes is still open. moc needs +// nothing from these headers: the macros, signals and `Q_INVOKABLE` +// signatures below are all it reads. +#ifndef Q_MOC_RUN +#include "bookmark_forms_controller.hpp" +#include "bookmark_presenter.hpp" +#include "shared_feed_presenter.hpp" +#include "tag_presenter.hpp" + +#include +#include +#endif + +/// @file +/// The four QML-facing adapters bookmarks' shells put in front of the Task 17 +/// presenters and this rung's forms controller. They live in `gui_lib` — not +/// in a shell's `main.cpp` — because every shell needs them and they must all +/// be the same program: `gui/main.cpp` (desktop) and a future +/// `gui_wasm/main_wasm.cpp` (browser) are to differ only in how they choose a +/// deployment mode, per `examples/TESTING.md`'s "same client code" +/// requirement. Same rationale, same shape and the same Qt6::Core-only bound +/// as `pastebin::gui`'s `FormsBridge`/`PasteBridge` +/// (`examples/pastebin/gui_lib/paste_qml_bridges.hpp`) — read that file's +/// "Why these adapters exist at all", "Qt6::Core only" and "Threading" +/// sections, which apply here verbatim and are not repeated. +/// +/// @par Why there is no separate `AuthBridge` +/// The login step is folded into `FormsBridge` rather than given a class of +/// its own, and that is a deliberate deviation from this task's brief. A +/// standalone `AuthBridge` taking `(Bridge&, IExecutor*)` — the presenter +/// rule-2 constructor every adapter here has — would have to own a second +/// `BookmarkFormsController`, and therefore a second `BridgeHandler` for +/// *each* of this rung's three form-serving models: six registered instances +/// per client where four is the number `bookmarks::app::App`'s own +/// `kMaxLiveModels` comment budgets for. The alternative (handing one +/// controller to two adapters) breaks that constructor rule instead. Login is +/// a schema-driven form submission like every other in this rung, so the +/// class that already submits schema-driven forms is where it belongs; the +/// one thing that makes it special — installing the returned token as the +/// bridge's default session — is `onLoginSucceeded` below, and it is the only +/// place in the whole client that touches a session. + +namespace bookmarks::gui { + +#ifndef Q_MOC_RUN +/// @brief Decodes a `Login` reply body into a `LoginResult`, or reports that +/// it could not be decoded. +/// +/// A named function rather than four lines inside `FormsBridge::submitIfValid` +/// for one reason: its failure arm is otherwise untestable. The reply that +/// reaches `submitIfValid`'s success callback is always produced by +/// `ActionTraits::resultToJson` — glaze writing the *same* reflected +/// type this reads back — on every backend the ladder ships (`LocalBackend`, +/// `SimulatedRemoteBackend`, `QtWebSocketBackend`), so no test driving a real +/// client can make that decode fail. The branch is still worth having and +/// still worth testing: the peer is a separate process that a real deployment +/// can have upgraded, downgraded or replaced independently of the client, and +/// the alternative to reporting a failed decode is installing a +/// default-constructed (tokenless) session and announcing an empty principal +/// as if login had worked. Splitting the decision out makes both arms +/// reachable from `tests/test_bookmark_qml_bridges.cpp` without a fake +/// backend, and leaves the caller with a single unambiguous branch. +/// +/// @param resultJson The reply body, verbatim as the dispatch resolved it. +/// @return The decoded result, or `std::nullopt` if @p resultJson is not a +/// readable `LoginResult`. +[[nodiscard]] std::optional decodeLoginResult(const std::string& resultJson); +#endif + +/// @brief QML-facing face of `bookmarks::gui::BookmarkFormsController`, plus +/// this client's one session-installing seam. +/// +/// Same surface `DynamicForm.qml` expects of a controller — a `schemasJson` +/// property, `submitIfValid(actionType, bodyJson)`, and a `replyReceived` +/// signal — so the shipped renderer needs no bookmarks-specific knowledge, +/// and one instance serves the login screen and every domain form alike. +class FormsBridge : public QObject { + Q_OBJECT + + /// @brief `{actionType: schema}` JSON — everything the QML renderer needs. + Q_PROPERTY(QString schemasJson READ schemasJson CONSTANT) + + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param parent Optional `QObject` parent. + FormsBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief The schema document supplied to the wrapped controller + /// (`bookmark_schemas.hpp`). + /// @return `{actionType: schema}` JSON. + [[nodiscard]] QString schemasJson() const; + + /// @brief Dispatches @p bodyJson as @p actionType's body, emitting + /// `replyReceived` when the reply (or the error) arrives — and, + /// for a successful `Login`, `loggedIn` after the returned token + /// has been installed as the bridge's default session. + /// @param actionType Registered action type id. + /// @param bodyJson Fully-assembled JSON body, as `DynamicForm` builds it. + Q_INVOKABLE void submitIfValid(const QString& actionType, const QString& bodyJson); + + signals: + /// @brief Emitted once per `submitIfValid`. @p payload is the result JSON + /// when @p ok, otherwise the error message. + /// @param actionType The action the reply belongs to. + /// @param ok Whether the dispatch succeeded. + /// @param payload Result JSON, or the error message. + void replyReceived(const QString& actionType, bool ok, const QString& payload); + + /// @brief Emitted after a successful `Login` has been *applied* — i.e. + /// after the token is installed, so a slot may dispatch straight + /// away. Ordered before the corresponding `replyReceived`. + /// @param principal The verified username the server echoed back. + void loggedIn(const QString& principal); + + private: +#ifndef Q_MOC_RUN + /// @brief Installs @p result's token as the shared `Bridge`'s default + /// session, so every subsequent action from every adapter carries + /// it, then announces the new identity. + /// + /// The whole of this client's authentication handling, and deliberately + /// so: this is infrastructure wiring, not business logic + /// (`examples/IMPLEMENTATION.md` rule 2's "(b) pure glue" clause). It + /// decides nothing — the token is the server's, minted and signed by it, + /// and `principal` is the server's echo of the identity it verified, not + /// the client's claim (`bookmarks/dto/auth_dto.hpp`). + /// @param result The decoded `LoginResult` the server returned. + void onLoginSucceeded(const LoginResult& result); + + ::morph::bridge::Bridge& _bridge; + BookmarkFormsController _controller; +#endif +}; + +/// @brief QML-facing face of `bookmarks::gui::BookmarkPresenter`. +/// +/// Turns the presenter's DTO-carrying signals into `QVariantMap`/ +/// `QVariantList` property bags and its typed calls into id invokables. No +/// decisions: ownership, tag diffing, archive filtering and pagination are +/// all the model's, and this only relays what the server computed. +/// +/// `create`/`edit`/`import` are absent on purpose: those are the +/// schema-driven forms `FormsBridge` submits, so their replies arrive on +/// `replyReceived`, and relaying a presenter signal nothing binds to would be +/// a stub (the same exclusion `pastebin::gui::PasteBridge` documents for +/// `created`/`edited`). `getChangesSince`/`exportAll` are absent for the same +/// reason — this rung's shell shows neither a poll view nor an export +/// screen. +class BookmarkBridge : public QObject { + Q_OBJECT + + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param parent Optional `QObject` parent. + BookmarkBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief Fetches the first page of the caller's own active bookmarks. + Q_INVOKABLE void refresh(); + + /// @brief Fetches the first page including archived bookmarks. + Q_INVOKABLE void refreshIncludingArchived(); + + /// @brief Reads one bookmark in full. Emits `loaded`, or `failed`. + /// @param id The bookmark to read. + Q_INVOKABLE void open(qlonglong id); + + /// @brief Archives one bookmark. + /// @param id The bookmark to archive. + Q_INVOKABLE void archive(qlonglong id); + + /// @brief Unarchives one bookmark. + /// @param id The bookmark to unarchive. + Q_INVOKABLE void unarchive(qlonglong id); + + /// @brief Deletes one bookmark. + /// @param id The bookmark to delete. + Q_INVOKABLE void remove(qlonglong id); + + /// @brief Archives or unarchives several bookmarks in one atomic + /// `BulkEdit` (all-or-nothing, README). + /// + /// Driven from the list's multi-selection rather than a form: `BulkEdit`'s + /// required `ids` member is a JSON array, which the shipped `DynamicForm` + /// has no control for — see `BookmarkFormsController`'s class comment. No + /// text is typed here at all; the ids come from rows the user ticked. + /// @param ids The bookmarks to affect, as list-row ids. + /// @param archive `true` to archive, `false` to unarchive. + Q_INVOKABLE void bulkArchive(const QVariantList& ids, bool archive); + + signals: + /// @brief Emitted once the wrapped presenter's registration round trip + /// settles — successfully or not (`Presenter::bound()`, + /// `morph/core/bridge.hpp`'s `whenBound()`). `BookmarkListView.qml` + /// gates its bootstrap `refresh()` on this instead of retrying on a + /// `Timer`. + void bound(); + + /// @brief One page of `ListBookmarks` rows, each an + /// `{id, url, title, tags, createdAt, updatedAt, readState, + /// archiveState, visibility}` map. + /// @param rows The page's rows. + void listed(const QVariantList& rows); + /// @brief A fetched bookmark, as a property bag. + /// @param bookmark The bookmark's fields, rendered as display strings. + void loaded(const QVariantMap& bookmark); + /// @brief An `ArchiveBookmark` succeeded. + void archived(); + /// @brief An `UnarchiveBookmark` succeeded. + void unarchived(); + /// @brief A `DeleteBookmark` succeeded. + void removed(); + /// @brief A `BulkEdit` succeeded. + /// @param affected How many rows the server reported changed. + void bulkEdited(const QString& affected); + /// @brief Any action's typed error, already rendered as a message. + /// @param message The model's own `what()`. + void failed(const QString& message); + + private: +#ifndef Q_MOC_RUN + BookmarkPresenter _presenter; +#endif +}; + +/// @brief QML-facing face of `bookmarks::gui::TagPresenter`. +/// +/// Listing only. `RenameTag`/`MergeTags` are schema-driven forms submitted +/// through `FormsBridge`, so their outcomes arrive on `replyReceived` and the +/// presenter's `renamed`/`merged` signals are deliberately not relayed — +/// relaying a signal nothing binds to would be a stub. +class TagBridge : public QObject { + Q_OBJECT + + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param parent Optional `QObject` parent. + TagBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief Fetches every tag the caller owns, with bookmark counts. + Q_INVOKABLE void refresh(); + + signals: + /// @brief Emitted once the wrapped presenter's registration round trip + /// settles — see `BookmarkBridge::bound`'s identical doc comment. + void bound(); + /// @brief Every tag the caller owns, each an `{id, name, bookmarkCount}` map. + /// @param rows The tag rows. + void listed(const QVariantList& rows); + /// @brief Any action's typed error, already rendered as a message. + /// @param message The model's own `what()`. + void failed(const QString& message); + + private: +#ifndef Q_MOC_RUN + TagPresenter _presenter; +#endif +}; + +/// @brief QML-facing face of `bookmarks::gui::SharedFeedPresenter`. +/// +/// The one cross-user view in this rung: every `Shared`, non-archived +/// bookmark from every owner. Same row shape as `BookmarkBridge::listed`, +/// because the model returns the same `BookmarkSummary` (and the same +/// non-leak rule applies — no `notes`). +class SharedFeedBridge : public QObject { + Q_OBJECT + + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param parent Optional `QObject` parent. + SharedFeedBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief Fetches the first page of the shared feed. + Q_INVOKABLE void refresh(); + + signals: + /// @brief Emitted once the wrapped presenter's registration round trip + /// settles — see `BookmarkBridge::bound`'s identical doc comment. + void bound(); + /// @brief One page of the shared feed, in `BookmarkBridge::listed`'s row shape. + /// @param rows The page's rows. + void listed(const QVariantList& rows); + /// @brief Any action's typed error, already rendered as a message. + /// @param message The model's own `what()`. + void failed(const QString& message); + + private: +#ifndef Q_MOC_RUN + SharedFeedPresenter _presenter; +#endif +}; + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_lib/bookmark_schemas.hpp b/examples/bookmarks/gui_lib/bookmark_schemas.hpp new file mode 100644 index 00000000..8de412d5 --- /dev/null +++ b/examples/bookmarks/gui_lib/bookmark_schemas.hpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "bookmarks/dto/auth_dto.hpp" +#include "bookmarks/dto/bookmark_dto.hpp" +#include "bookmarks/dto/import_export_dto.hpp" +#include "bookmarks/dto/tag_dto.hpp" + +/// @file +/// The one schema document every bookmarks form renders from, assembled in +/// one place so every shell that builds a `BookmarkFormsController` — the +/// desktop client (`gui/main.cpp`), a future WASM client, and the tests — +/// builds the *identical* map instead of each assembling its own +/// (`examples/TESTING.md`'s "same client code" requirement). Same split +/// `pastebin::gui::pasteSchemasJson()` uses, and for the same reason: +/// `BookmarkFormsController` takes the document as a constructor argument by +/// design, so whatever composes it decides which actions it serves. + +namespace bookmarks::gui { + +/// @brief The `{actionType: schema}` document this rung's forms render from. +/// +/// Exactly the six actions a user *enters* — everything else is +/// parameterised by an id picked from a list, never typed, and therefore +/// routes through a presenter rather than a form: +/// +/// * `Login` — the one action an unauthenticated caller can reach, and the +/// whole of this rung's login UI (`bookmarks/dto/auth_dto.hpp`'s `@file` +/// comment states plainly what "dev-mode login" does and does not mean). +/// Rendering it from its own schema rather than hand-building a username +/// field is what keeps `examples/IMPLEMENTATION.md` rule 2 true of the +/// login screen too. +/// * `CreateBookmark` / `EditBookmark` / `ImportBookmarks` — `BookmarkModel`. +/// * `RenameTag` / `MergeTags` — `TagModel`. +/// +/// `BulkEdit` is deliberately absent, and its absence is a renderer +/// limitation rather than a design choice: its one required member is +/// `std::vector`, and the shipped `DynamicForm` has no control +/// for a JSON `array` field (see `BookmarkFormsController`'s class comment +/// and `examples/bookmarks/README.md`'s known-gaps entry). The GUI therefore +/// drives `BulkEdit` from the list's own multi-selection through +/// `BookmarkBridge`, where no typing is involved at all. +/// +/// @return `{"Login": …, "CreateBookmark": …, "EditBookmark": …, +/// "ImportBookmarks": …, "RenameTag": …, "MergeTags": …}`. +[[nodiscard]] inline std::string bookmarkSchemasJson() { + return std::string{"{\"Login\":"} + ::morph::forms::schemaJson() + + ",\"CreateBookmark\":" + ::morph::forms::schemaJson() + + ",\"EditBookmark\":" + ::morph::forms::schemaJson() + + ",\"ImportBookmarks\":" + ::morph::forms::schemaJson() + + ",\"RenameTag\":" + ::morph::forms::schemaJson() + + ",\"MergeTags\":" + ::morph::forms::schemaJson() + "}"; +} + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_lib/shared_feed_presenter.cpp b/examples/bookmarks/gui_lib/shared_feed_presenter.cpp new file mode 100644 index 00000000..38b041b9 --- /dev/null +++ b/examples/bookmarks/gui_lib/shared_feed_presenter.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "shared_feed_presenter.hpp" + +namespace bookmarks::gui { + +SharedFeedPresenter::SharedFeedPresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, + QObject* parent) + : Presenter{parent}, _handler{bridge, executor} { + trackBound(_handler.whenBound()); +} + +void SharedFeedPresenter::reportError(const std::exception_ptr& err) { + try { + std::rethrow_exception(err); + } catch (const std::exception& ex) { + emit failed(QString::fromStdString(ex.what())); + } +} + +void SharedFeedPresenter::list(ListSharedFeed action) { + track( + _handler.execute(std::move(action)), [this](ListSharedFeedResult result) { emit listed(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_lib/shared_feed_presenter.hpp b/examples/bookmarks/gui_lib/shared_feed_presenter.hpp new file mode 100644 index 00000000..aa9917e3 --- /dev/null +++ b/examples/bookmarks/gui_lib/shared_feed_presenter.hpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "gui/presenter.hpp" +#include "bookmarks/dto/shared_feed_dto.hpp" + +#include + +// See pastebin::gui::PastePresenter's identical guard and doc comment +// (examples/pastebin/gui_lib/paste_presenter.hpp) for why moc must never see +// morph/core/bridge.hpp: its template machinery produces bogus moc output +// the same way shared_feed_model.hpp historically did when it transitively +// pulled in Lightweight's DataMapper machinery through the since-removed +// bookmarks/db/db_model.hpp -- shared_feed_model.hpp itself no longer has +// any Lightweight/ODBC dependency at all, now that SharedFeedModel acquires +// a connection per execute() call from Lightweight::GlobalDataMapperPool() +// instead of owning one, but this guard stays for bridge.hpp's own sake. +#ifndef Q_MOC_RUN +#include "bookmarks/models/shared_feed_model.hpp" + +#include +#include +#endif + +namespace bookmarks::gui { + +/// @brief Routes `SharedFeedModel`'s one action through a +/// `BridgeHandler`. Translates and routes only — no +/// domain logic (`IMPLEMENTATION.md` rule 2). +class SharedFeedPresenter : public ::morph::ladder::gui::Presenter { + Q_OBJECT + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param parent Optional `QObject` parent. + SharedFeedPresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, + QObject* parent = nullptr); + + /// @brief Fetches one page of every `Shared`, non-archived bookmark from + /// every owner. Emits `listed` on success, `failed` on error. + /// @param action The page request. + void list(ListSharedFeed action); + + signals: + void listed(ListSharedFeedResult result); + /// @brief Emitted for any action's typed error — @p message is + /// `std::exception::what()`, ready for direct display. + void failed(QString message); + + private: + /// @brief Shared error-display body passed as every `track()` call's + /// third argument below — see `Presenter::track()`'s doc comment + /// (`examples/common/gui/presenter.hpp`) for why. + void reportError(const std::exception_ptr& err); + + ::morph::bridge::BridgeHandler _handler; +}; + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_lib/tag_presenter.cpp b/examples/bookmarks/gui_lib/tag_presenter.cpp new file mode 100644 index 00000000..f2e49394 --- /dev/null +++ b/examples/bookmarks/gui_lib/tag_presenter.cpp @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "tag_presenter.hpp" + +namespace bookmarks::gui { + +TagPresenter::TagPresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent) + : Presenter{parent}, _handler{bridge, executor} { + trackBound(_handler.whenBound()); +} + +void TagPresenter::reportError(const std::exception_ptr& err) { + try { + std::rethrow_exception(err); + } catch (const std::exception& ex) { + emit failed(QString::fromStdString(ex.what())); + } +} + +void TagPresenter::rename(RenameTag action) { + track( + _handler.execute(std::move(action)), [this](Ack) { emit renamed(); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void TagPresenter::merge(MergeTags action) { + track( + _handler.execute(std::move(action)), [this](Ack) { emit merged(); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void TagPresenter::list(ListTags action) { + track( + _handler.execute(std::move(action)), [this](ListTagsResult result) { emit listed(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_lib/tag_presenter.hpp b/examples/bookmarks/gui_lib/tag_presenter.hpp new file mode 100644 index 00000000..897462dc --- /dev/null +++ b/examples/bookmarks/gui_lib/tag_presenter.hpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "gui/presenter.hpp" +#include "bookmarks/dto/tag_dto.hpp" + +#include + +// See pastebin::gui::PastePresenter's identical guard and doc comment +// (examples/pastebin/gui_lib/paste_presenter.hpp) for why moc must never see +// morph/core/bridge.hpp: its template machinery produces bogus moc output +// the same way tag_model.hpp historically did when it transitively pulled in +// Lightweight's DataMapper machinery through the since-removed +// bookmarks/db/db_model.hpp -- tag_model.hpp itself no longer has any +// Lightweight/ODBC dependency at all, now that TagModel acquires a +// connection per execute() call from Lightweight::GlobalDataMapperPool() +// instead of owning one, but this guard stays for bridge.hpp's own sake. +#ifndef Q_MOC_RUN +#include "bookmarks/models/tag_model.hpp" + +#include +#include +#endif + +namespace bookmarks::gui { + +/// @brief Routes every `TagModel` action through a `BridgeHandler`. +/// Translates and routes only — no domain logic (`IMPLEMENTATION.md` +/// rule 2). +class TagPresenter : public ::morph::ladder::gui::Presenter { + Q_OBJECT + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param parent Optional `QObject` parent. + TagPresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief Renames a tag. Emits `renamed` on success, `failed` on error. + /// @param action The rename to apply. + void rename(RenameTag action); + + /// @brief Reassigns every bookmark tagged `sourceId` to `targetId`, then + /// deletes `sourceId`. Emits `merged` on success, `failed` on error. + /// @param action The merge to apply. + void merge(MergeTags action); + + /// @brief Lists every tag the caller owns, with bookmark counts. Emits + /// `listed` on success, `failed` on error. + /// @param action The list request. + void list(ListTags action); + + signals: + void renamed(); + void merged(); + void listed(ListTagsResult result); + /// @brief Emitted for any action's typed error — @p message is + /// `std::exception::what()`, ready for direct display. + void failed(QString message); + + private: + /// @brief Shared error-display body passed as every `track()` call's + /// third argument below — see `Presenter::track()`'s doc comment + /// (`examples/common/gui/presenter.hpp`) for why. + void reportError(const std::exception_ptr& err); + + ::morph::bridge::BridgeHandler _handler; +}; + +} // namespace bookmarks::gui diff --git a/examples/bookmarks/gui_wasm/main_wasm.cpp b/examples/bookmarks/gui_wasm/main_wasm.cpp new file mode 100644 index 00000000..e25f17a9 --- /dev/null +++ b/examples/bookmarks/gui_wasm/main_wasm.cpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// bookmarks' WebAssembly client shell — rung 2's counterpart to rung 1's +/// `examples/pastebin/gui_wasm/main_wasm.cpp`, mirrored from it exactly. +/// +/// This file is the *only* difference between the browser client and the +/// desktop client (`gui/main.cpp`). Everything with behaviour in it — the +/// presenters (`gui_lib/bookmark_presenter.hpp`, `gui_lib/tag_presenter.hpp`, +/// `gui_lib/shared_feed_presenter.hpp`), the forms controller +/// (`gui_lib/bookmark_forms_controller.hpp`), the QML adapters +/// (`gui_lib/bookmark_qml_bridges.hpp`), the schema document +/// (`gui_lib/bookmark_schemas.hpp`) and the QML itself (`gui/qml/Main.qml`, +/// built into the `Bookmarks` module both binaries link) — is shared +/// verbatim. That is `examples/TESTING.md`'s "same client code" requirement, +/// and its explicit ban on bank's `gui_wasm` shadow-header pattern: no model, +/// DTO, presenter or QML file has a WASM variant here. +/// +/// Two things are genuinely WASM-specific, and both are one line each: +/// +/// * **Mode.** There is no `--server` flag and no `Local` alternative. A +/// browser has no ODBC and no in-process server to be `Local` against, so a +/// ladder WASM client is always `Remote` (`examples/IMPLEMENTATION.md` rule +/// 4's WASM clause: "Lightweight (ODBC) cannot run in the browser… the +/// ladder's WASM clients are **remote clients** — persistence lives +/// server-side, behind the model"). The url is baked in at build time via +/// `MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL` (`../CMakeLists.txt`), following +/// pastebin's own `MORPH_LADDER_PASTEBIN_WASM_SERVER_URL` convention — a +/// page served from a static bundle has no argv to read one from. +/// * **No database bootstrap, no local `TokenIssuer`.** `gui/main.cpp` calls +/// `bookmarks::db::setup()` and installs a dev-mode `TokenIssuer` only in +/// `Local` mode (`if (!serverUrl)`); there is nothing to set up here — the +/// server owns the store and the signing secret, and login mints a real +/// token over the wire via `AuthModel`/`FormsBridge`, exactly as the +/// desktop client's own `--server` path does. +/// +/// Note what is *not* here: no `asyncRegistrationEnabled` flag, no +/// `setConnectHandler`, no hand-rolled wait-for-binding timer. The +/// `examples/common/wasm_spike/main_wasm.cpp` spike had to hand-roll both; +/// `AppContext` (`examples/common/gui/app_context.hpp`) now owns them +/// generically for every client, native or browser. This rung hits the same +/// "handler not bound" window pastebin's own `--server`/WASM clients do (the +/// registration round trip that opens on connect and closes once it lands), +/// but needs no `whenBound()`-gated bootstrap dispatch of its own the way +/// `pastebin::gui::PasteBridge::bound` gates `Main.qml`'s first `refresh()`: +/// nothing in this rung's `Main.qml` dispatches on `Component.onCompleted`, +/// so the window closes before a user can click anything, not before a +/// bootstrap call needs to land. +/// +/// @par Verification status +/// Structurally complete and reviewed, **never compiled**: no Emscripten +/// toolchain was available in the environment this was authored in, exactly +/// as rung 1's own `gui_wasm/main_wasm.cpp` and +/// `examples/common/wasm_spike/README.md` record. The `ladder-wasm` compile +/// gate in `.github/workflows/wasm-ladder.yml` is what will actually prove +/// it, on the first push that runs it. + +#include +#include +#include +#include +#include + +#include "bookmark_qml_bridges.hpp" +#include "gui/app_context.hpp" + +#include + +int main(int argc, char** argv) { + QGuiApplication qtApp{argc, argv}; + + // Always Remote — see this file's header comment. `AppContext` builds the + // QtWebSocketBackend with asyncRegistrationEnabled=true, which is what + // makes registration WASM-safe at all (the synchronous path nests a + // QEventLoop and aborts the page — examples/TESTING.md, "WASM reality"). + ::morph::ladder::gui::AppContext ctx{::morph::ladder::gui::Remote{ + .url = QUrl{QString::fromUtf8(MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL)}}}; + + QQmlApplicationEngine engine; + std::unique_ptr formsBridge; + std::unique_ptr bookmarkBridge; + std::unique_ptr tagBridge; + std::unique_ptr feedBridge; + + // Every handler is built from inside onReady(), never before it -- a + // Remote context is not usable the line after its constructor returns, + // per AppContext's own readiness contract. Identical to gui/main.cpp's + // --server path, including building all four adapters up front rather + // than tearing one down and rebuilding it around login -- see that + // file's identical comment for why (a since-fixed deregister-callId + // race this shape was never actually exposed to anyway). + ctx.onReady([&] { + formsBridge = std::make_unique(ctx.bridge(), ctx.executor()); + bookmarkBridge = std::make_unique(ctx.bridge(), ctx.executor()); + tagBridge = std::make_unique(ctx.bridge(), ctx.executor()); + feedBridge = std::make_unique(ctx.bridge(), ctx.executor()); + engine.setInitialProperties({ + {QStringLiteral("formsController"), QVariant::fromValue(formsBridge.get())}, + {QStringLiteral("bookmarkController"), QVariant::fromValue(bookmarkBridge.get())}, + {QStringLiteral("tagController"), QVariant::fromValue(tagBridge.get())}, + {QStringLiteral("feedController"), QVariant::fromValue(feedBridge.get())}, + }); + engine.loadFromModule(MORPH_LADDER_QML_URI, "Main"); + if (engine.rootObjects().isEmpty()) { + qWarning("ladder_bookmarks_gui_wasm: QML engine produced no root object"); + } + }); + + qInfo("ladder_bookmarks_gui_wasm: connecting to %s ...", MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL); + return QGuiApplication::exec(); +} diff --git a/examples/bookmarks/include/bookmarks/app/app.hpp b/examples/bookmarks/include/bookmarks/app/app.hpp new file mode 100644 index 00000000..3e87515e --- /dev/null +++ b/examples/bookmarks/include/bookmarks/app/app.hpp @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "bookmarks/app/metadata_fetcher.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace bookmarks::app { + +/// @brief Owns the server-side pieces every bookmarks deployment shares: the +/// worker pool, the `RemoteServer` with a real `auth::BookmarksAuthorizer` +/// installed, the durable `FileActionLog` (installed process-wide via +/// `morph::journal::setActionLog`), the process-global `TokenIssuer` +/// `AuthModel` mints from (`auth::setTokenIssuer`), the periodic +/// metadata-fetch worker, and the periodic outbox relay. Nothing here decides +/// deployment mode — that stays `examples/common/gui::AppContext`'s job on +/// the client side; this is exclusively the server side. +/// +/// Mirrors `pastebin::app::App` (rung 1) closely and on purpose, including +/// its declaration-order-for-teardown-safety rule (see the private section) +/// and its internal-client pattern for background work: the metadata-fetch +/// worker dispatches `RecordMetadata` through a `Bridge` over +/// `SimulatedRemoteBackend{*server()}`, a first-class client of the same +/// `RemoteServer` a real socket client talks to +/// (`SimulatedRemoteBackend::execute()` calls `RemoteServer::handle()`, the +/// identical dispatch path), so every recorded fetch is authorized, +/// dispatched and journaled exactly like a client-issued action. +/// +/// @par The service principal, and why the worker's own instance is enough +/// The worker's bridge carries a default session holding a token this `App` +/// minted for `auth::kMetadataFetcherPrincipal` with the *same secret* it +/// gave the authorizer, so it verifies exactly like a real user's. It runs on +/// its own `BridgeHandler` — its own registered instance, +/// created by and attributed to itself — never on some user's instance, so +/// per-instance authorization has nothing to object to. What actually keeps +/// the worker's extra authority in bounds is +/// `BookmarkModel::execute(const RecordMetadata&)`'s own check that the +/// dispatching principal *is* the service principal, plus +/// `AuthModel`'s refusal to mint a token in the reserved `system:` namespace +/// on request. `authorizeInstance` could not have done that job here even +/// with the worker's instance recording a real owner (which register +/// envelopes now carry, unlike when this was first written): it compares +/// instance ownership, not row ownership, and the worker's own instance is +/// exactly what it is authorized to use — see +/// `bookmarks/auth/bookmarks_authorizer.hpp`'s `authorizeInstance` doc +/// comment. +class App : public QObject { + Q_OBJECT + public: + /// @brief Wires up the whole server side and starts both periodic timers. + /// @param actionLogPath Where `FileActionLog` persists entries. + /// @param tokenSecret Shared secret for the `auth::BookmarksAuthorizer` + /// this server installs, for the process-global `TokenIssuer` + /// `AuthModel` mints user tokens from, and for the + /// metadata-fetch worker's own service-principal token. All three + /// must be the same value, which is why there is one parameter: + /// a token minted by any of them has to verify against the + /// authorizer that checks every subsequent call. + /// @param fetcher Metadata fetch implementation; defaults to + /// `NullMetadataFetcher` (no network, no I/O at all). + /// @param fetchInterval How often the metadata-fetch worker runs. Tests + /// pass a long interval (effectively disabling the timer) and call + /// `fetchMetadataOnce()` directly instead, for determinism. + /// @param relayInterval How often the outbox relay runs. Same testing + /// convention as @p fetchInterval. + /// @param workers Size of the model worker pool. + /// @param parent Optional `QObject` parent. + explicit App(std::filesystem::path actionLogPath, std::string tokenSecret, + std::shared_ptr fetcher = std::make_shared(), + std::chrono::milliseconds fetchInterval = std::chrono::seconds{5}, + std::chrono::milliseconds relayInterval = std::chrono::seconds{2}, std::size_t workers = 4, + QObject* parent = nullptr); + + /// @brief Stops both timers and detaches the process-wide action log and + /// token issuer. + ~App() override; + + /// @brief Stops both periodic timers, so nothing this `App` owns can + /// dispatch new work from now on. + /// + /// `~App` calls this too, so an owner that never calls it sees exactly the + /// previous behavior. It is public because a *shutting-down* owner has to + /// call it earlier than that: the settle contract on `fetchInFlight()` + /// below says "pump until it is `false`, then destroy", and pumping is + /// precisely what lets `_fetchTimer` tick. A drain loop that ran with the + /// timer still armed could therefore dispatch a brand-new `RecordMetadata` + /// pass out of its own `processEvents()` call, re-raising `fetchInFlight()` + /// after it had settled — and if that late pass is still outstanding when + /// the drain's budget expires, `~App` runs with a dispatch in flight, which + /// is the exact window the drain exists to close. Calling this first makes + /// the drain monotonic: the outstanding set can only shrink. + /// + /// Idempotent (`QTimer::stop()` on a stopped timer is a no-op) and safe to + /// call from the Qt thread at any point in the object's life. + void stopBackgroundJobs(); + + App(const App&) = delete; + App& operator=(const App&) = delete; + App(App&&) = delete; + App& operator=(App&&) = delete; + + /// @brief The server every transport (a `QtWebSocketServer`, a test's + /// `BackendRig`) wraps or dispatches against. + /// @return The shared `RemoteServer`; never null. + [[nodiscard]] std::shared_ptr<::morph::backend::RemoteServer> server() const noexcept { return _server; } + + /// @brief Runs one metadata-fetch pass right now: finds every bookmark + /// (across every owner) whose title is still empty, calls the + /// injected fetcher for each, and fire-and-forget dispatches + /// `RecordMetadata` through the internal client. + /// + /// Does not block on the dispatched calls settling — callers that need to + /// observe completion (tests, shutdown) pump the Qt event loop afterward + /// (`morph::ladder::testkit::pumpUntil`) on `fetchInFlight()`. + /// + /// The internal client used to issue this pass's dispatches stays alive + /// until every dispatched `RecordMetadata` has actually settled, success + /// or failure — see the implementation's own comment for why + /// deregistering it any earlier would race `RemoteServer`'s still-pending + /// dispatch and silently drop the pass. + void fetchMetadataOnce(); + + /// @brief Whether any `RecordMetadata` dispatched by a previous + /// `fetchMetadataOnce()` has not settled yet. + /// + /// The settle seam a test needs before letting an `App` go, identical in + /// contract to `pastebin::app::App::sweepInFlight()`: observing the + /// *effect* of a pass (the titles are set) is not the same as the + /// dispatches having settled, because the update happens on a worker + /// thread while each call's completion callback is delivered later, on + /// the Qt event loop. Destroying the `App` in that window leaves those + /// callbacks queued against objects it owned. Pump on this until it is + /// `false`, then destroy. + /// @return `true` while at least one dispatched `RecordMetadata` is outstanding. + [[nodiscard]] bool fetchInFlight() const noexcept { return _fetchInFlight->load() != 0; } + + /// @brief Drains `bookmark_outbox` into the durable action log via + /// `journal::OutboxRelay`, once, right now. + /// + /// Synchronous, so it needs no in-flight seam of its own: it touches the + /// database and the log directly rather than dispatching through the + /// server. Both `BookmarkModel::execute(const BulkEdit&)` and + /// `TagModel`'s `RenameTag`/`MergeTags` write into that one table, so one + /// relay covers both models. + /// @return The number of outbox rows relayed in this pass. + std::size_t relayOutboxOnce(); + + private: + // Declaration order is load-bearing, and `_fetchExecutor` comes first on + // purpose — the identical hazard pastebin::app::App documents at length. + // Members are destroyed in reverse, so this is the *last* thing to go. A + // pass's RecordMetadata runs on `_pool`, and the worker thread that + // finishes it resolves the completion by calling `post()` on the executor + // the call was issued with. With the executor declared after the pool + // (its natural reading order), `~App` would destroy it while pool threads + // were still finishing dispatched work, and the next completion to + // resolve would post through a dangling `IExecutor*`. Destroying `_pool` + // (whose destructor joins its threads, so every in-flight completion has + // resolved) before the executor closes that window. `QtExecutor` holds no + // state and queues onto `QCoreApplication`, so callbacks it has already + // posted stay safe after `App` is gone. + ::morph::qt::QtExecutor _fetchExecutor; + /// Outstanding dispatches from `fetchMetadataOnce()`. A `shared_ptr` so + /// the completion callbacks that decrement it hold it by value rather + /// than through `this` — a callback delivered after the `App` is gone + /// (the very case `fetchInFlight()` exists to let callers avoid) must not + /// touch a destroyed member. + std::shared_ptr> _fetchInFlight{std::make_shared>(0)}; + std::shared_ptr<::morph::journal::FileActionLog> _actionLog; + ::morph::exec::ThreadPoolExecutor _pool; + std::shared_ptr<::morph::backend::RemoteServer> _server; + ::morph::bridge::Bridge _fetchBridge; + std::shared_ptr _fetcher; + QTimer _fetchTimer; + QTimer _relayTimer; +}; + +} // namespace bookmarks::app diff --git a/examples/bookmarks/include/bookmarks/app/metadata_fetcher.hpp b/examples/bookmarks/include/bookmarks/app/metadata_fetcher.hpp new file mode 100644 index 00000000..0310eb91 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/app/metadata_fetcher.hpp @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +/// @file +/// The metadata-fetch worker's one injectable seam. +/// +/// **Why no real HTTP client**: morph ships none, and building one is +/// squarely out of this rung's scope — the framework subsystem under stress +/// here is the *background-job dispatch pattern* (an internal client routing +/// through the full server pipeline: authorize, authenticate, dispatch, +/// journal), not network I/O. `IBookmarkMetadataFetcher` is the extension +/// point a real deployment implements; this rung ships only +/// `NullMetadataFetcher`, which performs no I/O and returns an empty +/// `FetchedMetadata`, so nothing in the test suite depends on timing or on a +/// network being reachable. + +namespace bookmarks::app { + +/// @brief What a metadata fetch produces. Both fields empty is a legitimate +/// "found nothing" result, not a distinguished failure — mirrors +/// `RecordMetadata`'s own "empty = leave the stored value alone" DTO +/// convention. +struct FetchedMetadata { + /// @brief The page title, or empty if none was found. + std::string title; + /// @brief A path/URL to the page's favicon, or empty if none was found. + std::string faviconPath; +}; + +/// @brief Pluggable page-metadata fetcher. See this file's own `@file` +/// comment for why this rung ships no real HTTP implementation. +class IBookmarkMetadataFetcher { + public: + IBookmarkMetadataFetcher() = default; + virtual ~IBookmarkMetadataFetcher() = default; + IBookmarkMetadataFetcher(const IBookmarkMetadataFetcher&) = delete; + IBookmarkMetadataFetcher& operator=(const IBookmarkMetadataFetcher&) = delete; + IBookmarkMetadataFetcher(IBookmarkMetadataFetcher&&) = delete; + IBookmarkMetadataFetcher& operator=(IBookmarkMetadataFetcher&&) = delete; + + /// @brief Fetches title/favicon metadata for @p url. + /// + /// Called synchronously from `App::fetchMetadataOnce()`, once per + /// untitled bookmark, on whichever thread drove that pass. An + /// implementation that really does network I/O is responsible for its + /// own timeout — a fetcher that blocks indefinitely blocks the sweep. + /// @param url The bookmark's url. + /// @return The fetched metadata, or an empty one if nothing was found. + [[nodiscard]] virtual FetchedMetadata fetch(const std::string& url) = 0; +}; + +/// @brief The shipped default: performs no I/O, always returns an empty +/// result. Deterministic and instant, for tests and for a deployment +/// that has not yet plugged in a real fetcher. +class NullMetadataFetcher : public IBookmarkMetadataFetcher { + public: + /// @brief Ignores @p url and reports "nothing found". + /// @param url Ignored. + /// @return A default-constructed `FetchedMetadata`. + [[nodiscard]] FetchedMetadata fetch([[maybe_unused]] const std::string& url) override { return {}; } +}; + +} // namespace bookmarks::app diff --git a/examples/bookmarks/include/bookmarks/auth/bookmarks_authorizer.hpp b/examples/bookmarks/include/bookmarks/auth/bookmarks_authorizer.hpp new file mode 100644 index 00000000..5cd09f0d --- /dev/null +++ b/examples/bookmarks/include/bookmarks/auth/bookmarks_authorizer.hpp @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include +#include +#include + +/// @file +/// The one `IAuthorizer` every model-bearing `RemoteServer` in this rung +/// installs. Real signed-token authentication (README "Sessions & +/// authorization" -- bookmarks is the first rung to wire this end-to-end, +/// not merely touch `IAuthorizer`), plus the two hooks +/// `SigningAuthorizer` leaves at their allow-all defaults: +/// `authorizeRegister` and `authorizeInstance`. +/// +/// `register`/`attach`/`assign`/`deregister` envelopes now carry the +/// client's authenticated session, so `RemoteServer` records a real, +/// non-empty owner principal for a plain-registered instance and both hooks +/// below can key on identity for it (shared/keyed instances remain recorded +/// ownerless, by a separate, deliberate design choice unrelated to session +/// plumbing — see `authorizeInstance`'s own doc comment). This rung's +/// `authorizeRegister` stays unconditionally permissive anyway (see its own +/// doc comment for why), so what is genuinely enforced -- and it *is* the +/// whole trust boundary this rung claims -- is: `SigningAuthorizer:: +/// authorize()` verifying a real signed token on **every `execute`**, +/// `RemoteServer` overwriting `Context::principal` with the verified +/// identity before the model runs, and each model re-reading +/// `session::current()->principal` and scoping its own queries to it +/// (`examples/IMPLEMENTATION.md` rule 1: "models must re-check their own +/// preconditions and authorization"). An unauthenticated caller can create a +/// model instance, and nothing else: every action it could dispatch on that +/// instance is rejected by `authorize()` before a model ever sees it. The +/// resulting unauthenticated-instance-churn surface is bounded by +/// `RemoteServer::setLimitPolicy`'s `maxLiveModels`, which +/// `bookmarks::app::App` sets for exactly this reason. + +namespace bookmarks::auth { + +/// @brief Service principal the internal metadata-fetch worker (Task 12) +/// authenticates as. Reserved by convention, not by any framework +/// mechanism -- nothing stops a real user from registering under this +/// name too, since usernames are not a secret; the worker is +/// distinguished by holding a token only the server process itself +/// can mint (it shares the server's `TokenIssuer` secret), not by the +/// string alone. +inline constexpr std::string_view kMetadataFetcherPrincipal = "system:metadata-fetcher"; + +/// @brief Namespace prefix reserved for service principals such as +/// `kMetadataFetcherPrincipal`. No human may log in under it — see +/// `isReservedPrincipal`. +inline constexpr std::string_view kServicePrincipalPrefix = "system:"; + +/// @brief Longest principal this rung accepts, in bytes. +inline constexpr std::size_t kMaxPrincipalBytes = 64; + +/// @brief Whether @p principal is acceptable as a login/registration +/// identity for this rung. +/// +/// Defense-in-depth, kept even though the gap it originally guarded against +/// is now closed framework-side: `morph::session::TokenIssuer::issue()` +/// writes `SessionToken::principal` via `glz::write_json` with +/// `escape_control_characters = true` (`session_auth.hpp`), so a principal +/// containing a raw control byte no longer corrupts the token's JSON payload +/// on the way in. This validator still rejects such input at this rung's own +/// boundary regardless -- a second, independent line of defense costs +/// nothing to keep. The bound is deliberately ASCII-only and +/// short: this is a *username*, not free text, so `[A-Za-z0-9._:-]` covers +/// every reasonable login identity without needing Unicode normalization +/// decisions (contrast tag names, Task 6, which are free text and do need +/// one). `:` is included specifically so `kMetadataFetcherPrincipal` +/// (`"system:metadata-fetcher"`) itself passes this check -- the +/// `system:`-prefix service-principal convention needs a separator between +/// the namespace and the name, and `:` is the one the README already uses. +/// @param principal Candidate principal string. +/// @return `true` if @p principal is non-empty, at most `kMaxPrincipalBytes` +/// long, and every byte is an ASCII letter, digit, `.`, `_`, `:`, or `-`. +[[nodiscard]] inline bool isValidPrincipal(std::string_view principal) noexcept { + if (principal.empty() || principal.size() > kMaxPrincipalBytes) { + return false; + } + for (const char ch : principal) { + const auto byte = static_cast(ch); + const bool ok = (byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z') || + (byte >= '0' && byte <= '9') || byte == '.' || byte == '_' || byte == '-' || + byte == ':'; + if (!ok) { + return false; + } + } + return true; +} + +/// @brief Whether @p principal is reserved for the server's own internal +/// workers and must never be handed to a caller. +/// +/// `kMetadataFetcherPrincipal`'s own doc comment notes that the service +/// principal is distinguished by "holding a token only the server process +/// itself can mint", not by the string. That is only true if the server +/// refuses to mint one on request — and `AuthModel::execute(const Login&)` +/// (Task 12) mints a token for whatever username it is given, since this +/// rung has no credential store. Without this check any client could log in +/// as `"system:metadata-fetcher"` and obtain a genuinely-signed service +/// token, which `BookmarkModel::execute(const RecordMetadata&)` accepts — +/// letting it rewrite the title and favicon of every other user's bookmarks. +/// The whole `system:` namespace is reserved rather than just the one known +/// name, so a later worker principal needs no change here. +/// @param principal Candidate principal string. +/// @return `true` if @p principal begins with `kServicePrincipalPrefix`. +[[nodiscard]] inline bool isReservedPrincipal(std::string_view principal) noexcept { + return principal.starts_with(kServicePrincipalPrefix); +} + +/// @brief This rung's `IAuthorizer`: real signed-token auth +/// (`SigningAuthorizer`'s inherited `authorize`/`authenticate`), plus +/// overrides of the two instance-lifecycle hooks — `authorizeRegister` +/// stays permissive by choice, `authorizeInstance` is genuinely +/// enforcing for plain-registered instances; see each hook's own doc +/// comment. +class BookmarksAuthorizer : public ::morph::session::SigningAuthorizer { + public: + using SigningAuthorizer::SigningAuthorizer; + + /// @brief Model type id of the one model a tokenless caller may execute on. + static constexpr std::string_view kAnonymousModelType = "AuthModel"; + /// @brief Action type id of the one action a tokenless caller may execute. + static constexpr std::string_view kAnonymousActionType = "Login"; + + /// @brief `SigningAuthorizer::authorize`, with exactly one carve-out: + /// `AuthModel`/`Login` is admitted without a token. + /// + /// Without this the rung has a chicken-and-egg deadlock that no client can + /// break: `SigningAuthorizer::authorize()` verifies `Context::token` on + /// **every** `execute` and returns `false` when there is none — including + /// for `Login`, which is the only way to obtain a token in the first + /// place. Every action a fresh client can send is therefore answered + /// `err "unauthorized"`, login included. This was found by driving the + /// desktop client against a real `ladder_bookmarks_server` (task 18); the + /// existing `Login` tests all call `AuthModel::execute()` directly, which + /// never consults an authorizer, so nothing had exercised the login action + /// *over a server* before. + /// + /// The carve-out is deliberately as narrow as it can be — one model type, + /// one action type, both compared exactly — and it gives away nothing that + /// was not already reachable: `AuthModel` is stateless, holds no database, + /// and `execute(const Login&)`'s own body rejects an invalid principal and + /// refuses the reserved `system:` namespace outright. What an anonymous + /// caller can do here is mint a token for a username it names, which is + /// exactly what a dev-mode login *is* (`bookmarks/dto/auth_dto.hpp`'s + /// `@file` comment states the whole security posture plainly). Every other + /// model and every other action still requires a validly signed, unexpired + /// token, and `RemoteServer` still clears the client-asserted principal + /// whenever `authenticate()` cannot vouch for it — so a `Login` dispatched + /// anonymously runs with an *empty* `session::current()->principal`, which + /// `AuthModel` neither reads nor needs. + /// + /// A real deployment replaces the body of `AuthModel::execute(const + /// Login&)` with password/OAuth verification; the fact that its login + /// action is reachable without a bearer token does not change, because + /// that is what "log in" means. + /// + /// @param ctx Per-call session (its `token` is verified for + /// everything but the carve-out). + /// @param modelType Target model type id. + /// @param actionType Target action type id. + /// @return `true` to allow dispatch, `false` to reject. + [[nodiscard]] bool authorize(const ::morph::session::Context& ctx, + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) + std::string_view modelType, std::string_view actionType) const override { + if (modelType == kAnonymousModelType && actionType == kAnonymousActionType) { + return true; + } + return SigningAuthorizer::authorize(ctx, modelType, actionType); + } + + /// @brief Admits every registration of a type this server actually + /// serves, regardless of caller identity — a deliberate choice, + /// not a framework limitation. + /// + /// This was originally written as "only an authenticated caller may + /// create an instance", copying the shape the framework's own suite + /// documents (`tests/test_register_authorization.cpp`'s + /// `AuthenticatedOnlyRegisterAuthorizer`), back when `register` + /// envelopes carried no session at all and @p ctx was therefore always + /// empty here — including for a client holding a perfectly valid token, + /// and including the `AuthModel` handler exempted below, so that rule + /// rejected every client's very first `BridgeHandler` construction. + /// `register`/`attach`/`assign`/`deregister` envelopes now carry the + /// caller's authenticated session, so @p ctx is populated when the + /// caller holds one — but this hook stays unconditionally permissive + /// anyway, since gating registration by identity buys nothing extra: + /// every subsequent `execute` on the instance still goes through the + /// inherited `SigningAuthorizer::authorize()`, which requires a validly + /// signed, unexpired token, and then through the model's own + /// `session::current()->principal` scoping. The `modelType` parameter + /// stays in the signature (and the `"AuthModel"` mention stays in this + /// comment) because the *type*-keyed half of this hook — refusing a + /// model type outright — remains available if this rung ever needs it; + /// the identity-keyed half is a choice not to gate, not an inability to. + /// @param ctx Per-call session. Populated with the caller's + /// verified principal when it holds a valid token, + /// empty otherwise; ignored either way (see above). + /// @param modelType Target model type id. `RemoteServer` has already + /// rejected a type its registry does not know by the + /// time this runs, so every value reaching here is one + /// this rung serves. + /// @return `true`, always — see this function's own doc comment. + [[nodiscard]] bool authorizeRegister([[maybe_unused]] const ::morph::session::Context& ctx, + [[maybe_unused]] std::string_view modelType) const override { + return true; + } + + /// @brief Real ownership for a plain-registered instance; a pass-through + /// for an ownerless (shared) one. + /// + /// `ownerPrincipal` is the value `RemoteServer` recorded at `register` + /// time. See `tests/test_policy_hardening.cpp`'s `OwnershipAuthorizer` + /// for the identical one-line shape this mirrors. + /// + /// Genuinely enforcing today, for every plain-registered `BookmarkModel`/ + /// `TagModel`/`AuthModel` instance: `register` envelopes now carry the + /// caller's authenticated session, so `RemoteServer` records that + /// caller's real principal as the instance's owner, and this function + /// denies a different principal's `execute`/`deregister` naming that + /// instance's `modelId` directly. `SharedFeedModel` (this rung's only + /// shared instance) still falls through the `ownerPrincipal.empty()` + /// branch — shared instances are recorded ownerless by separate, + /// deliberate design (there is no single owning user for a cross-user + /// feed), not because ownership can't be tracked. + /// + /// What this does *not* catch, and cannot: `BridgeHandler` (this + /// rung's only shipped client) never names another connection's + /// `modelId` — each client only ever dispatches through its own + /// registered instance — so a normal client's cross-user `GetBookmark{id}` + /// (naming *another user's row* through the caller's *own* instance) is + /// invisible to this instance-level check entirely; it would pass + /// regardless, since it never touches an instance this caller doesn't + /// own. That case is caught only by `BookmarkModel::execute`'s own + /// row-level re-check (see `tests/test_bookmark_model.cpp`'s "denied by + /// the model's own ownership re-check" case), which is the *only* layer + /// that could ever catch it — a per-instance check has no way to express + /// a per-row constraint. This function's real target is a client that + /// does not go through `BridgeHandler` at all: a raw wire client crafting + /// an `execute`/`deregister` envelope naming a `modelId` it learned or + /// guessed, belonging to an instance it never registered. + /// @param ctx Per-call session; `principal` is the verified identity. + /// @param modelType Ignored: the same rule applies to every model. + /// @param actionType Ignored. + /// @param modelId Ignored: the decision only needs the owner. + /// @param ownerPrincipal Principal recorded as the instance's owner, or + /// empty if none was recorded (a shared instance). + /// @return `true` if @p ownerPrincipal is empty or matches `ctx.principal`. + [[nodiscard]] bool authorizeInstance(const ::morph::session::Context& ctx, + [[maybe_unused]] std::string_view modelType, + [[maybe_unused]] std::string_view actionType, + [[maybe_unused]] std::uint64_t modelId, + std::string_view ownerPrincipal) const override { + return ownerPrincipal.empty() || ownerPrincipal == ctx.principal; + } +}; + +namespace detail { + +/// @brief Backing storage for `setTokenIssuer`/`tokenIssuer` — a single +/// shared slot, guarded by a single mutex. Not exposed directly; +/// both public functions below go through this pair, so they +/// genuinely observe each other's writes (unlike two independent +/// function-local statics, which would each own an unrelated slot). +[[nodiscard]] inline std::mutex& tokenIssuerMutex() { + static std::mutex mtx; + return mtx; +} + +[[nodiscard]] inline std::shared_ptr<::morph::session::TokenIssuer>& tokenIssuerSlot() { + static std::shared_ptr<::morph::session::TokenIssuer> slot; + return slot; +} + +} // namespace detail + +/// @brief Installs @p issuer as the process-global `TokenIssuer`, mirroring +/// `morph::journal::setActionLog`'s identical shape — the same answer +/// `AuthModel` (Task 12) reaches for since it is registered via the +/// plain `BRIDGE_REGISTER_MODEL` default-construction path rather +/// than `ModelRegistryFactory`'s per-instance construction-hook seam +/// (`include/morph/core/registry.hpp`): a process-global slot passes +/// the secret through instead. `App` calls this once at startup, +/// with the *same* secret it hands to `BookmarksAuthorizer`, so a +/// token `AuthModel::execute(const Login&)` mints verifies against +/// the very authorizer that will check every subsequent call. +/// @param issuer The issuer every `AuthModel` instance will read, or +/// `nullptr` to clear it (tests do this via `DbFixture`-adjacent +/// RAII if a test needs isolation — see `test_app.cpp`'s login case, +/// Task 12). +inline void setTokenIssuer(std::shared_ptr<::morph::session::TokenIssuer> issuer) { + const std::scoped_lock lock{detail::tokenIssuerMutex()}; + detail::tokenIssuerSlot() = std::move(issuer); +} + +/// @brief Returns the process-global `TokenIssuer` installed by +/// `setTokenIssuer`, or `nullptr` if none is installed yet. +[[nodiscard]] inline std::shared_ptr<::morph::session::TokenIssuer> tokenIssuer() { + const std::scoped_lock lock{detail::tokenIssuerMutex()}; + return detail::tokenIssuerSlot(); +} + +} // namespace bookmarks::auth diff --git a/examples/bookmarks/include/bookmarks/core/errors.hpp b/examples/bookmarks/include/bookmarks/core/errors.hpp new file mode 100644 index 00000000..ffa0e093 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/core/errors.hpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +/// @file +/// Domain exceptions. A model's `execute(...)` throws one of these; morph +/// captures it as a `std::exception_ptr` and delivers it to the caller's +/// `.onError(...)` callback. See `pastebin/core/errors.hpp` for the +/// identical shape and rationale this mirrors. + +namespace bookmarks { + +/// @brief Base of every bookmarks-specific error a model throws. +struct BookmarksError : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +/// @brief No bookmark/tag exists at the given id — it never existed, or it +/// was deleted. Ownership does *not* come into it: a row that exists +/// but belongs to another principal is `Forbidden`, which is what +/// `BookmarkModel::loadOwned()` actually throws for that case. +struct NotFound : BookmarksError { + using BookmarksError::BookmarksError; +}; + +/// @brief An action's `validate()` rejected its input. +struct ValidationError : BookmarksError { + using BookmarksError::BookmarksError; +}; + +/// @brief A write lost a race: the target row changed between this +/// client's read and its write (the compare-and-swap conflict shape +/// `pastebin::Conflict` established this session for `EditPaste`), +/// or a `MergeTags`/rename would collide with an existing tag name. +struct Conflict : BookmarksError { + using BookmarksError::BookmarksError; +}; + +/// @brief The caller is authenticated, but the target row exists and is +/// owned by a different principal. Distinguished from `NotFound` +/// deliberately: `docs/spec/security.md`'s registration/instance +/// hooks already keep a foreign id from being *reached* in most +/// cases (Task 14), but a model's own re-check (rule 1 — the local +/// backend enforces nothing) needs its own typed signal, and the +/// expected-strain-points test for "local mode has no authorization +/// at all" (Task 15) specifically wants to see this thrown, not a +/// NotFound that would quietly look like the row never existed. +struct Forbidden : BookmarksError { + using BookmarksError::BookmarksError; +}; + +/// @brief An import chunk (or other bounded payload) exceeded this rung's +/// own size bound, distinct from the transport's own message-size +/// limit (`docs/spec/security.md`) which rejects the call before a +/// model ever sees it. +struct TooLarge : BookmarksError { + using BookmarksError::BookmarksError; +}; + +} // namespace bookmarks diff --git a/examples/bookmarks/include/bookmarks/core/types.hpp b/examples/bookmarks/include/bookmarks/core/types.hpp new file mode 100644 index 00000000..a5497201 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/core/types.hpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +/// @file +/// Bookmarks' strong id/protocol-scalar types. `BookmarkId`/`TagId` are the +/// numeric-surrogate-key sibling of `pastebin::PasteId` (which wraps a +/// string, since a paste's id *is* its animal-name primary key) — +/// bookmarks' primary keys are ordinary auto-incrementing integers (bank's +/// convention, `Light::PrimaryKey::ServerSideAutoIncrement`), so the +/// wrapped payload is `std::int64_t`, not `std::string`. Same +/// `hasValue()`-capable shape and the same `fromOptional` factory +/// (`examples/pastebin/include/pastebin/core/types.hpp`'s own doc comment +/// explains why it exists as a named factory rather than a second +/// same-arity constructor). + +namespace bookmarks { + +/// @brief Strong id for a bookmark (a `bookmarks` table surrogate key). +/// +/// Wire form: a plain nullable JSON integer (via the `glz::meta` +/// specialisation below) — exactly like an unwrapped `std::optional`. +struct BookmarkId { + /// @brief The payload; `std::nullopt` means "not entered". + std::optional value; + + /// @brief Constructs the empty state. + constexpr BookmarkId() noexcept = default; + + /// @brief Engages with @p id. + explicit BookmarkId(std::int64_t id) noexcept : value{id} {} + + /// @brief Adopts an optional payload as-is. + /// @param payload The optional payload to adopt as-is. + /// @return A `BookmarkId` wrapping @p payload directly. + [[nodiscard]] static BookmarkId fromOptional(std::optional payload) noexcept { + BookmarkId result; + result.value = payload; + return result; + } + + /// @brief Whether a value has been entered. + /// @return `true` if the payload is engaged. + [[nodiscard]] bool hasValue() const noexcept { return value.has_value(); } + + /// @brief Unchecked access to the engaged value (UB when empty, exactly + /// like `std::optional::operator*`). + /// @return The engaged value. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + [[nodiscard]] std::int64_t operator*() const noexcept { return *value; } + + /// @brief Equality/ordering on the payload; empty compares only equal to empty. + [[nodiscard]] auto operator<=>(const BookmarkId&) const noexcept = default; +}; + +/// @brief Strong id for a tag (a `tags` table surrogate key). Same shape as +/// `BookmarkId` — see that type's doc comment. +struct TagId { + std::optional value; + + constexpr TagId() noexcept = default; + explicit TagId(std::int64_t id) noexcept : value{id} {} + + [[nodiscard]] static TagId fromOptional(std::optional payload) noexcept { + TagId result; + result.value = payload; + return result; + } + + [[nodiscard]] bool hasValue() const noexcept { return value.has_value(); } + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + [[nodiscard]] std::int64_t operator*() const noexcept { return *value; } + [[nodiscard]] auto operator<=>(const TagId&) const noexcept = default; +}; + +/// @brief Opaque pagination cursor, shared by every list action in this +/// rung (`ListBookmarks`, `ListSharedFeed`) — each keyset-paginates +/// on a numeric surrogate primary key, so one cursor shape serves +/// all of them (`IMPLEMENTATION.md` rule 3's protocol-scalars row: +/// a named opaque newtype per *role*, and "pagination cursor" is one +/// role here, not one per entity). +struct Cursor { + std::optional value; + + constexpr Cursor() noexcept = default; + explicit Cursor(std::int64_t token) noexcept : value{token} {} + + [[nodiscard]] static Cursor fromOptional(std::optional payload) noexcept { + Cursor result; + result.value = payload; + return result; + } + + [[nodiscard]] bool hasValue() const noexcept { return value.has_value(); } + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + [[nodiscard]] std::int64_t operator*() const noexcept { return *value; } + [[nodiscard]] auto operator<=>(const Cursor&) const noexcept = default; +}; + +/// @brief `GetChangesSince`'s cursor (issue #43): a millisecond timestamp +/// alone cannot be a correct "since" boundary, because a strict `>` +/// comparison on `updated_at_ms` silently drops a write that lands in +/// the *same millisecond* as the previous poll's cursor -- plausible +/// whenever poll -> write -> poll executes within one clock tick (a +/// fast machine, or a loaded CI runner). Neither `>` (under-inclusive, +/// the bug) nor `>=` (over-inclusive: would re-deliver the exact row +/// that established the cursor on every later poll at the same +/// instant) is correct alone. Pairing the timestamp with the id of +/// the last row already delivered *at that exact timestamp* makes +/// the boundary strictly orderable: a query filters on +/// `updated_at_ms > timestampMs OR (updated_at_ms = timestampMs AND +/// id > lastId)`, so a same-millisecond write with a higher id is +/// included, and the row that produced `lastId` itself is not +/// re-delivered. +/// +/// `lastId` is meaningful only relative to its own `timestampMs`; it +/// does not on its own establish a global row ordering the way +/// `Cursor` (this file, `ListBookmarks`' keyset pagination) does -- +/// `BookmarkRecord.id` and `updated_at_ms` do not necessarily +/// co-vary, since a row's id is assigned at creation but +/// `updated_at_ms` bumps on every later edit. `lastId.hasValue() == +/// false` (the default) means "no tie-break needed": correct both +/// for the empty "first poll ever" cursor and for an `asOf` whose +/// instant had no row landing at exactly that millisecond. +/// +/// Deliberately a plain aggregate with no user-declared special members +/// (matching `BookmarkSummary`/`GetChangesSince`/`GetChangesSinceResult`, +/// not `Cursor`/`BookmarkId`'s explicit-constructor-plus-`glz::meta` shape): +/// glaze's automatic reflection needs it that way, and no call site needs +/// direct `ChangesCursor` equality/ordering -- see this file's `glz::meta` +/// section for why `ChangesCursor` itself has none. +struct ChangesCursor { + /// @brief The boundary instant. Empty means "the beginning of time" + /// (`GetChangesSince`'s first-ever poll). + ::morph::time::Timestamp timestampMs; + /// @brief The highest id already delivered at exactly `timestampMs`. + /// Empty means no tie-break is needed at this boundary. + std::optional lastId; +}; + +/// @brief Idempotency key for one chunk of an `ImportBookmarks` call +/// (`IMPLEMENTATION.md` rule 3's protocol-scalars row: op-ids / +/// idempotency keys get a named opaque newtype). String-payload, +/// client-chosen, opaque — same shape as `pastebin::PasteId`. +struct ImportOpId { + std::optional value; + + constexpr ImportOpId() noexcept = default; + explicit ImportOpId(std::string token) noexcept : value{std::move(token)} {} + + [[nodiscard]] static ImportOpId fromOptional(std::optional payload) noexcept { + ImportOpId result; + result.value = std::move(payload); + return result; + } + + [[nodiscard]] bool hasValue() const noexcept { return value.has_value(); } + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + [[nodiscard]] const std::string& operator*() const noexcept { return *value; } + [[nodiscard]] auto operator<=>(const ImportOpId&) const noexcept = default; +}; + +/// @brief Trivial, fieldless acknowledgement result for actions with +/// nothing else to return. Mirrors `pastebin::Ack`. +struct Ack {}; + +} // namespace bookmarks + +/// @brief On the wire a `BookmarkId` is its nullable underlying integer. +template <> +struct glz::meta { + static constexpr auto value = &bookmarks::BookmarkId::value; + static constexpr std::string_view name = "BookmarkId"; +}; + +/// @brief On the wire a `TagId` is its nullable underlying integer. +template <> +struct glz::meta { + static constexpr auto value = &bookmarks::TagId::value; + static constexpr std::string_view name = "TagId"; +}; + +/// @brief On the wire a `Cursor` is its nullable underlying integer. +template <> +struct glz::meta { + static constexpr auto value = &bookmarks::Cursor::value; + static constexpr std::string_view name = "Cursor"; +}; + +// `ChangesCursor` needs no `glz::meta` specialisation: it is a plain +// aggregate with public named fields (`timestampMs`, `lastId`), so glaze's +// automatic reflection already maps it to a small wire object with those +// same field names -- the same reason `BookmarkSummary` and +// `GetChangesSince`/`GetChangesSinceResult` (bookmark_dto.hpp) have none +// either. `glz::meta` here is reserved for the single-scalar newtypes above +// (which must be *unwrapped* to their payload on the wire) and the enums +// below (which need a string mapping). + +/// @brief On the wire an `ImportOpId` is its nullable underlying string. +template <> +struct glz::meta { + static constexpr auto value = &bookmarks::ImportOpId::value; + static constexpr std::string_view name = "ImportOpId"; +}; diff --git a/examples/bookmarks/include/bookmarks/db/bookmark_entity.hpp b/examples/bookmarks/include/bookmarks/db/bookmark_entity.hpp new file mode 100644 index 00000000..07d14edf --- /dev/null +++ b/examples/bookmarks/include/bookmarks/db/bookmark_entity.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +/// @file +/// `BookmarkRecord` deliberately carries **zero** relation-typed members +/// (no `HasMany`, no `HasManyThrough`) — see this plan's Global Constraints +/// section for the verified reason: `DataMapper::Update()`'s +/// non-reflection path calls `field.IsModified()` on every member via +/// `EnumerateRecordMembers` (which does not filter by field kind), and +/// neither relation type declares that method, so a record embedding one +/// fails to compile the instant `Update()` is instantiated for it — exactly +/// what `examples/bank/include/bank/db/account_entity.hpp`'s own comment +/// independently documents for `HasMany`. Tag associations are read via a +/// plain `Query()` call in the model (`bookmark_model.cpp`, +/// Task 6), never through a relation field on this record. +/// +/// Every string column is a Lightweight strong string type, not +/// `std::string` (`docs/superpowers/specs/2026-08-11-strong-storage-types-design.md` +/// item 3): `ownerPrincipal` is `Light::SqlAnsiString<64>` — `bookmarks_authorizer.hpp`'s +/// `kMaxPrincipalBytes` already bounds every principal this rung accepts to +/// 64 ASCII bytes, so the column mirrors that bound exactly rather than +/// storing an unbounded string for a value that is already validated short. +/// `url`/`faviconPath` are `Light::SqlAnsiString` and `title` +/// is `Light::SqlAnsiString` — both DTO-level caps +/// (`bookmark_dto.hpp`) already exist; `bookmark_model.cpp`'s static_asserts +/// pin the column capacities to those same constants. `description`/`notes` +/// are `Light::SqlMaxDynamicAnsiString`: the DTO layer has never bounded +/// either (`bookmark_dto.hpp`'s `CreateBookmark`/`EditBookmark::validate()` +/// checks neither), so no new business limit is invented at the storage +/// layer where none exists on the wire. + +namespace bookmarks::db { + +/// @brief One row of the `bookmarks` table. +struct BookmarkRecord { + static constexpr std::string_view TableName = "bookmarks"; + + Light::Field id; // 0 + /// Authenticated owner (`session::Context::principal`) — every query the + /// model issues filters on this column; see Task 6's `execute()` bodies. + Light::Field, Light::SqlRealName{"owner_principal"}> ownerPrincipal; // 1 + Light::Field, Light::SqlRealName{"url"}> url; // 2 + Light::Field, Light::SqlRealName{"title"}> title; // 3 + Light::Field description; // 4 + Light::Field notes; // 5 + Light::Field isUnread{true}; // 6 + Light::Field isArchived{false}; // 7 + Light::Field isShared{false}; // 8 + Light::Field createdAtMs{0}; // 9 + Light::Field updatedAtMs{0}; // 10 + /// Empty = no favicon fetched yet. Path, not bytes — the metadata + /// worker's own doc comment (Task 12) explains why blobs never travel + /// the action protocol. Bounded the same as `url` (`kMaxUrlBytes`) since + /// a favicon path is itself a URL. + Light::Field, Light::SqlRealName{"favicon_path"}> faviconPath; // 11 +}; + +} // namespace bookmarks::db diff --git a/examples/bookmarks/include/bookmarks/db/bookmark_tag_entity.hpp b/examples/bookmarks/include/bookmarks/db/bookmark_tag_entity.hpp new file mode 100644 index 00000000..377e5f87 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/db/bookmark_tag_entity.hpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "bookmarks/db/bookmark_entity.hpp" +#include "bookmarks/db/tag_entity.hpp" + +#include + +#include +#include + +namespace bookmarks::db { + +/// @brief The bookmark<->tag many-to-many junction (`IMPLEMENTATION.md` +/// rule 4's "real Lightweight idiom" clause — this is an ordinary +/// `BelongsTo`-pair entity, not the sanctioned raw-SQL escape tier). +/// `BelongsTo<>` supports `Update()` (unlike `HasMany`/ +/// `HasManyThrough` — see `bookmark_entity.hpp`'s file comment), but +/// this record never needs it: tag assignment/removal is always a +/// `Create`/delete of a whole row (`BookmarkModel::execute`, Task 6). +struct BookmarkTagRecord { + static constexpr std::string_view TableName = "bookmark_tags"; + + Light::Field id; // 0 + Light::BelongsTo<&BookmarkRecord::id, Light::SqlRealName{"bookmark_id"}> bookmark; // 1 + Light::BelongsTo<&TagRecord::id, Light::SqlRealName{"tag_id"}> tag; // 2 +}; + +} // namespace bookmarks::db diff --git a/examples/bookmarks/include/bookmarks/db/database.hpp b/examples/bookmarks/include/bookmarks/db/database.hpp new file mode 100644 index 00000000..f0a61f92 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/db/database.hpp @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +namespace bookmarks::db { + +/// @brief Points Lightweight's default connection at @p connectionString and +/// applies every pending migration. Production-bootstrap-only, called +/// once by Task 12's server app — see `pastebin::db::setup`'s +/// identical doc comment for why tests never call this. +/// @param connectionString ODBC connection string. +void setup(const std::string& connectionString); + +} // namespace bookmarks::db diff --git a/examples/bookmarks/include/bookmarks/db/imported_op_entity.hpp b/examples/bookmarks/include/bookmarks/db/imported_op_entity.hpp new file mode 100644 index 00000000..ce9ca46e --- /dev/null +++ b/examples/bookmarks/include/bookmarks/db/imported_op_entity.hpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bookmarks::db { + +/// @brief One applied `ImportBookmarks` chunk, keyed by `(owner_principal, +/// op_id)` — Task 11's idempotency check: a repeated chunk with the +/// same `opId` after a dropped connection finds its row already +/// present and is a safe no-op. +/// +/// Both string columns are Lightweight strong string types +/// (`docs/superpowers/specs/2026-08-11-strong-storage-types-design.md` item +/// 3): `ownerPrincipal` is `Light::SqlAnsiString<64>`, matching +/// `bookmarks_authorizer.hpp`'s `kMaxPrincipalBytes`; `opId` is +/// `Light::SqlAnsiString<128>` — it is a small caller-chosen idempotency +/// token, not free text, so a fixed bound fits it the same way. +struct ImportedOpRecord { + static constexpr std::string_view TableName = "imported_ops"; + + Light::Field id; // 0 + Light::Field, Light::SqlRealName{"owner_principal"}> ownerPrincipal; // 1 + Light::Field, Light::SqlRealName{"op_id"}> opId; // 2 + Light::Field appliedAtMs{0}; // 3 +}; + +} // namespace bookmarks::db diff --git a/examples/bookmarks/include/bookmarks/db/outbox_entity.hpp b/examples/bookmarks/include/bookmarks/db/outbox_entity.hpp new file mode 100644 index 00000000..bc61e721 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/db/outbox_entity.hpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bookmarks::db { + +/// @brief `BookmarkModel`'s own transactional outbox — a row written inside +/// the same `SqlTransaction` as a multi-row mutation +/// (`BulkEdit`; `TagModel`'s `RenameTag`/`MergeTags`, Task 9, uses +/// the identical table), drained by `journal::OutboxRelay` (Task 12) +/// into the durable `FileActionLog`. Shaped after +/// `journal::LogEntry` (`include/morph/journal/action_log.hpp`) — +/// only the fields a relay actually needs, not a 1:1 mirror. A row +/// is deleted once relayed rather than flagged, so the table only +/// ever holds genuinely-unrelayed work. +/// +/// Every string column is a Lightweight strong string type +/// (`docs/superpowers/specs/2026-08-11-strong-storage-types-design.md` item +/// 3). `modelType`/`entityKey`/`actionType`/`principal` are +/// `Light::SqlAnsiString<64>` each — short, program-controlled identifiers +/// (a model's own type name, an owner principal, an action-dispatch tag), +/// never free text. `idempotencyKey` is `Light::SqlAnsiString<128>` — every +/// call site builds it as `owner + "-" + actionTag + "-" + nowMs() + "-" + +/// seq` (see `bookmark_model.cpp`/`tag_model.cpp`'s `writeOutboxEntry`/ +/// idempotency-key construction), which bounds it in practice well under +/// 128 bytes. `payload`/`result` are `Light::SqlMaxDynamicAnsiString` — +/// serialized JSON of arbitrary action/result shapes, unbounded like +/// `journal::LogEntry`'s own payload field. +struct BookmarkOutboxRecord { + static constexpr std::string_view TableName = "bookmark_outbox"; + + Light::Field id; // 0 + Light::Field, Light::SqlRealName{"model_type"}> modelType; // 1 + Light::Field, Light::SqlRealName{"entity_key"}> entityKey; // 2 + Light::Field, Light::SqlRealName{"action_type"}> actionType; // 3 + Light::Field payload; // 4 + Light::Field result; // 5 + Light::Field, Light::SqlRealName{"principal"}> principal; // 6 + Light::Field timestampMs{0}; // 7 + Light::Field, Light::SqlRealName{"idempotency_key"}> idempotencyKey; // 8 +}; + +} // namespace bookmarks::db diff --git a/examples/bookmarks/include/bookmarks/db/tag_entity.hpp b/examples/bookmarks/include/bookmarks/db/tag_entity.hpp new file mode 100644 index 00000000..9f0afcb1 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/db/tag_entity.hpp @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +namespace bookmarks::db { + +/// @brief One row of the `tags` table. Every string column is a Lightweight +/// strong string type +/// (`docs/superpowers/specs/2026-08-11-strong-storage-types-design.md` +/// item 3): `ownerPrincipal` is `Light::SqlAnsiString<64>`, matching +/// `bookmarks_authorizer.hpp`'s `kMaxPrincipalBytes`, and `name` is +/// `Light::SqlAnsiString` (128, +/// `bookmarks/dto/tag_dto.hpp`) — `tag_model.cpp`'s static_assert +/// pins the column capacity to that same constant. No relation-typed +/// member — see `bookmark_entity.hpp`'s file comment. +struct TagRecord { + static constexpr std::string_view TableName = "tags"; + + Light::Field id; // 0 + Light::Field, Light::SqlRealName{"owner_principal"}> ownerPrincipal; // 1 + Light::Field, Light::SqlRealName{"name"}> name; // 2 +}; + +} // namespace bookmarks::db diff --git a/examples/bookmarks/include/bookmarks/dto/auth_dto.hpp b/examples/bookmarks/include/bookmarks/dto/auth_dto.hpp new file mode 100644 index 00000000..6c48a950 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/dto/auth_dto.hpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +/// @file +/// `Login`, and the opaque token it mints. +/// +/// Every model-bearing action in this rung needs a signed token before it +/// can do anything: `SigningAuthorizer::authorize()` is consulted on every +/// `execute` and rejects a caller with no valid token outright. `Login` is +/// how a caller gets one in the first place, which is why `AuthModel` is the +/// one model whose actions a not-yet-authenticated caller can reach. +/// +/// **Dev-mode login, stated plainly, not smoothed over**: `Login` takes a +/// bare `username` with no password or other credential. This rung ships no +/// user registry, no password hashing and no account-recovery flow, none of +/// which `examples/bookmarks/README.md` asks for (its DoD is "two users… +/// with isolated collections", not a production auth system). What *is* real +/// and load-bearing is the **token**: a genuine, server-signed, +/// `SigningAuthorizer`-verified credential. Nothing downstream of `Login` +/// trusts a client's claimed identity un-verified — `RemoteServer` +/// overwrites `Context::principal` with the value it recovers from the +/// token's signature before any model runs, so `EditBookmark`, `GetBookmark` +/// and every other action see an authenticated identity or none at all. The +/// trust boundary this rung stress-tests (`authenticate` → `authorize` → +/// `session::current()->principal` inside a model) is exactly as real after +/// login as a production deployment's; only the *login step itself* is a +/// stand-in, and a real deployment replaces it — password verification, +/// OAuth, whatever — by changing the body of +/// `AuthModel::execute(const Login&)` and nothing else. + +namespace bookmarks { + +/// @brief Opaque bearer-token newtype (`examples/IMPLEMENTATION.md` rule 3's +/// protocol-scalars row: capability/confirmation tokens get a named +/// opaque wrapper, never a loose `std::string`). Same +/// `hasValue()`-capable shape as `BookmarkId` — see that type's doc +/// comment for the `fromOptional` factory rationale. Named +/// `AuthToken`, not `SessionToken`, to avoid colliding with +/// `morph::session::SessionToken`, an unrelated type this DTO's own +/// model wraps rather than reuses. +struct AuthToken { + /// @brief The payload; `std::nullopt` means "no token". + std::optional value; + + /// @brief Constructs the empty state. + constexpr AuthToken() noexcept = default; + + /// @brief Engages with @p token. + explicit AuthToken(std::string token) noexcept : value{std::move(token)} {} + + /// @brief Adopts an optional payload as-is. + /// @param payload The optional payload to adopt as-is. + /// @return An `AuthToken` wrapping @p payload directly. + [[nodiscard]] static AuthToken fromOptional(std::optional payload) noexcept { + AuthToken result; + result.value = std::move(payload); + return result; + } + + /// @brief Whether a value has been entered. + /// @return `true` if the payload is engaged. + [[nodiscard]] bool hasValue() const noexcept { return value.has_value(); } + + /// @brief Unchecked access to the engaged value (UB when empty, exactly + /// like `std::optional::operator*`). + /// @return The engaged value. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + [[nodiscard]] const std::string& operator*() const noexcept { return *value; } + + /// @brief Equality/ordering on the payload; empty compares only equal to empty. + [[nodiscard]] auto operator<=>(const AuthToken&) const noexcept = default; +}; + +/// @brief Dev-mode login: no password. See this file's `@file` comment for +/// exactly what that does and does not mean for this rung's security +/// posture. +struct Login { + /// @brief The identity to mint a token for. + std::string username; + + /// @brief Whether @p username is acceptable as a principal. + /// + /// Reuses `auth::isValidPrincipal`: a username this rejects could never + /// be used as an `ownerPrincipal` anywhere else in this rung anyway, and + /// rejecting it here keeps a control byte out of the token payload as a + /// second, independent line of defense (see that function's own doc + /// comment). Declared + /// rather than defined inline because the check lives in + /// `bookmarks/auth/bookmarks_authorizer.hpp`, and including that here + /// would pull `morph/session/session_auth.hpp` — and, transitively, its + /// whole HMAC/base64 implementation — into every translation unit that + /// only wants the DTO shape. + /// @return `true` if `username` is a valid principal. + [[nodiscard]] bool validate() const noexcept; +}; + +/// @brief What a successful `Login` returns. +struct LoginResult { + /// @brief The freshly minted, server-signed bearer token. The client + /// installs this via `Bridge::setDefaultSession`. + AuthToken token; + /// @brief The verified username, echoed back for display. Equal to the + /// `Login`'s own `username` — returned so a client need not keep + /// its own copy alongside the token. + std::string principal; +}; + +} // namespace bookmarks + +/// @brief Reflects `AuthToken` as its bare payload — same rationale and +/// shape as `glz::meta`: the wire form of an +/// opaque scalar newtype is the scalar, not an object with a `value` +/// member. +template <> +struct glz::meta { + static constexpr auto value = &bookmarks::AuthToken::value; + static constexpr std::string_view name = "AuthToken"; +}; diff --git a/examples/bookmarks/include/bookmarks/dto/bookmark_dto.hpp b/examples/bookmarks/include/bookmarks/dto/bookmark_dto.hpp new file mode 100644 index 00000000..2650c406 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/dto/bookmark_dto.hpp @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "bookmarks/core/types.hpp" + +#include + +#include +#include +#include +#include +#include + +/// @file +/// Bookmark wire DTOs. `RecordMetadata` is the one action a GUI client never +/// sends — it is dispatched exclusively by the app-layer metadata-fetch +/// worker's internal client (Task 12), the same "internal-only" shape +/// `pastebin::ExpirePaste` established. + +namespace bookmarks { + +/// @brief Whether a bookmark is visible only to its owner or to the shared feed. +enum class Visibility { Private, Shared }; + +/// @brief Whether a bookmark has been read. +enum class ReadState { Unread, Read }; + +/// @brief Whether a bookmark is archived (hidden from the default list, not deleted). +enum class ArchiveState { Active, Archived }; + +/// @brief `ListBookmarks`' read-state filter. +enum class ReadFilter { Any, UnreadOnly, ReadOnly }; + +/// @brief `ListBookmarks`' archive-state filter. +enum class ArchiveFilter { Any, ActiveOnly, ArchivedOnly }; + +/// @brief Longest `url`, in bytes, this rung accepts (a sanity bound, not a +/// storage-column width — url/title are variable-length `TEXT` +/// columns with no fixed capacity to overflow, per +/// `IMPLEMENTATION.md` rule 4's "content needs no equivalent bound" +/// clause). +inline constexpr std::size_t kMaxUrlBytes = 2048; +/// @brief Longest `title`, in bytes, this rung accepts. +inline constexpr std::size_t kMaxTitleBytes = 512; + +struct CreateBookmark { + std::string url; + std::string title; // empty = not yet known; the metadata worker fills it in + std::string description; + std::string notes; + std::vector tags; // tag names; auto-created on first use (Task 6) + Visibility visibility = Visibility::Private; + + /// @brief Every member but `url` may be omitted from a schema-driven + /// submission — see `pastebin::CreatePaste::optionalFields`'s + /// doc comment for why this list exists at all. + /// + /// `title` belongs here for a reason the rest do not: this member's own + /// comment above says "empty = not yet known; the metadata worker fills + /// it in", and `validate()` accepts an empty one. Omitting it from this + /// list made `schemaJson()` emit `title` as *required*, + /// so the generated create form refused to submit without one — which + /// meant the shipped GUI could not create the very title-less bookmark + /// the background metadata fetch exists to complete. Caught by driving + /// the desktop client against a real server (task 18). + static constexpr std::array optionalFields{"title", "description", "notes", "tags", + "visibility"}; + + [[nodiscard]] bool validate() const noexcept { + return !url.empty() && url.size() <= kMaxUrlBytes && title.size() <= kMaxTitleBytes; + } +}; + +struct CreateBookmarkResult { + BookmarkId id; +}; + +/// @brief Full replace-set edit: `tags` is the *desired final* tag set, not +/// a delta — `BookmarkModel::execute(const EditBookmark&)` (Task 6) +/// diffs it against the current junction rows. +struct EditBookmark { + BookmarkId id; + std::string url; + std::string title; + std::string description; + std::string notes; + std::vector tags; + Visibility visibility = Visibility::Private; + + /// @brief Same set as `CreateBookmark::optionalFields`, and `title` is in + /// it for the same reason — see that member's doc comment. + static constexpr std::array optionalFields{"title", "description", "notes", "tags", + "visibility"}; + + [[nodiscard]] bool validate() const noexcept { + return id.hasValue() && !url.empty() && url.size() <= kMaxUrlBytes && title.size() <= kMaxTitleBytes; + } +}; + +struct ArchiveBookmark { + BookmarkId id; + [[nodiscard]] bool validate() const noexcept { return id.hasValue(); } +}; + +struct UnarchiveBookmark { + BookmarkId id; + [[nodiscard]] bool validate() const noexcept { return id.hasValue(); } +}; + +struct DeleteBookmark { + BookmarkId id; + [[nodiscard]] bool validate() const noexcept { return id.hasValue(); } +}; + +struct GetBookmark { + BookmarkId id; + [[nodiscard]] bool validate() const noexcept { return id.hasValue(); } +}; + +/// @brief The full, owner-only view of one bookmark. +struct BookmarkView { + BookmarkId id; + std::string url; + std::string title; + std::string description; + std::string notes; + std::vector tags; + ::morph::time::Timestamp createdAt; + ::morph::time::Timestamp updatedAt; + ReadState readState = ReadState::Unread; + ArchiveState archiveState = ArchiveState::Active; + Visibility visibility = Visibility::Private; +}; + +/// @brief One row of `ListBookmarks`'/`GetChangesSince`'s result — +/// deliberately narrower than `BookmarkView`: a listing must not +/// leak `notes` (mirrors `pastebin::PasteSummary`'s non-leak rule). +struct BookmarkSummary { + BookmarkId id; + std::string url; + std::string title; + std::vector tags; + ::morph::time::Timestamp createdAt; + ::morph::time::Timestamp updatedAt; + ReadState readState = ReadState::Unread; + ArchiveState archiveState = ArchiveState::Active; + Visibility visibility = Visibility::Private; +}; + +struct ListBookmarks { + Cursor cursor; // empty = first page + ReadFilter readFilter = ReadFilter::Any; + ArchiveFilter archiveFilter = ArchiveFilter::ActiveOnly; // archived hidden by default, linkding's own convention + std::string tag; // empty = no tag filter + std::string searchText; // empty = no text filter + + static constexpr std::array optionalFields{"cursor", "readFilter", "archiveFilter", "tag", + "searchText"}; + + [[nodiscard]] bool validate() const noexcept { return true; } // every field is optional +}; + +struct ListBookmarksResult { + std::vector bookmarks; + Cursor nextCursor; // empty = no further page +}; + +/// @brief Minimal changes-since poll (README's rung-3 event-pattern +/// preview): every bookmark this owner touched (created, edited, +/// archived/unarchived, or metadata-recorded) since @p since. +struct GetChangesSince { + ChangesCursor since; // empty = every bookmark ever (first poll) + + static constexpr std::array optionalFields{"since"}; + + [[nodiscard]] bool validate() const noexcept { return true; } +}; + +struct GetChangesSinceResult { + std::vector changed; + /// @brief The boundary this poll ran to, captured *before* the query + /// itself (`BookmarkModel::execute`'s own doc comment, Task 7, + /// has the full argument for why) — the next poll's `since`. + /// `ChangesCursor` (issue #43), not a bare `Timestamp`: a + /// millisecond-resolution timestamp alone cannot distinguish a + /// write that lands in the exact same millisecond as this + /// instant from one that happened strictly before it. + ChangesCursor asOf; +}; + +/// @brief Internal-only: the metadata-fetch worker's write-back +/// (`app::MetadataFetchWorker`, Task 12). Never dispatched by a GUI +/// client — mirrors `pastebin::ExpirePaste`'s "internal-only" +/// convention exactly. +struct RecordMetadata { + BookmarkId id; + std::string title; // empty = the fetch found no + std::string faviconPath; // empty = no favicon fetched + + static constexpr std::array<std::string_view, 2> optionalFields{"title", "faviconPath"}; + + [[nodiscard]] bool validate() const noexcept { return id.hasValue(); } +}; + +} // namespace bookmarks + +/// @brief Reflects `Visibility` as readable strings — same rationale and +/// `glz::enumerate` shape as `pastebin`'s enum reflections +/// (`glz::meta<pastebin::Visibility>`'s doc comment has the full +/// argument: a bare ordinal degrades the schema writer's `$defs` +/// entry to an any-type union). +template <> +struct glz::meta<bookmarks::Visibility> { + using enum bookmarks::Visibility; + static constexpr auto value = glz::enumerate(Private, Shared); +}; + +template <> +struct glz::meta<bookmarks::ReadState> { + using enum bookmarks::ReadState; + static constexpr auto value = glz::enumerate(Unread, Read); +}; + +template <> +struct glz::meta<bookmarks::ArchiveState> { + using enum bookmarks::ArchiveState; + static constexpr auto value = glz::enumerate(Active, Archived); +}; + +template <> +struct glz::meta<bookmarks::ReadFilter> { + using enum bookmarks::ReadFilter; + static constexpr auto value = glz::enumerate(Any, UnreadOnly, ReadOnly); +}; + +template <> +struct glz::meta<bookmarks::ArchiveFilter> { + using enum bookmarks::ArchiveFilter; + static constexpr auto value = glz::enumerate(Any, ActiveOnly, ArchivedOnly); +}; diff --git a/examples/bookmarks/include/bookmarks/dto/bulk_dto.hpp b/examples/bookmarks/include/bookmarks/dto/bulk_dto.hpp new file mode 100644 index 00000000..8a390db9 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/dto/bulk_dto.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "bookmarks/core/types.hpp" +#include "bookmarks/units.hpp" + +#include <array> +#include <glaze/glaze.hpp> +#include <string> +#include <string_view> +#include <vector> + +namespace bookmarks { + +/// @brief `BulkEdit`'s archive-state instruction — a three-state enum +/// (`IMPLEMENTATION.md` rule 3: never a `bool` two-state flag, and +/// this action genuinely has a third "don't touch archive state at +/// all" option a bool cannot express). +enum class BulkArchiveOp { None, Archive, Unarchive }; + +/// @brief The rung's first multi-entity atomic action — all-or-nothing +/// against SQLite (README). `addTags`/`removeTags` are name-based +/// (auto-create-on-first-use for `addTags`, same as +/// `EditBookmark::tags`'s handling — Task 8's own doc comment has +/// the exact SQL). Every id must be owned by the caller or the +/// *whole* batch is rejected (Task 8's resolved "reject the whole +/// batch on one violation" design decision). +struct BulkEdit { + std::vector<BookmarkId> ids; + std::vector<std::string> addTags; + std::vector<std::string> removeTags; + BulkArchiveOp archive = BulkArchiveOp::None; + + static constexpr std::array<std::string_view, 3> optionalFields{"addTags", "removeTags", "archive"}; + + [[nodiscard]] bool validate() const noexcept { return !ids.empty(); } +}; + +struct BulkEditResult { + Count affected; +}; + +} // namespace bookmarks + +/// @brief Reflects `BulkArchiveOp` as readable strings — same rationale as +/// every other enum reflection in this rung. +template <> +struct glz::meta<bookmarks::BulkArchiveOp> { + using enum bookmarks::BulkArchiveOp; + static constexpr auto value = glz::enumerate(None, Archive, Unarchive); +}; diff --git a/examples/bookmarks/include/bookmarks/dto/import_export_dto.hpp b/examples/bookmarks/include/bookmarks/dto/import_export_dto.hpp new file mode 100644 index 00000000..0920ce11 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/dto/import_export_dto.hpp @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "bookmarks/core/types.hpp" +#include "bookmarks/units.hpp" + +#include <cstddef> +#include <string> + +namespace bookmarks { + +/// @brief Longest one `ImportBookmarks` chunk this rung accepts, in bytes — +/// well under the transport's own message-size bound +/// (`docs/spec/security.md`), so a client that respects this limit +/// never has to distinguish "this rung refused it" from "the +/// transport refused it". +/// +/// A chunk over this bound is refused by `BookmarkModel::execute` with +/// `TooLarge`, not `ValidationError`, precisely so those two answers stay +/// distinguishable. The transport's own bound is *not* separately measured +/// by this rung — see the README's known-gaps section. +inline constexpr std::size_t kMaxImportChunkBytes = 65536; + +/// @brief One chunk of a Netscape Bookmark HTML import. Idempotent per +/// `opId` (Task 5's `ImportedOpRecord`/Task 11's dedup check): a +/// retried chunk after a dropped connection is a safe no-op, never +/// a duplicate import. +struct ImportBookmarks { + std::string chunk; + ImportOpId opId; + + // Deliberately does NOT bound `chunk.size()` here: `validate()` is what + // the framework's `ActionValidator`/`Bridge::executeVia` consult before + // `Model::execute` is ever reached (`include/morph/core/bridge.hpp`, + // `include/morph/core/remote.hpp`), so a size check here would fail the + // request as `ValidationError` before `BookmarkModel::execute` gets a + // chance to throw the more specific `TooLarge` -- exactly the + // "make the chunks smaller" vs. "this request was malformed" distinction + // `kMaxImportChunkBytes`'s own doc comment promises. The bound is + // enforced once, in `BookmarkModel::execute(const ImportBookmarks&)`. + [[nodiscard]] bool validate() const noexcept { return !chunk.empty() && opId.hasValue(); } +}; + +struct ImportBookmarksResult { + Count imported; + /// @brief Entries the chunk contained but this import did not write: a + /// malformed `<A>` entry with no href, or one whose url/title + /// exceeds `kMaxUrlBytes`/`kMaxTitleBytes` (writing those would + /// create a row `EditBookmark::validate()` would then refuse). + Count skipped; +}; + +struct ExportBookmarks { + [[nodiscard]] bool validate() const noexcept { return true; } +}; + +struct ExportBookmarksResult { + std::string html; // a complete Netscape Bookmark File +}; + +} // namespace bookmarks diff --git a/examples/bookmarks/include/bookmarks/dto/shared_feed_dto.hpp b/examples/bookmarks/include/bookmarks/dto/shared_feed_dto.hpp new file mode 100644 index 00000000..579e889e --- /dev/null +++ b/examples/bookmarks/include/bookmarks/dto/shared_feed_dto.hpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "bookmarks/core/types.hpp" +#include "bookmarks/dto/bookmark_dto.hpp" + +#include <array> +#include <string_view> +#include <vector> + +namespace bookmarks { + +struct ListSharedFeed { + Cursor cursor; // empty = first page + + static constexpr std::array<std::string_view, 1> optionalFields{"cursor"}; + + [[nodiscard]] bool validate() const noexcept { return true; } +}; + +/// @brief `BookmarkSummary` doubles as the shared feed's row shape — same +/// non-leak rule applies (no `notes`), and a shared bookmark's +/// `visibility` is always `Shared` by construction (the query that +/// builds this only ever selects `WHERE visibility = Shared`, Task +/// 10), so there is nothing this result type needs beyond what +/// `BookmarkSummary` already carries. +struct ListSharedFeedResult { + std::vector<BookmarkSummary> bookmarks; + Cursor nextCursor; +}; + +} // namespace bookmarks diff --git a/examples/bookmarks/include/bookmarks/dto/tag_dto.hpp b/examples/bookmarks/include/bookmarks/dto/tag_dto.hpp new file mode 100644 index 00000000..9cddede3 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/dto/tag_dto.hpp @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "bookmarks/core/types.hpp" +#include "bookmarks/units.hpp" + +#include <cstddef> +#include <string> +#include <vector> + +namespace bookmarks { + +/// @brief Longest tag name, in bytes, this rung accepts — both a +/// `validate()` sanity bound and the storage-column width: +/// `TagRecord::name` (`tag_entity.hpp`) is `Light::SqlAnsiString<128>`, +/// and `tag_model.cpp`'s static_assert pins that capacity to this +/// same constant, so the two can never drift apart silently. +inline constexpr std::size_t kMaxTagNameBytes = 128; + +struct RenameTag { + TagId id; + std::string name; + + [[nodiscard]] bool validate() const noexcept { + return id.hasValue() && !name.empty() && name.size() <= kMaxTagNameBytes; + } +}; + +/// @brief Reassigns every bookmark tagged `sourceId` to `targetId` +/// (deduplicating), then deletes `sourceId` — `TagModel::execute` +/// (Task 9) does the cascade; this DTO only carries the two ids. +struct MergeTags { + TagId sourceId; + TagId targetId; + + [[nodiscard]] bool validate() const noexcept { + return sourceId.hasValue() && targetId.hasValue() && *sourceId != *targetId; + } +}; + +struct ListTags { + [[nodiscard]] bool validate() const noexcept { return true; } +}; + +struct TagSummary { + TagId id; + std::string name; + Count bookmarkCount; +}; + +struct ListTagsResult { + std::vector<TagSummary> tags; +}; + +} // namespace bookmarks diff --git a/examples/bookmarks/include/bookmarks/import/netscape_bookmarks.hpp b/examples/bookmarks/include/bookmarks/import/netscape_bookmarks.hpp new file mode 100644 index 00000000..92067cb8 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/import/netscape_bookmarks.hpp @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include <string> +#include <string_view> +#include <vector> + +namespace bookmarks::import { + +/// @brief One parsed `<A HREF="...">title</A>` entry. `url` empty means +/// "malformed, skip" — the caller (`BookmarkModel::execute(const +/// ImportBookmarks&)`) counts these toward `skipped`, not `imported`. +struct ParsedEntry { + std::string url; + std::string title; +}; + +/// @brief Extracts every `<A HREF="...">...</A>` entry from one Netscape +/// Bookmark File chunk. Deliberately minimal: recognizes `HREF` +/// case-insensitively, decodes the five predefined XML entities in +/// both the `HREF` value and the title text (symmetric with +/// `escapeHtml`, which `ExportBookmarks` applies to both), and +/// tolerates (by skipping) an `<A>` with no `HREF` attribute or an +/// unterminated tag. A URL therefore survives an export/reimport +/// round trip unchanged, including URLs containing `&`, `<`, `>`, +/// `"`, or `'`. Anything this rung's own `ExportBookmarks` never +/// produces (nested tags inside the title) is out of scope by +/// design, not an oversight. +/// @param chunk Raw HTML/text to scan. +/// @return Every entry found, in document order. +[[nodiscard]] std::vector<ParsedEntry> parseNetscapeChunk(std::string_view chunk); + +/// @brief Escapes `&`, `<`, `>`, `"`, and `'` for safe inclusion in +/// generated Netscape Bookmark File output. +/// @param text Raw text to escape. +/// @return The escaped text. +[[nodiscard]] std::string escapeHtml(std::string_view text); + +} // namespace bookmarks::import diff --git a/examples/bookmarks/include/bookmarks/models/auth_model.hpp b/examples/bookmarks/include/bookmarks/models/auth_model.hpp new file mode 100644 index 00000000..0201f755 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/models/auth_model.hpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include <morph/core/bridge.hpp> +#include <morph/core/registry.hpp> + +#include "bookmarks/core/errors.hpp" +#include "bookmarks/dto/auth_dto.hpp" + +namespace bookmarks { + +/// @brief Mints a signed token for whichever `username` the caller claims — +/// see `bookmarks/dto/auth_dto.hpp`'s own `@file` comment for exactly +/// what "dev-mode login" does and does not mean here. +/// +/// Stateless: no database, so nothing to persist. The secret it signs with +/// comes from the process-global +/// `auth::tokenIssuer()` slot, which `app::App` installs at startup with the +/// *same* secret it hands its `auth::BookmarksAuthorizer` — this model is +/// registered via the plain `BRIDGE_REGISTER_MODEL` default-construction +/// path rather than `ModelRegistryFactory`'s per-instance construction-hook +/// seam (`include/morph/core/registry.hpp`), so a process-global slot passes +/// the secret through instead, exactly as `morph::journal::setActionLog` +/// already works around for action logs. +class AuthModel { + public: + /// @brief Verifies @p action's username and mints a token for it. + /// @param action The login request. + /// @return The minted token plus the principal it was minted for. + /// @throws ValidationError if the username is not a valid principal, or + /// if no `TokenIssuer` has been installed (no `App` is alive). + LoginResult execute(const Login& action); +}; + +} // namespace bookmarks + +BRIDGE_REGISTER_MODEL(bookmarks::AuthModel, "AuthModel") +// Loggable::No: the action's JSON body is the caller's claimed identity and +// its result carries a live bearer token — neither belongs in a durable, +// replayable action log. +BRIDGE_REGISTER_ACTION(bookmarks::AuthModel, bookmarks::Login, "Login", ::morph::model::Loggable::No) diff --git a/examples/bookmarks/include/bookmarks/models/bookmark_model.hpp b/examples/bookmarks/include/bookmarks/models/bookmark_model.hpp new file mode 100644 index 00000000..beb12ae6 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/models/bookmark_model.hpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include <morph/core/bridge.hpp> +#include <morph/core/registry.hpp> + +#include "bookmarks/core/errors.hpp" +#include "bookmarks/dto/bookmark_dto.hpp" +#include "bookmarks/dto/bulk_dto.hpp" +#include "bookmarks/dto/import_export_dto.hpp" + +/// @file +/// `BookmarkModel` — every action this rung's one entity-owning model +/// serves. Declared once, complete, here; Tasks 7/8 add bodies to +/// `bookmark_model.cpp` for `ListBookmarks`/`GetChangesSince`/`BulkEdit`/ +/// `RecordMetadata` without touching this header again. + +namespace bookmarks { + +/// @brief Create/read/edit/archive/delete/list/bulk-edit over the +/// `bookmarks`/`bookmark_tags` tables, scoped to the authenticated +/// caller's own collection. +/// +/// Registered **plain** — no `BRIDGE_MODEL_KEY`, no `AllowShared`. Only +/// plain registration records a real instance owner (a *shared* instance is +/// recorded with an empty owner by design), so this is what makes +/// `BookmarksAuthorizer::authorizeInstance`'s per-instance ownership check +/// genuinely enforcing for this model: `register` envelopes now carry the +/// caller's authenticated session, so each client's own instance is recorded +/// under that client's real principal. +/// +/// That instance-level check alone is not what keeps one user out of +/// another's bookmarks, though — `BridgeHandler<Model>` (this rung's only +/// shipped client) never names another connection's `modelId`, so a normal +/// client's cross-user access attempt (`GetBookmark{id}` naming another +/// user's row through the caller's *own* instance) never triggers +/// `authorizeInstance` at all; see that function's own doc comment for why. +/// What actually carries per-user, per-*row* ownership is this model +/// itself: every `execute()` reads `session::current()->principal` fresh +/// (`requireOwner()`) and uses it both as the query filter and, via +/// `loadOwned()`, as the authorization check on any row it touches. +/// `IMPLEMENTATION.md` rule 1 requires that re-check regardless (the local +/// backend enforces nothing at all), and it is the only layer that could +/// ever catch a row-level mismatch, on top of `SigningAuthorizer:: +/// authorize()`'s per-`execute` token check and `authorizeInstance`'s +/// instance-level check. See `bookmarks/auth/bookmarks_authorizer.hpp` for +/// the full story. +/// +/// Holds no database state itself: each `execute()` acquires a +/// `Lightweight::GlobalDataMapperPool()` connection for its own duration and +/// returns it before returning, rather than owning a connection for its own +/// lifetime. +class BookmarkModel { +public: + CreateBookmarkResult execute(const CreateBookmark& action); + BookmarkView execute(const EditBookmark& action); + Ack execute(const ArchiveBookmark& action); + Ack execute(const UnarchiveBookmark& action); + Ack execute(const DeleteBookmark& action); + BookmarkView execute(const GetBookmark& action); + ListBookmarksResult execute(const ListBookmarks& action); // Task 7 + GetChangesSinceResult execute(const GetChangesSince& action); // Task 7 + BulkEditResult execute(const BulkEdit& action); // Task 8 + Ack execute(const RecordMetadata& action); // Task 8, internal-only + ImportBookmarksResult execute(const ImportBookmarks& action); // Task 11 + ExportBookmarksResult execute(const ExportBookmarks& action); // Task 11 +}; + +} // namespace bookmarks + +BRIDGE_REGISTER_MODEL(bookmarks::BookmarkModel, "BookmarkModel") +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::CreateBookmark, "CreateBookmark") +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::EditBookmark, "EditBookmark") +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::ArchiveBookmark, "ArchiveBookmark") +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::UnarchiveBookmark, "UnarchiveBookmark") +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::DeleteBookmark, "DeleteBookmark") +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::GetBookmark, "GetBookmark") +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::ListBookmarks, "ListBookmarks", + ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::GetChangesSince, "GetChangesSince", + ::morph::model::Loggable::No) +// BulkEdit is outbox-managed (Task 8) -- Loggable::No here too, so the +// framework's own auto-append never double-logs alongside the model's own +// outbox write. +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::BulkEdit, "BulkEdit", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::RecordMetadata, "RecordMetadata") +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::ImportBookmarks, "ImportBookmarks") +BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::ExportBookmarks, "ExportBookmarks", + ::morph::model::Loggable::No) diff --git a/examples/bookmarks/include/bookmarks/models/shared_feed_model.hpp b/examples/bookmarks/include/bookmarks/models/shared_feed_model.hpp new file mode 100644 index 00000000..6f640145 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/models/shared_feed_model.hpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include <morph/core/bridge.hpp> +#include <morph/core/registry.hpp> + +#include "bookmarks/core/errors.hpp" +#include "bookmarks/dto/shared_feed_dto.hpp" + +namespace bookmarks { + +/// @brief The one cross-principal read in this rung: every `Shared`, +/// non-archived bookmark, from every owner. Registered plain — see +/// this task's own header comment for why `AllowShared` is not used. +/// +/// Holds no database state itself: `execute()` acquires a +/// `Lightweight::GlobalDataMapperPool()` connection for its own duration and +/// returns it before returning, rather than owning a connection for its own +/// lifetime. +class SharedFeedModel { +public: + ListSharedFeedResult execute(const ListSharedFeed& action); +}; + +} // namespace bookmarks + +BRIDGE_REGISTER_MODEL(bookmarks::SharedFeedModel, "SharedFeedModel") +BRIDGE_REGISTER_ACTION(bookmarks::SharedFeedModel, bookmarks::ListSharedFeed, "ListSharedFeed", + ::morph::model::Loggable::No) diff --git a/examples/bookmarks/include/bookmarks/models/tag_model.hpp b/examples/bookmarks/include/bookmarks/models/tag_model.hpp new file mode 100644 index 00000000..d4ef0a96 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/models/tag_model.hpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include <morph/core/bridge.hpp> +#include <morph/core/registry.hpp> + +#include "bookmarks/core/errors.hpp" +#include "bookmarks/dto/tag_dto.hpp" + +namespace bookmarks { + +/// @brief Rename/merge/list over the `tags` table, scoped to the caller. +/// Registered plain — same rationale as `BookmarkModel`. +/// +/// Holds no database state itself: each `execute()` acquires a +/// `Lightweight::GlobalDataMapperPool()` connection for its own duration and +/// returns it before returning, rather than owning a connection for its own +/// lifetime. +class TagModel { +public: + Ack execute(const RenameTag& action); + Ack execute(const MergeTags& action); + ListTagsResult execute(const ListTags& action); +}; + +} // namespace bookmarks + +BRIDGE_REGISTER_MODEL(bookmarks::TagModel, "TagModel") +BRIDGE_REGISTER_ACTION(bookmarks::TagModel, bookmarks::RenameTag, "RenameTag") +// MergeTags is outbox-managed (this task) -- Loggable::No so the framework +// auto-append never double-logs alongside the model's own outbox write. +BRIDGE_REGISTER_ACTION(bookmarks::TagModel, bookmarks::MergeTags, "MergeTags", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(bookmarks::TagModel, bookmarks::ListTags, "ListTags", ::morph::model::Loggable::No) diff --git a/examples/bookmarks/include/bookmarks/units.hpp b/examples/bookmarks/include/bookmarks/units.hpp new file mode 100644 index 00000000..a86a1069 --- /dev/null +++ b/examples/bookmarks/include/bookmarks/units.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include <morph/util/quantity.hpp> + +/// @file +/// Bookmarks' one-unit system: a dimensionless count, reused for every +/// whole-number quantity this rung's DTOs carry (a tag's bookmark count, a +/// bulk edit's affected-row count, an import's imported/skipped counts). +/// Modeled on `pastebin/units.hpp` — see that file for the full +/// UnitTraits/consteval-algebra contract this mirrors; this rung needs no +/// unit algebra either, for the same reason. + +namespace bookmarks { + +/// @brief Units bookmarks works in. +enum class Unit { + count, ///< dimensionless whole-number count +}; + +} // namespace bookmarks + +/// @brief Static unit metadata: schema id, display text, default decimals. +template <> +struct morph::units::UnitTraits<bookmarks::Unit> { + static constexpr morph::units::UnitMeta meta(bookmarks::Unit unit) noexcept { + switch (unit) { + case bookmarks::Unit::count: + return {"count", "", 1}; + default: + return {"?", "?", 1}; + } + } +}; + +namespace bookmarks { + +/// @brief A whole-number count (bookmark counts, affected-row counts, +/// import result counts). +/// +/// `morph::units::Quantity<U, DeclaredDecimals>` requires `DeclaredDecimals +/// >= 1` (zero is not legal); every value that ever appears is a whole +/// number by construction. See `pastebin::Reads`'s identical doc comment. +using Count = ::morph::units::Quantity<Unit::count, 1>; + +} // namespace bookmarks diff --git a/examples/bookmarks/src/app/app.cpp b/examples/bookmarks/src/app/app.cpp new file mode 100644 index 00000000..473ca2f4 --- /dev/null +++ b/examples/bookmarks/src/app/app.cpp @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/app/app.hpp" + +#include "bookmarks/auth/bookmarks_authorizer.hpp" +#include "bookmarks/db/outbox_entity.hpp" +#include "bookmarks/dto/bookmark_dto.hpp" +// Every model this server hosts is included here, not only the one the +// metadata worker dispatches against. `BRIDGE_REGISTER_MODEL`/ +// `BRIDGE_REGISTER_ACTION` place their registrars in the *header*, so a +// translation unit that includes the header both registers the type with the +// process-wide registry/dispatcher and emits a reference to that model's +// `execute` bodies — which is what pulls each model's object file out of the +// static library for a binary (a server `main()`) whose own code names +// nothing but `App`. Without this, such a binary would either fail to link or +// come up serving no models at all. +#include "bookmarks/models/auth_model.hpp" +#include "bookmarks/models/bookmark_model.hpp" +#include "bookmarks/models/shared_feed_model.hpp" +#include "bookmarks/models/tag_model.hpp" + +#include <morph/core/logger.hpp> +#include <morph/journal/outbox.hpp> +#include <morph/session/session_auth.hpp> + +#include <Lightweight/DataMapper/DataMapper.hpp> +#include <Lightweight/SqlStatement.hpp> + +#include <cstdint> +#include <exception> +#include <span> +#include <string> +#include <utility> +#include <vector> + +namespace bookmarks::app { + +namespace { + +/// @brief Expiry stamped into the metadata worker's own service token. +/// +/// The same far-future constant `AuthModel` uses, for the same reason +/// (`SessionToken::expiresAtMs` must be strictly positive, so "no expiry" is +/// not expressible) — and with an additional one here: the worker has no +/// login to repeat, so a token that expired mid-run would silently stop the +/// background job on a long-lived server with nothing to renew it. The +/// process's own lifetime is the real bound; the token is never written down, +/// never leaves this process, and dies with it. +constexpr std::int64_t kServiceTokenExpiresAtMs = 4102444800000; // 2100-01-01T00:00:00Z + +/// @brief Live-instance cap this server installs. +/// +/// This rung's `authorizeRegister` is unconditionally permissive by choice +/// (`bookmarks/auth/bookmarks_authorizer.hpp` — the framework can gate +/// registration on identity now that `register` envelopes carry the +/// caller's session, this rung's authorizer just doesn't), so an +/// unauthenticated client *can* make the server create model instances even +/// though it can never execute anything on them. `maxLiveModels` is the +/// framework's own answer to that shape of churn: past the cap a `register` +/// is answered `err "too many models"` and no instance is constructed. The +/// value is generous on purpose — the shipped client registers six instances +/// (the forms controller owns an `AuthModel`, a `BookmarkModel` and a +/// `TagModel` handler; the three presenters own a `BookmarkModel`, a +/// `TagModel` and a `SharedFeedModel` handler — see the README's "Six model +/// instances per client, not four" gap for why they cannot be shared), so +/// this is ~42 concurrent clients, not a limit a real session will meet. +constexpr std::size_t kMaxLiveModels = 256; + +} // namespace + +App::App(std::filesystem::path actionLogPath, std::string tokenSecret, + std::shared_ptr<IBookmarkMetadataFetcher> fetcher, std::chrono::milliseconds fetchInterval, + std::chrono::milliseconds relayInterval, std::size_t workers, QObject* parent) + // Initialiser order follows the declaration order in app.hpp, which is + // itself chosen for teardown safety — see that header's comment. + : QObject{parent}, + _actionLog{std::make_shared<::morph::journal::FileActionLog>(std::move(actionLogPath))}, + _pool{workers}, + // hmacSha256 named explicitly -- same reason as the two TokenIssuer + // call sites below: BookmarksAuthorizer inherits SigningAuthorizer's + // constructor, whose MacFunction default is dropped entirely under + // MORPH_REQUIRE_VETTED_HMAC. + _server{std::make_shared<::morph::backend::RemoteServer>( + _pool, std::make_shared<auth::BookmarksAuthorizer>(tokenSecret, ::morph::session::hmacSha256))}, + _fetchBridge{std::make_unique<::morph::backend::SimulatedRemoteBackend>(*_server)}, + _fetcher{std::move(fetcher)} { + ::morph::journal::setActionLog(_actionLog); + + // Installed process-wide so AuthModel::execute(const Login&) can mint + // tokens against this exact secret — the same "registry-constructed + // models are always default-constructed, so there is no DI seam" answer + // morph::journal::setActionLog already uses one line above. + // hmacSha256 named explicitly (not relying on TokenIssuer's default): + // this rung wires no vetted MAC adapter (see examples/vetted_hmac/), so + // under MORPH_REQUIRE_VETTED_HMAC -- which drops the default entirely, + // by design (see TokenIssuer's own doc comment) -- this call site must + // still compile with the identical MAC it always used. + auth::setTokenIssuer( + std::make_shared<::morph::session::TokenIssuer>(tokenSecret, ::morph::session::hmacSha256)); + + ::morph::backend::LimitPolicy limits; + limits.maxLiveModels = kMaxLiveModels; + _server->setLimitPolicy(limits); + + // The worker's own service-principal session. Minted here rather than + // through AuthModel deliberately: AuthModel *refuses* to mint a token in + // the reserved `system:` namespace (see auth::isReservedPrincipal), which + // is exactly the property that keeps a client from obtaining this + // authority. The server process minting its own is the one legitimate + // path, and it shares `tokenSecret` with the authorizer installed above, + // so it verifies exactly like a real user's token. + // hmacSha256 named explicitly for the identical reason as the + // setTokenIssuer() call above -- and so both issuers stay verifiably the + // same MAC, which they must be: the authorizer this rung installs + // verifies every token (including this service one) against whichever + // MAC minted it. + const ::morph::session::TokenIssuer serviceIssuer{tokenSecret, ::morph::session::hmacSha256}; + ::morph::session::Context session; + session.principal = std::string{auth::kMetadataFetcherPrincipal}; + session.token = serviceIssuer.issue(::morph::session::SessionToken{ + .principal = std::string{auth::kMetadataFetcherPrincipal}, + .issuedAtMs = 0, + .expiresAtMs = kServiceTokenExpiresAtMs, + .roles = {}, + }); + _fetchBridge.setDefaultSession(session); + + // Both timer slots are wrapped rather than connected to the methods + // directly. An exception escaping a Qt slot is unsupported — Qt's event + // dispatcher propagates it out of `exec()` at best and calls + // `std::terminate` at worst — so a background pass that throws would take + // the whole server process down with it, taking every connected client's + // session with it, for a failure that only ever concerns one pass. + // Neither body is exception-free: `fetchMetadataOnce()` constructs a + // `BridgeHandler`, which throws if the register is refused (reachable + // here, because this server caps `maxLiveModels`), and + // `relayOutboxOnce()` can throw from its `Query<>()` or from the action + // log's own sink. Logging and dropping the pass is the right response to + // both: the next tick simply retries, since neither pass consumes the + // work it failed on. The public methods themselves keep throwing, so a + // test that calls one directly still sees the failure. + connect(&_fetchTimer, &QTimer::timeout, this, [this] { + try { + fetchMetadataOnce(); + } catch (const std::exception& e) { + ::morph::log::logError(std::string{"[bookmarks::App] metadata-fetch pass threw, pass abandoned: "} + + e.what()); + } catch (...) { + ::morph::log::logError("[bookmarks::App] metadata-fetch pass threw a non-std exception, pass abandoned"); + } + }); + _fetchTimer.start(fetchInterval); + connect(&_relayTimer, &QTimer::timeout, this, [this] { + try { + (void) relayOutboxOnce(); + } catch (const std::exception& e) { + ::morph::log::logError(std::string{"[bookmarks::App] outbox-relay pass threw, pass abandoned: "} + + e.what()); + } catch (...) { + ::morph::log::logError("[bookmarks::App] outbox-relay pass threw a non-std exception, pass abandoned"); + } + }); + _relayTimer.start(relayInterval); +} + +void App::stopBackgroundJobs() { + _fetchTimer.stop(); + _relayTimer.stop(); +} + +App::~App() { + // Stop first: a tick landing while the members below are being torn down + // would dispatch a pass into a half-destroyed App. A shutting-down owner + // will normally have called stopBackgroundJobs() already, before its own + // drain loop started pumping (see that method's doc comment); calling it + // again here is a no-op, and keeps this destructor correct for every owner + // that does not. + stopBackgroundJobs(); + ::morph::journal::setActionLog(nullptr); + // Matches setActionLog's own clear-on-destruction discipline: a later + // test (or a second App in the same process) must see + // auth::tokenIssuer() == nullptr rather than a previous App's still-live + // issuer, which would be holding a *different* secret than whatever + // authorizer is current. + auth::setTokenIssuer(nullptr); +} + +void App::fetchMetadataOnce() { + std::vector<std::pair<std::int64_t, std::string>> needsFetch; + { + ::Lightweight::SqlStatement stmt; + stmt.Prepare("SELECT id, url FROM bookmarks WHERE title = ''"); + auto cursor = stmt.Execute(); + while (cursor.FetchRow()) { + needsFetch.emplace_back(cursor.GetColumn<std::int64_t>(1), cursor.GetColumn<std::string>(2)); + } + } + if (needsFetch.empty()) { + return; + } + + // `handler` is kept alive by every dispatched call's own completion, not + // by this function's stack frame — the identical pattern (and identical + // race) pastebin::app::App::sweepExpiredOnce() documents at length. + // `BridgeHandler::execute()` posts to the worker pool and returns + // immediately, so this loop routinely returns before RemoteServer has so + // much as looked up the model instance for the first dispatch. A + // `handler` destroyed synchronously here would deregister its instance + // (a synchronous "deregister" in ~BridgeHandler) and race those pending + // dispatches, which would then find the instance missing and reply "model + // not found" instead of ever running RecordMetadata — silently dropping + // the pass. Capturing `handler` in every completion below closes that + // window: the instance is released only once every dispatch this pass + // issued has settled, whichever of .then()/.onError() that turns out to + // be for each. + // + // Constructing it here (per pass) rather than once in the constructor is + // also what keeps an idle server from holding a live model instance + // against `maxLiveModels` between passes. + auto handler = std::make_shared<::morph::bridge::BridgeHandler<BookmarkModel>>(_fetchBridge, &_fetchExecutor); + // Captured by value, never through `this`: the callbacks below can + // outlive this App (see fetchInFlight()'s doc comment), and a late one + // must still be able to decrement the counter safely. + auto inFlight = _fetchInFlight; + for (const auto& [id, url] : needsFetch) { + // Synchronous by design — see metadata_fetcher.hpp. + const auto metadata = _fetcher->fetch(url); + if (metadata.title.empty() && metadata.faviconPath.empty()) { + // Nothing was found. Dispatching anyway would be a write with no + // content: RecordMetadata ignores empty fields but still stamps + // `updated_at_ms`, which would show up as a spurious change in + // every client's GetChangesSince poll on every pass — and with + // the shipped NullMetadataFetcher, that is *every* untitled + // bookmark on *every* tick, forever. The bookmark stays in the + // "needs fetch" set and is retried next pass, which is the + // correct outcome for a fetch that found nothing. + continue; + } + // The raise has to precede the dispatch — a completion delivered from + // a worker thread could otherwise lower a count this loop had not + // raised yet — which leaves a window the `catch` below closes. + inFlight->fetch_add(1); + try { + handler + ->execute(RecordMetadata{.id = BookmarkId{id}, + .title = metadata.title, + .faviconPath = metadata.faviconPath}) + .then([handler, inFlight](Ack) { inFlight->fetch_sub(1); }) + .onError([handler, inFlight, id](const std::exception_ptr&) { + inFlight->fetch_sub(1); + ::morph::log::logError("[bookmarks::App] metadata fetch: RecordMetadata failed for bookmark " + + std::to_string(id)); + }); + } catch (const std::exception& e) { + // `execute()` threw instead of returning a `Completion`, so + // neither callback above was ever attached and nothing else will + // ever lower the count the line above raised. Leaving it raised + // wedges `fetchInFlight()` at `true` permanently, and with it + // every consumer that drains on it — `server/main.cpp`'s + // `drainMetadataFetches` would then burn its whole 5s budget on + // every subsequent shutdown and still report failure. + inFlight->fetch_sub(1); + ::morph::log::logError("[bookmarks::App] metadata fetch: dispatch for bookmark " + std::to_string(id) + + " threw: " + e.what()); + } + } +} + +std::size_t App::relayOutboxOnce() { + ::Lightweight::DataMapper mapper; + ::morph::journal::OutboxRelay relay; + relay.drainOutbox = [&mapper] { + auto rows = mapper.Query<db::BookmarkOutboxRecord>().All(); + std::vector<::morph::journal::LogEntry> entries; + entries.reserve(rows.size()); + for (const auto& row : rows) { + ::morph::journal::LogEntry entry; + // `journal::LogEntry`'s fields are plain `std::string`; every + // `BookmarkOutboxRecord` string column is a Lightweight strong + // string type (`outbox_entity.hpp`'s file comment), so each read + // here goes through that type's explicit `std::string` + // conversion — `Field::Value()` returns a `T const&`, and + // `SqlAnsiString<N>`/`SqlMaxDynamicAnsiString`'s `operator + // std::string()` is `explicit` by design (Lightweight's own + // truncation-safety discipline: an implicit path here would let + // a bounded column silently narrow on assignment into + // unrelated code with no cast visible at the call site). + entry.modelType = std::string{row.modelType.Value()}; + entry.entityKey = std::string{row.entityKey.Value()}; + entry.actionType = std::string{row.actionType.Value()}; + entry.payload = std::string{row.payload.Value()}; + entry.result = std::string{row.result.Value()}; + entry.principal = std::string{row.principal.Value()}; + entry.timestampMs = row.timestampMs.Value(); + entry.idempotencyKey = std::string{row.idempotencyKey.Value()}; + entries.push_back(std::move(entry)); + } + return entries; + }; + // Deleting the row rather than flagging it is what outbox_entity.hpp's + // own doc comment specifies: the table then only ever holds genuinely + // unrelayed work. OutboxRelay calls this only after `sink->flush()` + // returned normally, so a crash before this point simply re-drains the + // same rows next pass and the sink's idempotencyKey dedup absorbs the + // repeat (`FileActionLog` does this out of the box). + relay.markRelayed = [&mapper](std::span<const ::morph::journal::LogEntry> rows) { + for (const auto& row : rows) { + ::Lightweight::SqlStatement stmt{mapper.Connection()}; + stmt.Prepare("DELETE FROM bookmark_outbox WHERE idempotency_key = ?"); + (void) stmt.Execute(row.idempotencyKey); + } + }; + relay.sink = _actionLog; + return relay.relay().relayed; +} + +} // namespace bookmarks::app diff --git a/examples/bookmarks/src/db/schema.cpp b/examples/bookmarks/src/db/schema.cpp new file mode 100644 index 00000000..b01f18ab --- /dev/null +++ b/examples/bookmarks/src/db/schema.cpp @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/db/database.hpp" + +#include <Lightweight/SqlConnection.hpp> +#include <Lightweight/SqlMigration.hpp> +#include <Lightweight/SqlQuery/Migrate.hpp> + +namespace bookmarks::db { + +void setup(const std::string& connectionString) { + Lightweight::SqlConnection::SetDefaultConnectionString(Lightweight::SqlConnectionString{connectionString}); + Lightweight::SqlMigration::MigrationManager::GetInstance().CreateMigrationHistory(); + Lightweight::SqlMigration::MigrationManager::GetInstance().ApplyPendingMigrations(); +} + +} // namespace bookmarks::db + +using namespace Lightweight::SqlColumnTypeDefinitions; + +LIGHTWEIGHT_SQL_MIGRATION(20260807000001, "Create bookmarks tables") { + plan.CreateTableIfNotExists("bookmarks") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("owner_principal", Varchar(64)) + .RequiredColumn("url", Varchar(2048)) + .RequiredColumn("title", Varchar(512)) + .RequiredColumn("description", NVarchar(0)) + .RequiredColumn("notes", NVarchar(0)) + .RequiredColumn("is_unread", Bool()) + .RequiredColumn("is_archived", Bool()) + .RequiredColumn("is_shared", Bool()) + .RequiredColumn("created_at_ms", Bigint()) + .RequiredColumn("updated_at_ms", Bigint()) + .RequiredColumn("favicon_path", Varchar(2048)); + // Every list/get/edit/archive query filters on owner_principal first; + // the changes-since poll (Task 7) additionally filters on + // updated_at_ms, and the shared feed (Task 10) on is_shared alone. + plan.CreateIndex("idx_bookmarks_owner", "bookmarks", {"owner_principal"}); + plan.CreateIndex("idx_bookmarks_owner_updated", "bookmarks", {"owner_principal", "updated_at_ms"}); + plan.CreateIndex("idx_bookmarks_shared", "bookmarks", {"is_shared"}); + + plan.CreateTableIfNotExists("tags") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("owner_principal", Varchar(64)) + .RequiredColumn("name", Varchar(128)); + // Tag names are unique per owner, not globally -- two different users + // may both have a tag named "work". + plan.CreateUniqueIndex("idx_tags_owner_name", "tags", {"owner_principal", "name"}); + + const auto bookmarksRef = Lightweight::SqlForeignKeyReferenceDefinition{.tableName = "bookmarks", .columnName = "id"}; + const auto tagsRef = Lightweight::SqlForeignKeyReferenceDefinition{.tableName = "tags", .columnName = "id"}; + plan.CreateTableIfNotExists("bookmark_tags") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredForeignKey("bookmark_id", Bigint(), bookmarksRef) + .RequiredForeignKey("tag_id", Bigint(), tagsRef); + // A bookmark may never carry the same tag twice -- this is what makes + // TagModel::execute(const MergeTags&)'s "INSERT OR IGNORE"-shaped + // dedup (Task 9) meaningful rather than a defensive no-op. + plan.CreateUniqueIndex("idx_bookmark_tags_pair", "bookmark_tags", {"bookmark_id", "tag_id"}); + + plan.CreateTableIfNotExists("imported_ops") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("owner_principal", Varchar(64)) + .RequiredColumn("op_id", Varchar(128)) + .RequiredColumn("applied_at_ms", Bigint()); + plan.CreateUniqueIndex("idx_imported_ops_owner_op", "imported_ops", {"owner_principal", "op_id"}); +} + +LIGHTWEIGHT_SQL_MIGRATION(20260807000002, "Create bookmarks outbox table") { + plan.CreateTableIfNotExists("bookmark_outbox") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("model_type", Varchar(64)) + .RequiredColumn("entity_key", Varchar(64)) + .RequiredColumn("action_type", Varchar(64)) + .RequiredColumn("payload", NVarchar(0)) + .RequiredColumn("result", NVarchar(0)) + .RequiredColumn("principal", Varchar(64)) + .RequiredColumn("timestamp_ms", Bigint()) + .RequiredColumn("idempotency_key", Varchar(128)); + plan.CreateUniqueIndex("idx_bookmark_outbox_idempotency", "bookmark_outbox", {"idempotency_key"}); +} diff --git a/examples/bookmarks/src/dto/auth_dto.cpp b/examples/bookmarks/src/dto/auth_dto.cpp new file mode 100644 index 00000000..7b3c159a --- /dev/null +++ b/examples/bookmarks/src/dto/auth_dto.cpp @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/dto/auth_dto.hpp" + +#include "bookmarks/auth/bookmarks_authorizer.hpp" + +namespace bookmarks { + +bool Login::validate() const noexcept { return auth::isValidPrincipal(username); } + +} // namespace bookmarks diff --git a/examples/bookmarks/src/import/netscape_bookmarks.cpp b/examples/bookmarks/src/import/netscape_bookmarks.cpp new file mode 100644 index 00000000..01f6544f --- /dev/null +++ b/examples/bookmarks/src/import/netscape_bookmarks.cpp @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/import/netscape_bookmarks.hpp" + +#include <cctype> +#include <cstddef> + +namespace bookmarks::import { + +namespace { + +[[nodiscard]] std::string decodeEntities(std::string_view text) { + std::string out; + out.reserve(text.size()); + for (std::size_t i = 0; i < text.size();) { + if (text[i] == '&') { + if (text.substr(i, 5) == "&") { + out += '&'; + i += 5; + continue; + } + if (text.substr(i, 4) == "<") { + out += '<'; + i += 4; + continue; + } + if (text.substr(i, 4) == ">") { + out += '>'; + i += 4; + continue; + } + if (text.substr(i, 6) == """) { + out += '"'; + i += 6; + continue; + } + if (text.substr(i, 5) == "'") { + out += '\''; + i += 5; + continue; + } + } + out += text[i]; + ++i; + } + return out; +} + +/// @brief Case-insensitive substring search for @p needle in @p haystack, +/// starting at @p from. +[[nodiscard]] std::size_t findCaseInsensitive(std::string_view haystack, std::string_view needle, std::size_t from) { + if (needle.empty() || needle.size() > haystack.size()) { + return std::string_view::npos; + } + for (std::size_t i = from; i + needle.size() <= haystack.size(); ++i) { + bool match = true; + for (std::size_t j = 0; j < needle.size(); ++j) { + if (std::tolower(static_cast<unsigned char>(haystack[i + j])) != + std::tolower(static_cast<unsigned char>(needle[j]))) { + match = false; + break; + } + } + if (match) { + return i; + } + } + return std::string_view::npos; +} + +} // namespace + +std::vector<ParsedEntry> parseNetscapeChunk(std::string_view chunk) { + std::vector<ParsedEntry> entries; + std::size_t pos = 0; + while (true) { + const auto tagStart = findCaseInsensitive(chunk, "<a", pos); + if (tagStart == std::string_view::npos) { + break; + } + const auto tagEnd = chunk.find('>', tagStart); + if (tagEnd == std::string_view::npos) { + break; // unterminated tag -- nothing more to parse in this chunk + } + const auto closeStart = findCaseInsensitive(chunk, "</a>", tagEnd); + if (closeStart == std::string_view::npos) { + break; // unterminated element + } + + const std::string_view attrs = chunk.substr(tagStart, tagEnd - tagStart); + ParsedEntry entry; + const auto hrefPos = findCaseInsensitive(attrs, "href=", 0); + if (hrefPos != std::string_view::npos) { + auto valueStart = hrefPos + 5; + if (valueStart < attrs.size() && attrs[valueStart] == '"') { + const auto valueEnd = attrs.find('"', valueStart + 1); + if (valueEnd != std::string_view::npos) { + entry.url = decodeEntities(attrs.substr(valueStart + 1, valueEnd - valueStart - 1)); + } + } + } + entry.title = decodeEntities(chunk.substr(tagEnd + 1, closeStart - tagEnd - 1)); + entries.push_back(std::move(entry)); + + pos = closeStart + 4; + } + return entries; +} + +std::string escapeHtml(std::string_view text) { + std::string out; + out.reserve(text.size()); + for (const char ch : text) { + switch (ch) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + case '"': out += """; break; + case '\'': out += "'"; break; + default: out += ch; + } + } + return out; +} + +} // namespace bookmarks::import diff --git a/examples/bookmarks/src/models/auth_model.cpp b/examples/bookmarks/src/models/auth_model.cpp new file mode 100644 index 00000000..b5dfc728 --- /dev/null +++ b/examples/bookmarks/src/models/auth_model.cpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/models/auth_model.hpp" + +#include "bookmarks/auth/bookmarks_authorizer.hpp" + +#include <morph/session/session_auth.hpp> + +#include <cstdint> + +namespace bookmarks { + +namespace { + +/// @brief Expiry stamped into every minted token: 2100-01-01T00:00:00Z. +/// +/// `SessionToken::expiresAtMs` must be strictly positive — `TokenVerifier` +/// treats `<= 0` as already-expired precisely so a zeroed token is never an +/// eternal credential — so "no expiry" is not expressible and a value has to +/// be chosen. This rung chooses one far enough out to be irrelevant, because +/// it ships no session-renewal path: a shorter lifetime would mean a client +/// silently losing its session mid-run with nothing to recover it but +/// logging in again, which would be testing a re-authentication flow this +/// rung does not have rather than the authorization pipeline it does. A +/// deployment that replaces this model's body with a real credential check +/// (see `auth_dto.hpp`'s `@file` comment) sets a real lifetime here at the +/// same time. +constexpr std::int64_t kTokenExpiresAtMs = 4102444800000; + +} // namespace + +LoginResult AuthModel::execute(const Login& action) { + if (!action.validate()) { + throw ValidationError{"Login: username must be a valid principal"}; + } + if (auth::isReservedPrincipal(action.username)) { + // See isReservedPrincipal's doc comment: minting one of these on + // request would hand any caller the internal worker's authority. + throw ValidationError{"Login: the 'system:' principal namespace is reserved"}; + } + auto issuer = auth::tokenIssuer(); + if (!issuer) { + // No App has installed one -- e.g. a test that constructs AuthModel + // directly, or a server bootstrap that forgot. A clear, typed + // failure, not a null dereference. + throw ValidationError{"Login: no token issuer installed"}; + } + auto token = issuer->issue(::morph::session::SessionToken{ + .principal = action.username, + // 0 disables TokenVerifier's not-before check, which this rung has + // no use for: there is no scenario here where a token is minted + // against a clock ahead of the verifier's, since the issuer and the + // verifier are the same process. + .issuedAtMs = 0, + .expiresAtMs = kTokenExpiresAtMs, + .roles = {}, + }); + return LoginResult{.token = AuthToken{std::move(token)}, .principal = action.username}; +} + +} // namespace bookmarks diff --git a/examples/bookmarks/src/models/bookmark_model.cpp b/examples/bookmarks/src/models/bookmark_model.cpp new file mode 100644 index 00000000..a985acdb --- /dev/null +++ b/examples/bookmarks/src/models/bookmark_model.cpp @@ -0,0 +1,727 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/models/bookmark_model.hpp" + +#include "bookmarks/auth/bookmarks_authorizer.hpp" +#include "bookmarks/db/bookmark_entity.hpp" +#include "bookmarks/db/bookmark_tag_entity.hpp" +#include "bookmarks/db/imported_op_entity.hpp" +#include "bookmarks/db/outbox_entity.hpp" +#include "bookmarks/db/tag_entity.hpp" +#include "bookmarks/import/netscape_bookmarks.hpp" + +#include "clock.hpp" + +#include <Lightweight/DataMapper/DataMapper.hpp> +#include <Lightweight/DataMapper/Pool.hpp> +#include <Lightweight/SqlError.hpp> +#include <Lightweight/SqlErrorDetection.hpp> +#include <Lightweight/SqlStatement.hpp> +#include <Lightweight/SqlTransaction.hpp> + +#include <morph/core/registry.hpp> +#include <morph/session/session.hpp> + +#include <algorithm> +#include <atomic> +#include <cstddef> +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +namespace bookmarks { + +// The one place each DTO-level string bound and the storage layer's real +// column capacity are checked against each other -- the same discipline +// `pastebin::kMaxSyntaxBytes`'s own assertion (`paste_model.cpp`) +// established: widening a column without widening the constant, or the +// reverse, fails the build here rather than silently reopening either harm +// (`SqlAnsiString`'s `_size{std::min(N, s.size())}` truncating a value that +// `validate()` had already accepted, or `validate()` rejecting a value that +// would have fit in the real column). +static_assert(decltype(db::BookmarkRecord::ownerPrincipal)::ValueType{}.capacity() == auth::kMaxPrincipalBytes, + "bookmarks::auth::kMaxPrincipalBytes must equal BookmarkRecord::ownerPrincipal's SqlAnsiString " + "capacity -- otherwise a principal the authorizer accepts could be silently truncated on the way " + "into the row."); +static_assert(decltype(db::BookmarkRecord::url)::ValueType{}.capacity() == kMaxUrlBytes, + "bookmarks::kMaxUrlBytes must equal BookmarkRecord::url's SqlAnsiString capacity -- otherwise " + "CreateBookmark/EditBookmark either reject urls that would have fit, or accept ones that get " + "silently truncated on the way into the row."); +static_assert(decltype(db::BookmarkRecord::title)::ValueType{}.capacity() == kMaxTitleBytes, + "bookmarks::kMaxTitleBytes must equal BookmarkRecord::title's SqlAnsiString capacity -- otherwise " + "CreateBookmark/EditBookmark either reject titles that would have fit, or accept ones that get " + "silently truncated on the way into the row."); +static_assert(decltype(db::BookmarkRecord::faviconPath)::ValueType{}.capacity() == kMaxUrlBytes, + "bookmarks::kMaxUrlBytes must equal BookmarkRecord::faviconPath's SqlAnsiString capacity -- a " + "favicon path is itself a URL, so it shares url's bound; letting the two drift would let " + "RecordMetadata silently truncate a path CreateBookmark/EditBookmark would have accepted for url."); + +namespace { + +[[nodiscard]] std::int64_t nowMs() noexcept { + return (*::morph::ladder::now().value).value.time_since_epoch().count(); +} + +/// @brief Process-wide monotonic counter, used only to disambiguate +/// `BulkEdit`'s server-generated idempotency key (see its call site) +/// when two calls land in the same `nowMs()` millisecond -- +/// `morph::ladder::now()` has millisecond resolution (there is no +/// higher-resolution variant), so the timestamp alone cannot be +/// trusted to be unique across rapid back-to-back calls from the same +/// principal. `std::atomic` (not `thread_local`) because the model +/// instance is shared across whichever thread each dispatched call +/// lands on. +[[nodiscard]] std::uint64_t nextOutboxSeq() noexcept { + static std::atomic<std::uint64_t> counter{0}; + return counter.fetch_add(1, std::memory_order_relaxed); +} + +[[nodiscard]] ::morph::time::Timestamp fromEpochMs(std::int64_t epochMs) noexcept { + return ::morph::time::Timestamp{::morph::time::DateTime{ + std::chrono::sys_time<std::chrono::milliseconds>{std::chrono::milliseconds{epochMs}}}}; +} + +/// @brief The authenticated caller's principal, or throws `Forbidden`. +/// +/// `session::current()` is populated fresh on every dispatched action +/// (`session::detail::ScopedContext`, installed by `RemoteServer`/ +/// `LocalBackend` around each `execute()`); reading it here rather than +/// once at construction is what lets a single plain-registered +/// `BookmarkModel` instance serve whichever principal's call actually +/// reaches it -- there is exactly one instance per registration, so in +/// practice this is stable across a registration's whole lifetime, but the +/// model never assumes that, matching rule 1's "models re-check their own +/// authorization" requirement. `nullptr`/empty is treated identically to an +/// unauthenticated caller: `Forbidden`, not a crash -- reachable from a +/// test that calls `execute()` directly with no session installed, and +/// (defensively) from a local backend, which installs a `Context` but +/// never verifies it. +[[nodiscard]] const std::string& requireOwner() { + const auto* ctx = ::morph::session::current(); + if (ctx == nullptr || ctx->principal.empty()) { + throw Forbidden{"no authenticated principal"}; + } + return ctx->principal; +} + +/// @brief Finds @p owner's tag named @p name, creating it if it does not +/// exist yet. Shared by `applyTagSet` (Task 6) and `BulkEdit` +/// (this task) — both run inside the caller's own transaction. +[[nodiscard]] std::uint64_t findOrCreateTagId(::Lightweight::DataMapper& mapper, const std::string& owner, + const std::string& name) { + auto existing = mapper.Query<db::TagRecord>() + .Where(::Lightweight::FieldNameOf<&db::TagRecord::ownerPrincipal>, "=", owner) + .Where(::Lightweight::FieldNameOf<&db::TagRecord::name>, "=", name) + .All(); + if (!existing.empty()) { + return existing.front().id.Value(); + } + db::TagRecord tag; + tag.ownerPrincipal = owner; + tag.name = name; + mapper.Create(tag); + return tag.id.Value(); +} + +/// @brief Adds a bookmark<->tag association if it does not already exist — +/// the junction table's unique index (`idx_bookmark_tags_pair`) +/// makes a duplicate a no-op to *detect*, but this checks first +/// rather than relying on catching the constraint violation, so a +/// `BulkEdit`'s per-item loop never has to distinguish "this item's +/// add was a genuine no-op" from "this item hit an unrelated store +/// error" via exception type alone. +void addTagAssociationIfAbsent(::Lightweight::DataMapper& mapper, std::uint64_t bookmarkId, std::uint64_t tagId) { + auto existing = mapper.Query<db::BookmarkTagRecord>() + .Where(::Lightweight::FieldNameOf<&db::BookmarkTagRecord::bookmark>, "=", bookmarkId) + .Where(::Lightweight::FieldNameOf<&db::BookmarkTagRecord::tag>, "=", tagId) + .All(); + if (!existing.empty()) { + return; + } + db::BookmarkTagRecord junction; + junction.bookmark = bookmarkId; + junction.tag = tagId; + mapper.Create(junction); +} + +/// @brief Writes one row into `bookmark_outbox`. Must run inside the +/// caller's own `SqlTransaction` — see this task's own doc comment. +template <typename Action, typename Result> +void writeOutboxEntry(::Lightweight::DataMapper& mapper, const std::string& owner, const Action& action, + const Result& result, std::string_view actionType, std::string_view idempotencyKey) { + db::BookmarkOutboxRecord entry; + entry.modelType = "BookmarkModel"; + entry.entityKey = owner; + entry.actionType = std::string{actionType}; + entry.payload = ::morph::model::ActionTraits<Action>::toJson(action); + entry.result = ::morph::model::ActionTraits<Action>::resultToJson(result); + entry.principal = owner; + entry.timestampMs = nowMs(); + entry.idempotencyKey = std::string{idempotencyKey}; + mapper.Create(entry); +} + +} // namespace + +/// @brief Reads every tag name currently associated with @p bookmarkId. +/// +/// Takes no owner and needs none: a tag row is always owned by the same +/// principal as every bookmark it is attached to, by construction -- +/// `applyTagSet` below never creates a cross-owner association -- so the +/// junction rows for one bookmark are already owner-homogeneous, and the +/// caller has already established that the bookmark itself is readable. +[[nodiscard]] static std::vector<std::string> readTagNames(::Lightweight::DataMapper& mapper, std::uint64_t bookmarkId) { + auto junctionRows = mapper.Query<db::BookmarkTagRecord>() + .Where(::Lightweight::FieldNameOf<&db::BookmarkTagRecord::bookmark>, "=", bookmarkId) + .All(); + std::vector<std::string> names; + names.reserve(junctionRows.size()); + for (const auto& row : junctionRows) { + auto tagRows = mapper.Query<db::TagRecord>() + .Where(::Lightweight::FieldNameOf<&db::TagRecord::id>, "=", row.tag.Value()) + .All(); + if (!tagRows.empty()) { + // `TagRecord::name` is `Light::SqlAnsiString<kMaxTagNameBytes>` + // (`tag_entity.hpp`); `names` stays a plain `std::vector<std::string>` + // since tag names travel the wire as `std::string` per + // IMPLEMENTATION.md rule 4 -- the explicit `std::string{...}` + // conversion is the model-layer boundary that does that. + names.push_back(std::string{tagRows.front().name.Value()}); + } + } + return names; +} + +/// @brief Replaces @p bookmarkId's tag set with exactly @p desiredNames, +/// auto-creating any tag @p owner has never used before. Must run +/// inside the caller's own `SqlTransaction` -- this function opens +/// none of its own, so every write it makes commits or rolls back +/// with the surrounding action. +static void applyTagSet(::Lightweight::DataMapper& mapper, std::uint64_t bookmarkId, const std::string& owner, + const std::vector<std::string>& desiredNames) { + const auto current = readTagNames(mapper, bookmarkId); + std::vector<std::string> toAdd; + for (const auto& name : desiredNames) { + if (std::ranges::find(current, name) == current.end()) { + toAdd.push_back(name); + } + } + std::vector<std::string> toRemove; + for (const auto& name : current) { + if (std::ranges::find(desiredNames, name) == desiredNames.end()) { + toRemove.push_back(name); + } + } + + for (const auto& name : toAdd) { + const auto tagId = findOrCreateTagId(mapper, owner, name); + addTagAssociationIfAbsent(mapper, bookmarkId, tagId); + } + + for (const auto& name : toRemove) { + auto tagRows = mapper.Query<db::TagRecord>() + .Where(::Lightweight::FieldNameOf<&db::TagRecord::ownerPrincipal>, "=", owner) + .Where(::Lightweight::FieldNameOf<&db::TagRecord::name>, "=", name) + .All(); + if (tagRows.empty()) { + continue; + } + ::Lightweight::SqlStatement stmt{mapper.Connection()}; + stmt.Prepare("DELETE FROM bookmark_tags WHERE bookmark_id = ? AND tag_id = ?"); + (void) stmt.Execute(bookmarkId, tagRows.front().id.Value()); + } +} + +[[nodiscard]] static BookmarkView toView(const db::BookmarkRecord& rec, std::vector<std::string> tags) { + BookmarkView view; + view.id = BookmarkId{static_cast<std::int64_t>(rec.id.Value())}; + // Every `BookmarkRecord` string column is a Lightweight strong string + // type (`bookmark_entity.hpp`'s file comment); `BookmarkView`'s members + // stay plain `std::string` per IMPLEMENTATION.md rule 4, so each read + // here converts explicitly at this DTO boundary. + view.url = std::string{rec.url.Value()}; + view.title = std::string{rec.title.Value()}; + view.description = std::string{rec.description.Value()}; + view.notes = std::string{rec.notes.Value()}; + view.tags = std::move(tags); + view.createdAt = fromEpochMs(rec.createdAtMs.Value()); + view.updatedAt = fromEpochMs(rec.updatedAtMs.Value()); + view.readState = rec.isUnread.Value() ? ReadState::Unread : ReadState::Read; + view.archiveState = rec.isArchived.Value() ? ArchiveState::Archived : ArchiveState::Active; + view.visibility = rec.isShared.Value() ? Visibility::Shared : Visibility::Private; + return view; +} + +/// @brief Loads @p id, requiring it to exist and be owned by @p owner. +/// @throws NotFound if no such row exists at all. +/// @throws Forbidden if it exists but belongs to a different principal -- +/// distinguished on purpose (`bookmarks::Forbidden`'s own doc +/// comment) so the "local mode has no authorization at all" test +/// (Task 15) has something specific to assert against. +[[nodiscard]] static db::BookmarkRecord loadOwned(::Lightweight::DataMapper& mapper, std::uint64_t id, + const std::string& owner) { + auto rows = + mapper.Query<db::BookmarkRecord>().Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::id>, "=", id).All(); + if (rows.empty()) { + throw NotFound{"no such bookmark"}; + } + if (rows.front().ownerPrincipal.Value() != owner) { + throw Forbidden{"bookmark belongs to a different principal"}; + } + return rows.front(); +} + +CreateBookmarkResult BookmarkModel::execute(const CreateBookmark& action) { + if (!action.validate()) { + throw ValidationError{"CreateBookmark: a non-empty url within the length bound is required"}; + } + const auto& owner = requireOwner(); + + db::BookmarkRecord rec; + rec.ownerPrincipal = owner; + rec.url = action.url; + rec.title = action.title; + rec.description = action.description; + rec.notes = action.notes; + rec.isShared = action.visibility == Visibility::Shared; + const auto now = nowMs(); + rec.createdAtMs = now; + rec.updatedAtMs = now; + + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + ::Lightweight::SqlTransaction transaction{mapper->Connection(), ::Lightweight::SqlTransactionMode::ROLLBACK}; + mapper->Create(rec); + applyTagSet(mapper.Get(), rec.id.Value(), owner, action.tags); + transaction.Commit(); + + return CreateBookmarkResult{.id = BookmarkId{static_cast<std::int64_t>(rec.id.Value())}}; +} + +BookmarkView BookmarkModel::execute(const EditBookmark& action) { + if (!action.validate()) { + throw ValidationError{"EditBookmark: id and a non-empty url within the length bound are required"}; + } + const auto& owner = requireOwner(); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto rec = loadOwned(mapper.Get(), static_cast<std::uint64_t>(*action.id), owner); + + rec.url = action.url; + rec.title = action.title; + rec.description = action.description; + rec.notes = action.notes; + rec.isShared = action.visibility == Visibility::Shared; + rec.updatedAtMs = nowMs(); + + ::Lightweight::SqlTransaction transaction{mapper->Connection(), ::Lightweight::SqlTransactionMode::ROLLBACK}; + mapper->Update(rec); + applyTagSet(mapper.Get(), rec.id.Value(), owner, action.tags); + transaction.Commit(); + + return toView(rec, readTagNames(mapper.Get(), rec.id.Value())); +} + +Ack BookmarkModel::execute(const ArchiveBookmark& action) { + if (!action.validate()) { + throw ValidationError{"ArchiveBookmark: id is required"}; + } + const auto& owner = requireOwner(); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto rec = loadOwned(mapper.Get(), static_cast<std::uint64_t>(*action.id), owner); + rec.isArchived = true; + rec.updatedAtMs = nowMs(); + mapper->Update(rec); + return Ack{}; +} + +Ack BookmarkModel::execute(const UnarchiveBookmark& action) { + if (!action.validate()) { + throw ValidationError{"UnarchiveBookmark: id is required"}; + } + const auto& owner = requireOwner(); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto rec = loadOwned(mapper.Get(), static_cast<std::uint64_t>(*action.id), owner); + rec.isArchived = false; + rec.updatedAtMs = nowMs(); + mapper->Update(rec); + return Ack{}; +} + +Ack BookmarkModel::execute(const DeleteBookmark& action) { + if (!action.validate()) { + throw ValidationError{"DeleteBookmark: id is required"}; + } + const auto& owner = requireOwner(); + const auto id = static_cast<std::uint64_t>(*action.id); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + (void) loadOwned(mapper.Get(), id, owner); // NotFound/Forbidden, same as every other action + + ::Lightweight::SqlTransaction transaction{mapper->Connection(), ::Lightweight::SqlTransactionMode::ROLLBACK}; + { + ::Lightweight::SqlStatement stmt{mapper->Connection()}; + stmt.Prepare("DELETE FROM bookmark_tags WHERE bookmark_id = ?"); + (void) stmt.Execute(id); + } + { + ::Lightweight::SqlStatement stmt{mapper->Connection()}; + stmt.Prepare("DELETE FROM bookmarks WHERE id = ?"); + (void) stmt.Execute(id); + } + transaction.Commit(); + return Ack{}; +} + +BookmarkView BookmarkModel::execute(const GetBookmark& action) { + if (!action.validate()) { + throw ValidationError{"GetBookmark: id is required"}; + } + const auto& owner = requireOwner(); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + const auto rec = loadOwned(mapper.Get(), static_cast<std::uint64_t>(*action.id), owner); + return toView(rec, readTagNames(mapper.Get(), rec.id.Value())); +} + +ListBookmarksResult BookmarkModel::execute(const ListBookmarks& action) { + const auto& owner = requireOwner(); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto query = mapper->Query<db::BookmarkRecord>(); + (void) query.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::ownerPrincipal>, "=", owner); + if (action.archiveFilter == ArchiveFilter::ActiveOnly) { + (void) query.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::isArchived>, "=", false); + } else if (action.archiveFilter == ArchiveFilter::ArchivedOnly) { + (void) query.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::isArchived>, "=", true); + } + if (action.readFilter == ReadFilter::UnreadOnly) { + (void) query.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::isUnread>, "=", true); + } else if (action.readFilter == ReadFilter::ReadOnly) { + (void) query.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::isUnread>, "=", false); + } + if (action.cursor.hasValue()) { + (void) query.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::id>, "<", + static_cast<std::uint64_t>(*action.cursor)); + } + // Text/tag filters run in C++ after the SQL page is fetched, not as a + // LIKE/JOIN in the query above: this rung's scale (a demo bookmark + // collection, not a production search index) does not warrant it, and + // combining a tag filter with keyset pagination correctly needs the + // junction table anyway, which the per-row loop below already touches. + constexpr std::size_t kPageSize = 20; + auto rows = query.OrderBy(::Lightweight::FieldNameOf<&db::BookmarkRecord::id>, ::Lightweight::SqlResultOrdering::DESCENDING) + .First(kPageSize + 1); + const bool hasMore = rows.size() > kPageSize; + if (hasMore) { + rows.resize(kPageSize); + } + + ListBookmarksResult result; + for (const auto& rec : rows) { + auto tags = readTagNames(mapper.Get(), rec.id.Value()); + if (!action.tag.empty() && std::ranges::find(tags, action.tag) == tags.end()) { + continue; + } + // `SqlAnsiString<N>` has no `.find()` member of its own; `.str()` + // (Lightweight's own view accessor) hands back the + // `std::basic_string_view` that does. + if (!action.searchText.empty() && rec.title.Value().str().find(action.searchText) == std::string_view::npos && + rec.url.Value().str().find(action.searchText) == std::string_view::npos) { + continue; + } + BookmarkSummary summary; + summary.id = BookmarkId{static_cast<std::int64_t>(rec.id.Value())}; + summary.url = std::string{rec.url.Value()}; + summary.title = std::string{rec.title.Value()}; + summary.tags = std::move(tags); + summary.createdAt = fromEpochMs(rec.createdAtMs.Value()); + summary.updatedAt = fromEpochMs(rec.updatedAtMs.Value()); + summary.readState = rec.isUnread.Value() ? ReadState::Unread : ReadState::Read; + summary.archiveState = rec.isArchived.Value() ? ArchiveState::Archived : ArchiveState::Active; + summary.visibility = rec.isShared.Value() ? Visibility::Shared : Visibility::Private; + result.bookmarks.push_back(std::move(summary)); + } + if (hasMore) { + // The cursor must be set whenever more raw rows exist, independent of + // whether this page's *filtered* results happen to be empty: rows.back() + // is the correct pagination boundary regardless of the tag/searchText + // filters above. Gating this on !result.bookmarks.empty() would let a + // page whose 20 raw rows are all filtered out (while a 21st still + // proves hasMore) return an empty, cursor-less response -- a + // tag/text-filtering client would then wrongly conclude the search is + // exhausted and silently miss real matches further down the id space. + result.nextCursor = Cursor{static_cast<std::int64_t>(rows.back().id.Value())}; + } + return result; +} + +GetChangesSinceResult BookmarkModel::execute(const GetChangesSince& action) { + const auto& owner = requireOwner(); + // Captured *before* the query -- see this task's own doc comment for + // why a later capture would let a racing write be lost across two + // consecutive polls instead of merely duplicated across them. + const auto asOf = nowMs(); + const std::int64_t sinceMs = + action.since.timestampMs.hasValue() ? (*action.since.timestampMs).value.time_since_epoch().count() : 0; + const std::uint64_t sinceLastId = static_cast<std::uint64_t>(action.since.lastId.value_or(0)); + + // See ChangesCursor's doc comment (issue #43): a strict `updatedAtMs > + // sinceMs` alone drops a write landing in the exact same millisecond as + // `sinceMs`. The id tie-break recovers it without over-including: any + // row strictly after sinceMs qualifies outright; a row *at* sinceMs + // qualifies only if its id is past the last one already delivered at + // that same instant. + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto rows = mapper + ->Query<db::BookmarkRecord>() + .Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::ownerPrincipal>, "=", owner) + .Where([&](auto& q) { + return q.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::updatedAtMs>, ">", sinceMs) + .OrWhere([&](auto& q2) { + return q2.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::updatedAtMs>, "=", + sinceMs) + .Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::id>, ">", sinceLastId); + }); + }) + .OrderBy(::Lightweight::FieldNameOf<&db::BookmarkRecord::updatedAtMs>) + .OrderBy(::Lightweight::FieldNameOf<&db::BookmarkRecord::id>) + .All(); + + GetChangesSinceResult result; + result.asOf.timestampMs = fromEpochMs(asOf); + // The next cursor's tie-break is the highest id delivered *at exactly + // asOf* -- a row strictly before asOf needs no tie-break (already + // excluded outright by the next poll's `>` on its own), and no row can + // exist strictly after asOf, since asOf was captured before this query + // ran. Rows are ordered (updatedAtMs, id) ascending above, so the last + // row sharing asOf's timestamp, if any, is found from the back. + for (auto it = rows.rbegin(); it != rows.rend(); ++it) { + if (static_cast<std::int64_t>(it->updatedAtMs.Value()) == asOf) { + result.asOf.lastId = static_cast<std::int64_t>(it->id.Value()); + break; + } + } + for (const auto& rec : rows) { + BookmarkSummary summary; + summary.id = BookmarkId{static_cast<std::int64_t>(rec.id.Value())}; + summary.url = std::string{rec.url.Value()}; + summary.title = std::string{rec.title.Value()}; + summary.tags = readTagNames(mapper.Get(), rec.id.Value()); + summary.createdAt = fromEpochMs(rec.createdAtMs.Value()); + summary.updatedAt = fromEpochMs(rec.updatedAtMs.Value()); + summary.readState = rec.isUnread.Value() ? ReadState::Unread : ReadState::Read; + summary.archiveState = rec.isArchived.Value() ? ArchiveState::Archived : ArchiveState::Active; + summary.visibility = rec.isShared.Value() ? Visibility::Shared : Visibility::Private; + result.changed.push_back(std::move(summary)); + } + return result; +} + +BulkEditResult BookmarkModel::execute(const BulkEdit& action) { + if (!action.validate()) { + throw ValidationError{"BulkEdit: at least one id is required"}; + } + const auto& owner = requireOwner(); + + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + ::Lightweight::SqlTransaction transaction{mapper->Connection(), ::Lightweight::SqlTransactionMode::ROLLBACK}; + + // Ownership check first, for *every* id, before any write: one + // violation rejects the whole batch (README's "all-or-nothing" + // framing, this task's resolved design decision) rather than applying + // a partial edit and reporting which ids failed. + std::vector<std::uint64_t> ids; + ids.reserve(action.ids.size()); + for (const auto& bookmarkId : action.ids) { + if (!bookmarkId.hasValue()) { + throw ValidationError{"BulkEdit: every id must be engaged"}; + } + const auto id = static_cast<std::uint64_t>(*bookmarkId); + (void) loadOwned(mapper.Get(), id, owner); // throws Forbidden/NotFound -> whole transaction rolls back + ids.push_back(id); + } + + for (const auto id : ids) { + if (action.archive == BulkArchiveOp::Archive) { + ::Lightweight::SqlStatement stmt{mapper->Connection()}; + stmt.Prepare("UPDATE bookmarks SET is_archived = 1, updated_at_ms = ? WHERE id = ?"); + (void) stmt.Execute(nowMs(), id); + } else if (action.archive == BulkArchiveOp::Unarchive) { + ::Lightweight::SqlStatement stmt{mapper->Connection()}; + stmt.Prepare("UPDATE bookmarks SET is_archived = 0, updated_at_ms = ? WHERE id = ?"); + (void) stmt.Execute(nowMs(), id); + } + for (const auto& name : action.addTags) { + const auto tagId = findOrCreateTagId(mapper.Get(), owner, name); + addTagAssociationIfAbsent(mapper.Get(), id, tagId); + } + for (const auto& name : action.removeTags) { + auto tagRows = mapper + ->Query<db::TagRecord>() + .Where(::Lightweight::FieldNameOf<&db::TagRecord::ownerPrincipal>, "=", owner) + .Where(::Lightweight::FieldNameOf<&db::TagRecord::name>, "=", name) + .All(); + if (tagRows.empty()) { + continue; + } + ::Lightweight::SqlStatement stmt{mapper->Connection()}; + stmt.Prepare("DELETE FROM bookmark_tags WHERE bookmark_id = ? AND tag_id = ?"); + (void) stmt.Execute(id, tagRows.front().id.Value()); + } + } + + BulkEditResult result{.affected = Count::fromDouble(static_cast<double>(ids.size()))}; + // idempotencyKey: not a client-supplied op-id (BulkEdit carries none -- + // unlike ImportBookmarks, retried bulk edits are not expected to be + // idempotent at this layer), so a fresh key per call is enough to keep + // this row distinguishable from any other outbox row; the relay's + // dedup only matters across relay *retries* of the same row, not + // across separate BulkEdit calls. `nowMs()` alone is only millisecond + // resolution, so two calls from the same owner landing in the same + // millisecond (a script, a double-click, a retry) would otherwise + // produce the identical key and collide against + // `idx_bookmark_outbox_idempotency`'s unique index, spuriously failing + // the second, legitimate call with a raw SQL constraint-violation + // exception instead of succeeding; `nextOutboxSeq()` (a process-wide + // monotonic counter) makes the key collision-resistant regardless of + // clock resolution. + writeOutboxEntry(mapper.Get(), owner, action, result, "BulkEdit", + owner + "-bulkedit-" + std::to_string(nowMs()) + "-" + std::to_string(nextOutboxSeq())); + transaction.Commit(); + return result; +} + +Ack BookmarkModel::execute(const RecordMetadata& action) { + if (!action.validate()) { + throw ValidationError{"RecordMetadata: id is required"}; + } + // Dispatched only by the internal metadata-fetch worker's + // "system:metadata-fetcher" service principal (Task 12) -- deliberately + // skips the *row-owner* check every GUI-reachable action performs: the + // worker acts on behalf of whichever principal owns the row, not on + // behalf of itself, so filtering by owner here would make it able to + // update nothing at all. Mirrors pastebin::ExpirePaste's internal-only + // shape, including the deleted-before-processed no-op below (that + // action's "already gone" tolerance). + // + // What replaces the owner check is a *caller* check, and it has to live + // here rather than in the authorizer: `authorizeInstance`'s + // owner-vs-principal comparison would not fit here even with a real + // recorded owner (which register envelopes now carry). The worker + // dispatches through its OWN plain-registered instance -- an instance it + // legitimately owns -- to touch a *row* some other user owns. + // authorizeInstance compares instance ownership, not row ownership, so + // it has nothing to object to: the worker's own instance is exactly what + // it is authorized to use. Without this line any authenticated user + // could dispatch RecordMetadata against any other user's bookmark id and + // overwrite its title and favicon, since this is the one action that + // does not scope its query to the caller. Rule 1 ("models must re-check + // their own authorization") is exactly the instruction being followed. + if (requireOwner() != auth::kMetadataFetcherPrincipal) { + throw Forbidden{"RecordMetadata is dispatched only by the metadata-fetch service principal"}; + } + const auto id = static_cast<std::uint64_t>(*action.id); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto rows = + mapper->Query<db::BookmarkRecord>().Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::id>, "=", id).All(); + if (rows.empty()) { + return Ack{}; + } + auto rec = rows.front(); + if (!action.title.empty()) { + rec.title = action.title; + } + if (!action.faviconPath.empty()) { + rec.faviconPath = action.faviconPath; + } + rec.updatedAtMs = nowMs(); + mapper->Update(rec); + return Ack{}; +} + +ImportBookmarksResult BookmarkModel::execute(const ImportBookmarks& action) { + // Checked ahead of the general `validate()` so the size bound gets the + // typed signal `TooLarge`'s own doc comment promises. `validate()` folds + // three conditions into one bool, and a caller that chunked its file too + // coarsely needs to tell "make the chunks smaller" apart from "this + // request was malformed" — which is the entire reason `TooLarge` exists + // as a distinct type. + if (action.chunk.size() > kMaxImportChunkBytes) { + throw TooLarge{"ImportBookmarks: chunk exceeds kMaxImportChunkBytes"}; + } + if (!action.validate()) { + throw ValidationError{"ImportBookmarks: a non-empty chunk and an opId are required"}; + } + const auto& owner = requireOwner(); + const auto& opIdStr = *action.opId; + + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto existingOp = mapper + ->Query<db::ImportedOpRecord>() + .Where(::Lightweight::FieldNameOf<&db::ImportedOpRecord::ownerPrincipal>, "=", owner) + .Where(::Lightweight::FieldNameOf<&db::ImportedOpRecord::opId>, "=", opIdStr) + .All(); + if (!existingOp.empty()) { + // Already applied -- a retried chunk after a dropped connection is + // a safe no-op, per this task's idempotency requirement. Reports + // zero: the caller's own first, successful attempt already learned + // the real counts, and a retry's purpose is confirming "did this + // land," not re-reporting them. + return ImportBookmarksResult{.imported = Count::fromDouble(0.0), .skipped = Count::fromDouble(0.0)}; + } + + const auto entries = ::bookmarks::import::parseNetscapeChunk(action.chunk); + std::size_t imported = 0; + std::size_t skipped = 0; + + ::Lightweight::SqlTransaction transaction{mapper->Connection(), ::Lightweight::SqlTransactionMode::ROLLBACK}; + for (const auto& entry : entries) { + // The parser is a *file* parser, not a DTO: nothing upstream of it + // applies this rung's own field bounds. Writing an over-long url or + // title anyway would create a row that `EditBookmark::validate()` + // (and `CreateBookmark::validate()`) then refuse to accept — an + // imported bookmark the owner can see but can never edit, which is a + // worse outcome than not importing it. Truncating instead would be + // worse still: a silently mangled url is not the bookmark the user + // saved. So such an entry is skipped and counted, exactly like a + // malformed one. + if (entry.url.empty() || entry.url.size() > kMaxUrlBytes || entry.title.size() > kMaxTitleBytes) { + ++skipped; + continue; + } + db::BookmarkRecord rec; + rec.ownerPrincipal = owner; + rec.url = entry.url; + rec.title = entry.title; + const auto now = nowMs(); + rec.createdAtMs = now; + rec.updatedAtMs = now; + mapper->Create(rec); + ++imported; + } + db::ImportedOpRecord op; + op.ownerPrincipal = owner; + op.opId = opIdStr; + op.appliedAtMs = nowMs(); + mapper->Create(op); + transaction.Commit(); + + return ImportBookmarksResult{.imported = Count::fromDouble(static_cast<double>(imported)), + .skipped = Count::fromDouble(static_cast<double>(skipped))}; +} + +ExportBookmarksResult BookmarkModel::execute(const ExportBookmarks&) { + const auto& owner = requireOwner(); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto rows = mapper + ->Query<db::BookmarkRecord>() + .Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::ownerPrincipal>, "=", owner) + .All(); + std::string html = "<!DOCTYPE NETSCAPE-Bookmark-file-1>\n<TITLE>Bookmarks\n

Bookmarks

\n

\n"; + for (const auto& rec : rows) { + // `escapeHtml` takes a `std::string_view`; `.str()` is `SqlAnsiString`'s + // own view accessor for exactly this (see this file's other reads). + html += "

" + + ::bookmarks::import::escapeHtml(rec.title.Value().str()) + "\n"; + } + html += "

\n"; + return ExportBookmarksResult{.html = std::move(html)}; +} + +} // namespace bookmarks diff --git a/examples/bookmarks/src/models/shared_feed_model.cpp b/examples/bookmarks/src/models/shared_feed_model.cpp new file mode 100644 index 00000000..2ad3068a --- /dev/null +++ b/examples/bookmarks/src/models/shared_feed_model.cpp @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/models/shared_feed_model.hpp" + +#include "bookmarks/db/bookmark_entity.hpp" +#include "bookmarks/db/bookmark_tag_entity.hpp" +#include "bookmarks/db/tag_entity.hpp" + +#include "clock.hpp" + +#include +#include + +#include + +#include +#include +#include +#include + +namespace bookmarks { + +namespace { + +[[nodiscard]] ::morph::time::Timestamp fromEpochMs(std::int64_t epochMs) noexcept { + return ::morph::time::Timestamp{::morph::time::DateTime{ + std::chrono::sys_time{std::chrono::milliseconds{epochMs}}}}; +} + +/// @brief Requires *some* authenticated principal, but never filters on it +/// — this model's whole point is a cross-principal read. See this +/// task's own doc comment for why the check still exists. +void requireAnyPrincipal() { + const auto* ctx = ::morph::session::current(); + if (ctx == nullptr || ctx->principal.empty()) { + throw Forbidden{"no authenticated principal"}; + } +} + +} // namespace + +ListSharedFeedResult SharedFeedModel::execute(const ListSharedFeed& action) { + requireAnyPrincipal(); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto query = mapper->Query(); + (void) query.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::isShared>, "=", true); + (void) query.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::isArchived>, "=", false); + if (action.cursor.hasValue()) { + (void) query.Where(::Lightweight::FieldNameOf<&db::BookmarkRecord::id>, "<", + static_cast(*action.cursor)); + } + constexpr std::size_t kPageSize = 20; + auto rows = query.OrderBy(::Lightweight::FieldNameOf<&db::BookmarkRecord::id>, ::Lightweight::SqlResultOrdering::DESCENDING) + .First(kPageSize + 1); + const bool hasMore = rows.size() > kPageSize; + if (hasMore) { + rows.resize(kPageSize); + } + + // Batched, not per-row: one query for every page bookmark's junction + // rows, one for every referenced tag's name, grouped in-memory below -- + // instead of a junction query plus one tag query *per junction row* + // (N+1+M), this page's tag names cost exactly 2 queries regardless of + // how many bookmarks or tags-per-bookmark it holds. + std::vector pageIds; + pageIds.reserve(rows.size()); + for (const auto& rec : rows) { + pageIds.push_back(rec.id.Value()); + } + auto junctionRows = mapper->Query() + .WhereIn(::Lightweight::FieldNameOf<&db::BookmarkTagRecord::bookmark>, pageIds) + .All(); + + std::vector tagIds; + tagIds.reserve(junctionRows.size()); + for (const auto& jrow : junctionRows) { + tagIds.push_back(jrow.tag.Value()); + } + auto tagRows = mapper->Query().WhereIn(::Lightweight::FieldNameOf<&db::TagRecord::id>, tagIds).All(); + + std::unordered_map tagNameById; + tagNameById.reserve(tagRows.size()); + for (const auto& tagRow : tagRows) { + tagNameById.emplace(tagRow.id.Value(), std::string{tagRow.name.Value()}); + } + + std::unordered_map> tagsByBookmarkId; + tagsByBookmarkId.reserve(pageIds.size()); + for (const auto& jrow : junctionRows) { + if (const auto it = tagNameById.find(jrow.tag.Value()); it != tagNameById.end()) { + tagsByBookmarkId[jrow.bookmark.Value()].push_back(it->second); + } + } + + ListSharedFeedResult result; + for (const auto& rec : rows) { + BookmarkSummary summary; + summary.id = BookmarkId{static_cast(rec.id.Value())}; + summary.url = std::string{rec.url.Value()}; + summary.title = std::string{rec.title.Value()}; + summary.tags = std::move(tagsByBookmarkId[rec.id.Value()]); + summary.createdAt = fromEpochMs(rec.createdAtMs.Value()); + summary.updatedAt = fromEpochMs(rec.updatedAtMs.Value()); + summary.readState = rec.isUnread.Value() ? ReadState::Unread : ReadState::Read; + summary.archiveState = ArchiveState::Active; // the query already excludes archived rows + summary.visibility = Visibility::Shared; // the query already excludes non-shared rows + result.bookmarks.push_back(std::move(summary)); + } + if (hasMore) { + // Gated on `hasMore` alone, matching `BookmarkModel::execute(const + // ListBookmarks&)` — see that call site's comment for the argument. + // The extra `!result.bookmarks.empty()` conjunct this used to carry + // is redundant here (this loop filters nothing, so `hasMore` already + // implies a non-empty page) but it is the exact predicate + // shape that *was* a real bug in the sibling model, and two sibling + // paginators disagreeing invites re-introducing it. + result.nextCursor = Cursor{static_cast(rows.back().id.Value())}; + } + return result; +} + +} // namespace bookmarks diff --git a/examples/bookmarks/src/models/tag_model.cpp b/examples/bookmarks/src/models/tag_model.cpp new file mode 100644 index 00000000..f6809cb9 --- /dev/null +++ b/examples/bookmarks/src/models/tag_model.cpp @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/models/tag_model.hpp" + +#include "bookmarks/auth/bookmarks_authorizer.hpp" +#include "bookmarks/db/bookmark_tag_entity.hpp" +#include "bookmarks/db/outbox_entity.hpp" +#include "bookmarks/db/tag_entity.hpp" + +#include "clock.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace bookmarks { + +// See `bookmark_model.cpp`'s identical static_asserts for the full +// rationale this mirrors -- pinning `TagRecord`'s column capacities to the +// same DTO-level constants that already gate `RenameTag`/`MergeTags` +// input. +static_assert(decltype(db::TagRecord::ownerPrincipal)::ValueType{}.capacity() == auth::kMaxPrincipalBytes, + "bookmarks::auth::kMaxPrincipalBytes must equal TagRecord::ownerPrincipal's SqlAnsiString capacity " + "-- otherwise a principal the authorizer accepts could be silently truncated on the way into the " + "row."); +static_assert(decltype(db::TagRecord::name)::ValueType{}.capacity() == kMaxTagNameBytes, + "bookmarks::kMaxTagNameBytes must equal TagRecord::name's SqlAnsiString capacity -- otherwise " + "RenameTag either rejects names that would have fit, or accepts ones that get silently truncated " + "on the way into the row."); + +namespace { + +[[nodiscard]] std::int64_t nowMs() noexcept { + return (*::morph::ladder::now().value).value.time_since_epoch().count(); +} + +/// @brief Process-wide monotonic counter, used only to disambiguate +/// `MergeTags`'s server-generated idempotency key (see its call +/// site) when two calls land in the same `nowMs()` millisecond -- +/// `morph::ladder::now()` has millisecond resolution, so the +/// timestamp alone cannot be trusted to be unique across rapid +/// back-to-back calls from the same principal. Mirrors +/// `BookmarkModel`'s own `nextOutboxSeq()` +/// (`bookmark_model.cpp`) -- duplicated rather than shared across +/// translation units, this rung's established convention for small +/// internal details (see this task's own header comment). +/// `std::atomic` (not `thread_local`) because the model instance is +/// shared across whichever thread each dispatched call lands on. +[[nodiscard]] std::uint64_t nextOutboxSeq() noexcept { + static std::atomic counter{0}; + return counter.fetch_add(1, std::memory_order_relaxed); +} + +/// @brief The authenticated caller's principal, or throws `Forbidden`. See +/// `BookmarkModel`'s identical helper (`bookmark_model.cpp`) for the +/// full rationale this mirrors. +[[nodiscard]] const std::string& requireOwner() { + const auto* ctx = ::morph::session::current(); + if (ctx == nullptr || ctx->principal.empty()) { + throw Forbidden{"no authenticated principal"}; + } + return ctx->principal; +} + +/// @brief Loads tag @p id, requiring it to exist and be owned by @p owner. +/// @throws NotFound if no such row exists at all. +/// @throws Forbidden if it exists but belongs to a different principal. +[[nodiscard]] db::TagRecord loadOwnedTag(::Lightweight::DataMapper& mapper, std::uint64_t id, const std::string& owner) { + auto rows = mapper.Query().Where(::Lightweight::FieldNameOf<&db::TagRecord::id>, "=", id).All(); + if (rows.empty()) { + throw NotFound{"no such tag"}; + } + if (rows.front().ownerPrincipal.Value() != owner) { + throw Forbidden{"tag belongs to a different principal"}; + } + return rows.front(); +} + +} // namespace + +Ack TagModel::execute(const RenameTag& action) { + if (!action.validate()) { + throw ValidationError{"RenameTag: id and a non-empty, bounded name are required"}; + } + const auto& owner = requireOwner(); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto rec = loadOwnedTag(mapper.Get(), static_cast(*action.id), owner); + rec.name = action.name; + try { + mapper->Update(rec); + } catch (const ::Lightweight::SqlException& error) { + if (::Lightweight::IsUniqueConstraintViolation(error.info(), mapper->Connection().ServerType())) { + throw Conflict{"RenameTag: a tag named '" + action.name + "' already exists"}; + } + throw; + } + return Ack{}; +} + +Ack TagModel::execute(const MergeTags& action) { + if (!action.validate()) { + throw ValidationError{"MergeTags: sourceId and a distinct targetId are required"}; + } + const auto& owner = requireOwner(); + const auto sourceId = static_cast(*action.sourceId); + const auto targetId = static_cast(*action.targetId); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + (void) loadOwnedTag(mapper.Get(), sourceId, owner); + (void) loadOwnedTag(mapper.Get(), targetId, owner); + + ::Lightweight::SqlTransaction transaction{mapper->Connection(), ::Lightweight::SqlTransactionMode::ROLLBACK}; + + auto sourceRows = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::BookmarkTagRecord::tag>, "=", sourceId) + .All(); + for (const auto& row : sourceRows) { + const auto bookmarkId = row.bookmark.Value(); + auto clash = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::BookmarkTagRecord::bookmark>, "=", bookmarkId) + .Where(::Lightweight::FieldNameOf<&db::BookmarkTagRecord::tag>, "=", targetId) + .All(); + // Either way the source association must go -- delete it outright + // rather than `mapper->Update()`-ing its `tag` field in place: + // `BelongsTo::operator=(ValueType)` goes through the implicit + // converting constructor + copy-assignment, which never sets the + // field's `_modified` flag (only `operator=(ReferencedRecord&)` + // does), so `Update()` would silently skip writing the column -- + // this is exactly why `bookmark_tag_entity.hpp`'s own doc comment + // says tag (re)assignment is always a Create/delete of a whole row, + // never an in-place Update. + { + ::Lightweight::SqlStatement stmt{mapper->Connection()}; + stmt.Prepare("DELETE FROM bookmark_tags WHERE bookmark_id = ? AND tag_id = ?"); + (void) stmt.Execute(bookmarkId, sourceId); + } + if (clash.empty()) { + // No existing target association for this bookmark -- recreate + // the row pointing at targetId instead of sourceId. When a + // clash does exist, the target association already covers this + // bookmark, so nothing further is needed (this is the + // dedup case the unique index on (bookmark_id, tag_id) exists + // to protect). + db::BookmarkTagRecord junction; + junction.bookmark = bookmarkId; + junction.tag = targetId; + mapper->Create(junction); + } + } + { + ::Lightweight::SqlStatement stmt{mapper->Connection()}; + stmt.Prepare("DELETE FROM tags WHERE id = ?"); + (void) stmt.Execute(sourceId); + } + + Ack result{}; + db::BookmarkOutboxRecord entry; + entry.modelType = "TagModel"; + entry.entityKey = owner; + entry.actionType = "MergeTags"; + entry.payload = ::morph::model::ActionTraits::toJson(action); + entry.result = ::morph::model::ActionTraits::resultToJson(result); + entry.principal = owner; + entry.timestampMs = nowMs(); + // idempotencyKey: nowMs() alone is only millisecond resolution, so two + // MergeTags calls from the same owner landing in the same millisecond + // would otherwise produce the identical key and collide against + // `idx_bookmark_outbox_idempotency`'s unique index, spuriously failing + // the second, legitimate call with a raw SQL constraint-violation + // exception instead of succeeding -- the exact bug Task 8's review + // caught in `BookmarkModel::execute(const BulkEdit&)`. `nextOutboxSeq()` + // (a process-wide monotonic counter) makes the key collision-resistant + // regardless of clock resolution. + entry.idempotencyKey = owner + "-mergetags-" + std::to_string(nowMs()) + "-" + std::to_string(nextOutboxSeq()); + mapper->Create(entry); + + transaction.Commit(); + return result; +} + +ListTagsResult TagModel::execute(const ListTags&) { + const auto& owner = requireOwner(); + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + auto rows = + mapper->Query().Where(::Lightweight::FieldNameOf<&db::TagRecord::ownerPrincipal>, "=", owner).All(); + + // Batched, not per-tag: one query for every junction row across all of + // this owner's tags, counted in-memory below -- instead of a `COUNT` + // query *per tag* (N+1, and each one still pulls full rows just to + // discard everything but `.size()`), this owner's whole tag list costs + // exactly 1 extra query regardless of how many tags they have. + std::vector tagIds; + tagIds.reserve(rows.size()); + for (const auto& rec : rows) { + tagIds.push_back(rec.id.Value()); + } + auto junctionRows = mapper->Query() + .WhereIn(::Lightweight::FieldNameOf<&db::BookmarkTagRecord::tag>, tagIds) + .All(); + std::unordered_map countByTagId; + countByTagId.reserve(tagIds.size()); + for (const auto& jrow : junctionRows) { + ++countByTagId[jrow.tag.Value()]; + } + + ListTagsResult result; + for (const auto& rec : rows) { + TagSummary summary; + summary.id = TagId{static_cast(rec.id.Value())}; + // `TagRecord::name` is `Light::SqlAnsiString`; + // `TagSummary::name` stays plain `std::string` on the wire. + summary.name = std::string{rec.name.Value()}; + const auto it = countByTagId.find(rec.id.Value()); + const auto count = it != countByTagId.end() ? it->second : std::uint64_t{0}; + summary.bookmarkCount = Count::fromDouble(static_cast(count)); + result.tags.push_back(std::move(summary)); + } + return result; +} + +} // namespace bookmarks diff --git a/examples/bookmarks/src/server/main.cpp b/examples/bookmarks/src/server/main.cpp new file mode 100644 index 00000000..d18c5cae --- /dev/null +++ b/examples/bookmarks/src/server/main.cpp @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// bookmarks' standalone server process: `bookmarks::db::setup()` once, one +/// `bookmarks::app::App` (worker pool + `RemoteServer` with a real +/// `BookmarksAuthorizer` + durable action log + the process-global +/// `TokenIssuer` + the metadata-fetch worker + the outbox relay), and one +/// `morph::qt::QtWebSocketServer` in front of it. The desktop client +/// (`examples/bookmarks/gui/`) talks to this over `ws://127.0.0.1:`; +/// nothing here knows anything about bookmarks at all — `app.cpp` includes +/// every model header deliberately so a `main()` that names only `App` still +/// links and serves all four models. +/// +/// Usage: +/// @code +/// BOOKMARKS_TOKEN_SECRET=... BOOKMARKS_DB=... BOOKMARKS_PORT=8766 \ +/// ladder_bookmarks_server +/// @endcode +/// +/// @par No `--seed`, and why +/// `pastebin`'s server ships one; this one does not, deliberately. Every +/// action in this rung is scoped to `session::current()->principal`, so +/// seeding by calling a model directly — the shape rung 1 used — would have +/// to install a thread-local session itself, i.e. reach into +/// `morph::session::detail::ScopedContext`, a `detail::` namespace with no +/// public seam for this — exactly the class of reach-in +/// `examples/common/testkit` migrated away from onto public seams +/// (`Completion::makeSettleable()`, `BridgeHandler::whenBound()`, the +/// `QtWebSocketBackend(url, tls, cfg)` overload) once #55's public seams +/// existed; adding a new one here from an *example* would be a step +/// backward, not forward. The alternative — an internal client with a +/// minted service token, the shape `App`'s own metadata worker uses — is +/// real infrastructure that `LADDER.md` already assigns to rung 4's +/// `action_driver` generators. Demo data is therefore created through the +/// client, which also exercises the path a user actually takes. + +#include "bookmarks/app/app.hpp" +#include "bookmarks/db/database.hpp" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// `unsetenv` is POSIX, not . This server target is only built for +// desktop platforms (morph_add_rung() does not emit it for WASM), all of +// which provide it. +#if __has_include() +#include +#endif + +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()`. This exists so the shutdown path below is actually +/// *reachable*: a demo server is stopped with Ctrl-C, and the default `SIGINT` +/// disposition would terminate the process outright, so `exec()` would never +/// return and `App`'s destructor would never run at all. Identical in shape to +/// `pastebin`'s own server main. +volatile std::sig_atomic_t gStopRequested = 0; + +extern "C" void onStopSignal(int /*signum*/) { gStopRequested = 1; } + +/// @brief Pumps the Qt event loop until no metadata-fetch dispatch is +/// outstanding. +/// +/// `bookmarks::app::App::fetchInFlight()` is observe-only and its header +/// states the contract explicitly — "pump on this until it is `false`, then +/// destroy" — because `~App` does *not* wait for the `RecordMetadata` calls a +/// pass dispatched to settle before destroying the bridge they complete +/// against. This task's brief said no drain step was needed here, on the +/// grounds that `fetchInFlight()` is a test-only concern; that is not what the +/// header says, and it is not true of a *server*: the fetch timer fires every +/// five seconds by default, so a `SIGTERM` landing mid-pass is an ordinary +/// event, not an exotic one. The drain is therefore kept, exactly as +/// `pastebin::app::App::sweepInFlight()`'s consumer keeps its own. Bounded by +/// @p budget so a wedged dispatch cannot hang shutdown forever; overrunning it +/// is strictly better than not draining at all, and is reported. +/// +/// The outbox relay needs no equivalent: `relayOutboxOnce()` is synchronous — +/// it touches the database and the log directly rather than dispatching +/// through the server — so there is never anything of its own in flight. +/// +/// @pre `app.stopBackgroundJobs()` has already been called. This loop's own +/// `processEvents()` is what delivers @p app's fetch-timer ticks, so with the +/// timer still armed the drain would race the very thing it is draining — see +/// `App::stopBackgroundJobs()`'s doc comment for the full sequence. +/// +/// @param app The app whose metadata dispatches must settle. +/// @param budget Maximum time to wait. +/// @return `true` if everything settled within @p budget. +[[nodiscard]] bool drainMetadataFetches(const bookmarks::app::App& app, std::chrono::milliseconds budget) { + const auto deadline = std::chrono::steady_clock::now() + budget; + while (app.fetchInFlight()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + } + return true; +} + +} // namespace + +int main(int argc, char** argv) { + QCoreApplication qtApp{argc, argv}; + + for (int i = 1; i < argc; ++i) { + std::cerr << "bookmarks-server: unknown argument '" << argv[i] + << "' (usage: BOOKMARKS_TOKEN_SECRET=... ladder_bookmarks_server)\n"; + return 2; + } + + // Required, with no default: the secret signs every token this server + // mints and verifies every token it is shown, so a built-in fallback + // would be a published signing key. Refusing to start is the only honest + // behavior (`docs/spec/security.md`). + const char* tokenSecretEnv = std::getenv("BOOKMARKS_TOKEN_SECRET"); + if (tokenSecretEnv == nullptr || *tokenSecretEnv == '\0') { + std::cerr << "bookmarks-server: BOOKMARKS_TOKEN_SECRET must be set to a non-empty value\n"; + return 2; + } + const std::string tokenSecret{tokenSecretEnv}; + // Cleared from the environment the moment it has been copied. The + // environment block is readable for the process's whole lifetime — by + // anything that later calls `getenv`, by a crash dump, and on some + // platforms by other processes — and the secret has no business being + // there once this process holds it. `App` receives it by value, so + // nothing below reads the variable again. Guarded by the same + // `__has_include` check as the `` include above: on a + // hypothetical desktop platform without it, this degrades to leaving + // the variable set rather than failing to compile. +#if __has_include() + static_cast(::unsetenv("BOOKMARKS_TOKEN_SECRET")); +#endif + + const char* connectionString = std::getenv("BOOKMARKS_DB"); + bookmarks::db::setup(connectionString != nullptr ? connectionString + : "DRIVER=SQLite3;Database=bookmarks.db;Timeout=5000"); + + // `std::from_chars`, not `std::atoi`: `atoi` has no error channel at all, + // so `BOOKMARKS_PORT=abc` would silently bind port 0 (a kernel-assigned + // ephemeral port — the server comes up on an address no client was told + // about) and `BOOKMARKS_PORT=99999` would silently wrap to 34463 on the + // cast to `quint16`. Both are worse than not starting: an operator who + // mistyped the port gets a server that *looks* healthy. Failing loudly + // matches how BOOKMARKS_TOKEN_SECRET above already treats a bad value. + // Parsed before `App` is constructed so a bad value costs nothing. + quint16 port = 8766; + if (const char* portEnv = std::getenv("BOOKMARKS_PORT"); portEnv != nullptr) { + const std::string_view text{portEnv}; + std::uint16_t parsed = 0; + const auto [end, ec] = std::from_chars(text.data(), text.data() + text.size(), parsed); + if (ec != std::errc{} || end != text.data() + text.size()) { + std::cerr << "bookmarks-server: BOOKMARKS_PORT='" << portEnv + << "' is not a valid port number (0-65535)\n"; + return 2; + } + port = parsed; + } + + int exitCode = 0; + { + bookmarks::app::App app{std::filesystem::current_path() / "bookmarks_actions.jsonl", tokenSecret}; + + ::morph::qt::QtWebSocketServer wsServer{*app.server(), port}; + if (!wsServer.listen()) { + std::cerr << "bookmarks-server: failed to listen on port " << port << "\n"; + return 1; + } + std::cout << "bookmarks-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(); + + // First, before anything below spins the event loop again: disarm the + // periodic timers. Both `closeGracefully` and `drainMetadataFetches` + // pump events, and a fetch tick delivered by one of *their* + // `processEvents()` calls would start a whole new `RecordMetadata` + // pass — re-raising `fetchInFlight()` after the drain had watched it + // settle, and potentially leaving a dispatch outstanding when the + // drain's budget expires and `app` is destroyed anyway. With the timer + // stopped the drain is monotonic: the outstanding set only shrinks. + app.stopBackgroundJobs(); + + // Order matters: let connected clients' in-flight executes reply and + // close cleanly first, *then* drain the metadata worker's own + // dispatches (see drainMetadataFetches) before `app` leaves this + // scope. + static_cast(wsServer.closeGracefully(std::chrono::seconds{2})); + if (!drainMetadataFetches(app, std::chrono::seconds{5})) { + std::cerr << "bookmarks-server: metadata-fetch dispatches did not settle within 5s; " + "shutting down anyway\n"; + } + } + + std::cout << "bookmarks-server: stopped\n"; + return exitCode; +} diff --git a/examples/bookmarks/tests/test_app.cpp b/examples/bookmarks/tests/test_app.cpp new file mode 100644 index 00000000..732a73e5 --- /dev/null +++ b/examples/bookmarks/tests/test_app.cpp @@ -0,0 +1,464 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/app/app.hpp" + +#include "bookmarks/auth/bookmarks_authorizer.hpp" +#include "bookmarks/db/outbox_entity.hpp" +#include "bookmarks/models/auth_model.hpp" +#include "bookmarks/models/bookmark_model.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::pumpUntil; + +namespace { + +/// @brief A `Context` carrying only @p principal. +/// +/// Built field-by-field rather than with a designated initializer on +/// purpose: `-Weverything` includes +/// `-Wmissing-designated-field-initializers`, which fires on a partial +/// designated-initializer list, and `ladder__tests` is built with +/// `apply_warnings()` (so `-Werror` under `MORPH_ENABLE_STRICT_COMPILATION`, +/// CI's default). Same reason `makeCreate` below exists. +[[nodiscard]] morph::session::Context contextFor(std::string principal) { + morph::session::Context ctx; + ctx.principal = std::move(principal); + return ctx; +} + +class ScopedPrincipal { + public: + explicit ScopedPrincipal(std::string principal) : _ctx{contextFor(std::move(principal))}, _scope{_ctx} {} + + private: + morph::session::Context _ctx; + morph::session::detail::ScopedContext _scope; +}; + +/// @brief A `CreateBookmark` for @p url, optionally pre-titled. See +/// `contextFor` for why this is not a designated initializer. +[[nodiscard]] bookmarks::CreateBookmark makeCreate(std::string url, std::string title = {}) { + bookmarks::CreateBookmark action; + action.url = std::move(url); + action.title = std::move(title); + return action; +} + +/// @brief Deterministic stand-in for a real fetcher: derives the "fetched" +/// title from the url, so a test can assert the exact value that came +/// back through the whole dispatch path. +class StubFetcher : public bookmarks::app::IBookmarkMetadataFetcher { + public: + bookmarks::app::FetchedMetadata fetch(const std::string& url) override { + return {.title = "Fetched: " + url, .faviconPath = ""}; + } +}; + +/// @brief A fresh, empty action-log path per test. +/// +/// `FileActionLog` appends and rebuilds its idempotency-dedup set from +/// whatever is already on disk, so a leftover file from an earlier test would +/// silently suppress a re-relayed row. Deleted before use and after, matching +/// `examples/pastebin/tests/test_paste_model.cpp`'s own App-test convention. +[[nodiscard]] std::filesystem::path freshLogPath(const std::string& name) { + auto path = std::filesystem::temp_directory_path() / ("bookmarks_" + name + ".jsonl"); + std::filesystem::remove(path); + return path; +} + +constexpr std::chrono::hours kTimersOff{1}; + +/// @brief A fetch interval short enough that a handful of pumped event-loop +/// slices are certain to contain several ticks of it. +/// +/// Only the two `stopBackgroundJobs()` cases use it; every other case keeps +/// `kTimersOff` and drives passes by hand. The pair is deliberately +/// asymmetric: the *control* case waits for a tick to arrive (bounded by +/// `pumpUntil`'s own generous, `MORPH_LADDER_DEADLINE_MS`-scaled deadline, so +/// a slow runner cannot fail it), while the case under test waits for one that +/// must never arrive — the only place a fixed budget appears, and a +/// deliberately long one. +constexpr std::chrono::milliseconds kFastFetchInterval{20}; + +/// @brief Records every url it was asked about, so a test can assert a pass +/// ran — or, more to the point below, that none did. +class RecordingFetcher : public bookmarks::app::IBookmarkMetadataFetcher { + public: + bookmarks::app::FetchedMetadata fetch(const std::string& url) override { + calls.push_back(url); + return {.title = "Recorded", .faviconPath = ""}; + } + std::vector calls; +}; + +} // namespace + +TEST_CASE("App::fetchMetadataOnce records a fetched title for an empty-title bookmark", + "[bookmarks][app]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + bookmarks::BookmarkId id; + { + const ScopedPrincipal alice{"alice"}; + id = model.execute(makeCreate("https://one.example")).id; // no title + } + + const auto logPath = freshLogPath("fetch"); + { + // Hour-long intervals effectively disable both timers; the pass is + // driven directly instead, so nothing here depends on wall-clock + // timing. `App` reaches the same database this test does because both + // go through Lightweight's process-global default connection string, + // which `DbFixture` (constructed above, before `App`) already set. + bookmarks::app::App app{logPath, "test-secret", std::make_shared(), kTimersOff, kTimersOff}; + app.fetchMetadataOnce(); + REQUIRE(pumpUntil([&app] { return !app.fetchInFlight(); })); + + const ScopedPrincipal alice{"alice"}; + CHECK(model.execute(bookmarks::GetBookmark{.id = id}).title == "Fetched: https://one.example"); + } + std::filesystem::remove(logPath); +} + +TEST_CASE("App::fetchMetadataOnce leaves an already-titled bookmark untouched", "[bookmarks][app]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + bookmarks::BookmarkId titled; + { + const ScopedPrincipal alice{"alice"}; + titled = model.execute(makeCreate("https://one.example", "Already Set")).id; + } + + const auto logPath = freshLogPath("fetch_titled"); + { + bookmarks::app::App app{logPath, "test-secret", std::make_shared(), kTimersOff, kTimersOff}; + app.fetchMetadataOnce(); + REQUIRE(pumpUntil([&app] { return !app.fetchInFlight(); })); + + const ScopedPrincipal alice{"alice"}; + CHECK(model.execute(bookmarks::GetBookmark{.id = titled}).title == "Already Set"); + } + std::filesystem::remove(logPath); +} + +TEST_CASE("App::fetchMetadataOnce updates a bookmark owned by someone else entirely", + "[bookmarks][app]") { + // The property the service principal exists for: the worker acts on + // behalf of every owner, and is itself the owner of none of them. + DbFixture fixture; + bookmarks::BookmarkModel model; + bookmarks::BookmarkId aliceId; + bookmarks::BookmarkId bobId; + { + const ScopedPrincipal alice{"alice"}; + aliceId = model.execute(makeCreate("https://alice.example")).id; + } + { + const ScopedPrincipal bob{"bob"}; + bobId = model.execute(makeCreate("https://bob.example")).id; + } + + const auto logPath = freshLogPath("fetch_multi"); + { + bookmarks::app::App app{logPath, "test-secret", std::make_shared(), kTimersOff, kTimersOff}; + app.fetchMetadataOnce(); + REQUIRE(pumpUntil([&app] { return !app.fetchInFlight(); })); + + { + const ScopedPrincipal alice{"alice"}; + CHECK(model.execute(bookmarks::GetBookmark{.id = aliceId}).title == "Fetched: https://alice.example"); + } + const ScopedPrincipal bob{"bob"}; + CHECK(model.execute(bookmarks::GetBookmark{.id = bobId}).title == "Fetched: https://bob.example"); + } + std::filesystem::remove(logPath); +} + +TEST_CASE("App::fetchMetadataOnce with the shipped NullMetadataFetcher dispatches nothing", + "[bookmarks][app]") { + // A fetch that found nothing must not turn into a write: RecordMetadata + // ignores empty fields but still stamps updated_at_ms, which every + // client's GetChangesSince poll would then see churn on every tick. + DbFixture fixture; + bookmarks::BookmarkModel model; + bookmarks::BookmarkId id; + { + const ScopedPrincipal alice{"alice"}; + id = model.execute(makeCreate("https://one.example")).id; + } + const ScopedPrincipal alice{"alice"}; + const auto before = model.execute(bookmarks::GetBookmark{.id = id}); + + const auto logPath = freshLogPath("fetch_null"); + { + bookmarks::app::App app{logPath, "test-secret", std::make_shared(), + kTimersOff, kTimersOff}; + app.fetchMetadataOnce(); + CHECK_FALSE(app.fetchInFlight()); + const auto after = model.execute(bookmarks::GetBookmark{.id = id}); + CHECK(after.title.empty()); + CHECK(after.updatedAt == before.updatedAt); + } + std::filesystem::remove(logPath); +} + +TEST_CASE("App::relayOutboxOnce drains a BulkEdit outbox row into the durable action log", + "[bookmarks][app]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + const auto id = model.execute(makeCreate("https://one.example")).id; + bookmarks::BulkEdit edit; + edit.ids = {id}; + edit.archive = bookmarks::BulkArchiveOp::Archive; + model.execute(edit); + + Lightweight::DataMapper mapper; + REQUIRE(mapper.Query().All().size() == 1); + + const auto logPath = freshLogPath("relay"); + { + bookmarks::app::App app{logPath, "test-secret", std::make_shared(), + kTimersOff, kTimersOff}; + CHECK(app.relayOutboxOnce() == 1); + CHECK(mapper.Query().All().empty()); + + // A second pass has nothing left to move -- the row was deleted, not + // flagged. + CHECK(app.relayOutboxOnce() == 0); + } + + // The entry really reached the durable sink, not just "left the outbox". + // Scoped so `reopened`'s file handle is closed before the remove() below + // -- unlike POSIX, Windows refuses to delete a file a live handle still + // has open. + std::vector entries; + { + const morph::journal::FileActionLog reopened{logPath}; + entries = reopened.entries(); + } + REQUIRE(entries.size() == 1); + CHECK(entries[0].modelType == "BookmarkModel"); + CHECK(entries[0].actionType == "BulkEdit"); + CHECK(entries[0].principal == "alice"); + CHECK_FALSE(entries[0].idempotencyKey.empty()); + std::filesystem::remove(logPath); +} + +TEST_CASE("AuthModel::execute(Login) mints a token that verifies against the same App's authorizer", + "[bookmarks][app]") { + const auto logPath = freshLogPath("login"); + { + const bookmarks::app::App app{logPath, "login-test-secret"}; + bookmarks::AuthModel authModel; + const auto result = authModel.execute(bookmarks::Login{.username = "alice"}); + REQUIRE(result.token.hasValue()); + CHECK(result.principal == "alice"); + + // Verified against a *separately constructed* authorizer holding the + // same secret -- exactly what the App's own RemoteServer installed. + const bookmarks::auth::BookmarksAuthorizer authz{std::string{"login-test-secret"}, + morph::session::hmacSha256}; + morph::session::Context ctx; + ctx.token = *result.token; + const auto principal = authz.authenticate(ctx); + REQUIRE(principal.has_value()); + CHECK(*principal == "alice"); + CHECK(authz.authorize(ctx, "BookmarkModel", "CreateBookmark")); + + // ...and does not verify against a different secret. + const bookmarks::auth::BookmarksAuthorizer other{std::string{"a-different-secret"}, + morph::session::hmacSha256}; + CHECK_FALSE(other.authenticate(ctx).has_value()); + } + std::filesystem::remove(logPath); +} + +TEST_CASE("AuthModel::execute(Login) refuses to mint a token in the reserved system: namespace", + "[bookmarks][app]") { + // Otherwise any client could log in as the metadata worker and rewrite + // every other user's titles through RecordMetadata. + const auto logPath = freshLogPath("login_reserved"); + { + const bookmarks::app::App app{logPath, "login-test-secret"}; + bookmarks::AuthModel authModel; + REQUIRE_THROWS_AS( + authModel.execute(bookmarks::Login{.username = std::string{bookmarks::auth::kMetadataFetcherPrincipal}}), + bookmarks::ValidationError); + REQUIRE_THROWS_AS(authModel.execute(bookmarks::Login{.username = "system:anything"}), + bookmarks::ValidationError); + } + std::filesystem::remove(logPath); +} + +TEST_CASE("AuthModel::execute(Login) throws when no App has installed a TokenIssuer", + "[bookmarks][app]") { + // Every other [bookmarks][app] case constructs its App as a scoped local, + // and ~App clears the global issuer, so this case sees a clean nullptr + // regardless of Catch2's run order. + REQUIRE(bookmarks::auth::tokenIssuer() == nullptr); + bookmarks::AuthModel authModel; + REQUIRE_THROWS_AS(authModel.execute(bookmarks::Login{.username = "alice"}), bookmarks::ValidationError); +} + +TEST_CASE("Login rejects an invalid username via the shared principal charset", "[bookmarks][app]") { + bookmarks::AuthModel authModel; + REQUIRE_THROWS_AS(authModel.execute(bookmarks::Login{.username = ""}), bookmarks::ValidationError); + REQUIRE_THROWS_AS(authModel.execute(bookmarks::Login{.username = "alice bob"}), bookmarks::ValidationError); + CHECK_FALSE(bookmarks::Login{.username = std::string(65, 'a')}.validate()); + CHECK(bookmarks::Login{.username = "alice"}.validate()); +} + +TEST_CASE("App's metadata-fetch worker dispatches through the real RemoteServer, not a shortcut", + "[bookmarks][app]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + bookmarks::BookmarkId id; + { + const ScopedPrincipal alice{"alice"}; + id = model.execute(makeCreate("https://one.example")).id; + } + + auto fetcher = std::make_shared(); + + const auto logPath = freshLogPath("worker_dispatch"); + { + bookmarks::app::App app{logPath, "test-secret", fetcher, kTimersOff, kTimersOff}; + // Proves the dispatch went through the server's own registration path + // (which requires authorizeRegister to pass -- an unauthenticated + // internal client would fail here exactly like a real socket client + // would): if the worker's own token/session wiring were broken, the + // dispatched RecordMetadata would fail authorization/authentication + // (the completion's onError path, logged but not surfaced to this + // test directly) and fetchInFlight() would still settle to false, but + // the title would never update -- which the assertion below catches. + // RecordingFetcher::calls only proves fetchMetadataOnce() found the + // untitled bookmark and called the injected fetcher in-process; it is + // the GetBookmark title assertion afterward that can only pass if the + // resulting RecordMetadata genuinely round-tripped through + // RemoteServer::handle() -- BookmarkModel::execute(const + // RecordMetadata&) is the only thing that ever writes that column. + app.fetchMetadataOnce(); + REQUIRE(pumpUntil([&app] { return !app.fetchInFlight(); })); + REQUIRE(fetcher->calls.size() == 1); + CHECK(fetcher->calls.front() == "https://one.example"); + + const ScopedPrincipal alice{"alice"}; + CHECK(model.execute(bookmarks::GetBookmark{.id = id}).title == "Recorded"); + } + std::filesystem::remove(logPath); +} + +// ═════════════════════════════════════════════════════════════════════════ +// stopBackgroundJobs(): the shutdown precondition the server's drain needs +// ═════════════════════════════════════════════════════════════════════════ +// +// `src/server/main.cpp`'s `drainMetadataFetches()` pumps `processEvents()` +// until `fetchInFlight()` settles — and pumping is exactly what delivers +// `_fetchTimer`'s ticks. With the timer still armed, the drain's own +// `processEvents()` can start a brand-new pass, re-raising `fetchInFlight()` +// after it had settled and, if that pass is still outstanding when the budget +// expires, leaving `~App` to run with a dispatch in flight — the very window +// the drain exists to close. The server therefore calls +// `App::stopBackgroundJobs()` before draining. The two cases below are a +// matched pair: the control proves the timer really does fire under a pumping +// loop (so the case under test is not vacuously green), and the case under +// test proves `stopBackgroundJobs()` genuinely disarms it. + +TEST_CASE("App's fetch timer really does fire under a pumping loop (the control for stopBackgroundJobs)", + "[bookmarks][app]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + { + const ScopedPrincipal alice{"alice"}; + static_cast(model.execute(makeCreate("https://timer.example")).id); // untitled: a pass has work to do + } + auto fetcher = std::make_shared(); + + const auto logPath = freshLogPath("timer_control"); + { + bookmarks::app::App app{logPath, "test-secret", fetcher, kFastFetchInterval, kTimersOff}; + // Nothing is dispatched by hand here: the *timer* is the subject. + REQUIRE(pumpUntil([&fetcher] { return !fetcher->calls.empty(); })); + REQUIRE(pumpUntil([&app] { return !app.fetchInFlight(); })); + } + std::filesystem::remove(logPath); +} + +TEST_CASE("App::stopBackgroundJobs disarms the fetch timer, so a drain loop cannot provoke a new pass", + "[bookmarks][app]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + bookmarks::BookmarkId id; + { + const ScopedPrincipal alice{"alice"}; + id = model.execute(makeCreate("https://timer.example")).id; // untitled, exactly as above + } + auto fetcher = std::make_shared(); + + const auto logPath = freshLogPath("timer_stopped"); + { + bookmarks::app::App app{logPath, "test-secret", fetcher, kFastFetchInterval, kTimersOff}; + // No event loop has turned between the constructor's `start()` and + // this call, so the timer has had no chance to tick yet — the state + // `main()` is *not* in when it calls this (it calls it after `exec()` + // returns), but the strictly harder one to keep quiet. + app.stopBackgroundJobs(); + + // The drain window, simulated: pump for far longer than the interval. + // The predicate must never become true, so a `true` here means a tick + // got through and `pumpUntil` returning `false` is the passing outcome + // — the one place in this suite where a timeout is the assertion. + CHECK_FALSE(pumpUntil([&fetcher] { return !fetcher->calls.empty(); }, std::chrono::milliseconds{500})); + CHECK(fetcher->calls.empty()); + CHECK_FALSE(app.fetchInFlight()); + + // ...and nothing was written, which is what a spurious pass would + // have left behind (`RecordMetadata` sets the title and stamps + // `updated_at_ms`). + const ScopedPrincipal alice{"alice"}; + CHECK(model.execute(bookmarks::GetBookmark{.id = id}).title.empty()); + } + std::filesystem::remove(logPath); +} + +TEST_CASE("App::stopBackgroundJobs is idempotent, and ~App still stops the timers on its own", + "[bookmarks][app]") { + // The refactor's two invariants: calling it twice is harmless (QTimer::stop + // on a stopped timer is a no-op), and an owner that never calls it at all + // — every test above, and any other consumer — still gets the destructor's + // original stop-first behaviour, because ~App now calls it too. + DbFixture fixture; + bookmarks::BookmarkModel model; + { + const ScopedPrincipal alice{"alice"}; + static_cast(model.execute(makeCreate("https://timer.example")).id); + } + auto fetcher = std::make_shared(); + + const auto logPath = freshLogPath("timer_idempotent"); + { + bookmarks::app::App app{logPath, "test-secret", fetcher, kFastFetchInterval, kFastFetchInterval}; + app.stopBackgroundJobs(); + app.stopBackgroundJobs(); + CHECK_FALSE(pumpUntil([&fetcher] { return !fetcher->calls.empty(); }, std::chrono::milliseconds{300})); + } + // The App is gone; pumping now must not resurrect a tick from either timer + // (a still-armed QTimer owned by a destroyed App would be a use-after-free, + // not merely a stray call). + CHECK_FALSE(pumpUntil([&fetcher] { return !fetcher->calls.empty(); }, std::chrono::milliseconds{200})); + std::filesystem::remove(logPath); +} diff --git a/examples/bookmarks/tests/test_bookmark_dto.cpp b/examples/bookmarks/tests/test_bookmark_dto.cpp new file mode 100644 index 00000000..a4eca8eb --- /dev/null +++ b/examples/bookmarks/tests/test_bookmark_dto.cpp @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/dto/bookmark_dto.hpp" + +#include + +#include + +#include +#include +#include + +TEST_CASE("CreateBookmark validate() requires a non-empty url within the length bound", + "[bookmarks][dto]") { + bookmarks::CreateBookmark action; + CHECK_FALSE(action.validate()); // empty url + + action.url = "https://example.com"; + CHECK(action.validate()); + + action.url = std::string(bookmarks::kMaxUrlBytes + 1, 'a'); + CHECK_FALSE(action.validate()); + + action.url = std::string(bookmarks::kMaxUrlBytes, 'a'); + CHECK(action.validate()); +} + +TEST_CASE("CreateBookmark's optionalFields excludes everything but url", "[bookmarks][dto]") { + // Mirrors CreatePaste::optionalFields's own test intent: a create with + // only a url must be schema-submittable without hand-typing every + // enum's default. + using bookmarks::CreateBookmark; + using bookmarks::EditBookmark; + // Five: title, description, notes, tags, visibility — everything but url. + // `title` is in the list because a bookmark may legitimately be created + // without one (the metadata worker fills it in); see that member's own + // doc comment for why leaving it out broke the shipped create form. + STATIC_REQUIRE(CreateBookmark::optionalFields.size() == 5); + STATIC_REQUIRE(EditBookmark::optionalFields.size() == 5); + + // A count alone would still pass if `title` were swapped out for some + // other name, which is precisely the regression this guard exists to + // catch: `title` missing from the list is the shipped-GUI bug the + // README's "Two bugs the first real client run found" records. + STATIC_REQUIRE(std::ranges::contains(CreateBookmark::optionalFields, std::string_view{"title"})); + STATIC_REQUIRE(std::ranges::contains(EditBookmark::optionalFields, std::string_view{"title"})); +} + +TEST_CASE("The generated create/edit schemas do not mark title required", "[bookmarks][dto]") { + // The other half of the guard above: `optionalFields` is only meaningful + // through `morph::forms::schemaJson()`'s derived `required` array, + // which is what `DynamicForm` actually reads. Checking the list without + // checking the schema would not have caught the original bug either. + for (const auto& schema : {::morph::forms::schemaJson(), + ::morph::forms::schemaJson()}) { + CAPTURE(schema); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + REQUIRE(dom.contains("required")); + const auto& required = dom["required"].get_array(); + CHECK(std::ranges::none_of(required, [](const auto& entry) { return entry.get_string() == "title"; })); + // `url` is the one member that genuinely is required, so this is a + // check that the schema is populated at all, not vacuously passing. + CHECK(std::ranges::any_of(required, [](const auto& entry) { return entry.get_string() == "url"; })); + } +} + +TEST_CASE("EditBookmark validate() requires an id and a non-empty url", "[bookmarks][dto]") { + bookmarks::EditBookmark action; + CHECK_FALSE(action.validate()); + action.id = bookmarks::BookmarkId{1}; + CHECK_FALSE(action.validate()); // still no url + action.url = "https://example.com"; + CHECK(action.validate()); +} + +TEST_CASE("GetBookmark/ArchiveBookmark/UnarchiveBookmark/DeleteBookmark all require an id", + "[bookmarks][dto]") { + CHECK_FALSE(bookmarks::GetBookmark{}.validate()); + CHECK(bookmarks::GetBookmark{.id = bookmarks::BookmarkId{1}}.validate()); + CHECK_FALSE(bookmarks::ArchiveBookmark{}.validate()); + CHECK_FALSE(bookmarks::UnarchiveBookmark{}.validate()); + CHECK_FALSE(bookmarks::DeleteBookmark{}.validate()); +} + +// No `;` in the name, deliberately: `catch_discover_tests` splits its +// discovered-name list on semicolons (CMake's own list separator), so a test +// name containing one is parsed as two bogus names and the real test silently +// receives none of the `ladder`/`ladder-bookmarks` labels CI filters by. +TEST_CASE("RecordMetadata requires an id — title/faviconPath may be empty (a failed fetch)", + "[bookmarks][dto]") { + CHECK_FALSE(bookmarks::RecordMetadata{}.validate()); + // Every field named explicitly rather than a partial designated-initializer + // list: -Weverything includes -Wmissing-designated-field-initializers, which + // fires on a partial list, and ladder__tests is -Werror under + // MORPH_ENABLE_STRICT_COMPILATION (CI's default). + bookmarks::RecordMetadata action{.id = bookmarks::BookmarkId{1}, .title = {}, .faviconPath = {}}; + CHECK(action.validate()); // empty title/faviconPath is a legitimate "fetch found nothing" +} + +TEST_CASE("Visibility/ReadState/ArchiveState/ReadFilter/ArchiveFilter reflect as readable strings", + "[bookmarks][dto]") { + std::string json; + REQUIRE_FALSE(glz::write_json(bookmarks::Visibility::Shared, json)); + CHECK(json == "\"Shared\""); + json.clear(); + REQUIRE_FALSE(glz::write_json(bookmarks::ReadFilter::UnreadOnly, json)); + CHECK(json == "\"UnreadOnly\""); +} diff --git a/examples/bookmarks/tests/test_bookmark_model.cpp b/examples/bookmarks/tests/test_bookmark_model.cpp new file mode 100644 index 00000000..096d36ae --- /dev/null +++ b/examples/bookmarks/tests/test_bookmark_model.cpp @@ -0,0 +1,938 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/models/bookmark_model.hpp" +#include "testkit/db_fixture.hpp" + +#include "bookmarks/auth/bookmarks_authorizer.hpp" +#include "bookmarks/db/outbox_entity.hpp" + +#include "clock.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_busy_fixture.hpp" +#include "testkit/db_pool_drain.hpp" +#include "testkit/pump.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::drainPoolIdleMappers; +using morph::ladder::testkit::pumpUntil; + +namespace { + +/// @brief A `Context` carrying only @p principal. +/// +/// Built field-by-field rather than with a designated initializer on +/// purpose: `-Weverything` includes +/// `-Wmissing-designated-field-initializers`, which fires on a partial +/// designated-initializer list, and `ladder__tests` is built with +/// `apply_warnings()` (so `-Werror` under `MORPH_ENABLE_STRICT_COMPILATION`, +/// CI's default). Same reason `makeCreate` below exists (see +/// `test_app.cpp`'s `contextFor`/`makeCreate` for the original pattern). +[[nodiscard]] morph::session::Context contextFor(std::string principal) { + morph::session::Context ctx; + ctx.principal = std::move(principal); + return ctx; +} + +class ScopedPrincipal { + public: + explicit ScopedPrincipal(std::string principal) : _ctx{contextFor(std::move(principal))}, _scope{_ctx} {} + + private: + morph::session::Context _ctx; + morph::session::detail::ScopedContext _scope; +}; + +/// @brief A `CreateBookmark` for @p url, optionally titled and/or tagged. +/// See `contextFor` for why this is not a designated initializer. +[[nodiscard]] bookmarks::CreateBookmark makeCreate(std::string url, std::string title = {}, + std::vector tags = {}) { + bookmarks::CreateBookmark action; + action.url = std::move(url); + action.title = std::move(title); + action.tags = std::move(tags); + return action; +} + +} // namespace + +TEST_CASE("CreateBookmark stores a bookmark owned by the authenticated principal", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal principal{"alice"}; + + bookmarks::CreateBookmark action; + action.url = "https://example.com"; + action.title = "Example"; + action.tags = {"work", "reading"}; + const auto id = model.execute(action).id; + REQUIRE(id.hasValue()); + + const auto view = model.execute(bookmarks::GetBookmark{.id = id}); + CHECK(view.url == "https://example.com"); + CHECK(view.title == "Example"); + CHECK(view.readState == bookmarks::ReadState::Unread); + CHECK(view.archiveState == bookmarks::ArchiveState::Active); + CHECK(view.tags.size() == 2); +} + +TEST_CASE("CreateBookmark without a principal is Forbidden", "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + // No ScopedPrincipal installed -- session::current() is nullptr. + bookmarks::CreateBookmark action; + action.url = "https://example.com"; + REQUIRE_THROWS_AS(model.execute(action), bookmarks::Forbidden); +} + +TEST_CASE("GetBookmark refuses a different principal's bookmark with Forbidden, not NotFound", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + bookmarks::BookmarkId id; + { + const ScopedPrincipal alice{"alice"}; + id = model.execute(makeCreate("https://example.com")).id; + } + const ScopedPrincipal mallory{"mallory"}; + REQUIRE_THROWS_AS(model.execute(bookmarks::GetBookmark{.id = id}), bookmarks::Forbidden); +} + +TEST_CASE("EditBookmark replaces the tag set: adds new tags, drops removed ones, keeps shared ones", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + + auto create = makeCreate("https://example.com", {}, {"a", "b"}); + const auto id = model.execute(create).id; + + bookmarks::EditBookmark edit; + edit.id = id; + edit.url = "https://example.com"; + edit.tags = {"b", "c"}; + const auto edited = model.execute(edit); + std::vector tags = edited.tags; + std::ranges::sort(tags); + CHECK(tags == std::vector{"b", "c"}); // "a" dropped, "b" kept, "c" auto-created +} + +TEST_CASE("ArchiveBookmark/UnarchiveBookmark flip archiveState and nothing else", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + const auto id = model.execute(makeCreate("https://example.com")).id; + + model.execute(bookmarks::ArchiveBookmark{.id = id}); + CHECK(model.execute(bookmarks::GetBookmark{.id = id}).archiveState == bookmarks::ArchiveState::Archived); + model.execute(bookmarks::UnarchiveBookmark{.id = id}); + CHECK(model.execute(bookmarks::GetBookmark{.id = id}).archiveState == bookmarks::ArchiveState::Active); +} + +TEST_CASE("DeleteBookmark removes the bookmark and its tag associations", "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + const auto id = model.execute(makeCreate("https://example.com", {}, {"a"})).id; + + model.execute(bookmarks::DeleteBookmark{.id = id}); + REQUIRE_THROWS_AS(model.execute(bookmarks::GetBookmark{.id = id}), bookmarks::NotFound); +} + +TEST_CASE("GetBookmark against an unknown id throws NotFound, and an empty id is a ValidationError", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + REQUIRE_THROWS_AS(model.execute(bookmarks::GetBookmark{.id = bookmarks::BookmarkId{99999}}), + bookmarks::NotFound); + REQUIRE_THROWS_AS(model.execute(bookmarks::GetBookmark{}), bookmarks::ValidationError); +} + +TEST_CASE("ListBookmarks filters by archive state and hides archived bookmarks by default", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + const auto activeId = model.execute(makeCreate("https://active.example")).id; + const auto archivedId = model.execute(makeCreate("https://archived.example")).id; + model.execute(bookmarks::ArchiveBookmark{.id = archivedId}); + + const auto defaultPage = model.execute(bookmarks::ListBookmarks{}); + REQUIRE(defaultPage.bookmarks.size() == 1); + CHECK(*defaultPage.bookmarks.front().id == *activeId); + + bookmarks::ListBookmarks archivedOnly; + archivedOnly.archiveFilter = bookmarks::ArchiveFilter::ArchivedOnly; + const auto archivedPage = model.execute(archivedOnly); + REQUIRE(archivedPage.bookmarks.size() == 1); + CHECK(*archivedPage.bookmarks.front().id == *archivedId); +} + +TEST_CASE("ListBookmarks only ever returns the calling principal's own bookmarks", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + { + const ScopedPrincipal alice{"alice"}; + model.execute(makeCreate("https://alice.example")); + } + const ScopedPrincipal mallory{"mallory"}; + model.execute(makeCreate("https://mallory.example")); + const auto page = model.execute(bookmarks::ListBookmarks{}); + REQUIRE(page.bookmarks.size() == 1); + CHECK(page.bookmarks.front().url == "https://mallory.example"); +} + +TEST_CASE("ListBookmarks sets nextCursor even when a filtered page's matches are empty, " + "so a tag/text search doesn't silently truncate", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + + // Created first, so it has the lowest id and therefore sorts last in the + // DESCENDING-by-id keyset pagination below -- i.e. it lands beyond the + // first raw SQL page. + const auto targetId = + model.execute(makeCreate("https://target.example", {}, {"target"})).id; + for (int i = 0; i < 25; ++i) { + model.execute(makeCreate("https://filler" + std::to_string(i) + ".example")); + } + + bookmarks::ListBookmarks filtered; + filtered.tag = "target"; + const auto firstPage = model.execute(filtered); + // The 20 newest raw rows are all untagged fillers, so the filtered result + // is empty -- but a 21st raw row (eventually the tagged bookmark) still + // exists further down the id space, so nextCursor must still be set. + REQUIRE(firstPage.bookmarks.empty()); + REQUIRE(firstPage.nextCursor.hasValue()); + + filtered.cursor = firstPage.nextCursor; + const auto secondPage = model.execute(filtered); + REQUIRE(secondPage.bookmarks.size() == 1); + CHECK(*secondPage.bookmarks.front().id == *targetId); +} + +TEST_CASE("GetChangesSince returns only bookmarks touched after the given instant", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + + const auto before = *morph::ladder::now(); + const morph::ladder::ScopedClockOverride clock1{before + std::chrono::milliseconds{10}}; + const auto id1 = model.execute(makeCreate("https://one.example")).id; + + const auto cursor = model.execute(bookmarks::GetChangesSince{}).asOf; + + const morph::ladder::ScopedClockOverride clock2{before + std::chrono::milliseconds{20}}; + const auto id2 = model.execute(makeCreate("https://two.example")).id; + + const auto changes = model.execute(bookmarks::GetChangesSince{.since = cursor}); + REQUIRE(changes.changed.size() == 1); + CHECK(*changes.changed.front().id == *id2); + (void) id1; +} + +TEST_CASE("GetChangesSince does not miss a write landing in the same millisecond as the cursor", + "[bookmarks][model]") { + // Regression test for issue #43: a cursor that compares only on + // updated_at_ms with strict `>` can silently drop a write whose + // timestamp equals the previous poll's asOf (same millisecond -- a + // plausible timing window on a fast machine or a loaded CI runner, not + // a contrived one). Frozen to a single instant, like the analogous + // same-millisecond BulkEdit regression test above, so the race is + // deterministic rather than relying on incidental timing. + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + + const auto frozenAt = *morph::ladder::now(); + const morph::ladder::ScopedClockOverride clock{frozenAt}; + + // First poll: nothing exists yet. Its asOf is frozenAt with no + // tie-break id (nothing at that instant to break a tie against). + const auto cursor = model.execute(bookmarks::GetChangesSince{}).asOf; + + // A write lands in the *same* frozen millisecond as the cursor just + // captured -- still under the same ScopedClockOverride, so + // updated_at_ms for this row is bit-for-bit equal to cursor's instant. + const auto id = model.execute(makeCreate("https://same-ms.example")).id; + + // The strict `>` bug would exclude this row: updated_at_ms == since, + // not >. The fix must still return it via the id tie-break. + const auto changes = model.execute(bookmarks::GetChangesSince{.since = cursor}); + REQUIRE(changes.changed.size() == 1); + CHECK(*changes.changed.front().id == *id); +} + +TEST_CASE("GetChangesSince's same-millisecond tie-break never re-delivers an already-seen write", + "[bookmarks][model]") { + // Companion to the test above: the id tie-break must be a strict `>` + // on id, not `>=` -- otherwise the write that established the cursor + // would be re-delivered forever on every subsequent poll at the same + // frozen instant. + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + + const auto frozenAt = *morph::ladder::now(); + const morph::ladder::ScopedClockOverride clock{frozenAt}; + + (void) model.execute(makeCreate("https://first.example")); + const auto cursor = model.execute(bookmarks::GetChangesSince{}).asOf; + + // No further writes -- polling again with the cursor that already + // covers the one write above must come back empty. + const auto changes = model.execute(bookmarks::GetChangesSince{.since = cursor}); + CHECK(changes.changed.empty()); +} + +TEST_CASE("BulkEdit archives every listed bookmark and adds/removes tags atomically", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + const auto id1 = model.execute(makeCreate("https://one.example", {}, {"old"})).id; + const auto id2 = model.execute(makeCreate("https://two.example")).id; + + bookmarks::BulkEdit edit; + edit.ids = {id1, id2}; + edit.addTags = {"new"}; + edit.removeTags = {"old"}; + edit.archive = bookmarks::BulkArchiveOp::Archive; + const auto result = model.execute(edit); + CHECK(morph::math::floor(*result.affected) == 2); + + for (const auto id : {id1, id2}) { + const auto view = model.execute(bookmarks::GetBookmark{.id = id}); + CHECK(view.archiveState == bookmarks::ArchiveState::Archived); + CHECK(std::ranges::find(view.tags, "new") != view.tags.end()); + CHECK(std::ranges::find(view.tags, "old") == view.tags.end()); + } +} + +TEST_CASE("BulkEdit rejects the whole batch if any id is not owned by the caller", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + bookmarks::BookmarkId aliceId; + { + const ScopedPrincipal alice{"alice"}; + aliceId = model.execute(makeCreate("https://alice.example")).id; + } + const ScopedPrincipal mallory{"mallory"}; + const auto malloryId = model.execute(makeCreate("https://mallory.example")).id; + + bookmarks::BulkEdit edit; + edit.ids = {malloryId, aliceId}; // one owned, one not + edit.archive = bookmarks::BulkArchiveOp::Archive; + REQUIRE_THROWS_AS(model.execute(edit), bookmarks::Forbidden); + + // All-or-nothing: mallory's own bookmark was NOT archived either. + CHECK(model.execute(bookmarks::GetBookmark{.id = malloryId}).archiveState == bookmarks::ArchiveState::Active); +} + +TEST_CASE("BulkEdit writes exactly one outbox row per call, consumed by an OutboxRelay", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + const auto id = model.execute(makeCreate("https://one.example")).id; + + bookmarks::BulkEdit edit; + edit.ids = {id}; + edit.archive = bookmarks::BulkArchiveOp::Archive; + model.execute(edit); + + Lightweight::DataMapper mapper; + auto rows = mapper.Query().All(); + REQUIRE(rows.size() == 1); + CHECK(rows.front().actionType.Value() == "BulkEdit"); + CHECK(rows.front().principal.Value() == "alice"); +} + +TEST_CASE("BulkEdit from the same principal in the same millisecond both succeed, " + "each with its own outbox row", + "[bookmarks][model]") { + // Regression test: the outbox idempotency key used to be + // owner + "-bulkedit-" + nowMs() alone, which collides across two + // BulkEdit calls from the same principal landing in the same + // millisecond (nowMs() has millisecond resolution) -- the second + // model.execute() would throw a raw SQL constraint-violation exception + // from idx_bookmark_outbox_idempotency instead of succeeding. + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + const auto id = model.execute(makeCreate("https://one.example")).id; + + const auto frozenAt = *morph::ladder::now(); + const morph::ladder::ScopedClockOverride clock{frozenAt}; + + bookmarks::BulkEdit edit; + edit.ids = {id}; + edit.archive = bookmarks::BulkArchiveOp::Archive; + REQUIRE_NOTHROW(model.execute(edit)); + // Second call, still under the same frozen instant -- must also + // succeed, not throw on the idempotency key's unique index. + REQUIRE_NOTHROW(model.execute(edit)); + + Lightweight::DataMapper mapper; + auto rows = mapper.Query().All(); + REQUIRE(rows.size() == 2); + CHECK(rows[0].idempotencyKey.Value() != rows[1].idempotencyKey.Value()); +} + +TEST_CASE("RecordMetadata updates another principal's bookmark when the service principal dispatches it", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + bookmarks::BookmarkId id; + { + const ScopedPrincipal alice{"alice"}; + id = model.execute(makeCreate("https://one.example")).id; + } + // Dispatched as the service principal, not "alice" -- must not throw + // Forbidden even though the row belongs to someone else. That asymmetry + // is the whole point of the action. + const ScopedPrincipal worker{std::string{bookmarks::auth::kMetadataFetcherPrincipal}}; + model.execute(bookmarks::RecordMetadata{.id = id, .title = "Fetched Title", .faviconPath = {}}); + + const ScopedPrincipal alice{"alice"}; + CHECK(model.execute(bookmarks::GetBookmark{.id = id}).title == "Fetched Title"); +} + +TEST_CASE("RecordMetadata refuses any principal other than the metadata-fetch service principal", + "[bookmarks][model]") { + // The check that stands in because authorizeInstance can't express this: + // it compares instance ownership, not row ownership, and this action + // deliberately touches rows the calling principal (the service worker) + // doesn't own -- an instance-level check has nothing to object to when + // the worker dispatches through its own, legitimately-owned instance. + // Without this model-level check, `mallory` below would silently + // overwrite alice's title. + DbFixture fixture; + bookmarks::BookmarkModel model; + bookmarks::BookmarkId id; + { + const ScopedPrincipal alice{"alice"}; + id = model.execute(makeCreate("https://one.example", "Alice's Title")).id; + } + { + const ScopedPrincipal mallory{"mallory"}; + REQUIRE_THROWS_AS(model.execute(bookmarks::RecordMetadata{.id = id, .title = "Owned", .faviconPath = {}}), + bookmarks::Forbidden); + } + { + // Not even the row's own owner may dispatch it: this action exists + // for the internal worker, and EditBookmark is the user-facing way + // to set a title. + const ScopedPrincipal alice{"alice"}; + REQUIRE_THROWS_AS(model.execute(bookmarks::RecordMetadata{.id = id, .title = "By hand", .faviconPath = {}}), + bookmarks::Forbidden); + CHECK(model.execute(bookmarks::GetBookmark{.id = id}).title == "Alice's Title"); + } +} + +TEST_CASE("RecordMetadata against an already-deleted bookmark is a benign no-op", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + const auto id = model.execute(makeCreate("https://one.example")).id; + model.execute(bookmarks::DeleteBookmark{.id = id}); + const ScopedPrincipal worker{std::string{bookmarks::auth::kMetadataFetcherPrincipal}}; + REQUIRE_NOTHROW(model.execute(bookmarks::RecordMetadata{.id = id, .title = "Too Late", .faviconPath = {}})); +} + +TEST_CASE("ImportBookmarks stores every well-formed entry in one chunk", "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + + bookmarks::ImportBookmarks action; + action.chunk = R"(

One +
Two +
No href)"; + action.opId = bookmarks::ImportOpId{"chunk-1"}; + const auto result = model.execute(action); + CHECK(morph::math::floor(*result.imported) == 2); + CHECK(morph::math::floor(*result.skipped) == 1); + + const auto page = model.execute(bookmarks::ListBookmarks{}); + CHECK(page.bookmarks.size() == 2); +} + +TEST_CASE("ImportBookmarks skips an entry whose url or title exceeds this rung's field bounds", + "[bookmarks][model]") { + // The Netscape parser applies no field bounds of its own, so without an + // explicit check here an import would happily write a row that + // `EditBookmark::validate()` then refuses -- a bookmark the owner can see + // but can never edit. Skipped-and-counted is the answer; truncation would + // silently store a url that is not the one the user saved. + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + + const std::string longUrl = "https://" + std::string(bookmarks::kMaxUrlBytes, 'u') + ".example"; + const std::string longTitle(bookmarks::kMaxTitleBytes + 1, 't'); + REQUIRE(longUrl.size() > bookmarks::kMaxUrlBytes); + + bookmarks::ImportBookmarks action; + action.chunk = R"(
Fine +
Over-long url +
)" + + longTitle + R"()"; + REQUIRE(action.chunk.size() <= bookmarks::kMaxImportChunkBytes); // not the chunk bound under test + action.opId = bookmarks::ImportOpId{"chunk-oversized-fields"}; + + const auto result = model.execute(action); + CHECK(morph::math::floor(*result.imported) == 1); + CHECK(morph::math::floor(*result.skipped) == 2); + + // Not merely uncounted: neither oversized entry reached the store, in + // truncated form or otherwise. + const auto page = model.execute(bookmarks::ListBookmarks{}); + REQUIRE(page.bookmarks.size() == 1); + CHECK(page.bookmarks.front().url == "https://fine.example"); +} + +TEST_CASE("An ImportBookmarks chunk over kMaxImportChunkBytes throws TooLarge, not ValidationError", + "[bookmarks][model]") { + // `TooLarge`'s own doc comment promises exactly this, and the distinction + // is what lets a client tell "re-chunk your file" apart from "your + // request was malformed". validate() deliberately does NOT bound + // chunk size (see import_export_dto.hpp) -- an oversized-but-otherwise- + // well-formed chunk passes validate() and reaches execute(), which is + // what actually throws TooLarge. If validate() rejected it too, every + // real dispatch path (Bridge::executeVia / RemoteServer both consult + // validate() before execute() is ever reached) would fail the request + // as ValidationError first and TooLarge would never be observable + // outside a bare, bridge-bypassing model.execute() call like this one. + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + + bookmarks::ImportBookmarks action; + action.chunk = std::string(bookmarks::kMaxImportChunkBytes + 1, 'x'); + action.opId = bookmarks::ImportOpId{"chunk-too-large"}; + REQUIRE(action.validate()); + + CHECK_THROWS_AS(model.execute(action), bookmarks::TooLarge); + + // A chunk that is malformed for some *other* reason still gets the + // untyped answer, so the check above is not vacuous. + bookmarks::ImportBookmarks noOpId; + noOpId.chunk = R"(
One)"; + CHECK_THROWS_AS(model.execute(noOpId), bookmarks::ValidationError); +} + +TEST_CASE("An oversized ImportBookmarks chunk reaches TooLarge through the real Bridge dispatch path, " + "not just a bare model.execute() call", + "[bookmarks][model]") { + // The case above proves execute() throws the right type; it calls + // execute() directly, bypassing ActionValidator/Bridge::executeVia + // entirely, so it cannot by itself prove the fix above (validate() not + // bounding chunk size) actually matters. This case drives the same + // oversized chunk through BackendRig -- Bridge::executeVia's real + // validate()-then-execute() sequence -- and confirms TooLarge survives + // as a distinguishable C++ type through Completion/awaitQt's + // exception_ptr rethrow (Local/LocalSingleThread dispatch is in-process, + // so the exception object itself propagates; see pump.hpp's awaitQt). + // + // This does NOT hold over Socket/remote transport: RemoteServer encodes + // every server-side exception as an opaque wire::makeErr(exc.what()) + // string (remote.hpp), and the client reconstructs a generic + // std::runtime_error from it, discarding the original type. That is a + // framework-wide property of every model's typed errors, not specific + // to TooLarge or to this rung -- Socket-mode dispatch is deliberately + // not exercised in this case for that reason. + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread); + CAPTURE(mode); + DbFixture fixture; + BackendRig rig{mode, 1}; + auto handler = rig.client(0); + + bookmarks::ImportBookmarks action; + action.chunk = std::string(bookmarks::kMaxImportChunkBytes + 1, 'x'); + action.opId = bookmarks::ImportOpId{"chunk-too-large-over-bridge"}; + REQUIRE(action.validate()); // must pass, or Bridge::executeVia never reaches execute() at all + + REQUIRE_THROWS_AS(awaitQt(handler.execute(action)), bookmarks::TooLarge); +} + +TEST_CASE("ImportBookmarks is idempotent on a retried opId", "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + const ScopedPrincipal alice{"alice"}; + + bookmarks::ImportBookmarks action; + action.chunk = R"(
One)"; + action.opId = bookmarks::ImportOpId{"chunk-retry"}; + model.execute(action); + model.execute(action); // simulates a retry after a dropped connection + + const auto page = model.execute(bookmarks::ListBookmarks{}); + CHECK(page.bookmarks.size() == 1); // not duplicated +} + +TEST_CASE("ExportBookmarks emits every owned bookmark as a Netscape file, and it re-imports", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel model; + { + const ScopedPrincipal alice{"alice"}; + model.execute(makeCreate("https://one.example", "One")); + model.execute(makeCreate("https://two.example", "Two")); + } + std::string exported; + { + const ScopedPrincipal alice{"alice"}; + exported = model.execute(bookmarks::ExportBookmarks{}).html; + } + CHECK(exported.find("https://one.example") != std::string::npos); + CHECK(exported.find("https://two.example") != std::string::npos); + + const ScopedPrincipal bob{"bob"}; + bookmarks::ImportBookmarks reimport; + reimport.chunk = exported; + reimport.opId = bookmarks::ImportOpId{"reimport-1"}; + const auto result = model.execute(reimport); + CHECK(morph::math::floor(*result.imported) == 2); +} + +TEST_CASE("A URL containing '&' survives an ExportBookmarks/ImportBookmarks round trip unchanged", + "[bookmarks][model]") { + // Regression test: export used to escape '&' to "&" in the HREF + // attribute, but import never decoded it back out, so a reimported + // bookmark's URL ended up with the literal "&" text baked in instead + // of the original '&'. This is the common case for URLs with query + // strings, not an edge case. + DbFixture fixture; + bookmarks::BookmarkModel model; + const std::string originalUrl = "https://example.com/search?a=1&b=2"; + { + const ScopedPrincipal alice{"alice"}; + model.execute(makeCreate(originalUrl, "Search")); + } + std::string exported; + { + const ScopedPrincipal alice{"alice"}; + exported = model.execute(bookmarks::ExportBookmarks{}).html; + } + // The exported HTML entity-escapes the '&' in the HREF attribute. + CHECK(exported.find("https://example.com/search?a=1&b=2") != std::string::npos); + CHECK(exported.find(originalUrl) == std::string::npos); + + const ScopedPrincipal bob{"bob"}; + bookmarks::ImportBookmarks reimport; + reimport.chunk = exported; + reimport.opId = bookmarks::ImportOpId{"reimport-amp-1"}; + const auto result = model.execute(reimport); + CHECK(morph::math::floor(*result.imported) == 1); + + const auto page = model.execute(bookmarks::ListBookmarks{}); + REQUIRE(page.bookmarks.size() == 1); + CHECK(page.bookmarks[0].url == originalUrl); // decoded back to the original, not "&" +} + +TEST_CASE("BookmarkModel over the full backend-mode matrix: create, list, get round-trip", + "[bookmarks][model]") { + // Every case above dispatches model.execute(action) directly, C++-to-C++, + // with ScopedPrincipal standing in for a real dispatch's Context -- it + // never exercises the dispatch machinery itself. This case drives the + // create -> list -> get round trip through the real path instead: + // Local/LocalSingleThread/Socket via BackendRig, authenticated with a + // real signed token verified by a real BookmarksAuthorizer. Socket mode + // is the one that actually matters here -- authorizeRegister is + // unconditionally permissive by this rung's own design choice (not a + // framework limitation -- the register envelope now carries the + // caller's identity), so this case does not prove anything about + // registration being gated. What it does prove is that + // SigningAuthorizer::authorize(), which sees the token on every + // subsequent execute(), correctly admits a validly signed token end to + // end through the real RemoteServer/QtWebSocketServer wiring -- the + // boundary that is genuinely enforced (see bookmarks_authorizer.hpp's + // @file comment). + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + + constexpr std::string_view kSecret = "matrix-test-secret"; + const auto authorizer = + std::make_shared(std::string{kSecret}, morph::session::hmacSha256); + BackendRig rig{mode, 1, authorizer}; + + const morph::session::TokenIssuer issuer{std::string{kSecret}, morph::session::hmacSha256}; + morph::session::Context ctx; + ctx.principal = "alice"; + ctx.token = issuer.issue(morph::session::SessionToken{ + .principal = "alice", .expiresAtMs = 4102444800000, .roles = {}}); + rig.bridge(0).setDefaultSession(ctx); + + auto handler = rig.client(0); + bookmarks::CreateBookmark create; + create.url = "https://matrix.example"; + create.title = "Matrix"; + const auto createResult = awaitQt(handler.execute(create)); + REQUIRE(createResult.id.hasValue()); + + const auto listResult = awaitQt(handler.execute(bookmarks::ListBookmarks{})); + REQUIRE(listResult.bookmarks.size() == 1); + + const auto view = awaitQt(handler.execute(bookmarks::GetBookmark{.id = createResult.id})); + CHECK(view.url == "https://matrix.example"); + CHECK(view.title == "Matrix"); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Task 15 — DoD/strain-point closers: BulkEdit atomicity under injected +// failure, cross-user Socket-mode auth, and the local-mode-no-auth strain +// point demonstrated rather than just asserted. +// ═════════════════════════════════════════════════════════════════════════ + +namespace { + +/// @brief Installs a short SQLite `busy_timeout` on every connection opened +/// while it is alive, and restores the default afterwards. +/// +/// Identical shape to `test_paste_model.cpp`'s helper of the same name +/// (rung 1) -- test-only, one file's own concern, not yet promoted. See that +/// file's doc comment for why the post-connected hook (rather than a +/// connection-string `Timeout=` override) is the seam that actually works: +/// `Lightweight::SqlConnection::PostConnect()` unconditionally issues +/// `PRAGMA busy_timeout = 60000` on every new SQLite connection, which would +/// otherwise make a contended write block for a real minute before this test +/// observed `SQLITE_BUSY`. +class ScopedShortBusyTimeout { + public: + explicit ScopedShortBusyTimeout(int milliseconds) { + ::Lightweight::SqlConnection::SetPostConnectedHook([milliseconds](::Lightweight::SqlConnection& connection) { + ::Lightweight::SqlStatement stmt{connection}; + (void) stmt.ExecuteDirect("PRAGMA busy_timeout = " + std::to_string(milliseconds)); + }); + } + ~ScopedShortBusyTimeout() { ::Lightweight::SqlConnection::ResetPostConnectedHook(); } + + ScopedShortBusyTimeout(const ScopedShortBusyTimeout&) = delete; + ScopedShortBusyTimeout& operator=(const ScopedShortBusyTimeout&) = delete; + ScopedShortBusyTimeout(ScopedShortBusyTimeout&&) = delete; + ScopedShortBusyTimeout& operator=(ScopedShortBusyTimeout&&) = delete; +}; + +} // namespace + +TEST_CASE("BulkEdit rolls back entirely when a genuine SQLITE_BUSY interrupts the batch", + "[bookmarks][model]") { + // DoD: "Bulk edit is atomic under injected mid-batch failure." A real + // mid-transaction failure, not a mock -- mirrors test_paste_model.cpp's + // proven DbBusyFixture/ScopedShortBusyTimeout recipe exactly (finding + // 018's resolved mechanism for the SQLITE_BUSY class, rung 1). + DbFixture fixture; + bookmarks::BookmarkModel seedModel; + bookmarks::BookmarkId id1; + bookmarks::BookmarkId id2; + { + const ScopedPrincipal alice{"alice"}; + id1 = seedModel.execute(makeCreate("https://one.example")).id; + id2 = seedModel.execute(makeCreate("https://two.example")).id; + } + + // contendedModel's execute() below must acquire its connection from + // Lightweight::GlobalDataMapperPool() *while* the short busy-timeout + // hook is installed for this hook to actually apply to it (see + // db_busy_fixture.hpp's `GlobalDataMapperPool()` note). Draining the + // pool's idle mappers first (testkit/db_pool_drain.hpp) turns that into + // a hard guarantee rather than an incidental one -- seedModel's own + // earlier acquisitions above already returned to the pool by this + // point, so without draining they would be exactly the kind of stale, + // already-connected mapper this hook must not silently miss. + const ScopedShortBusyTimeout shortTimeout{200}; + auto drained = drainPoolIdleMappers(); + bookmarks::BookmarkModel contendedModel; + const ScopedPrincipal alice{"alice"}; + + const morph::ladder::testkit::DbBusyFixture busy{"bookmarks"}; + bookmarks::BulkEdit edit; + edit.ids = {id1, id2}; + edit.archive = bookmarks::BulkArchiveOp::Archive; + REQUIRE_THROWS(contendedModel.execute(edit)); + // contendedModel's one execute() call above already made its one pool + // acquisition, synchronously, on this thread. + drained.clear(); + + // Neither bookmark was archived, and no outbox row survived -- the + // whole transaction (mutation + outbox write) rolled back together. + CHECK(seedModel.execute(bookmarks::GetBookmark{.id = id1}).archiveState == bookmarks::ArchiveState::Active); + CHECK(seedModel.execute(bookmarks::GetBookmark{.id = id2}).archiveState == bookmarks::ArchiveState::Active); + Lightweight::DataMapper mapper; + CHECK(mapper.Query().All().empty()); +} + +TEST_CASE("BackendRig::Socket: a second principal's GetBookmark is denied by the model's own " + "ownership re-check over a real wire transport, not by authorizeInstance", + "[bookmarks][model][socket-only]") { + // DoD: "authorization enforced server-side, not by the client." Two real + // sockets, two real signed tokens, one tries to GetBookmark an id it + // does not own. + // + // This is deliberately NOT titled "authorizeInstance denies ..." -- + // register envelopes now carry a session, so RemoteServer records a + // real, non-empty owner for each of alice's and mallory's own + // plain-registered BookmarkModel instances (confirmed empirically: + // authorizeInstance runs with ctx.principal == ownerPrincipal == the + // dispatching principal's own name for both). But `authorizeInstance` + // checks instance ownership, not row ownership -- mallory dispatches + // GetBookmark through her OWN instance, which she legitimately owns, and + // the id she names in the action payload is alice's bookmark. An + // instance-ownership check has no way to see that mismatch; it would + // pass for any row id mallory happened to name, since the check never + // looks past which instance is making the call. + // + // What actually denies mallory's call is + // BookmarkModel::execute(const GetBookmark&)'s own loadOwned()/ + // requireOwner() re-check: the row's real `ownerPrincipal` DB column + // (a column on the bookmarks table itself, keyed by the row's id, not + // the calling instance) does not match mallory's server-verified + // principal, so the model itself throws Forbidden -- confirmed + // empirically (the propagated error message is "bookmark belongs to a + // different principal", not authorizeInstance's "unauthorized"). This is + // exactly the mechanism the README's DoD section names as what is + // genuinely enforced today -- `SigningAuthorizer::authorize()` on every + // action plus the models' own verified-principal, per-row scoping -- + // and it is the *only* layer that could ever catch this specific + // mismatch, regardless of instance-ownership tracking. + DbFixture fixture; + constexpr std::string_view kSecret = "cross-user-secret"; + const auto authorizer = + std::make_shared(std::string{kSecret}, morph::session::hmacSha256); + BackendRig rig{Mode::Socket, 2, authorizer}; + const morph::session::TokenIssuer issuer{std::string{kSecret}, morph::session::hmacSha256}; + + auto tokenFor = [&issuer](std::string principal) { + morph::session::Context ctx; + ctx.principal = principal; + ctx.token = issuer.issue(morph::session::SessionToken{ + .principal = std::move(principal), .expiresAtMs = 4102444800000, .roles = {}}); + return ctx; + }; + rig.bridge(0).setDefaultSession(tokenFor("alice")); + rig.bridge(1).setDefaultSession(tokenFor("mallory")); + + auto aliceHandler = rig.client(0); + auto malloryHandler = rig.client(1); + + const auto created = awaitQt(aliceHandler.execute(makeCreate("https://alice.example"))); + + bool malloryFailed = false; + malloryHandler.execute(bookmarks::GetBookmark{.id = created.id}) + .then([](bookmarks::BookmarkView) {}) + .onError([&malloryFailed](const std::exception_ptr&) { malloryFailed = true; }); + REQUIRE(pumpUntil([&malloryFailed] { return malloryFailed; })); +} + +TEST_CASE("BackendRig::Socket: a token signed with a different secret is rejected by " + "SigningAuthorizer::authorize(), not merely by the client", + "[bookmarks][model][socket-only]") { + // Closes a gap Task 14's review flagged as parked, not blocking: a + // Socket-mode negative-auth case (wrong-secret token rejected over the + // real wire transport) was manually fault-injection-verified during + // Task 14's development (task-14-report.md's "Finding-027 framing + // check") but never committed as a permanent test. Composes naturally + // alongside this task's own cross-user case above -- same + // BackendRig::Socket setup, one more BridgeHandler. + // + // Registration itself is unaffected by the wrong secret, for a different + // reason than "no session to check": a wrong-secret token fails + // authenticate(), so env.session.principal is cleared before + // authorizeRegister ever runs -- but authorizeRegister is unconditionally + // permissive here regardless of principal, by this rung's own design + // (see its own doc comment). The rejection below can therefore only come + // from the per-execute() check -- SigningAuthorizer::authorize() + // verifying the token's signature against the server's real secret on + // every action. + DbFixture fixture; + constexpr std::string_view kServerSecret = "socket-negauth-server-secret"; + constexpr std::string_view kWrongSecret = "socket-negauth-wrong-secret"; + const auto authorizer = std::make_shared(std::string{kServerSecret}, + morph::session::hmacSha256); + BackendRig rig{Mode::Socket, 1, authorizer}; + const morph::session::TokenIssuer wrongIssuer{std::string{kWrongSecret}, morph::session::hmacSha256}; + + morph::session::Context ctx; + ctx.principal = "alice"; + ctx.token = wrongIssuer.issue( + morph::session::SessionToken{.principal = "alice", .expiresAtMs = 4102444800000, .roles = {}}); + rig.bridge(0).setDefaultSession(ctx); + + auto handler = rig.client(0); + + bool callFailed = false; + handler.execute(makeCreate("https://mismatched-secret.example")) + .then([](bookmarks::CreateBookmarkResult) {}) + .onError([&callFailed](const std::exception_ptr&) { callFailed = true; }); + REQUIRE(pumpUntil([&callFailed] { return callFailed; })); +} + +TEST_CASE("Mode::Local has no authorization at all: isolation depends entirely on the model's own re-check", + "[bookmarks][model]") { + // Expected strain points: "Local mode has no authorization at all (the + // local backend never authorizes): the first multi-user rung must + // demonstrate this with a test and document the mitigation." Demonstrated + // here, not just asserted in prose. + DbFixture fixture; + // No authorizer passed -- Mode::Local's LocalBackend never consults one + // regardless (verified against backend.hpp: LocalBackend's registration + // and dispatch paths carry no IAuthorizer reference at all -- grep for + // it there and there is nothing to find), so this is the same as passing + // one: the point this test makes. + BackendRig rig{Mode::Local, 1}; + auto handler = rig.client(0); + + bookmarks::BookmarkId aliceId; + { + const ScopedPrincipal alice{"alice"}; + // Constructed directly, not through the rig's handler -- this + // establishes the row to attack; the attack itself goes through + // the rig, matching a real client's only path. + bookmarks::BookmarkModel seedModel; + aliceId = seedModel.execute(makeCreate("https://alice.example")).id; + } + + // No token/session set on rig.bridge(0) at all -- Local mode's own + // Context::principal, whatever the caller sets client-side, would + // normally be untrustworthy on a Socket transport; here there is no + // authorizer to strip it, so it passes straight through. This test + // simulates the honest worst case: an attacker who sets principal + // directly, which Local mode lets through unchecked. + morph::session::Context ctx; + ctx.principal = "mallory"; + rig.bridge(0).setDefaultSession(ctx); + + bool malloryFailed = false; + handler.execute(bookmarks::GetBookmark{.id = aliceId}) + .then([](bookmarks::BookmarkView) {}) + .onError([&malloryFailed](const std::exception_ptr&) { malloryFailed = true; }); + REQUIRE(pumpUntil([&malloryFailed] { return malloryFailed; })); + // malloryFailed is true only because BookmarkModel::execute(GetBookmark) + // itself re-checked ownership (loadOwned/requireOwner) -- Local mode + // contributed nothing to this result. Documented, not smoothed over, + // per the README's own "Expected strain points" framing. +} diff --git a/examples/bookmarks/tests/test_bookmark_presenter.cpp b/examples/bookmarks/tests/test_bookmark_presenter.cpp new file mode 100644 index 00000000..2692ad8b --- /dev/null +++ b/examples/bookmarks/tests/test_bookmark_presenter.cpp @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// BookmarkPresenter's own suite (Task 17): each of its ten actions +// (create/edit/archive/unarchive/remove/get/list/getChangesSince/bulkEdit/ +// importChunk/exportAll) round-trips through the presenter's own signals — +// not the model directly — across the full BackendRig mode matrix +// (Local/LocalSingleThread/Socket, examples/TESTING.md "The dual-mode +// fixture"), plus a `failed()` case per action. Domain rules (ownership, +// tag diffing, archive-state filtering, bulk-atomicity, ...) already have a +// dedicated suite at the model level (test_bookmark_model.cpp); this file +// only proves the presenter wires each action to the right signal, sets +// `busy()`/`idle()` correctly, and neither crashes nor hangs — the +// "translates and routes only" contract bookmark_presenter.hpp's own doc +// comment states (examples/IMPLEMENTATION.md rule 2). +// +// Every mode needs a real signed token: `BookmarksAuthorizer::authorize()` +// requires one on every single execute, unconditionally (see +// bookmarks_authorizer.hpp's own doc comment), so even Local/LocalSingleThread +// mode (which runs no real authorizer) still needs `session::current()-> +// principal` populated for a model's own scoping to succeed — +// `Bridge::setDefaultSession` supplies the per-call Context every mode +// dispatches through, exactly the recipe test_bookmark_model.cpp's own +// backend-mode-matrix case uses. + +#include "bookmark_presenter.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +/// @brief Builds a rig authenticated as @p principal, for @p mode, over a +/// fresh authorizer keyed on @p secret. See this file's own top +/// comment for why every mode needs this, not just Socket. +[[nodiscard]] std::unique_ptr makeAuthedRig(Mode mode, std::string_view secret, std::string principal, + std::size_t nClients = 1) { + const auto authorizer = + std::make_shared(std::string{secret}, morph::session::hmacSha256); + auto rig = std::make_unique(mode, nClients, authorizer); + const morph::session::TokenIssuer issuer{std::string{secret}, morph::session::hmacSha256}; + morph::session::Context ctx; + ctx.principal = std::move(principal); + ctx.token = issuer.issue( + morph::session::SessionToken{.principal = ctx.principal, .expiresAtMs = 4102444800000, .roles = {}}); + for (std::size_t i = 0; i < nClients; ++i) { + rig->bridge(i).setDefaultSession(ctx); + } + return rig; +} + +[[nodiscard]] bookmarks::CreateBookmark makeCreate(std::string url, std::string title = {}) { + bookmarks::CreateBookmark create; + create.url = std::move(url); + create.title = std::move(title); + return create; +} + +} // namespace + +TEST_CASE("BookmarkPresenter::create then get round-trips a bookmark, all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "presenter-create-get-secret", "alice"); + bookmarks::gui::BookmarkPresenter presenter{rig->bridge(0), rig->executor()}; + + bookmarks::BookmarkId createdId; + bool created = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::created, + [&](bookmarks::CreateBookmarkResult result) { + createdId = result.id; + created = true; + }); + presenter.create(makeCreate("https://one.example", "One")); + REQUIRE(pumpUntil([&] { return created; })); + REQUIRE_FALSE(presenter.busy()); + REQUIRE(createdId.hasValue()); + + bookmarks::BookmarkView loaded; + bool gotLoaded = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::loaded, [&](bookmarks::BookmarkView view) { + loaded = view; + gotLoaded = true; + }); + presenter.get(bookmarks::GetBookmark{.id = createdId}); + REQUIRE(pumpUntil([&] { return gotLoaded; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(loaded.id == createdId); + CHECK(loaded.url == "https://one.example"); + CHECK(loaded.title == "One"); +} + +TEST_CASE("BookmarkPresenter::edit replaces a bookmark's fields, all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "presenter-edit-secret", "alice"); + bookmarks::gui::BookmarkPresenter presenter{rig->bridge(0), rig->executor()}; + + bookmarks::BookmarkId createdId; + bool created = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::created, + [&](bookmarks::CreateBookmarkResult result) { + createdId = result.id; + created = true; + }); + presenter.create(makeCreate("https://before.example", "Before")); + REQUIRE(pumpUntil([&] { return created; })); + + bookmarks::BookmarkView edited; + bool gotEdited = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::edited, [&](bookmarks::BookmarkView view) { + edited = view; + gotEdited = true; + }); + presenter.edit(bookmarks::EditBookmark{ + .id = createdId, .url = "https://after.example", .title = "After", .description = {}, .notes = {}, .tags = {}}); + REQUIRE(pumpUntil([&] { return gotEdited; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(edited.id == createdId); + CHECK(edited.url == "https://after.example"); + CHECK(edited.title == "After"); + + // Persisted, not merely reflected back from the action. + bookmarks::BookmarkView reloaded; + bool gotReloaded = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::loaded, [&](bookmarks::BookmarkView view) { + reloaded = view; + gotReloaded = true; + }); + presenter.get(bookmarks::GetBookmark{.id = createdId}); + REQUIRE(pumpUntil([&] { return gotReloaded; })); + CHECK(reloaded.url == "https://after.example"); + CHECK(reloaded.title == "After"); +} + +TEST_CASE("BookmarkPresenter::archive then unarchive a bookmark, all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "presenter-archive-secret", "alice"); + bookmarks::gui::BookmarkPresenter presenter{rig->bridge(0), rig->executor()}; + + bookmarks::BookmarkId createdId; + bool created = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::created, + [&](bookmarks::CreateBookmarkResult result) { + createdId = result.id; + created = true; + }); + presenter.create(makeCreate("https://archivable.example")); + REQUIRE(pumpUntil([&] { return created; })); + + bool archived = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::archived, [&] { archived = true; }); + presenter.archive(bookmarks::ArchiveBookmark{.id = createdId}); + REQUIRE(pumpUntil([&] { return archived; })); + REQUIRE_FALSE(presenter.busy()); + + bookmarks::BookmarkView archivedView; + bool gotArchivedView = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::loaded, [&](bookmarks::BookmarkView view) { + archivedView = view; + gotArchivedView = true; + }); + presenter.get(bookmarks::GetBookmark{.id = createdId}); + REQUIRE(pumpUntil([&] { return gotArchivedView; })); + CHECK(archivedView.archiveState == bookmarks::ArchiveState::Archived); + + bool unarchived = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::unarchived, [&] { unarchived = true; }); + presenter.unarchive(bookmarks::UnarchiveBookmark{.id = createdId}); + REQUIRE(pumpUntil([&] { return unarchived; })); + REQUIRE_FALSE(presenter.busy()); +} + +TEST_CASE("BookmarkPresenter::remove deletes a bookmark, and a follow-up get fails, all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "presenter-remove-secret", "alice"); + bookmarks::gui::BookmarkPresenter presenter{rig->bridge(0), rig->executor()}; + + bookmarks::BookmarkId createdId; + bool created = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::created, + [&](bookmarks::CreateBookmarkResult result) { + createdId = result.id; + created = true; + }); + presenter.create(makeCreate("https://doomed.example")); + REQUIRE(pumpUntil([&] { return created; })); + + bool removed = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::removed, [&] { removed = true; }); + presenter.remove(bookmarks::DeleteBookmark{.id = createdId}); + REQUIRE(pumpUntil([&] { return removed; })); + REQUIRE_FALSE(presenter.busy()); + + QString failure; + bool failed = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::failed, [&](QString message) { + failure = message; + failed = true; + }); + presenter.get(bookmarks::GetBookmark{.id = createdId}); + REQUIRE(pumpUntil([&] { return failed; })); + CHECK_FALSE(failure.isEmpty()); + REQUIRE_FALSE(presenter.busy()); +} + +TEST_CASE("BookmarkPresenter::list returns the bookmarks just created, all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "presenter-list-secret", "alice"); + bookmarks::gui::BookmarkPresenter presenter{rig->bridge(0), rig->executor()}; + + std::vector createdIds; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::created, + [&](bookmarks::CreateBookmarkResult result) { createdIds.push_back(result.id); }); + + constexpr int kCount = 3; + for (int i = 0; i < kCount; ++i) { + presenter.create(makeCreate("https://listed" + std::to_string(i) + ".example")); + REQUIRE(pumpUntil([&] { return static_cast(createdIds.size()) == i + 1; })); + } + REQUIRE(createdIds.size() == static_cast(kCount)); + + bookmarks::ListBookmarksResult listed; + bool gotListed = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::listed, + [&](bookmarks::ListBookmarksResult result) { + listed = std::move(result); + gotListed = true; + }); + presenter.list(bookmarks::ListBookmarks{}); + REQUIRE(pumpUntil([&] { return gotListed; })); + REQUIRE_FALSE(presenter.busy()); + + REQUIRE(listed.bookmarks.size() == static_cast(kCount)); + for (const auto& id : createdIds) { + CHECK(std::ranges::find_if(listed.bookmarks, [&](const bookmarks::BookmarkSummary& summary) { + return summary.id == id; + }) != listed.bookmarks.end()); + } +} + +TEST_CASE("BookmarkPresenter::getChangesSince returns only bookmarks touched after the given instant, " + "all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "presenter-changes-secret", "alice"); + bookmarks::gui::BookmarkPresenter presenter{rig->bridge(0), rig->executor()}; + + bookmarks::GetChangesSinceResult firstPoll; + bool gotFirstPoll = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::changesSince, + [&](bookmarks::GetChangesSinceResult result) { + firstPoll = std::move(result); + gotFirstPoll = true; + }); + presenter.getChangesSince(bookmarks::GetChangesSince{}); + REQUIRE(pumpUntil([&] { return gotFirstPoll; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(firstPoll.changed.empty()); + const auto cursor = firstPoll.asOf; + + bookmarks::BookmarkId createdId; + bool created = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::created, + [&](bookmarks::CreateBookmarkResult result) { + createdId = result.id; + created = true; + }); + presenter.create(makeCreate("https://changed.example")); + REQUIRE(pumpUntil([&] { return created; })); + + bookmarks::GetChangesSinceResult secondPoll; + bool gotSecondPoll = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::changesSince, + [&](bookmarks::GetChangesSinceResult result) { + secondPoll = std::move(result); + gotSecondPoll = true; + }); + presenter.getChangesSince(bookmarks::GetChangesSince{.since = cursor}); + REQUIRE(pumpUntil([&] { return gotSecondPoll; })); + REQUIRE_FALSE(presenter.busy()); + REQUIRE(secondPoll.changed.size() == 1); + CHECK(secondPoll.changed.front().id == createdId); +} + +TEST_CASE("BookmarkPresenter::bulkEdit applies tags and archive state to every given id, " + "all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "presenter-bulk-secret", "alice"); + bookmarks::gui::BookmarkPresenter presenter{rig->bridge(0), rig->executor()}; + + std::vector createdIds; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::created, + [&](bookmarks::CreateBookmarkResult result) { createdIds.push_back(result.id); }); + presenter.create(makeCreate("https://bulk-one.example")); + REQUIRE(pumpUntil([&] { return createdIds.size() == 1; })); + presenter.create(makeCreate("https://bulk-two.example")); + REQUIRE(pumpUntil([&] { return createdIds.size() == 2; })); + + bookmarks::BulkEditResult bulkResult; + bool bulkEdited = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::bulkEdited, + [&](bookmarks::BulkEditResult result) { + bulkResult = result; + bulkEdited = true; + }); + presenter.bulkEdit(bookmarks::BulkEdit{.ids = createdIds, + .addTags = {"batch"}, + .removeTags = {}, + .archive = bookmarks::BulkArchiveOp::Archive}); + REQUIRE(pumpUntil([&] { return bulkEdited; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(morph::math::floor(*bulkResult.affected) == 2); +} + +TEST_CASE("BookmarkPresenter::importChunk then exportAll round-trips bookmarks, all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "presenter-import-secret", "alice"); + bookmarks::gui::BookmarkPresenter presenter{rig->bridge(0), rig->executor()}; + + bookmarks::ImportBookmarksResult importResult; + bool imported = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::imported, + [&](bookmarks::ImportBookmarksResult result) { + importResult = result; + imported = true; + }); + bookmarks::ImportBookmarks importAction; + importAction.chunk = R"(
Imported)"; + importAction.opId = bookmarks::ImportOpId{"presenter-import-1"}; + presenter.importChunk(importAction); + REQUIRE(pumpUntil([&] { return imported; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(morph::math::floor(*importResult.imported) == 1); + + bookmarks::ExportBookmarksResult exportResult; + bool exported = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::exported, + [&](bookmarks::ExportBookmarksResult result) { + exportResult = std::move(result); + exported = true; + }); + presenter.exportAll(bookmarks::ExportBookmarks{}); + REQUIRE(pumpUntil([&] { return exported; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(exportResult.html.find("https://imported.example") != std::string::npos); +} + +TEST_CASE("Every BookmarkPresenter validation-driven action routes its failure to failed(), not just create()", + "[bookmarks][presenter]") { + // Not a completeness ritual: each action's `reportError` is wired + // independently at its own `track()` call site (`bookmark_presenter.cpp`), + // so a passing test for one action says nothing about whether another + // action's wiring is correct. See pastebin::gui::PastePresenter's + // identical test for the same rationale. + DbFixture fixture; + auto rig = makeAuthedRig(Mode::Local, "presenter-fail-secret", "alice"); + bookmarks::gui::BookmarkPresenter presenter{rig->bridge(0), rig->executor()}; + + QString failure; + int failures = 0; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::failed, [&](QString message) { + failure = message; + ++failures; + }); + + // create: empty url fails CreateBookmark::validate(). + presenter.create(bookmarks::CreateBookmark{}); + REQUIRE(pumpUntil([&] { return failures == 1; })); + REQUIRE_FALSE(presenter.busy()); + + // edit: disengaged id and empty url both fail EditBookmark::validate(). + presenter.edit(bookmarks::EditBookmark{}); + REQUIRE(pumpUntil([&] { return failures == 2; })); + REQUIRE_FALSE(presenter.busy()); + + // archive/unarchive/remove/get: a disengaged id fails each validate(). + presenter.archive(bookmarks::ArchiveBookmark{}); + REQUIRE(pumpUntil([&] { return failures == 3; })); + presenter.unarchive(bookmarks::UnarchiveBookmark{}); + REQUIRE(pumpUntil([&] { return failures == 4; })); + presenter.remove(bookmarks::DeleteBookmark{}); + REQUIRE(pumpUntil([&] { return failures == 5; })); + presenter.get(bookmarks::GetBookmark{}); + REQUIRE(pumpUntil([&] { return failures == 6; })); + REQUIRE_FALSE(presenter.busy()); + + // bulkEdit: an empty id list fails BulkEdit::validate(). + presenter.bulkEdit(bookmarks::BulkEdit{}); + REQUIRE(pumpUntil([&] { return failures == 7; })); + REQUIRE_FALSE(presenter.busy()); + + // importChunk: an empty chunk fails ImportBookmarks::validate(). + presenter.importChunk(bookmarks::ImportBookmarks{}); + REQUIRE(pumpUntil([&] { return failures == 8; })); + REQUIRE_FALSE(presenter.busy()); + CHECK_FALSE(failure.isEmpty()); +} + +TEST_CASE("BookmarkPresenter::get against an unknown id emits failed, not a crash", "[bookmarks][presenter]") { + DbFixture fixture; + auto rig = makeAuthedRig(Mode::Local, "presenter-get-unknown-secret", "alice"); + bookmarks::gui::BookmarkPresenter presenter{rig->bridge(0), rig->executor()}; + + QString failure; + bool failed = false; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::failed, [&](QString message) { + failure = message; + failed = true; + }); + presenter.get(bookmarks::GetBookmark{.id = bookmarks::BookmarkId{999999}}); + REQUIRE(pumpUntil([&] { return failed; })); + CHECK_FALSE(failure.isEmpty()); + REQUIRE_FALSE(presenter.busy()); +} + +TEST_CASE("BookmarkPresenter::list/getChangesSince/exportAll all emit failed with no session at all, " + "not a crash", + "[bookmarks][presenter]") { + // list/getChangesSince/exportAll all have `validate() { return true; }` + // unconditionally -- their only reachable failure is a genuine model-level + // error, not a validation one. `BookmarkModel`'s own `requirePrincipal()` + // (bookmark_model.cpp) throws `Forbidden` before touching the database at + // all when `session::current()` carries no principal, so an unauthenticated + // bridge (no `setDefaultSession` call, mirroring + // test_shared_feed_presenter.cpp's identical "no session" case) reaches + // exactly that path safely. + // + // A dropped-table variant of this case was tried first and reverted: even + // one drop-then-`DbFixture`-reapply cycle against `bookmarks` (a table + // three other tables foreign-key into), run inside this file's much larger + // suite of `BackendRig`-driven test cases, was empirically observed to + // corrupt Lightweight's `SqlMigration` fold-state cache + // (`ComputeUpgradeForTable`'s `.at()` lookup stops finding its key) and + // cascade failures into unrelated later tests across the whole binary, + // including files that never touch a dropped table. Not a bug in + // `BookmarkPresenter` or in this rung's schema -- this case avoids it + // entirely by never mutating the schema mid-suite. + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + bookmarks::gui::BookmarkPresenter presenter{rig.bridge(0), rig.executor()}; + + QString failure; + int failures = 0; + QObject::connect(&presenter, &bookmarks::gui::BookmarkPresenter::failed, [&](QString message) { + failure = message; + ++failures; + }); + + presenter.list(bookmarks::ListBookmarks{}); + REQUIRE(pumpUntil([&] { return failures == 1; })); + REQUIRE_FALSE(presenter.busy()); + + presenter.getChangesSince(bookmarks::GetChangesSince{}); + REQUIRE(pumpUntil([&] { return failures == 2; })); + REQUIRE_FALSE(presenter.busy()); + + presenter.exportAll(bookmarks::ExportBookmarks{}); + REQUIRE(pumpUntil([&] { return failures == 3; })); + CHECK_FALSE(failure.isEmpty()); + REQUIRE_FALSE(presenter.busy()); +} diff --git a/examples/bookmarks/tests/test_bookmark_qml_bridges.cpp b/examples/bookmarks/tests/test_bookmark_qml_bridges.cpp new file mode 100644 index 00000000..00d4c902 --- /dev/null +++ b/examples/bookmarks/tests/test_bookmark_qml_bridges.cpp @@ -0,0 +1,883 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The QML-adapter layer's own suite: `FormsBridge`, `BookmarkBridge`, +// `TagBridge` and `SharedFeedBridge` (`gui_lib/bookmark_qml_bridges.hpp`) plus +// the action-type routing in `BookmarkFormsController::dispatch` +// (`gui_lib/bookmark_forms_controller.cpp`) — everything that stands between +// the Task 17 presenters and the QML shell. +// +// Why this file exists as a *separate* suite from test_bookmark_presenter.cpp: +// those adapters are the only place in the rung where a `BookmarkView` becomes +// a `QVariantMap`, an action type becomes a routing-table string, and a signal +// acquires the exact name and signature `gui/qml/Main.qml`, +// `gui/qml/LoginView.qml` and `gui/qml/BookmarkListView.qml` bind against. QML +// binds by *string*, so a renamed key, a mistyped action id or a changed +// signal signature is not a compile error anywhere — it is a silently empty +// label at run time, and the offscreen engine-load smoke test +// (test_gui_qml_smoke.cpp) deliberately loads the QML with every controller +// null, so it cannot catch it either. Every assertion below that names a +// string key, an action id or a signal signature is therefore a cross-check +// against a real binding site in those three QML files, cited inline. Mirrors +// rung 1's own `examples/pastebin/tests/test_paste_qml_bridges.cpp`, which +// established this suite's shape. +// +// All four adapters are Qt-Core-only (`QVariantMap` is Qt Core; the +// engine-facing side is `setInitialProperties` in the shell), so they +// instantiate under the testkit's owned application object exactly like the +// presenters do — no QML engine, no window. Domain rules (ownership, tag +// diffing, archive filtering, bulk atomicity, the shared feed's query) are the +// models' and are covered in test_bookmark_model.cpp / test_tag_model.cpp / +// test_shared_feed_model.cpp; routing and busy/idle are the presenters' and are +// covered in their own suites. This file only proves the translation. +// +// ── Arms that are structurally unreachable, and are therefore not asserted ── +// Three of the private renderers in bookmark_qml_bridges.cpp have an arm no +// test in this file can reach, because nothing in the rung can *produce* the +// input: +// * `readStateText(ReadState::Read)` — no action anywhere in the rung clears +// `BookmarkRecord::isUnread` (it is `true` at construction and is only ever +// read, in `bookmark_model.cpp` and `shared_feed_model.cpp`), so every row +// any client can ever see is `Unread`. There is no "mark as read" action. +// * `isoOrEmpty`'s empty arm — every `Timestamp` in a bookmark bag comes from +// `bookmark_model.cpp`'s `fromEpochMs`, which always returns an engaged +// `Timestamp`, and both `createdAtMs`/`updatedAtMs` are stamped on insert. +// * `countText`'s `"N/A"` arm — every `Count` that reaches a bag is built by +// `Count::fromDouble`, which is always engaged. +// They are defensive, not dead-by-mistake (each mirrors a shape rung 1 does +// reach), and reaching them from here would mean exposing the renderers +// themselves purely for a test. Stated rather than silently skipped; if a later +// rung adds the missing action, the arms become reachable and belong here. + +#include "bookmark_qml_bridges.hpp" +#include "bookmark_schemas.hpp" +#include "bookmarks/auth/bookmarks_authorizer.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +constexpr std::string_view kSecret = "qml-bridges-test-secret"; + +/// @brief Builds a rig whose one bridge already carries a valid session for +/// @p principal — the state a client is in *after* login. +/// +/// Every action in this rung needs a populated `session::current()->principal` +/// for the model's own scoping to succeed, even in `Mode::Local` (which runs no +/// authorizer at all) — the same recipe, and the same reason, as +/// test_bookmark_presenter.cpp's own helper. +/// @param principal The identity to install. +/// @return The rig, owning the bridge and executor the adapters take. +[[nodiscard]] std::unique_ptr makeAuthedRig(std::string principal) { + auto rig = std::make_unique(Mode::Local, 1); + const morph::session::TokenIssuer issuer{std::string{kSecret}, morph::session::hmacSha256}; + morph::session::Context ctx; + ctx.principal = std::move(principal); + // Every field named, not just the two that matter: `-Weverything` includes + // `-Wmissing-designated-field-initializers`, which fires on a partial + // designated-initializer list (see test_app.cpp's own note on this). + ctx.token = issuer.issue(morph::session::SessionToken{ + .principal = ctx.principal, + .issuedAtMs = 0, + .expiresAtMs = 4102444800000, // year 2100, far future + .roles = {}, + }); + rig->bridge(0).setDefaultSession(ctx); + return rig; +} + +/// @brief Installs a process-global `TokenIssuer` for a scope and clears it +/// again on the way out — `AuthModel::execute(const Login&)` throws +/// without one. Same shape, and the same +/// failing-REQUIRE-must-not-leak-it rationale, as +/// test_bookmarks_authorizer.cpp's own. +class ScopedTokenIssuer { + public: + explicit ScopedTokenIssuer(std::shared_ptr issuer) { + bookmarks::auth::setTokenIssuer(std::move(issuer)); + } + ~ScopedTokenIssuer() { bookmarks::auth::setTokenIssuer(nullptr); } + ScopedTokenIssuer(const ScopedTokenIssuer&) = delete; + ScopedTokenIssuer& operator=(const ScopedTokenIssuer&) = delete; + ScopedTokenIssuer(ScopedTokenIssuer&&) = delete; + ScopedTokenIssuer& operator=(ScopedTokenIssuer&&) = delete; +}; + +/// @brief One `submitIfValid` round trip, exactly as a `DynamicForm`'s submit +/// button performs it. +/// @param forms The bridge to submit through. +/// @param actionType The action id QML names as a string literal. +/// @param bodyJson Fully-assembled JSON body, as `DynamicForm` builds it. +/// @return `{ok, payload}` from the single `replyReceived` the submit produces. +[[nodiscard]] std::pair submit(bookmarks::gui::FormsBridge& forms, const QString& actionType, + const QString& bodyJson) { + bool replied = false; + bool ok = false; + QString payload; + QString echoedType; + const auto connection = + QObject::connect(&forms, &bookmarks::gui::FormsBridge::replyReceived, + [&](const QString& type, bool succeeded, const QString& body) { + echoedType = type; + ok = succeeded; + payload = body; + replied = true; + }); + forms.submitIfValid(actionType, bodyJson); + const bool settled = pumpUntil([&] { return replied; }); + QObject::disconnect(connection); + REQUIRE(settled); + // BookmarkListView.qml:190 dispatches on the echoed type (it returns early + // for "Login" and resets a different form for each of the others), so a + // normalised or empty echo would misroute every outcome on that screen. + REQUIRE(echoedType == actionType); + return {ok, payload}; +} + +/// @brief Creates one bookmark through the schema-driven form path and returns +/// its id in the `qlonglong` shape list rows and invokables use. +/// +/// This is the composition the shell actually performs: `BookmarkListView.qml` +/// creates through `formsController.submitIfValid` (:249) and reads the outcome +/// in `onReplyReceived` (:190), never through `bookmarkController` — +/// `BookmarkBridge` relays no `created` signal at all (see +/// bookmark_qml_bridges.hpp's comment on why that is deliberate). The id comes +/// out of the reply payload, a `CreateBookmarkResult` (`{"id": …}`). +/// @param forms The bridge to submit through. +/// @param bodyJson A `CreateBookmark` body. +/// @return The new bookmark's id. +[[nodiscard]] qlonglong createVia(bookmarks::gui::FormsBridge& forms, const QString& bodyJson) { + const auto [ok, payload] = submit(forms, QStringLiteral("CreateBookmark"), bodyJson); + REQUIRE(ok); + const QJsonDocument reply = QJsonDocument::fromJson(payload.toUtf8()); + REQUIRE(reply.isObject()); + const auto id = reply.object().value(QStringLiteral("id")).toVariant().toLongLong(); + REQUIRE(id > 0); + return id; +} + +/// @brief `BookmarkBridge::open`'s one bag. +/// @param bridge The bridge to read through. +/// @param id The bookmark to open. +/// @return The property bag `loaded` carried. +[[nodiscard]] QVariantMap openBag(bookmarks::gui::BookmarkBridge& bridge, qlonglong id) { + QVariantMap bag; + bool loaded = false; + const auto connection = QObject::connect(&bridge, &bookmarks::gui::BookmarkBridge::loaded, + [&](const QVariantMap& bookmark) { + bag = bookmark; + loaded = true; + }); + bridge.open(id); + const bool settled = pumpUntil([&] { return loaded; }); + QObject::disconnect(connection); + REQUIRE(settled); + return bag; +} + +/// @brief The rows `BookmarkBridge::refresh` (or `refreshIncludingArchived`) +/// hands the list delegate. +/// @tparam Refresh Callable invoked to start the listing. +/// @param bridge The bridge to list through. +/// @param refresh Which listing to start. +/// @return The page's rows. +template +[[nodiscard]] QVariantList listRows(bookmarks::gui::BookmarkBridge& bridge, Refresh refresh) { + QVariantList rows; + bool listed = false; + const auto connection = QObject::connect(&bridge, &bookmarks::gui::BookmarkBridge::listed, + [&](const QVariantList& page) { + rows = page; + listed = true; + }); + refresh(); + const bool settled = pumpUntil([&] { return listed; }); + QObject::disconnect(connection); + REQUIRE(settled); + return rows; +} + +/// @brief `TagBridge::refresh`'s rows. +/// @param tags The bridge to list through. +/// @return The tag rows. +[[nodiscard]] QVariantList tagRows(bookmarks::gui::TagBridge& tags) { + QVariantList rows; + bool listed = false; + const auto connection = + QObject::connect(&tags, &bookmarks::gui::TagBridge::listed, [&](const QVariantList& page) { + rows = page; + listed = true; + }); + tags.refresh(); + const bool settled = pumpUntil([&] { return listed; }); + QObject::disconnect(connection); + REQUIRE(settled); + return rows; +} + +/// @brief The id of the tag named @p name in @p rows. +/// @param rows Tag rows from `TagBridge::listed`. +/// @param name The tag name to find. +/// @return Its id, or `-1` if absent. +[[nodiscard]] qlonglong tagIdNamed(const QVariantList& rows, const QString& name) { + for (const QVariant& row : rows) { + const QVariantMap bag = row.toMap(); + if (bag.value(QStringLiteral("name")).toString() == name) { + return bag.value(QStringLiteral("id")).toLongLong(); + } + } + return -1; +} + +/// @brief How many methods a class declares itself (signals + `Q_INVOKABLE`s), +/// i.e. excluding everything it inherits from `QObject`. +/// @param meta The class's meta-object. +/// @return The count of own methods. +[[nodiscard]] int ownMethodCount(const QMetaObject* meta) { return meta->methodCount() - meta->methodOffset(); } + +} // namespace + +// ═════════════════════════════════════════════════════════════════════════ +// The QML-visible surface: names and signatures QML binds by string +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("FormsBridge exposes exactly the surface DynamicForm, LoginView.qml and BookmarkListView.qml bind against", + "[bookmarks][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + bookmarks::gui::FormsBridge forms{rig->bridge(0), rig->executor()}; + + const QMetaObject* meta = forms.metaObject(); + + // `root.formsController.schemasJson` — Main.qml:35. + REQUIRE(meta->indexOfProperty("schemasJson") >= 0); + CHECK(meta->property(meta->indexOfProperty("schemasJson")).isConstant()); + CHECK(meta->propertyCount() - meta->propertyOffset() == 1); + + // `page.formsController.submitIfValid("Login", loginForm.previewLine)` — + // LoginView.qml:90; the same call with five other action ids in + // BookmarkListView.qml (:249, :413, :428, :480, :495). Two QString + // arguments, invokable from QML. + REQUIRE(meta->indexOfMethod("submitIfValid(QString,QString)") >= 0); + + // `function onReplyReceived(actionType, ok, payload)` — LoginView.qml:42 + // and BookmarkListView.qml:190; `function onLoggedIn(principal)` — + // Main.qml:47. + REQUIRE(meta->indexOfSignal("replyReceived(QString,bool,QString)") >= 0); + REQUIRE(meta->indexOfSignal("loggedIn(QString)") >= 0); + + // Nothing else: an adapter method with no binding site is a stub, and one + // removed from under a binding is a silent runtime gap. + CHECK(ownMethodCount(meta) == 3); + + // The property's value is the shared schema document, verbatim — the same + // one every shell builds (bookmark_schemas.hpp exists so they cannot + // diverge), and `JSON.parse`-able, since Main.qml:35 does exactly that. + CHECK(forms.schemasJson().toStdString() == bookmarks::gui::bookmarkSchemasJson()); + const QJsonDocument schemas = QJsonDocument::fromJson(forms.schemasJson().toUtf8()); + REQUIRE(schemas.isObject()); + // The six action ids QML passes to `submitIfValid` as string literals must + // each have a schema to render from, or the form is blank. + for (const char* actionType : {"Login", "CreateBookmark", "EditBookmark", "ImportBookmarks", "RenameTag", + "MergeTags"}) { + INFO("missing schema: " << actionType); + CHECK(schemas.object().contains(QString::fromLatin1(actionType))); + } + CHECK(schemas.object().size() == 6); +} + +TEST_CASE("BookmarkBridge exposes exactly the surface BookmarkListView.qml binds against", + "[bookmarks][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + bookmarks::gui::BookmarkBridge bookmarkBridge{rig->bridge(0), rig->executor()}; + + const QMetaObject* meta = bookmarkBridge.metaObject(); + + // `page.bookmarkController.refresh()` (BookmarkListView.qml:68), + // `.refreshIncludingArchived()` (:66), `.open(row.modelData.id)` (:301), + // `.archive(page.currentBookmark.id)` (:377), `.unarchive(...)` (:383), + // `.remove(...)` (:389), `.bulkArchive(page.selectedIds, true/false)` + // (:323, :329). + REQUIRE(meta->indexOfMethod("refresh()") >= 0); + REQUIRE(meta->indexOfMethod("refreshIncludingArchived()") >= 0); + REQUIRE(meta->indexOfMethod("open(qlonglong)") >= 0); + REQUIRE(meta->indexOfMethod("archive(qlonglong)") >= 0); + REQUIRE(meta->indexOfMethod("unarchive(qlonglong)") >= 0); + REQUIRE(meta->indexOfMethod("remove(qlonglong)") >= 0); + REQUIRE(meta->indexOfMethod("bulkArchive(QVariantList,bool)") >= 0); + + // `function onBound()` / `onListed(rows)` / `onLoaded(bookmark)` / + // `onArchived()` / `onUnarchived()` / `onRemoved()` / `onBulkEdited(affected)` / + // `onFailed(message)` — BookmarkListView.qml:105, :109, :114, :119, :124, :129, + // :135, :141. + REQUIRE(meta->indexOfSignal("bound()") >= 0); + REQUIRE(meta->indexOfSignal("listed(QVariantList)") >= 0); + REQUIRE(meta->indexOfSignal("loaded(QVariantMap)") >= 0); + REQUIRE(meta->indexOfSignal("archived()") >= 0); + REQUIRE(meta->indexOfSignal("unarchived()") >= 0); + REQUIRE(meta->indexOfSignal("removed()") >= 0); + REQUIRE(meta->indexOfSignal("bulkEdited(QString)") >= 0); + REQUIRE(meta->indexOfSignal("failed(QString)") >= 0); + + CHECK(ownMethodCount(meta) == 15); + // `bulkEdited` carries an already-rendered *string*, not a number: + // BookmarkListView.qml:148 concatenates it straight into a status line. + const int bulkEdited = meta->indexOfSignal("bulkEdited(QString)"); + REQUIRE(bulkEdited >= 0); + CHECK(meta->method(bulkEdited).parameterMetaType(0).id() == QMetaType::QString); +} + +TEST_CASE("TagBridge and SharedFeedBridge expose exactly the surface BookmarkListView.qml binds against", + "[bookmarks][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + bookmarks::gui::TagBridge tags{rig->bridge(0), rig->executor()}; + bookmarks::gui::SharedFeedBridge feed{rig->bridge(0), rig->executor()}; + + // `page.tagController.refresh()` (BookmarkListView.qml:74) and + // `function onBound()` / `onListed(rows)` / `onFailed(message)` (:149, + // :153, :157). + const QMetaObject* tagMeta = tags.metaObject(); + REQUIRE(tagMeta->indexOfMethod("refresh()") >= 0); + REQUIRE(tagMeta->indexOfSignal("bound()") >= 0); + REQUIRE(tagMeta->indexOfSignal("listed(QVariantList)") >= 0); + REQUIRE(tagMeta->indexOfSignal("failed(QString)") >= 0); + CHECK(ownMethodCount(tagMeta) == 4); + + // `page.feedController.refresh()` (:76) and the same three signals (:165, + // :169, :173). Same surface, deliberately: the feed pane is the bookmark + // list's read-only twin. + const QMetaObject* feedMeta = feed.metaObject(); + REQUIRE(feedMeta->indexOfMethod("refresh()") >= 0); + REQUIRE(feedMeta->indexOfSignal("bound()") >= 0); + REQUIRE(feedMeta->indexOfSignal("listed(QVariantList)") >= 0); + REQUIRE(feedMeta->indexOfSignal("failed(QString)") >= 0); + CHECK(ownMethodCount(feedMeta) == 4); +} + +// ═════════════════════════════════════════════════════════════════════════ +// The property-bag shapes: exactly N keys, no leaked field +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("BookmarkBridge::open emits a bookmark bag carrying every key BookmarkListView.qml reads", + "[bookmarks][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + bookmarks::gui::FormsBridge forms{rig->bridge(0), rig->executor()}; + bookmarks::gui::BookmarkBridge bookmarkBridge{rig->bridge(0), rig->executor()}; + + const qlonglong id = createVia( + forms, QStringLiteral(R"({"url":"https://bag.example","title":"Bag","description":"desc","notes":"private note",)" + R"("tags":["work","home"]})")); + const QVariantMap bag = openBag(bookmarkBridge, id); + + // Every key below is read by name in QML: `title`/`url` from + // BookmarkListView.qml:345-347, `description`/`notes`/`tags`/`visibility`/ + // `readState`/`archiveState`/`createdAt`/`updatedAt` from the detail + // Repeater's model (:352-360), `id` from :377, :383, :389. + for (const char* key : {"id", "url", "title", "description", "notes", "tags", "createdAt", "updatedAt", + "readState", "archiveState", "visibility"}) { + INFO("missing key: " << key); + REQUIRE(bag.contains(QString::fromLatin1(key))); + } + // Nothing extra: the bag is exactly these eleven, so a key added here + // without a QML binding (or removed from under one) shows up as a failure + // rather than as dead weight. + CHECK(bag.size() == 11); + + CHECK(bag.value(QStringLiteral("id")).toLongLong() == id); + CHECK(bag.value(QStringLiteral("url")).toString() == QStringLiteral("https://bag.example")); + CHECK(bag.value(QStringLiteral("title")).toString() == QStringLiteral("Bag")); + CHECK(bag.value(QStringLiteral("description")).toString() == QStringLiteral("desc")); + CHECK(bag.value(QStringLiteral("notes")).toString() == QStringLiteral("private note")); + + // `id` is a *number*, not a string: `open`/`archive`/`unarchive`/`remove` + // all take `qlonglong`, and BookmarkListView.qml feeds them straight from + // this bag (:377) and from a list row (:301). + CHECK(bag.value(QStringLiteral("id")).typeId() == QMetaType::LongLong); + // `tags` is a list, because :355 calls `.join(", ")` on it. + REQUIRE(bag.value(QStringLiteral("tags")).typeId() == QMetaType::QVariantList); + const QVariantList tags = bag.value(QStringLiteral("tags")).toList(); + CHECK(tags.size() == 2); + // Every *other* value is already a display string — the detail pane + // concatenates them into a Label with no formatting of its own (rule 2's + // "pure glue" allowance depends on this being true here). + for (auto it = bag.cbegin(); it != bag.cend(); ++it) { + if (it.key() == QStringLiteral("id") || it.key() == QStringLiteral("tags")) { + continue; + } + INFO("non-string value for key: " << it.key().toStdString()); + CHECK(it.value().typeId() == QMetaType::QString); + } + + // The three enum renderers, in their default arms, rendered as the words + // the detail pane displays verbatim. + CHECK(bag.value(QStringLiteral("visibility")).toString() == QStringLiteral("Private")); + CHECK(bag.value(QStringLiteral("readState")).toString() == QStringLiteral("Unread")); + CHECK(bag.value(QStringLiteral("archiveState")).toString() == QStringLiteral("Active")); + + // `isoOrEmpty`'s engaged arm — a real ISO-8601 instant, shown verbatim. + const QString created = bag.value(QStringLiteral("createdAt")).toString(); + CHECK(created.contains(QLatin1Char('T'))); + CHECK(created.endsWith(QLatin1Char('Z'))); + CHECK_FALSE(bag.value(QStringLiteral("updatedAt")).toString().isEmpty()); +} + +TEST_CASE("BookmarkBridge::refresh emits rows in the narrower summary shape, with no notes key", + "[bookmarks][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + bookmarks::gui::FormsBridge forms{rig->bridge(0), rig->executor()}; + bookmarks::gui::BookmarkBridge bookmarkBridge{rig->bridge(0), rig->executor()}; + + static_cast(createVia( + forms, QStringLiteral(R"({"url":"https://row.example","title":"Row","notes":"must not leak"})"))); + + const QVariantList rows = listRows(bookmarkBridge, [&bookmarkBridge] { bookmarkBridge.refresh(); }); + REQUIRE(rows.size() == 1); + const QVariantMap bag = rows.front().toMap(); + + // `id`/`title`/`url`/`visibility`/`archiveState` are read off `modelData` + // at BookmarkListView.qml:290, :296-298, :301, :307; the remaining four are + // the summary shape the shared-feed delegate also reads (:516-517). + for (const char* key : {"id", "url", "title", "tags", "createdAt", "updatedAt", "readState", "archiveState", + "visibility"}) { + INFO("missing key: " << key); + REQUIRE(bag.contains(QString::fromLatin1(key))); + } + // Narrower than the `loaded` bag *on purpose*: a listing must not leak + // `notes` (`bookmarks/dto/bookmark_dto.hpp`'s `BookmarkSummary`). This + // assertion is the one that would catch a well-meaning widening of the + // summary bag into a full `BookmarkView` map. + CHECK(bag.size() == 9); + CHECK_FALSE(bag.contains(QStringLiteral("notes"))); + CHECK_FALSE(bag.contains(QStringLiteral("description"))); + + CHECK(bag.value(QStringLiteral("id")).typeId() == QMetaType::LongLong); + CHECK(bag.value(QStringLiteral("title")).toString() == QStringLiteral("Row")); + CHECK(bag.value(QStringLiteral("visibility")).toString() == QStringLiteral("Private")); + CHECK(bag.value(QStringLiteral("archiveState")).toString() == QStringLiteral("Active")); +} + +TEST_CASE("TagBridge::refresh emits {id, name, bookmarkCount} rows and nothing else", + "[bookmarks][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + bookmarks::gui::FormsBridge forms{rig->bridge(0), rig->executor()}; + bookmarks::gui::TagBridge tags{rig->bridge(0), rig->executor()}; + + static_cast( + createVia(forms, QStringLiteral(R"({"url":"https://tagged.example","tags":["work"]})"))); + + const QVariantList rows = tagRows(tags); + REQUIRE(rows.size() == 1); + const QVariantMap bag = rows.front().toMap(); + + // `modelData.id` / `.name` / `.bookmarkCount` — BookmarkListView.qml:455-456. + for (const char* key : {"id", "name", "bookmarkCount"}) { + INFO("missing key: " << key); + REQUIRE(bag.contains(QString::fromLatin1(key))); + } + CHECK(bag.size() == 3); + CHECK(bag.value(QStringLiteral("name")).toString() == QStringLiteral("work")); + // The id is a number the rename/merge forms are filled in with by hand + // (":455" prints it after a '#'); the count is already a display string, + // concatenated straight into the same label. + CHECK(bag.value(QStringLiteral("id")).typeId() == QMetaType::LongLong); + CHECK(bag.value(QStringLiteral("id")).toLongLong() > 0); + REQUIRE(bag.value(QStringLiteral("bookmarkCount")).typeId() == QMetaType::QString); + const QString count = bag.value(QStringLiteral("bookmarkCount")).toString(); + CHECK(count.startsWith(QStringLiteral("1"))); + CHECK(count != QStringLiteral("N/A")); +} + +TEST_CASE("SharedFeedBridge::refresh emits the same summary shape, and only Shared bookmarks", + "[bookmarks][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + bookmarks::gui::FormsBridge forms{rig->bridge(0), rig->executor()}; + bookmarks::gui::SharedFeedBridge feed{rig->bridge(0), rig->executor()}; + + static_cast(createVia( + forms, QStringLiteral(R"({"url":"https://shared.example","title":"Shared one","notes":"must not leak",)" + R"("visibility":"Shared"})"))); + static_cast(createVia(forms, QStringLiteral(R"({"url":"https://private.example"})"))); + + QVariantList rows; + bool listed = false; + QObject::connect(&feed, &bookmarks::gui::SharedFeedBridge::listed, [&](const QVariantList& page) { + rows = page; + listed = true; + }); + feed.refresh(); + REQUIRE(pumpUntil([&] { return listed; })); + + REQUIRE(rows.size() == 1); + const QVariantMap bag = rows.front().toMap(); + // Same nine keys as BookmarkBridge::listed — the shared feed reuses + // `BookmarkSummary`, so the same non-leak rule applies here too. + CHECK(bag.size() == 9); + CHECK_FALSE(bag.contains(QStringLiteral("notes"))); + // `modelData.title` / `.url` / `.createdAt` — BookmarkListView.qml:516-517. + CHECK(bag.value(QStringLiteral("title")).toString() == QStringLiteral("Shared one")); + CHECK_FALSE(bag.value(QStringLiteral("createdAt")).toString().isEmpty()); + // `visibilityText`'s *other* arm: the feed only ever carries Shared rows. + CHECK(bag.value(QStringLiteral("visibility")).toString() == QStringLiteral("Shared")); +} + +// ═════════════════════════════════════════════════════════════════════════ +// The renderers' second arms, and bulkArchive's bool -> BulkArchiveOp map +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("BookmarkBridge renders the second arm of the visibility and archive-state renderers", + "[bookmarks][gui][qml-bridges]") { + // The bag cases above exercise each renderer's *default* arm (Private, + // Unread, Active). This one exercises the other arm of the two that a + // client can actually reach, which is where a formatting regression would + // be visible: BookmarkListView.qml:307 shows + // `visibility + " · " + archiveState` on every row, verbatim. + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + bookmarks::gui::FormsBridge forms{rig->bridge(0), rig->executor()}; + bookmarks::gui::BookmarkBridge bookmarkBridge{rig->bridge(0), rig->executor()}; + + const qlonglong id = + createVia(forms, QStringLiteral(R"({"url":"https://arms.example","visibility":"Shared"})")); + CHECK(openBag(bookmarkBridge, id).value(QStringLiteral("visibility")).toString() == QStringLiteral("Shared")); + + bool archived = false; + QObject::connect(&bookmarkBridge, &bookmarks::gui::BookmarkBridge::archived, [&] { archived = true; }); + bookmarkBridge.archive(id); + REQUIRE(pumpUntil([&] { return archived; })); + CHECK(openBag(bookmarkBridge, id).value(QStringLiteral("archiveState")).toString() == QStringLiteral("Archived")); + + // The archived row is gone from the default listing and back in the + // archive-inclusive one — the two `refresh` invokables the toggle at + // BookmarkListView.qml:66-68 switches between. + CHECK(listRows(bookmarkBridge, [&bookmarkBridge] { bookmarkBridge.refresh(); }).isEmpty()); + CHECK(listRows(bookmarkBridge, [&bookmarkBridge] { bookmarkBridge.refreshIncludingArchived(); }).size() == 1); + + bool unarchived = false; + QObject::connect(&bookmarkBridge, &bookmarks::gui::BookmarkBridge::unarchived, [&] { unarchived = true; }); + bookmarkBridge.unarchive(id); + REQUIRE(pumpUntil([&] { return unarchived; })); + CHECK(openBag(bookmarkBridge, id).value(QStringLiteral("archiveState")).toString() == QStringLiteral("Active")); +} + +TEST_CASE("BookmarkBridge::bulkArchive maps true to BulkArchiveOp::Archive and false to Unarchive", + "[bookmarks][gui][qml-bridges]") { + // The one place in the client where a QML `bool` becomes a domain enum + // (`bulkArchive(page.selectedIds, true)` at BookmarkListView.qml:323, and + // `false` at :329). Inverting the ternary would archive on "Unarchive" and + // vice versa, with no compile error and no visible difference until a user + // pressed the wrong-behaving button. + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + bookmarks::gui::FormsBridge forms{rig->bridge(0), rig->executor()}; + bookmarks::gui::BookmarkBridge bookmarkBridge{rig->bridge(0), rig->executor()}; + + const qlonglong first = createVia(forms, QStringLiteral(R"({"url":"https://bulk-one.example"})")); + const qlonglong second = createVia(forms, QStringLiteral(R"({"url":"https://bulk-two.example"})")); + const QVariantList ids{QVariant{first}, QVariant{second}}; + + QString affected; + int bulkEdits = 0; + QObject::connect(&bookmarkBridge, &bookmarks::gui::BookmarkBridge::bulkEdited, [&](const QString& count) { + affected = count; + ++bulkEdits; + }); + + bookmarkBridge.bulkArchive(ids, true); + REQUIRE(pumpUntil([&] { return bulkEdits == 1; })); + // `affected` reaches QML already rendered ("bulk edit affected N + // bookmark(s)", :148). + CHECK(affected.startsWith(QStringLiteral("2"))); + CHECK(listRows(bookmarkBridge, [&bookmarkBridge] { bookmarkBridge.refresh(); }).isEmpty()); + for (const QVariant& row : listRows(bookmarkBridge, [&bookmarkBridge] { bookmarkBridge.refreshIncludingArchived(); })) { + CHECK(row.toMap().value(QStringLiteral("archiveState")).toString() == QStringLiteral("Archived")); + } + + // ...and the other direction, on the same two rows. + bookmarkBridge.bulkArchive(ids, false); + REQUIRE(pumpUntil([&] { return bulkEdits == 2; })); + const QVariantList active = listRows(bookmarkBridge, [&bookmarkBridge] { bookmarkBridge.refresh(); }); + REQUIRE(active.size() == 2); + for (const QVariant& row : active) { + CHECK(row.toMap().value(QStringLiteral("archiveState")).toString() == QStringLiteral("Active")); + } +} + +// ═════════════════════════════════════════════════════════════════════════ +// BookmarkFormsController::dispatch — the six-entry routing table +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("BookmarkFormsController::dispatch routes every one of the six form actions to the model that serves it", + "[bookmarks][gui][qml-bridges]") { + // `dispatch()` maps an action-type *string* to one of three + // `BridgeHandler`s. A typo, or a new action added to bookmark_schemas.hpp + // and forgotten here, is not a compile error: the form renders, the button + // submits, and the reply is an error message. This case submits all six + // ids exactly as the QML string literals spell them. + DbFixture fixture; + const ScopedTokenIssuer issuer{ + std::make_shared(std::string{kSecret}, morph::session::hmacSha256)}; + // Deliberately *not* pre-authenticated: the Login route below is what + // installs the session the other five need, which is the real client's own + // startup order. + BackendRig rig{Mode::Local, 1}; + bookmarks::gui::FormsBridge forms{rig.bridge(0), rig.executor()}; + bookmarks::gui::TagBridge tags{rig.bridge(0), rig.executor()}; + + // 1/6 — Login -> AuthModel. + { + const auto [ok, payload] = submit(forms, QStringLiteral("Login"), QStringLiteral(R"({"username":"alice"})")); + REQUIRE(ok); + CHECK(payload.contains(QStringLiteral("\"principal\""))); + } + + // 2/6 — CreateBookmark -> BookmarkModel. Reaching the model at all proves + // Login's reply was decoded and installed as the bridge's default session. + const qlonglong id = createVia( + forms, QStringLiteral(R"({"url":"https://route.example","tags":["work","home"]})")); + + // 3/6 — EditBookmark -> BookmarkModel. + { + const auto [ok, payload] = + submit(forms, QStringLiteral("EditBookmark"), + QStringLiteral(R"({"id":%1,"url":"https://edited.example","title":"Edited"})").arg(id)); + INFO(payload.toStdString()); + REQUIRE(ok); + } + + // 4/6 — ImportBookmarks -> BookmarkModel. + { + const auto [ok, payload] = + submit(forms, QStringLiteral("ImportBookmarks"), + QStringLiteral(R"({"chunk":"
Imported",)" + R"("opId":"import-op-1"})")); + INFO(payload.toStdString()); + REQUIRE(ok); + CHECK(payload.contains(QStringLiteral("\"imported\""))); + } + + // 5/6 — RenameTag -> TagModel. The ids come from the tag list, exactly as + // the user reads them off BookmarkListView.qml:455 before typing them in. + const QVariantList before = tagRows(tags); + REQUIRE(before.size() == 2); + const qlonglong workId = tagIdNamed(before, QStringLiteral("work")); + const qlonglong homeId = tagIdNamed(before, QStringLiteral("home")); + REQUIRE(workId > 0); + REQUIRE(homeId > 0); + { + const auto [ok, payload] = submit(forms, QStringLiteral("RenameTag"), + QStringLiteral(R"({"id":%1,"name":"office"})").arg(workId)); + INFO(payload.toStdString()); + REQUIRE(ok); + } + CHECK(tagIdNamed(tagRows(tags), QStringLiteral("office")) == workId); + + // 6/6 — MergeTags -> TagModel. + { + const auto [ok, payload] = + submit(forms, QStringLiteral("MergeTags"), + QStringLiteral(R"({"sourceId":%1,"targetId":%2})").arg(homeId).arg(workId)); + INFO(payload.toStdString()); + REQUIRE(ok); + } + const QVariantList after = tagRows(tags); + CHECK(after.size() == 1); + CHECK(tagIdNamed(after, QStringLiteral("home")) == -1); +} + +TEST_CASE("BookmarkFormsController::dispatch reports an unrouted action type instead of dropping it", + "[bookmarks][gui][qml-bridges]") { + // The exact failure mode the routing table risks: a QML string literal + // that no `if` in `dispatch()` matches. It must surface as a message in + // the status line (BookmarkListView.qml:193 renders `actionType + ": " + + // payload` on `!ok`), never as a submit that silently does nothing. + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + bookmarks::gui::FormsBridge forms{rig->bridge(0), rig->executor()}; + + // A plausible typo of a real id, and a name from a model this client does + // not serve forms for at all. + for (const auto& actionType : {QStringLiteral("CreateBookmarks"), QStringLiteral("ListSharedFeed")}) { + const auto [ok, payload] = submit(forms, actionType, QStringLiteral(R"({"url":"https://typo.example"})")); + INFO(actionType.toStdString()); + CHECK_FALSE(ok); + CHECK(payload.contains(QStringLiteral("no model in this client serves action"))); + CHECK(payload.contains(actionType)); + } + + // A *routed* action whose body the model refuses still comes back on the + // same `!ok` arm, with the model's own message — the two failures are + // indistinguishable to QML by design, and both must be non-empty. + const auto [ok, payload] = submit(forms, QStringLiteral("CreateBookmark"), QStringLiteral(R"({"url":""})")); + CHECK_FALSE(ok); + CHECK_FALSE(payload.isEmpty()); + CHECK(payload.contains(QStringLiteral("CreateBookmark"))); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Login: the session-installing seam, and both arms of the reply decode +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("FormsBridge installs the returned token and announces loggedIn before replyReceived", + "[bookmarks][gui][qml-bridges]") { + // `onLoginSucceeded` is the whole of this client's authentication + // handling. Main.qml:47 pushes BookmarkListView on `loggedIn`, and that + // screen dispatches immediately (:66-76), so the token must already be + // installed when the signal fires — the ordering asserted below is load + // bearing, not cosmetic. + DbFixture fixture; + const ScopedTokenIssuer issuer{ + std::make_shared(std::string{kSecret}, morph::session::hmacSha256)}; + BackendRig rig{Mode::Local, 1}; + bookmarks::gui::FormsBridge forms{rig.bridge(0), rig.executor()}; + bookmarks::gui::BookmarkBridge bookmarkBridge{rig.bridge(0), rig.executor()}; + + // Before login the bridge carries no session at all, so a domain action is + // refused — the state a just-launched client is in. + { + QString message; + bool failed = false; + const auto connection = QObject::connect(&bookmarkBridge, &bookmarks::gui::BookmarkBridge::failed, + [&](const QString& text) { + message = text; + failed = true; + }); + bookmarkBridge.refresh(); + REQUIRE(pumpUntil([&] { return failed; })); + QObject::disconnect(connection); + CHECK_FALSE(message.isEmpty()); + } + + QString announced; + int order = 0; + int loggedInAt = 0; + int replyAt = 0; + QObject::connect(&forms, &bookmarks::gui::FormsBridge::loggedIn, [&](const QString& principal) { + announced = principal; + loggedInAt = ++order; + }); + QObject::connect(&forms, &bookmarks::gui::FormsBridge::replyReceived, + [&](const QString&, bool, const QString&) { replyAt = ++order; }); + + forms.submitIfValid(QStringLiteral("Login"), QStringLiteral(R"({"username":"alice"})")); + REQUIRE(pumpUntil([&] { return replyAt != 0; })); + + // The server's echo of the identity it verified, not the client's claim. + CHECK(announced == QStringLiteral("alice")); + REQUIRE(loggedInAt != 0); + CHECK(loggedInAt < replyAt); + + // ...and the same bridge now works, which is the only observable proof + // that `setDefaultSession` was called with the returned token. + QVariantList rows; + bool listed = false; + QObject::connect(&bookmarkBridge, &bookmarks::gui::BookmarkBridge::listed, [&](const QVariantList& page) { + rows = page; + listed = true; + }); + bookmarkBridge.refresh(); + REQUIRE(pumpUntil([&] { return listed; })); + CHECK(rows.isEmpty()); // a real, empty collection — not an error +} + +TEST_CASE("decodeLoginResult accepts a real Login reply and rejects anything that is not one", + "[bookmarks][gui][qml-bridges]") { + // The failure arm's *caller* — `FormsBridge::submitIfValid`'s + // "login succeeded but its reply could not be decoded" branch — cannot be + // reached through any backend the ladder ships, because the reply is + // always written by `resultToJson` from the same reflected type this reads + // back. See `decodeLoginResult`'s own doc comment: the decision was split + // out precisely so both arms are testable without a fake backend. + const auto decoded = + bookmarks::gui::decodeLoginResult(R"({"token":"signed.token.value","principal":"alice"})"); + REQUIRE(decoded.has_value()); + REQUIRE(decoded->token.hasValue()); + CHECK(*decoded->token == "signed.token.value"); + CHECK(decoded->principal == "alice"); + + // Everything a peer could hand back that is *not* a LoginResult. Each must + // yield nullopt rather than a default-constructed result, which is what + // would otherwise be installed as a tokenless session under an empty + // principal — a client that believes it is logged in and is not. + for (const char* body : {"", "not json at all", "[1,2,3]", "null", R"({"token":123,"principal":"alice"})", + R"({"principal":"alice")"}) { + INFO("unexpectedly decoded: " << body); + CHECK_FALSE(bookmarks::gui::decodeLoginResult(body).has_value()); + } +} + +TEST_CASE("decodeLoginResult reads back exactly what a real Login dispatch produced, redacted", + "[bookmarks][gui][qml-bridges]") { + // Pins the assumption the case above rests on: the reply shape asserted + // there by hand is the shape the wire really carries. If `LoginResult`'s + // reflection ever changed, this fails here rather than silently making the + // hand-written literals above test nothing. + // + // `payload` here is `FormsBridge::submitIfValid`'s emitted + // `replyReceived` argument, not the model's raw wire reply -- and for a + // successful `Login` those two are deliberately different + // (`bookmark_qml_bridges.cpp`'s own comment on the `Login` branch): the + // token has already been installed onto the session by the time this + // signal fires, so the QML-facing payload has it redacted rather than + // broadcasting a live bearer credential to every bound handler. + DbFixture fixture; + const ScopedTokenIssuer issuer{ + std::make_shared(std::string{kSecret}, morph::session::hmacSha256)}; + BackendRig rig{Mode::Local, 1}; + bookmarks::gui::FormsBridge forms{rig.bridge(0), rig.executor()}; + bookmarks::gui::BookmarkBridge bookmarkBridge{rig.bridge(0), rig.executor()}; + + const auto [ok, payload] = submit(forms, QStringLiteral("Login"), QStringLiteral(R"({"username":"alice"})")); + REQUIRE(ok); + + const auto decoded = bookmarks::gui::decodeLoginResult(payload.toStdString()); + REQUIRE(decoded.has_value()); + CHECK(decoded->principal == "alice"); + CHECK_FALSE(decoded->token.hasValue()); + + // ...and the same bridge now works, which is the only observable proof + // that `setDefaultSession` was called with the *real* token -- the one + // this test just confirmed never left the process via `payload`. + QVariantList rows; + bool listed = false; + QObject::connect(&bookmarkBridge, &bookmarks::gui::BookmarkBridge::listed, [&](const QVariantList& page) { + rows = page; + listed = true; + }); + bookmarkBridge.refresh(); + REQUIRE(pumpUntil([&] { return listed; })); + CHECK(rows.isEmpty()); // a real, empty collection — not an error +} diff --git a/examples/bookmarks/tests/test_bookmarks_authorizer.cpp b/examples/bookmarks/tests/test_bookmarks_authorizer.cpp new file mode 100644 index 00000000..c6416346 --- /dev/null +++ b/examples/bookmarks/tests/test_bookmarks_authorizer.cpp @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/auth/bookmarks_authorizer.hpp" + +#include "bookmarks/models/auth_model.hpp" +#include "bookmarks/models/bookmark_model.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#include + +#include +#include + +using bookmarks::auth::BookmarksAuthorizer; +using bookmarks::auth::isValidPrincipal; +using bookmarks::auth::kMetadataFetcherPrincipal; +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::session::Context; +using morph::session::SessionToken; +using morph::session::TokenIssuer; + +namespace { +constexpr std::string_view kSecret = "test-only-shared-secret"; + +/// @brief Installs a process-global `TokenIssuer` for a scope and clears it +/// again on the way out, whether the scope exits normally or through a +/// failing Catch2 assertion. +class ScopedTokenIssuer { + public: + explicit ScopedTokenIssuer(std::shared_ptr issuer) { + bookmarks::auth::setTokenIssuer(std::move(issuer)); + } + ~ScopedTokenIssuer() { bookmarks::auth::setTokenIssuer(nullptr); } + ScopedTokenIssuer(const ScopedTokenIssuer&) = delete; + ScopedTokenIssuer& operator=(const ScopedTokenIssuer&) = delete; + ScopedTokenIssuer(ScopedTokenIssuer&&) = delete; + ScopedTokenIssuer& operator=(ScopedTokenIssuer&&) = delete; +}; +} // namespace + +TEST_CASE("isValidPrincipal accepts ordinary usernames and the service principal", + "[bookmarks][auth]") { + CHECK(isValidPrincipal("alice")); + CHECK(isValidPrincipal("alice_2")); + CHECK(isValidPrincipal("alice.smith-99")); + CHECK(isValidPrincipal(kMetadataFetcherPrincipal)); +} + +TEST_CASE("isValidPrincipal rejects the empty string, control bytes, and overlong input", + "[bookmarks][auth]") { + // Empty: never a valid identity to register as. + CHECK_FALSE(isValidPrincipal("")); + // A raw control byte -- the class of input TokenIssuer::issue()'s + // glz::write_json now escapes correctly, but rejected here too, at this + // rung's own boundary, as an independent line of defense regardless. + // Split into two adjacent string-literal tokens: `\x` escapes consume + // every following hex digit, and `c`/`e` are valid hex digits, so an + // unsplit "ali\x01ce" is parsed as the single out-of-range escape + // `\x01ce` rather than `\x01` followed by literal "ce". + CHECK_FALSE(isValidPrincipal(std::string_view{"ali\x01" + "ce", + 6})); + CHECK_FALSE(isValidPrincipal(std::string_view{"ali\nce", 6})); + // 65 bytes -- one past the 64-byte bound. + const std::string tooLong(65, 'a'); + CHECK_FALSE(isValidPrincipal(tooLong)); + // 64 bytes -- the boundary itself is accepted. + const std::string atLimit(64, 'a'); + CHECK(isValidPrincipal(atLimit)); +} + +TEST_CASE("BookmarksAuthorizer authenticates and authorizes a validly signed token", + "[bookmarks][auth]") { + const BookmarksAuthorizer authz{std::string{kSecret}, morph::session::hmacSha256}; + const TokenIssuer issuer{std::string{kSecret}, morph::session::hmacSha256}; + + const std::string token = issuer.issue(SessionToken{ + .principal = "alice", + .issuedAtMs = 0, + .expiresAtMs = 4102444800000, // year 2100, far future + .roles = {}, + }); + + Context ctx; + ctx.token = token; + + CHECK(authz.authorize(ctx, "BookmarkModel", "CreateBookmark")); + const auto principal = authz.authenticate(ctx); + REQUIRE(principal.has_value()); + CHECK(*principal == "alice"); +} + +TEST_CASE("BookmarksAuthorizer rejects a tampered or expired token", "[bookmarks][auth]") { + const BookmarksAuthorizer authz{std::string{kSecret}, morph::session::hmacSha256}; + const TokenIssuer issuer{std::string{kSecret}, morph::session::hmacSha256}; + + const std::string expired = issuer.issue(SessionToken{ + .principal = "alice", + .issuedAtMs = 0, + .expiresAtMs = 1, // 1970-01-01T00:00:00.001Z -- long expired + .roles = {}, + }); + Context expiredCtx; + expiredCtx.token = expired; + CHECK_FALSE(authz.authorize(expiredCtx, "BookmarkModel", "CreateBookmark")); + + const std::string valid = issuer.issue(SessionToken{ + .principal = "alice", + .issuedAtMs = 0, + .expiresAtMs = 4102444800000, + .roles = {}, + }); + Context tamperedCtx; + tamperedCtx.token = valid + "x"; // corrupt the signature + CHECK_FALSE(authz.authorize(tamperedCtx, "BookmarkModel", "CreateBookmark")); + + Context noTokenCtx; // empty token: malformed + CHECK_FALSE(authz.authorize(noTokenCtx, "BookmarkModel", "CreateBookmark")); +} + +TEST_CASE("BookmarksAuthorizer::authorizeRegister admits an anonymous register, by choice " + "rather than necessity", + "[bookmarks][auth]") { + const BookmarksAuthorizer authz{std::string{kSecret}, morph::session::hmacSha256}; + + // `anonymous` is a real, reachable input -- an unauthenticated client's + // first construction -- but no longer the *only* one now that + // `register`/`attach`/`assign`/`deregister` envelopes carry the caller's + // session: an authenticated caller's `ctx.principal` is populated here + // too. This hook stays unconditionally permissive regardless of which + // one it sees -- see authorizeRegister's own doc comment for why. + Context anonymous; + CHECK(authz.authorizeRegister(anonymous, "BookmarkModel")); + CHECK(authz.authorizeRegister(anonymous, "TagModel")); + CHECK(authz.authorizeRegister(anonymous, "SharedFeedModel")); + CHECK(authz.authorizeRegister(anonymous, "AuthModel")); + + // A stamped principal changes nothing -- the decision does not key on it + // in either direction. + Context authenticated; + authenticated.principal = "alice"; + CHECK(authz.authorizeRegister(authenticated, "BookmarkModel")); +} + +TEST_CASE("Registering is not authorizing: an anonymous caller's execute is still refused", + "[bookmarks][auth]") { + // The property that actually carries this rung's trust boundary now that + // authorizeRegister admits everyone. `authorize()` is consulted on every + // single execute (remote.hpp:1160), before authenticate() and before any + // model runs, and it is the inherited SigningAuthorizer one. + const BookmarksAuthorizer authz{std::string{kSecret}, morph::session::hmacSha256}; + + Context anonymous; // no token at all -- exactly what an un-logged-in client has + CHECK_FALSE(authz.authorize(anonymous, "BookmarkModel", "CreateBookmark")); + CHECK_FALSE(authz.authorize(anonymous, "BookmarkModel", "RecordMetadata")); + CHECK_FALSE(authz.authorize(anonymous, "TagModel", "RenameTag")); + + // A token signed with the wrong secret is refused just as flatly -- an + // instance registered anonymously buys a caller no shortcut here. + const TokenIssuer wrongIssuer{std::string{"not-the-server-secret"}, morph::session::hmacSha256}; + Context forged; + forged.token = wrongIssuer.issue(SessionToken{ + .principal = "alice", + .issuedAtMs = 0, + .expiresAtMs = 4102444800000, + .roles = {}, + }); + CHECK_FALSE(authz.authorize(forged, "BookmarkModel", "CreateBookmark")); + CHECK_FALSE(authz.authenticate(forged).has_value()); +} + +TEST_CASE("BookmarksAuthorizer::authorizeInstance enforces real ownership for a " + "plain-registered instance, and passes through an ownerless (shared) one", + "[bookmarks][auth]") { + // `register` envelopes now carry the caller's session, so RemoteServer + // records a real, non-empty `ownerPrincipal` for a plain-registered + // instance -- all three CHECKs below are reachable in production + // (against a real `RemoteServer`, not just at this unit level), not + // merely illustrations of hypothetical future behavior. See the + // function's own doc comment for what this does and does not protect. + const BookmarksAuthorizer authz{std::string{kSecret}, morph::session::hmacSha256}; + + Context asAlice; + asAlice.principal = "alice"; + Context asMallory; + asMallory.principal = "mallory"; + + // A plain-registered instance genuinely recorded "alice" as its owner + // (RemoteServer's real register path, verified in this plan's own + // research -- see remote.hpp:1011): the owner may act on it... + CHECK(authz.authorizeInstance(asAlice, "BookmarkModel", "EditBookmark", 42, "alice")); + // ...a different, real, authenticated principal may not. + CHECK_FALSE(authz.authorizeInstance(asMallory, "BookmarkModel", "EditBookmark", 42, "alice")); + + // An empty recorded owner -- what a *shared* instance always gets + // (remote.hpp:800, "shared instances are ownerless, by design") -- must + // pass through for anyone, matching the framework's own documented + // rationale for why authorizeInstance cannot reject shared access. + CHECK(authz.authorizeInstance(asMallory, "SharedFeedModel", "ListSharedFeed", 7, "")); +} + +TEST_CASE("setTokenIssuer/tokenIssuer share one process-global slot", "[bookmarks][auth]") { + CHECK(bookmarks::auth::tokenIssuer() == nullptr); + auto issuer = std::make_shared(std::string{kSecret}, morph::session::hmacSha256); + bookmarks::auth::setTokenIssuer(issuer); + CHECK(bookmarks::auth::tokenIssuer() == issuer); + bookmarks::auth::setTokenIssuer(nullptr); + CHECK(bookmarks::auth::tokenIssuer() == nullptr); +} + +TEST_CASE("BookmarksAuthorizer::authorize admits Login without a token, and nothing else", + "[bookmarks][auth]") { + // The carve-out that makes login possible at all. Without it + // SigningAuthorizer::authorize() rejects every tokenless execute -- + // including the one action whose whole purpose is handing out the first + // token -- and a fresh client can never get past `err "unauthorized"`. + // See BookmarksAuthorizer::authorize's own doc comment. + const BookmarksAuthorizer authz{std::string{kSecret}, morph::session::hmacSha256}; + const Context anonymous; // no token at all, like a just-launched client + + CHECK(authz.authorize(anonymous, "AuthModel", "Login")); + + // Nothing else is reachable anonymously -- not another action on the same + // model, not the same action name on another model, and not any real + // domain action. + CHECK_FALSE(authz.authorize(anonymous, "AuthModel", "SomethingElse")); + CHECK_FALSE(authz.authorize(anonymous, "BookmarkModel", "Login")); + CHECK_FALSE(authz.authorize(anonymous, "BookmarkModel", "CreateBookmark")); + CHECK_FALSE(authz.authorize(anonymous, "TagModel", "ListTags")); + CHECK_FALSE(authz.authorize(anonymous, "SharedFeedModel", "ListSharedFeed")); + + // A garbage token is still a rejection everywhere but the carve-out -- + // the carve-out ignores the token rather than accepting a bad one. + Context forged; + forged.principal = "alice"; + forged.token = "not.a.real.token"; + CHECK_FALSE(authz.authorize(forged, "BookmarkModel", "CreateBookmark")); + CHECK(authz.authorize(forged, "AuthModel", "Login")); + CHECK_FALSE(authz.authenticate(forged).has_value()); +} + +TEST_CASE("A tokenless client logs in over a real RemoteServer and its token unlocks the rest", + "[bookmarks][auth]") { + // The end-to-end shape of the bug above, at the wire level: this is the + // exact sequence a freshly launched desktop client performs, and the one + // no test covered before task 18 drove the real client against the real + // server (every previous Login test called AuthModel::execute() directly, + // which never consults an authorizer at all). + DbFixture fixture; + const auto authorizer = std::make_shared(std::string{kSecret}, morph::session::hmacSha256); + // RAII, not a trailing reset: a failing REQUIRE below throws, and a + // leaked process-global issuer would then break the sibling case that + // asserts none is installed ("AuthModel::execute(Login) throws when no + // App has installed a TokenIssuer", test_app.cpp) under any run order. + const ScopedTokenIssuer issuer{std::make_shared(std::string{kSecret}, morph::session::hmacSha256)}; + BackendRig rig{Mode::Socket, 1, authorizer}; + + // Deliberately no setDefaultSession: this bridge carries no credential. + morph::bridge::BridgeHandler auth{rig.bridge(0), rig.executor()}; + morph::bridge::BridgeHandler bookmarksHandler{rig.bridge(0), rig.executor()}; + + // Without a token, a domain action is refused by the server. + bookmarks::CreateBookmark beforeLogin; + beforeLogin.url = "https://example.com/before"; + CHECK_THROWS(awaitQt(bookmarksHandler.execute(beforeLogin))); + + const auto result = awaitQt(auth.execute(bookmarks::Login{.username = "alice"})); + REQUIRE(result.token.hasValue()); + CHECK(result.principal == "alice"); + + // Exactly what FormsBridge::onLoginSucceeded does with the reply. + morph::session::Context session; + session.principal = result.principal; + session.token = *result.token; + rig.bridge(0).setDefaultSession(session); + + bookmarks::CreateBookmark afterLogin; + afterLogin.url = "https://example.com/after"; + const auto created = awaitQt(bookmarksHandler.execute(afterLogin)); + REQUIRE(created.id.hasValue()); + + const auto listed = awaitQt(bookmarksHandler.execute(bookmarks::ListBookmarks{})); + REQUIRE(listed.bookmarks.size() == 1); + CHECK(listed.bookmarks.front().url == "https://example.com/after"); +} diff --git a/examples/bookmarks/tests/test_bookmarks_schema.cpp b/examples/bookmarks/tests/test_bookmarks_schema.cpp new file mode 100644 index 00000000..50d43fb6 --- /dev/null +++ b/examples/bookmarks/tests/test_bookmarks_schema.cpp @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/db/bookmark_entity.hpp" +#include "bookmarks/db/bookmark_tag_entity.hpp" +#include "bookmarks/db/imported_op_entity.hpp" +#include "bookmarks/db/tag_entity.hpp" +#include "testkit/db_fixture.hpp" + +#include +#include + +using morph::ladder::testkit::DbFixture; + +TEST_CASE("The bookmarks schema creates all four tables and a bookmark round-trips", + "[bookmarks][schema]") { + DbFixture fixture; + Lightweight::DataMapper mapper; + + bookmarks::db::BookmarkRecord rec; + rec.ownerPrincipal = "alice"; + rec.url = "https://example.com"; + rec.title = "Example"; + rec.createdAtMs = 1000; + rec.updatedAtMs = 1000; + mapper.Create(rec); + REQUIRE(rec.id.Value() > 0); + + bookmarks::db::TagRecord tag; + tag.ownerPrincipal = "alice"; + tag.name = "example"; + mapper.Create(tag); + REQUIRE(tag.id.Value() > 0); + + bookmarks::db::BookmarkTagRecord junction; + junction.bookmark = rec.id.Value(); + junction.tag = tag.id.Value(); + mapper.Create(junction); + REQUIRE(junction.id.Value() > 0); + + bookmarks::db::ImportedOpRecord op; + op.ownerPrincipal = "alice"; + op.opId = "chunk-1"; + op.appliedAtMs = 1000; + mapper.Create(op); + REQUIRE(op.id.Value() > 0); + + // Tag reads go through a plain query, never an embedded relation field + // (Global Constraints) -- proving that path works end-to-end here. + auto rows = mapper.Query() + .Where(Lightweight::FieldNameOf<&bookmarks::db::BookmarkTagRecord::bookmark>, "=", rec.id.Value()) + .All(); + REQUIRE(rows.size() == 1); + CHECK(rows.front().tag.Value() == tag.id.Value()); +} + +TEST_CASE("Duplicate (ownerPrincipal, name) tags are rejected by the unique index", + "[bookmarks][schema]") { + DbFixture fixture; + Lightweight::DataMapper mapper; + bookmarks::db::TagRecord first; + first.ownerPrincipal = "alice"; + first.name = "dup"; + mapper.Create(first); + + bookmarks::db::TagRecord second; + second.ownerPrincipal = "alice"; + second.name = "dup"; + CHECK_THROWS_AS(mapper.Create(second), Lightweight::SqlException); + + // A different owner may reuse the same name -- the index is scoped per owner. + bookmarks::db::TagRecord thirdOwner; + thirdOwner.ownerPrincipal = "bob"; + thirdOwner.name = "dup"; + CHECK_NOTHROW(mapper.Create(thirdOwner)); +} + +TEST_CASE("BookmarkRecord has no relation-typed member -- Update() must compile", + "[bookmarks][schema]") { + // A compile-time proof, not a runtime assertion: if BookmarkRecord ever + // grows an embedded HasMany/HasManyThrough field, this line stops + // compiling with the exact "no member IsModified" error the Global + // Constraints section documents -- catching the regression at build + // time, in the one file whose entire job is proving this works. + DbFixture fixture; + Lightweight::DataMapper mapper; + bookmarks::db::BookmarkRecord rec; + rec.ownerPrincipal = "alice"; + rec.url = "https://example.com"; + rec.createdAtMs = 1; + rec.updatedAtMs = 1; + mapper.Create(rec); + rec.title = "Changed"; + CHECK_NOTHROW(mapper.Update(rec)); +} diff --git a/examples/bookmarks/tests/test_bookmarks_types.cpp b/examples/bookmarks/tests/test_bookmarks_types.cpp new file mode 100644 index 00000000..734d3b78 --- /dev/null +++ b/examples/bookmarks/tests/test_bookmarks_types.cpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/core/errors.hpp" +#include "bookmarks/core/types.hpp" +#include "bookmarks/units.hpp" + +#include +#include + +TEST_CASE("BookmarkId/TagId round-trip through JSON as a nullable integer", "[bookmarks][types]") { + bookmarks::BookmarkId empty; + CHECK_FALSE(empty.hasValue()); + std::string json; + REQUIRE_FALSE(glz::write_json(empty, json)); + CHECK(json == "null"); + + const bookmarks::BookmarkId id{42}; + REQUIRE(id.hasValue()); + CHECK(*id == 42); + json.clear(); + REQUIRE_FALSE(glz::write_json(id, json)); + CHECK(json == "42"); + + bookmarks::TagId decoded; + REQUIRE_FALSE(glz::read_json(decoded, json)); + REQUIRE(decoded.hasValue()); + CHECK(*decoded == 42); +} + +TEST_CASE("BookmarkId equality and ordering follow the payload", "[bookmarks][types]") { + CHECK(bookmarks::BookmarkId{} == bookmarks::BookmarkId{}); + CHECK(bookmarks::BookmarkId{1} != bookmarks::BookmarkId{2}); + CHECK(bookmarks::BookmarkId{1} < bookmarks::BookmarkId{2}); +} + +TEST_CASE("Cursor and ImportOpId are independently hasValue()-capable", "[bookmarks][types]") { + CHECK_FALSE(bookmarks::Cursor{}.hasValue()); + CHECK(bookmarks::Cursor{7}.hasValue()); + CHECK_FALSE(bookmarks::ImportOpId{}.hasValue()); + CHECK(bookmarks::ImportOpId{"chunk-1"}.hasValue()); + CHECK(*bookmarks::ImportOpId{"chunk-1"} == "chunk-1"); +} + +TEST_CASE("Count is a whole-number dimensionless quantity", "[bookmarks][types]") { + const auto five = bookmarks::Count::fromDouble(5.0); + REQUIRE(five.hasValue()); + CHECK(morph::math::floor(*five) == 5); +} + +TEST_CASE("Every bookmarks error derives from BookmarksError and carries its message", + "[bookmarks][types]") { + try { + throw bookmarks::NotFound{"no such bookmark"}; + } catch (const bookmarks::BookmarksError& err) { + CHECK(std::string{err.what()} == "no such bookmark"); + } + // Compile-time check that every leaf really is-a BookmarksError. + static_assert(std::is_base_of_v); + static_assert(std::is_base_of_v); + static_assert(std::is_base_of_v); + static_assert(std::is_base_of_v); + static_assert(std::is_base_of_v); +} diff --git a/examples/bookmarks/tests/test_gui_qml_smoke.cpp b/examples/bookmarks/tests/test_gui_qml_smoke.cpp new file mode 100644 index 00000000..cf96cf94 --- /dev/null +++ b/examples/bookmarks/tests/test_gui_qml_smoke.cpp @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The one QML test examples/TESTING.md presenter rule 6 asks each rung for: +// "one offscreen engine-load smoke test (engine creates root object, no +// errors) registered in ctest — not Qt Quick Test, and no synthesized-mouse- +// event flows." It loads the *same* Bookmarks/Main.qml the desktop client +// ships (both link the ladder_bookmarks_qml module), with no controllers +// attached — which is why Main.qml's four `*Controller` properties, and the +// ones LoginView.qml/BookmarkListView.qml declare, all default to null. +// +// What this does and does not prove, restated here rather than silently +// inherited from rung 1's identical test (Task 12 of that rung's ledger). +// +// It proves: every QML file reachable from the two roots loaded below parses; +// the engine resolves every *type* they instantiate and every property those +// types declare; and it builds a root object emitting zero QML warnings. +// +// It specifically does NOT prove that `Connections` signal-handler names or +// delegate `modelData.*` property names are correct. Both are resolved +// dynamically, against an object this test never supplies: every controller +// property is null, so no `Connections` block has a live `target` and none of +// its `onXxx` handler names is ever matched against a real signal; and every +// list model is empty, so no delegate is ever instantiated and no +// `modelData.someField` is ever looked up. A handler bound to a signal that +// does not exist, or a delegate reading a property the model never supplies, +// passes this test. +// +// It also proves nothing about behavior against a live backend — with +// `formsController` null there is no schema document, so each DynamicForm +// renders an empty field list, and the bootstrap timer in BookmarkListView +// never runs (it is gated on a non-null controller). The backend-facing half +// is covered by the presenter suites (test_bookmark_presenter.cpp and its two +// siblings) and, for the composed client, by manual end-to-end verification — +// see this rung's README. +// +// One structural consequence, and what is done about it: Main.qml's +// StackView starts on LoginView, so loading Main alone would instantiate +// LoginView but *not* BookmarkListView — nothing can push it here, since +// `loggedIn` comes from a controller that is null. The second case below +// therefore loads BookmarkListView as a root object in its own right, so the +// screen with all five DynamicForms, three list views and four `Connections` +// blocks is genuinely engine-checked rather than merely compiled. +// +// MORPH_LADDER_QML_URI is defined by morph_add_rung() only when the rung's QML +// module was actually built (MORPH_BUILD_FORMS_QML=ON — the shipped MorphForms +// renderer these files import). Without it this file is an empty translation +// unit, so a configure that legitimately has no Qt Quick still builds. +// +// Runs under QT_QPA_PLATFORM=offscreen (already set for the ladder-tests and +// clang-coverage CI legs) against the QGuiApplication testkit_main.cpp owns +// when this rung's test binary is built — Qt Quick cannot instantiate a window +// under a plain QCoreApplication. + +#ifdef MORPH_LADDER_QML_URI + +#include + +#include +#include +#include +#include + +#include + +namespace { + +/// @brief Loads @p typeName from this rung's QML module and returns the first +/// warning the engine emitted, or an empty string. +/// @param typeName Unqualified QML type name within `MORPH_LADDER_QML_URI`. +/// @param created Set to whether a root object was produced. +/// @return The first warning's text, or an empty string if there was none. +[[nodiscard]] std::string firstWarningLoading(const char* typeName, bool& created) { + QQmlApplicationEngine engine; + + QString firstWarning; + QObject::connect(&engine, &QQmlApplicationEngine::warnings, [&firstWarning](const QList& warnings) { + if (firstWarning.isEmpty() && !warnings.isEmpty()) { + firstWarning = warnings.front().toString(); + } + }); + + engine.loadFromModule(MORPH_LADDER_QML_URI, typeName); + created = !engine.rootObjects().isEmpty(); + return firstWarning.toStdString(); +} + +} // namespace + +TEST_CASE("bookmarks' QML engine loads Main.qml and creates a root object with no errors", + "[bookmarks][gui][qml-smoke]") { + bool created = false; + // Reported through the message, not a bare boolean: a QML warning is + // otherwise a failing assertion with nothing to act on. + CHECK(firstWarningLoading("Main", created) == std::string{}); + REQUIRE(created); +} + +TEST_CASE("bookmarks' post-login screen loads standalone with no errors", "[bookmarks][gui][qml-smoke]") { + // Main.qml's StackView never reaches BookmarkListView without a live + // controller, so it is loaded directly here — see this file's header + // comment. Every controller property defaults to null, exactly as when + // the desktop client has not finished connecting yet. + bool created = false; + CHECK(firstWarningLoading("BookmarkListView", created) == std::string{}); + REQUIRE(created); +} + +#endif // MORPH_LADDER_QML_URI diff --git a/examples/bookmarks/tests/test_netscape_bookmarks.cpp b/examples/bookmarks/tests/test_netscape_bookmarks.cpp new file mode 100644 index 00000000..43100c89 --- /dev/null +++ b/examples/bookmarks/tests/test_netscape_bookmarks.cpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/import/netscape_bookmarks.hpp" + +#include + +TEST_CASE("parseNetscapeChunk extracts url and title from entries", + "[bookmarks][import]") { + const std::string chunk = R"(

+

Example +
Second & Site +

)"; + const auto entries = bookmarks::import::parseNetscapeChunk(chunk); + REQUIRE(entries.size() == 2); + CHECK(entries[0].url == "https://example.com"); + CHECK(entries[0].title == "Example"); + CHECK(entries[1].url == "https://second.example"); + CHECK(entries[1].title == "Second & Site"); // entity-decoded +} + +TEST_CASE("parseNetscapeChunk decodes entities in the HREF value, not just the title", + "[bookmarks][import]") { + // Guards against the export/reimport corruption where a URL containing '&' + // (e.g. a real-world query string) got escaped on export but never + // decoded back on import, baking the literal "&" text into the URL. + const std::string chunk = + R"(

Search)"; + const auto entries = bookmarks::import::parseNetscapeChunk(chunk); + REQUIRE(entries.size() == 1); + CHECK(entries[0].url == "https://example.com/search?a=1&b=2"); +} + +TEST_CASE("parseNetscapeChunk skips a malformed with no href", "[bookmarks][import]") { + const std::string chunk = R"(
No href here +
Good)"; + const auto entries = bookmarks::import::parseNetscapeChunk(chunk); + REQUIRE(entries.size() == 2); + CHECK(entries[0].url.empty()); // caller counts this as skipped + CHECK(entries[1].url == "https://good.example"); +} + +TEST_CASE("escapeHtml escapes the five predefined XML entities", "[bookmarks][import]") { + CHECK(bookmarks::import::escapeHtml("a & b < c > d \"e\" 'f'") == + "a & b < c > d "e" 'f'"); +} diff --git a/examples/bookmarks/tests/test_shared_feed_model.cpp b/examples/bookmarks/tests/test_shared_feed_model.cpp new file mode 100644 index 00000000..a33c6720 --- /dev/null +++ b/examples/bookmarks/tests/test_shared_feed_model.cpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/models/bookmark_model.hpp" +#include "bookmarks/models/shared_feed_model.hpp" +#include "testkit/db_fixture.hpp" + +#include +#include + +using morph::ladder::testkit::DbFixture; + +namespace { + +/// @brief A `Context` carrying only @p principal. +/// +/// Built field-by-field rather than with a designated initializer on +/// purpose: `-Weverything` includes +/// `-Wmissing-designated-field-initializers`, which fires on a partial +/// designated-initializer list, and `ladder__tests` is built with +/// `apply_warnings()` (so `-Werror` under `MORPH_ENABLE_STRICT_COMPILATION`, +/// CI's default). Same reason `makeCreate` below exists (see +/// `test_app.cpp`'s `contextFor`/`makeCreate` for the original pattern). +[[nodiscard]] morph::session::Context contextFor(std::string principal) { + morph::session::Context ctx; + ctx.principal = std::move(principal); + return ctx; +} + +class ScopedPrincipal { + public: + explicit ScopedPrincipal(std::string principal) : _ctx{contextFor(std::move(principal))}, _scope{_ctx} {} + + private: + morph::session::Context _ctx; + morph::session::detail::ScopedContext _scope; +}; + +/// @brief A `CreateBookmark` for @p url with the given @p visibility. See +/// `contextFor` for why this is not a designated initializer. +[[nodiscard]] bookmarks::CreateBookmark makeCreate(std::string url, + bookmarks::Visibility visibility = bookmarks::Visibility::Private) { + bookmarks::CreateBookmark action; + action.url = std::move(url); + action.visibility = visibility; + return action; +} + +} // namespace + +TEST_CASE("ListSharedFeed returns every user's shared bookmarks, never a private one", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel bookmarkModel; + bookmarks::SharedFeedModel feedModel; + { + const ScopedPrincipal alice{"alice"}; + bookmarkModel.execute(makeCreate("https://alice-private.example")); + bookmarkModel.execute(makeCreate("https://alice-shared.example", bookmarks::Visibility::Shared)); + } + const ScopedPrincipal bob{"bob"}; + bookmarkModel.execute(makeCreate("https://bob-shared.example", bookmarks::Visibility::Shared)); + + const auto feed = feedModel.execute(bookmarks::ListSharedFeed{}); + REQUIRE(feed.bookmarks.size() == 2); + for (const auto& row : feed.bookmarks) { + CHECK((row.url == "https://alice-shared.example" || row.url == "https://bob-shared.example")); + } +} + +TEST_CASE("ListSharedFeed excludes an archived-but-shared bookmark", "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel bookmarkModel; + bookmarks::SharedFeedModel feedModel; + const ScopedPrincipal alice{"alice"}; + const auto id = bookmarkModel.execute(makeCreate("https://one.example", bookmarks::Visibility::Shared)).id; + bookmarkModel.execute(bookmarks::ArchiveBookmark{.id = id}); + CHECK(feedModel.execute(bookmarks::ListSharedFeed{}).bookmarks.empty()); +} + +TEST_CASE("ListSharedFeed with no session at all is Forbidden", "[bookmarks][model]") { + DbFixture fixture; + bookmarks::SharedFeedModel feedModel; + REQUIRE_THROWS_AS(feedModel.execute(bookmarks::ListSharedFeed{}), bookmarks::Forbidden); +} diff --git a/examples/bookmarks/tests/test_shared_feed_presenter.cpp b/examples/bookmarks/tests/test_shared_feed_presenter.cpp new file mode 100644 index 00000000..38c651a6 --- /dev/null +++ b/examples/bookmarks/tests/test_shared_feed_presenter.cpp @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// SharedFeedPresenter's own suite (Task 17): its one action (list) round-trips +// through the presenter's own signals, not the model directly, across the +// full BackendRig mode matrix (Local/LocalSingleThread/Socket). Domain rules +// (cross-principal visibility, archived-bookmark exclusion) already have a +// dedicated suite at the model level (test_shared_feed_model.cpp); this file +// only proves the presenter wires the action to the right signal and neither +// crashes nor hangs. See test_bookmark_presenter.cpp's own top comment for +// the full rationale this mirrors, including why every mode needs a real +// signed token. + +#include "shared_feed_presenter.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace { + +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +/// @brief Builds a rig authenticated as @p principal, for @p mode, over a +/// fresh authorizer keyed on @p secret. See +/// test_bookmark_presenter.cpp's identical helper for the full +/// rationale. +[[nodiscard]] std::unique_ptr makeAuthedRig(Mode mode, std::string_view secret, std::string principal) { + const auto authorizer = + std::make_shared(std::string{secret}, morph::session::hmacSha256); + auto rig = std::make_unique(mode, 1, authorizer); + const morph::session::TokenIssuer issuer{std::string{secret}, morph::session::hmacSha256}; + morph::session::Context ctx; + ctx.principal = std::move(principal); + ctx.token = issuer.issue( + morph::session::SessionToken{.principal = ctx.principal, .expiresAtMs = 4102444800000, .roles = {}}); + rig->bridge(0).setDefaultSession(ctx); + return rig; +} + +/// @brief Creates a bookmark with the given @p visibility via a direct +/// `BookmarkModel` dispatch through @p handler, bypassing +/// `BookmarkPresenter` entirely -- this suite's job is +/// `SharedFeedPresenter`, not bookmark creation. +/// +/// @p handler is supplied by the caller and must outlive every call site: +/// see test_tag_presenter.cpp's identical helper (`seedTaggedBookmark`) for +/// why a short-lived, per-call handler is unsafe in `Mode::Socket` -- two +/// such handlers constructed back to back race a `deregister` reply against +/// the next handler's synchronous registration, occasionally leaving the new +/// binding permanently unbound (`Bridge::executeVia` then fails every +/// dispatch with "handler not bound", not just the first). Reproduced here +/// empirically, not just by inference: this file's own two-`seedBookmark` +/// call sequence below hit it directly. +void seedBookmark(::morph::bridge::BridgeHandler& handler, std::string url, + bookmarks::Visibility visibility) { + bookmarks::CreateBookmark create; + create.url = std::move(url); + create.visibility = visibility; + (void) awaitQt(handler.execute(create)); +} + +} // namespace + +TEST_CASE("SharedFeedPresenter::list returns every shared bookmark, never a private one, " + "all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "shared-feed-presenter-list-secret", "alice"); + // Declared before `presenter` (and so, by C++'s reverse local-destruction + // order, torn down *after* it) -- see `seedBookmark`'s own doc comment. + auto bookmarkHandler = rig->client(0); + seedBookmark(bookmarkHandler, "https://alice-private.example", bookmarks::Visibility::Private); + seedBookmark(bookmarkHandler, "https://alice-shared.example", bookmarks::Visibility::Shared); + + bookmarks::gui::SharedFeedPresenter presenter{rig->bridge(0), rig->executor()}; + bookmarks::ListSharedFeedResult listed; + bool gotListed = false; + QObject::connect(&presenter, &bookmarks::gui::SharedFeedPresenter::listed, + [&](bookmarks::ListSharedFeedResult result) { + listed = std::move(result); + gotListed = true; + }); + presenter.list(bookmarks::ListSharedFeed{}); + REQUIRE(pumpUntil([&] { return gotListed; })); + REQUIRE_FALSE(presenter.busy()); + REQUIRE(listed.bookmarks.size() == 1); + CHECK(listed.bookmarks.front().url == "https://alice-shared.example"); +} + +TEST_CASE("SharedFeedPresenter::list with no session at all emits failed, not a crash", + "[bookmarks][presenter]") { + // SharedFeedModel::execute throws Forbidden with no session + // (test_shared_feed_model.cpp's identical model-level case) -- proves the + // presenter surfaces that as `failed()` rather than crashing, using a + // bridge that never had `setDefaultSession` called on it. + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + bookmarks::gui::SharedFeedPresenter presenter{rig.bridge(0), rig.executor()}; + + QString failure; + bool failed = false; + QObject::connect(&presenter, &bookmarks::gui::SharedFeedPresenter::failed, [&](QString message) { + failure = message; + failed = true; + }); + presenter.list(bookmarks::ListSharedFeed{}); + REQUIRE(pumpUntil([&] { return failed; })); + CHECK_FALSE(failure.isEmpty()); + REQUIRE_FALSE(presenter.busy()); +} + +// A dedicated "ListSharedFeed against a broken store" case (drop the +// `bookmarks` table out from under the query) is deliberately not repeated +// here: test_bookmark_presenter.cpp's own consolidated broken-store case +// already drops and reapplies that same table's schema once per process -- +// see that test's doc comment for why a *second* such cycle in the same +// process deterministically corrupts Lightweight's `SqlMigration` fold-state +// cache and takes down every later `DbFixture` in the binary. The no-session +// case above already proves `SharedFeedPresenter` surfaces a genuine +// model-thrown error as `failed()` rather than crashing; that mechanism +// (typed exception -> `reportError` -> `failed()`) is identical regardless of +// which exception type triggers it, and `BookmarkPresenter`'s own suite +// separately proves the broken-store path specifically. diff --git a/examples/bookmarks/tests/test_tag_bulk_dto.cpp b/examples/bookmarks/tests/test_tag_bulk_dto.cpp new file mode 100644 index 00000000..7e926ff0 --- /dev/null +++ b/examples/bookmarks/tests/test_tag_bulk_dto.cpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/dto/bulk_dto.hpp" +#include "bookmarks/dto/import_export_dto.hpp" +#include "bookmarks/dto/shared_feed_dto.hpp" +#include "bookmarks/dto/tag_dto.hpp" + +#include + +TEST_CASE("RenameTag requires an id and a non-empty, bounded name", "[bookmarks][dto]") { + bookmarks::RenameTag action; + CHECK_FALSE(action.validate()); + action.id = bookmarks::TagId{1}; + CHECK_FALSE(action.validate()); // still no name + action.name = "programming"; + CHECK(action.validate()); + action.name = std::string(bookmarks::kMaxTagNameBytes + 1, 'x'); + CHECK_FALSE(action.validate()); +} + +TEST_CASE("MergeTags requires two distinct ids", "[bookmarks][dto]") { + bookmarks::MergeTags action; + CHECK_FALSE(action.validate()); + action.sourceId = bookmarks::TagId{1}; + action.targetId = bookmarks::TagId{1}; + CHECK_FALSE(action.validate()); // merging a tag into itself + action.targetId = bookmarks::TagId{2}; + CHECK(action.validate()); +} + +TEST_CASE("BulkEdit requires at least one id", "[bookmarks][dto]") { + bookmarks::BulkEdit action; + CHECK_FALSE(action.validate()); + action.ids = {bookmarks::BookmarkId{1}}; + CHECK(action.validate()); +} + +TEST_CASE("BulkArchiveOp reflects as a readable string", "[bookmarks][dto]") { + std::string json; + REQUIRE_FALSE(glz::write_json(bookmarks::BulkArchiveOp::Archive, json)); + CHECK(json == "\"Archive\""); +} + +TEST_CASE("ImportBookmarks requires a non-empty chunk and an opId; the chunk-size bound is " + "deliberately NOT one of validate()'s checks", + "[bookmarks][dto]") { + bookmarks::ImportBookmarks action; + CHECK_FALSE(action.validate()); + action.chunk = "Example"; + CHECK_FALSE(action.validate()); // still no opId + action.opId = bookmarks::ImportOpId{"chunk-1"}; + CHECK(action.validate()); + // An oversized chunk still passes validate() -- see import_export_dto.hpp's + // comment on validate(): the size bound is enforced once, in + // BookmarkModel::execute(), specifically so it can be signaled as the + // more specific TooLarge rather than being folded into validate()'s + // single untyped ValidationError (which is what every real dispatch + // path, e.g. Bridge::executeVia, would produce if validate() rejected + // it here instead). + action.chunk = std::string(bookmarks::kMaxImportChunkBytes + 1, 'x'); + CHECK(action.validate()); +} + +TEST_CASE("ListSharedFeed/ListTags/ExportBookmarks validate() with no required fields", + "[bookmarks][dto]") { + CHECK(bookmarks::ListSharedFeed{}.validate()); + CHECK(bookmarks::ListTags{}.validate()); + CHECK(bookmarks::ExportBookmarks{}.validate()); +} diff --git a/examples/bookmarks/tests/test_tag_model.cpp b/examples/bookmarks/tests/test_tag_model.cpp new file mode 100644 index 00000000..5229356a --- /dev/null +++ b/examples/bookmarks/tests/test_tag_model.cpp @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks/models/bookmark_model.hpp" +#include "bookmarks/models/tag_model.hpp" +#include "testkit/db_fixture.hpp" + +#include "bookmarks/db/outbox_entity.hpp" + +#include +#include + +#include +#include + +using morph::ladder::testkit::DbFixture; + +namespace { + +/// @brief A `Context` carrying only @p principal. +/// +/// Built field-by-field rather than with a designated initializer on +/// purpose: `-Weverything` includes +/// `-Wmissing-designated-field-initializers`, which fires on a partial +/// designated-initializer list, and `ladder__tests` is built with +/// `apply_warnings()` (so `-Werror` under `MORPH_ENABLE_STRICT_COMPILATION`, +/// CI's default). Same reason `makeCreate` below exists (see +/// `test_app.cpp`'s `contextFor`/`makeCreate` for the original pattern). +[[nodiscard]] morph::session::Context contextFor(std::string principal) { + morph::session::Context ctx; + ctx.principal = std::move(principal); + return ctx; +} + +class ScopedPrincipal { + public: + explicit ScopedPrincipal(std::string principal) : _ctx{contextFor(std::move(principal))}, _scope{_ctx} {} + + private: + morph::session::Context _ctx; + morph::session::detail::ScopedContext _scope; +}; + +/// @brief A `CreateBookmark` for @p url with the given @p tags. See +/// `contextFor` for why this is not a designated initializer. +[[nodiscard]] bookmarks::CreateBookmark makeCreate(std::string url, std::vector tags = {}) { + bookmarks::CreateBookmark action; + action.url = std::move(url); + action.tags = std::move(tags); + return action; +} + +} // namespace + +TEST_CASE("RenameTag renames a tag owned by the caller", "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel bookmarkModel; + bookmarks::TagModel tagModel; + const ScopedPrincipal alice{"alice"}; + + const auto bookmarkId = bookmarkModel.execute(makeCreate("https://one.example", {"old"})).id; + const auto tags = tagModel.execute(bookmarks::ListTags{}).tags; + REQUIRE(tags.size() == 1); + const auto tagId = tags.front().id; + + tagModel.execute(bookmarks::RenameTag{.id = tagId, .name = "new"}); + const auto renamed = tagModel.execute(bookmarks::ListTags{}).tags; + REQUIRE(renamed.size() == 1); + CHECK(renamed.front().name == "new"); + CHECK(bookmarkModel.execute(bookmarks::GetBookmark{.id = bookmarkId}).tags == std::vector{"new"}); +} + +TEST_CASE("RenameTag against another principal's tag is Forbidden", "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel bookmarkModel; + bookmarks::TagModel tagModel; + bookmarks::TagId aliceTagId; + { + const ScopedPrincipal alice{"alice"}; + bookmarkModel.execute(makeCreate("https://one.example", {"mine"})); + aliceTagId = tagModel.execute(bookmarks::ListTags{}).tags.front().id; + } + const ScopedPrincipal mallory{"mallory"}; + REQUIRE_THROWS_AS(tagModel.execute(bookmarks::RenameTag{.id = aliceTagId, .name = "stolen"}), + bookmarks::Forbidden); +} + +TEST_CASE("RenameTag colliding with an existing tag name is a Conflict", "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel bookmarkModel; + bookmarks::TagModel tagModel; + const ScopedPrincipal alice{"alice"}; + bookmarkModel.execute(makeCreate("https://one.example", {"a", "b"})); + const auto tags = tagModel.execute(bookmarks::ListTags{}).tags; + const auto tagA = std::ranges::find_if(tags, [](auto& t) { return t.name == "a"; })->id; + REQUIRE_THROWS_AS(tagModel.execute(bookmarks::RenameTag{.id = tagA, .name = "b"}), bookmarks::Conflict); +} + +TEST_CASE("MergeTags reassigns every bookmark from source to target, dedups, and deletes source", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel bookmarkModel; + bookmarks::TagModel tagModel; + const ScopedPrincipal alice{"alice"}; + + const auto id1 = bookmarkModel.execute(makeCreate("https://one.example", {"cpp"})).id; + const auto id2 = bookmarkModel.execute(makeCreate("https://two.example", {"cpp", "c++"})).id; + const auto tags = tagModel.execute(bookmarks::ListTags{}).tags; + const auto cppId = std::ranges::find_if(tags, [](auto& t) { return t.name == "cpp"; })->id; + const auto cxxId = std::ranges::find_if(tags, [](auto& t) { return t.name == "c++"; })->id; + + tagModel.execute(bookmarks::MergeTags{.sourceId = cppId, .targetId = cxxId}); + + CHECK(bookmarkModel.execute(bookmarks::GetBookmark{.id = id1}).tags == std::vector{"c++"}); + auto tagsOfId2 = bookmarkModel.execute(bookmarks::GetBookmark{.id = id2}).tags; + CHECK(tagsOfId2.size() == 1); // "cpp" and "c++" merged into one, not duplicated + CHECK(tagsOfId2.front() == "c++"); + const auto remaining = tagModel.execute(bookmarks::ListTags{}).tags; + CHECK(remaining.size() == 1); // "cpp" is gone +} + +TEST_CASE("MergeTags writes exactly one outbox row", "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel bookmarkModel; + bookmarks::TagModel tagModel; + const ScopedPrincipal alice{"alice"}; + bookmarkModel.execute(makeCreate("https://one.example", {"a", "b"})); + const auto tags = tagModel.execute(bookmarks::ListTags{}).tags; + tagModel.execute(bookmarks::MergeTags{.sourceId = tags[0].id, .targetId = tags[1].id}); + + Lightweight::DataMapper mapper; + auto rows = mapper.Query().All(); + REQUIRE(rows.size() == 1); + CHECK(rows.front().actionType.Value() == "MergeTags"); +} + +TEST_CASE("Cross-model race: TagModel renames a tag while BookmarkModel's BulkEdit adds the old " + "name -- documents where consistency becomes app responsibility, per the README", + "[bookmarks][model]") { + DbFixture fixture; + bookmarks::BookmarkModel bookmarkModel; + bookmarks::TagModel tagModel; + const ScopedPrincipal alice{"alice"}; + + bookmarkModel.execute(makeCreate("https://one.example", {"old"})); + const auto tagId = tagModel.execute(bookmarks::ListTags{}).tags.front().id; + + // Sequential, not genuinely racing (this test suite calls execute() + // directly, C++-to-C++, with no thread-level concurrency -- the README's + // own framing already concedes "the strand cannot fix it," i.e. this is + // a documentation test, not a fix-verification test): rename first, + // then a second bookmark's BulkEdit tries to add the *old* name back. + tagModel.execute(bookmarks::RenameTag{.id = tagId, .name = "new"}); + const auto id2 = bookmarkModel.execute(makeCreate("https://two.example")).id; + + bookmarks::BulkEdit edit; + edit.ids = {id2}; + edit.addTags = {"old"}; // the pre-rename name -- TagModel already renamed it away + bookmarkModel.execute(edit); + + // BulkEdit's own findOrCreateTagId has no way to know "old" was renamed + // to "new" -- it faithfully creates a *new* tag literally named "old". + // This is the documented, accepted outcome: two strands, no + // cross-instance transaction, and the model layer cannot see the other + // model's in-flight rename. Consistency here is app/UI responsibility + // (e.g. a client re-fetching the tag list before offering it), not a + // framework or model guarantee. + const auto tags = tagModel.execute(bookmarks::ListTags{}).tags; + CHECK(std::ranges::any_of(tags, [](auto& t) { return t.name == "new"; })); + CHECK(std::ranges::any_of(tags, [](auto& t) { return t.name == "old"; })); // recreated, not merged +} diff --git a/examples/bookmarks/tests/test_tag_presenter.cpp b/examples/bookmarks/tests/test_tag_presenter.cpp new file mode 100644 index 00000000..9b27dbc3 --- /dev/null +++ b/examples/bookmarks/tests/test_tag_presenter.cpp @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// TagPresenter's own suite (Task 17): each of its three actions +// (rename/merge/list) round-trips through the presenter's own signals, not +// the model directly, across the full BackendRig mode matrix +// (Local/LocalSingleThread/Socket). Domain rules (ownership, collision +// detection, the merge cascade) already have a dedicated suite at the model +// level (test_tag_model.cpp); this file only proves the presenter wires each +// action to the right signal and neither crashes nor hangs. See +// test_bookmark_presenter.cpp's own top comment for the full rationale this +// mirrors, including why every mode needs a real signed token. + +#include "tag_presenter.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +/// @brief Builds a rig authenticated as @p principal, for @p mode, over a +/// fresh authorizer keyed on @p secret. See +/// test_bookmark_presenter.cpp's identical helper for the full +/// rationale. +[[nodiscard]] std::unique_ptr makeAuthedRig(Mode mode, std::string_view secret, std::string principal) { + const auto authorizer = + std::make_shared(std::string{secret}, morph::session::hmacSha256); + auto rig = std::make_unique(mode, 1, authorizer); + const morph::session::TokenIssuer issuer{std::string{secret}, morph::session::hmacSha256}; + morph::session::Context ctx; + ctx.principal = std::move(principal); + ctx.token = issuer.issue( + morph::session::SessionToken{.principal = ctx.principal, .expiresAtMs = 4102444800000, .roles = {}}); + rig->bridge(0).setDefaultSession(ctx); + return rig; +} + +/// @brief Creates a bookmark tagged @p tags via a direct `BookmarkModel` +/// dispatch through @p handler, bypassing `BookmarkPresenter` +/// entirely -- this suite's job is `TagPresenter`, not bookmark +/// creation. +/// +/// @p handler is supplied by the caller, and deliberately outlives every +/// call site below -- see those call sites' own comments for why: a +/// short-lived, per-call handler is the actual root cause this signature +/// avoids. +/// +/// Returns nothing: no caller in this suite needs the new bookmark's id -- +/// every assertion here is about the *tags* the seed created, looked up by +/// name. The `awaitQt` is still load-bearing, and is the whole point of the +/// helper: it makes the seed synchronous, so a `TagPresenter::list` issued +/// on the next line cannot race the rows it is meant to see. +/// +/// @param handler Live handler the create is dispatched through. +/// @param url The new bookmark's url. +/// @param tags Tag names to create and attach. +void seedTaggedBookmark(::morph::bridge::BridgeHandler& handler, std::string url, + std::vector tags) { + bookmarks::CreateBookmark create; + create.url = std::move(url); + create.tags = std::move(tags); + static_cast(awaitQt(handler.execute(create))); +} + +} // namespace + +TEST_CASE("TagPresenter::list returns every tag the caller owns, all three backend modes", "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "tag-presenter-list-secret", "alice"); + // Declared before `presenter` (and so, by C++'s reverse local-destruction + // order, torn down *after* it): see this file's top-of-suite note above + // `seedTaggedBookmark` -- a short-lived handler's teardown message would + // otherwise race `presenter`'s own registration on the same connection. + auto bookmarkHandler = rig->client(0); + seedTaggedBookmark(bookmarkHandler, "https://one.example", {"cpp", "rust"}); + + bookmarks::gui::TagPresenter presenter{rig->bridge(0), rig->executor()}; + bookmarks::ListTagsResult listed; + bool gotListed = false; + QObject::connect(&presenter, &bookmarks::gui::TagPresenter::listed, [&](bookmarks::ListTagsResult result) { + listed = std::move(result); + gotListed = true; + }); + presenter.list(bookmarks::ListTags{}); + REQUIRE(pumpUntil([&] { return gotListed; })); + REQUIRE_FALSE(presenter.busy()); + REQUIRE(listed.tags.size() == 2); + CHECK(std::ranges::find_if(listed.tags, [](auto& t) { return t.name == "cpp"; }) != listed.tags.end()); + CHECK(std::ranges::find_if(listed.tags, [](auto& t) { return t.name == "rust"; }) != listed.tags.end()); +} + +TEST_CASE("TagPresenter::rename renames a tag owned by the caller, all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "tag-presenter-rename-secret", "alice"); + // See the list test above for why this handler outlives `presenter`. + auto bookmarkHandler = rig->client(0); + seedTaggedBookmark(bookmarkHandler, "https://one.example", {"old"}); + + bookmarks::gui::TagPresenter presenter{rig->bridge(0), rig->executor()}; + bookmarks::ListTagsResult before; + bool gotBefore = false; + QObject::connect(&presenter, &bookmarks::gui::TagPresenter::listed, [&](bookmarks::ListTagsResult result) { + before = std::move(result); + gotBefore = true; + }); + presenter.list(bookmarks::ListTags{}); + REQUIRE(pumpUntil([&] { return gotBefore; })); + REQUIRE(before.tags.size() == 1); + const auto tagId = before.tags.front().id; + + bool renamed = false; + QObject::connect(&presenter, &bookmarks::gui::TagPresenter::renamed, [&] { renamed = true; }); + presenter.rename(bookmarks::RenameTag{.id = tagId, .name = "new"}); + REQUIRE(pumpUntil([&] { return renamed; })); + REQUIRE_FALSE(presenter.busy()); + + bookmarks::ListTagsResult after; + bool gotAfter = false; + QObject::connect(&presenter, &bookmarks::gui::TagPresenter::listed, [&](bookmarks::ListTagsResult result) { + after = std::move(result); + gotAfter = true; + }); + presenter.list(bookmarks::ListTags{}); + REQUIRE(pumpUntil([&] { return gotAfter; })); + REQUIRE(after.tags.size() == 1); + CHECK(after.tags.front().name == "new"); +} + +TEST_CASE("TagPresenter::merge reassigns every bookmark from source to target and deletes source, " + "all three backend modes", + "[bookmarks][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeAuthedRig(mode, "tag-presenter-merge-secret", "alice"); + // One handler reused for both seed calls -- not just for the list test's + // reason above, but because this test is where the underlying bug was + // actually caught: `Bridge::registerHandler()`'s only synchronous path + // (`BackendRig::Socket` never opts into `asyncRegistrationEnabled`) blocks + // in `QtWebSocketBackend::sendSync` via a nested `QEventLoop`, waiting for + // a reply whose wire envelope carries `callId == 0` -- the same `callId` + // every fire-and-forget `deregister` reply also carries (`onTextMessage` + // has no other way to tell "the sync reply I'm parked for" from "an + // unrelated deregister ack") from `QtWebSocketBackend::deregisterModel`. + // Two short-lived handlers back to back -- construct, dispatch, destruct + // (deregister), construct again -- let a fresh registration's `sendSync` + // park its nested loop while the *previous* handler's still-in-flight + // deregister ack is loose on the wire; if that ack's "ok" reply (with no + // `modelId` field) lands first, `onTextMessage` hands it to the parked + // loop instead of the real register reply, and the new binding's + // `currentId` is stored as 0 -- permanently, since the actual register + // reply that arrives afterward has nowhere left to go (`_syncLoop` was + // already reset). Every later dispatch on that binding then fails fast + // with "handler not bound" (`Bridge::executeVia`), forever, not just + // transiently -- confirmed by instrumented reruns: a bounded retry loop + // (an earlier version of this fix) burned its full deadline every time + // rather than ever recovering, exactly what a permanently-zeroed + // `currentId` predicts, not what a merely slow round trip would. Keeping + // one handler alive across both bookmarks removes the *deregister* from + // between the two registrations entirely -- there is no longer a stray + // reply in flight for a later `sendSync` to catch. This is a real + // `QtWebSocketBackend`/`Bridge` protocol-correlation bug (`include/morph/ + // qt/qt_websocket_backend.hpp`'s `deregisterModel` vs. `sendSync`'s + // shared `callId == 0` bucket), not a `Presenter`/`TagPresenter` defect; + // fixing it there is out of scope here (framework code, not this rung's + // testkit) -- see this task's report for the finding writeup. + auto bookmarkHandler = rig->client(0); + seedTaggedBookmark(bookmarkHandler, "https://one.example", {"cpp"}); + seedTaggedBookmark(bookmarkHandler, "https://two.example", {"cpp", "c++"}); + + bookmarks::gui::TagPresenter presenter{rig->bridge(0), rig->executor()}; + bookmarks::ListTagsResult before; + bool gotBefore = false; + QObject::connect(&presenter, &bookmarks::gui::TagPresenter::listed, [&](bookmarks::ListTagsResult result) { + before = std::move(result); + gotBefore = true; + }); + presenter.list(bookmarks::ListTags{}); + REQUIRE(pumpUntil([&] { return gotBefore; })); + REQUIRE(before.tags.size() == 2); + const auto cppId = std::ranges::find_if(before.tags, [](auto& t) { return t.name == "cpp"; })->id; + const auto cxxId = std::ranges::find_if(before.tags, [](auto& t) { return t.name == "c++"; })->id; + + bool merged = false; + QObject::connect(&presenter, &bookmarks::gui::TagPresenter::merged, [&] { merged = true; }); + presenter.merge(bookmarks::MergeTags{.sourceId = cppId, .targetId = cxxId}); + REQUIRE(pumpUntil([&] { return merged; })); + REQUIRE_FALSE(presenter.busy()); + + bookmarks::ListTagsResult after; + bool gotAfter = false; + QObject::connect(&presenter, &bookmarks::gui::TagPresenter::listed, [&](bookmarks::ListTagsResult result) { + after = std::move(result); + gotAfter = true; + }); + presenter.list(bookmarks::ListTags{}); + REQUIRE(pumpUntil([&] { return gotAfter; })); + REQUIRE(after.tags.size() == 1); // "cpp" is gone + CHECK(after.tags.front().name == "c++"); +} + +TEST_CASE("Every TagPresenter action routes its failure to failed(), not just rename()", "[bookmarks][presenter]") { + DbFixture fixture; + auto rig = makeAuthedRig(Mode::Local, "tag-presenter-fail-secret", "alice"); + bookmarks::gui::TagPresenter presenter{rig->bridge(0), rig->executor()}; + + QString failure; + int failures = 0; + QObject::connect(&presenter, &bookmarks::gui::TagPresenter::failed, [&](QString message) { + failure = message; + ++failures; + }); + + // rename: a disengaged id fails RenameTag::validate(). + presenter.rename(bookmarks::RenameTag{}); + REQUIRE(pumpUntil([&] { return failures == 1; })); + REQUIRE_FALSE(presenter.busy()); + + // merge: two disengaged (and thus equal) ids fail MergeTags::validate(). + presenter.merge(bookmarks::MergeTags{}); + REQUIRE(pumpUntil([&] { return failures == 2; })); + REQUIRE_FALSE(presenter.busy()); + CHECK_FALSE(failure.isEmpty()); +} + +TEST_CASE("TagPresenter::list with no session at all emits failed, not a crash", "[bookmarks][presenter]") { + // ListTags has `validate() { return true; }` unconditionally -- its only + // reachable failure is a genuine model-level error, not a validation one. + // `TagModel`'s own `requirePrincipal()` (tag_model.cpp) throws `Forbidden` + // before touching the database at all when `session::current()` carries + // no principal, so an unauthenticated bridge (no `setDefaultSession` call) + // reaches exactly that path safely. See + // test_bookmark_presenter.cpp's identical "no session" case for why this + // -- not a dropped table -- is the safe way to provoke a genuine failure + // for an always-`validate()`-true action in this rung's test binary. + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + bookmarks::gui::TagPresenter presenter{rig.bridge(0), rig.executor()}; + + QString failure; + bool failed = false; + QObject::connect(&presenter, &bookmarks::gui::TagPresenter::failed, [&](QString message) { + failure = message; + failed = true; + }); + presenter.list(bookmarks::ListTags{}); + REQUIRE(pumpUntil([&] { return failed; })); + CHECK_FALSE(failure.isEmpty()); + REQUIRE_FALSE(presenter.busy()); +}