diff --git a/.github/skills/xlf-strings/SKILL.md b/.github/skills/xlf-strings/SKILL.md index c9c36104a226..ab88d76dcbfd 100644 --- a/.github/skills/xlf-strings/SKILL.md +++ b/.github/skills/xlf-strings/SKILL.md @@ -46,6 +46,8 @@ After the `ID: ...` line, add a **second ``** whenever a tran The note should state: what UI element it labels, where it appears in the UI, and any constraints (e.g. "appears mid-sentence, should be lowercase", "step N of M in X instructions", "'Bloom' is a product name and must not be translated", "{0} is replaced with a count"). +**Never put a double hyphen (`--`) inside `` text.** L10NSharp's XLIFF reader routes note content through XML-comment parsing, and `--` in a comment is illegal XML — Bloom then crashes at startup with "An XML comment cannot contain '--'" while loading localization (found 8 Jul 2026; it killed every launch). Use a period, semicolon, or single hyphen instead. (`--` in `` text is fine.) + Example with context note: ```xml diff --git a/AGENTS.md b/AGENTS.md index 2716eed87200..b1231876b153 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,25 @@ The vscode terminal often loses the first character sent from copilot agents. So - Do not launch Bloom with `dotnet run` or `node scripts/watchBloomExe.mjs` unless you are specifically working on the launcher scripts themselves or a better repo-supported source-aware launcher has been documented. +- Two source-aware launchers exist; both start Vite (so the front-end hot-reloads) and pass + `--automation --attended`: + - `./go.sh` — runs Bloom under `dotnet watch`. Best for front-end work and small C# tweaks on a + **single** instance. The catch: the watcher holds the `output/` apphost locked, so a C# change + forces a full rebuild+relaunch anyway *and* nothing else can rebuild until you stop Bloom. + - `./run.sh` — the **build-once** launcher: it builds `BloomExe` once (Debug), then launches the + built `Bloom.exe` **directly**, with no C# file watcher. Prefer this for substantial C# work and + for running **two instances at once** (e.g. Team Collection testing). Two windows both run + `./run.sh`; a build lock + freshness check means only the first actually builds and the second + reuses that build (and its Vite). To pick up a later C# change, close Bloom (or press Enter at + its relaunch prompt) — `run.sh` rebuilds and relaunches while keeping Vite warm. + (`pnpm run run` also works; bare `pnpm run` only lists scripts.) + - Any `Bloom.exe` running from this repo's `output/` tree is a dev build of this worktree. Since + `run.sh` uses no watcher, such an instance can be stopped (and stays stopped) to free the build. + Identify them via the `bloom-automation` skill's status probe (HTTP `common/instanceInfo`), and + stop one with `killBloomProcess.mjs --http-port `. NOTE: `killBloomProcess.mjs` with no + args enumerates processes via `wmic`, which is absent on newer Windows 11 — prefer the + `--http-port`/`--pid` forms there. + If you create new files for temporary purposes (e.g. output or artifact or log files), be sure to clean them up when you're done and be careful not to accidentally commit them. # Don't run pnpm build diff --git a/Design/CloudTeamCollections.md b/Design/CloudTeamCollections.md new file mode 100644 index 000000000000..3a4cdefe8e62 --- /dev/null +++ b/Design/CloudTeamCollections.md @@ -0,0 +1,180 @@ +# Cloud Team Collections: S3 + Supabase design + +Status: **design complete, implementation not started** (2 July 2026). +Interactive review version of this document (with diagrams and the full narrative): +https://claude.ai/code/artifact/14157962-d8e4-4c3f-afa7-34c49ed29e1a +Work breakdown: [CloudTeamCollections/IMPLEMENTATION.md](CloudTeamCollections/IMPLEMENTATION.md) · +API contracts: [CloudTeamCollections/CONTRACTS.md](CloudTeamCollections/CONTRACTS.md) + +## Motivation + +Team Collections today wraps a shared Dropbox/LAN folder. Chronic problems: Dropbox is hard +and expensive for our users to set up; Bloom can never truly know sync status; and a +third-party sync agent underneath us creates a long tail of races (conflicted-copy files, +two-phase delivery, partial downloads that look corrupt) that the code and tests spend +enormous effort taming. Replacement: a central SIL-hosted backend — Supabase Postgres as the +authoritative database (locks, membership, current-version manifests, history events), one S3 +bucket with a prefix per collection for book content, and sharing via an approved-accounts +model. The check-in/check-out editorial model is preserved. + +## Requirements (decided) + +1. Zero third-party setup for users; only a Bloom (BloomLibrary) sign-in. +2. Central SIL-hosted S3; per-collection prefixes; access brokered per-operation (STS). +3. Check-in/check-out preserved; offline editing of checked-out books always works. +4. Identity = BloomLibrary Firebase account (real auth, not self-declared email). +5. Sharing v1 = **approved accounts**: admin lists emails (+ role Admin/Member); an approved + person signs in anywhere, asks "Get my Team Collections", pulls a collection down. + Invite links/codes, `bloom://` deep links, landing page, Mailgun emails: all deferred to v2. +6. **Send/Receive model** (modal in v1): explicit transfers with progress; no ambient content + copying. Metadata (locks, version numbers) syncs continuously and cheaply; the UI always + knows "checked out to Sara" / "v12 exists, you have v10" without moving bytes. +7. No secondary local repo copy. Transfers stage in temp dirs and swap atomically per book — + never new HTML with stale images. +8. Per-file delta transfers, SHA-256 verified, both directions. +9. **No content retention**: replaced files are not kept anywhere; no restore. The history + *log* (who/when/comment/incidents) is kept — it is metadata. S3 object versioning stays ON + purely as a transactional safety device (crashed Send harmless; torn reads impossible), + with noncurrent versions auto-deleted after ~7 days. +10. Work is never silently lost: whenever the repo must win over local work, that work is + saved as a `.bloomSource` in a local **Lost & Found** folder AND recorded as a server-side + incident event admins can see. +11. Coexists indefinitely with folder/Dropbox TC (second backend behind the same abstraction). + No auto-migration in v1: un-team (delete link file), then enable cloud sharing. +12. Subscriptions untouched: existing client-side gate only; the server has no tier/quota logic. +13. Experimental flag until GA (plus the existing feature gate), like the current TC. + +## Architecture + +- **Bloom desktop (C#)**: `CloudTeamCollection` — second subclass of the existing abstract + `TeamCollection` (src/BloomExe/TeamCollection/TeamCollection.cs) — plus support classes + (`CloudCollectionClient`, `CloudRepoCache`, `CloudBookTransfer`, `BookVersionManifest`, + `CloudCollectionMonitor`, `CloudAuth`, `CloudJoinFlow`), a new `SharingApi`, and reuse of + `BloomS3Client` (session credentials + TransferUtility), the WebSocket progress harness, and + nearly all existing TC UI. +- **Supabase** (schema `tc`): tables `collections`, `members` (the approved-accounts table; + unclaimed rows have null user_id), `books` (with the authoritative lock columns and + `deleted_at` tombstone), `versions` (metadata row per check-in), `version_files` (the + CURRENT manifest: path → sha256, size, s3_version_id; superseded rows pruned at commit), + `collection_file_groups`/`collection_group_files`, `color_palette_entries` (union merge = + `insert … on conflict do nothing`), `events` (history + realtime source + polling cursor; + numeric types match `BookHistoryEventType`, extended with incident types), and + `checkin_transactions`. RLS on everything; no direct writes to books/versions — state + transitions go through RPCs/edge functions. +- **S3**: bucket per environment (`bloom-teams-production`/`-sandbox`), versioning ON, + lifecycle = abort-multipart 7d + expire noncurrent versions ~7d. Layout: + `tc/{collectionId}/books/{bookInstanceId}/{relativePath}` (+ `.manifest.json` current-state + backup per book; `tc/{cid}/collectionFiles/{group}/...`). Folder keyed by instance id → + rename is a DB row update. Existing buckets/credentials for publishing are untouched; cloud + collections never use embedded keys. +- **Access brokering**: STS temporary credentials from edge functions, scoped by inline + session policy (write: the one book being sent, only while its transaction is open; read: + the collection prefix incl. `GetObjectVersion`). Uploads carry `x-amz-checksum-sha256`; + checkin-finish verifies before commit. +- **Checkout** = conditional UPDATE (`WHERE locked_by IS NULL OR locked_by = me`) — race-free. + Locks never auto-expire; "Force Unlock" (label decided) is a distinct audited RPC. +- **Send** = checkin-start (diff manifest → transaction + changed paths + STS creds) → + parallel PUT changed files → checkin-finish (verify, one DB tx: version row + manifest + + lock release + events; the commit is the atomicity point). Crash mid-Send: nothing + committed, resumable 48h, no partial state visible ever. +- **New books** (first-class): checkin-start with `bookId:null` creates the row locked to + caller **with no current version — invisible to teammates** until first commit. Crash → no + phantom; expired transaction reaps. Same-name race → NameConflict → existing "name2" + resolution; duplicate instance id → existing id-conflict flow. +- **Receive** = `get_collection_state(since)` → reuse of the existing `SyncAtStartup` + reconciliation → download changed files by pinned (path, s3VersionId) into temp → atomic + swap per book. +- **Realtime**: one private broadcast channel per collection driven by an events-table + trigger; clients keep a `last_seen_event_id` cursor; `get_changes(since)` is both reconnect + catch-up and the 60s polling fallback (polling ships first; realtime is an optimization). +- **Auth** (DECIDED 8 Jul 2026: **Option A** — Supabase third-party Firebase auth; brief was + grounded in the BloomLibrary2 + bloom-parse-server code): the login page forwards the + Firebase ID+refresh tokens it already holds (~5 lines in BloomLibrary2 `src/editor.ts`), + plus one NEW small Firebase Admin cloud function adding the static `role:"authenticated"` + claim (+ backfill; no claims infra exists today). Rejected alternatives: B = exchange the + legacy Parse session token (welds us to the server being decommissioned); C = hand-validate + Firebase JWTs per the stale `bloom-parse-server/supabase/` docs. `CloudAuth` isolates the + choice. Claiming an approval requires `email_verified` (all BloomLibrary accounts are + verified — the existing parse adapter already enforces this). Go-live steps: + `CloudTeamCollections/GOING-LIVE.md` Phase 3. +- **Identity/account rules**: account email is the identity in cloud TCs (server stamps lock + identity from the token; client sends only machine name). Sign-out/in as the same account + is always safe. Switching accounts with unsent checked-out changes is **blocked** with + explicit choices (Send first, or preserve `.bloomSource` + release locally); the server + lock stays with the original account. Nothing is ever discarded implicitly. +- **History tab** (cloud TCs): reads server events (cached locally for offline display); + the SQLite-in-book mechanism remains for folder TCs. +- **Unicode**: NFC normalization for names and paths everywhere. + +## Client integration: base-class changes (all folder-backend-behavior-preserving) + +1. Backend factory: `TeamCollectionLink` parses `TeamCollectionLink.txt` (folder path or + `cloud://sil.bloom/collection/`); factory replaces the three hardcoded + `new FolderTeamCollection(...)` sites in `TeamCollectionManager`. Old Bloom versions read + the cloud link as a folder path and land in the safe Disconnected state. +2. Lock seams: `protected virtual TryLockInRepo/UnlockInRepo` (folder keeps read-modify-write; + cloud is one conditional RPC). +3. Status-write discipline: cloud `WriteBookStatusJsonToRepo` diff-dispatches to the narrowest + RPC; audit the ~10 `WriteBookStatus` callers. +4. Capability flags: `SupportsVersionHistory`, `SupportsSharingUi`, `RequiresSignIn` — UI + branches on capability, never on concrete type. + +`SyncAtStartup`, clobber/conflict logic, message log, local status files, `DisconnectedTeamCollection` +are reused unchanged. `CloudRepoCache` (thread-safe, persisted snapshot + event cursor) makes +the synchronous status calls cheap and hydrates Disconnected mode. + +## UI changes (summary) + +Settings gains the cloud share path + a Sharing panel (approved-accounts list) replacing the +free-text admin list for cloud TCs; the cloud create dialog is sign-in → confirm immutable +name → initial Send (no folder chooser, no restart). Collection chooser gains "Get my Team +Collections". Collection tab: same status chip (now live/precise), status dialog gains +"Receive Updates" (successor of Reload Collection) and "Send All", a Share button appears, the +per-book panel carries over nearly unchanged (+ signedOut / updatesAvailable states). All new +strings via the XLF pipeline with Send/Receive terminology. + +## Edge cases + +The full ~135-case disposition matrix (every case in src/BloomTests/TeamCollection and the +TeamCollection.cs decision logic → impossible-now / changed / unchanged / disallowed, plus 15 +new cloud-specific cases) lives in the review artifact, Level 4, and drives the test plan. +Headline: most Dropbox-era cases exist only because Dropbox is not transactional and become +structurally impossible; all offline-work-vs-moved-on-repo collisions converge on the unified +`.bloomSource` + incident-event recovery. + +## Test plan (summary) + +- C# unit suites (src/BloomTests/TeamCollection/Cloud/): link/factory, lock seams, cache, + manifest, transfer, member-by-member backend tests, the ported SyncAtStartup matrix + (asserting `.bloomSource` + incident events), monitor, auth, sharing API. +- Server: pgTAP (RLS matrix, checkout concurrency, claiming requires verified email, + last-admin guard, cursor, tombstone/undelete, name uniqueness) + Deno tests per edge function. +- Component (vitest browser mode): SharingPanel, chooser, status panel states, create dialog. +- E2E (Playwright over CDP driving real Bloom.exe; local Supabase + MinIO/sandbox): + E2E-1 create · E2E-2 two-instance collaboration · E2E-3 checkout contention · + E2E-4 forced check-in recovery (`.bloomSource` + incident) · E2E-5 approved accounts across + two machines · E2E-6 kill-mid-Send/resume · E2E-7 un-team adoption · + E2E-8 Receive-during-Send coherence (mandated) · E2E-9 new-book lifecycle/phantom/name-race · + E2E-10 account-switch safety. +- Standing gate: the entire existing folder-TC suite passes unchanged on every branch. + +## Provisioning + +Supabase CLI (migrations in `supabase/` in this repo — decided location; local dev/CI via +`supabase start`) + AWS CLI/Terraform script creating the versioned bucket, lifecycle rules, +`bloom-teams-broker` role, and the assume-role-only IAM user for edge functions. Docker for +local Supabase + MinIO. Two hosted Supabase projects (production, sandbox). + +## Roadmap + +M0 enablers (1–2wk) → M1 create + read-only + join-by-listing + polling + modal Receive +(4–6wk) → M2 checkout + Send + force-unlock + `.bloomSource` recovery (4–6wk) → M3 sharing +panel + Get-my-Team-Collections (2–3wk) → M4 realtime + reconnect hardening (2–3wk) → +M5 adoption polish + server-fed history tab + dogfood (2–3wk). Build is orchestrated per +[CloudTeamCollections/IMPLEMENTATION.md](CloudTeamCollections/IMPLEMENTATION.md). + +## Open items + +- Auth option A/B/C: colleague decision pending (brief in the artifact). +- Safety-window duration: recorded as 7 days ("option one" reading — if "one day" was meant, + transaction lifetime shrinks to ~12h; invariant: transaction lifetime < expiry floor). diff --git a/Design/CloudTeamCollections/CONTRACTS.md b/Design/CloudTeamCollections/CONTRACTS.md new file mode 100644 index 000000000000..48fdb395beac --- /dev/null +++ b/Design/CloudTeamCollections/CONTRACTS.md @@ -0,0 +1,165 @@ +# Cloud Team Collections — frozen API contracts (v1) + +Changes to this file require an orchestrator commit and a version-note bump here. +**Contract version: 1.7** (16 Jul 2026 — added the `get_collection_file_manifest` RPC, +additive (E9): a per-file manifest for one collection-file group so the download path fetches +only changed files pinned to their committed `s3_version_id`, mirroring `get_book_manifest`; +the data already lived in `tc.collection_group_files`, this just exposes it for reads. No +schema change. v1.6, 13 Jul 2026 — durable member display names, additive (John's +13 Jul request): `tc.members` gains an editable `display_name` column; new +`members_set_display_name(collection_id, member_id, display_name)` RPC (admin may set +anyone's, a claimed member their own; blank clears); `members_list` rows carry +`display_name`; `resolve_member_display` (hence `locked_by_name` everywhere it already +appears) prefers it over the JWT-claim event capture; `get_changes` event rows gain +`by_display_name` (the CURRENT durable name of `by_user_id`). Display rule everywhere: +name when set, else email. v1.5, 11 Jul: per-collection-copy "seat" on checkouts, additive +(bug #0, John's ruling): `checkout_book`/`checkout_book_takeover` gain an optional third +`seat` parameter (client-computed stable hash of the local collection folder path) and +return `locked_seat`; `get_collection_state`/`get_changes` book rows carry `locked_seat`; +takeover requires machine AND seat to match, and a NULL stored seat never matches +(fail-safe). v1.4, 9 Jul: added the `checkout_book_takeover` RPC, additive +(account-switch behavior, dogfood batch item 9); `checkin_start_tx`/`checkin_finish_tx` +unchanged. v1.3, 8 Jul: added the "Auth (Option A)" section: the token-receipt endpoint +BloomLibrary2's `src/editor.ts` forwards Firebase tokens to. v1.2, 7 Jul: added the +`get_book_manifest` RPC, additive; the Receive path needs a per-book file manifest and no +existing RPC carried one. v1.1, 6 Jul: two wire-format clarifications under "Postgres +RPCs"; no semantic changes.) + +## Link file + +`TeamCollectionLink.txt` content is either a folder path (legacy folder TC) or +`cloud://sil.bloom/collection/` where `` = the Bloom +CollectionId GUID (also the server `collections.id`). + +## Auth + +Bearer JWT on every request (**Option A, decided 8 Jul 2026**: Supabase third-party Firebase +auth — the JWT is the Firebase ID token itself, unmodified; `CloudAuth` isolates the client +side of this). Claims used server-side: `sub` (user id), `email`, `email_verified`. Claiming +an approval requires `email_verified = true`. + +## Auth (Option A): token-receipt endpoint + +The Bloom-side half of BloomLibrary2's forwarding change (`src/editor.ts`, GOING-LIVE.md +Phase 3.2, not yet written against this file's *previous* prose — this section is the +precise text to write it against). Reuses the exact conventions of the pre-existing +`external/login` endpoint (ExternalApi.cs) that the same BloomLibrary-hosted login page +already posts back to for the legacy Parse session: same host/port +(`http://127.0.0.1:{port}/bloom/api/...`, `port` is the query param the login page was opened +with — see `BloomLibraryAuthentication.LogIn`'s `login-for-editor?port=` URL), same +CORS/OPTIONS handling, same "POST it and move on" shape. It is a **separate** endpoint, not +new fields on `external/login`, because the two payloads are independent (a legacy Parse +sign-in does not imply a Cloud Team Collection one, and vice versa) and the login page may +call either or both. + +**Route**: `POST /bloom/api/external/cloudLogin` + +**Request body** (JSON): +```json +{ "idToken": "", "refreshToken": "" } +``` +Both fields are required, non-empty strings. `idToken` is the raw Firebase ID token JWT +(the login page's own Firebase SDK session already holds this after sign-in); `refreshToken` +is its paired Firebase refresh token. Bloom derives identity (email/user id/email_verified/ +expiry) **only** from decoding `idToken`'s own claims — it never trusts a separately-supplied +email or verified flag (see `FirebaseCloudAuthProvider.AcceptExternalSession` / +`SessionFromIdToken`). + +**Reply**: `200` with an empty body on success (`request.PostSucceeded()`, matching +`external/login`); a non-2xx status with a plain-text error message on failure (e.g. a +malformed/unparseable token). An `OPTIONS` preflight always succeeds with an empty 200, same +as every other `external/*` endpoint. + +**Side effects on success**: the same as the dev-mode `sharing/login` endpoint — +`CloudAuth`'s in-memory session is replaced (persisted via `DpapiCloudTokenStore` once the +production wiring in GOING-LIVE.md Phase 3.5 selects it), the `sharing`/`loginState` +websocket event fires so any open `useSharingLoginState()` subscriber (e.g. `SignInDialog`) +re-queries and updates/closes itself, and the Bloom window is brought to the front (matching +`external/login`'s `Shell.ComeToFront()` — the user's attention is already on the browser tab +that just finished signing in). + +## Postgres RPCs (PostgREST `/rest/v1/rpc/...`) + +Wire-format clarifications (v1.1): (1) the implemented SQL functions prefix every parameter +with `p_`, and PostgREST matches JSON keys to parameter names — so clients send +`{"p_collection_id": ...}` etc.; the table below keeps the logical (unprefixed) names. +(2) The `tc` schema is exposed as a separate PostgREST schema: RPC calls must carry the +`Content-Profile: tc` header (reads: `Accept-Profile: tc`). + +| RPC | Args → Result | +|-----|----------------| +| `create_collection(id uuid, name text)` | creates collection + caller as sole claimed admin | +| `my_collections()` | collections where caller's email is approved (claimed or not) | +| `claim_memberships()` | fills user_id on rows matching caller's verified email | +| `get_collection_state(collection_id, since_event_id?)` | full/delta snapshot: book rows (locks, current version seq + checksum), collection-file group versions, `max_event_id` | +| `get_changes(collection_id, since_event_id)` | events + touched book rows (polling/catch-up) | +| `get_book_manifest(book_id)` | v1.2: per-file current manifest `{bookId, versionId, seq, checksum, files:[{path, sha256, size, s3VersionId}]}` for pinned-version Receive; never-committed books invisible except to their mid-Send lock holder | +| `get_collection_file_manifest(collection_id, group_key)` | v1.7: per-file current manifest `{groupKey, version, files:[{path, sha256, size, s3VersionId}]}` for one collection-file group, so the download path fetches only changed files pinned to their committed `s3_version_id` (E9); a never-written group returns `version 0` / empty `files`. Mirrors `get_book_manifest`. | +| `checkout_book(book_id, machine text, seat text?)` | conditional lock; returns resulting status (winner's identity on failure). v1.5: also records the caller's `seat` — a stable hash of the local collection folder path identifying WHICH local copy took the lock (never the raw path); returns `locked_seat`. | +| `checkout_book_takeover(book_id, machine text, seat text?)` | v1.4/v1.5: atomically reassigns another account's lock to the caller ONLY when the existing lock is recorded for the same machine AND the same seat (account-switch, batch item 9 + bug #0: two local copies on one computer are two seats); a NULL stored seat never matches (fail-safe). Returns `{success, locked_by, locked_by_machine, locked_seat, locked_at}` (same shape as checkout_book); emits a CheckOut event only on a genuine handover; safe to call speculatively — no-ops (success:false) when unlocked, already the caller's, locked on a different machine, or locked in a different/unknown seat. Note: `machine` and `seat` are client-asserted, consistent with checkout_book's existing trust model. | +| `unlock_book(book_id)` | release own lock (undo checkout, no content change) | +| `force_unlock(book_id)` | admin; audited; emits ForcedUnlock event | +| `delete_book(book_id)` | requires caller holds the lock; sets `deleted_at`; emits Deleted | +| `undelete_book(book_id)` | admin; clears tombstone (name-uniqueness enforced) | +| `rename_check(book_id, new_name)` | advisory uniqueness pre-check | +| `members: list/add/remove/set_role` | admin-only approved-accounts management; remove force-unlocks that user's checkouts (evented); last-admin guard. v1.6: list rows carry `display_name` | +| `members_set_display_name(collection_id, member_id bigint, display_name text)` | v1.6: sets the durable human-readable name shown in place of the email (member list, checkout status, history). Admin may set anyone's; a claimed member may set their own; blank/whitespace clears to NULL (display falls back to email); max 100 chars | +| `add_palette_colors(collection_id, palette, colors[])` | union merge | +| `log_event(...)` | client-originated history entries | + +All timestamps server-side. All RPCs RLS-gated; books/versions accept no direct writes. + +## Edge functions (`/functions/v1/`, JWT-verified; only these hold AWS creds) + +### `checkin-start` POST +Req: `{ collectionId, bookId?, bookInstanceId, proposedName, baseVersionId?, checksum, +clientVersion, files: [{path, sha256, size}] }` +- `bookId` null ⇒ first Send of a new book: validates name/instance-id uniqueness; creates the + row locked to caller with NO current version (invisible to teammates until first commit). +- Re-call with the same open transaction ⇒ refreshed credentials, same transactionId. +200: `{ transactionId, changedPaths[], s3: { bucket, region, prefix, +credentials: { accessKeyId, secretAccessKey, sessionToken, expiration } } }` +(creds scoped `tc/{cid}/books/{bookInstanceId}/*`, 1 h) +Errors: 401/403 · 409 `LockHeldByOther` (+holder) / `BaseVersionSuperseded` / `NameConflict` +· 426 `ClientOutOfDate`. + +### `checkin-finish` POST +Req: `{ transactionId, comment?, keepCheckedOut? }` +Verifies each changed object's sha256 attribute; captures s3 version-ids; one DB tx: +version (metadata) row, current-manifest rows (superseded rows pruned), book row update, +lock release (unless keepCheckedOut), events (Created+CheckIn for a new book), writes +`.manifest.json`. 200: `{ versionId, seq }` · 409 `MissingOrBadUploads { paths[] }` +(re-upload + retry, idempotent) · 410 transaction expired. + +### `checkin-abort` POST — `{ transactionId }` → 200. + +### `download-start` POST — `{ collectionId }` → +200 `{ s3: {...} }` read-only creds (`GetObject` + `GetObjectVersion`) scoped `tc/{cid}/*`, 1 h. + +### `collection-files-start` / `collection-files-finish` POST +`{ collectionId, groupKey: 'other'|'allowed-words'|'sample-texts', expectedVersion, files[] }` +two-phase like check-in; finish bumps the group version atomically; 409 `VersionConflict` +⇒ client pulls first (repo-wins rule). + +## Realtime + +Private broadcast channel `collection:{uuid}` (events-table trigger). Message: +`{ eventId, type, bookId?, versionSeq?, byUserName, byEmail, lock?, name?, groupKey? }`. +Clients persist `last_seen_event_id`; on (re)connect always run one `get_changes` delta first. +Event `type` values = existing `BookHistoryEventType` numerics + incident extensions +(e.g. WorkPreservedLocally). + +## S3 layout (bucket versioning ON; lifecycle: abort-multipart 7d, noncurrent expiry ~7d) + +``` +tc/{collectionId}/books/{bookInstanceId}/{relativePath} (NFC-normalized) +tc/{collectionId}/books/{bookInstanceId}/.manifest.json (current manifest backup) +tc/{collectionId}/collectionFiles/{group}/{relativePath} +``` +Reads are ALWAYS by (path, s3VersionId) from the committed manifest — never "latest". +Invariant: check-in transaction lifetime < noncurrent-expiry floor. + +## Book-status JSON (client ↔ TeamCollectionApi, additive) + +Existing `IBookTeamCollectionStatus` fields unchanged; adds `localVersionSeq?`, +`repoVersionSeq?`, `signedIn`, backend capability flags. diff --git a/Design/CloudTeamCollections/GOING-LIVE.md b/Design/CloudTeamCollections/GOING-LIVE.md new file mode 100644 index 000000000000..fcb338f10b58 --- /dev/null +++ b/Design/CloudTeamCollections/GOING-LIVE.md @@ -0,0 +1,232 @@ +# Cloud Team Collections — going-live runbook + +How to take Cloud Team Collections from the fully-local dev stack (local Supabase + MinIO + +dev auth; see `server/dev/README.md`) to real, testable infrastructure, and what must be true +before the `cloud-collections` branch merges to master. Each step is tagged **[HUMAN]** (needs +credentials, org access, or a judgment call) or **[AGENT]** (a codeable task an agent can be +given, with the human reviewing). Steps are ordered; parallelizable groups are noted. + +Design context: `../CloudTeamCollections.md` · Contracts: `CONTRACTS.md` · Progress: +`IMPLEMENTATION.md` (this file expands its "Deferred until real infrastructure" list). + +--- + +## Phase 1 — Decisions (block everything else in Phase 2+) + +### 1.1 [DECIDED 8 Jul 2026] Auth option: **A** +**Option A: Supabase third-party Firebase auth** — Supabase is configured to trust +Firebase-issued JWTs directly, so the Bloom user signs in with the same BloomLibrary +(Firebase) account they already have, and that token IS the Supabase credential. Phase 3 +below is therefore actionable; the Bloom-side provider work (3.4) started the same day. +- **A (chosen)**: BloomLibrary2's login page forwards the Firebase ID + refresh tokens it + already holds to Bloom (~5 lines in BloomLibrary2 `src/editor.ts`), and Supabase is set to + accept Firebase as a third-party auth provider. Requires one NEW small Firebase Admin cloud + function that adds the static `role: "authenticated"` custom claim to every user (plus a + one-time backfill over existing users — no custom-claims infrastructure exists today). +- ~~B~~: exchange the legacy Parse session token — rejected; welds us to bloom-parse-server, + which is being decommissioned. +- ~~C~~: hand-validate Firebase JWTs ourselves per the stale `bloom-parse-server/supabase/` + docs — rejected; more code we own, no benefit over A. + +### 1.2 [DECIDED 9 Jul 2026] Safety-window duration: **7 days** +John confirmed the `provision-aws.ps1` default of 7 days for noncurrent-version expiry +("keeping noncurrent objects for 7 days seems plenty"). Constraint honored: strictly greater +than the 48-hour checkin-transaction lifetime (`tc.checkin_transactions.expires_at`). No +script change needed — step 2.1 runs as written. + +--- + +## Phase 2 — Infrastructure provisioning + +### 2.1 [HUMAN] Provision AWS (S3 + IAM) +Run the reviewed-but-never-run script (needs an AWS account + CLI credentials with S3/IAM +admin rights — that's why it's a human step): + +```powershell +# dry run first: +server\provision-aws.ps1 -WhatIf +server\provision-aws.ps1 # defaults: bloom-teams-production + bloom-teams-sandbox, us-east-1 +``` + +It idempotently creates, per environment: +- S3 bucket `bloom-teams-` — versioning ON, public access blocked, lifecycle rules + (abort incomplete multipart 7d; expire noncurrent versions under `tc/` per step 1.2). +- IAM role `bloom-teams-broker` — the role the edge functions AssumeRole into to mint + short-lived, per-request, per-book-prefix-scoped credentials for clients. +- IAM user `bloom-teams-broker-caller` — assume-only (its sole permission is sts:AssumeRole on + that role). Its access key becomes the edge functions' `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`. +- IAM user `bloom-teams-admin` — direct S3 permissions, used ONLY server-side (checksum/ + version-id verification, manifest backup). Key becomes `BLOOM_S3_ADMIN_ACCESS_KEY`/`_SECRET_KEY`. + +Before running: review the embedded IAM policy JSON against current least-privilege guidance, +and note the script's own NOTES section (developed against AWS CLI v2, never executed). +**Record the two access-key pairs somewhere safe; they are needed in 2.3.** + +### 2.2 [HUMAN] Create the hosted Supabase projects +Two projects (production + sandbox) in the org's Supabase account. For each: +1. `supabase link --project-ref ` from the repo root. +2. `supabase db push` — applies the exact same checked-in migrations + (`supabase/migrations/*.sql`) the local stack runs. No schema differences exist. +3. `supabase functions deploy` — deploys the same checked-in edge functions + (`supabase/functions/*`). +4. Record each project's URL and anon key (Dashboard → Settings → API) for 2.4 and Phase 3. + +This is how "Supabase gets access to the S3 buckets": the edge functions run INSIDE Supabase +and read the AWS credentials from Supabase **secrets** (next step). Nothing else links them. + +### 2.3 [HUMAN] Set the edge-function secrets (this is the Supabase↔S3 handshake) +For each project: + +```bash +supabase secrets set BLOOM_CLOUD_LOCAL_MODE=false +supabase secrets set AWS_ACCESS_KEY_ID= +supabase secrets set AWS_SECRET_ACCESS_KEY= +supabase secrets set BLOOM_S3_ADMIN_ACCESS_KEY= +supabase secrets set BLOOM_S3_ADMIN_SECRET_KEY= +supabase secrets set BLOOM_S3_BUCKET=bloom-teams- +supabase secrets set BLOOM_S3_REGION=us-east-1 +# do NOT set BLOOM_S3_ENDPOINT in production — its absence selects real AWS endpoints +``` + +`BLOOM_CLOUD_LOCAL_MODE=false` flips `_shared/env.ts`/`s3.ts` from MinIO-AssumeRole local +credentials to real AWS STS (false is also the default when unset — hosted deployments, +including any future "dev"-named project, never set it). The names mirror the local +`server/dev/functions.env` (which is the committed, local-only-constants version of this +same set). + +### 2.4 [AGENT] Security hardening: lock down the `tc.*_tx` RPCs ← REQUIRED before production +Currently the internal transaction RPCs are EXECUTE-granted to `authenticated` because edge +functions forward the caller's JWT. A member could call e.g. `checkin_finish_tx` directly and +bypass the edge function's S3 checksum verification (blast radius limited to their own +collection, but still). Task: new migration that (a) switches the edge functions to use the +service-role key with an explicit verified-user-id parameter, and (b) REVOKEs `authenticated` +from all `*_tx` functions. Convention: never edit merged migrations — new migration files only. +Verify with a pgTAP test that `authenticated` can no longer execute them. Human review of the +migration before deploy. + +--- + +## Phase 3 — Auth implementation (after 1.1; assumes Option A) + +### 3.1 [HUMAN] Enable Firebase as third-party auth on both Supabase projects +Dashboard → Authentication → Third-party Auth → add Firebase, pointing at the BloomLibrary +Firebase project. (This is configuration, not code, and needs org access to both consoles.) + +### 3.2 [AGENT, other repo] BloomLibrary2 token forwarding +The ~5-line change in BloomLibrary2 `src/editor.ts`: after login, forward the Firebase ID + +refresh tokens to Bloom (the login flow Bloom already hosts in a browser for registration). +Lives in the BloomLibrary2 repo; needs that repo's normal review/deploy cycle. + +### 3.3 [AGENT, Firebase] `role: "authenticated"` custom-claim function + backfill +A small Firebase Admin cloud function that stamps the static claim on user creation, plus a +one-time backfill script over existing users. Supabase requires this claim on third-party JWTs. +[HUMAN]: deploy it (Firebase console/CLI credentials) and run the backfill. + +### 3.4 [AGENT] Real `CloudAuth` provider in Bloom +Implement the production provider behind the existing `CloudAuth`/`CloudAuthProvider` seam +(`src/BloomExe/TeamCollection/Cloud/`): accept the forwarded Firebase tokens, refresh as +needed, expose the same `GetLoginState`/`SignIn`/`SignOut` surface the dev provider has. +Includes the deferred **persistent token store** (survive Bloom restarts; the dev provider +skips this). `CloudAuthMode.Cloud` already exists as the selector. The server-side +`tc.jwt_email_verified()` helper already isolates the Firebase-vs-GoTrue `email_verified` +claim-shape difference — verify it against a real Firebase JWT and adjust in a new migration +if needed. Claiming an approval requires `email_verified` (all BloomLibrary accounts qualify). + +### 3.5 [AGENT] Client production defaults +`CloudEnvironment.cs` compiled defaults currently point at the local stack. Change to: real +production Supabase URL + anon key, empty `DefaultS3Endpoint` (its absence selects real AWS +virtual-hosted style — `S3ForcePathStyle` already keys off this), production bucket, +`CloudAuthMode.Cloud`. Sandbox/dev keep working via the `BLOOM_CLOUDTC_*` env-var overrides +(document a "sandbox profile" env block in server/dev/README.md). Anon keys are public by +design; committing them is fine. + +--- + +## Phase 4 — Verification against real infrastructure (sandbox) + +### 4.1 [AGENT] Parity re-verification +Re-run `server/dev/parity-check` against the sandbox bucket + real STS: sha256 checksum +headers, S3 version-id capture on PUT, lifecycle behavior, AssumeRole session-policy scoping. +These were verified against MinIO on the explicit assumption they'd be re-checked on AWS. +[HUMAN]: supply temporary credentials for the run. + +### 4.2 [AGENT] E2E matrix against sandbox +Point the E2E harness at sandbox via env vars (the harness's `devStack.ts` values become a +config block) and run the full matrix. Expect to keep per-scenario reset working — that needs +a sandbox-reset path (`supabase db reset --linked` is destructive and slow; an agent should +add a `tc`-schema truncate + bucket-prefix-clear script instead). Also re-run the two +`[Explicit]` C# live tests (`CloudTeamCollectionLiveTests`) with sandbox env vars. + +### 4.3 [DECIDED 9 Jul 2026] AWSSDK.S3 version bump: **on this branch, before go-live** +John: "It's fine for this branch to bump the S3 sdk version." [AGENT] executes the bump and +runs the full suites (cloud filter + FULL BloomTests once + the E2E matrix — AWSSDK is also +used by the BloomLibrary web-upload code, so the blast radius is wider than cloud TCs). +**[HUMAN] added to the test plan at John's request: after the bump, manually verify that web +book UPLOAD (publish to bloomlibrary.org) and DOWNLOAD (get a book from bloomlibrary.org +into Bloom) are unaffected.** Queued as item 10 in `orchestration/DOGFOOD-BATCH-1.md`. +EXECUTED 9 Jul 2026 (merged): AWSSDK.S3/Core 3.5.x → 4.0.100.3; MinIO-facing clients pin +checksum behavior to WHEN_REQUIRED (v4's WHEN_SUPPORTED default breaks S3-compatibles); +real-AWS BloomS3Client keeps v4 defaults; full BloomTests baseline-identical (3036/0). +**[HUMAN/cross-repo] BloomHarvester extends BloomS3Client in its own repo — it must take a +matching AWSSDK v4 bump before consuming a Bloom release containing this change.** +Also worth a quick [HUMAN] smoke when convenient: problem-report book upload (YouTrack / +bloom-problem-books bucket) rides the same SDK. + +--- + +## Phase 5 — Product gaps to close before REAL-USER testing + +Found during Wave-4 E2E work (see `tasks/09-e2e.md` progress log for full detail). The +experimental-feature flag gates all of this UI, so merging to master does NOT require these — +but giving the feature to real testers does: + +- **[DECIDED 9 Jul 2026 → AGENT] Account-switch behavior (E2E-10).** John's full spec is + recorded as item 9 in `orchestration/DOGFOOD-BATCH-1.md`: local access is unrestricted and + only shared-data operations are gated by the CURRENT logon; opening a collection joined + under another account REFUSES (with admin emails + last local editor named) when the new + logon is not a member, and opens CONNECTED (with atomic checkout takeover on first edit) + when it is. Supersedes the earlier "block logout with unsent changes" sketch. E2E-10 + becomes the acceptance test once implemented. +- **[DECIDED + IMPLEMENTED 9 Jul 2026] Cloud recovery (E2E-4's blocked half).** John's + decision (batch item 8): a sync that must overwrite locally-changed content goes ahead but + first preserves the previous local version as a `.bloomSource` in Lost and Found (+ server + incident). Implemented as a pure checksum guard in the auto-apply worker and the Sync + loop (commit e0526fa30); deliberately NOT the persist-checkout-state-to-local-file + alternative, which was only needed to reproduce folder-TC blocking semantics. Remaining + [AGENT] follow-up: extend E2E-4's spec to cover the now-reachable preserve path. +- **[POLICY DECIDED 9 Jul 2026 → AGENT] Subscription-tier check timing.** John: cloud TCs + require the SAME subscription tier as folder Team Collections — no new policy, reuse the + existing FeatureName.TeamCollection gate. Remaining [AGENT] work is purely the timing bug: + `CheckDisablingTeamCollections` can intermittently disconnect a cloud TC when the + subscription check races cloud sign-in (harness works around it with a test subscription + code); make the check deterministic for cloud TCs. +- **[DONE 9 Jul 2026] Preview pane refresh on Receive** — fixed by batch item 4+5's + auto-apply (the worker refreshes the preview when the applied book is selected). Still + open, nice-to-have: join-conflict states show generic errors (dedicated resolution dialog + was deferred from task 07). + +--- + +## Phase 6 — Merging `cloud-collections` to master + +Prerequisites (mostly already true; verify at merge time): + +1. [AGENT] Final rebase onto master; full folder-TC regression suite green (the widened + filter `~Cloud|~TeamCollection|~SharingApi` plus the FULL BloomTests run once); vitest + suite green; E2E matrix green locally. +2. [HUMAN] Confirm the experimental flag ("Cloud Team Collections (experimental)" in + Settings → Advanced) is the ONLY way the new UI appears — merged code must be inert for + everyone else. (Verified in Wave 4; re-verify after rebase.) +3. [AGENT] XLF check: all new strings `translate="no"`, en-only, and **no `--` inside any + ``** (crashes every launch; rule + history in `.github/skills/xlf-strings/SKILL.md`). +4. [HUMAN] Normal PR review + team heads-up that `server/`, `supabase/`, and + `src/BloomTests/e2e/` are new top-level areas. +5. [DECIDED 9 Jul 2026] Dogfood plan: NO existing team collections are touched. Create + fresh test collections, turn them into cloud TCs, have various testers join and try + things out (against the sandbox infra from Phases 2–4). (Task 10's + `docs/user-walkthrough.md` is the tester-facing doc; feedback channel: whatever is + convenient — nothing formal was mandated.) + +Merging BEFORE Phases 2–5 are done is fine and useful (code is flag-gated and local-stack +self-sufficient); real-user testing needs Phases 2–4 plus at least the account-switch and +recovery items from Phase 5 triaged. diff --git a/Design/CloudTeamCollections/IMPLEMENTATION.md b/Design/CloudTeamCollections/IMPLEMENTATION.md new file mode 100644 index 000000000000..faea071413e6 --- /dev/null +++ b/Design/CloudTeamCollections/IMPLEMENTATION.md @@ -0,0 +1,305 @@ +# Cloud Team Collections — implementation master checklist + +Design: [../CloudTeamCollections.md](../CloudTeamCollections.md) · Contracts: [CONTRACTS.md](CONTRACTS.md) +Rules: agents tick checkboxes **only in their own task file**; this master file is updated +**only by the orchestrator**. Every task PR must build, pass its acceptance tests, and pass +the entire existing folder-TC test suite. + +## Local-first development strategy + +All development and testing through Wave 4 runs against a **fully local stack**: no real S3 +bucket, no hosted Supabase project, and no Firebase/BloomLibrary auth changes are needed to +start. Setup and details live in [tasks/11-local-dev-stack.md](tasks/11-local-dev-stack.md). + +- **Database + API**: local Supabase (`supabase start`, Docker) — the identical Postgres + schema, RLS, RPCs, edge functions (`supabase functions serve`), and realtime that production + will use. Migrations written now ARE the production migrations. (SQLite rejected: it has no + PostgREST/RLS/edge-function equivalents, so we would build a parallel backend and then throw + it away; local Supabase is the product stack itself.) +- **S3 substitute**: MinIO in Docker — an S3-compatible server that stores objects on the + local file system, with object versioning and checksum support. `BloomS3Client` / + TransferUtility talk to it through a service-URL override; nothing else in the client + changes. In dev mode the edge functions return static MinIO credentials **in the same JSON + shape as the production STS response**, so CONTRACTS.md is unchanged (per-book credential + scoping is a production security measure, not a functional dependency). +- **Auth substitute**: local GoTrue (bundled with local Supabase) email/password with + auto-confirm — any email + password signs up/in as a valid, verified user ("a backend that + accepts any login"), yielding real JWTs so RLS and every RPC run unchanged. Real + BloomLibrary/Firebase sign-in (Option A/B/C) plugs in later behind the existing `CloudAuth` + seam; the server side isolates the token-shape difference (Firebase `email_verified` claim + vs GoTrue confirmation) in one SQL helper, `tc.jwt_email_verified()`. +- **Two instances on one machine**: each Bloom instance gets its own collection folder plus + `BLOOM_CLOUDTC_USER` / `BLOOM_CLOUDTC_PASSWORD` env-var overrides so it runs as a distinct + dev identity (bypassing the shared stored-token settings). This is the Wave 3 manual smoke + and the mechanism the Wave 4 E2E harness scales up. +- **Environment switching**: every external endpoint (Supabase URL, anon key, S3 + endpoint/bucket/path-style, auth mode) resolves through one `CloudEnvironment` config + (env vars over compiled defaults; owned by task 03). Cutover to the real bucket, hosted + Supabase, and real sign-in is configuration plus the deferred-infrastructure list below — + zero protocol or schema change. + +## Branching + +- Integration branch: `cloud-collections` (base branch: **confirm with John** — master vs the + active Version6.x branch). Base merged into integration weekly. +- One branch + one git worktree per task; PRs into the integration branch, merged one at a + time by the orchestrator after code review. + +## Waves + +| Wave | Tasks | Parallel? | Gate | +|------|-------|-----------|------| +| 0 | [00-enablers](tasks/00-enablers.md) | No — orchestrator-led (shared hot files) | Existing TC suite green, zero behavior change | +| 1 | [11-local-dev-stack](tasks/11-local-dev-stack.md) · [01-server-schema](tasks/01-server-schema.md) · [02-edge-functions](tasks/02-edge-functions.md) · [03-auth](tasks/03-auth.md) · [07-ui-setup](tasks/07-ui-setup.md) | Yes — zero file overlap (contracts frozen first) | Each task's acceptance tests; 11's stack-smoke script green | +| 2 | [04-client-core](tasks/04-client-core.md) · [08-ui-collection-tab](tasks/08-ui-collection-tab.md) | Yes | Unit suites green | +| 3 | [05-cloud-backend](tasks/05-cloud-backend.md) → [06-api-endpoints](tasks/06-api-endpoints.md) → UI wiring | **Sequenced** (shared files) | Two-instance smoke on ONE machine against the local stack | +| 4 | [09-e2e](tasks/09-e2e.md) · [10-adoption](tasks/10-adoption.md) | Yes | Full E2E matrix green against the local stack; dogfood | + +## Shared-file schedule (no two concurrent tasks may touch the same one) + +| File | Owner | +|------|-------| +| TeamCollection.cs, TeamCollectionManager.cs | Wave 0 only (orchestrator) | +| TeamCollectionApi.cs | 06 only | +| CollectionChooserDialog | 07 only | +| FeatureRegistry.cs, BloomExe.csproj | Orchestrator at merge time | +| supabase/** | 01/02 (01 owns migrations; 02 owns functions/); 11 owns config.toml auth/dev settings + seed | +| server/dev/** (docker-compose, seeds, smoke script, docs) | 11 only | +| Cloud/CloudEnvironment.cs | 03 only | + +## Deferred until real infrastructure is available (tracked, NOT blocking) + +**The detailed go-live runbook — ordered steps, each tagged [HUMAN] or [AGENT], covering AWS +provisioning, hosted Supabase, the Supabase↔S3 secret handshake, Firebase auth (Option A), +sandbox verification, remaining product gaps, and the merge-to-master checklist — is +[GOING-LIVE.md](GOING-LIVE.md).** The list below remains as the summary index. + +Each of these is a config/provisioning swap, not a code change, thanks to the seams above. + +- [ ] Auth Option A/B/C decision (colleague review) and, for Option A: the BloomLibrary2 + `src/editor.ts` token-forwarding change + the Firebase Admin claim function (other repos). + Then: implement the real `CloudAuth` provider behind the existing interface. +- [ ] Run `server/provision-aws` (script is written and reviewed in task 02) against real AWS: + buckets, versioning, lifecycle, `bloom-teams-broker` role, assume-only IAM user. +- [ ] Create hosted Supabase projects (production + sandbox); `supabase db push` the same + migrations; deploy the same edge functions; `supabase secrets set` the AWS credentials. +- [ ] Flip edge functions from static-MinIO-credential dev mode to real STS (env switch). +- [ ] Re-verify MinIO/AWS parity assumptions against the real bucket (sha256 checksum headers, + s3 version-id capture, lifecycle behavior) and re-run the E2E matrix against sandbox. +- [ ] **Security hardening (REQUIRED before production):** the internal `tc.*_tx` RPCs are + currently EXECUTE-granted to `authenticated` because edge functions forward the + caller's JWT (identity stamping via auth.jwt()). A member could therefore call e.g. + `checkin_finish_tx` directly, bypassing the edge function's S3 checksum verification + (blast radius: corrupting manifests in their own collection only — membership/lock + still enforced). Before cutover: switch edge functions to the service-role key + + explicit verified-user-id parameter, then revoke `authenticated` from all `*_tx` + functions. + +## Status + +- [x] Wave 0 complete (folder backend provably unchanged — 208/208 TC tests green) +- [x] Wave 1 complete 7 Jul 2026 (local dev stack live; schema+RPCs pgTAP-green; edge + functions live-verified; auth skeleton w/ dev provider; UI shells tested) + - [x] 01-server-schema DONE — pgTAP 42/42 green on the live local stack (6 Jul 2026) + - [x] 11-local-dev-stack DONE — full stack verified on **Podman 5.8.3** (rootful, WSL2, + Docker-compat pipe) instead of Docker Desktop: MinIO up w/ versioning+lifecycle, + dev sign-in works, parity spike 4/4, smoke green. README documents the recipe. + - [x] 02-edge-functions DONE — six functions live-verified (26/26 integration checks on + the local stack incl. MinIO AssumeRole dev creds); Deno 32/32; provision-aws + authored-not-run. Found+fixed: get_collection_state new-book leak (01's file); + edge_runtime per_worker; MinIO joined to the supabase network (gvproxy hang). + - [x] 03-auth DONE — CloudEnvironment/CloudAuth(dev provider)/CloudCollectionClient; + 46/46 cloud + 244/244 folder-TC tests; live-verified vs local stack. Deferred: + persistent token store; real provider awaits Option A/B/C; human GUI smoke + 2h soak. + - [x] 07-ui-setup (shells) DONE — SharingPanel, cloud create dialog, chooser + "Get my Team Collections", registration email lock; 29/29 component tests; + XLF en-only. Wiring to real endpoints deferred to Wave 3 (after 06) as planned. +- [x] Wave 2 complete 7 Jul 2026 — 04 client core (83/83 cloud, 281/281 folder-TC); + 08 collection-tab shells (60/60 component tests; folder path zero-extra-requests) +- [x] Wave 3 complete 7 Jul 2026 — GATE PASSED: two-instance manual smoke on one machine + (checkout contention visible immediately; Send/Receive round trip with real content; + automatic status propagation via polling). Twelve real bugs found+fixed+pinned during + the smoke; see merge log. +- [ ] Wave 4 complete + - [x] 09 harness + E2E-1/E2E-2 DONE 8 Jul 2026 — Playwright-over-CDP harness at + `src/BloomTests/e2e/` (build-once/launch-many, per-scenario DB+MinIO+scratch reset, + DB/S3 verification, experimental-flag automation); E2E-1 (1.4 min) and E2E-2 + (2.5 min, the automated two-instance smoke) green on orchestrator re-runs. + - [x] 09 scenarios E2E-3..9 DONE 8 Jul 2026 — all green individually AND each has passed + in matrix context; found+fixed 3 product bugs (UpdateUiForBook NRE = the Wave-3 + latent recovery NRE; MapError error-envelope mismatch; LogEvent p_message). E2E-4 + partial (`.bloomSource` recovery unreachable via cloud — product decision needed); + E2E-10 blocked (account-switch safety unimplemented — product decision needed). + See tasks/09-e2e.md findings 1–10. + - [x] Acceptance PASSED 9 Jul 2026: **full matrix 13/13 green in one run** (29.7 min, + idle machine) — all ten planned scenarios plus the join-auto-open pin. What made it + converge after five 8–11/12 runs: the finding-9 product fix (automation mode logs + errors instead of hanging on modal problem-report/notify/progress dialogs — approved + by John, gated strictly on --automation) plus harness hardening (kill-by-PID, + propagation polls budgeted past the organic 60s cycle, status-probe fallback). + - [x] 10-adoption DONE 8 Jul 2026 — all 7 polish items; see merge log. + - [ ] Dogfood (needs GOING-LIVE.md phases 2–4 for real-infra, or a local-stack pilot). +- [ ] Real-infrastructure cutover complete (deferred list above) +- [x] Auth option DECIDED 8 Jul 2026: **Option A** (Supabase third-party Firebase auth). + Bloom-side provider work unblocked (see GOING-LIVE.md Phase 3 and task 12); the + BloomLibrary2 token-forwarding change and the Firebase custom-claim function/backfill + remain other-repo/[HUMAN] items. +- [ ] Safety-window duration confirmed (7 days vs 1 day) + +## Merge log + +(orchestrator appends: date · task · PR · notes) + +- 9 Jul 2026 · dogfood bug: checkin comment lost · direct commit · The user's + "what did you change?" message was written only to the book's local history.db (correct + and sufficient for folder TCs, where history.db rides inside the .bloom file), but cloud + history is displayed from the server's event log — and PutBookInRepo never forwarded the + comment to checkin-finish, so it silently vanished for everyone. Fix: checkinComment + threaded through PutBook/PutBookInRepo into CheckinFinish (server p_comment was already + fully plumbed into tc.versions.comment and the event message). Live round-trip test now + asserts the comment appears in get_changes. Found by John while dogfooding. +- 8 Jul 2026 · 12-real-auth (Option A seams) · merged locally · Same-day implementation of + everything the Option A decision unblocked in this repo: FirebaseCloudAuthProvider + (identity strictly from ID-token claims; securetoken refresh; signature verification + deliberately delegated to Supabase per the provider's doc comment), DPAPI-persistent + token store, `POST external/cloudLogin` receipt endpoint (CONTRACTS.md v1.3 documents the + exact shape the BloomLibrary2 editor.ts change must target — note it should POST to this + NEW endpoint, not add fields to external/login), tc.jwt_email_verified() verified + Firebase-ready (no migration needed), reference Firebase Admin claim function + backfill + under server/firebase/. Agent found+fixed a real cross-end mismatch: CloudAuthMode was + "real" in C# but "cloud" in the TS contract; also populated the emailVerified field TS + had declared but C# never sent. Verified independently: C# widened filter 359/359; + pgTAP 42/42; vitest 5/5. Still deferred (GOING-LIVE 3.1–3.3): hosted-Supabase Firebase + config, the BloomLibrary2 change itself, Firebase deploy + backfill run. + +- 8 Jul 2026 · post-merge E2E gate · direct commits · The gate caught ONE real merge bug: + task 10's new XLF `` texts contained double hyphens, which L10NSharp parses as + illegal XML-comment content — EVERY Bloom launch crashed at startup in SetUpLocalization + (no unit/component test can catch this; only a real launch reads the installed XLF). + Fixed + rule pinned in .github/skills/xlf-strings/SKILL.md. Remaining gate failures were + environmental, proven by a pre-merge control run failing identically: (a) a locked Windows + session stalls WebView2 at about:blank indefinitely (E2E needs an unlocked desktop); + (b) createCloudTeamCollection deadlocks if posted while the workspace WebView2 is still + initializing (UI-thread handler + modal progress dialog vs nested message pump — E2E-1 + now uses E2E-2's connect-before-trigger pattern as a guard). Full diagnosis in task 09's + progress log, findings 7–8. + +- 8 Jul 2026 · 09-e2e (harness + E2E-1/2) · merged locally · Harness encodes every smoke-test + environment rule (Release build MANDATORY — Debug shows a blocking attach-debugger dialog on + any positional arg; build-once/launch-many; foreign-Bloom fail-loud; per-scenario + `supabase db reset` + `mc` bucket clear + scratch wipe; user.config flag automation). + E2E-1 and E2E-2 green on orchestrator re-runs after the agent's runs were starved by + concurrent sessions (~6GB free RAM — diagnosis confirmed by clean re-run). Two product + findings REPORTED for follow-up, not fixed: (1) every ReactDialog-hosted WebView2 requests + the same fixed remote-debugging port, so secondary dialogs are never CDP-reachable — + harness drives their backend endpoints directly instead; (2) checkout/check-in buttons + ignore CDP-synthesized clicks (root cause undiagnosed; direct API used). Also found: + WebView2 temp-profile folders leak per launch (harness cleans them in globalSetup); + ~1s endpoint-registration race after BLOOM_AUTOMATION_READY (harness retries 404s). + E2E-3..10 remain; E2E-4 must reproduce the recovery-path NRE. + +- 8 Jul 2026 · 10-adoption (+ Wave-3 polish list) · merged locally · All 7 items: proper + "Cloud Team Collections (experimental)" checkbox in Settings→Advanced (ends the + user.config hack); pull-down auto-opens the joined collection; un-team cleanup + (CleanStaleTeamCollectionArtifacts) + TeamCollectionLinkConflictException guard with + fix-instructions message; first-Receive reconcile verified-by-reading (no checksum + reconcile happens — matches folder-TC behavior, documented as known limitation); + user walkthrough doc (Design/CloudTeamCollections/docs/user-walkthrough.md); XLF sweep + (11 en entries; fixed a TeamCollection.ConflictingCollection id collision); analytics + audit (cloud join + Receive Updates events added, Backend=Cloud verified elsewhere). + Agent also found+fixed: Team Collection settings tab was invisible when ONLY the cloud + flag was on. Orchestrator review fixes: pullDown replied with the collection FOLDER but + workspace/openCollection needs the .bloomCollection FILE path (renamed field to + collectionPath); doc-comment placement. Verified: C# widened filter 332/332; vitest + 29/29 on touched files. + +- 7 Jul 2026 · two-instance smoke (Wave-3 gate) · direct commits · PASSED after fixing 12 + live-found bugs: members_add scalar-response crash; missing claim_memberships in join; + identity-model registration-vs-account comparisons (4 sites incl. OkToCheckIn, whose + false conflict ALSO exposed the unified-recovery clobber + .bloomSource save working); + 'null null' display; BookButton anonymous avatar; missing experimental-feature checkbox + (flag set via user.config for now — proper Advanced-settings checkbox still owed); + collection-file mirror-delete stripping TeamCollectionLink.txt; update-Send committing + changed-files-only manifests (data-loss class — server was right, client wrong; Receive + now also refuses empty manifests); cloud change events discarded by the .bloom-suffix + contract; Receive Updates now refreshes UI immediately. Deferred niceties logged: + selected-book PREVIEW pane doesn't refresh on Receive until reselect (old base-code + ENHANCE); pull-down doesn't auto-open; recovery-path NRE reproduction (E2E-4). + +- 7 Jul 2026 · ui-wiring · merged locally · Dispatcher fixes a live folder-TC create-dialog + breakage (WireUpForWinforms last-caller-wins; three instances of the bug class fixed, + regression-tested). Sign-in dialog; SharingPanel + pull-down wiring done. Orchestrator + fix on cloud-collections: SharingApi claimed-detection treated JSON-null user_id as + claimed (JTokenType.Null gotcha) — found because SharingApiTests match NO task's filter; + the widened mandatory filter is now recorded in orchestration/RESUME.md. Post-rebase + full suite 318/318. Known smoke-test limitations: pull-down doesn't auto-open the new + collection; join-conflict states show generic errors (matching-flags endpoint TBD); + real-auth mode intentionally still a placeholder. + +- 7 Jul 2026 · 05-cloud-backend · merged locally · Live Send→Receive→lock round trip green. + Agent's live test found+fixed 2 integration bugs (RestSharp serializer mangling JTokens; + S3 keys built at wrong prefix level). Orchestrator base fixes at review: AttemptLock now + honors TryLockInRepo refusal; BookHistoryEventType.WorkPreservedLocally=100. Findings + routed forward: (a) server stamps locked_by/created_by with auth UUID — 06 adds a + migration surfacing lockedByEmail/Name for display; (b) collection-file groups have no + pinned per-file manifest RPC (client reads latest from S3) — acceptable dev-mode gap, + revisit before production; (c) checkin-start/finish omit the new book's server id — + client refreshes state post-commit (works; consider contract addition later); + (d) SyncAtStartup matrix only partially ported — remainder folded into task 09's scope. + +- 7 Jul 2026 · 08-ui-collection-tab · merged locally · Survived one session-limit + interruption (WIP preserved). Orchestrator review fix: the capability/experimental-flag + hooks fetched per component mount — BookButton would have issued hundreds of identical + requests per Collection-tab visit; now cached once per page load with test-reset seams + in vitest.setup. 60/60 re-verified. Wiring of the ~9 mocked endpoints lands with task 06. + WAVE 2 COMPLETE. + +- 7 Jul 2026 · 04-client-core · merged locally · 83/83 cloud (re-verified) + 281/281 + folder-TC. Three findings for later tasks: (1) CONTRACT GAP — no RPC returns a book's + per-file manifest for Receive; decide at 05 launch (likely additive get_book_manifest + RPC, CONTRACTS bump) vs reading S3 .manifest.json. (2) AWSSDK.S3 3.5.3.10 predates + native checksum properties; manual x-amz-checksum-sha256 header live-verified — SDK bump + is a deliberate SEPARATE follow-up (publish path shares the package). (3) Task 05 must + point CloudBookTransfer.DownloadFiles at a temp book folder and do the final whole- + directory swap itself (the class's per-file move loop after full verification is not a + single atomic dir swap). + +- 7 Jul 2026 · 07-ui-setup · merged locally · Orchestrator review fix: the chooser's cloud + section rendered UNGATED for all users — now behind the experimental feature (re-verified + 29/29 after fix). Registration/settings changes are additive and folder-safe. Deferred to + Wave 3 wiring (documented in task file): SharingPanel into settings' isTeamCollection + branch; JoinCloudCollectionDialog's matching logic into the chooser's onPullDown. + WAVE 1 COMPLETE. + +- 7 Jul 2026 · 02-edge-functions · merged locally · Two agent interruptions survived via + per-step commits (the gvproxy hang the agent later diagnosed was likely the stall cause). + Independently re-verified Deno 32/32 + pgTAP 42/42. In-place edit of applied migration + 20260706000003 accepted THIS TIME (nothing deployed anywhere yet); convention from now + on: schema changes to merged migrations arrive as NEW migration files. Deferred-infra + list gains the *_tx grant hardening item. + +- 6 Jul 2026 · 03-auth · merged locally · Reviewed all three classes; one cosmetic fix + (stranded doc comment). Evidence: 46/46 cloud, 244/244 folder-TC, live GoTrue sign-in + + RPC error-shape verification, [Explicit] two-concurrent-sessions test green. Note for 04: + build RPC/edge wrappers on CallRpc/CallEdgeFunction; RPC errors carry Postgres codes + (typed CONTRACTS codes are edge-function-shaped). Dev default AnonKey is empty — devs set + BLOOM_CLOUDTC_ANON_KEY from `supabase status` (consider compiling in the stable local + demo key later). + +- 6 Jul 2026 · 00-enablers · merged locally · Reviewed diff line-by-line; seams preserve + folder behavior; 208/208 TeamCollection tests (24 new) verified against fresh binaries. + Note: TryLockInRepo gained a BookStatus param vs the task file (avoids redundant GetStatus). +- 6 Jul 2026 · 01-server-schema · merged locally · Orchestrator fixes: checkout_book + ROW_COUNT type bug; pgTAP errcode; realtime pg_notify→realtime.send TODO (wave 4); + seed wiring in config.toml. CONTRACTS.md bumped to v1.1 (p_ arg prefix; Content-Profile + header). pgTAP suite authored but UNRUN (no Docker yet). +- 6 Jul 2026 · 11-local-dev-stack · merged locally · Orchestrator fix: seed bcrypt hash was + invalid (verified with bcryptjs); replaced with a self-verified hash. Smoke script and + parity spike authored but UNRUN (no Docker yet); parity-check compiles clean. +- 6 Jul 2026 (later) · runtime verification of 01+11 · direct commits · Full local stack + verified on Podman (not Docker Desktop). pgTAP 42/42; parity 4/4; smoke green; live RPC + round-trip OK. Fixes found by running: pgTAP plan count + RLS superuser-bypass (tests); + parity-check fabricated session token (MinIO validates tokens → DEV-CREDENTIALS spec + corrected to MinIO AssumeRole); smoke.ps1 PS-5.1 syntax/encoding bugs + JSON `supabase + status` parsing; compose lifecycle via `mc ilm rule add`; committed .gitkeep in all + bind-mounted dirs (Podman does not auto-create them). diff --git a/Design/CloudTeamCollections/docs/unit-test-setup.md b/Design/CloudTeamCollections/docs/unit-test-setup.md new file mode 100644 index 000000000000..f2dae6fcb907 --- /dev/null +++ b/Design/CloudTeamCollections/docs/unit-test-setup.md @@ -0,0 +1,45 @@ +# Cloud Team Collections — unit-test setup + +What a dev machine (or CI agent) needs to run the Cloud Team Collections *unit* tests, as +opposed to the E2E harness (whose much longer requirements live in +`src/BloomTests/e2e/README.md`). + +## The short version + +The default suites need **nothing beyond normal Bloom dev setup** (`./init.sh` once). They run +mocked, with no containers, no local Supabase, no MinIO, no network, and no interactive +desktop — safe for any ordinary CI agent, including one running as a service. + +| Suite | Command | Extra setup | +|---|---|---| +| C# cloud/TC unit tests (~334) | `dotnet test src/BloomTests/BloomTests.csproj --filter "(FullyQualifiedName~Cloud\|FullyQualifiedName~TeamCollection\|FullyQualifiedName~SharingApi)&FullyQualifiedName!~LiveTests"` | none | +| Front-end component tests | ` cd src/BloomBrowserUI` then `yarn vitest run --pool=threads` | `yarn install` once | + +Notes that matter: +- **Never pass `--no-build` to `dotnet test`** — a stale DLL can hide real regressions + (AGENTS.md rule). Building first is the point. +- The C# filter above is the mandatory *widened* filter: `SharingApiTests` live under + `web.controllers` and match neither `~Cloud` nor `~TeamCollection`; a narrower filter once + let a real bug merge behind an "all green" claim. +- vitest on Windows wants `--pool=threads` and single-run mode (`vitest run`, never watch); + the default fork pool has been seen timing out ("Timeout starting forks runner"). + +## Suites that DO need the local dev stack + +Three opt-in suites talk to real services. They all need the local stack from +`server/dev/README.md` (Supabase CLI + Podman/Docker + MinIO — the same "Machine setup" +steps 2–4 in the E2E README, but NOT its unlocked-desktop requirement, since no Bloom.exe +is launched): + +1. **C# live tests** — `CloudTeamCollectionLiveTests`, marked `[Explicit]`, excluded by the + default filter's `!~LiveTests`. Run deliberately with the stack up: + `dotnet test src/BloomTests/BloomTests.csproj --filter "FullyQualifiedName~CloudTeamCollectionLiveTests"`. +2. **pgTAP schema/RLS tests** — `supabase test db` (42 tests; they `SET LOCAL ROLE + authenticated` because superuser bypasses RLS — a vacuously-green trap if ever rewritten). +3. **Deno edge-function tests** — `deno test` under `supabase/functions/` (32 tests; these + mock S3/STS so they only need `deno`, not the stack — `npm i -g deno` or the standard + installer). + +A CI shape that works: the mocked suites in the ordinary per-commit build; the stack-backed +suites (1–2) plus the E2E matrix on the dedicated interactive agent described in the E2E +README's TeamCity section. diff --git a/Design/CloudTeamCollections/docs/user-walkthrough.md b/Design/CloudTeamCollections/docs/user-walkthrough.md new file mode 100644 index 000000000000..b22136296fb3 --- /dev/null +++ b/Design/CloudTeamCollections/docs/user-walkthrough.md @@ -0,0 +1,133 @@ +# Moving your Team Collection to the cloud + +*This page is the working source of truth for this walkthrough until it moves to the +Bloom docs site. It covers migrating an existing folder-based Team Collection (for +example, one shared over Dropbox or a network drive) to a cloud Team Collection, and +inviting your team to it.* + +> **This feature is experimental.** You'll need to turn on "Cloud Team Collections +> (experimental)" in Settings before any of the buttons below appear. Experimental +> features can change or have rough edges. If you run into trouble, use Bloom's +> **Report a Problem** button so the Bloom team can help. + +## Who this is for + +You already have a Team Collection that your team shares by putting a folder in +Dropbox (or a similar shared/synced folder), and you want to switch to Bloom's new +cloud-hosted Team Collections instead — no shared folder, no Dropbox account +required for your team, and everyone just needs an internet connection and a Bloom +account. + +## Before you begin: everyone checks in first + +**This step matters.** Before you disconnect your old Team Collection, make sure +every team member has checked in all of their work, and that nobody has a book +checked out. On the machine you'll use to create the new cloud collection (see +below), open Bloom, go to the Collection tab, and check that every book shows as +checked in (no lock icon). + +Why this matters: when you move to the cloud, Bloom copies whatever is in your +*local* folder at that moment up to the cloud as the starting point for everyone. +If a book had unsaved local edits that were never checked in to the old shared +folder, that book's cloud copy won't have those edits. Checking in first (on every +team member's machine, into the *old* Team Collection) makes sure nothing is lost. + +## Step 1: Turn on the experimental feature + +1. Open Bloom and go to **Settings > Advanced Settings**. +2. Under **Experimental Features**, turn on **Cloud Team Collections (experimental)**. +3. Restart Bloom when prompted. + +Do this on the computer of whoever will create the cloud collection. Each team +member who wants to join later will also need to turn this on (once, on their own +computer) before they can see or join a cloud Team Collection. + +## Step 2: Disconnect ("un-team") the old shared-folder collection + +Pick ONE computer to do this on — normally whoever manages the Team Collection. +This step only needs to happen once, on one machine; other team members don't need +to do anything to their own copies yet. + +1. Make sure step "Before you begin" above is done: everyone has checked in, and no + books are checked out. +2. Open the collection in Bloom. +3. Close Bloom. +4. In your file manager, open this collection's folder (it's normally a + subfolder of Documents\Bloom, and shows a small Team Collection icon on + Bloom's own collection-chooser screen). +5. Find the file named `TeamCollectionLink.txt` in that folder and delete it (or + move it somewhere else, in case you want it back). +6. This collection is now an ordinary, un-shared local collection again. Your + original shared folder (in Dropbox or wherever it lived) is untouched — you can + clean it up later once your whole team has moved to the cloud. + +If you skip this step and try to enable cloud sharing anyway, Bloom will refuse +with an error explaining that `TeamCollectionLink.txt` still links this collection +to the old shared folder, and telling you to delete it first — so it's safe to try +even if you're not sure whether this step already happened. + +## Step 3: Turn your local collection into a cloud Team Collection + +1. Open the (now un-teamed) collection in Bloom. +2. Go to **Settings > Team Collection**. +3. Click **Share this collection on the Bloom sharing server (experimental)**. +4. Sign in with your Bloom account if you're not already signed in. +5. Confirm the collection name — this can't be changed later, so make sure it's + right. +6. Bloom uploads your books to the cloud. This can take a while for a large + collection; you'll see a progress bar. When it's done, you're the collection's + administrator. + +At this point, only you can see and use this cloud collection. The next step +invites the rest of your team. + +## Step 4: Invite your team + +1. Still in **Settings > Team Collection**, you'll now see a list of people + approved to use this collection (just you, so far), with an option to add more. +2. Enter each team member's email address and choose their role (**Member** or + **Administrator**). Use the email address they'll sign in to Bloom with. +3. Repeat for everyone on your team. + +You don't need to invite people one at a time and wait — add everyone now, and +they can each join whenever they're ready. + +## Step 5: Each team member joins + +Each invited team member does this, once, on their own computer: + +1. Turn on the **Cloud Team Collections (experimental)** feature, as in Step 1 + above (if not already on), and restart Bloom. +2. On Bloom's startup screen (the collection chooser), look for **Get my Team + Collections** on the right-hand side. +3. Sign in with the same email address the administrator used to invite you. +4. Your collection should appear in the list. Click it to pull it down. +5. Bloom downloads a local copy and opens it automatically. + +### If you already have a local copy of this collection + +If you (or your team) had local copies of some of these books before switching to +the cloud — for example, from before you un-teamed the old shared collection — +Bloom will offer to merge your existing local collection with the cloud one instead +of starting fresh, but this may replace divergent local copies of a book with +whatever is in the cloud. This is one more reason the "everyone checks in first" +step matters: it means everyone's local copy already matches what got uploaded to +the cloud, so there's nothing to lose. + +## Troubleshooting + +- **"There is already a different Team Collection... on this computer"** — this + computer has a `TeamCollectionLink.txt` left over from Step 2 that wasn't fully + removed, or it's already linked to some other Team Collection with the same + name. Check the folder and remove or rename the conflicting collection folder, + then try again. +- **The "Share this collection" button is disabled or missing** — check that + **Cloud Team Collections (experimental)** is turned on (Step 1) and that you've + restarted Bloom since turning it on. +- **A team member can't see the collection under "Get my Team Collections"** — + double check the administrator invited the exact email address that person signs + in with, and that they've actually signed in (not just opened the sign-in + dialog). +- Still stuck? Use Bloom's **Report a Problem** button — this feature is + experimental and the Bloom team wants to hear about anything that doesn't work + as described here. diff --git a/Design/CloudTeamCollections/notes/write-book-status-audit.md b/Design/CloudTeamCollections/notes/write-book-status-audit.md new file mode 100644 index 000000000000..eaa858300701 --- /dev/null +++ b/Design/CloudTeamCollections/notes/write-book-status-audit.md @@ -0,0 +1,132 @@ +# WriteBookStatus caller audit (task 00 prerequisite for task 05) + +`TeamCollection.WriteBookStatus(bookName, status)` writes a `BookStatus` to **both** the repo +(via the abstract `WriteBookStatusJsonToRepo`) **and** the local status file. The cloud backend +will need `WriteBookStatusJsonToRepo` to diff-dispatch to the narrowest RPC rather than always +writing the full JSON blob. + +All callers are in `TeamCollection.cs` (abstract base class). None of the existing callers are +in `FolderTeamCollection.cs` — that class only overrides `WriteBookStatusJsonToRepo`. + +--- + +## Caller inventory + +### 1. `ForgetChangesCheckin` (line ~271) + +```csharp +status = status.WithLockedBy(null); +WriteBookStatus(finalBookName, status); +``` + +**What it writes**: Clears the lock (releases checkout) after abandoning local changes; the +checksum is unchanged (it came from `GetLocalStatus` before restore). +**Clears lock**: Yes — `WithLockedBy(null)`. +**Cloud dispatch**: → `unlock_book` RPC (no content change). + +--- + +### 2. `AttemptLock` — routed through `TryLockInRepo` (line ~717, post-task-00) + +```csharp +TryLockInRepo(bookName, status); // status already has lockedBy set +``` + +**What it writes**: Sets `lockedBy`, `lockedByFirstName`, `lockedBySurname`, `lockedWhere`, +`lockedWhen` on an otherwise-unchanged status. +**Clears lock**: No — sets it. +**Cloud dispatch**: → `checkout_book` RPC (conditional lock). + +--- + +### 3. `UnlockBook` — routed through `UnlockInRepo(force:false)` (line ~697, post-task-00) + +```csharp +WriteBookStatus(bookName, GetStatus(bookName).WithLockedBy(null)); +``` + +**What it writes**: Clears the lock; all other fields unchanged. +**Clears lock**: Yes. +**Cloud dispatch**: → `unlock_book` RPC. + +--- + +### 4. `ForceUnlock` — routed through `UnlockInRepo(force:true)` (line ~729, post-task-00) + +```csharp +WriteBookStatus(bookName, GetStatus(bookName).WithLockedBy(null)); +``` + +**What it writes**: Force-clears the lock (admin operation); all other fields unchanged. +**Clears lock**: Yes. +**Cloud dispatch**: → `force_unlock` RPC (audited; emits ForcedUnlock event). + +--- + +### 5. `SyncAtStartup` — restore checkout (line ~2467) + +```csharp +WriteBookStatus(bookName, localStatus); +``` + +**Context**: `localAndRepoChecksumsMatch && repoStatus.lockedBy == null`. Someone started a +checkout remotely then changed their mind. We restore our checkout in the repo. +**What it writes**: Restores the full local status (lock + checksum) to repo. +**Clears lock**: No — re-asserts our lock. +**Cloud dispatch**: → `checkout_book` RPC (re-assert our existing checkout). + +--- + +### 6. `SyncAtStartup` — accept remote lock, no local edits (line ~2503) + +```csharp +WriteBookStatus(bookName, repoStatus); +``` + +**Context**: `localAndRepoChecksumsMatch`, repo shows a different lock holder; local has no +edits. We accept the repo's lock state. +**What it writes**: Overwrites local status with repo status (changes lock owner). +**Clears lock**: No (may set or change lock owner). +**Cloud dispatch**: Local-only write — repo already has the correct state. Only +`WriteLocalStatus` should be called; no repo RPC needed. + +--- + +### 7. `SyncAtStartup` — update checksum after repo change, no local edits (line ~2527–2530) + +```csharp +WriteBookStatus(bookName, localStatus.WithChecksum(repoStatus.checksum)); +``` + +**Context**: Book changed in repo; local had no edits; we keep our local checkout but update +the checksum to match the newly-downloaded repo version. +**What it writes**: Updates the checksum in both repo and local status; lock unchanged. +**Clears lock**: No. +**Cloud dispatch**: Local-only write — the repo already has the new checksum (it IS the source +of truth). Only `WriteLocalStatus` should be called; no repo RPC. + +--- + +## Summary table + +| # | Call site | State written | Lock change | Cloud RPC | +|---|-----------|---------------|-------------|-----------| +| 1 | `ForgetChangesCheckin` | restore from repo + clear lock | clears | `unlock_book` | +| 2 | `TryLockInRepo` (`AttemptLock`) | set lock fields | sets | `checkout_book` | +| 3 | `UnlockInRepo(force:false)` (`UnlockBook`) | clear lock | clears | `unlock_book` | +| 4 | `UnlockInRepo(force:true)` (`ForceUnlock`) | force-clear lock | clears | `force_unlock` | +| 5 | `SyncAtStartup` — restore checkout | full local status | sets | `checkout_book` | +| 6 | `SyncAtStartup` — accept remote lock | full repo status | changes owner | local-only | +| 7 | `SyncAtStartup` — update checksum | checksum only | none | local-only | + +## Design note for task 05 + +Callers 6 and 7 write to the repo despite the cloud already holding the authoritative state. +For the cloud backend `WriteBookStatus` should be split so the repo half (`WriteBookStatusJsonToRepo`) +becomes a no-op (or a thin diff) when the cloud is already up-to-date. The cleanest approach is +to override `WriteBookStatus` in `CloudTeamCollection` and route each caller through the +narrowest available RPC, falling back to a local-only write for cases 6 and 7. + +`ForgetChangesCheckin` (caller 1) currently reads the local status before restore; after the +cloud book-copy lands in task 02+, this caller will need to signal an `unlock_book` RPC rather +than going through the full `WriteBookStatus` path. diff --git a/Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md b/Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md new file mode 100644 index 000000000000..ff36ce093d93 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md @@ -0,0 +1,1911 @@ +# Dogfood batch 1 (9 Jul 2026) — restartable work plan + +John's bug/improvement list from first real dogfooding, plus decisions already made. +This file is the durable state for the batch: the orchestrator ticks checkboxes and +updates each item's `Status:` line as work proceeds (same protocol as RESUME.md — commit +after every completed step, progress state lives in git, never only in a conversation). + +**To restart after any interruption:** start a fresh Claude Code session in this repo and +say: **"Resume the dogfood batch per +Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md."** The resumer reads each +item's Status line, secures any uncommitted `task/*` or worktree work as WIP commits, and +continues with the next unchecked step. General orchestration rules (review-before-merge, +C# test filter, dev-stack bring-up, environment quirks) are in RESUME.md and apply here. + +**Testing protocol for this batch (agreed with John 9 Jul):** per item, run only the 1–2 +E2E specs covering the touched area (each spec independently wipes + reseeds the dev DB, +so subsets are trustworthy); add `e2e-1-create-share` whenever a change touches startup or +localizable strings. One full-matrix run before pushing the finished batch. E2E runs are +serialized (shared dev DB + real Bloom launches); code work may proceed during a run. +Bloom windows go to John's spare screen via machine-level `BLOOM_E2E_SCREEN=1`. + +**Decision already made (9 Jul, John):** remote-checkin application is FULLY AUTOMATIC — +when the poll notices a checkin for a book not checked out here, apply it to the local +book folder immediately and refresh the preview if that book is selected. The Sync button +(renamed from Reload) forces an immediate pass; no updates-available nag for the common +case. + +## Work order + +Chosen order: quick wins first, then the propagation cluster (items 4+5 are one work +item), then the two larger UI features. Items 1–3 are independent of everything else. + +### 1. "Bloom is busy" missing localization `[quick]` +Status: DONE (commit 2d74d280f; e2e-1 GREEN in the 9 Jul PM 4-spec queue) +- [x] Found: ExternalBusyOverlay.tsx's fallback message already had id Common.BloomIsBusy + but no XLF entry; the useL10n lookup logged the complaint on every collection-tab + mount. (The specific BloomBridge message the overlay usually shows is intentionally + unlocalized; only the fallback needed an entry.) +- [x] Added to BloomLowPriority.xlf (John's choice) with translate="no" + context note. +- [x] Verify: `e2e-1-create-share` GREEN (9 Jul PM 4-spec queue, 4/4 in 9.5 min). An + earlier attempt failed ONLY because the desktop locked mid-run (WebView2 stuck at + about:blank — the known signature). + +### 2. Poll immediately on book selection `[quick]` +Status: DONE (commit 6f0c4a068; e2e-2 GREEN in the 9 Jul PM queue) +- [x] TeamCollectionManager ctor now subscribes BookSelection.SelectionChanged → if the + current collection is a CloudTeamCollection, Task.Run(PollNow). Results flow through + the existing change-event pipeline (same as timer polls). +- [x] Guard: PollNow's own in-flight coalescing covers rapid selection changes; null + bookSelection guard for unit-test constructions (caught by test run: 10 failures, + fixed, 363/363 green). +- [x] `e2e-2-collaboration-loop` GREEN (9 Jul PM queue) (note: E2E uses a 5s poll, so the + speedup itself is mostly invisible there — the run guards against regressions; the + real check is John's manual test at the default 60s poll). + +### 3. Center the checkin-progress dialog in the status panel `[quick]` +Status: **COMPLETE — John's visual check PASSED 13 Jul 2026** ("Confirmed checking progress +is in the right place"). Same session also hand-confirmed the batch's very first fix: +check-in MESSAGES appear in history and are shared to teammates. +- [x] It's the React BloomDialog in TeamCollectionBookStatusPanel (not BrowserProgressDialog): + now positioned via PaperProps over the #teamCollection div's center, vertically + clamped so the paper stays on-screen (the panel hugs the window bottom). Falls back + to default whole-window centering when #teamCollection is absent (unit tests). + Panel vitest suite 11/11. +- [x] `e2e-2-collaboration-loop` GREEN (9 Jul PM queue). +- [x] [HUMAN, John] Visual check that the checkin-progress dialog appears centered over the + status panel during a manual checkin — PASSED 13 Jul 2026. + +### 4+5. Automatic remote-update application + in-place Sync (one work item) `[medium]` +Status: MERGED + E2E VERIFIED (9 Jul PM queue: e2e-2 + e2e-8 GREEN with auto-apply active; +earlier: orchestrator re-ran C# 375/375 + both vitest files green, reviewed +queue/wiring/panel/XLF line by line). Residual risk (checkout racing the download window) +did not surface in the contention-heavy e2e-8 run; keep an eye on it in the pre-push full +matrix. +Observed bug: after a remote checkin, the other instance updated lock state (avatar + +status panel) but the TC button showed no "updates available" and the preview did not +refresh; book folder content update unverified. +- [x] Diagnose: CloudCollectionMonitor.OnPolledChanges DOES already notify the base + TeamCollection change pipeline correctly (RaiseBookStateChange → HandleModifiedFile at + Application.Idle), and it DID reach the HasBeenChangedRemotely branch — but that branch + only ever wrote a "TeamCollection.BookModifiedRemotely" NewStuff log message; it never + copied anything to the local folder (confirmed: folder TC has the exact same message-only + behavior for this branch, so this wasn't a cloud-specific regression, just a gap neither + backend had filled in). Root cause of "TC button showed no updates available": the + top-bar TeamCollectionButton's color/label state comes from `teamCollection/tcStatus` + (CollectionTabView.cs sends this from TeamCollectionMessageLog.TeamCollectionStatus, which + is driven ONLY by whether a NewStuff message log entry exists) — it is a SEPARATE signal + from `teamCollection/tcStatusMetadata`'s `updatesAvailableCount` (which reads the + CloudRepoCache version numbers directly and is pushed via the `statusMetadataChanged` + socket event on every poll). So `updatesAvailableCount` was almost certainly already + correct and live; what was missing was confirming a NewStuff message actually got written + for John's test run (a corrupted/stuck session, a race, or simply that this was the very + poll that revealed the gap) — this needs re-verification once the desktop is available, + but the code-level cause (message-only branch, no auto-apply) is confirmed and is exactly + what this item's implementation now fixes. +- [x] Implement fully-automatic application: TeamCollection.CanAutoApplyRemoteChanges (false by + default; true only for CloudTeamCollection) + a new single-consumer RemoteBookAutoApplyQueue + (dedupes by book name, one book at a time, runs on Task.Run so downloads never block the UI + thread). HandleModifiedFile's HasBeenChangedRemotely branch now queues the book instead of + just logging when CanAutoApplyRemoteChanges is true; the worker re-verifies eligibility + (still changed remotely, not checked out here, no clobber/checkout conflict) at the moment + it actually runs, then reuses CopyBookFromRepoToLocal, updates book status, and refreshes + the preview (SendBookContentReload) only if the applied book is the one currently selected. + Falls back to exactly the old message-only behavior on failure or ineligibility. Folder TCs + are unaffected (CanAutoApplyRemoteChanges stays false there). +- [x] SAFETY (John, 9 Jul): CopyBookFromRepoToLocal already staged-then-atomically-swapped before + this task (two directory renames; not reimplemented). The auto-apply worker's + re-verification (checked-out-here / clobber / checkout-conflict, re-read fresh on the + worker thread right before copying) is the mechanism that keeps a book "busy-safe": if the + user checks it out, starts editing, or otherwise changes its state between queueing and the + worker actually running, the worker backs off silently rather than clobbering anything. + NOT separately implemented: an explicit UI-level "busy" lock during the download window + itself (publish/delete/rename aren't specially blocked while a download is in flight) — + the re-verification step covers the has-this-changed-since-queueing race but a reviewer + should double check there's no narrow window between the re-verification check and the + actual folder swap where a concurrent user action could interleave badly. Given the swap + itself is two fast directory renames (not file-by-file writes), this window is believed + negligible but wasn't independently stress-tested. +- [x] Rename "Reload" to "Sync" for cloud TCs. The existing `teamCollection/receiveUpdates` + backend endpoint already did PollNow() before its receive loop (no server-side change + needed) — only the label changed, in both the dialog and the per-book status panel. + Discovered and fixed a related bug while in there: the per-book panel's generic + "needsReload" state (isChangedRemotely, a content-update signal identical for both + backends) was checked ahead of the cloud-specific "updatesAvailable" state, so a cloud + book with a pending remote change showed "Reload Collection" instead of "Sync" — cloud now + renders needsReload with the same copy/button as updatesAvailable; folder TCs (and genuine + hasConflictingChange for either backend) are unchanged. +- [x] Keep the reload-requiring paths (settings changes etc.) working — HandleCollectionSettingsChange + and the dialog's showReloadButton/"Reload Collection" path were not touched at all. +- [x] XLF for the new "Sync" label: renamed TeamCollection.ReceiveUpdates → TeamCollection.Sync + (was translate="no", so free to rename per the xlf skill) and updated + UpdatesAvailableForBookDescription's text/note to match. +- [ ] Verify: `e2e-2-collaboration-loop` + `e2e-8-receive-during-send`; e2e-1 for the XLF. NOT + run — this task's hard rules forbid launching Bloom / running e2e (desktop session + locked); queued for the next E2E pass alongside items 1–3. +- [x] Update tests/specs that assert on the old Reload wording/behavior: TeamCollectionDialog.test.tsx + and TeamCollectionBookStatusPanel.test.tsx updated for the rename, plus new tests for the + auto-apply eligibility logic (RemoteBookAutoApplyQueueTests, TeamCollectionAutoApplyTests) + and the needsReload cloud/folder split. C# required filter 375/375 green; both touched + vitest files green (5/5, 13/13); yarn typecheck and eslint clean. + +### 6. Join-card integration in the collection chooser `[medium]` +Status: CODE DONE on branch `task/b1-6-join-cards` (288e4057e, 9fe1c4de4, 8a3a01130, +f59dc6ea3, 5e572a84d; not yet merged into cloud-collections) — E2E verification QUEUED +(desktop locked; forbidden by this task's rules) +- [x] In the collection chooser dialog, remove the separate "team collections to join" + list; instead add extra cards to the MAIN collection list for collections the user + is invited to (server membership exists) but has no local copy of. + MyCloudCollectionsSection.tsx + its test deleted; CollectionChooser now fetches the + new `collections/getJoinCards` endpoint and passes results to CollectionCardList. +- [x] NO join card when the user already joined + has a local copy. DO show a join card + when a same-named local collection exists that is NOT a TC (existing join-conflict + code handles the actual join). CollectionChooserApi.ComputeJoinCards matches by + cloud collection id ONLY (via TeamCollectionLink.txt scan of MRU + discovered local + folders, GetLocalCloudCollectionIds) -- a same-named non-cloud-linked local folder + still gets a join card; CloudJoinFlow's own scenario matching resolves merge-or- + conflict once the user actually tries to join, unchanged. +- [x] Join cards do not count against the MRU-list card limit. CollectionCardList slices + `collections` at maxCardCount=10 first, then appends `joinCollections` (unsliced). +- [x] Omit any card info not available for an unjoined TC (thumbnail, languages, …). + CollectionCard's isJoinCard variant shows only title + TeamCollectionIcon + a "Get" + join cue (reusing CollectionChooser.PullDown's wording); no per-card fetch (the + unpublished-count effect is skipped) and the "..." Show-in-Explorer menu is hidden + (no local folder exists yet). +- [ ] Verify: `join-auto-open` + `e2e-1-create-share`; vitest for the card-list logic. The + vitest half is DONE (CollectionCardList.test.tsx: 4/4; CollectionChooser.test.tsx + rewritten: 3/3) -- only the E2E launches remain queued (desktop locked; this task's + hard rules forbid launching Bloom/e2e). `join-auto-open.spec.ts` exists under + src/BloomTests/e2e/tests; checked its content -- it drives collections/pullDown and + workspace/openCollection directly via HTTP (not through any chooser UI selector), so + it does not touch MyCloudCollectionsSection/join cards at all and should be + unaffected by this change. Still queued to actually run (desktop locked) as a + regression check, per this task's hard rules. + +Implementation notes (scouted 9 Jul, read-only — verified paths/lines): +- Chooser: `collection/CollectionChooserDialog.tsx` wraps `CollectionChooser.tsx` (MRU via + `collections/getMostRecentlyUsedCollections`; cloud list via `useMyCloudCollections` → + GET `collections/mine`; `joinTarget` state opens `JoinCloudCollectionDialog`). +- The separate list to REMOVE: `collection/MyCloudCollectionsSection.tsx` (+ its test); + its "Get" button → `pullDownCollection` → POST `collections/pullDown` → + `SharingApi.HandlePullDown` → `CloudJoinFlow.JoinCollection` (keep all of that; join + cards reuse `JoinCloudCollectionDialog` and the pull-down + auto-open flow unchanged). +- MRU card cap: `CollectionCardList.tsx` `maxCardCount = 10` slice — join cards must be + appended AFTER the slice. Card shape: `ICollectionInfo` in `CollectionCard.tsx` + (path/title/bookCount/checkedOutCount/unpublishedCount/isTeamCollection) — join-card + variant needs `collectionId`, a join flag, minimal info, no per-card unpublished fetch. +- "Has local copy of cloud collection X": for each MRU/local folder, + `TeamCollectionManager.GetTcLinkPathFromLcPath` + `TeamCollectionLink.FromFile` + (`IsCloud`, `CloudCollectionId`); a summary from `collections/mine` gets a join card iff + no local cloud link matches its id. `CloudJoinFlow.DetermineScenario` (CloudJoinFlow.cs + ~128) already does this matching — reference/reuse. Same-name non-TC local collections + STILL get a join card (CloudJoinFlow's PlainCollectionSameGuid/DifferentGuid handles the + merge-or-error; the card test keys off cloud links ONLY, not names). +- Server-side merge preferred: extend `CollectionChooserApi` (application-level, like + SharingApi) with an `internal static` pure matching helper (SharingApiTests' pattern — + no CollectionChooserApiTests file exists yet). +- Tests to rewrite: `CollectionChooser.test.tsx`; delete `MyCloudCollectionsSection.test.tsx` + with its component; `JoinCloudCollectionDialog.test.tsx` stays valid. + +### 7. Progressive join: open the collection before all books download `[large]` +Status: CODE DONE on branch `task/b1-7-progressive-join` (created from origin/cloud-collections; +not yet merged) — E2E verification QUEUED (orchestrator's job per this task's hard rules) +- [x] On join, fetch collection settings + book list (titles) first, open the collection + immediately; books not yet downloaded show a placeholder icon suggesting an + in-progress download. CloudJoinFlow.JoinCollection: removed the blocking + CopyAllBooksFromRepoToLocalFolder call; every repo book is now queued via the new + TeamCollection.QueueBookForBackgroundDownload right after settings download. + CollectionApi.HandleBooksRequest merges CloudTeamCollection's repo book list against local + folders (new pure ComputeNotYetDownloadedBookEntries + BookListEntry DTO) so repo-only + books appear with `notYetDownloaded: true`; BookButton.tsx renders these as a simple + dashed-border placeholder (cloud-download icon + title, no thumbnail request) instead of + the normal interactive button. +- [x] Background-download books; swap each placeholder for the real icon as its download + completes. New TeamCollection.DownloadMissingBookInBackground (the RemoteBookAutoApplyQueue + worker's new branch for books with no local folder at all) downloads, updates status, then + invalidates the cached book list and sends the existing "editableCollectionList"/"reload:" + websocket event so BooksOfCollection.tsx's collections/books re-fetch swaps the placeholder + for the real button automatically. +- [x] Selecting a not-yet-downloaded book bumps it to the front of the download queue; status + panel shows a "downloading" message until it arrives. RemoteBookAutoApplyQueue gained + EnqueueFront (priority, dedupe-preserving, never interrupts an in-flight download); + CollectionApi's selected-book POST handler gracefully detects a placeholder click + (TryPrioritizeNotYetDownloadedBook, via new CloudTeamCollection.TryGetBookNameForInstanceId + + PrioritizeDownload) and bumps it to the front instead of crashing on the missing + BookInfo. DEVIATION (flagged for John/orchestrator): the "downloading" indicator is shown + as a persistent placeholder icon on the book button itself (visible for every + not-yet-downloaded book) rather than routing through the real BookSelection/preview-pane + and TeamCollectionBookStatusPanel.tsx's StatusPanelState union, per the scout notes' exact + seam — faking a "selected" placeholder book (no real local folder/Book object exists yet) + risked destabilizing the preview iframe and the lock/checkout endpoints that also key off + BookSelection.CurrentSelection. The functionally important, tested part (priority bump) IS + implemented; only the panel-specific visual treatment was simplified. +- [x] Same SAFETY rule as item 4+5: a book appears in the collection only as placeholder + (no dangerous actions possible) or fully downloaded — never as a half-populated folder the + user can act on. Temp-folder staging + atomic swap. The placeholder branch in BookButton.tsx + is a completely separate render path with no context menu, no rename, no double-click-edit; + the only action is a click that posts to collections/selected-book (priority bump, graceful, + never a real selection). CopyBookFromRepoToLocal's existing stage-then-atomic-swap (unchanged) + still guarantees a book is placeholder-only or fully downloaded, never half-populated. +- [x] Handle interruption: Bloom closed mid-join resumes/completes downloads on next open + (SyncAtStartup should already fetch missing books — verify). Verified AND changed: cloud + SyncAtStartup's "brand new book!" branch now reroutes to the same background queue + (QueueBookForBackgroundDownload) instead of fetching synchronously inline, when + CanAutoApplyRemoteChanges is true (cloud only) — so a half-joined collection's next open + stays fast and downloads keep resuming in the background, instead of blocking the startup + sync dialog on every still-missing book. Folder TCs are completely unaffected (unchanged + synchronous fetch, pinned by a new regression test). +- [ ] Verify: `join-auto-open` + `e2e-9-new-book-lifecycle`; consider a new spec for the + placeholder/priority behavior if cheap. NOT run — this task's hard rules forbid launching + Bloom/e2e (orchestrator's job after merge, serialized with other E2E runs). + +Implementation notes (9 Jul, agent report): +- Files changed: RemoteBookAutoApplyQueue.cs (EnqueueFront + LinkedList-based priority queue, + dedupe-preserving); TeamCollection.cs (QueueBookForBackgroundDownload/PrioritizeBackgroundDownload, + DownloadMissingBookInBackground, ProcessAutoApplyRemoteChange's new missing-folder branch, + SyncAtStartup's cloud rerouting); CloudJoinFlow.cs (blocking call removed, enqueue loop added); + CloudTeamCollection.cs (TryGetBookInstanceIdForName/TryGetBookNameForInstanceId/PrioritizeDownload); + CollectionApi.cs (BookListEntry DTO, ComputeNotYetDownloadedBookEntries pure merge function + + GetNotYetDownloadedBookEntries wiring via TeamCollectionApi.TheOneInstance -- no new constructor + dependency needed, following SharingApi's existing precedent -- and TryPrioritizeNotYetDownloadedBook + for the graceful selected-book handling); BookButton.tsx (placeholder render branch + new + CollectionTab.BookNotYetDownloaded tooltip string); BooksOfCollection.tsx (IBookInfo.notYetDownloaded). +- New XLF string CollectionTab.BookNotYetDownloaded added to Bloom.xlf, translate="no", flagged as + provisional placement pending John's priority confirmation (note in the entry itself suggests + BloomMediumPriority.xlf as a likely alternative). The pre-existing sibling progress-message ids in + this same code path (JoiningCloudCollection, FetchedNewBook, and this task's new + FetchingNewBookInBackground) have NO XLF entries at all -- an established (if arguably + incomplete) precedent for TeamCollection sync-dialog progress text in this codebase; the new one + was left unlocalized to match, flagged here rather than silently deviating. +- Tests: C# required filter 393/393 green (15 new: 4 EnqueueFront tests + 1 real-async EnqueueFront + sanity test in RemoteBookAutoApplyQueueTests.cs; 2 missing-folder ProcessAutoApplyRemoteChange + tests + 3 SyncAtStartup rerouting tests in TeamCollectionAutoApplyTests.cs, using + TestFolderTeamCollection's existing AutoApplyRemoteChangesForTests toggle so the shared + base-class logic is exercised without needing a full CloudTeamCollection; 6 new + CollectionApiTests.cs tests for the pure ComputeNotYetDownloadedBookEntries merge function). + CloudSyncAtStartupTests.SyncAtStartup_NewBookOnlyInRepo_IsFetchedToLocal updated per this item's + own instruction (TestOnly_MakeAutoApplyQueueSynchronous added, assertion kept, reasoning + documented in the test). BookButton.test.tsx (new, 5/5 green) covers the placeholder + render/label/click-priority-bump behavior and the unaffected normal-button paths. yarn typecheck + and eslint show no NEW errors/warnings introduced (compared before/after via git stash) beyond + this codebase's large pre-existing unrelated baseline of typecheck errors. +- Deliberate omissions/risks for the orchestrator to re-verify live: (1) the status-panel + simplification noted above; (2) no dedicated CloudJoinFlow test file was added (no existing + FakeRestExecutor-based harness for it, and the diff there is a small, low-risk 3-line change + covered indirectly by the queue's own tests) -- the orchestrator's join-auto-open E2E run is the + real coverage for this path; (3) CollectionApi.HandleBooksRequest now calls + CloudTeamCollection.GetBookList()/EnsureCacheHydrated() on every collections/books request for a + cloud TC, which may trigger a network hydrate call the first time (idempotent afterwards) -- + minor latency risk, not previously present in this endpoint; (4) the placeholder's "id" is the + book's stable InstanceId (matches BookInfo.Id once downloaded) so the React key doesn't change + across the download -- worth an E2E spot-check that the placeholder truly swaps in place rather + than flickering/remounting; (5) no dedicated E2E spec for the placeholder/priority behavior was + added (existing join-auto-open + e2e-9-new-book-lifecycle only) -- consider one if the live + verification surfaces gaps. + +### 8. Recovery safety net (John decision, 9 Jul — replaces the old "recovery preconditions" +question) `[quick-medium]` +Status: NOT STARTED +John's spec: when a sync operation brings a remote version of a book to local but the local +copy has somehow changed (rare — e.g. force-steal while edited, or any unexplained local +drift), GO AHEAD and make local consistent with the TC, but FIRST save the previous local +version as a .bloomSource so nothing is lost. SaveLocalCopyForRecovery (CloudTeamCollection, +~line 668: zips to /Lost and Found/.bloomSource + logs an incident) +already does exactly this and the STARTUP sync path already uses it (pinned by +CloudSyncAtStartupTests.SyncAtStartup_LocalEditConflictsWithRemoteChange_...). The gap is +the two RUNTIME overwrite paths, made urgent by item 4+5's auto-apply (whose eligibility +gates use IsCheckedOutHereBy(GetLocalStatus) — dead-false for cloud, since cloud checkout +never writes the local status file; see tasks/09-e2e.md E2E-4 finding): +- [ ] In ProcessAutoApplyRemoteChange (TeamCollection.cs): before CopyBookFromRepoToLocal, + if the local folder's current checksum differs from the local status checksum (local + changed since last sync), preserve via a new virtual seam (base no-op; cloud override + → SaveLocalCopyForRecovery) — then apply as normal. +- [ ] Same guard in TeamCollectionApi.HandleReceiveUpdates (the Sync button loop). +- [ ] Unit tests through TestFolderTeamCollection (seam already has the synchronous-queue + test hooks); assert preserve-called-iff-locally-modified. +- [ ] NOT needed (per John): persisting cloud checkout state to the local status file — + that was only required to reproduce folder-TC *blocking* semantics; John chose + apply-and-preserve instead. +- [ ] E2E: this unblocks E2E-4's blocked .bloomSource sub-requirement — extend that spec + when convenient. + +### 9. Account-switch behavior (John decision, 9 Jul — unblocks E2E-10) `[medium-large]` +Status: CODE DONE on branch `task/b1-9-account-switch` (created from origin/cloud-collections; +not yet merged) — E2E verification QUEUED (orchestrator's job; hard rules forbade +launching Bloom/e2e for this task) +John's spec: local machine access is unrestricted; only shared-data operations are gated by +the CURRENT logon's server permissions. Collection was joined under account A, Bloom now +signed in as B: +- [x] B NOT a member of the TC → REFUSE to open the collection. Message must name the current + logon, say it is not a member, give the admin email(s) to ask for membership, and name + the last team member who edited this collection on this machine. + TeamCollectionManager.CheckConnection gained an `allowHardRefusal` parameter (default + false, preserving every existing mid-session caller); only the constructor's initial + open-time call passes true. CloudTeamCollection.CheckConnection's non-member branch now + sets a new TeamCollectionMessage.IsAccessRefusal flag and composes the full detail text + (admins + last-known-user, see ComposeNotAMemberRefusalDetail) instead of the old + one-line message; a hard-refusal message throws the new + TeamCollectionAccessRefusedException, which propagates up through Autofac/ProjectContext + to Program.HandleErrorOpeningProjectWindow (new early special-case: plain message box, + no "Report this crash" flow, then falls through to the existing chooser-reopen path + exactly like any other failed project open). +- [x] B IS a member → open CONNECTED. Books locally checked out by A show as checked out by A + but may be edited as if checked out by B — ONLY if the server state would have let A edit + here (i.e. A's lock is for THIS machine; not if A holds it elsewhere). On first edit of + such a book, atomically switch the checkout everywhere to B. If B checks it in (even + without editing), history records the checkin by B. + New virtual seams on TeamCollection (IsEditableHere, CanTakeOverLockOnThisMachine, + TryTakeOverLock) default to today's strict behavior for folder TCs; CloudTeamCollection + overrides them for the same-machine-different-account case. New RPC + tc.checkout_book_takeover (migration 20260709000007) atomically reassigns a lock from a + different account to the caller ONLY when the existing lock's machine matches — purely + additive, does NOT modify checkin_start_tx/checkin_finish_tx. The takeover call happens + in PutBookInRepo just before check-in (there is no per-keystroke "edit happened" hook + anywhere in this codebase — confirmed by research — so "on first edit" is implemented as + "on first check-in of that edit", the earliest point a takeover has any observable + effect) and in AttemptLock (for an explicit "check out" click, though the UI is unlikely + to show that affordance here since IsEditableHere already reports the book as usable). + Checkin attribution already falls out for free (checkin_finish_tx uses the caller's JWT). + +### 10. AWSSDK.S3 version bump (John decision, 9 Jul: take it on this branch) `[quick-medium]` +Status: CODE DONE + SUITES GREEN on branch `task/b1-10-awssdk-bump` (bump 9b81c6040; not yet +merged) — remaining: orchestrator's e2e-1 + e2e-2 through MinIO, then John's [HUMAN] web +up/download check +- [x] Bump AWSSDK.S3 (and its AWSSDK.Core pair) to current stable in the csproj(s); check + whether BloomHarvester/other projects pin the same package family and must move in + lockstep. DONE: BloomExe.csproj Core 3.5.1.32 -> 4.0.100.3, S3 3.5.3.10 -> 4.0.100.3 + (major v4 jump); server/dev/parity-check floats 3.* -> 4.*. No other project in this + repo pins the family (BloomHarvester is a separate repo; there is no central + Directory.Packages.props — per-csproj pins are the convention). AWSSDK.SecurityToken + is not referenced anywhere (per-book session creds arrive as plain strings from the + edge functions), so only S3+Core move. project.assets.json confirms no SIL package + transitively pins AWSSDK.Core. v4 adjustments (details in commit 9b81c6040): checksum + config RequestChecksumCalculation/ResponseChecksumValidation=WHEN_REQUIRED on the two + MinIO-facing client builders (CloudBookTransfer.BuildDefaultClient, + CloudTeamCollection.BuildS3Client) because v4's WHEN_SUPPORTED default sends CRC32/ + CRC64 trailing checksums S3-compatible endpoints may reject; BloomS3Client (real AWS + only) deliberately keeps the v4 defaults. Null-collection/bool? compile+runtime fixes + in S3Extensions.ListAllObjects and BloomS3ClientTests.DeleteFromUnitTestBucketAsync; + removed two orphaned usings that broke the v4 compile (ThirdParty.Json.LitJson was + embedded in AWSSDK.Core v3 and is gone in v4). +- [x] Suites: cloud filter + ONE full BloomTests run (AWSSDK is used by the BloomLibrary + web-upload code — WebLibraryIntegration — so cloud-only filters are NOT sufficient). + DONE: baseline full run on UNMODIFIED cloud-collections FIRST (so pre-existing + failures can't be blamed on the bump): 3036 passed / 0 failed / 13 skipped / 3049 + total. Post-bump: cloud filter 387/387; full run 3036 passed / 0 failed / 13 skipped + / 3049 total — identical to baseline, zero regressions. S3-specific fixtures called + out explicitly: CloudBookTransferTests 11/11, BloomS3ClientTests + + CloudEnvironmentTests' S3ForcePathStyle test, 44/44 in the combined ~S3/~ + CloudBookTransfer/~BloomS3Client filter, including the LIVE + DownloadBook_DoesNotExist_Throws which hit the real BloomLibraryBooks-UnitTests + bucket with the v4 client (validating the null-S3Objects fix against real AWS). +- [ ] E2E: at least e2e-1 + e2e-2 (S3 up/down through MinIO exercises the new SDK's + path-style + AssumeRole handling — the risky surface for a bump). +- [ ] [HUMAN, John] Manual check that web book upload (publish to bloomlibrary.org) and + download (into Bloom) still work — recorded in GOING-LIVE.md 4.3. + +## Also queued from dogfooding (not in John's list, orchestrator-flagged) +- Administrators field shows the REGISTRATION email (john_thomson@sil.org) instead of the + signed-in account email for cloud TCs (`ConnectToCloudCollection` sets + `Settings.Administrators = new[] { CurrentUser }`) — cosmetic identity-model + inconsistency, fix opportunistically with item 4+5 or 6. +- Tier-timing fix (GOING-LIVE.md Phase 5, `task/b1-tier-timing`): `CheckDisablingTeamCollections` + gated solely on `CurrentCollection == null`, which for a cloud TC doesn't mean + "Settings.Subscription is trustworthy" (CurrentCollection is set before the connect-and-sync + sequence completes, and that sequence's success depends on cloud sign-in timing plus an S3 + download that silently swallows exceptions) — so a healthy cloud TC could be permanently + disabled for the session on a stale/blank subscription snapshot. Fixed by deferring the cloud + check (WorkspaceModel) until after the collection-file sync, and re-reading the SubscriptionCode + fresh from disk at that point instead of trusting the in-memory snapshot. Folder-TC behavior/ + timing unchanged. See branch for full diagnosis + tests. + +## OUTSTANDING BUGS (10 Jul 2026 PM — the current work list) +0. **RESOLVED 11 Jul 2026 — implemented as option (a), per John's ruling (recorded verbatim in + the 11 Jul progress entry): editing/takeover of a checkout is only legitimate in the local + copy of the collection where the book is checked out.** Implementation: migration + 20260711000003 adds `tc.books.locked_seat` (client-computed hash of the local collection + folder path — the "seat"), recorded by checkout_book/checkout_book_takeover; takeover + requires machine AND seat match, and a NULL stored seat never matches (fail-safe); a + trigger clears the seat with every unlock path. Client: CloudTeamCollection.SeatId; + IsEditableHere/CanTakeOverLockOnThisMachine seat-gated (own pre-seat locks grandfathered; + other accounts strict). CONTRACTS.md bumped to v1.5. pgTAP 65/65, C# filter 428/428, + e2e-4 PASSES. FOLLOW-UP flagged for John (not blocking): checkin_start_tx still accepts a + same-user check-in regardless of seat/machine (pre-existing behavior; the client-side + editable gate is the enforcement point today) — decide whether the server should also + refuse cross-seat check-ins by the SAME user. + **What that question means (plain English, clarified 14 Jul 2026):** A lock is recorded on + the server as (user, machine, SEAT), where the seat identifies WHICH local copy of the + collection took the checkout (hash of that copy's folder path). The bug #0 fix makes + *takeover* of a lock require a matching machine+seat, and enforces it in BOTH places — the + client (IsEditableHere/CanTakeOverLockOnThisMachine) AND the server (checkout_book_takeover). + But an ordinary *check-in* (checkin_start_tx) only verifies the lock is held by the same + USER; it does not check that the check-in comes from the seat that holds the lock. Today the + only thing stopping a same-user cross-seat check-in is the client: from a second copy the + book shows as not-editable-here, so the UI won't let you edit/check it in. Concretely: John + has two copies of the Tetun collection (C:\temp\Tetun Books and the OneDrive copy) under one + account. If he checks a book out in copy A and then, from copy B, something got a check-in + through (a client bug, a bypass, or a future/alternate client), the server would accept it + and could clobber the version copy A is working on. The question is whether to close that gap + server-side — i.e. also make checkin_start_tx require the caller's machine+seat to match the + lock's — as defense-in-depth so the seat rule doesn't rely solely on client behavior. + Trade-off: it needs the client to send its seat/machine on check-in and a server migration + + pgTAP; risk is low and it mirrors the takeover gate we already trust. (Not urgent — no live + data-loss seen; the client gate holds in normal use.) + **DECISION (14 Jul 2026, John): WON'T DO — do not enforce seat on check-in server-side.** + Two reasons: (1) a client buggy or hacked enough to check in from the wrong source folder + could just as easily send the wrong seat checksum, so the server gate wouldn't actually + protect against that threat; and (2) a server-side seat requirement could get in the way of + recovering a collection whose local folder has legitimately been moved or renamed (which + changes the seat hash). The client-side editable gate remains the enforcement point. **This + was the last open piece of bug #0; bug #0 is now fully closed.** + Original problem statement follows for the record. + **[Original — NEEDS JOHN] Item 9's same-machine takeover can steal ANY same-machine + lock, even across separate collection folders (found by e2e-4 after its download bugs were + fixed).** Scenario: Bob (admin) force-unlocks Alice's checkout and takes the lock himself; + Alice's later attemptLockOfCurrentBook RETURNS FALSE — but the server lock silently ends up + reassigned to ALICE, because item 9's takeover path (AttemptLock → TryTakeOverLock → + checkout_book_takeover) fires whenever the existing lock's MACHINE matches, and in E2E (and + any genuinely shared computer) every user is on the same machine. The machine-match gate + cannot distinguish John's intended scenario ("collection was joined under account A, B opens + the SAME local folder") from two users with SEPARATE local folders on one computer (two + 'seats', which is what e2e-4 simulates and what a shared lab machine would really be). + Design options sketched for John: + (a) Server-side 'seat': checkout_book/checkout_book_takeover store+compare a per-local-folder + id (e.g. hash of folder path) alongside machine — cleanest semantics, needs a migration + + pgTAP + client change; + (b) Client-side gate on the LOCAL folder's own state: only allow takeover if THIS folder + shows evidence the lock holder was using THIS folder. TeamCollectionLastKnownUser.txt + does NOT work for this as-is (CheckConnection overwrites it with the NEW user at open + time, before any takeover); writing a minimal local status record at cloud checkout would + work but John earlier decided cloud checkouts deliberately DON'T write local status; + (c) Accept the behavior (any same-machine user can take over any same-machine lock) and fix + e2e-4's expectation — probably wrong: it makes force-unlock semantics unreliable on + shared machines, and the takeover is SILENT (attemptLock even reported false while the + server lock changed hands — at minimum that inconsistency is a bug in any option). + Suggested: (a). Until decided, e2e-4 fails at its 'server lock is exactly Bob's' assertion + (spec line ~166). The e2e-4 DOWNLOAD failures that motivated the original bug #1 are FIXED + (see below). +1. **e2e-4 background download fails + retry skipped (FIXED 10 Jul PM, verified by rerun — + the book now downloads in ~5s; kept for the record).** Evidence + (bob-joined SIL log, 14:51, preserved by the new durable logging): the one real download + attempt failed with `Could not find file 'C:\Users\\AppData\Local\Temp\ + BloomCloudTCDownload\A5 Portrait.htm'` — the cloud download STAGING FOLDER IS A FIXED + SHARED TEMP PATH (`Temp\BloomCloudTCDownload`), so concurrent instances (two Blooms run in + every two-instance spec, plus any leftover files from earlier runs/specs) can clobber or + half-empty each other's staging area mid-copy. FIX: make the staging dir unique per + download (e.g. `BloomCloudTCDownload--`), clean up after. Secondly: after + that failure, Alice's checkout made QueueMissingRepoBooksForBackgroundDownload skip every + retry (books locked by ANYONE are skipped). The skip is TOO BROAD — a book locked by + someone ELSE is still safely downloadable (that is exactly what Receive does); the skip + only needs to cover books locked BY THE CURRENT USER (the local-rename-mid-checkin edge, + where the old repo name intentionally has no local folder). FIX: change the guard in + TeamCollection.QueueMissingRepoBooksForBackgroundDownload from "locked by anyone" to + "locked by me", and add a unit test mirroring QueueMissingRepoBooks_BookLockedInRepo_SkipsIt + but with a foreign lock expecting download. Then rerun e2e-4. +2. **e2e-5 + e2e-8 singles: BOTH PASSED (10 Jul PM, post-merge tree)** — confirms their 10 Jul + AM matrix failures were transient infra as suspected. Current single-spec scoreboard on the + merged + defect-fixed tree: e2e-3 ✅, e2e-5 ✅, e2e-8 ✅, e2e-10 ✅; e2e-4 ❌ blocked solely + on bug #0 (its download failures are fixed; it now fails at the takeover-semantics + assertion, spec line ~166). +3. **Full E2E matrix** not yet run on the post-defect-fix, master-merged state (was 8/14 + before the fixes; 10/14 under heavy load 10 Jul PM). Run it after John decides bug #0 (or + accept one known e2e-4 failure). Standalone scoreboard as of 10 Jul late evening (after + the queue-arrival spec fixes): e2e-3 ✅, e2e-5 ✅, e2e-6 ✅, e2e-8 ✅, e2e-9 ✅ (3/3), + e2e-10 ✅; e2e-4 ❌ blocked solely on bug #0. +4. **UPGRADED from cosmetic (13 Jul human test): cloud TC identity must be the SIGNED-IN + account, not the registration email.** TeamCollectionManager.CurrentUser (= + Registration.Default.Email) drives Administrators, checkout attribution, and every + CurrentUserIdentity comparison — so John's Alice-signed-in instance displayed his books + as "checked out to john_thomson@sil.org" and every lockedBy comparison crosses + identities (it limps through only because the same-seat logic tolerates the mismatch). + For a cloud TC, CurrentUserIdentity should resolve to the signed-in cloud email. + Dogfood workaround (= what the e2e harness does): impersonate.txt line 1 in the + collection folder overrides the TC user (alice's copy got one 13 Jul). +5. **Preflight (10 Jul PM, John's request):** light-review pass over the day's diff found 2 + valid adjacent holes, BOTH FIXED + tested (72246c2975): per-account (not per-instance) + claim_memberships guard; machine-aware lock skip in the requeue pass. The GitHub half of + preflight (draft PR, Devin, Greptile/CodeRabbit, CI gauntlet) is BLOCKED: `gh` is not + authenticated in the agent session — John must run `gh auth login`, then re-run + `/preflight` to create the draft PR and run the bot gauntlet. +6. **Full C# suite: RESOLVED AS FLAKE** — the first run's single failure (1/3131) did not + reproduce on the identification rerun (3120 passed / 0 failed / 3133 total, merged tree). +7. **[NEW, 13 Jul human test] Create-over-stale-cloud-link reports FAKE SUCCESS.** John + shared Tetun Books, whose folder still carried Thursday's cloud link (server rows wiped + by e2e resets): ConnectToCloudCollection correctly threw + TeamCollectionLinkConflictException, but HandleCreateCloudTeamCollection's catch calls + request.PostSucceeded() (comment: avoid a double toast) — so CreateTeamCollection.tsx + advanced to "Your Team Collection is ready. Invite your team from the Sharing panel" with + NO server row, NO link rewrite, and no Sharing panel (nothing was created). The + ErrorReport.NotifyUserOfProblem dialog was not seen (may not display in this context). + FIX NEEDED: the reply must let the dialog distinguish failure (show the conflict message + + guidance) — and decide the recovery UX for "cloud link points at a collection that no + longer exists server-side" (same family as the deferred recovery-preconditions decision; + the create flow could offer re-create when the dead link's id == this collection's id). + Workaround applied for the human test: moved TeamCollectionLink.txt + + .bloom-cloud-repo-cache.json + lastCollectionFileSyncData.txt out of Tetun Books (backup: + Documents/Bloom/Tetun-Books-stale-tc-backup-2026-07-13). +8. **[NEW, 13 Jul — UPSTREAM, not this branch] Debug builds die silently at the collection + chooser**: UrlLookup.LookupFullUrl's Debug.Assert ("provide an appropriate acceptFinalUrl + param when looking up a url during startup") fires via CollectionChooserApi. + HandleGetUnpublishedCount → GetLibraryStatusForBooks → BloomLibraryDetailPageUrlFromBookId + (all master code; timing/network dependent) and a failed assert TERMINATES the process + ("Process terminated. Assertion Failed", captured stdout 13 Jul). Release unaffected. + Candidate for a YouTrack issue against master, not a Cloud TC fix. +9. **[NEW, 13 Jul human test] Client cache survives server-side collection recreation and + poisons everything after.** C:\temp\Tetun Books kept Thursday's + .bloom-cloud-repo-cache.json (lastSeenEventId 13, phantom books at seq 4) across the + server wipe + 13 Jul re-create of the SAME collection id. Consequences observed live: + the initial share push uploaded NOTHING (cache said the server already had every book at + the current version — zero checkin transactions ever), and Alice's checkout sent a + phantom Thursday book id → checkout_book raised book_not_found → "Bloom was not able to + check out". FIX NEEDED: a FULL get_collection_state snapshot must REPLACE the cache + (prune cached books absent from the snapshot), and lastSeenEventId > server max_event_id + must be detected as server regression → drop cache, full resync. Workaround: delete the + stale cache files before reopening (pending — an instance is still holding them). +10. **[NEW, 13 Jul] `pnpm go` (watchBloomExe.mjs) always passes `--automation`**, so HUMAN + dogfooding runs under automation semantics: progress-dialog problems auto-close + (BrowserProgressDialog, 11:19:07 log — this hid the failed initial push), and + NonFatalProblem reports go to stdout only. The dev launcher needs a way to run WITHOUT + automation mode (flag through go.mjs → watchBloomExe), or humans keep not-seeing errors. +12. **[NEW, 13 Jul] Cloud identity silently leaks between instances on one Windows account.** + John signed in "as fred" in a second instance, yet that instance's server calls ran as + ALICE (proof: collection-file + book downloads succeeded — fred isn't a member and would + get 403; tc.members/events show zero fred activity). Cause: the DPAPI CloudTokenStore is + per-Windows-user (shared by every instance), and the shared MRU auto-opened Alice's + C:\temp copy (which also collided with her running instance — IOException on the + .bloomCollection). Access control HELD server-side; the failure is client identity UX. + Design question queued: what is a UI sign-in's scope (instance? machine?), and should + BLOOM_CLOUDTC_USER-style per-instance pinning be a first-class dev affordance? + Dogfood rule until then: one PowerShell (env-pinned user) + one collection folder per + identity. +13. **[NEW, 13 Jul, UX] "Send All" is hard to find**: it lives at the bottom of the TC + dialog's STATUS tab, but the dialog deliberately opens on the History tab when there are + no important status messages — John saw only "Sync + Close". The create-success message + also points at the Sharing panel, not here. Consider surfacing Send All on the status + panel or making the Status tab default when there are uncommitted local books. + **DECISION (14 Jul 2026, John): leave "Send All" where it is — no location/UX change + needed. The Send-All-discoverability half of this item is CLOSED; the admin-role half + below is still open.** ALSO + 13 Jul: create-time `Settings.Administrators = CurrentUser` stamps the REGISTRATION + email (bug #4 family) — locked Alice out of her own collection's settings until the + .bloomCollection was hand-patched; and TeamCollectionApi.isUserAdmin + (OkToEditCollectionSettings) reports false for the server-side admin — the admin flag + for cloud should come from the server membership role. +11. **FIXED 13 Jul — join-card dedup is now identity-aware (John's ruling).** Bob (invited, + claimed member) got NO join card for Tetun because the dedup suppressed by cloud id + against EVERY local copy in the machine-wide chooser list — including ALICE's C:\temp + copy. Fix: ComputeJoinCards now takes (id, lastKnownUser) pairs + the signed-in email + and suppresses only when the copy's TeamCollectionLastKnownUser.txt matches the + signed-in account (case-insensitive); unknown/missing marker or another account's copy + → the card SHOWS (CloudJoinFlow's scenario matching handles merge/conflict at join + time, unchanged). New SharingApi.SignedInEmailForJoinCards; + GetLocalCloudCollectionIds → GetLocalCloudCopies. 8 chooser tests incl. the exact live + scenario (other account's copy → card shows); filter 435/435. Also cleaned this + morning's stale-state workarounds: poisoned Thursday caches purged from C:\temp\Tetun + Books (bug #9's trigger), both Chodri copies un-teamed (dead collection); backups in + C:\temp\stale-cloud-tc-backup-2026-07-13. +14. **FIXED 13 Jul — only ONE pending invitation per collection was possible.** Found by + the display-name test fixture, present since the original schema: + `members_claimed_user_uq` was `UNIQUE NULLS NOT DISTINCT (collection_id, user_id)`, so + two unclaimed rows (user_id NULL) collided — inviting a second person before the first + claimed made members_add die with 23505 (its ON CONFLICT clause targets the email + constraint, not this one). Contradicted the schema's own documented intent ("claimed + user unique"); dogfooding never tripped it because invites happened to alternate with + claims. Fix: 20260713000002 recreates the constraint with default NULLS DISTINCT. +18. **FIXED 13 Jul PM — a teammate's rename arrived as a DUPLICATE book (two local folders, + one instance id) on the receiving side.** John's live report: after Bob's retitle + check-in (server row renamed to "Tetun moon and cap", seq 3, unlocked — verified), + Alice ended up with old-name AND new-name folders, same id, same content, phantom + checkout displays, "both selected at once". Root cause: an identity-first REGRESSION — + base `NewBookRenamedFrom`'s heuristic ("a local folder with repo status can't be the + rename source") assumes name-keyed status; under identity-first the old-name folder + resolving (by id) to the renamed row is precisely what MARKS it as the rename source, + so rename detection always failed, the renamed book was treated as NEW and + auto-downloaded beside the old folder, and each later sync happily refreshed the + old-name folder's content via its identity binding (explaining "same content incl. the + new title in both"). FIX: `NewBookRenamedFrom` is now `protected internal virtual` + (base body byte-identical — folder TCs keep their heuristic); + CloudTeamCollection overrides it with an exact instance-id comparison (repo name → row + instance id → local folder carrying that id under a different name). Guards added in + the two auto-download sweeps (`QueueMissingRepoBooksForBackgroundDownload` + a + re-check in `DownloadMissingBookInBackground`, both inside CanAutoApplyRemoteChanges- + gated paths, i.e. cloud-only at runtime): a repo book that is a rename of an existing + local book is NOT "missing" — the rename is applied by the next sync's + rename-from-remote pass, which is already identity-driven (GetRepoBooksByIdMap) and + works for cloud unchanged. CLEANUP: Alice's old-name duplicate backed up to + C:\temp\stale-cloud-tc-backup-2026-07-13\ and removed; her copy now has one folder per + id. 3 new CloudIdentityFirstLookupTests. +17. **FIXED 13 Jul PM — new/local-only books in a cloud TC showed "checked out to John1" + (the REGISTRATION identity).** John's live report from Bob's copy; Alice's copy only + looked right because C:\temp\Tetun Books\impersonate.txt makes her registration EQUAL + the account. The base status JSON stamps `who` (plus whoFirstName/whoSurname and + currentUserName) from TeamCollectionManager.CurrentUser for books whose checkout is + purely local (new local book / not in repo). FIX in + TeamCollectionApi.AddCloudBookStatusFields (same method as the earlier currentUser + override, bug #4 family): when `who` equals the base registration currentUser, rewrite + it to the signed-in account email and clear the registration first/surname; + currentUserName now also carries the account identity (avatar dialog). A real repo + lock's `who` (a member email) is untouched. 2 new TeamCollectionApiCloudTests. + SECOND ROUND (John: "still showing John1"): the who-rewrite was aimed at the wrong + field — `who` was ALREADY the account (CurrentUserForStatus resolves to + CloudTeamCollection.CurrentUserIdentity); the REAL leak is whoFirstName/whoSurname, + which base WhoHasBookLockedFirstName stamps from the REGISTRATION name for + new/local-only books, and the UI prefers the name fields over `who`. Fix: + AddCloudBookStatusFields clears whoFirstName/whoSurname whenever isNewLocalBook — the + display falls back to `who` (account email). The earlier who-rewrite kept (covers a + signed-out registration leak). +1 test — NOT yet run (John's two instances hold + output\Debug); run the filter when they close. + RESIDUAL of the same family (already in bug #13's tail): create-time + Settings.Administrators and isUserAdmin still use registration identity. +16. **FIXED 13 Jul PM — `pnpm go` + `--automation` silently killed Bob's startup on any + sync warning (bug #10's sharp edge, now with a live victim).** John's report: Bob + exited without any message when switching to the OneDrive Tetun copy, twice. SIL log + (Log-tmpcgri51/tmp2dcrx1.txt): the startup TC sync reported problems (run 1: the + perfectly LEGITIMATE "Renaming the local book 'The Moon and the Cap' because there is + a new one with the same name" — the conflict machinery working as designed on a + folder whose meta.json id (db07d1f3…, some earlier derivative) differed from the repo + book's (ca252af0…)) → BrowserProgressDialog's automation gate auto-closed the dialog + ("problems were reported … auto-closing because Bloom is in automation mode") → + Application.Run() returned → silent exit. Every relaunch re-tripped it. FIX: new + `--attended` flag (Program.StartupAttended / UnattendedAutomation = automation && + !attended): --automation keeps the ready-handshake, port summary, and single-instance + bypass, while the four no-human UI policies (BrowserProgressDialog auto-close ×2, + HtmlErrorReporter notify suppression, NonFatalProblem stdout redirect, + ProblemReportApi report suppression) now gate on UnattendedAutomation. + watchBloomExe.mjs (pnpm go) passes --attended by default (opt out with + BLOOM_GO_UNATTENDED=1 for agent-driven CDP runs that must never block on a dialog); + the E2E harness's own launch.ts passes plain --automation and keeps full unattended + behavior. +4 ProgramTests; filter 454/454. RESIDUAL for next E2E pass: agent `pnpm go` + sessions now show real dialogs unless BLOOM_GO_UNATTENDED=1 is set — update agent + runbooks. CLEANUP done with it: Bob's OneDrive copy had TWO folders with instance id + ca252af0 ("The Moon and the Cap" re-downloaded by the sync + "Tetun moon and cap", + Bob's uncommitted retitle from the shared-folder chaos) — the retitle folder is backed + up at C:\temp\stale-cloud-tc-backup-2026-07-13\ and removed; "The Moon and the Cap1" + (db07d1f3, the sync's own conflict rename) left in place as a plain local book. Run + 2's problem message was never logged (blank) — with dialogs visible again it will + identify itself on screen if it recurs. +15. **FIXED 13 Jul PM — identity-first book resolution (John's ruling: "the status of a + particular record by instanceID in the database is the source of truth for that book's + state" — ALWAYS by instance id for a local folder, not merely as a name-miss fallback, + so an offline-created local book X never wears the status of a teammate's checked-in + book that happens to share the name X).** CloudTeamCollection.ResolveBookId: a local + folder resolves ONLY by its meta.json bookInstanceId → _bookIdByInstanceId (unreadable + id ⇒ null, fail-safe "local-only", never a name guess); the name index applies only + when no local folder exists (repo-name queries, e.g. GetBookList names). Applied to + status/lock/seat/version/delete/fetch/casing/history-filter lookups; PutBookInRepo now + resolves by instance id ONLY (a name match could have checked in over a different + same-named book — the worst form of this bug); RenameBookInRepo is now a documented + no-op (_pendingRenameBookId deleted — identity resolution makes the bridge redundant); + GetRepoBookFile deliberately stays name-based (base contract: repo names, per + NewBookRenamedFrom/GetRepoBooksByIdMap). Placeholder merge + (CollectionApi.ComputeNotYetDownloadedBookEntries) also suppresses by local instance + id, killing the phantom "shadow" card. Per John: FolderTeamCollection untouched — all + changes are CloudTeamCollection overrides + the cloud-only placeholder path; + TeamCollection.cs/FolderTeamCollection.cs byte-identical. Tests: new + CloudIdentityFirstLookupTests (renamed-checked-out book keeps its row+avatar; John's + offline same-name conflict scenario; repo-name lookup unaffected; no-meta folder + fail-safe) + placeholder-suppression test; two OkToCheckIn fixtures updated to give + their local folder its real meta.json identity; PolledChanges fake server made + tolerant of the auto-apply background download (pre-existing cross-thread flake its + strict assert caused). Filter (Cloud|TeamCollection|SharingApi|CollectionApi) 442/442. + ORIGINAL DIAGNOSIS (13 Jul PM, John's live report; server verified CONSISTENT — one row, + "The Moon and the Cap", still locked by Bob, seq 1, no rename/check-in events). + Cloud renames are carried only at check-in (RenameBookInRepo + checkin-start + proposedName, by design), but every client-side repo lookup is by FOLDER NAME: + `TryGetBookStatusJsonFromRepo` → `TryGetCachedBook(bookFolderName)` → `_bookIdByName`. + After the local folder rename, the new name misses the index (only the in-memory + `_pendingRenameBookId` bridges it, and only during PutBook), so the checked-out book + reads as "not in repo" → treated as a NEW LOCAL BOOK: editable, no checkout avatar + (Bob's symptom). Meanwhile `GetBookList()` still returns the server (old) name with no + matching local folder → the progressive-join merge shows a phantom cloud-download + placeholder of the same book (the "shadow" card). FIX SHAPE (queued, not yet applied): + resolve by IDENTITY when the name lookup misses — the local folder's meta.json + bookInstanceId → `_bookIdByInstanceId` (and/or the local status file's `oldName`, which + HandleBookRename already records) — in TryGetBookStatusJsonFromRepo / + IsBookPresentInRepo / TryLockInRepo-family lookups; and suppress a placeholder when a + LOCAL book with the same instance id exists regardless of name. SESSION CONFOUND, + important: BOTH of John's instances were running against ONE local copy — + `C:\Users\JohnThomson\OneDrive\Documents\Bloom\Tetun Books` (Bob's pull-down; NOTE + Documents is OneDrive-redirected). Seat proof: both live locks carry seat + 6d5c0907085da326 = that folder; Alice's C:\temp\Tetun Books is bed69996f9b9d0e1 and + holds NO current lock. So "Alice got the rename instantly" via the SHARED FILESYSTEM, + not the server, and her instance likely auto-opened Bob's copy from the MRU after a + restart (the MRU trap again — and my C# push at ~14:5x likely recycled the dotnet + watch, killing both test instances). Two Blooms sharing one collection folder is + unsupported; her doubled cards are partly that artifact, but Bob's symptoms reproduce + single-instance and are the real bug. + +## Progress log +(orchestrator appends: date · what was just completed · EXACT next action) +- 16 Jul 2026 (E9 DONE — collection files download only what changed, pinned + consistent; + John chose Option 2) · Collection-file group downloads used to LIST the S3 prefix and re-fetch + every object every time (no change detection; a mid-write listing could read an uncommitted + mix). Discovery: the committed per-file manifest ALREADY existed durably in + tc.collection_group_files (path/sha256/size_bytes/s3_version_id, replaced atomically by + collection-files-finish) — just not exposed for reads, so NO schema migration was needed (and + John confirmed we're free to change the local test DB anyway). Fix (commit 66de46e910): new + get_collection_file_manifest(collection_id, group_key) RPC mirroring get_book_manifest + (CONTRACTS v1.7); client GetCollectionFileManifest; DownloadCollectionFileGroup rewritten to + fetch the manifest and hand the files to CloudBookTransfer.DownloadFiles — the SAME path book + content uses, which already skips files whose local sha256+size match and downloads the rest + pinned to their committed s3VersionId (a consistent snapshot). Delete-extras keys off manifest + paths; dead S3-listing code + its Amazon.S3.Model/WebLibraryIntegration usings removed. A + never-written group returns version 0 / empty files (handled gracefully). Verified live on the + dev stack: pgTAP 95/95 (6 new get_collection_file_manifest tests); C# required filter 452/452; + e2e-2 collaboration loop PASS (join downloads collection files, Send/Receive byte-identical). + · EXACT next action: remaining open items for John — (a) pull the 8 mid-session master commits, + (b) re-run PR #8052 regen vs current master + Greptile, (c) remaining refactors E7/R5/R6/R13. +- 16 Jul 2026 (E8 DONE — incremental history fetch; John chose approach A) · The history panel + (SharingApi sharing/history) called get_changes(0) on every open, re-downloading the entire + event log each time. Now (commit 9ceeded909) it persists the cursor (max_event_id) alongside the + on-disk history cache and fetches only events past it (get_changes(cursor)), merging into the + cache. Safe: the event log is append-only and get_changes' cursor is exclusive (confirmed vs + pgTAP test 7c: get_changes(since=max) returns empty). Cache format gained a cursor + ({MaxEventId, Events}); an old bare-array cache loads with cursor 0 → one full refetch → re-saved + in the new format (back-compat, offline view preserved). TRADE-OFF (John's call, "go with A; + switch to B if users complain about stale names"): already-cached rows keep the book name / + author display name they were fetched with until the cache rebuilds (book renames + admin + display-name edits don't retroactively update old rows). Fallback if that bites: approach B + (refetch-only-when-changed). Refactor: extracted testable LoadHistoryCache / SaveHistoryCache / + MergeHistory statics + a shared FilterToCurrentBook; 6 new unit tests. Required filter 452/452. + · EXACT next action: remaining open items for John — (a) pull the 8 mid-session master commits, + (b) re-run PR #8052 regen vs current master + Greptile, (c) remaining refactors E7/E9/R5/R6/R13. +- 16 Jul 2026 (E2 DONE — cache collection-scoped download credentials) · download-start returns + read-only S3 creds scoped to the WHOLE collection prefix (tc/{cid}/*, ~1h); every book + + collection-file-group download was fetching them separately (N+ edge-function calls + server STS + AssumeRole per join). Now cached (CloudTeamCollection.GetCollectionDownloadLocation, commit + a9e817f616): one fetch serves a whole join/Receive. Re-fetched only near the creds' stated + expiration (captured via new CloudS3Location.ExpiresAtUtc + ParseCredentialExpiration; MinValue + when absent ⇒ non-cacheable) or on account change. Risk review (John asked): creds are + collection-scoped + read-only so cross-book reuse is correct; no new exposure; only expiry to + manage, handled by honoring the server's real expiration (no TTL assumption). BUG caught by the + existing SyncAtStartup_TeammateRenamedBook test during verification: an absent-expiration + (MinValue) cache entry made the staleness check compute `MinValue - margin` → underflow throw on + the 2nd call; fixed by adding the margin to `now` instead. 3 new tests (reuse-within-expiry / + refetch-when-expired / refetch-without-throwing-when-no-expiration). Required filter 446/446. + · EXACT next action: remaining open items for John — (a) pull the 8 mid-session master commits, + (b) re-run PR #8052 regen vs current master + Greptile, (c) remaining refactors E7/E8/E9/R5/R6/R13. +- 16 Jul 2026 (decision C RESOLVED — 'updates available' now includes books checked out elsewhere) + · John's ruling: GetUpdatesAvailableCount should count books checked out by ANOTHER user (a + reviewer can still receive the latest checked-in version; and it's wrong for the badge to switch + off — without the update ever being received — just because someone else took the book out). + Fix (commit d2bd21847d): GetUpdatesAvailableCount now excludes only books checked out HERE + (was: any locked book), computed via the SAME StatusFromCachedBook + IsCheckedOutHereBy path + ReceiveAllUpdates uses — so the badge count and what Sync receives can't drift (this closes the + predicate-drift item flagged during the batch-3 ReceiveAllUpdates extraction). The top-bar + TeamCollectionButton's "Updates Available (N)" label reads this count, so it now stays on while a + book is checked out elsewhere. Test updated to pin both halves; required filter 443/443. · EXACT + next action: remaining open items — John decides (a) pull the 8 mid-session master commits, (b) + re-run PR #8052 regen vs current master, (c) the remaining efficiency/reuse refactors + (E2/E7/E8/E9/R5/R6/R13). +- 16 Jul 2026 (DpapiCloudTokenStore WIRED — cloud login now survives restart; John decision B + resolved) · The REPORT-ONLY "DpapiCloudTokenStore never wired" item is DONE (John: we want a + real cloud login to survive restart; make the progress we can now). Root state found: CloudAuth's + whole persistence mechanism (Save session/refresh-token on sign-in+refresh, Load+refresh at + startup, Clear on sign-out, proactive-refresh timer) was already built AND unit-tested via a fake + store — the ONLY gap was CloudAuth.CreateInitialized injecting the in-memory store, so nothing + reached disk. Fix (commit f4c5dbf2e3): CreateInitialized now injects DpapiCloudTokenStore (all + three call sites — CloudTeamCollection/TeamCollectionManager/SharingApi — flow through it). Dev + env-override path unaffected (re-signs from BLOOM_CLOUDTC_USER before consulting the store). Added + an end-to-end test using the REAL DpapiCloudTokenStore (session saved by one CloudAuth restored by + a fresh one over the same on-disk file = simulated restart); the dev provider's refresh follows + the identical Load-then-Refresh path Firebase will use, so the mechanism is fully verified now + without a live Firebase account. Required filter 443/443. WHAT STILL WAITS FOR FIREBASE: only a + literal real-account restart round-trip — the code path is identical and proven. · EXACT next + action: unchanged open items — John decides (a) pull the 8 mid-session master commits, (b) re-run + PR #8052 regen vs current master, (c) the remaining deferred refactors (E2/E7/E8/E9/R5/R6/R13) + + the ReceiveAllUpdates/GetUpdatesAvailableCount predicate drift. +- 16 Jul 2026 (merge origin/master — John: "rebase on current master") · Merged origin/master + (tip 5ed9403367, #8071 go-launcher-parallel-worktrees) into cloud-collections as commit + 760695d960 (a merge, NOT a literal rebase — the SQUASH-PLAN forbids rebasing this 270-commit + branch). **Launcher reconciliation per John's guidance** (minimize changes to existing files; + reuse master's helpers; no duplicate methods): go.mjs, processTree.mjs, watchBloomExe.mjs, + build.js resolved to MASTER's versions (our branch now carries ZERO diff on them — adopts + #8071's parallel-worktree design + reapOrphanedBloomDevStacks, drops our behavior-neutral + /simplify of them). The cloud build-once launcher stays as pure additions (run.sh, run.mjs, + automationReady.mjs, childOutput.mjs, viteDevServer.mjs); run.mjs rewritten to use master's + killProcessTree (dropped our duplicate terminateChildProcess) and reapOrphanedBloomDevStacks + (dropped our sweepStaleWorktreeNodeProcesses). CollectionCard.tsx: union-merged master's card + redesign (isCurrentCollection highlight + Chip/metadata row) with our join-card support, wiring + the "Get" join cue into master's new inline metadata row; join-card tests updated for the new + title element (body1 not h5). Verified: C# required filter 442/442; auto-merged + CollectionChooserApi + BloomS3ClientTests 39/39; collection + teamCollection vitest 100/100 + (run sequentially — the parallel full-suite stall is the KNOWN Windows vitest flake, hang-fix in + vitest.setup.ts intact, NOT a merge regression); all launchers pass node --check. · NOTE: + origin/master advanced 8 more commits DURING this session (fba6b758ab etc.: speedUpCSharpTests, + fasterIntegrationTests, a Version6.4 merge, BL-16532 toolbox fix) — the merge captured + master-as-of-merge-time; those 8 are not yet pulled (surfaced to John). · EXACT next action: + John decides whether to also pull the 8 newer master commits now; then the PR #8052 regen should + be re-run against CURRENT origin/master (now that cloud-collections contains it — the earlier + regen was against the merge-base); the launcher-reconciliation question is CLOSED. +- 16 Jul 2026 (final — John authorized the force-push + freed memory) · Two things closed: + (1) **PR #8052 force-pushed** — regenerated `cloud-tc-for-review` (byte-identical to + cloud-collections) pushed with --force-with-lease (51da40f8f0→98bc3524ac); **Greptile + re-triggered** via `@greptile-apps review` comment (issuecomment-4989391847) describing the + /simplify pass. (2) **Live e2e now PASSES** — with memory freed (49% free vs the earlier 14%), + `e2e-1-create-share` + `e2e-2-collaboration-loop` both PASSED (2/2, 3.9 min) through the live + Supabase+MinIO stack. This confirms the earlier about:blank failures were purely the documented + memory-pressure flake, and LIVE-VERIFIES R2 (BuildDefaultClient S3 client, up+down), R1 + (ListAllObjects in DownloadCollectionFileGroup, via e2e-2's join/receive), and the batch-3 + ReceiveAllUpdates extraction end-to-end. · **Still for John (morning):** master-merge + + launcher reconciliation (origin/master's #8071 go-launcher change vs our 85ba119594); the + REPORT-ONLY decision list; an optional go.sh/run.sh launcher smoke (e2e bypasses those scripts). + Dev stack is still up. Working tree clean (only session-local .claude/settings.json untracked). +- 16 Jul 2026 (later — "FINISH EVERYTHING" per John; /simplify application COMPLETE & COMMITTED) + · John chose "Finish everything" for the remaining optional work, and gave an uninterrupted + multi-hour window (machine already set never-sleep-on-AC; verified, no change needed). + **All /simplify batches are now done, verified, and committed on cloud-collections as 6 clean + logical-chunk commits** (plus the RESUME doc commit 046cd6f855): + 1c7b66e0fd Cloud TC C#: simplify/reuse (dead-API removal + S3/path reuse) [batch 1+2] + 3d928bfa07 Cloud TC UI: shared sign-in form + test-render helper + 88720668fd Cloud TC E2E: shared waitForSharingReady + paths header + 6ea968daec Cloud TC server: shared paths + S3 upload verify (deno 33/33) + 85ba119594 Cloud TC launchers: shared process-tree + automation-ready + 68b5b8c634 Cloud TC C#: file organization (partials + provider split) [batch 3] + · **Batch-2 finished**: R11-use (reuse hoisted AvailablePath for the cloud .bloomSource path), + R1 (DownloadCollectionFileGroup → S3Extensions.ListAllObjects), R2 (deleted duplicate + BuildS3Client, reuse CloudBookTransfer.BuildDefaultClient) + a NEW test pinning E4's + localManifest hash-reuse. · **Batch-3 finished** (subagent did A/B/C mechanically, I did D; + all independently re-verified): CloudTeamCollection.CollectionFiles.cs + TeamCollection. + AutoApply.cs partials, Dev/FirebaseCloudAuthProvider split out of CloudAuth.cs, and + HandleReceiveUpdates' loop → CloudTeamCollection.ReceiveAllUpdates. Verified as PURE + relocations — member-signature counts preserved exactly (CloudTeamCollection 71→71, + TeamCollection 130→130, CloudAuth 34→34). + · **Verification**: C# required filter 442/442 (0 fail); front-end vitest 593 pass (the only 5 + failures are pre-existing/unrelated talkingBookSpec — dir byte-identical to master); eslint + clean on changed files; deno edge-fn tests 33/33. + · **Live e2e — ATTEMPTED, BLOCKED BY ENVIRONMENT (not a regression).** Brought up the full dev + stack (podman → supabase [was wedged; clean stop+start fixed] → MinIO on the supabase net → + functions serve --env-file). Ran e2e-1 + e2e-2, then e2e-1 alone: ALL failed with the + DOCUMENTED "WebView2 stuck at about:blank" signature at connectOverCdp (launch.ts:490) — i.e. + the UI never navigated, BEFORE any cloud/S3 logic runs. Root cause is the README's documented + memory/CPU-pressure flakiness: only 14-16% RAM free (below the ~18-20% danger line), driven by + the user's Chrome (51 procs, ~5 GB) + the WSL/Podman VM (~3 GB) — none of which are mine to + kill. NOT caused by this session's code (first failure was at the initial FOLDER-collection + launch; 442 tests pass; front-end is built; single-instance also fails so it isn't + port-collision). R1/R2 are consolidations onto ALREADY-live-proven code (BuildDefaultClient = + the builder all uploads already use and that passed prior e2e; ListAllObjects = the helper + BloomS3Client already uses in prod), so unit+integration+reuse analysis is strong. Dev stack + LEFT UP so John can run e2e-1/e2e-2 himself in a less-loaded environment as the final + confirmation. · **NOT run**: a live go.sh/run.sh launcher smoke — the e2e harness launches + Bloom.exe directly (not via go.sh/run.sh), so it doesn't exercise the launcher refactor; + validated statically instead (all shared-helper imports resolve to real exports; node --check + + eslint clean). Recommend John do a quick go.sh/run.sh launch (he runs these constantly). + · **PR #8052 regen — DONE LOCALLY & BYTE-IDENTICAL, but the force-push is CORRECTLY BLOCKED + pending John's explicit OK.** Regenerated cloud-tc-for-review (9 review-grained groups) against + the MERGE-BASE master commit e476af54d6 (NOT latest origin/master — see the master-merge flag + below); regen-bucket reported 0 unmatched (all new files bucket correctly), and regen-rebuild's + identity check confirmed "tree matches cloud-collections exactly". The branch is ready locally; + `git push origin cloud-tc-for-review --force-with-lease` was denied by the safety classifier + (rewriting/force-pushing a public PR branch's history wasn't explicitly authorized) — CORRECT; + it needs John's go-ahead. Greptile will need re-triggering after the push. + · **FLAG FOR JOHN — master-merge is a separate call (conflict risk on OUR launcher work):** + cloud-collections is 14 commits behind origin/master (15 Jul PM); origin/master HEAD is + "go-launcher-parallel-worktrees" (#8071), an UPSTREAM launcher change that almost certainly + collides with commit 85ba119594's launcher-simplify. Merging latest master + reconciling the + two launcher refactors is a judgment call left to John. (The PR regen above sidesteps this by + basing on the merge-base; a "proper" regen against current master should follow the + master-merge.) + · **REPORT-ONLY decisions still pending for John** (from the PAUSE-#2 list, none actioned this + session): DpapiCloudTokenStore is NEVER WIRED (real sessions won't survive restart); E2 cache + DownloadStart creds (~1h TTL join speedup); E7 rename-scan cost/poll; E8 history refetches whole + log; E9 collection-file group re-downloads unchanged files; A3 AddCloudBookStatusFields JSON + surgery behind virtual seams (skipped deliberately); R6 remaining hooks → useWatchApiData; R13 + getApiDataOnce dedup; R5 shared C# cloud test-fixture builder. ALSO: the ReceiveAllUpdates / + GetUpdatesAvailableCount predicate drift (Sync receives books locked by another user; the badge + excludes them) — documented inline at ReceiveAllUpdates, decide whether to unify. + · **EXACT next action on resume:** (1) John: decide + authorize the `git push origin + cloud-tc-for-review --force-with-lease`, then re-trigger Greptile on #8052. (2) John: run live + e2e-1/e2e-2 in a less memory-loaded environment (stack is up) as the R1/R2 confirmation, plus a + quick go.sh/run.sh launcher smoke. (3) John: decide the master-merge + launcher-reconciliation. + (4) Work through the REPORT-ONLY decisions. Nothing else is in-flight; working tree is clean + (only untracked LIVE-STATUS.md scratch + session-local .claude/settings.json). +- 16 Jul 2026 (RESUME from PAUSE #2 — verification pass; WIP is HEALTHIER than the pause note + feared) · Resumed the /simplify application. Working tree matched the PAUSE #2 doc exactly + (67 modified + 5 new source files + untracked .claude/settings.json — nothing lost; top + stash is unrelated Version6.4). **Findings, all now resolved or characterized:** + - **C# COMPILES** (the pause note's "tree may not compile" worry was unfounded — R1/R2/ + R11-use are unstarted *additional* cleanups, not half-applied breakage). BloomExe builds + clean. The ONLY real breakage: 4 call sites in CloudBookTransferTests.cs still passed the + pre-E4 arg list to `UploadChangedFiles` (missing the new `localManifest` param) → + CS7036. FIXED: inserted `null` for `localManifest` at all 4 sites (behavior-preserving — + doc says null ⇒ hash as before). BloomTests now compiles. + - **C# required filter GREEN: 441/441** (non-live: `(~Cloud|~TeamCollection|~SharingApi) + &!~LiveTests`), 0 failures. (Doc's 460 pre-batch-2 count included ~19 LiveTests, excluded + here since the stack was not confirmed up; zero failures either way.) CloudBookTransfer + fixture 10/10. + - **vitest full suite TRIAGED: 593 passed, 5 failed, 5 skipped.** All 5 failures are in + `bookEdit/toolbox/talkingBook/talkingBookSpec.ts` ("sentence splitting" audio-checksum + tests) — a directory this branch NEVER touched (git diff vs origin/master is empty; last + commit Mar 2026; clean in working tree; none of its deps are in the changed set; global + test infra untouched). **Conclusion: pre-existing / environmental, NOT caused by the + refactor.** Every refactored teamCollection/collection test passes. + - **eslint (changed front-end files): 0 errors.** Fixed 3 warnings in CreateTeamCollection.tsx + (dropped unused `showDialog`/`closeDialog` from the useSetupBloomDialog destructure — a + leftover of the CreateCloudTeamCollection extraction; `==`→`===` on the boxesChecked + gate). CreateTeamCollection + CreateCloudTeamCollection vitest 14/14. + - e2e: prior agent reported tsc/eslint-level clean; live run still queued (desktop/stack gated). + **State of REMAINING documented work (unchanged, still open):** batch-2 R1 (DownloadCollection + FileGroup→S3Extensions.ListAllObjects), R2 (delete CloudTeamCollection.BuildS3Client, reuse + CloudBookTransfer.BuildDefaultClient), R11-use (reuse hoisted AvailablePath) — all UNSTARTED, + and R1/R2 touch AWSSDK-v4 S3 client construction (want live MinIO verify); batch-3 file org + (partials + provider split, large mechanical reorg); full pass + live run.sh/go.sh smoke; + commit in logical chunks; regen #8052 + re-trigger Greptile (needs `gh auth`, was broken at + pause). **My 7 line-fixes above are LEFT UNCOMMITTED with the rest of the WIP** (matching this + batch's established pattern — code WIP uncommitted, state in this doc), fully reproducible from + this entry. **EXACT next action:** paused to confirm scope with John — how far to push the + remaining OPTIONAL/quality work (R1/R2/R11-use + batch-3 file org) in this session given the + token concern that triggered the pause, and whether `gh` is now authenticated for the PR regen. +- 15 Jul 2026 (PAUSED #2 — John: "put this task on hold until the next session; too many + tokens"). Continuation of the entry below; tree has UNCOMMITTED WIP (~67 modified + 5 new + files). **What changed since the previous PAUSE entry:** + - C# batch-1 is COMPLETE and GREEN: fixed the CloudEnvironmentTests compile break, finished + the whole batch-1 tail (CloudBookTransfer dead `alreadyUploadedThisTransaction` param + dropped from UploadChangedFiles + both CloudTeamCollection call sites + tests; + CloudCollectionMonitor dead Stop() deleted + Dispose doc'd terminal; SharingApi + serializes `emailVerified`, SignedInEmailForJoinCards→CurrentAuth().CurrentEmail, + RegisterWithApiHandler doc'd; CloudTeamCollection CollectionIdForCloud→CloudCollectionId + and TryGetBookIdForTests→GetBookIdByNameIndexForTests with callers; TryTakeOverLock doc + reworded). Required filter ran **460/460 PASSED** (down from 465 because dead-API tests + were deleted with their APIs). + - MID-PAUSE INCIDENT (resolved): an old stash mis-applied during the previous pause left + conflict markers in 9 files foreign to this branch's work + a spurious untracked + CanvasElementManager.ts; with John's approval all 9 were restored from HEAD and the stray + file deleted. No stash was dropped; nothing of ours was lost. + - Three fix agents were then relaunched and STOPPED for THIS pause, each mid-flight: + (a) React/TS agent — finished auditing/completing the test-render-helper refactor and was + running the FULL vitest suite, which **exited code 1**; it was stopped while reading the + failure summary. UNKNOWN whether the failure is refactor-caused or pre-existing — first + resume job: run `pnpm exec vitest run` in src/BloomBrowserUI and triage. + (b) e2e agent — verification came back CLEAN (stragglers converted, tsc/eslint-level check + passed); it was stopped during scratchpad cleanup (it made a HEAD-baseline checkout in the + session scratchpad with a node_modules junction — harmless, dies with the session). + Treat e2e work as DONE pending one final look. + (c) C# batch-2 agent — had applied E1 (GetRepoBooksByIdMap protected virtual + cloud + override from _cache.GetAllBooks()), E3 (poll early-return), E4 (manifest hashes into + UploadChangedFiles), E6 (display-name failure TTL), R12 (CloudAuth.CreateInitialized), + and the R11 hoist (AvailablePath→base); its last words were "Now R11 (use hoisted + AvailablePath), R1 (ListAllObjects), R2 (delete BuildS3Client)" — so R11's USE side, R1, + R2 are NOT done, and NOTHING of batch 2 has been compiled or tested. The C# tree may not + compile. + **EXACT next action on resume:** (1) `git diff` the C# files to see batch-2 agent's actual + edits; finish R11-use/R1/R2 (specs in the entry below); run the required filter (baseline + was 460/460 pre-batch-2). (2) Triage the vitest full-suite failure; finish React/TS + verification (vitest/eslint/typecheck). (3) C# batch 3 (file org — specs below). (4) Full + test pass + live run.sh smoke (launchers still unverified live). (5) Commit in logical + chunks (NOTE: `.claude/settings.json` is untracked session-local config — do NOT commit it; + add files explicitly, no `git add -A`). (6) Regenerate #8052 via orchestration/regen-*.sh, + re-trigger Greptile. (7) Final report to John: fixed/skipped summary + REPORT-ONLY decision + list (below, incl. DpapiCloudTokenStore never wired). +- 15 Jul 2026 (PAUSED MID-REFACTOR — tokens ran out; tree has UNCOMMITTED WIP that DOES NOT + COMPILE yet) · John asked for a /simplify-style quality review of the whole branch; 5 review + agents produced ~49 findings; application was in progress when paused. **STATE:** + - DONE + verified, uncommitted: (a) supabase edge functions — shared + `_shared/paths.ts` (key layout) + `captureVerifiedUploads` in `_shared/s3.ts` (Map + 8-way + bounded parallel verify) used by both finish fns; `stubAssumeRole`→test_support; + `S3_WRITE_ACTIONS` shared — deno 33/33 BEFORE AND AFTER + deno check clean. (b) launcher + scripts — `terminateChildProcess` (with signalFirst option) in processTree.mjs used by + go/run/watchBloomExe; new `automationReady.mjs` (prefix + scanner) used by run+watchBloomExe; + watchBloomExe now uses pipeChildOutput — node --check + eslint clean (needs a live + go.sh/run.sh smoke later). + - DONE, uncommitted, MY C# batch-1 so far: BookVersionManifest Diff API deleted (+tests); + CloudJoinFlow.ListMyCollections+CreateAndJoinCollection deleted; CloudCollectionClient + UndeleteBookRpc/RenameCheck deleted + UnlockBookRpc/ForceUnlockRpc/DeleteBookRpc renamed + without Rpc suffix (callers in CloudTeamCollection.cs updated; LockTests test method + renamed); CloudAuth AccountSwitched/SignedOut events + EventArgs + raiseEvent param deleted + (+tests rewritten as SignIn_WithDifferentAccount_ReplacesIdentity; stale comments fixed in + CloudTeamCollection.cs + MemberTests); CloudEnvironment S3Bucket + FirebaseProjectId props + + env reads deleted. **BROKEN RIGHT NOW: CloudEnvironmentTests still asserts env.S3Bucket + (~line 18, ~71) and env.FirebaseProjectId (~76, ~88) — delete those assertion lines (and the + two env entries "BLOOM_CLOUDTC_FIREBASE_PROJECT_ID"/S3_BUCKET in the sandbox test dict) to + compile.** + - Two fix agents were STOPPED MID-WORK (partial uncommitted edits, unverified): React/TS agent + (was doing: extract CreateCloudTeamCollection.tsx from CreateTeamCollection.tsx — likely + done; shared DevSignInForm.tsx — likely done; useSharingLoginState→useWatchApiData; shared + test-render helper — was mid-way through the 13 test files, stopped at SharingPanel.test.tsx/ + NewerVersionAvailableMarker.test.tsx) and e2e agent (edits done — waitForSharingReady in + harness/bloomApi.ts replacing ~14 poll copies + paths.ts header — but verification NOT run). + On resume: run vitest/eslint/typecheck for BloomBrowserUI and finish/verify both. + - REMAINING C# work I had queued (batch 1 tail): CloudBookTransfer drop dead + `alreadyUploadedThisTransaction` param (both CloudTeamCollection call sites pass throwaway + sets — removes the lock complexity too); CloudCollectionMonitor delete dead Stop() + doc + Dispose; SharingApi: serialize `emailVerified` in HandleLoginState reply (1 line, makes the + declared TS field truthful), simplify SignedInEmailForJoinCards→CurrentAuth().CurrentEmail + (+line ~560 IsSignedIn), doc RegisterWithApiHandler; CloudTeamCollection rename + CollectionIdForCloud→CloudCollectionId + doc (caller TeamCollectionApi.cs:293), rename + TryGetBookIdForTests→GetBookIdByNameIndexForTests (caller LiveTests:224); TeamCollection.cs + reword TryTakeOverLock doc ("true = may proceed as lock holder incl. nothing-to-take-over"). + - BATCH 2 (efficiency/reuse, NOT started): E1 GetRepoBooksByIdMap→protected virtual + cloud + override from _cache.GetAllBooks() (kills 3 network calls/book at startup); E3 + OnPolledChanges early-return when get_changes empty + cursor unchanged (kills full-cache + Save every 60s); E4 pass local manifest hashes into UploadChangedFiles (kills double SHA256); + E6 CurrentUserDisplayName failure retry TTL ~30s; R1 DownloadCollectionFileGroup→ + S3Extensions.ListAllObjects; R2 delete CloudTeamCollection.BuildS3Client, reuse + CloudBookTransfer.BuildDefaultClient (make internal static); R11 hoist + FolderTeamCollection.AvailablePath→base TeamCollection, reuse in GetAvailableBloomSourcePath; + R12 CloudAuth.CreateInitialized factory (3 dup sites: TeamCollectionManager:707, + CloudTeamCollection:130, SharingApi:144). + - BATCH 3 (file org, NOT started; John asked explicitly): extract partial + CloudTeamCollection.CollectionFiles.cs (~310-line banner-delimited cluster ~1462-1770); + partial TeamCollection.AutoApply.cs (queue machinery ~104-244 + ProcessAutoApplyRemoteChange + + DownloadMissingBookInBackground + QueueMissingRepoBooks...); split DevCloudAuthProvider + + FirebaseCloudAuthProvider out of CloudAuth.cs; move HandleReceiveUpdates' loop into + CloudTeamCollection.ReceiveAllUpdates (NOTE: its predicate skips checked-out-HERE while + GetUpdatesAvailableCount excludes locked-by-ANYONE — drift to surface to John, don't silently + unify). + - REPORT-ONLY for John (decisions): DpapiCloudTokenStore is NEVER WIRED (real sessions won't + survive restart — wire at the 2 CloudAuth construction sites when AuthMode==Cloud, or delete + until Firebase ships); E2 cache DownloadStart credentials (~1h TTL, big join speedup); E7 + rename-scan cost per poll; E8 history refetches whole log each open (use since-cursor); E9 + collection-file group re-downloads unchanged files (compare size/ETag from listing); A3 + AddCloudBookStatusFields JSON-surgery could move behind virtual WhoHasBookLocked* seams + (skipped deliberately: identity logic just live-verified, not worth re-destabilizing); R6 + remaining hooks (useSharingMembers etc.) could share a reload-capable useWatchApiData; R13 + getApiDataOnce dedup (sharingApi/teamCollectionApi cached-promise idiom); R5 shared C# cloud + test-fixture builder (9 copy-pasted harness blocks). Full agent reports also survive in the + session tasks dir (a6925…=reuse, a095b…=simplify, af6da…=efficiency, ad850…=altitude, + a48a9…=naming .output files). + **EXACT next action on resume:** (1) fix CloudEnvironmentTests compile break; (2) finish C# + batch-1 tail; (3) run required filter (expect ~465 minus deleted diff/event tests, plus fix + fallout); (4) verify/finish the React+e2e agents' partial work (vitest/eslint/typecheck); + (5) batches 2-3; (6) full test pass, live-smoke run.sh, commit in logical chunks, regenerate + #8052 via regen scripts, re-trigger Greptile; (7) give John the fixed/skipped summary + the + REPORT-ONLY decision list. +- 15 Jul 2026 (doc cleanup, per John) · Removed the spent orchestration scratch documents from + the branch: all 11 agent launch prompts (`orchestration/*.prompt.md` — one-time kickoff + prompts; the durable per-task specs stay in `tasks/*.md`, which code comments reference), + `BUG0-OPTION-A-SKETCH.md` (bug #0 implemented as option (a) and fully closed — the outcome is + recorded in this file's OUTSTANDING BUGS #0), and `notes-item7-progressive-join.md` (scouting + notes with stale line numbers; item 7 shipped). RESUME.md was TRIMMED, not removed: its + still-operative rules (mandatory C# test filter, review-before-merge, dev-stack bring-up, + environment quirks) are what this file and IMPLEMENTATION.md point at; its stale Wave-4 + status and prompt-launching machinery are gone. Three e2e harness comments that cited + 09-e2e.prompt.md now cite src/BloomTests/e2e/README.md (same rules, kept doc). KEPT (the + durable set): CloudTeamCollections.md (design), CONTRACTS.md, GOING-LIVE.md (real + buckets/DB/deploy), IMPLEMENTATION.md (wave/merge history), tasks/*.md (specs+findings, + code-referenced), docs/ (walkthrough, unit-test setup), notes/write-book-status-audit.md + (code-referenced), this file (state), SQUASH-PLAN.md + regen-*.sh (needed until merge), + server/dev + firebase + e2e READMEs. Mentions of the deleted files in OLD progress-log + entries here and in tasks/IMPLEMENTATION logs are historical records and were left alone. + **EXACT next action:** regenerate cloud-tc-for-review + force-push (diff shrinks by the + deleted docs), re-trigger Greptile; then back to the standing next steps (item 10 [HUMAN], + promote #8052 when ready). +- 15 Jul 2026 (AM — fresh-eyes review of the 14 Jul work; three real fixes) · John asked for a + review of yesterday's session. Findings, all fixed + regression-tested (465/465 on the + required filter): + 1. **Rename-apply guard was unsound (data-loss risk).** The bug B fix guarded the + SyncAtStartup rename-apply block with LOCAL status (`!GetLocalStatus(..).IsCheckedOut()`), + but cloud checkouts never stamp local status (TryLockInRepo is RPC+cache only) — so + "check out → retitle → RESTART before check-in" would rename the folder back to the repo + name and overwrite the checked-out edits from the repo. Local status can also carry a + STALE teammate lock (accept-remote-lock sync writes repo status locally), wrongly + SKIPPING a legitimate rename. Fix: guard on the REPO lock (`IsCheckedOutHereBy(repo + status)` — same machine-local rule as QueueMissingRepoBooksForBackgroundDownload) plus a + `NewBookRenamedFrom(newName)==thisFolder` confirmation, which doubles as the + folder-TC gate (base implementation always answers null when the folder has repo status, + so folder TCs provably never enter the block). New tests: + CloudSyncAtStartupTests.SyncAtStartup_TeammateRenamedBook_RenamesLocalFolderInPlace and + SyncAtStartup_OwnRenameMidCheckin_DoesNotRevertRenameOrClobber (+ per-key S3 test harness). + 2. **Display-name cache ignored account switch.** CurrentUserDisplayName cached on a + session-wide boolean, so after Bob signs out and Alice signs in (batch item 9), Alice's + new local books showed BOB's display name. Fix: cache keyed by the signed-in email. New + test: AddCloudBookStatusFields_AccountSwitch_RefreshesDisplayName. + 3. **run.sh freshness check was effectively dead after any edit.** It compared source mtimes + against the apphost Bloom.exe, which incremental builds usually don't touch (only + Bloom.dll changes) — so the skip-build fast path never fired again after the first C# + edit. Fix: compare against the newest of Bloom.exe/Bloom.dll; also include the repo-root + Directory.Build.* files in the source scan (master's new Directory.Build.props affects + builds). + Also: the review-branch regeneration scripts now live IN THE REPO + (orchestration/regen-bucket.sh + regen-rebuild.sh, referenced from SQUASH-PLAN.md) instead + of a session-scratchpad that dies with the session. Reviewed-and-fine: incremental-download + seeding, upload-race lock, LockedSeat/LocalVersionSeq cache fixes, ExperimentalFeatures + exact-token match, XLF fix, launcher architecture. **EXACT next action:** regenerate + cloud-tc-for-review with the new scripts, force-push #8052, re-trigger Greptile. +- 14 Jul 2026 (PM #6 — CLEAN REVIEW CHECKPOINT: Devin + Greptile both satisfied) · Greptile + re-review of #8052 head 8fbe66e4ea PASSED (9m27s) with NO new findings; the prior XLF P1 is + fixed in the new head and its thread is resolved/outdated; all checks green (Greptile, + pr-automation, track); PR MERGEABLE, still draft. **Review status: both bots clear.** Devin + (via the fork slice-reviews) → 7 actionable, 5 fixed + 1 dismissed + 1 deferred. Greptile + (full diff) → clean after the XLF P1 fix. **Remaining before human review/merge are the known + [HUMAN] items only:** item 10 real Bloom Library web upload/download + the deferred + S3ForcePathStyle production-AWS check (GOING-LIVE.md 4.3); and John's live spot-checks. No + agent action pending on the bot gauntlet. EXACT next action (future): item-10 production + validation; when ready, promote #8052 from draft to ready-for-human. +- 14 Jul 2026 (PM #5 — review branch refreshed with all fixes; Greptile re-running) · Re-merged + origin/master (now 7209ba3bc1 → cloud-collections df580ff6bf); required filter 462/462; pushed. + cloud-tc-for-review REGENERATED (bucket now also assigns ExperimentalFeaturesTests.cs → g6; + --no-verify; byte-identical to cloud-collections — empty diff) and force-pushed → **PR #8052 + head 8fbe66e4ea, MERGEABLE**, now carrying all Devin-finding fixes. Greptile re-review triggered + (@greptile-apps review) and PENDING on the new head. **Fork Devin-probe cleanup DONE:** PRs + JohnThomson #3/#4/#5 closed, branches devin-probe-{base,client-core,server,backend-api} deleted + (remote + local). Devin slice-review harvest fully complete (7 actionable → 5 fixed, 1 dismissed, + 1 deferred to item 10). **EXACT next action:** when Greptile finishes on 8fbe66e4ea, gather + + triage its findings; fix any real ones on cloud-collections and re-regenerate/force-push. +- 14 Jul 2026 (PM #4 — Devin findings triaged + FIXED) · Commit `99bfb1e102` on cloud-collections + (pushed) fixes 5 of the 7 actionable Devin findings; 462/462 required-filter + new regression + test; eslint clean. **Fixed:** (1) CloudBookTransfer upload race — alreadyUploadedThisTransaction + now accessed only under resultGate; (2) CloudRepoCache.RecordCheckinFinish now clears LockedSeat + on release; (3) ApplyFullSnapshot now carries LocalVersionSeq across the swap (was wiping it → + needless re-downloads); (6) ExperimentalFeatures now exact-token match (was substring — cloud/ + folder TC flags collided) + regression test CloudAndFolderTeamCollectionTokensDoNotCollide; + (7) CollectionHistoryTable.tsx now labels numeric-type ≥100 cloud incident events (were blank). + **DISMISSED (5) BloomS3Client.cs:130** — false positive: the flagged `internal static + CreateAmazonS3Client(config,credentials)` is a NEW helper, not a visibility downgrade; the methods + bloom-harvester actually overrides (GetAccessKeyCredentials, the bucketName CreateAmazonS3Client + overload) are unchanged `protected virtual` — no cross-repo break. **DEFERRED (4) + CloudEnvironment.cs:144 S3ForcePathStyle** to item 10 (GOING-LIVE real-AWS validation): the logic + `= !IsNullOrEmpty(S3Endpoint)` is correct for dev/MinIO (DefaultS3Endpoint is the MinIO URL) but + would force path-style on real AWS; the right production config (leave S3Endpoint empty, or an + explicit force-path-style override) is exactly what item 10's production validation must settle. + **EXACT next action:** re-regenerate cloud-tc-for-review from cloud-collections (bucket.sh/ + rebuild.sh, --no-verify), force-push #8052; check/handle Greptile's re-review of the new head; + delete the throwaway fork probe PRs/branches (JohnThomson #3/#4/#5, branches devin-probe-*). +- 14 Jul 2026 (PM #3 — ALL Devin slice findings gathered; triage/fix next) · Heads unchanged: + cloud-collections `5c8275bc9a`, cloud-tc-for-review `b45e5e21be` = #8052 (MERGEABLE). Devin + reviews of all three fork slices are COMPLETE. **Full actionable inventory to triage/fix on + cloud-collections:** + - #3 client-core (g5): BUG CloudBookTransfer.cs:241 `alreadyUploadedThisTransaction` ISet.Add + inside Parallel.ForEach = data race (CONFIRMED real); BUG CloudRepoCache.cs:404 lock seat not + cleared on check-in release (stale cache); BUG CloudRepoCache.cs:270 full snapshot wipes local + version tracking → needless re-downloads; INVESTIGATE CloudEnvironment.cs:144 S3ForcePathStyle + always true (non-empty default endpoint) → breaks real AWS (ties to item 10); INVESTIGATE + BloomS3Client.cs:130 protected→internal-static breaks bloom-harvester subclass. + - #4 server (g2+g3): 0 Bugs, 0 Investigate. 6 Informational (3 are "already fixed by a later + migration"; 1 worth a glance: rpc.ts:65 user values interpolated into PostgREST query strings + w/o URL-encoding — Devin rated Informational). + - #5 backend+API (g6+g7): BUG ExperimentalFeatures.cs:20 enabling cloud TC feature silently + enables the folder one too + disabling folder corrupts the cloud flag; BUG HistoryEvent.cs:36 + a new (cloud) history event type renders BLANK in the history table. Informational (skip): + FolderTeamCollection PutBook checkinComment not passed; RemoteBookAutoApplyQueue.cs:83-91 + case-sensitivity mismatch between dedup set & priority-move (glance-worthy, rename-adjacent); + SharingApi static auth/client never disposed; a positive null-ref-fix note; a test-only virtual. + Net: **7 actionable** (5 from g5, 2 from #5), plus 2 glance-worthy Informationals (rpc.ts URL-encode, + auto-apply-queue case-sensitivity). Greptile re-review of #8052 new head still not posted — check. + **EXACT next action:** triage each of the 7 against the code; fix the real ones on cloud-collections + (begin with the CONFIRMED CloudBookTransfer race). Then Greptile #8052, re-regenerate + force-push, + delete fork probe branches/PRs (#3/#4/#5, branches devin-probe-*). +- 14 Jul 2026 (PM #2 — SESSION-SAVE for machine sleep; fixes + Devin-slicing review in flight) · + **Branch heads:** cloud-collections = `812703d56a` (origin in sync); cloud-tc-for-review = + `b45e5e21be` (origin in sync) = PR #8052 head, MERGEABLE. Working tree clean. + **Code shipped this session (all committed + pushed on cloud-collections, ride to #8052 via the + regen):** + - Build-once dev launcher `run.sh` (+ `run.mjs`, shared `viteDevServer.mjs`/`childOutput.mjs`; + `go.mjs` refactored onto them; Program.cs suppresses the DEBUG "Attach debugger" modal under + --automation; AGENTS.md documents both launchers). Commit c1665fecdc. + - Bug A (new-local-book avatar shows account display name, not email): CloudTeamCollection + `CurrentUserDisplayName` cached from MembersList; TeamCollectionApi stamps it. Commit 3d240de7a7. + - Bug B (teammate-rename round-trip): SyncAtStartup now detects the remote rename by instance id + even when statusJson is non-null (cloud identity-first) and renames the local folder IN PLACE + (no duplicate, no stale name). Commit 0b27f6a6f6. LIVE-VERIFIED by John. + - Incremental Receive: FetchBookFromRepo seeds staging from the local folder so DownloadFiles' + hash-skip re-downloads only changed files (rename refresh + all Receives). Commit 696958aff8. + - Greptile P1: launch-crashing `--` in an XLF (CollectionTab.BookNotYetDownloaded) → + replaced with ';'. Commit acf3ba9272. + - Decisions recorded (see items #0, #13 above): Send All stays put; server-side cross-seat + check-in enforcement = WON'T DO (bug #0 fully closed). + **Preflight / review state:** + - origin/master (51e467d5de) merged into cloud-collections (clean, 812703d56a); required-filter + 460/460. cloud-tc-for-review regenerated per SQUASH-PLAN (byte-identical, `--no-verify` on the + 9 regen commits — John-approved, since the content already passed hooks on cloud-collections; + this made the rebuild seconds instead of timing out on the per-commit hook). Force-pushed → #8052. + - **Devin can't review #8052 (246 files "too large").** So we review the feature in SLICES via + throwaway PRs ON JOHN'S FORK (JohnThomson/BloomDesktop) to keep the main repo clean. Mechanism: + base branch = exact master commit `devin-probe-base`; each slice branch checks out that group's + files from cloud-collections; stacked where dependent. Slices → fork PRs: + - #3 client-core (g5, 19 files, base devin-probe-base) — **Devin DONE, 3 Bugs + 2 Investigate + + 3 Info** (below). + - #4 server (g2+g3, 39 files, base devin-probe-base) — Devin ANALYZING (triggered). + - #5 backend+API (g6+g7, 54 files, base devin-probe-client-core so g5 types are context) — + Devin ANALYZING (triggered). + - **g5 (#3) Devin findings — NOT yet triaged/fixed:** Bugs: (1) CloudRepoCache.cs:404 lock SEAT + not cleared when a check-in releases the lock (stale cache metadata); (2) CloudRepoCache.cs:270 + full server snapshot wipes local version tracking → needless re-downloads (matches a queued + follow-up); (3) CloudBookTransfer.cs:241 `alreadyUploadedThisTransaction` (plain ISet) `.Add()` + inside a Parallel.ForEach = data race — **CONFIRMED real by code read.** Investigate: (a) + BloomS3Client.cs:130 protected→internal-static on a method bloom-harvester (separate repo) + extends; (b) CloudEnvironment.cs:144 S3ForcePathStyle always true (non-empty default endpoint) + → breaks real AWS in production (ties to item 10 GOING-LIVE). Info (skip): CloudSession setters, + CloudAuth timer race (benign), S3Extensions null-guards OK. + - Greptile on #8052 NEW head (b45e5e21be): only pr-automation shows so far; Greptile's re-review + not yet posted — CHECK next session. + **EXACT next actions (resume here):** + 1. Gather Devin findings from fork PRs #4 (server) and #5 (backend+API) — devin-review skill, + chrome-devtools isolated context, URLs app.devin.ai/review/JohnThomson/BloomDesktop/pull/{4,5}. + 2. Triage ALL Devin findings (g5 #3 + #4 + #5) against the code; fix the real ones on + cloud-collections (start with the confirmed CloudBookTransfer.cs:241 race). Also handle + Greptile's fresh #8052 review. + 3. After fixes: re-run required filter, re-regenerate cloud-tc-for-review (bucket.sh/rebuild.sh + in scratchpad — rebuild.sh now uses --no-verify), force-push #8052. + 4. Clean up the throwaway fork probe branches + PRs (JohnThomson #3/#4/#5, branches + devin-probe-base / -client-core / -server / -backend-api) when the review harvest is done. + **Tooling notes:** regen scripts live in this session's scratchpad (bucket.sh updated so all 253 + diff files bucket into the 9 groups, 0 unmatched; rebuild.sh has --no-verify). Master added + `build/agent-dotnet.sh|ps1` + Directory.Build.props: builds/tests into a private per-terminal tree + so a running Bloom no longer blocks builds (`build/agent-dotnet.sh test --filter ...`). + Guard: plain `dotnet test` under the new Directory.Build.props left spurious `Bloom.sln` / + `BloomTests.csproj` edits (dropped BloomExe project+ref) — discard them (`git checkout --`) if they + reappear; the committed versions are correct. +- 14 Jul 2026 (John pausing — low Fable credits; preflight kicked off) · All instances + closed; full required filter 459/459 (includes bug #17-round-2's new test, previously + unrun). origin/master merged into cloud-collections (clean, 17 files, f5a00c1cae) and + cloud-tc-for-review REGENERATED per SQUASH-PLAN (same 9 review-grained commits, head + 8ac48df0db, byte-identity verified — empty diff vs cloud-collections), force-pushed; + PR #8052 now MERGEABLE at the new head with all dogfood fixes through bug #18. + Bot gauntlet triggered: pr-automation (Devin) run 29341744792 in progress; + @greptile-apps review comment posted (246 files > auto limit). Regeneration gotcha + recorded: the identity check REQUIRES cloud-collections to be up to date with + origin/master first (stale master files otherwise show as diff; one BloomExe.csproj + merge artifact forced a second rebuild — cheap). Also: bash scripting note — $GROUPS is + a readonly bash builtin; don't use it as a variable name (cost one puzzled retry). + NEXT (when agent returns): gather Devin/Greptile findings from PR #8052 and + triage/fix; then remaining [HUMAN] items. FOR JOHN meanwhile (no agent needed): + (1) restart Bob → verify new/local-only books now say bob@dev.local (bug #17 fix); + (2) rename round-trip retest: Bob checkout → retitle → check in → Alice should get ONE + renamed folder (bug #18 fix), watch for the "renamed by a teammate" message; + (3) display names: set names via the Sharing-panel pencil and verify "checked out to + " + history show them; (4) item 10 [HUMAN]: real Bloom Library web + upload/download (AWSSDK v4 validation, GOING-LIVE.md 4.3); (5) decide bug #0 follow-up + (server-side refusal of same-user cross-seat check-in?) and bug #13 UX (Send All + discoverability); (6) if Alice's "My first test" blocks editing (lock carries the + OneDrive seat), admin Force Unlock from the Status panel; (7) housekeeping when done: + restore sleep timeouts (powercfg /change standby-timeout-ac 120; standby-timeout-dc 3). +- 13 Jul 2026 (Sunday PM #2 — **member display names**, John's request, CODE + SQL TESTS + DONE; bug #14 found+fixed) · "Show who has a book checked out (and similar) by a + human-readable name, email as fallback; admins edit the name in the Sharing panel." + Server (CONTRACTS.md bumped to v1.6, additive): 20260713000001 adds + tc.members.display_name; members_set_display_name RPC (admin sets anyone's, a claimed + member their own, blank clears, ≤100 chars); members_list rows carry display_name + (DROP+CREATE, re-granted); resolve_member_display prefers the durable column over the + JWT-claim event capture — so locked_by_name in get_collection_state/get_changes/ + get_book_manifest picks it up with no signature changes; get_changes event rows gain + by_display_name (the CURRENT durable name). 20260713000002 fixes bug #14 (see + OUTSTANDING BUGS). Both migrations applied to the live local stack via + `supabase migration up` (NO db reset — John's Tetun server data untouched); + `supabase test db` 89/89 (24 new in 03_tc_member_display_name_test.sql: auth matrix + admin/self/other/non-member, trim/clear/too-long, precedence, full pipeline via + get_collection_state + get_changes). C#: SharingApi.ToApprovedMember maps display_name + → name; new sharing/setDisplayName endpoint; CloudCollectionClient.MembersSetDisplayName; + ToBookHistoryEvent UserName preference by_display_name → by_user_name → by_email; + CloudTeamCollection.StatusFromCachedBook now puts the whole display name in + lockedByFirstName (surname null — both TS consumers render that cleanly; lockedBy STAYS + the email because the panel compares it with currentUser for lockedByMe). UI: + SharingPanel member rows get an admin-only pencil → inline input (Enter/blur commits + trimmed, Escape cancels, empty clears; all spans/inputs, no new divs, per the CSS + hazard above); sharingApi.setDisplayName. Vitest 12/12 (5 new); eslint/tsc clean. + SharingApiTests updated (+2 new C# tests) but **the C# suite has NOT been run**: John's + Alice instance (pnpm go, dotnet watch PID 67472) is live from THIS worktree, so + `dotnet test` would fight the locked output\Debug — ALSO NOTE the watcher has likely + already picked up this session's C# edits (may have rebuilt/restarted Alice) · Next: + run the C# required filter once Alice closes; John verifies the pencil in the Sharing + panel + a "checked out to " status; follow-ups queued: member self-service name + UI, folder-TC parity n/a (folder TCs already use registration first/surname). +- 13 Jul 2026 (Sunday PM — John's UI niggles fixed; **CSS HAZARD recorded**) · Three niggles + from live testing, all pushed: (1) create-success message now says "Team Collection panel" + instead of "Sharing panel" (CreateTeamCollection.tsx + XLF, John's call: fix the message, + don't rename the panel); (2) "(experimental)" dropped from both the Cloud Team Collections + checkbox label (AdvancedSettingsPanel.tsx — it already sits in an "Experimental Features" + list) and the "Share this collection on the Bloom sharing server" button + (TeamCollectionSettingsPanel.tsx); (3) Sharing-panel row alignment fixed (23b636f966). + **HAZARD for anyone adding UI to the Team Collection settings panel:** the legacy rule + `#teamCollection-settings div:not(.no-space-below) { margin-bottom: 10px; }` + (TeamCollectionSettingsPanel.less) uses an ID selector, so it outranks every + emotion-class rule and silently adds 10px below EVERY div descendant — inside a + `align-items: center` flex row this lifts div children (avatar, text column, MUI Chip) + 10px above honestly-centered non-div siblings (select, button). Neutralize with + `> div { margin-bottom: 0 !important; }` on the flex row (see SharingPanel.tsx's + MemberRow/AddMemberRow comments); nothing weaker wins. Found by John with the inspector + after two rounds of margin/height guesses failed · Next: member display names (John's + 13 Jul request): display_name column on tc.members + admin editing in the Sharing panel + + "checked out to " with email fallback. +- 13 Jul 2026 (Sunday AM — weekend recovery + second launch failure diagnosed) · The Podman + WSL machine stopped over the weekend: whole local stack was down (supabase functions serve + "failed to run docker"; podman ps connection refused). RECOVERED: podman machine start → + supabase stop/start → db reset (migrations + seed users) → MinIO compose up → functions + serve fresh → smoke 3/3 PASS. John's second `pnpm go` failure ("Bloom was started on port + 51040, but no vite server was available") did NOT reproduce on the next run (Bloom came up + fine, ready line + Vite connected): cold-start race — first post-weekend launch runs + Vite's dependency optimizer concurrently with a full dotnet-watch rebuild, and Bloom's + ReactControl.IsViteDevServerRunning allows only 400ms per origin (~1.2s total) at exactly + that moment. **QUEUED DX FOLLOW-UP: make the startup --vite-port validation patient** + (retry/longer window, mirroring go.mjs's own two-consecutive-successes poll) — NOT done + yet because a live dotnet watch was attached to John's running instance (never edit C# + under a live watcher) · Next: John's human tests in the running instance + (alice@dev.local / BloomDev123!, fresh collection). +- 11 Jul 2026 (afternoon — human-test launch failure diagnosed + fixed) · John's `pnpm go` + timed out ("Bloom did not emit BLOOM_AUTOMATION_READY within 120000 ms"). Cause: the E2E + harness launches write to the SHARED per-machine user.config, so the last matrix left + `BloomE2E-join-auto-open` (a cloud TC whose server rows the per-scenario DB resets wiped) + at the top of MruProjects; pnpm go auto-opened it and the connection-refusal MessageBox + (native, blocking, invisible to the launcher) hung startup. FIX: removed the BloomE2E/ + BobPlaceholder MRU entries (backup: user.config.bak-pre-mru-clean). **HARNESS FOLLOW-UP + (queued):** the E2E harness must stop polluting the human's MRU — snapshot+restore + MruProjects around a run (globalSetup/globalTeardown), or launch instances with an + isolated profile. Same class of hazard as the experimental-flag manipulation, but this + one actively breaks the next manual launch · Next: John's human tests proceed + (alice@dev.local / BloomDev123!; fresh collection required — old server rows are gone). +- 11 Jul 2026 (morning — **GOLD STAMP: FULL MATRIX 14/14**, 34.4 min, one run, zero + failures) · The desktop-unlock watcher fired, environment verified clean, and the + matrix ran green end to end on the final tree (af8a92a516 = PR #8052's content + + batch-log commits): e2e-1 through e2e-10 + join-auto-open, including e2e-4's + seat-gated takeover refusal and e2e-10's legitimate same-seat takeover. This is the + first fully-green FULL matrix since the post-batch defect hunt began — the batch's + test pipeline is CLOSED. Everything that remains is John's: [HUMAN] item 3 (centered + check-in dialog) + item 10 (web up/download, GOING-LIVE.md 4.3), and the OUTSTANDING + BUGS #0 follow-up decision (server-side same-user cross-seat check-in refusal). + PR #8052 is ready for review whenever the human checks are done · Next: John. +- 11 Jul 2026 (~04:30 — optional gold-stamp matrix INVALID: desktop locked again mid-run, + 14/14 CDP-connect failures = the locked-session signature; sleep timeouts were already + disabled, so this was a manual lock or an unseen policy). No leaked processes. NOT a + loss: the gold run was optional — every scenario has already passed on this exact tree + (13/14 matrix + standalone exonerations). Unlock watcher re-armed in the orchestrator + session; if it fires while the session lives, the matrix reruns · Next: John's items + (see the SQUASH-PLAN-EXECUTED entry above); rerun `yarn test` with the desktop + UNLOCKED whenever a gold stamp is wanted. +- 11 Jul 2026 (early AM — SQUASH PLAN EXECUTED; PR #8052 open, #8048 closed) · Merged + origin/master into cloud-collections first (13 commits, RAB/spreadsheet only, zero + conflicts, filter 428/428). Built `cloud-tc-for-review` per SQUASH-PLAN.md: 9 + review-grained commits, **byte-identical to cloud-collections (empty diff)** after + converging the prettier drift the packaging build exposed in 14 edge-function files + (formatting-only; deno 33/33 after; details in SQUASH-PLAN.md's executed-note). Draft + **PR #8052** opened with review order + caveat + bot history; **#8048 closed** with + pointer. REMAINING for the batch: (1) John's [HUMAN] tests — item 3 centered check-in + dialog, item 10 web up/download (GOING-LIVE.md 4.3); (2) John's OUTSTANDING BUGS #0 + follow-up decision (server-side same-user cross-seat check-in refusal); (3) optionally + one more full matrix as the gold stamp (every scenario HAS passed on this tree; the + last run was 13/14 with the 14th standalone-exonerated + spec-hardened); (4) regenerate + cloud-tc-for-review whenever cloud-collections advances. +- 11 Jul 2026 (early AM — POST-SEAT-FIX FULL MATRIX: 13/14, and the 14th is exonerated) · + Matrix on the seat-fixed tree (36 min): **e2e-4 PASSED IN THE MATRIX** (bug #0 verified + under full load); sole failure e2e-5, which passed standalone immediately after (3.0 min) + = load flake. Root cause found anyway and HARDENED: the spec killed Alice before her + initial share's asynchronous v1 commit was guaranteed done (nothing between + createCloudTeamCollection and the kill waits for the book row) — killing her mid-first- + Send leaves no book row ever. Fix: 90s poll for current_version_seq >= 1 BEFORE + alice.kill(). Every scenario has now passed on this exact tree; the tight-timeout flake + class is systematically addressed (all queue/commit polls at the 90s convention: + e2e-5/6/7/9) · Next: execute SQUASH-PLAN.md (preconditions met: bug #0 fixed+verified, + matrix verdict in) → cloud-tc-for-review branch + PR, close #8048 with pointer; then + optionally one more matrix as the gold stamp; John: [HUMAN] tests + OUTSTANDING BUGS #0 + follow-up decision. +- 11 Jul 2026 (early AM — BUG #0 FIXED AND VERIFIED; bot gauntlet fully closed) · John's + ruling (his words, from the in-session Q&A): "we should only be allowed to edit (either as + the original user checking the book out, or taking it over) if it is being worked on here, + in this copy of the collection… as long as the book is checked out here (this local copy) + and the logged-in user is a member, editing and take-over of the checkout should be + allowed. (A different user who has a different copy of the collection open, like our bob + and alice collections, definitely can't do this.)" — i.e. option (a) extended to the + "checked out here" determination. IMPLEMENTED (details in OUTSTANDING BUGS #0): server + seat column + gated takeover + auto-clear trigger (migration 20260711000003), client + SeatId + seat-gated IsEditableHere/CanTakeOverLockOnThisMachine (seams now take bookName), + CONTRACTS.md v1.5. VERDICTS: pgTAP 65/65 (10 new seat cases incl. e2e-4's + same-machine-different-seat refusal), C# filter 428/428 (6 new), **e2e-4 PASS** (first + time since the defect hunt began), **e2e-10 PASS** (same-seat takeover intact). Earlier + same night: full matrix 12/14 (37 min, desktop unlocked after John returned; sleep + timeouts disabled via powercfg — the mid-run locks were the 120-min AC idle-sleep timer); + the two failures were e2e-4 (now fixed) and e2e-7 (standalone 2/2 = load flake; its 20s + first-commit poll bumped to 90s). GREPTILE RE-REVIEW: "all three findings correctly + resolved. No new blocking issues." Gauntlet state: Greptile complete+clean, Devin + size-failed (terminal), CodeRabbit not installed, CI green · Next: (1) final full matrix + (expect 14/14 — first ever fully-green matrix if it holds), (2) execute SQUASH-PLAN.md → + cloud-tc-for-review PR, close #8048 with pointer, (3) John: follow-up decision in + OUTSTANDING BUGS #0 (server-side same-user cross-seat check-in) + [HUMAN] tests (item 3 + centered dialog, item 10 web up/download, GOING-LIVE.md 4.3). +- 10 Jul 2026 (night) · FULL MATRIX ATTEMPT INVALID — 14/14 failed because the Windows + desktop LOCKED sometime after the standalone runs (LogonUI confirmed running afterward; + every failure is at connectOverCdp / launch, the locked-session signature the E2E rules + warn about). NOT a code regression: e2e-3/6/9 had passed standalone within the previous + hour on the same tree. Orchestrator error to not repeat: re-check LogonUI immediately + BEFORE every launch, not just at session start. No leaked Bloom processes; stack healthy + (functions serve re-served itself cleanly after the pgTAP db reset). Greptile thread + replies posted (all 3 findings fixed in b93d0c9d82) · Next: rerun `yarn test` (full + matrix) as the FIRST action once the desktop is unlocked — expect 13/14 (e2e-4 = bug #0); + a desktop-unlock watcher is armed in the orchestrator session to catch the moment. +- 10 Jul 2026 (late evening — runbook step 1 COMPLETE + Greptile findings fixed) · + **e2e-3/6/9 ALL GREEN STANDALONE.** e2e-3 passed as-is (pure load flake). e2e-6 FAILED + standalone and was a REAL spec bug: since item 7 (progressive join), a book new to an + instance arrives via the background download queue AFTER pollNowViaReceiveUpdates + returns — the spec read Bob's file immediately (evidence: the book folder existed on + disk moments after the assertion failed). Fixed: v1-baseline read is now an expect.poll + (90s, the harness convention); the two 20s ceilings on queue-driven arrivals (e2e-6 v2 + arrival, e2e-9 first test) bumped to 90s. e2e-9 then 3/3 — its one intermediate failure + (name-race alice: 0-byte stdout at launch) was load I caused myself by running + lint/vitest during the run; reran truly idle → green. LESSON REINFORCED: "standalone" + means the AGENT runs nothing else concurrently either. **GREPTILE (bypass) DELIVERED: + 1 P1 + 2 P2, all verified real and FIXED:** (P1/security) checkin-start scoped S3 write + creds to the CALLER-SUPPLIED bookInstanceId — checkin_start_tx never validates it for + existing books, so any member could get write creds for any book's prefix in their + collection; now reads the DB-canonical instance_id back (same selectTcRow pattern as + checkin-finish) + new deno test pinning that a mismatched client value cannot steer the + prefix. (P2) reap_expired_checkin_transactions returned only the collection-file count + (GET DIAGNOSTICS clobbered the loop total) → new migration 20260711000001. (P2) + checkout_book_takeover raised P0002/42501 bare strings instead of the schema-wide + PT404/PT403 JSON convention (C# would map both to CloudErrorCode.Unknown) → new + migration 20260711000002 (logic untouched); pgTAP 4a expectation updated. Also fixed + Greptile's style note: JoinCloudCollectionDialog.tsx nested ternaries → if/else chains + (12/12 vitest, lint+prettier clean). Suites: pgTAP 55/55 on the reset stack; deno + 33/33 (NOTE: invariants.test.ts needs --allow-read; without it 2 tests fail on file + access, not logic). CONTRACTS.md check: the takeover row was ALREADY added in v1.4 — + the "flagged, not applied" comments in 20260709000007/CloudTeamCollection.cs are stale + · Next: reply to + resolve the Greptile threads on PR #8048, push, then bug #0 (John), + squash plan, human tests. +- 10 Jul 2026 (evening — RESUMED after machine sleep; runbook step 1 + bot gauntlet closure) · + Environment: containers survived sleep (all healthy), functions serve restarted per the + zombie rule, smoke.ps1 3/3 PASS, desktop unlocked. e2e-3 STANDALONE: **PASS** (3.1 min, + idle machine) — its matrix failure confirmed as a load flake; e2e-6/e2e-9 standalone runs + in progress. BOT GAUNTLET now TERMINAL for PR #8048 (no more waiting): **Devin FAILED — + "This pull request's diff exceeds the size limit for analysis"** (its review page's Info + sidebar; no bypass exists, so Devin will also fail on the future squash-plan PR — same + 237-file diff); **Greptile REFUSED — 237 files > its 100-file limit** — but offers a + bypass, which was TAKEN: `@greptile-apps review` posted on #8048 (bot findings may arrive + async; check the PR's comments/reviews next visit); **CodeRabbit is NOT INSTALLED on this + repo** (zero comments ever, repo-wide search; no .coderabbit.yaml) — last session's + "timed out after 35 min" was waiting on a bot that isn't there; drop it from all future + waits in this repo; CI 2/2 pass (unchanged) · Next: e2e-6/e2e-9 standalone verdicts, then + the remaining runbook order (bug #0 = John, squash plan, human tests). +- 10 Jul 2026 (EOD — SHUTDOWN STATE; machine going to sleep; next session may be a different + agent: read this entry + OUTSTANDING BUGS + SQUASH-PLAN.md and you have everything) · + FULL MATRIX under HEAVY LOAD: 10/14 (40 min, ran concurrently with the 16-min full C# + suite + review agents — the known load-correlated-flake regime). Failures: e2e-4 + (EXPECTED — bug #0, John's pending decision), e2e-3 / e2e-6 / e2e-9 (all three are + suspected LOAD FLAKES: e2e-3 passed standalone TWICE earlier today on this exact tree; + e2e-6/e2e-9 were green in the last pre-batch matrix; artifacts in + src/BloomTests/e2e/test-results/). BOT GAUNTLET at cutoff: CI 2/2 pass (pr-automation + + track; heavy CI doesn't run on this draft); CodeRabbit TIMED OUT after 35 min (no + review/comment via API); Devin TIMED OUT this session (huge PR — only the diff tree + renders on its page, no findings pass yet for HEAD; it keeps analyzing server-side). + Devin/CodeRabbit results will simply be waiting on PR #8048 whenever checked next. + SQUASH-PLAN.md committed (d8ff5c830e): review-grained packaging branch design, 9 grouped + commits, regenerable, byte-identical-verified · NEXT SESSION, in order: (1) rerun e2e-3, + e2e-6, e2e-9 STANDALONE on an idle machine (expect green; investigate for real if any + fails again), (2) John: bug #0 decision (options in OUTSTANDING BUGS #0; ready-to-implement + option-(a) sketch in BUG0-OPTION-A-SKETCH.md, same folder), implement + rerun e2e-4, + (3) gather bots: run the devin-review skill against PR 8048 (it mirrors findings to the + PR) + read CodeRabbit's review if posted; fix/reply per preflight rules, (4) execute + SQUASH-PLAN.md once 1–3 are done, open the new PR from cloud-tc-for-review, close 8048 + with a pointer, (5) John's [HUMAN] tests: item 3 centered dialog, item 10 web + up/download (GOING-LIVE.md 4.3). Environment reminders for the resumer: functions-serve + zombie rule (server/dev/README.md) after any sleep/restart of the stack; E2E needs + BLOOM_E2E_SCREEN=1 and an unlocked desktop; front-end is pnpm now (e2e harness stays + yarn). +- 10 Jul 2026 (PM, gauntlet running) · John authenticated gh. DRAFT PR CREATED: + https://github.com/BloomBooks/BloomDesktop/pull/8048 (cloud-collections → master, draft). + Devin triggered for HEAD 24b0f5c740 via the pr-automation workflow (completed = trigger + loaded); CodeRabbit + CI self-triggered on the PR. FULL E2E MATRIX running concurrently + (expected: 13/14, e2e-4's takeover assertion the only known failure — bug #0 pending + John's decision). If this session is cut off mid-gauntlet: re-run `/preflight` in a fresh + session — it re-enters wherever the PR/bots currently are (the devin-review skill gathers + + mirrors any finished Devin findings; matrix results land in the next entry) · Next: + poll bots (~30 min cap) → mirror Devin findings → fix/reply → matrix verdict → John's + decisions (bug #0, human tests). +- 10 Jul 2026 (PM, preflight — END-OF-SESSION STATE) · Preflight (John's request) ran to the + limit of what the session could do: LOCAL HALF COMPLETE — light-review sub-agent over the + day's diff found 2 valid adjacent holes, both FIXED + unit-tested + pushed (72246c2975: + per-account claim_memberships guard — an in-session account switch would have resurrected + defect 2; machine-aware lock skip — your own other-machine checkout no longer blocks the + self-heal download). Gate results: cloud filter 422/422; FULL C# suite 3120/0/13 of 3133 + (first run's single failure did NOT reproduce → flake); pnpm lint 0 errors; targeted vitest + 14/14; mergeability with origin/master clean (0 behind, 0 conflicts). E2E singles on the + final tree: e2e-3/5/8/10 ALL PASS; e2e-4 fails ONLY at the takeover-semantics assertion + (OUTSTANDING BUGS #0, John's decision — options a/b/c documented there, recommend (a) + server-side seat). GITHUB HALF BLOCKED: `gh` unauthenticated in the agent session, so no + draft PR / Devin / CodeRabbit / CI ran — after `gh auth login`, re-run `/preflight`. + Preflight report artifact (decisions + copy-back form) published for John · NEXT, in + order: (1) John: gh auth login + answer the report's decisions (esp. bug #0), (2) implement + bug #0 as decided + rerun e2e-4, (3) full E2E matrix, (4) re-run /preflight for the bot + gauntlet, (5) John's [HUMAN] tests: item 3 centered dialog, item 10 web up/download + (GOING-LIVE.md 4.3). +- 10 Jul 2026 (PM, master integration) · cloud-collections is now UP TO DATE with + origin/master (c41fcfd2bd) — as a MERGE, not the planned rebase, deliberately: a true + rebase meant replaying 189 commits over a master that already contains cherry-picked + batch commits (e.g. the Common.BloomIsBusy l10n fix is master's tip), and it started + conflicting at commit 4/189 (add/add on files master partially has); cloud-collections + also already has merge-style history (task merges), so linearizing + force-pushing a + shared branch was worse than integrating. The merge itself completed with ZERO conflicts + (the feared overlap files — 2 XLF, CollectionApi.cs, ExternalApi.cs — all auto-merged; + the cherry-picked l10n fix was byte-identical on both sides). A safety branch + `cloud-collections-pre-rebase-2026-07-10` marks the pre-merge state. PNPM: the front-end + (src/BloomBrowserUI, BloomVisualRegressionTests, src/content) is now pnpm 11.5.2 — NEVER + yarn/npm there anymore (root AGENTS.md updated by master); the E2E harness + (src/BloomTests/e2e) deliberately KEEPS its own yarn.lock (unaffected by the migration) + · Next: pnpm install + C# cloud filter on the merged tree (running), push + cloud-collections, then OUTSTANDING BUGS #1 (e2e-4) and the remaining test pipeline. +- 10 Jul 2026 (PM, later) · e2e-4 rerun FAILED with a NEW, fully-diagnosed signature (see + OUTSTANDING BUGS #1 above — fixed shared download staging folder + over-broad locked-book + skip in the new retry pass). Per John's live instruction: pausing the test loop here, + merging the defect-fix branch, then REBASING cloud-collections onto current origin/master + (~62 commits incl. the pnpm migration) before returning to test fixes · Next: merge + task/b1-postbatch-defects (fast-forward) + push, rebase, post-rebase build sanity, then + fix OUTSTANDING BUGS #1 and rerun e2e-4/5/8 + full matrix, then John's [HUMAN] checks. +- 10 Jul 2026 (PM — HANDOFF ENTRY; possibly the last session with this agent for a while; + written for human/agent resumers weeks later) · ALL THREE post-batch defects DIAGNOSED, + FIXED, COMMITTED on `task/b1-postbatch-defects`, unit suites green (cloud filter 418/418), + and E2E-verified per the fail-fast protocol (one failing spec at a time, no full-suite + reruns until each passed). Root causes, for the record: + · DEFECT 1 (books never arrived after join-relaunch; e2e-3/e2e-4): the pullDown→kill→ + relaunch pattern guarantees the in-memory RemoteBookAutoApplyQueue dies with the process; + the relaunch's SyncAtStartup rerouting was the only redelivery path and every failure in + that pipeline was SILENT, while the poll only raises events for books whose repo state + CHANGED — so one miss = book missing forever. Fix (4339e02d60): new + TeamCollection.QueueMissingRepoBooksForBackgroundDownload (queues every unlocked repo + book with no local folder), called from CloudTeamCollection.StartMonitoring (post-sync) + and after every OnPolledChanges — any drop now self-heals within one poll interval; plus + durable SIL logging on all previously-silent paths (incl. ReportProgressAndLog, whose + startup-sync record previously vanished with the collection folder). 4 new unit tests. + VERIFIED: e2e-3 PASSED (was the failing waitForBookFile signature). + · DEFECT 2 (e2e-10 bob-takeover: alive 90s, empty stdout, no window/server): dotnet-stack + dump of the live hung process showed the UI thread blocked in MessageBox.Show inside + TeamCollectionManager's ctor. Chain: an APPROVED-but-never-CLAIMED membership (bob opens + ALICE's local folder — item 9's shared-computer scenario — so bob never ran the join flow, + the only place claim_memberships was called) passes CheckConnection's EMAIL-based + my_collections check, then get_collection_state throws not_a_member (RLS gates are + user_id-based) during the ctor's first sync; the generic catch shows a MODAL MessageBox + no automation can dismiss. Fix (ae35b87c34): CheckConnection now calls ClaimMemberships + (idempotent, once per session) on membership confirm; NonFatalProblem.Report in + --automation mode writes BLOOM_AUTOMATION_NONFATAL_PROBLEM + stack to stdout and returns + instead of blocking (mirrors the RunningInConsoleMode guard) — any future startup report + is a readable harness-log line, never a silent hang. New unit test pins the claim call. + VERIFIED: e2e-10 PASSED end-to-end (refusal + takeover-checkin attribution). + · DEFECT 3 (Cannot Find API Endpoint teamCollection/capabilities toast): the endpoint was + project-level but is legitimately probed with no project open — the E2E harness readiness + poll (proven: a probe landed BEFORE any WebView2 existed in bob's 10:11 log) and late + calls from a closing collection tab while the chooser is up (John's sighting; the item-6 + "chooser bundle hook" hypothesis was DISPROVEN — no chooser component calls it). Fix + (4819eda881): registration moved to the app-level SharingApi (TheOneInstance precedent), + all-false when no project/TC is current. Fallout fix (c24af86042): two harness call + sites that single-shot-asserted supportsSharingUi===true right after a relaunch now use + the same 20s expect.poll as every other site (the app-level endpoint answers + truthfully-false while the project is still opening; the old one-shots only ever passed + because a project-level registration race hid the timing). VERIFIED by the e2e-3/e2e-10 + passes above (both exercise the polled path). + Also noteworthy: the window-placement watcher (7029006d5) is CONFIRMED working now + (windowPlacement.log files written, windows moved to the spare screen); the missing SIL + Log-tmp files for hard-killed instances are expected (SIL Logger doesn't flush on kill) — + stdout via the NonFatalProblem automation line is now the reliable channel · NEXT ACTIONS, + in order: (1) e2e-4 + e2e-5 + e2e-8 singles (running/queued at handoff time — see the next + entry if one was added, else run them first), (2) merge `task/b1-postbatch-defects` into + `cloud-collections` (fast-forward; branch is strictly ahead) + push, (3) FULL E2E MATRIX + (cd src/BloomTests/e2e && yarn test; ~30 min, desktop unlocked, stack up — remember the + functions-serve zombie rule in server/dev/README.md), (4) rebase cloud-collections onto + origin/master — now ~62 commits behind incl. the pnpm migration; only ~4 overlapping files + expected (2 XLF, CollectionApi.cs, ExternalApi.cs); after rebasing, remember the front-end + package manager may switch from yarn to pnpm on the rebased branch — re-read the rebased + AGENTS.md before running front-end commands, (5) post-rebase full matrix, (6) John's + [HUMAN] checks: item 3 centered-dialog visual, item 10 web upload/download + (GOING-LIVE.md 4.3), John's dogfood-plan decision. Open cosmetic item: Administrators + shows registration email (see "Also queued from dogfooding"). +- 10 Jul 2026 (resumed again after VS Code restart) · Verified state: main tree clean on + `task/b1-postbatch-defects` at b0941db62, no worktree WIP. Dev stack was BROKEN at resume: + edge-runtime container missing entirely and no `supabase functions serve` process (several + supabase containers had restarted ~45 min prior) — restarted functions serve with + server/dev/functions.env, endpoint now answering (401 on bare probe = healthy), edge + container up. Relaunched the three-defect diagnosis/fix agent (defects 1–3 from the 10 Jul + AM entry) on the existing branch in the main tree · Next: review + merge that branch, then + rerun e2e-3/4/5/8/10, then full matrix → rebase onto origin/master → post-rebase matrix → + John's visual checks. +- 10 Jul 2026 (resumed after VS Code restart) · Verified resume state: working tree clean + at ff6c5a6f8, no uncommitted worktree work, dev stack healthy (edge-runtime container + restarted ~15 min prior but has its BLOOM_* env — NOT a functions-serve zombie). + Relaunched the three-defect diagnosis/fix agent (defects 1–3 from the 10 Jul AM pause + note) on branch `task/b1-postbatch-defects` in the main tree · Next: review + merge that + branch, then rerun e2e-3/4/5/8/10, then full matrix → rebase onto origin/master → + post-rebase matrix → John's visual checks. +- 10 Jul 2026 (PAUSED again for another restart, John's request) · The three-defect agent + was stopped while still in its read-only diagnosis phase — NO code work or commits lost + (branch `task/b1-postbatch-defects` contains only the two orchestrator log commits; main + tree is checked out on it, clean). The three defect descriptions in the 10 Jul AM entry + remain the full open work list; stack was verified healthy at resume time · Next action: + relaunch the three-defect diagnosis/fix agent on the existing + `task/b1-postbatch-defects` branch (defect descriptions above are self-sufficient), then + the unchanged pipeline: review/merge → e2e-3/4/5/8/10 → full matrix → rebase onto + origin/master → post-rebase matrix → John's visual checks. +- 10 Jul 2026 (AM, PAUSED for VS Code restart) · State: all batch items 1–10 + tier-timing + fix MERGED and pushed (through commit 7029006d5). Post-batch E2E stabilization in + progress — full matrix run 1 was 8/14. Fixed + pushed since: AWSSDK-v4 null S3Objects + second site (DownloadCollectionFileGroup); item-9 sidecar idle-loop that starved the UI + thread (checkin timeouts; watcher-file feedback loop — see commit f451aa865); harness + waitForBookFile for progressive-join; e2e-10 refusal line to stdout + (BLOOM_AUTOMATION_REFUSED_COLLECTION); window watcher NEVER RAN (node detached spawn + kills powershell instantly — fixed non-detached + DPI-aware + spawn-time + placement + logs, commits 80b333c4c/7029006d5). THREE OPEN DEFECTS, diagnosis agent was killed + before starting (no work lost): + (1) background book download silently dropped after join-relaunch — hypothesis: + DownloadMissingBookInBackground's IsBookPresentInRepo pre-check on an unhydrated cache + returns false → silent return → dedupe means never re-queued (e2e-3/e2e-4 failures; no + RemoteBookAutoApplyQueue error lines in SIL logs = silent drop confirmed); + (2) e2e-10 'bob-takeover' relaunch never reaches BLOOM_AUTOMATION_READY (empty stdout; + check SIL Log-tmp*.txt ~10:0x AM Jul 10); + (3) collection chooser triggers 'Cannot Find API Endpoint teamCollection/capabilities' + toast (project-level endpoint called at app level; John saw it on screen; suspect a + hook item 6 pulled into the chooser bundle). + ALSO PENDING: full matrix re-run → rebase onto origin/master (47 commits incl. pnpm + migration; only 4 overlapping files: 2 XLF, CollectionApi.cs, ExternalApi.cs) → + post-rebase matrix → John's visual checks. e2e-5/e2e-8 retest failures were TRANSIENT + infra (podman/db-reset under load; verified clean after). Stack is up; remember the + functions-serve zombie rule (server/dev/README.md) after any supabase stop/start · + Next action: relaunch the three-defect diagnosis/fix agent (its full brief is in the + orchestrator conversation; the three defect descriptions above are self-sufficient), + then rerun e2e-3/4/5/8/10, then the full pipeline above. +- 9 Jul 2026 · Batch plan created; full-matrix baseline run in progress (validates + checkin-comment fix + 5s poll live) · Next: item 1 ("Bloom is busy" l10n) code work + while the matrix runs. +- 9 Jul 2026 (PM) · 4-spec verification queue GREEN 4/4 in 9.5 min (e2e-1, join-auto-open, + e2e-2, e2e-8) on the merged state incl. items 1–6 and 8 — items 1/2 fully DONE, 4+5 E2E + verified, 6 join-flow regression clear. John decisions recorded: safety window 7d; + subscription tier same as folder TCs; AWSSDK bump on this branch (item 10) with [HUMAN] + web up/download check; account-switch spec (item 9); recovery spec (item 8, implemented, + 382/382). Remaining: item 7 (agent next), items 9/10, John's dogfood-plan decision + + visual dialog check · Next: launch item 7 implementation agent. +- 9 Jul 2026 (later) · Baseline matrix 13/13 GREEN (31 min). Items 1–3 code done + + committed (2d74d280f, 6f0c4a068, 207cc1d0); unit suites green (C# 363/363, panel vitest + 11/11). Screen NOW LOCKED (John away): all Bloom-launching verification queued — e2e-1 + (item 1 gate), e2e-2 (items 2+3), plus item 3 visual check. A Debug Bloom (PID 48012, + origin unknown, possibly John's) is running and locks output/Debug — build/test with + `-c Release` until it's gone; do NOT kill it without John · Next: item 4+5 design read + (CloudTeamCollection change-application path), code-only work. +- 9 Jul 2026 (later still) · Item 4+5 CODE DONE on branch `task/b1-45-auto-sync` (created + from cloud-collections; not yet merged): TeamCollection.CanAutoApplyRemoteChanges + + RemoteBookAutoApplyQueue (auto-apply for cloud TCs, folder TCs unchanged); "Receive + Updates" renamed to "Sync" everywhere (dialog + per-book panel) plus a fixed + needsReload/updatesAvailable priority bug for cloud found along the way; XLF renamed + TeamCollection.ReceiveUpdates → TeamCollection.Sync. Diagnosis of the missing + "updates available" badge: it's driven solely by the message log's NewStuff milestone + (TeamCollectionStatus.NewStuff → teamCollection/tcStatus), a SEPARATE signal from + tcStatusMetadata's updatesAvailableCount (which was likely already correct/live) — see + the item's own diagnosis bullet above for detail. Tests: C# required filter 375/375 + green (incl. new RemoteBookAutoApplyQueueTests + TeamCollectionAutoApplyTests); both + touched vitest files green (5/5, 13/13); yarn typecheck/eslint clean. E2E NOT run + (desktop locked; forbidden by this task's rules) — `e2e-2-collaboration-loop` + + `e2e-8-receive-during-send` + e2e-1 (XLF gate) queued alongside items 1–3's pending + runs · Next: orchestrator review + merge of task/b1-45-auto-sync into cloud-collections, + then the queued full E2E pass covering items 1–5, then item 6 (join-card integration). +- 9 Jul 2026 (even later) · Item 6 CODE DONE on branch `task/b1-6-join-cards` (created from + cloud-collections; not yet merged): removed MyCloudCollectionsSection.tsx (+ test); + CollectionChooserApi gains `collections/getJoinCards` (SharingApi.GetMyCollectionsForJoinCards + for the signed-in check + cloud list, no network call when signed out; ComputeJoinCards is the + pure id-matching helper, internal static, unit-tested in new CollectionChooserApiTests.cs; + GetLocalCloudCollectionIds scans MRU + discovered local folders' TeamCollectionLink.txt files + the same way CloudJoinFlow.DetermineScenario does, but across ALL known folders rather than one + expected name, since a join card is about "has ANY local copy", not "would this name collide"). + CollectionCard grows an isJoinCard variant (title + TC icon + reused "Get" cue only, no per-card + fetch, no Show-in-Explorer menu); CollectionCardList appends joinCollections AFTER its + maxCardCount(10) slice so they're never capped. CollectionChooser.test.tsx rewritten for the + card-based flow; new CollectionCardList.test.tsx covers the append-after-slice logic; new + CollectionCardList.stories.tsx "WithJoinCards" story. Removed 4 now-orphaned untranslated XLF + entries from the deleted sidebar (kept + repurposed CollectionChooser.PullDown, "Get", as the + join cue). C# required filter 380/380 green; CollectionCardList.test.tsx 4/4 and + CollectionChooser.test.tsx 3/3 green; yarn typecheck and eslint (changed files) clean. E2E NOT + run (desktop locked; forbidden by this task's rules) — `join-auto-open` (checked: drives + pullDown/openCollection directly via HTTP, doesn't touch the chooser UI, should be unaffected) + + `e2e-1-create-share` (XLF gate) queued · Next: orchestrator review + merge of + task/b1-6-join-cards into cloud-collections, then item 7 (progressive join) once the queued + E2E pass covering items 1–6 runs. +- 9 Jul 2026 (agent) · Item 7 (progressive join) CODE DONE on branch + `task/b1-7-progressive-join` (created from origin/cloud-collections; not yet merged): + CloudJoinFlow no longer blocks on CopyAllBooksFromRepoToLocalFolder -- every repo book is + queued via the new TeamCollection.QueueBookForBackgroundDownload right after settings download, + so the collection opens immediately. CollectionApi.HandleBooksRequest merges the cloud repo book + list into the collections/books JSON (new BookListEntry DTO + pure + ComputeNotYetDownloadedBookEntries, unit-tested) so repo-only books show `notYetDownloaded: + true`; BookButton.tsx renders those as a simple placeholder (dashed border, cloud-download icon, + title, no thumbnail request, no context menu -- SAFETY: no dangerous action reachable). + RemoteBookAutoApplyQueue gained EnqueueFront (priority, never interrupts an in-flight download); + selecting a placeholder (CollectionApi's selected-book handler) gracefully bumps its download to + the queue front instead of crashing on the missing BookInfo. Each background download + (TeamCollection.DownloadMissingBookInBackground, the queue worker's new no-local-folder branch) + invalidates the cached book list and re-sends the existing editableCollectionList/reload + websocket event so the placeholder swaps for the real button automatically. SyncAtStartup's + "brand new book!" branch now reroutes to the same background queue for cloud + (CanAutoApplyRemoteChanges) instead of fetching synchronously, so a half-joined collection's + next open stays fast (folder TCs completely unaffected, pinned by a new regression test). + DEVIATION flagged for John/orchestrator: the "downloading" status indicator is a persistent + placeholder icon on the book button itself, not routed through the real BookSelection/preview + pane and TeamCollectionBookStatusPanel.tsx's StatusPanelState union as the scout notes' + exact seam suggested -- judged too risky (no real Book/local folder exists yet to fake a + selection with) for the value added; the functionally important part (priority bump) IS + implemented and tested. New XLF string CollectionTab.BookNotYetDownloaded added to Bloom.xlf, + provisional/translate="no", flagged for John's priority-file confirmation. Tests: C# required + filter 393/393 green (15 new across RemoteBookAutoApplyQueueTests, TeamCollectionAutoApplyTests, + and new CollectionApiTests.cs); CloudSyncAtStartupTests.SyncAtStartup_NewBookOnlyInRepo_IsFetchedToLocal + updated per this item's own instruction (queue now made synchronous for the test, assertion + unchanged, reasoning documented inline); new BookButton.test.tsx 5/5 green. yarn typecheck/eslint + show no NEW issues (verified via git-stash before/after diff against this codebase's large + pre-existing unrelated typecheck-error baseline). E2E NOT run (this task's hard rules forbid + launching Bloom/e2e) — `join-auto-open` + `e2e-9-new-book-lifecycle` queued for the orchestrator + · Next: orchestrator review + merge of task/b1-7-progressive-join into cloud-collections, then + the queued E2E pass, then items 9/10. +- 9 Jul 2026 (agent) · Item 10 (AWSSDK bump) CODE DONE + SUITES GREEN on branch + `task/b1-10-awssdk-bump` (created from origin/cloud-collections; not yet merged): AWSSDK.Core + 3.5.1.32 -> 4.0.100.3 and AWSSDK.S3 3.5.3.10 -> 4.0.100.3 in BloomExe.csproj (major v4); + parity-check tool floats 3.* -> 4.*; no other project pins the family, AWSSDK.SecurityToken is + not referenced anywhere, no transitive SIL pin conflicts. v4 adjustments: + RequestChecksumCalculation/ResponseChecksumValidation=WHEN_REQUIRED on the two MinIO-facing + client builders (CloudBookTransfer, CloudTeamCollection) — v4's WHEN_SUPPORTED default sends + CRC32/CRC64 trailing checksums S3-compatible endpoints may reject; BloomS3Client (real AWS) + keeps v4 defaults. Null-collection/bool? fixes in S3Extensions.ListAllObjects + + BloomS3ClientTests; removed 2 orphaned usings (LitJson embedded in v3 Core, gone in v4). + Baseline full BloomTests on UNMODIFIED cloud-collections FIRST: 3036/0/13 (3049 total); + post-bump: cloud filter 387/387, full suite 3036/0/13 — identical, zero regressions; + S3-specific fixtures 44/44 incl. the LIVE DownloadBook_DoesNotExist_Throws against real AWS. + E2E NOT run (orchestrator's job): e2e-1 + e2e-2 through MinIO queued — watch for checksum + (should be silent now), path-style, and AuthenticationRegion behavior; then John's [HUMAN] + web up/download check (GOING-LIVE.md 4.3) · Next: orchestrator review + merge of + task/b1-10-awssdk-bump, then the queued E2E pass. +- 9 Jul 2026 (agent) · Item 9 (account-switch behavior) CODE DONE on branch + `task/b1-9-account-switch` (created from origin/cloud-collections; not yet merged): refusal + path — TeamCollectionManager.CheckConnection(allowHardRefusal) (default false, only the + constructor's initial open-time call passes true) throws the new + TeamCollectionAccessRefusedException when CloudTeamCollection.CheckConnection's non-member + branch sets the new TeamCollectionMessage.IsAccessRefusal flag; Program.HandleErrorOpeningProjectWindow + special-cases that exception (plain message box, no crash-report flow) before falling through + to the existing chooser-reopen path. The refusal message composes admin email(s) (read from + the local .bloomCollection's Administrators field — flagged risk: this inherits the + already-tracked "Administrators shows registration email not signed-in email" bug from the + "Also queued from dogfooding" list, since that fix was out of this item's scope) and "last + known team member on this machine" from a NEW durable local record, + TeamCollectionLastKnownUser.txt (sidecar file next to TeamCollectionLink.txt; chosen over + extending TeamCollectionLink.txt's tightly-scoped tested format; written at join time + (CloudJoinFlow) and refreshed on every successful membership confirmation + (CloudTeamCollection.CheckConnection), so it doubles as "who joined" and "last confirmed + local user" — documented as an approximation, not literally "last edited"). Takeover path — + new virtual seams on TeamCollection (IsEditableHere/CanTakeOverLockOnThisMachine/ + TryTakeOverLock, all no-op/strict by default so folder TCs are unaffected) let + CloudTeamCollection treat a book locked to a DIFFERENT account on THIS machine as editable + and checkin-able; new additive RPC tc.checkout_book_takeover (migration + 20260709000007_tc_checkout_takeover.sql) atomically reassigns the lock, called from + PutBookInRepo just before check-in (no per-keystroke "edit happened" hook exists anywhere in + this codebase, confirmed by research, so "on first edit" == "on first check-in of that edit") + and from AttemptLock (explicit checkout click, likely unreachable in the UI here but kept for + symmetry). checkin_start_tx/checkin_finish_tx are UNTOUCHED — purely additive, so no existing + RPC's contract changed. CONTRACTS.md addition flagged, NOT applied (orchestrator decision per + this task's rules): a `checkout_book_takeover(book_id, machine) -> {success, locked_by, + locked_by_machine, locked_at}` row alongside checkout_book/unlock_book/force_unlock. Tests: 55 + pgTAP (42 existing + 13 new in 02_tc_checkout_takeover_test.sql, actually run against the + local dev stack — same-machine takeover, cross-machine rejection, no-op re-takeover, + non-member rejection all green); C# required filter (Cloud|TeamCollection|SharingApi) 406/406 + green (17 new: 5 ComposeNotAMemberRefusalDetail + 2 CheckConnection refusal/last-known-user in + CloudTeamCollectionMemberTests.cs, 9 in new CloudAccountSwitchTakeoverTests.cs, 3 in new + TeamCollectionAccountSwitchRefusalTests.cs). One new XLF string, + TeamCollection.Cloud.NotAMemberRefusal, added to Bloom.xlf (translate="no"), FLAGGED + PROVISIONAL for John's priority-file confirmation — it's shown in a plain MessageBox, arguably + more user-facing than most existing unlocalized TC internal strings, so may deserve a + different priority file or eventual real translation. New (non-run) E2E spec + `e2e-10-account-switch.spec.ts` written for the orchestrator's next pass, replacing the old + blocked task-09 scenario of the same number (different shape now — open-time refuse/takeover, + not in-session block-with-choices); flags that the refusal MessageBox is a native Win32 dialog + invisible to CDP entirely, so the spec verifies it via the instance's own log file instead. + Known omissions/risks for the orchestrator: (1) the Administrators-email identity bug noted + above; (2) no automated test exercises PutBookInRepo's pre-checkin takeover call end-to-end + (would need a full book-folder + checkin-start/finish edge-function mock harness) — covered + indirectly by direct unit tests of the virtual seams plus the new E2E spec; (3) TestFolderTeamCollection's + own takeover behavior was not separately tested since CanTakeOverLockOnThisMachine's folder + default is `false` (unchanged behavior, no new folder-TC surface to test) · Next: orchestrator + review + merge of task/b1-9-account-switch, then the queued E2E pass including e2e-10. +- 10 Jul 2026 (agent) · Tier-timing fix ("Also queued from dogfooding") CODE + TESTS DONE on + branch `task/b1-tier-timing` (created from origin/cloud-collections; not yet merged): diagnosis + — `TeamCollectionManager.CheckDisablingTeamCollections` (TeamCollectionManager.cs ~782) gates + solely on `CurrentCollection == null`; for a cloud TC that's set (TeamCollectionManager.cs ~364) + BEFORE the connect-and-sync sequence (~374-391) that is the only thing able to deliver a fresh, + repo-authoritative SubscriptionCode into `Settings.Subscription` — an in-memory CollectionSettings + snapshot captured once at ProjectContext startup and never reloaded mid-session. That sequence's + success depends on cloud sign-in readiness (`CloudTeamCollection.CheckConnection` short-circuits + on `!_auth.IsSignedIn`) and an S3 download that silently swallows exceptions + (`CloudTeamCollection.DownloadCollectionFileGroup`'s catch-and-report-only handler) rather than + propagating failure — so a cloud TC's subscription snapshot can still be stale/blank when the + check runs, permanently disconnecting a healthy collection for the session (matches the E2E-9 + harness's observed ~1-in-40 misfire, tasks/09-e2e.md). Fix: `WorkspaceModel.HandleTeamStuffBeforeGetBookCollections` + now defers the check for cloud TCs to run inside `SynchronizeRepoAndLocal`'s `whenDone` callback + (after sync), and `TeamCollectionManager.GetSubscriptionForDisablingCheck` (new) re-reads the + SubscriptionCode fresh from the on-disk `.bloomCollection` file for a cloud TC instead of + trusting the in-memory snapshot; folder TCs (and the no-TC case) keep the original immediate + check, byte-identical. `TeamCollectionManager.CheckDisablingTeamCollections` and + `TeamCollection.SynchronizeRepoAndLocal` marked `virtual` (previously plain `public void`) purely + so test subclasses can observe call order/behavior without invoking a real progress dialog. New + tests: `TeamCollectionTierTimingTests` (misfire no longer disables; genuinely insufficient tier + still disables for cloud via fresh disk read; non-cloud path unaffected, in both directions) and + `WorkspaceModelTierTimingOrderingTests` (folder TC still checks-then-syncs; cloud TC now + syncs-then-checks) — 7 new tests, all green. Full required filter + `(~Cloud|~TeamCollection|~SharingApi)&!~LiveTests`: 413/413 (406 baseline + 7 new), zero + regressions. Risk for the orchestrator's E2E pass: the harness's `createScratchCollection` + (collectionFixture.ts) stamps a fake valid subscription code onto every scratch collection as a + workaround for this exact bug — with the fix merged, that workaround is likely safe to REMOVE + (or at least no longer load-bearing), but flagged for the orchestrator to verify live before + touching the harness, since removing it now means every E2E cloud-TC scenario exercises the real + timing path for the first time · Next: orchestrator review + merge of task/b1-tier-timing. + +--- + +**2026-07-16 — E7 done (per-poll rename-scan is now O(missing + local), not O(missing × local)).** +`QueueMissingRepoBooksForBackgroundDownload` runs unconditionally at the end of every poll's +`OnPolledChanges` (60s + on-activation/reconnect) and calls `NewBookRenamedFrom(bookName)` for +each repo book that has no local folder. The cloud override enumerated *every* local book folder +(FileInfo-stat per meta.json, cached) to find the one sharing the repo book's instance id — so +during a progressive join of a large collection it re-enumerated the whole local folder set once +per still-missing book, every 60s. Network cost was already gone (E1's instance-id cache); this +was the remaining per-poll disk cost. Fix per John's direction ("first time a poll needs the map +from instanceID→localFolder, build it, then use it"): added a bulk-scan overload +`NewBookRenamedFrom(string, ref object scanState)` (base delegates to the per-book method; +[TeamCollection.cs](../../../src/BloomExe/TeamCollection/TeamCollection.cs)). The pass owns +`scanState` as a **local**, so the index is built lazily on the first missing book, reused for the +rest of that poll, rebuilt fresh next poll (never stale across polls), and confined to the polling +thread (no lock, no race with the worker thread's one-off `NewBookRenamedFrom` calls). Cloud +override builds `instanceId→folderName` once via `BuildLocalInstanceIdToFolder()` (per-folder id +reads hit the timestamp/size-validated cache; first-enumerated wins on the pathological dup-id +case, matching the old first-match loop). Steady state (no missing books) does zero enumeration — +the overload is never called. One-off callers (`DownloadMissingBookInBackground`, sync's +rename pass) keep the simple per-book path. Verified: clean build, TeamCollection filter 436/436 +(includes the teammate-rename regression tests that exercise this exact path). Note: while tracing +this I did **not** find an empty-poll early-return I'd associated with a batch-2 item ("E3") in +`OnPolledChanges` — it still does `ApplyDelta`/`Save`/self-healing unconditionally each poll. +Flagged for a separate look; independent of E7. + +--- + +**2026-07-16 — E3 done (skip the per-poll cache write on idle polls).** Resolves the flag in the +E7 entry above: the batch-2 "OnPolledChanges early-return" item had never actually landed — no +commit, and `OnPolledChanges` still did `ApplyDelta` + full `_cache.Save()` (whole repo-cache JSON +to disk) + `RefreshIndexFromCache()` unconditionally on every 60s poll, even when the poll carried +no changes. Done the safe way per John: `CloudRepoCache.ApplyDelta` now returns whether it actually +mutated state (a book row upserted OR the cursor advanced); `OnPolledChanges` skips Save + index +rebuild + the book-event pass when it returns false. Crucially the two things that legitimately +must run on a no-change poll still run unconditionally: the **group-file** check +(`RaiseRepoCollectionFilesChanged`, since `ApplyDelta` only inspects books+cursor, never groups) +and the **self-healing** `QueueMissingRepoBooksForBackgroundDownload` pass (retries still-missing +downloads during a join, when polls are legitimately empty — the exact scenario E7 concerns). The +`previousBooksById` diff snapshot is now taken only when the poll carried book rows. Extracted the +book-event loop into `RaiseBookEventsForPolledChanges` to keep the fast path readable. New unit +test `ApplyDelta_ReportsWhetherAnythingChanged` pins the true/false contract E3 rides on. Verified: +clean build, TeamCollection 436/436, CloudRepoCache 17/17 (16 + 1 new). + +--- + +**2026-07-16 — R5 done (shared C# cloud test-fixture builder).** Every CloudTeamCollection unit +fixture hand-rolled the same ~15-line setup block: temp folder + mock ITeamCollectionManager + +CloudEnvironment(test anon key) + CloudAuth(stub provider, in-memory token store) + signed-in + +CloudCollectionClient wired to a FakeRestExecutor + CloudTeamCollection with a mock-S3 +CloudBookTransfer, and an identical TearDown (dispose folder + reset ForceCurrentUserForTests). +New `CloudTestHarness.Create(folderName, collectionId, currentUser=, signIn=, s3Factory=)` returns +the pieces individually (Collection/Executor/Auth/MockTcManager/CollectionFolder) so each fixture +stays explicit about what it drives and can still script the executor / inject a custom S3 mock; +`Dispose()` is IDisposable and undoes the two process-global seams. Converted 5 fixtures +(Lock, Member [signIn:false — it tests the signed-out path], IdentityFirst, AccountSwitch +[currentUser:bob@dev.local], SyncAtStartup [s3Factory: scripted S3]); each keeps its own fields as +aliases so no test body changed. Deliberately NOT applied to CloudCollectionClientTests / +CloudCollectionMonitorTests (they test client/monitor directly, the former with a custom auth +provider, and never build a whole collection) nor to SyncAtStartup's MakeCollectionWithPerKeyS3 +(builds a *second* collection reusing the fixture's folder — folding it in would bloat the helper +or change behavior; exactly the over-consolidation to avoid). Net -123 dup lines across fixtures +for +91 shared. Verified: TeamCollection|Cloud filter 438/438, no new warnings. + +--- + +**2026-07-16 — R6 + R13 done (front-end hook reuse).** Two duplicated front-end idioms in the +cloud-TC TS, both extracted into utils/bloomApi.ts: +- **R13 (cached-promise dedup):** sharingApi.ts (`enabledExperimentalFeaturesPromise` / + getEnabledExperimentalFeaturesOnce) and teamCollectionApi.tsx (`capabilitiesPromise` / + getTeamCollectionCapabilitiesOnce) hand-rolled the identical "fetch at most once per page load, + cache the promise, expose a test-only reset" pattern (needed because per-book components would + otherwise fire hundreds of identical requests). New `getApiDataOnce(urlSuffix, mapResult)` keyed + by a Map, plus `resetApiDataOnceCacheForTests()` (clears the whole map). The two module-specific + reset exports remain (vitest.setup.ts calls them by name) but now delegate to the shared clear. +- **R6 (reload-capable watch hook):** useSharingMembers hand-rolled the generation-counter + + websocket-refetch + manual-reload + skip-until-param-known idiom that useWatchApiData almost + covers. New additive `useWatchApiDataWithReload(urlSuffix?, default, ctx, event)` returns + `{data, reload}` and skips fetching while urlSuffix is undefined; useSharingMembers is now a + 4-line wrapper over it. Left useWatchApiData (used by ~12 files) untouched, and left + useMyCloudCollections alone (its `loading` flag doesn't fit the watch/reload shape -- forcing it + would be over-consolidation). +Verified: eslint 0 errors / 0 new warnings on the 3 files; typecheck clean for them; 7 vitest +consumer files (SettingsPanel, Dialog, BookStatusPanel, ShareButton, CollectionHistoryTable, +BookButton, CollectionChooser) 38/38 green. No `pnpm build` run (the CodeReview worktree's Vite on +:5173 is a different tree and was left alone). diff --git a/Design/CloudTeamCollections/orchestration/RESUME.md b/Design/CloudTeamCollections/orchestration/RESUME.md new file mode 100644 index 000000000000..e5fd8abd2079 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/RESUME.md @@ -0,0 +1,48 @@ +# Cloud TC — orchestration rules & resume protocol + +> **Current state lives in [DOGFOOD-BATCH-1.md](DOGFOOD-BATCH-1.md)** (its progress log's +> newest entry is the resume point). This file keeps the general working rules that batch +> doc and IMPLEMENTATION.md refer to. +> +> History note (15 Jul 2026): this folder originally also held the per-task agent launch +> prompts (`-.prompt.md`) used to build the feature in waves. All tasks are long +> merged, so those scratch prompts were removed; the durable per-task specs and findings +> remain in `../tasks/*.md`, and the wave-by-wave merge log is in `../IMPLEMENTATION.md`. + +## The durable-state rule + +All work state lives in **git**, never only in a conversation: + +- Commit after EVERY completed step — small, coherent commits; tick the step's checkbox / + update the item's `Status:` line in the same commit. +- The state doc (currently DOGFOOD-BATCH-1.md) ends with a `## Progress log`; every work + session appends: `date · what was just completed · EXACT next action`. A resumer starts + by reading the newest entry. +- A step half-done at interruption is redone from its last commit; uncommitted work found + in a leftover worktree is secured as a WIP commit first. + +## Working rules (still operative) + +- Review before merge is MANDATORY (independently re-run the tests; see the merge log in + IMPLEMENTATION.md for the kinds of bugs review has caught: SQL type bug, bad bcrypt + hash, fake-session-token spec error, ungated UI section, JSON-null claimed bug). +- C# test filter for cloud work MUST be + `"FullyQualifiedName~Cloud|FullyQualifiedName~TeamCollection|FullyQualifiedName~SharingApi"` + (exclude `~LiveTests` unless the stack is up): SharingApiTests live under + web.controllers and match NEITHER ~Cloud NOR ~TeamCollection — that gap let a real bug + merge with "all green" claims (7 Jul). +- The local dev stack must be up for server/C#-integration verification: `supabase start` + + `docker-compose -f server/dev/docker-compose.yml up -d` (see server/dev/README.md; + MinIO must be on the supabase network — the compose file handles this). +- Known environment quirks: pre-commit hook fails in worktrees (prettier manually + + `--no-verify`, orchestrator re-verifies); Bloom.exe often running → apphost copy error + MSB3027 is benign if test DLLs are fresh (or use `build/agent-dotnet.sh`, which builds + into a private tree and avoids the lock entirely); edge-runtime containers must reach + MinIO as `bloom-minio:9000`, never `host.containers.internal` (hangs under Podman). + +## How to resume (human instructions) + +Start a fresh Claude Code session in this repo and say: **"Resume the dogfood batch per +Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md."** The resumer reads that +file's newest progress-log entry and item Status lines, secures any uncommitted work, and +continues with the stated next action. diff --git a/Design/CloudTeamCollections/orchestration/SQUASH-PLAN.md b/Design/CloudTeamCollections/orchestration/SQUASH-PLAN.md new file mode 100644 index 000000000000..a154080177b9 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/SQUASH-PLAN.md @@ -0,0 +1,120 @@ +# Squash plan: review-grained history for the Cloud TC feature + +> **EXECUTED 11 Jul 2026** — `cloud-tc-for-review` built (9 commits on origin/master), +> byte-identity verified (empty diff vs cloud-collections), pushed; draft **PR #8052** +> opened; #8048 closed with a pointer. One wrinkle worth knowing for regeneration: the +> pre-commit formatter diffs against origin/master on the packaging branch, so it exposed +> (and fixed) prettier drift in 14 edge-function files that had bypassed it on +> cloud-collections — converged by applying the formatted versions back to +> cloud-collections (e782978f9d) BEFORE the final identity check. Regenerating after +> cloud-collections advances: delete the branch, rerun the groups below, force-push. + +Goal (John, 10 Jul 2026): a new branch from current `origin/master` whose commits are +meaningful, human-reviewable steps — replacing `cloud-collections`' ~204 orchestration-grained +commits (126 first-parent) for review/merge purposes. The working branch `cloud-collections` +stays untouched; the squashed branch is a **regenerable packaging artifact**. + +> **Scripts (15 Jul 2026):** the regeneration is now fully scripted, right next to this file — +> `regen-bucket.sh` (assigns the `origin/master..cloud-collections` diff to the 9 groups by +> path pattern; **must report 0 unmatched** — extend its patterns when new files join the +> branch) and `regen-rebuild.sh` (stages + commits each group with `--no-verify`, safe because +> the resulting tree is byte-identical to `cloud-collections`, which already passed every hook, +> then verifies that identity). Usage: +> ```bash +> git checkout -B cloud-tc-for-review origin/master +> git diff --name-only origin/master cloud-collections > /tmp/allfiles.txt +> Design/CloudTeamCollections/orchestration/regen-bucket.sh /tmp/allfiles.txt /tmp/groups +> Design/CloudTeamCollections/orchestration/regen-rebuild.sh /tmp/groups +> git push origin cloud-tc-for-review --force-with-lease +> ``` +> Precondition unchanged: `cloud-collections` must already contain `origin/master` (merge it +> first), or the byte-identity check fails. + +## Method: path-staged rebuild (recommended) + +Do NOT interactive-rebase 126+ commits (it conflicts immediately — master already contains +cherry-picked batch commits, and the branch has merge-style history). Instead, rebuild the +final tree in dependency-ordered file groups: + +```bash +git fetch origin +git checkout -b cloud-tc-for-review origin/master +# For each group below, in order: +# git checkout cloud-collections -- (adds/modifies) +# git rm -q (see Deletions note) +# git commit (message per group, below) +# Then VERIFY (all three must hold): +git diff cloud-tc-for-review cloud-collections --stat # MUST be empty +dotnet test src/BloomTests/BloomTests.csproj -c Release --filter "(FullyQualifiedName~Cloud|FullyQualifiedName~TeamCollection|FullyQualifiedName~SharingApi)&FullyQualifiedName!~LiveTests" +cd src/BloomBrowserUI && pnpm lint && pnpm vitest run # (or the targeted cloud files) +``` + +Properties: byte-identical end state (verified by the empty diff), zero conflict resolution, +zero history surgery, re-runnable any time `cloud-collections` advances (delete + regenerate + +force-push the packaging branch — it carries no one's work). + +Caveat for reviewers (put in the PR description): each commit is a coherent reviewable unit +and the ORDER makes most of them compile, but only the FINAL tree is test-verified. That is +the accepted trade-off; per-commit CI-green is not a goal. + +Deletions note: files the feature DELETED relative to master must be `git rm`'d in their +group. Enumerate with `git diff --name-status origin/master...cloud-collections | grep '^D'` +(currently expected: none or near-none; MyCloudCollectionsSection.tsx etc. were added AND +deleted within the branch so they never existed on master). + +## The groups (dependency-ordered; ~9 commits) + +1. **Design docs & plans** — `Design/CloudTeamCollections/**` (34 files), + `.github/skills/xlf-strings` tweak. "Read this first" context: architecture, + CONTRACTS.md, GOING-LIVE.md, orchestration records incl. the dogfood batch log. + Msg: `Cloud Team Collections: design docs, wire contracts, and project records` +2. **Server: schema, RLS, RPCs, pgTAP** — `supabase/migrations/**` (7), `supabase/tests/**`, + `supabase/config.toml`, `supabase/snippets/**`, `supabase/.gitignore`. + Msg: `Cloud TC server: tc schema, RLS policies, RPCs, and pgTAP tests` +3. **Server: edge functions** — `supabase/functions/**` (21). + Msg: `Cloud TC server: edge functions for checkin/download/collection-file transactions` +4. **Local dev stack** — `server/**` (16: MinIO compose, seed users, functions env, + parity-check console, README). + Msg: `Cloud TC dev stack: local Supabase + MinIO, seed users, S3 parity checks` +5. **Client core** — `src/BloomExe/TeamCollection/Cloud/{CloudEnvironment,CloudAuth, + CloudCollectionClient,CloudRepoCache,CloudBookTransfer,...}.cs`, `S3Extensions`, + `BloomExe.csproj` (AWSSDK v4 bump) + matching `src/BloomTests/TeamCollection/Cloud/` + unit-test files for these classes. + Msg: `Cloud TC client core: auth, API client, repo cache, S3 transfer (AWSSDK v4)` +6. **Cloud TeamCollection backend** — `CloudTeamCollection.cs`, `CloudCollectionMonitor.cs`, + `CloudJoinFlow.cs`, `RemoteBookAutoApplyQueue.cs`, `TeamCollection*.cs` seams, + `TeamCollectionManager.cs`, `TeamCollectionLink/LastKnownUser`, `Program.cs` (refusal + path), `NonFatalProblem.cs` (automation guard), `DisconnectedTeamCollection.cs` + their + tests (TeamCollectionAutoApplyTests, CloudSyncAtStartupTests, CloudAccountSwitch*, …). + Msg: `Cloud TC backend: cache-backed TeamCollection, polling monitor, join flow, + background downloads, account-switch handling` +7. **HTTP API layer** — `SharingApi.cs`, `TeamCollectionApi.cs`, `CollectionChooserApi.cs`, + `CollectionApi.cs`, `ExternalApi.cs`, `WorkspaceApi/Model` bits + their tests. + Msg: `Cloud TC API: sharing/membership endpoints, capabilities, join cards, book-list merge` +8. **Front-end UI + strings** — `src/BloomBrowserUI/**` (44), `DistFiles/localization/en/**`. + Msg: `Cloud TC UI: sign-in, sharing dialog, status panel, join cards, download placeholders` +9. **E2E harness + specs** — `src/BloomTests/e2e/**`. + Msg: `Cloud TC E2E: Playwright-over-CDP harness and 10 two-instance scenarios` + +Bucketing rule: run `git diff --name-only origin/master...cloud-collections`, assign every +file to exactly one group (a file with mixed concerns goes to its PRIMARY group — e.g. +TeamCollection.cs → group 6 even though item-8 recovery touched it); after group 9, stage +**everything still unassigned** into the best-fitting group before its commit — the empty +final diff is the proof nothing was dropped. + +## Alternative: single squash (fallback) + +`git checkout -b cloud-tc-for-review origin/master && git merge --squash cloud-collections +&& git commit` — one giant commit, 235 files. Only if reviewers prefer one unit; the grouped +version costs ~30 min more and is far more reviewable. + +## Coordination + +- **When:** after bug #0 (takeover semantics) is decided+fixed and the full matrix verdict is + in — packaging before that just means regenerating. Regeneration is cheap by design. +- **PRs:** open a NEW draft PR from `cloud-tc-for-review`; close #8048 with a comment pointing + at it (bots then review the meaningful commits). `cloud-collections` remains the working + branch until merge; regenerate the packaging branch (delete, rebuild, force-push) whenever + the working branch advances. +- **Merge:** master ultimately merges `cloud-tc-for-review`; verify once more that its tree + equals `cloud-collections`' before merging, then archive the working branch. diff --git a/Design/CloudTeamCollections/orchestration/regen-bucket.sh b/Design/CloudTeamCollections/orchestration/regen-bucket.sh new file mode 100644 index 000000000000..88c6f4686354 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/regen-bucket.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Bucket the origin/master...cloud-collections diff into the SQUASH-PLAN groups. +# Usage: bucket.sh -> writes g1.txt..g9.txt + unmatched.txt +ALL="$1"; OUT="$2" +mkdir -p "$OUT" +for i in 1 2 3 4 5 6 7 8 9; do : > "$OUT/g$i.txt"; done +: > "$OUT/unmatched.txt" +while IFS= read -r f; do + case "$f" in + Design/CloudTeamCollections/*|Design/CloudTeamCollections.md|.github/skills/xlf-strings/*|AGENTS.md) echo "$f" >> "$OUT/g1.txt" ;; + supabase/migrations/*|supabase/tests/*|supabase/config.toml|supabase/snippets/*|supabase/.gitignore) echo "$f" >> "$OUT/g2.txt" ;; + supabase/functions/*) echo "$f" >> "$OUT/g3.txt" ;; + server/*) echo "$f" >> "$OUT/g4.txt" ;; + # Group 5: client core classes + their dedicated unit tests + csproj (AWSSDK bump) + src/BloomExe/TeamCollection/Cloud/CloudEnvironment.cs|\ + src/BloomExe/TeamCollection/Cloud/CloudAuth*.cs|\ + src/BloomExe/TeamCollection/Cloud/*AuthProvider*.cs|\ + src/BloomExe/TeamCollection/Cloud/CloudTokenStore*.cs|\ + src/BloomExe/TeamCollection/Cloud/CloudCollectionClient.cs|\ + src/BloomExe/TeamCollection/Cloud/CloudRepoCache.cs|\ + src/BloomExe/TeamCollection/Cloud/CloudBookTransfer.cs|\ + src/BloomExe/TeamCollection/Cloud/BookVersionManifest.cs|\ + src/BloomExe/TeamCollection/Cloud/S3*.cs|\ + src/BloomExe/WebLibraryIntegration/BloomS3Client.cs|\ + src/BloomExe/WebLibraryIntegration/S3Extensions.cs|\ + src/BloomTests/WebLibraryIntegration/BloomS3ClientTests.cs|\ + src/BloomExe/BloomExe.csproj|\ + src/BloomTests/TeamCollection/Cloud/CloudEnvironmentTests.cs|\ + src/BloomTests/TeamCollection/Cloud/CloudAuthTests.cs|\ + src/BloomTests/TeamCollection/Cloud/FirebaseCloudAuthProviderTests.cs|\ + src/BloomTests/TeamCollection/Cloud/CloudTokenStoreTests.cs|\ + src/BloomTests/TeamCollection/Cloud/CloudCollectionClientTests.cs|\ + src/BloomTests/TeamCollection/Cloud/CloudRepoCacheTests.cs|\ + src/BloomTests/TeamCollection/Cloud/CloudBookTransferTests.cs|\ + src/BloomTests/TeamCollection/Cloud/BookVersionManifestTests.cs) echo "$f" >> "$OUT/g5.txt" ;; + # Group 7: HTTP API layer + their tests (before group 6's broader TeamCollection match) + src/BloomExe/web/controllers/SharingApi.cs|\ + src/BloomExe/web/controllers/CollectionChooserApi.cs|\ + src/BloomExe/web/controllers/CollectionApi.cs|\ + src/BloomExe/web/controllers/ExternalApi.cs|\ + src/BloomExe/web/controllers/ProblemReportApi.cs|\ + src/BloomExe/web/controllers/CollectionSettingsApi.cs|\ + src/BloomExe/web/controllers/FileIOApi.cs|\ + src/BloomExe/web/ReadersApi.cs|\ + src/BloomExe/TeamCollection/TeamCollectionApi.cs|\ + src/BloomExe/Workspace/*|\ + src/BloomTests/web/controllers/*|\ + src/BloomTests/TeamCollection/TeamCollectionApiCloudTests.cs|\ + src/BloomTests/TeamCollection/WorkspaceModelTierTimingOrderingTests.cs) echo "$f" >> "$OUT/g7.txt" ;; + # Group 9: E2E harness + src/BloomTests/e2e/*) echo "$f" >> "$OUT/g9.txt" ;; + # Group 6: everything else in the TeamCollection backend + app-level automation guards + src/BloomExe/TeamCollection/*|\ + src/BloomExe/Program.cs|\ + src/BloomExe/NonFatalProblem.cs|\ + src/BloomExe/MiscUI/BrowserProgressDialog.cs|\ + src/BloomExe/ErrorReporter/HtmlErrorReporter.cs|\ + src/BloomExe/Shell.cs|\ + src/BloomExe/web/BloomServer.cs|\ + src/BloomExe/ApplicationContainer.cs|\ + src/BloomExe/Collection/CollectionSettingsDialog.cs|\ + src/BloomExe/ExperimentalFeatures.cs|\ + src/BloomExe/History/HistoryEvent.cs|\ + src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs|\ + run.sh|\ + scripts/*|\ + src/BloomTests/TeamCollection/*|\ + src/BloomTests/ProgramTests.cs|\ + src/BloomTests/ExperimentalFeaturesTests.cs|\ + src/BloomTests/ShellTests.cs) echo "$f" >> "$OUT/g6.txt" ;; + # Group 8: front-end + strings + src/BloomBrowserUI/*|DistFiles/localization/en/*) echo "$f" >> "$OUT/g8.txt" ;; + *) echo "$f" >> "$OUT/unmatched.txt" ;; + esac +done < "$ALL" +for i in 1 2 3 4 5 6 7 8 9; do printf "g%s: %s\n" "$i" "$(wc -l < "$OUT/g$i.txt")"; done +printf "unmatched: %s\n" "$(wc -l < "$OUT/unmatched.txt")" +cat "$OUT/unmatched.txt" diff --git a/Design/CloudTeamCollections/orchestration/regen-rebuild.sh b/Design/CloudTeamCollections/orchestration/regen-rebuild.sh new file mode 100644 index 000000000000..1bff1c9f2b67 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/regen-rebuild.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Rebuild cloud-tc-for-review per SQUASH-PLAN.md: 9 path-staged commits from cloud-collections. +set -e +GRPDIR="$1" +cd /c/github/BloomDesktop + +declare -a MSGS +MSGS[1]="Cloud Team Collections: design docs, wire contracts, and project records" +MSGS[2]="Cloud TC server: tc schema, RLS policies, RPCs, and pgTAP tests" +MSGS[3]="Cloud TC server: edge functions for checkin/download/collection-file transactions" +MSGS[4]="Cloud TC dev stack: local Supabase + MinIO, seed users, S3 parity checks" +MSGS[5]="Cloud TC client core: auth, API client, repo cache, S3 transfer (AWSSDK v4)" +MSGS[6]="Cloud TC backend: cache-backed TeamCollection, polling monitor, join flow, background downloads, account-switch handling" +MSGS[7]="Cloud TC API: sharing/membership endpoints, capabilities, join cards, book-list merge" +MSGS[8]="Cloud TC UI: sign-in, sharing dialog, status panel, join cards, download placeholders" +MSGS[9]="Cloud TC E2E: Playwright-over-CDP harness and 10 two-instance scenarios" + +for i in 1 2 3 4 5 6 7 8 9; do + echo "=== Group $i: $(wc -l < "$GRPDIR/g$i.txt") files ===" + # xargs handles the long path list; -d '\n' preserves paths with spaces + xargs -d '\n' -a "$GRPDIR/g$i.txt" git checkout cloud-collections -- + xargs -d '\n' -a "$GRPDIR/g$i.txt" git add -- + git commit -q --no-verify -m "${MSGS[$i]} + +Part of the Cloud Team Collections feature (S3 + Supabase backed Team +Collections). This branch is a regenerable review-grained packaging of the +cloud-collections working branch; only the final tree is test-verified (see +Design/CloudTeamCollections/orchestration/SQUASH-PLAN.md). + +Co-Authored-By: Claude Fable 5 " + echo "=== Group $i committed ===" +done + +echo "=== Verifying byte identity ===" +if [ -n "$(git diff cloud-tc-for-review cloud-collections --stat)" ]; then + echo "IDENTITY CHECK FAILED:" + git diff cloud-tc-for-review cloud-collections --stat + exit 1 +fi +echo "IDENTITY OK: tree matches cloud-collections exactly" +git log --oneline origin/master..HEAD diff --git a/Design/CloudTeamCollections/tasks/00-enablers.md b/Design/CloudTeamCollections/tasks/00-enablers.md new file mode 100644 index 000000000000..37c4fb608dbf --- /dev/null +++ b/Design/CloudTeamCollections/tasks/00-enablers.md @@ -0,0 +1,33 @@ +# 00 — Enablers (Wave 0, orchestrator-led, sequential) + +**Goal**: make the base classes backend-pluggable with provably zero behavior change for the +folder backend. + +**Dependencies**: none. **Do not parallelize** — touches shared hot files. + +**File ownership (shared, exclusive during this task)**: `src/BloomExe/TeamCollection/TeamCollection.cs`, +`TeamCollectionManager.cs`; new `TeamCollectionLink.cs`. + +## Steps +- [x] `TeamCollectionLink` class: parse/write folder-path and `cloud://sil.bloom/collection/` + forms of `TeamCollectionLink.txt`; error on both-forms-present. +- [x] Backend factory replacing the three hardcoded `new FolderTeamCollection(...)` sites + (manager ctor ~line 335–416, `ConnectToTeamCollection` ~500, subscription-reconnect + handler ~260–306). Add `ConnectToCloudCollection(collectionId)` to `ITeamCollectionManager` + (throws NotImplemented for now). +- [x] Virtual lock seams: `protected virtual bool TryLockInRepo(bookName)` / + `UnlockInRepo(bookName, force)`; folder overrides preserve current read-modify-write + behavior verbatim; `AttemptLock`/`UnlockBook`/`ForceUnlock` route through them. +- [x] Capability flags: virtual `SupportsVersionHistory` / `SupportsSharingUi` / + `RequiresSignIn` (folder: false/false/false). +- [x] Audit + document the ~10 `WriteBookStatus` callers (notes for task 05's diff-dispatch). +- [x] Feature flag: cloud sharing behind the experimental-features setting (registration only; + no UI yet). + +## Acceptance +- `dotnet test` — the ENTIRE existing TeamCollection suite passes unchanged (no test edits). +- New `TeamCollectionLinkTests` (parse/write/garbage/missing/both-present). +- Manual smoke: an existing folder TC opens, checks out, checks in exactly as before. + +**Agent notes**: orchestrator only. No cloud code in this task. Keep each refactor +mechanical and reviewable in isolation. diff --git a/Design/CloudTeamCollections/tasks/01-server-schema.md b/Design/CloudTeamCollections/tasks/01-server-schema.md new file mode 100644 index 000000000000..fd34efc5cec6 --- /dev/null +++ b/Design/CloudTeamCollections/tasks/01-server-schema.md @@ -0,0 +1,41 @@ +# 01 — Server schema + RPCs (Wave 1) + +**Goal**: the `tc` Postgres schema, RLS, and all pure-DB RPCs, per CONTRACTS.md. + +**Dependencies**: CONTRACTS.md frozen. Parallel-safe (owns `supabase/migrations/**`, +`supabase/tests/**` only). + +## Steps +- [x] `supabase init` layout at repo root (`supabase/config.toml`), local dev via `supabase start`. +- [x] Migrations: `collections`, `members` (approved accounts; unique claimed user per + collection; last-admin guard trigger), `books` (lock columns; `deleted_at`; unique + (collection, instance_id); unique live lower(name)), `versions` (metadata), + `version_files` (current manifest incl. s3_version_id), `collection_file_groups` + + `collection_group_files`, `color_palette_entries`, `events` (+ indexes, realtime trigger), + `checkin_transactions`. +- [x] RLS policies per design: member read; admin membership writes; NO direct writes to + books/versions; realtime channel authorization. +- [x] `tc.jwt_email_verified()` SQL helper: the ONE place that reads verification off the + token — Firebase-style `email_verified` claim OR local-GoTrue auto-confirmed users + (dev stack, task 11). Everything else calls the helper, never the claim directly. +- [x] RPCs from CONTRACTS.md: create_collection, my_collections, claim_memberships + (requires `tc.jwt_email_verified()`), get_collection_state, get_changes, checkout_book (conditional + UPDATE), unlock_book, force_unlock, delete_book (lock required), undelete_book, + rename_check, member management (remove ⇒ force-unlock + events), add_palette_colors, + log_event. +- [x] Events: `BookHistoryEventType` numeric parity + incident types (WorkPreservedLocally…). + +## Acceptance — MET +- pgTAP suite **GREEN: 42/42 (6 Jul 2026, `supabase test db` on local stack via Podman)**. + Covers: RLS matrix; checkout concurrency (two racing calls, exactly one winner); claiming requires + verified email; last-admin guard; get_changes cursor; tombstone/undelete; live-name uniqueness + (tombstoned names reusable). + Orchestrator fixes to get green: plan count corrected (60 → 42 actual assertions); RLS + assertions now run under `SET LOCAL ROLE authenticated` — the suite's postgres superuser + BYPASSES row security, so unswitched RLS tests pass vacuously (keep this pattern for all + future RLS assertions). Migrations + seed also verified live: RPC round-trip + (create_collection → my_collections) via PostgREST with Content-Profile: tc succeeded. + +**Agent notes**: Sonnet. All timestamps `now()` server-side. User ids are text, not uuid — +Firebase UIDs are ~28 chars (local-GoTrue dev users are uuids; text covers both). +NFC-normalize name/path comparisons. diff --git a/Design/CloudTeamCollections/tasks/02-edge-functions.md b/Design/CloudTeamCollections/tasks/02-edge-functions.md new file mode 100644 index 000000000000..062887769b87 --- /dev/null +++ b/Design/CloudTeamCollections/tasks/02-edge-functions.md @@ -0,0 +1,167 @@ +# 02 — Edge functions + AWS provisioning (Wave 1) + +**Goal**: the five S3-brokering edge functions per CONTRACTS.md, and the checked-in AWS +provisioning script. Dev/test target is MinIO via the local stack (task 11); real AWS is a +deferred config swap (see the master checklist's deferred-infrastructure list). + +**Dependencies**: CONTRACTS.md; task 11's docker-compose/env-var contract. Runs parallel to +01 (mock DB until 01 merges, then integrate). Owns `supabase/functions/**`, `server/provision-aws.*`. + +## Steps +- [x] `checkin-start`: membership + lock + base-version checks; new-book path (bookId null ⇒ + row locked-to-caller, versionless, invisible); diff proposed manifest vs current; + transaction reuse/resume; credential issuance behind a small provider seam: + **dev mode** (env-selected) returns static MinIO credentials, **production mode** does + real STS via `bloom-teams-broker` with a per-request session policy scoped to the one + book prefix — both in the identical response shape (CONTRACTS.md unchanged; clients + can't tell the difference). +- [x] `checkin-finish`: verify sha256 attributes; capture s3 version-ids; single DB tx + (version metadata row, current-manifest replacement, book update, lock release, events, + `.manifest.json` write); MissingOrBadUploads retry path; idempotent. +- [x] `checkin-abort`; transaction expiry sweep (versionless-row reaping). +- [x] `download-start`: read-only creds (`GetObject`+`GetObjectVersion`), collection scope. +- [x] `collection-files-start/finish` with optimistic `expectedVersion`. +- [x] `ClientOutOfDate` handling (client version in requests). +- [x] `server/provision-aws` script (idempotent): buckets `bloom-teams-production|sandbox`, + versioning ON, public access blocked, lifecycle (abort-multipart 7d; noncurrent expiry + per the confirmed window), broker role + assume-only IAM user; document secrets setup + (`supabase secrets set`). **Written and reviewed now; RUN later** when real AWS access + exists — acceptance for this task does not require an AWS account. + +## Acceptance +- [x] Deno tests per function: happy path; lock-held; base-version-superseded; checksum failure + (missing + wrong-content object); resume; expiry; new-book invisibility until commit. + (Lock-held/base-version-superseded/resume/new-book-invisibility/checksum-failure verified + live against the real stack — see Progress log; per-function Deno unit tests below cover + the handler-level wiring/error-passthrough hermetically. `expiry` is covered by the + SQL's reap logic + the invariant test below, not a live 48h wait.) +- [x] Invariant test: transaction lifetime < noncurrent-expiry floor (config assertion). + +**Agent notes**: Sonnet. MinIO for S3 in tests AND as the dev-mode target (task 11's stack). +Only these functions ever hold AWS/MinIO admin creds. + +## Progress log + +- 2026-07-06 · done: new migration `20260706000004_tc_checkin_txn_functions.sql` adding + the internal SECURITY DEFINER transaction functions (`checkin_start_tx`, + `checkin_finish_tx`, `checkin_abort_tx`, `collection_files_start_tx`, + `collection_files_finish_tx`, `download_start_check`, expiry-reap helpers, PT### + HTTP-status passthrough convention) that back all 6 edge functions; applied clean via + `supabase db reset --local`. All 6 edge functions authored under `supabase/functions/` + (`checkin-start`, `checkin-finish`, `checkin-abort`, `download-start`, + `collection-files-start`, `collection-files-finish`) plus `_shared/` helpers + (env, errors, handler, rpc, s3-credential-provider-seam). `deno check` passes on all. + NOT YET tested against the live stack. Next action: run + `supabase functions serve --env-file server/dev/functions.env` (env file not yet + created — create it first with `BLOOM_DEV_MODE=true` [historical name; renamed + `BLOOM_CLOUD_LOCAL_MODE` 8 Jul 2026], `BLOOM_S3_ENDPOINT=http://host.containers.internal:9000`, + `BLOOM_S3_BUCKET=bloom-teams-local`), then exercise checkin-start → checkin-finish + happy path end-to-end with a real dev-seed user JWT (alice@dev.local), then write Deno + unit tests per function and continue through the acceptance checklist (lock-held, + base-version-superseded, checksum failure, resume, expiry, new-book invisibility). +- 2026-07-06 (later same day) · done: full LIVE integration verification against the real + local stack (Supabase 127.0.0.1:54321 + MinIO via `supabase functions serve --env-file + server/dev/functions.env`), using a scratch Deno driver script (not committed — ad hoc) + exercising every item in the Acceptance checklist except a real 48h expiry wait: happy + path (new-book checkin-start → PUT via MinIO AssumeRole creds → checkin-finish → + download-start GetObject round-trip), idempotent checkin-finish retry, resume of an + open transaction (both new-book and existing-book), lock-held (409 LockHeldByOther), + base-version-superseded (409), checksum failure via MissingOrBadUploads (409, upload + omitted), new-book invisibility until commit (verified via get_collection_state), + collection-files two-phase commit + VersionConflict (409). **26/26 checks passed** after + fixing 4 real bugs found along the way (all fixed + migrations reapplied via + `supabase db reset`): + 1. **`host.containers.internal`/`host.docker.internal` DNS-resolves but the traffic + HANGS** (not slow — indefinite) for any Deno/edge-runtime HTTP call through Podman's + gvproxy host-gateway hop, even though plain `curl` over the identical URL succeeds + instantly (verified with raw `Deno.connect()`, native `fetch`, and the AWS SDK, both + from a throwaway container and the real edge-runtime container). This is almost + certainly what stalled the prior interrupted attempt at this task. **Fix**: MinIO + now also joins the Supabase CLI's own project Docker network + (`server/dev/docker-compose.yml`'s `networks:` block — external network + `supabase_network_bloom-team-collections`, created by `supabase start`) and is + addressed by container name (`http://bloom-minio:9000`, in `functions.env`). + Container-to-container traffic on a shared bridge network is instant. Documented in + `server/dev/README.md`'s new "Known gotchas" section. + 2. **`supabase/config.toml`'s `[edge_runtime].policy = "oneshot"`** (the generated + default) re-transpiles/type-checks the whole module graph — including the heavy + `npm:@aws-sdk/client-s3`+`client-sts` imports in `_shared/s3.ts` — on every request, + which reliably exceeds the edge-runtime's ~10s worker-boot timeout + (`InvalidWorkerCreation: worker did not respond in time`) for any function that + reaches `getScopedCredentials`/`adminS3Client`. **Fix**: switched to + `policy = "per_worker"` (compiles once; also closer to production). Documented in the + same README section. + 3. **New-book checkin-start resume was unreachable** in `checkin_start_tx` + (`20260706000004_...sql`): CONTRACTS.md's checkin-start response never exposes + `bookId` (by design — that's what makes an uncommitted book invisible), so a client + resuming an interrupted new-book Send has no id to send back and must re-call with + `bookId: null` + the same `bookInstanceId`. The old code always ran the + insert-a-new-row path whenever `bookId` was null, so the very row created by try #1 + tripped the "instance_id already in use" conflict check on try #2. Fixed: the + new-book branch now looks up any existing row by `(collection_id, instance_id)` + first and treats "found, not yet committed, still locked to me" as a resume (not a + conflict) — see the updated comment block in the migration. + 4. **`get_collection_state`'s full-snapshot branch leaked uncommitted new books** to + every collection member (`20260706000003_tc_rpcs.sql`, task 01's file — a + cross-cutting bug this task's acceptance criterion "new-book invisibility until + commit" directly depends on). The delta branch was already safe (a never-committed + book has no events to join against yet), but the full-snapshot branch queried + `tc.books` directly with no such filter. Fixed by excluding rows where + `current_version_id IS NULL` unless `locked_by = tc.current_user_id()` (the sender + still sees their own in-flight new book). + Remaining for this task: Deno unit tests per function (mocked RPC/S3 — the live spike + above covers integration but not fast, hermetic CI-friendly coverage), the invariant + test (transaction lifetime 48h < noncurrent-expiry-floor 7d — config assertion), and + `server/provision-aws` (author + review only, no AWS account). +- 2026-07-06 (later still) · done: refactored all 6 `supabase/functions/*/index.ts` to + `export const handler = async (req, body) => {...}` + `if (import.meta.main) { serveJsonPost(handler); }` + instead of passing the arrow function straight into `serveJsonPost(...)`. Empirically + verified (via the running local stack) that the real supabase-edge-runtime still sets + `import.meta.main = true` and serves normally with this guard — re-ran the full 26-check + live-integration suite after the refactor, still 26/26. This makes each handler + importable and directly callable from a Deno test with a mocked `Request`, without a + module-load side effect of starting a real `Deno.serve` (which would collide across + test files). `deno check` clean on all 6. Next action: write the Deno test suite + (`_shared/s3.test.ts` using `aws-sdk-client-mock` for STS/S3; per-function + `index.test.ts` mocking `globalThis.fetch` for the RPC calls), the invariant config + assertion, then author `server/provision-aws`. +- 2026-07-06 (later still) · done: wrote the Deno test suite. `_shared/test_support.ts` + (new) provides `setTestEnv`/`mockRequest`/`withMockFetch`/`routedFetchStub`/ + `callHandler` (the last translates a thrown `HttpError` into its `Response`, mirroring + what `serveJsonPost` does, since tests call the exported `handler` directly). + `_shared/s3.test.ts`: 8 tests covering `hexToBase64`, `getScopedCredentials` (dev-mode + MinIO AssumeRole call shape + no-Policy-in-dev + missing-credentials failure), + `verifyUploadedObject` (match/mismatch/missing-object/missing-VersionId), and + `writeManifestBackup` (never throws). One `index.test.ts` per function (checkin-start + 5, checkin-finish 4, checkin-abort 4, download-start 3, collection-files-start 3, + collection-files-finish 3 = 22 tests) covering happy path, required-field validation, + and RPC error passthrough (409/404/426) with S3-not-called assertions on the error + paths. `_shared/invariants.test.ts`: 2 tests — re-parses the actual migration/compose + source text (rather than hardcoding both sides) to assert (a) every 48h transaction- + expiry `INTERVAL` literal agrees and (b) 48h < the dev MinIO noncurrent-expiry floor + (7d, from `docker-compose.yml`'s `--noncurrent-expire-days`). All S3/STS mocked via + `aws-sdk-client-mock`; all PostgREST calls mocked via a `fetch` stub — no live stack + needed. **32/32 Deno tests pass** + (`deno test --allow-net --allow-env --allow-sys --allow-read supabase/functions/`). + Remaining for this task: `server/provision-aws` (author + review only). +- 2026-07-06 (final) · done: authored `server/provision-aws.ps1` (PowerShell, matching + the only existing `server/` script convention — `server/dev/smoke.ps1`). Idempotent: + checks-then-creates S3 buckets (`bloom-teams-`, default + production+sandbox) with versioning ON, all-public-access blocked, and lifecycle + rules (noncurrent-version-expiry 7d + abort-incomplete-multipart-upload 7d under + `tc/`, both parameterized); an IAM role `bloom-teams-broker` (permission-policy + ceiling: PutObject/GetObject/GetObjectVersion/AbortMultipartUpload/ + ListMultipartUploadParts on both buckets — the edge function's per-request session + Policy narrows further, per `_shared/s3.ts`'s `buildSessionPolicy`); an assume-only + IAM user `bloom-teams-broker-caller` (sole permission: `sts:AssumeRole` on that + role — this is the identity behind `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` in + `_shared/env.ts`'s `prodBrokerConfig`); an IAM admin user `bloom-teams-admin` with + direct S3 permissions (`BLOOM_S3_ADMIN_ACCESS_KEY`/`SECRET`, backing + `adminS3Client()`). Ends by printing the exact `supabase secrets set` command block + (including `BLOOM_S3_ENDPOINT=https://s3..amazonaws.com` and + `BLOOM_S3_FORCE_PATH_STYLE=false`, since `_shared/env.ts`'s `s3Env()` requires an + explicit endpoint even for real AWS). Supports `-WhatIf`. Verified with + `[System.Management.Automation.Language.Parser]::ParseFile` (no syntax errors) — + NOT run against a real AWS account (none available; matches the task's explicit + "written and reviewed now; RUN later" acceptance bar). All 5 steps of task 02 are + now complete. diff --git a/Design/CloudTeamCollections/tasks/03-auth.md b/Design/CloudTeamCollections/tasks/03-auth.md new file mode 100644 index 000000000000..f9eee8cae6ec --- /dev/null +++ b/Design/CloudTeamCollections/tasks/03-auth.md @@ -0,0 +1,71 @@ +# 03 — Auth (Wave 1) + +**Goal**: `CloudAuth` + `CloudCollectionClient` skeleton behind one interface, with a **dev +auth provider** (local GoTrue email/password against task 11's stack) as the first concrete +implementation. Real BloomLibrary/Firebase sign-in (Option A/B/C) is a later drop-in provider — +the decision is **not blocking** for this task or anything downstream. + +**Dependencies**: CONTRACTS.md; task 11's env-var contract. The Option A/B/C decision +(colleague review — see design doc) is deferred to the real-infrastructure cutover; the +interface is option-agnostic. Owns new files +`src/BloomExe/TeamCollection/Cloud/CloudEnvironment.cs`, `CloudAuth.cs`, +`CloudCollectionClient.cs`. (When the real option lands: if Option A, the BloomLibrary2 +`src/editor.ts` change and the Firebase Admin claim function live in their own repos — +coordinate, do not fork here.) + +## Steps +- [x] `CloudEnvironment`: one place resolving Supabase URL, anon key, S3 endpoint/bucket/ + path-style, and auth mode from the `BLOOM_CLOUDTC_*` env vars (names per task 11's + README) over compiled defaults. Everything cloud-related reads config from here; + switching local ↔ sandbox ↔ production is config only. +- [x] `CloudAuth` interface + session core (provider-agnostic): token store, proactive refresh + (timer at ~80% TTL + on-401), sign-out, "who am I" (email/user id), account-switch + detection hook. +- [x] **Dev provider** (`AUTH_MODE=dev`): sign in = GoTrue password grant against the local + stack; unknown email ⇒ sign-up (auto-confirmed) then sign in — i.e. any login is + accepted. Honors `BLOOM_CLOUDTC_USER`/`BLOOM_CLOUDTC_PASSWORD` for silent auto-sign-in, + **bypassing shared stored tokens** — this is what lets two Bloom instances on one + machine run as two different users. +- [x] Real-provider seam (`AUTH_MODE=real`): stub that surfaces "not yet available"; the + `external/login` payload hook (`ExternalApi.LoginSuccessful`) and refresh-token + user-setting (alongside LastLoginSessionToken) are wired but inert until the Option + A/B/C provider is implemented (deferred-infrastructure list). +- [x] `CloudCollectionClient`: RestSharp client for RPCs + edge functions per CONTRACTS.md + (model on `BloomLibraryBookApiClient`), bearer injection, ClientOutOfDate surfacing, + typed errors (LockHeldByOther etc.). +- [x] `sharing/loginState` endpoint groundwork (used by UI tasks; reports mode + identity so + dev-mode sign-in can be a plain email/password form instead of the browser flow). + +## Acceptance +- `CloudAuthTests`: refresh on timer/401; refresh failure mid-operation aborts cleanly and + surfaces "please sign in"; account-switch detection hook; env-override identity wins over + stored tokens. +- Client tests: bearer attached; typed error mapping; out-of-date handling. +- Manual (local stack): two Bloom instances with different `BLOOM_CLOUDTC_USER` values hold + two distinct valid sessions simultaneously; a session survives > 2h (refresh soak). + +**Agent notes**: Sonnet. Editing a checked-out book must NEVER block on auth. Keep the dev +provider tiny — it must be deletable without touching the session core. + +## Progress log + +- 6 Jul 2026 · done: CloudEnvironment/CloudAuth/CloudCollectionClient skeleton implemented and + building clean (BloomExe.csproj auto-globs new .cs files, no csproj edit needed); dev-provider + GoTrue calls live-verified against the local stack (sign-in, RPC with Content-Profile: tc, + 401/error shapes). All "Steps" checkboxes ticked. · next: write CloudAuthTests + + CloudCollectionClientTests under src/BloomTests/TeamCollection/Cloud/ covering the Acceptance + section, then run `dotnet test --filter FullyQualifiedName~Cloud` and the TeamCollection + regression filter. +- 6 Jul 2026 (later) · done: full Acceptance test suite written and green — + CloudAuthTests/CloudCollectionClientTests/CloudEnvironmentTests (36 tests: mocked + ICloudAuthProvider/IRestExecutor fakes cover refresh-on-timer, refresh-on-401 success/failure, + account-switch, env-override-wins-over-stored-session, bearer injection, and every + CloudErrorCode mapping incl. ClientOutOfDate); plus one `[Explicit]` test + (LiveDevProvider_TwoUsersSignInConcurrently_HoldDistinctSessions) that live-verified alice/bob + get independent sessions + independent refreshes against the running local Supabase stack. + Full `FullyQualifiedName~Cloud` filter: 46/46 green. Full `FullyQualifiedName~TeamCollection` + regression filter: 244/244 green (no folder-TC regression). Task complete except the + multi-hour manual two-window/>2h-soak items in Acceptance, which need a human at a keyboard — + see the final report for what was substituted. · next: none for this task; ready for + orchestrator review/merge. Downstream: task 04 (client-core) builds the actual RPC/edge-function + method wrappers on top of CloudCollectionClient.CallRpc/CallEdgeFunction. diff --git a/Design/CloudTeamCollections/tasks/04-client-core.md b/Design/CloudTeamCollections/tasks/04-client-core.md new file mode 100644 index 000000000000..96285b59a3c5 --- /dev/null +++ b/Design/CloudTeamCollections/tasks/04-client-core.md @@ -0,0 +1,62 @@ +# 04 — Client core: cache, manifest, transfer (Wave 2) + +**Goal**: the data plumbing `CloudTeamCollection` will sit on. + +**Dependencies**: 03 (client/auth), CONTRACTS.md. Owns new files +`src/BloomExe/TeamCollection/Cloud/CloudRepoCache.cs`, `BookVersionManifest.cs`, +`CloudBookTransfer.cs`. + +## Steps +- [x] `CloudRepoCache`: thread-safe in-memory book/lock/version map + collection-file record + + `last_seen_event_id`; persisted snapshot (`.bloom-cloud-repo-cache.json` in the local + collection folder, excluded from book enumeration); full-snapshot + delta application; + write-through from mutating RPC results. Never trusted for mutations. +- [x] `BookVersionManifest`: model (path → sha256, size, s3VersionId), NFC path normalization, + junk/derived-file exclusion list (reuse the publish path's filters), local-folder diff + (changed/added/removed/unchanged) with hash computation reuse (`MakeChecksum` internals / + `Book.ComputeHashForAllBookRelatedFiles`). +- [x] `CloudBookTransfer`: hash-skip uploads (parallel PUTs with checksum headers, byte + progress via IProgress) and downloads **by pinned (path, s3VersionId) only — never + "latest"**; staged-temp-then-atomic-swap; resume support both directions. Reuse + `BloomS3Client` session-credential + TransferUtility mechanics (extract shared helper if + needed — don't disturb the publish path). + +## Acceptance +- [x] `CloudRepoCacheTests` (concurrency, snapshot round-trip, delta, cursor). +- [x] `BookVersionManifestTests` (diff matrix with data sanity pre-checks; junk exclusion; NFC). +- [x] `CloudBookTransferTests` (mock S3): skip logic both directions; resume skips done files; + checksum-mismatch retry; interrupted download leaves the working folder untouched; + **assert no code path issues an unversioned GET**. + +**Agent notes**: Sonnet. This task has no UI and no base-class edits. + +## Progress log + +- 7 Jul 2026 · Implemented CloudRepoCache.cs, BookVersionManifest.cs, CloudBookTransfer.cs + (src/BloomExe/TeamCollection/Cloud/); BloomExe builds clean. Extracted + `BloomS3Client.CreateAmazonS3Client(AmazonS3Config, AmazonS3Credentials)` from `protected` + instance to `internal static` (pure visibility/staticness change, no behavior change) so + CloudBookTransfer can reuse session-credential client construction without disturbing the + publish path. Checksummed uploads use a manually-set `x-amz-checksum-sha256` header (not the + SDK's native ChecksumAlgorithm/ChecksumSHA256 properties, which don't exist in the + AWSSDK.S3 3.5.3.10 this project is pinned to) — **live-verified against the local MinIO + stack**: PUT with the manual header, then read back via a separate probe using a newer + AWSSDK.S3 (3.7.511.8) GetObjectAttributes(ChecksumMode: ENABLED) — the SHA-256 came back + correctly, confirming MinIO (and by the same S3 API contract, real AWS) treats the manual + header identically to the native SDK feature, so `_shared/s3.ts`'s `verifyUploadedObject` + will accept it once wired up in a later task. Recommend (not done here — BloomExe.csproj is + orchestrator-owned at merge time per the shared-file schedule): bump AWSSDK.S3 so future + code can use the native properties instead of a raw header string. +- 7 Jul 2026 · Added BookVersionManifestTests.cs (10), CloudRepoCacheTests.cs (16), + CloudBookTransferTests.cs (11, mocking IAmazonS3 via Moq) in + src/BloomTests/TeamCollection/Cloud/. `dotnet test --filter FullyQualifiedName~Cloud`: + 83/83 green (37 new + 46 pre-existing from task 03). `dotnet test --filter + FullyQualifiedName~TeamCollection`: 281/281 green (full folder+cloud TC regression). No + apphost/MSB3027 issue hit this run (clean 0-error build both times). Contract gap found (not + fixed — out of this task's file scope): CONTRACTS.md v1.1 defines no RPC returning a book's + per-file manifest (path/sha256/size/s3VersionId) for Receive/download — get_collection_state + only returns the aggregate current_checksum/current_version_seq. CloudRepoCache. + CloudCachedBook.Manifest and CloudRepoCache.RecordManifest are written with this gap in mind + (nullable, populated whenever a caller obtains a manifest by any means) so 05/06 aren't + blocked, but whoever wires up Receive will need either a new RPC or to read the S3 + `.manifest.json` backup. Task complete; no further action needed from this agent. diff --git a/Design/CloudTeamCollections/tasks/05-cloud-backend.md b/Design/CloudTeamCollections/tasks/05-cloud-backend.md new file mode 100644 index 000000000000..a15ea3a605f7 --- /dev/null +++ b/Design/CloudTeamCollections/tasks/05-cloud-backend.md @@ -0,0 +1,172 @@ +# 05 — CloudTeamCollection + monitor (Wave 3, first) + +**Goal**: the backend subclass and change monitoring — the heart of the feature. + +**Dependencies**: 00, 01, 02, 03, 04. Owns new files `Cloud/CloudTeamCollection.cs`, +`Cloud/CloudCollectionMonitor.cs`, `Cloud/CloudJoinFlow.cs`. Touches (exclusive this task): +nothing shared — the manager factory seam from 00 is wired by config. + +## Steps +- [ ] Implement every abstract member per the design doc's mapping table (list/status members + from cache; PutBookInRepo = Send pipeline via checkin-start/finish; FetchBookFromRepo = + pinned-version staged fetch + swap; delete/rename/tombstone via RPCs; collection-file + members on the group contracts; casing members against the book row; CheckConnection = + network + session + membership with precise messages; GetBackendType = "Cloud"). +- [ ] `WriteBookStatusJsonToRepo` diff-dispatch (per 00's caller audit): lock changes → + lock RPCs; bookkeeping-only writes never clear a lock; server stamps identity. +- [ ] New-book first-Send path incl. NameConflict → "name2" resolution and id-conflict flow. +- [ ] Unified recovery: on lock-lost/base-superseded, save `.bloomSource` to local Lost & Found, + Receive current, post incident event + message (distinct texts per sub-case). +- [ ] Account rules: same-account sign-out/in safe; account switch with unsent changes blocked + with Send-or-preserve choices. +- [ ] `CloudCollectionMonitor`: polling first (get_changes, 60s; on-activation), event→base-queue + mapping, event-id self-echo suppression, catch-up-then-trust on reconnect. +- [ ] `CloudJoinFlow`: my_collections listing → local collection creation → first Receive + (six-scenario matching logic moved from FolderTeamCollection). +- [ ] Modal Send/Receive orchestration on the existing BrowserProgressDialog harness. + +## Acceptance +- `CloudTeamCollectionMemberTests`, `CloudTeamCollectionLockTests`, `CloudSyncAtStartupTests` + (ported matrix; asserts `.bloomSource` + incident events), `CloudCollectionMonitorTests`. +- Folder-TC suite still green. +- Manual: two Bloom instances on ONE machine (distinct collection folders + dev identities + via `BLOOM_CLOUDTC_USER`) against the local stack (task 11) — checkout/Send/Receive loop + works, lock state visible across instances. + +**Agent notes**: Sonnet, orchestrator reviews closely. Base-class code is read-only here; +anything needing a base change goes back to the orchestrator. + +## Progress log + +- 7 Jul 2026 · done · Read task brief, CONTRACTS.md v1.2, architecture doc, write-book-status + audit, and surveyed (via sub-agent) the full abstract-member surface of TeamCollection.cs, + TeamCollectionManager.cs's two NotImplementedException seams, and the Wave 3/4 support + classes (CloudCollectionClient/CloudRepoCache/BookVersionManifest/CloudBookTransfer/CloudAuth/ + CloudEnvironment). Added typed RPC/edge-function wrapper methods to CloudCollectionClient.cs + (create_collection, my_collections, claim_memberships, get_collection_state, get_changes, + get_book_manifest, checkout_book, unlock_book, force_unlock, delete_book, undelete_book, + rename_check, members list/add/remove/set_role, add_palette_colors, log_event, + checkin-start/finish/abort, download-start, collection-files-start/finish) — this file's own + doc comment said later tasks build these on top of it. Builds clean. + Next action: write `Cloud/CloudTeamCollection.cs` implementing every abstract member per the + mapping table, using `CloudRepoCache` + the new client methods + `CloudBookTransfer`. +- 7 Jul 2026 · done · Wrote `Cloud/CloudTeamCollection.cs` (every abstract/virtual member of + TeamCollection implemented: status/list/presence from cache; TryLockInRepo/UnlockInRepo via + checkout_book/unlock_book/force_unlock; WriteBookStatusJsonToRepo diff-dispatch per the task 00 + audit; PutBookInRepo = Send via checkin-start/upload/checkin-finish with "name2" NameConflict + retry and an inLostAndFound -> unified-recovery branch (.bloomSource to local Lost and Found + + log_event type 100 WorkPreservedLocally + distinct message per sub-case); FetchBookFromRepo = + Receive via get_book_manifest + pinned download into a staging folder + a two-rename atomic + directory swap done by this class (per the merge-log note that CloudBookTransfer's own move loop + isn't itself atomic); GetRepoBookFile = single pinned-file fetch, cached per sync pass; casing + methods against the cached book's canonical Name; collection files via collection-files-start/ + finish (push) and a direct S3 prefix-list+download (pull, since no group manifest RPC exists); + color palette sync is push-only via add_palette_colors (no read-back RPC exists); CheckConnection + = signed-in + membership check; StartMonitoring/StopMonitoring wire a new CloudCollectionMonitor + instance whose polled deltas raise the same low-level events FolderTeamCollection's file watcher + raises. Also wrote `Cloud/CloudCollectionMonitor.cs` (60s Timer polling get_changes, PollNow() for + on-activation/manual triggering, self-echo suppression falls out of the shared last_seen_event_id + cursor). Builds clean (`dotnet build src/BloomExe/BloomExe.csproj`). + Contract gaps/ambiguities found while implementing (see final report for full list): (1) + checkin-start/finish never return the server-assigned book id for a brand-new book -- worked + around with a post-commit get_collection_state refresh matched on bookInstanceId; (2) no manifest + RPC exists for collection-file groups (only a version-bump counter) -- worked around with a direct + S3 ListObjectsV2, reading latest rather than a pinned version; (3) add_palette_colors has no + matching read-back RPC -- palette sync is currently push-only; (4) member first/last name isn't in + the book row shape -- lock display fields lockedByFirstName/Surname are null for cloud TCs; (5) + collection-files-start/finish's exact response shape isn't spelled out -- assumed + checkin-start-like `{transactionId, s3}` / `{version}`; (6) the `members` RPC names + (members_list/add/remove/set_role) are a guess at CONTRACTS.md's "members: list/add/remove/ + set_role" shorthand. + Base-class change identified but NOT made (file discission): `Bloom.History.BookHistoryEventType` + has no `WorkPreservedLocally` member; used the literal `100` per the task brief instead of adding + it to that shared enum (out of this task's owned-file scope) -- recommend the orchestrator add + `WorkPreservedLocally = 100` there. + Next action: write `Cloud/CloudJoinFlow.cs`, then the one authorized + TeamCollectionManager.cs edit, then tests. +- 7 Jul 2026 · done · Wrote `Cloud/CloudJoinFlow.cs`: `ListMyCollections()` (my_collections), + `DetermineScenario()` (the six-scenario local-vs-remote matrix ported from + `FolderTeamCollection.ShowJoinCollectionTeamDialog`'s boolean logic: FreshJoin/ + AlreadyJoinedSameCollection/LinkedToDifferentCloudCollection/LinkedToFolderTeamCollection/ + PlainCollectionSameGuid/PlainCollectionDifferentGuid), `JoinCollection()` (creates the local + folder + link file + CloudTeamCollection + first Receive of collection files and every book; + throws `CloudJoinConflictException` for the three human-decision scenarios), and + `CreateAndJoinCollection()` (create_collection + join). Key design point: a cloud collection's + `.bloomCollection` file travels with it in the "other" collection-file group (same as a folder + TC's zipped settings), so joining does not need to fabricate a fresh CollectionSettings -- + downloading collection files brings the real settings file down. Builds clean. + Scope note: the interactive resolution dialog for the three conflict scenarios (the cloud + equivalent of ShowJoinCollectionTeamDialog's React dialog) is UI-layer work outside this task's + file ownership; CloudJoinConflictException.Message is ready for a future dialog to display. + Next action: the one authorized TeamCollectionManager.cs edit (CreateTeamCollectionFromLink's + cloud branch + ConnectToCloudCollection), then the test suite (CloudTeamCollectionMemberTests, + CloudTeamCollectionLockTests, CloudSyncAtStartupTests, CloudCollectionMonitorTests), then + build/test verification and the final report. +- 7 Jul 2026 · done · Made the one authorized TeamCollectionManager.cs edit: replaced both + NotImplementedException placeholders (`CreateTeamCollectionFromLink`'s cloud branch now calls a + new private `CreateCloudTeamCollection` helper mirroring `CreateFolderTeamCollection`; + `ConnectToCloudCollection(collectionId)` now creates the server-side row via create_collection + -- collectionId doubles as both this Bloom collection's own CollectionId GUID and the server's + `collections.id`, per CONTRACTS.md's link-format note -- links the local collection, and pushes + existing local content up via a new `CloudTeamCollection.SetupCloudTeamCollectionWithProgressDialog` + mirroring FolderTeamCollection's own Setup*WithProgressDialog). Touched nothing else in the file + (verified via `git diff --stat` + full diff review). Builds clean. + Next action: write the test suite (CloudTeamCollectionMemberTests, CloudTeamCollectionLockTests, + CloudSyncAtStartupTests, CloudCollectionMonitorTests) under src/BloomTests/TeamCollection/Cloud/, + run the cloud + TC-regression filters, then write the final report. +- 7 Jul 2026 · done · Wrote the four acceptance-criteria test files under + src/BloomTests/TeamCollection/Cloud/ (CloudTeamCollectionMemberTests, CloudTeamCollectionLockTests, + CloudSyncAtStartupTests, CloudCollectionMonitorTests), reusing the FakeRestExecutor/ + StubCloudAuthProvider/FakeResponses harness already established in CloudCollectionClientTests.cs + (same namespace, no duplication). `dotnet test src/BloomTests/BloomTests.csproj --filter + "FullyQualifiedName~Cloud"` → 102 passed, 0 failed. `--filter "FullyQualifiedName~TeamCollection"` + (folder-TC regression + all cloud) → 300 passed, 0 failed. + CloudSyncAtStartupTests ports the single highest-value scenario from the folder suite (checked-out + + locally-edited book whose repo checksum ALSO changed remotely -> SyncAtStartup's + PutBook(inLostAndFound: true) path) and asserts all three unified-recovery effects: a `.bloomSource` + file appears in a local "Lost and Found" folder, a `log_event` RPC fires, and a + "TeamCollection.Cloud.WorkPreservedLocally" message lands in the log; plus a simpler "new book + fetched from repo" case. Porting the full ~15-case SyncAtStartupTests.cs matrix is flagged as + follow-up work in the final report (needs a fuller scripted-server fake for rename/id-conflict + detection via GetRepoBookFile). + Real bug/behavior found while writing CloudTeamCollectionLockTests: `TeamCollection.AttemptLock` + (base class, unchanged) discards `TryLockInRepo`'s bool return value and returns based on the LOCAL + status it optimistically set to "locked by me" BEFORE calling TryLockInRepo -- it never re-reads + status afterward. So AttemptLock's own return value can be `true` even when the cloud server denied + the lock; only a separate `WhoHasBookLocked`/`GetStatus` call afterward reliably reflects who won + (which TryLockInRepo's own doc comment already anticipates: "the caller should re-read status to + find out who won"). Not fixed (TeamCollection.cs is read-only for this task) -- flagged in the + final report as a base-class change worth considering. + Next action: build/test verification is done; write the final report. +- 7 Jul 2026 · done · Added `Cloud/CloudTeamCollectionLiveTests.cs` ([Explicit] Send-then-Receive + round trip against the REAL local dev stack: `supabase start` + `supabase functions serve + --env-file server/dev/functions.env`, started in the background per the anti-hang rules) and ran + it to green. It found and fixed TWO real bugs that no mocked test could have caught: + (1) **JSON serialization bug** in `CloudCollectionClient.CallRpc`/`CallEdgeFunction`: RestSharp's + own default JSON serializer doesn't know how to serialize a `Newtonsoft.Json.Linq.JArray`/ + `JObject` embedded in the anonymous body object (it reflects over JToken's own CLR properties + instead of writing a native array/object), which silently produced a malformed `files` array and + a cryptic Postgres "jsonb_to_recordset must be an array of objects" error on the very first live + checkin-start call. Fixed by serializing the body with Newtonsoft ourselves and attaching the + resulting JSON text as a raw request-body parameter (`CloudCollectionClient.AddJsonBody` private + helper), rather than handing RestSharp a raw object to serialize itself. + (2) **Missing book-instance path segment on Receive**: `download-start`'s credentials are scoped + to the whole COLLECTION prefix (`tc/{cid}/`), not the book-specific one (`tc/{cid}/books/ + {bookInstanceId}/`) that `checkin-start` returns for uploads -- `FetchBookFromRepo`/ + `GetRepoBookFile` were building S3 keys directly under the collection prefix, producing + `NoSuchVersion` (confirmed via a manual Node/aws-sdk repro against the live MinIO). Fixed with a + new `CloudTeamCollection.BuildBookS3Location` helper that inserts `books/{bookInstanceId}/` + (looked up from the cache) before combining with `download-start`'s prefix. + Also confirmed a THIRD issue that is NOT fixable in this task's files: the live server currently + stamps `locked_by`/`created_by` with the raw auth user id (JWT `sub`, confirmed by decoding a + real session token) rather than the email CONTRACTS.md's identity model specifies ("account email + is the identity in cloud TCs") -- a task-01 SQL-function issue, out of scope here. Added a + client-side `ResolveLockedByForDisplay` workaround that resolves OUR OWN id back to our own email + (fixes every IsCheckedOutHereBy/"is this checked out to me" check, the case that matters most), + leaving a teammate's id unresolved to a friendly name pending the server-side fix. + Re-ran `--filter "FullyQualifiedName~TeamCollection&FullyQualifiedName!~LiveTests"` after all + three fixes: still 300 passed, 0 failed. Live test: 1 passed (run manually with + `BLOOM_CLOUDTC_ANON_KEY` exported, `supabase functions serve --env-file server/dev/functions.env` + running in the background). + Next action: none for this task; final report follows. Recommend the orchestrator route the + locked_by-is-a-uuid finding to whoever owns task 01's SQL functions. diff --git a/Design/CloudTeamCollections/tasks/06-api-endpoints.md b/Design/CloudTeamCollections/tasks/06-api-endpoints.md new file mode 100644 index 000000000000..5933a244c85d --- /dev/null +++ b/Design/CloudTeamCollections/tasks/06-api-endpoints.md @@ -0,0 +1,43 @@ +# 06 — API endpoints (Wave 3, after 05) + +**Goal**: expose cloud operations to the browser UI. + +**Dependencies**: 05. **Exclusive owner of shared file `TeamCollectionApi.cs`** during this +task. Owns new `src/BloomExe/web/controllers/SharingApi.cs`. + +## Steps +- [x] `SharingApi`: `sharing/members` (GET), `sharing/addApproval`, `sharing/removeApproval`, + `sharing/setRole`, `sharing/loginState`, `sharing/login`, `sharing/logout`, + `collections/mine` (Get-my-Team-Collections), `collections/pullDown`. Plus (matching what + the Wave-2/UI tasks already call): `sharing/showSignIn`, `sharing/forceUnlock`, + `sharing/history`, `sharing/historyCache`. +- [x] `TeamCollectionApi` additions (existing endpoints untouched for folder TCs): book-status + JSON gains `localVersionSeq`/`repoVersionSeq`/`signedIn`/capability flags (additive); + `teamCollection/receiveUpdates`; `sendAll` alias of checkInAllBooks for cloud; force-unlock + routes through the audited RPC. Plus `teamCollection/capabilities`, `tcStatusMetadata`, + `cloudCollectionId`, `isUserAdmin`, `createCloudTeamCollection`, + `showCreateCloudTeamCollectionDialog` (all named by the Wave-2 UI tasks' mocks). +- [x] Websocket pushes for member-list changes and status refresh reuse existing contexts + (`BloomWebSocketServer.SendEvent`, same as folder TCs already use). +- [x] One authorized migration: `20260707000006_tc_locked_by_display.sql` + (`lockedByEmail`/`lockedByName` on get_collection_state/get_changes/get_book_manifest). + +## Acceptance +- `SharingApiTests` + `TeamCollectionApiTests` additions (permissions, listing, status fields). + DONE: `SharingApiTests.cs` (9 tests) + `TeamCollectionApiCloudTests.cs` (10 tests). +- Folder-TC API behavior byte-identical (existing tests untouched and green). DONE: full + `~TeamCollection` suite 311/311; `AddCloudBookStatusFields` returns the exact same string + instance (not even a re-parse) when the current collection isn't a `CloudTeamCollection`. + +**Agent notes**: Sonnet. Thin pass-throughs to `CloudCollectionClient`; no business logic here. + +## Progress log +- 7 Jul 2026 · done: full task — migration (live-verified against the local dev stack), all + SharingApi + TeamCollectionApi endpoints, live-verification fixes to + `CloudCollectionClient.MembersRemove/MembersSetRole` (wrong RPC param name — found by actually + calling the deployed RPCs) and a `CloudTeamCollection` auth-initialization gap (reopening an + existing cloud TC never called `InitializeAtStartup`), tests (112/112 Cloud, 31/31 + TeamCollectionApi, 311/311 full TeamCollection). See the task's final report (orchestrator + merge notes) for the full endpoint list, known gaps (pullDown's untested manager=null path, + no dedicated sign-in dialog yet, WireUpForWinforms bundle-collision finding for the UI team), + and exact next action (UI wiring, task 07/08's "next" notes). diff --git a/Design/CloudTeamCollections/tasks/07-ui-setup.md b/Design/CloudTeamCollections/tasks/07-ui-setup.md new file mode 100644 index 000000000000..a5ef6fb585c5 --- /dev/null +++ b/Design/CloudTeamCollections/tasks/07-ui-setup.md @@ -0,0 +1,299 @@ +# 07 — UI: setup, settings, sharing panel, chooser (Wave 1 shells → Wave 3 wiring) + +**Goal**: the create/share/join surfaces, Notion-simple. + +**Dependencies**: shells against mocked endpoints in Wave 1; real wiring after 06. +Owns new `src/BloomBrowserUI/teamCollection/SharingPanel.tsx`, +`JoinCloudCollectionDialog.tsx`; **exclusive owner of** `CreateTeamCollection.tsx`, +`TeamCollectionSettingsPanel.tsx`, `CollectionChooserDialog` during its waves. + +## Steps +- [x] Settings (not shared): keep folder-TC button; add "Share this collection on the Bloom + sharing server (experimental)" behind the experimental flag + feature gate, disabled + state explains gating. +- [x] Cloud create dialog: sign-in step (inline; in dev auth mode this is a plain + email/password form driven by `sharing/loginState`'s reported mode — the real + BloomLibrary browser flow slots in later), immutable-name acknowledgement, initial Send + progress; no folder chooser, no Dropbox checkboxes, no restart. +- [x] SharingPanel (cloud TCs): approved-emails list (avatar, name-when-claimed, email, role + chip, claimed/pending), add-with-role, remove (warns: force-unlocks their checkouts), + change role; last-admin protections; member read-only view. Folder TCs keep old panel. +- [x] Collection chooser: "Get my Team Collections" (signed-out state included); pull-down join + via the six-scenario dialog (new states: NotSignedIn, ApprovalRemoved). +- [x] Registration dialog: email unlock for cloud TCs (identity = account). +- [x] All strings via XLF (DistFiles/localization/en only), Send/Receive terminology. + +## Acceptance +- vitest browser-mode component tests: SharingPanel CRUD/pending/last-admin/read-only; + chooser listing + signed-out; create dialog gating and flow. +- `yarn lint` clean. (Never run `yarn build`.) + +**Agent notes**: Sonnet. Emotion `css` prop styling; arrow-function components; no prop +destructuring — follow src/BloomBrowserUI/AGENTS.md. + +## Progress log +- 2026-07-06 · done: `sharingApi.ts` (shells for the Wave-3 SharingApi endpoints) and + `SharingPanel.tsx`/`SharingMembersList` (approved-accounts CRUD, claimed/pending, last-admin + protection, member read-only view) with `SharingPanel.test.tsx` (7 tests, all green); also + fixed a pre-existing vitest gap (`processSimpleMarkdown` missing from the + `localizationManager` mock, which crashed any test rendering `Div`/`BloomButton`/etc.) · + next: wire the "Share this collection..." experimental-gated entry point into + TeamCollectionSettingsPanel.tsx (step 1). +- 2026-07-06 · done: resumed session; re-verified WIP files are prettier-clean (no changes) + and `SharingPanel.test.tsx` (7 tests) still passes — `yarn vitest run` in default `forks` + pool hit "Timeout starting forks runner" on this machine, so re-ran with `--pool=threads`, + which passed cleanly in 37.8s; will use `--pool=threads` for the rest of this session. +- 2026-07-06 · done: step 1 (Settings). Added + `useIsCloudTeamCollectionsExperimentalFeatureEnabled()` to `sharingApi.ts` (reads + `app/enabledExperimentalFeatures`, checks for the `cloud-team-collections` token — matches + `ExperimentalFeatures.kCloudTeamCollections` in C#) and a new "Share this collection on the + Bloom sharing server (experimental)" `BloomButton` in `TeamCollectionSettingsPanel.tsx`'s + not-yet-a-TC branch, disabled until the experimental flag is on, with + `l10nTipEnglishDisabled` explaining the gate; posts + `teamCollection/showCreateCloudTeamCollectionDialog` (new Wave-3 endpoint) when enabled. + Also added `createCloudTeamCollection()` to `sharingApi.ts` for the next step · next: cloud + create dialog (sign-in → immutable-name ack → initial Send progress) in + `CreateTeamCollection.tsx` (step 2). +- 2026-07-06 · done: step 2 (Cloud create dialog). Added `CreateCloudTeamCollectionBody` + (presentational, four states derived from props: sign-in [dev-mode email/password form or a + cloud-mode "Sign in with your Bloom account" placeholder button per `loginState.mode`] → + immutable-name-acknowledgement checkbox gating the Share button → sending [LinearProgress] → + done/error) and `CreateCloudTeamCollectionDialog` (container wiring it to + `useSharingLoginState`/`signIn`/`createCloudTeamCollection` from `sharingApi.ts` and the + BloomDialog frame; no folder chooser, no Dropbox checkboxes; bottom button is Cancel until + done, then Close — no restart) in `CreateTeamCollection.tsx`. Fixed `createCloudTeamCollection` + to use `postJson` instead of `post` (the latter is fire-and-forget and returns no promise — + would have crashed the `.then()` chain). Added `CreateCloudTeamCollection.test.tsx` (10 + tests, all green, same raw-DOM-render pattern as `SharingPanel.test.tsx`) covering sign-in + gating (dev vs cloud mode), email/password field wiring, name-ack gating of the Share button, + sending/error/done states. `yarn eslint` on all touched files: 0 errors, pre-existing warnings + only (unrelated lines in the original `CreateTeamCollectionDialog`) · next: Collection chooser + "Get my Team Collections" (signed-in listing + signed-out state) in `CollectionChooser.tsx` + (step 3), then `JoinCloudCollectionDialog.tsx` (new, six-scenario + NotSignedIn + + ApprovalRemoved) for pull-down join. +- 2026-07-06 · done (partial step 3 — listing half; pull-down-join dialog still to do): added + `MyCloudCollectionsSection.tsx` (new; presentational "Get my Team Collections" sidebar of the + collection chooser: signed-out sign-in prompt, loading, empty state, and a listing with a + per-row pull-down button) and wired it into `CollectionChooser.tsx` alongside the existing + recent-collections grid, backed by `useSharingLoginState`/`useMyCloudCollections`/ + `pullDownCollection` from `sharingApi.ts`. Added `MyCloudCollectionsSection.test.tsx` (4 + tests, all green) covering signed-out/loading/empty/listing+pull-down-click. Found and fixed + the same "l10n `Div`/`P`/`Span` don't forward `data-testid`" trap as elsewhere in this task — + wrapped in a plain `
` for the loading/empty states. `yarn eslint` clean + on all touched files · next: `JoinCloudCollectionDialog.tsx` (new) — the pull-down-join dialog + with the six folder-TC scenarios adapted to cloud collections plus NotSignedIn and + ApprovalRemoved (8 states total), to complete step 3; `onPullDown` in `CollectionChooser.tsx` + will eventually need to open it once it exists (Wave-1 shell can leave the direct + `pullDownCollection` call as the placeholder action for now). +- 2026-07-06 · done (step 3 complete): added `JoinCloudCollectionDialog.tsx` (new) — the + pull-down-join dialog, structurally mirroring the folder-TC `JoinTeamCollectionDialog.tsx`'s + six scenarios (CreateNewCollection, MatchesExistingNonTeamCollection, + MatchesExistingTeamCollection[Elsewhere], MatchesDifferentTeamCollection, + IncompleteLocalCopy — renamed from IncompleteTeamCollection since the cloud failure mode is a + corrupt local pull-down cache, not a missing ".txt" file) plus two new states unique to cloud + collections: NotSignedIn (action button becomes "Sign In", posts `sharing/showSignIn`) and + ApprovalRemoved (action button disabled, explains the user isn't on the approved list). The + join/pull-down action calls `pullDownCollection` from `sharingApi.ts`. Added + `JoinCloudCollectionDialog.test.tsx` (8 tests, all green) covering all eight states; had to + query `document.body` rather than the local render container since MUI's `Dialog` portals its + content, and to assert on l10n *keys* rather than English text because the test-only + `localizationManager` mock resolves every key to itself (same trap noted in + `SharingPanel.test.tsx`). Not wired into `CollectionChooser.tsx`'s `onPullDown` yet — knowing + which of the eight states applies requires the six-scenario matching logic that task + 05-cloud-backend.md says lives server/backend-side and doesn't exist until later; the chooser + keeps calling `pullDownCollection` directly for now (Wave-1 shell). Similarly, `SharingPanel` + is not wired into `TeamCollectionSettingsPanel.tsx`'s `isTeamCollection` branch yet — that + needs a "is this a cloud TC" signal that isn't available until the backend capability flags + land; both are complete, tested, standalone shells ready for that wiring. `yarn eslint` + clean on all touched files · next: Registration dialog email unlock for cloud TCs (step 4), + then XLF strings for everything added in this task (step 5, follow + `.github/skills/xlf-strings/SKILL.md`, only `DistFiles/localization/en/`). +- 2026-07-06 · done: step 4 (Registration dialog). Added `cloudAccountEmail?: string` to + `IRegistrationContentsProps` (registrationTypes.ts) and `IRegistrationDialogProps` + (registrationDialogLauncher.tsx), threaded through `RegistrationDialog` and both launchers + (`RegistrationDialogLauncher`, `RegistrationDialogEventLauncher`). In + `registrationContents.tsx`, when `cloudAccountEmail` is set: the email field is force-synced + to it and always locked (`mayChangeEmail` becomes `false` regardless of the prop — identity is + the signed-in account, not a free-text field), with a distinct label + ("RegisterDialog.CloudAccountEmail") and an explanatory note + ("RegisterDialog.CloudAccountEmailNote") instead of the folder-TC "Check in to change email" + message (which means something different — "already registered" rather than + "tied to your account"). Wired this into `CreateCloudTeamCollectionDialog` in + `CreateTeamCollection.tsx`: `startSend` now checks `registration/userInfo` first (same pattern + as the folder dialog's `tryToCreate`) and shows the registration dialog with + `cloudAccountEmail: loginState.email` if not yet registered, only calling the actual + `createCloudTeamCollection()` (renamed the old body to `doSend`) once registered. No new test + file added (no pre-existing vitest coverage for the registration components in this repo — + only Playwright `.uitest.ts` files, which are excluded from the vitest run and out of scope + here); re-ran the full task test suite (29 tests across + SharingPanel/CreateCloudTeamCollection/MyCloudCollectionsSection/JoinCloudCollectionDialog) to + confirm no regressions. `yarn eslint` clean on all touched files · next: step 5 — XLF strings + for every new user-visible string added across this whole task (follow + `.github/skills/xlf-strings/SKILL.md`; only `DistFiles/localization/en/`). This is the last + step before final report. +- 2026-07-07 · done: step 5 (XLF strings) was already completed in the prior WIP commit + (`8c82b4b37`) but not yet logged/verified — closing out the task. Confirmed every new + user-facing string across `CollectionChooser`/`MyCloudCollectionsSection`, `SharingPanel`, + `CreateTeamCollection`'s cloud dialog, `JoinCloudCollectionDialog`, and the registration + cloud-account additions goes through `l10nKey`/`Div`/`P`/`Span`/`BloomButton`/ + `AttentionTextField` (no hardcoded UI text), and that every one of those keys has a matching + `translate="no"` entry with an `ID:` note in `DistFiles/localization/en/Bloom.xlf` or + `BloomMediumPriority.xlf` (priority split follows the SKILL.md table: primary actions/labels + in `Bloom.xlf`, secondary/help/error text in `BloomMediumPriority.xlf`), each with a second + translator-context note wherever the string is short/generic, a fragment, or contains a + placeholder or product name — including the auto-derived + `TeamCollection.Sharing.ShareOnCloudServer.ToolTipWhenDisabled` key consumed via + `l10nTipEnglishDisabled` on the Settings button. No existing entries were modified. Only + `DistFiles/localization/en/` was touched. Terminology check: the initial collection upload + uses "Send"/"Sending" (matching folder-TC Send/Receive language), and the join/pull-down flow + uses "Get"/"download" — consistent with the existing folder-TC `JoinTeamCollectionDialog`'s + own wording, so no new/conflicting terminology was introduced. +- 2026-07-07 · done: final wrap-up for task 07. Ran `yarn prettier --write` on every file this + branch touched (`git diff --name-only 087e9a725..HEAD`); all 16 already matched (the two WIP + commits that skipped the pre-commit hook turned out to be prettier-clean already, so no + reformatting was needed). Re-ran the full component-test suite in single-run mode: + `yarn vitest run --pool=threads teamCollection/SharingPanel.test.tsx + teamCollection/CreateCloudTeamCollection.test.tsx + collection/MyCloudCollectionsSection.test.tsx teamCollection/JoinCloudCollectionDialog.test.tsx` + → 4 test files, 29 tests, all passing. Ran `yarn lint` across the whole project: 0 errors, + 777 warnings, none of them on lines this branch added or in files newly created by this + branch (checked `CreateTeamCollection.tsx`'s two reported warnings and `vitest.setup.ts`'s + one — both sit on pre-existing lines outside this branch's diff hunks). All five task steps + are now checked off. Task 07 (Wave-1 shells) is complete and ready for Wave-3 wiring per the + "next" notes left in each shell (SharingPanel into `TeamCollectionSettingsPanel`'s + `isTeamCollection` branch; `JoinCloudCollectionDialog`'s eight-state matching logic into + `CollectionChooser`'s `onPullDown`) once the relevant backend capability flags/matching logic + from tasks 05/06 land — see the note at the end of the 2026-07-06 step-3 entry above. + +## Wave-3 UI wiring (task/ui-wiring branch) + +- 2026-07-07 · done: item 1 (the live folder-TC breakage) + item 2 (dedicated sign-in dialog). + Root cause confirmed: `createTeamCollectionDialogBundle` is ONE shared Vite entry + (`CreateTeamCollection.tsx`) that used to have both `CreateTeamCollectionDialog` (folder) and + `CreateCloudTeamCollectionDialog` (cloud) call `WireUpForWinforms` at module scope — that + function sets a single global (`window.wireUpRootComponentFromWinforms`), so whichever call + ran last at module load silently won, breaking the other dialog (in practice: the folder-TC + "Create Team Collection" dialog could no longer open, since the cloud dialog's call always + ran second in source order). Fixed by introducing `CreateTeamCollectionBundleDispatcher` + (new, in `CreateTeamCollection.tsx`) as the ONLY component in the file that calls + `WireUpForWinforms`; it renders one of `CreateTeamCollectionDialog` / + `CreateCloudTeamCollectionDialog` / the new `SignInDialog` based on a `dialogKind` prop + ("folder" / "cloud" / "signIn") that C# now always passes. Updated all three C# call sites to + pass it: `TeamCollectionApi.cs`'s `HandleShowCreateTeamCollectionDialog` (`"folder"`) and + `HandleShowCreateCloudTeamCollectionDialog` (`"cloud"`), and `SharingApi.cs`'s + `HandleShowSignIn` (`"signIn"`, also shrunk its dialog size from 600x580 to 420x320 to fit + the much smaller sign-in-only form). Also fixed the parallel Vite-dev-mode entry + (`CreateTeamCollection.entry.tsx`), which bootstrapped `CreateTeamCollectionDialog` by name + but — since it's the same module — was *already* silently rendering whichever dialog's + `WireUpForWinforms` call happened to win, the same bug in a different guise; it now + bootstraps the dispatcher too. New `SignInDialog.tsx` (item 2): a small dedicated sign-in + dialog (dev-mode email/password form per `loginState.mode`; a "Signing in ... isn't available + yet" message for the eventual production/"cloud" mode), replacing the old placeholder + behavior where `sharing/showSignIn` reused the cloud create-collection dialog's first screen + even in contexts unrelated to creating a collection. Auto-closes once `useSharingLoginState` + reports `signedIn: true` (picks up the "sharing"/"loginState" websocket event + `SharingApi.HandleLogin` already raises). Reuses three existing XLF keys + (`TeamCollection.Sharing.EmailAddress`/`Password`/`SignIn`) and adds one new one, + `TeamCollection.Sharing.SignInNotYetAvailable`, to `BloomMediumPriority.xlf` (secondary/error + text per the skill's priority table). New tests: `SignInDialog.test.tsx` (4 tests: dev-mode + form, dev-mode Sign In click, dev-mode error display, cloud-mode not-yet-available message — + the presentational `SignInDialogBody` only, same pattern as `CreateCloudTeamCollectionBody`'s + own tests) and `CreateTeamCollectionBundleDispatcher.test.tsx` (4 tests: a direct regression + test for the bug, proving the dispatcher renders the right component for every `dialogKind` + including the omitted/default case, which must still be the folder dialog). Full run: + `yarn vitest run --pool=threads teamCollection/CreateTeamCollectionBundleDispatcher.test.tsx + teamCollection/SignInDialog.test.tsx teamCollection/CreateCloudTeamCollection.test.tsx + teamCollection/SharingPanel.test.tsx teamCollection/JoinCloudCollectionDialog.test.tsx` → 5 + files, 33 tests, all green (no regressions in the two pre-existing suites this change's + neighbors touch). `yarn eslint` on all touched/new files: 0 errors, 3 pre-existing warnings + (all on lines predating this change, in the untouched `CreateTeamCollectionDialog` body). + `dotnet build src/BloomExe/BloomExe.csproj`: succeeds, 0 errors (needed `./init.sh` first in + this fresh worktree — missing `PodcastUtilities`/`IDevice` per AGENTS.md's known-issue note). + Note for future sessions in this sandbox: `yarn vitest`/`yarn eslint`/`dotnet build` need + `dangerouslyDisableSandbox: true` on the Bash call — the default sandbox blocks + worker-thread/child-process spawning, which otherwise fails every vitest pool + ("Timeout starting threads runner") within ~5s. · next: item 3 (wire `SharingPanel` into + `TeamCollectionSettingsPanel`'s `isTeamCollection` branch for cloud TCs). +- 2026-07-07 · done: item 3. `TeamCollectionSettingsPanel.tsx`'s `isTeamCollection` branch now + renders `SharingPanel` (collectionId from `useCloudCollectionId()`, currentUserEmail from + `useSharingLoginState().email`, isAdmin from `useIsTeamCollectionAdmin()`) when + `isCloudTeamCollection(useTeamCollectionCapabilities())` is true; folder TCs keep the old + free-text administrator-emails field + "Cloud Storage Folder Location" link completely + unchanged (the link/`fileIO/showInFolder` call doesn't make sense for a cloud TC's + `cloud://sil.bloom/collection/{id}` `RepoDescription`, confirmed by reading + `CloudTeamCollection.RepoDescription` in `TeamCollection/Cloud/CloudTeamCollection.cs`). All + four hooks used are already gated internally on the experimental feature, so this adds zero + extra requests for folder TCs. New `TeamCollectionSettingsPanel.test.tsx` (3 tests: folder TC + shows the old field not SharingPanel; cloud TC shows SharingPanel wired to the real + collectionId/email/isAdmin values not the old field; not-yet-a-TC shows neither) — mocks + `teamCollectionApi`/`sharingApi`/`SharingPanel` itself (already covered by its own + `SharingPanel.test.tsx`) so this file only tests the branching logic this task adds. +- 2026-07-07 · done: item 4. `JoinCloudCollectionDialog`'s `handleJoinClick` now actually wires + `pullDownCollection`'s promise (previously fired-and-forgotten): success closes the dialog and + calls a new optional `onClose` prop; failure shows the server's real error message in a new + `ErrorBox` and re-enables the action button for retry. Added the `onClose` prop and removed + the dialog's own (unused, and actively dangerous once embedded — see below) `WireUpForWinforms` + call. `CollectionChooser.tsx`'s `onPullDown` now looks up the clicked row's full + `ICloudCollectionSummary` from the already-fetched `cloudCollections` list and renders + `JoinCloudCollectionDialog` inline (embedded directly in the tree, not as a separate WinForms + dialog — there never was a C# call site or bundle entry for one), passing the real + `signedIn`/`collectionId`/`collectionName`. Documented, deliberate scope limit: no endpoint + exposes the six local-vs-remote matching flags (`existingCollection` etc.) ahead of an actual + pull-down attempt — `CloudJoinFlow.DetermineScenario`/`JoinCollection` (task 05) resolve that + server-side, inside `collections/pullDown` itself, and `SharingApi.HandlePullDown` only + surfaces a human-readable message on conflict (via `CloudJoinConflictException.Message`), not + the structured `JoinScenario` enum/`LocalCollectionFolder` it also carries. So the dialog + defaults to the ordinary `CreateNewCollection` copy and, if a real conflict is hit, shows the + server's real message instead of guessing which of the eight dialog states to switch to (a + future task could plumb `JoinScenario`/`LocalCollectionFolder` through the failure response to + make this exact, but that's new backend surface, out of this wiring task's scope). Found (and + fixed as part of this item, since it would otherwise become live once `JoinCloudCollectionDialog` + is imported into `CollectionChooser`'s bundle) a second instance of item 1's bug class: the + dialog's leftover `WireUpForWinforms(JoinCloudCollectionDialog)` call (dead code — no C# call + site/bundle entry ever pointed at it) would have silently overwritten + `CollectionChooserDialog`'s own `WireUpForWinforms` registration in the shared + `collectionChooserBundle` the moment this task's import wired them into the same module graph. + New tests: 2 added to `JoinCloudCollectionDialog.test.tsx` (closes + calls `onClose` on + success; shows the server's real error and stays open, button re-enabled, on failure — updated + the file's `pullDownCollection` mock to return a resolved promise by default, since the + existing 8 state tests all synchronously assert `pullDownCollection` was called without + needing to await it) and new `CollectionChooser.test.tsx` (2 tests: pull-down opens the dialog + for the exact row clicked with real login/collection data, and `onClose` removes it; dialog + never renders when the cloud feature is off) — mocks `sharingApi`/`JoinCloudCollectionDialog` + itself. +- 2026-07-07 · done: item 5 (sweep for lingering mock-only defaults). No behavioral defaults + needed changing: every `get()` call in `sharingApi.ts`/`teamCollectionApi.tsx` already omits an + error callback, so a real endpoint failure throws (unhandled rejection, reported to + Sentry/console) rather than being silently swallowed into some "safe" default — already + fail-fast per AGENTS.md, and consistent with every other hook in this file family. The `?? []` + /`?? initialTeamCollectionCapabilities`-style fallbacks that exist are all inside the SUCCESS + callback (a 200 response with an unexpectedly-empty body), not the failure path, so they don't + mask anything. What WAS stale: doc comments in `sharingApi.ts` (top-of-file), `teamCollectionApi.tsx` + (the Cloud Team Collections section header), `CreateTeamCollection.tsx` (the cloud-dialog + section banner), and `TeamCollectionButton.test.tsx` claiming the relevant endpoints were + "not-yet-implemented" / "mocked" / task-06-pending — all now real per task 06's merge. Left as + a misleading residue, this is exactly the kind of thing that could make a future + agent/developer distrust or re-mock already-real endpoints, or skip auditing their error paths + on the (false) assumption they're still shells. Updated all four to say what's actually true + now. No production code behavior changed by this item. +- 2026-07-07 · done: item 6 (final sweep). `yarn vitest run --pool=threads teamCollection + collectionsTab collection react_components/TopBar`: this sandbox's vitest pool is slow enough + (each file's "collect" phase alone often 40-100s+) that the single combined invocation the + task brief names hung at final teardown past this session's tool timeout before printing its + summary line (no failures were reported by any individual file before that point, and no + hanging-process reporter was needed to diagnose it: `ps aux | grep vitest` showed the process + already gone, so it was killed externally, not deadlocked) — split into per-directory/file runs + instead to get a clean result within the timeout, then cross-checked file counts (`find + teamCollection -name "*.test.tsx"` etc.) against what each run actually reported to make sure + nothing silently dropped. Full accounting: `teamCollection` (11 files, 65 tests: ShareButton 4, + JoinCloudCollectionDialog 10, CollectionHistoryTable 5, CreateTeamCollectionBundleDispatcher 4, + TeamCollectionBookStatusPanel 11, SharingPanel 7, CreateCloudTeamCollection 10, SignInDialog 4, + TeamCollectionDialog 5, TeamCollectionSettingsPanel 3, NewerVersionAvailableMarker 2), + `collectionsTab` (0 test files -- nothing to run there), `collection` (2 files, 6 tests: + MyCloudCollectionsSection 4, CollectionChooser 2), `react_components/TopBar` (1 file, 8 tests: + TeamCollectionButton). **Total: 14 files, 79 tests, all green, zero failures.** `yarn lint` + (whole project, one process, no pool-timeout issue): 0 errors, 777 warnings -- the exact same + count task 07's own final wrap-up entry reported before this branch's work started, confirming + zero new warnings introduced across all six items. `dotnet build src/BloomExe/BloomExe.csproj` + re-verified clean (0 errors) after the last commit. All six work items from + the ui-wiring launch prompt (a scratch file, since removed) are now complete. diff --git a/Design/CloudTeamCollections/tasks/08-ui-collection-tab.md b/Design/CloudTeamCollections/tasks/08-ui-collection-tab.md new file mode 100644 index 000000000000..43b93981e072 --- /dev/null +++ b/Design/CloudTeamCollections/tasks/08-ui-collection-tab.md @@ -0,0 +1,210 @@ +# 08 — UI: collection tab (Wave 2 shells → Wave 3 wiring) + +**Goal**: status button, status/history dialog, Share button, per-book panel states. + +**Dependencies**: shells in Wave 2 (mocked); wiring after 06. **Exclusive owner of** +`TeamCollectionButton.tsx`, `TeamCollectionDialog.tsx`, `TeamCollectionBookStatusPanel.tsx`, +`statusPanelCommon.tsx`, `CollectionHistoryTable.tsx` during its waves. + +## Steps +- [x] Status button: same chip/colors, driven by live metadata ("Updates available (3 books)"). +- [x] Status dialog: "Receive Updates" primary action (Reload remains only for applied + collection-settings changes); "Send All"; message log unchanged. +- [x] Share button beside the status button → SharingPanel (admin manage / member read-only). +- [x] Per-book panel: keep Check out/Check in + note field + avatars; add signedOut (with + Sign-in action), updatesAvailable, offline-disabled-with-reason states; check-in progress + = modal Send; "Force Unlock (Administrator Only)" wired to the audited RPC. +- [x] Book thumbnails: holder-avatar overlay unchanged; subtle "newer version exists" marker. +- [x] History tab: server events feed for cloud TCs (incl. incident entries), local cache for + offline; folder TCs unchanged. + +## Acceptance +- Component tests: panel state matrix (incl. new states), status-button states, history + rendering incl. incidents. +- `yarn lint` clean; folder-TC UI behavior unchanged. + +**Agent notes**: Sonnet. `StatusPanelState` additions must stay in sync with the C# status +JSON (CONTRACTS.md, book-status section). + +## Progress log + +- 7 Jul 2026 · Status button done. Added `teamCollectionApi.tsx` shared plumbing for Wave 2: + `ITeamCollectionCapabilities`/`useTeamCollectionCapabilities` (mocked `teamCollection/capabilities`), + `isCloudTeamCollection()` helper (branch on capability, not concrete type), + `useTeamCollectionStatusMetadata` (mocked `teamCollection/tcStatusMetadata`), + `useCloudCollectionId`/`useIsTeamCollectionAdmin` (mocked, for the upcoming Share button), and + the additive `IBookTeamCollectionStatus` fields from CONTRACTS.md + (localVersionSeq/repoVersionSeq/signedIn/requiresSignIn/offlineDisabledReason). All these hooks + only call their endpoint when the cloud-team-collections experimental feature is on, so folder + Team Collections make zero extra requests and see zero UI change. `TeamCollectionButton.tsx` now + shows "Updates Available (N books)" when the metadata provides a count. Also front-loaded the + full Wave-2 XLF string set into `Bloom.xlf` (steps 1-4's strings) in this commit, since they were + designed together; later steps consume already-added ids rather than adding more XLF. New test: + `TeamCollectionButton.test.tsx` (8 tests, passing). `yarn eslint` clean (1 pre-existing warning + unrelated to this change, in `useTColBookStatus`'s dependency array). + Next action: implement step 2 (status dialog "Receive Updates" / "Send All"), then run its + tests + prettier + commit. +- 7 Jul 2026 · Status dialog done (resumed after a session-limit interruption; predecessor's + in-flight `TeamCollectionDialog.tsx` change and new `TeamCollectionDialog.test.tsx` had been + preserved by the orchestrator in a WIP commit). `checkInAll`'s l10nKey/label switches to + `TeamCollection.SendAll`/"Send All" and its post target to `teamCollection/sendAllBooks` when + `isCloudTeamCollection(useTeamCollectionCapabilities())`; a new `receiveUpdates` button + (`TeamCollection.ReceiveUpdates`, posts `teamCollection/receiveUpdates`) appears beside the + existing "Reload Collection" button only for cloud TCs and only when `showReloadButton` is + false, so the two are mutually exclusive (Reload stays reserved for applied collection-settings + changes, per the design doc). Folder Team Collections are unaffected: `isCloud` is false + whenever the capabilities hook's mocked endpoint is never called (flag off) or reports no cloud + support, so `checkInAll` keeps its exact previous key/label/endpoint and `receiveUpdates` never + renders. Fixed up the predecessor's WIP test file: removed leftover debug `console.log`s, made + its `afterEach` use the repo's established `renderedContainer`/`unmountRoot` cleanup (matching + `SharingPanel.test.tsx`/`JoinCloudCollectionDialog.test.tsx`) instead of a bare + `document.body.innerHTML = ""`, and — the actual bug blocking all but one assertion — switched + button lookups from matching visible English text to matching by element `id` + (`checkInAll`/`receiveUpdates`/`reload`), because the vitest-only localizationManager mock + resolves every `l10nKey` to the key itself rather than the English fallback (same gotcha + documented in `JoinCloudCollectionDialog.test.tsx`'s file comment); the predecessor's + English-text lookups could never match and were failing 4 of 5 tests before this fix. + `TeamCollectionDialog.test.tsx`: 5 tests, all passing. `yarn eslint` clean on both changed + files. No new XLF entries needed — `TeamCollection.SendAll`/`TeamCollection.ReceiveUpdates` + were already front-loaded into `Bloom.xlf` in the step-1 commit. + Next action: implement step 3 (Share button beside the status button → SharingPanel). +- 7 Jul 2026 · Share button done. New `teamCollection/ShareButton.tsx`: gated on + `isCloudTeamCollection(useTeamCollectionCapabilities())` — folder Team Collections render + nothing (not even a hidden node), so this is strictly additive UI. For cloud TCs, renders a + `TeamCollection.Sharing.ShareButton` ("Share") button next to `TeamCollectionButton` (wired + into `CollectionTopBarControls.tsx`, which isn't reserved by any other Wave-2 task per + IMPLEMENTATION.md's shared-file schedule); clicking it opens an MUI `Popover` anchored under + the button containing the existing `SharingPanel` (task 07), fed by + `useCloudCollectionId()`/`useIsTeamCollectionAdmin()`/`useSharingLoginState().email` — so an + admin gets the manage view and a regular member gets SharingPanel's own read-only view, with + no new branching needed here. No new XLF: `TeamCollection.Sharing.ShareButton` was already + front-loaded in the step-1 commit; used `@mui/icons-material/Share` (no dialog title needed, + so no new string). New test: `ShareButton.test.tsx` (4 tests: folder TC renders nothing; + cloud TC shows the button without opening the panel; admin click opens SharingPanel with + `isAdmin: true`; non-admin click opens it with `isAdmin: false` and the member's own email) — + SharingPanel itself is mocked (already unit-tested in `SharingPanel.test.tsx`) to a + prop-recording stub; MUI's Popover portals to `document.body` like MUI Dialog, so assertions + query `document` (same pattern as `JoinCloudCollectionDialog.test.tsx`). All 4 passing. + `yarn eslint` clean on the 3 changed/added files. Re-ran `TeamCollectionButton.test.tsx` (8 + tests) to confirm the `CollectionTopBarControls.tsx` layout change (wrapped + `TeamCollectionButton` + new `ShareButton` in a flex div) didn't regress it. + Next action: implement step 4 (per-book panel states: signedOut/updatesAvailable/ + offline-disabled-with-reason, Force Unlock wiring) in `TeamCollectionBookStatusPanel.tsx`. +- 7 Jul 2026 · Per-book panel done. `TeamCollectionBookStatusPanel.tsx`: added + `isCloud = isCloudTeamCollection(useTeamCollectionCapabilities())` and three new + `StatusPanelState` values, all cloud-gated in the state-derivation effect (folder TCs never + produce them, since their driving fields are always undefined there, but the effect also + explicitly checks `isCloud` per the gating rule): `offlineDisabled` (from + `props.offlineDisabledReason`, checked right after the existing `invalidRepoDataErrorMsg` + check — a book that can't be used offline at all takes priority over lock state) renders the + server-supplied reason verbatim (unlocalized, same precedent as `props.error` elsewhere in + this file); `signedOut` (from `requiresSignIn && !signedIn`, checked where the book would + otherwise be "unlocked") shows `TeamCollection.SignedOut`/`SignedOutDescription` with a + `TeamCollection.Sharing.SignIn` button posting `sharing/showSignIn` (same action + `JoinCloudCollectionDialog`'s NotSignedIn case uses); `updatesAvailable` (from + `repoVersionSeq > localVersionSeq` on an otherwise-unlocked book) shows + `TeamCollection.UpdatesAvailableForBook(Description)` with the existing + `TeamCollection.ReceiveUpdates` button/endpoint reused from step 2. Check-in progress "modal + Send": for cloud TCs, the `lockedByMe` case's inline yellow progress bar is replaced by a + `BloomDialog` (title reuses the existing `checkingIn` string) with an MUI `LinearProgress` + driven by the same `checkInProgress` websocket-driven state folder TCs already use; folder + TCs keep the exact inline bar. Force Unlock: `ForceUnlockDialog.tsx` now posts + `sharing/forceUnlock` instead of `teamCollection/forceUnlock` when `isCloud` — per + CONTRACTS.md this maps (Wave-3) to the audited `force_unlock(book_id)` RPC ("admin; audited; + emits ForcedUnlock event"); no client-side audit logic needed since the RPC handles that + server-side. No new XLF: every string above (`SignedOut`, `SignedOutDescription`, `Sharing.SignIn`, + `UpdatesAvailableForBook`, `UpdatesAvailableForBookDescription`, `ReceiveUpdates`, + `OfflineDisabled`, and the reused `CheckingIn`) was already front-loaded or pre-existing. + New test: `TeamCollectionBookStatusPanel.test.tsx` (11 tests) covering the folder-TC state + matrix (unlocked/locked/lockedByMe/hasInvalidRepoData unchanged, plus an explicit + gating regression test proving cloud-shaped fields are ignored when capabilities are all + false) and the three new cloud states (signedOut incl. its Sign In action, + signedIn-so-normal-checkout, updatesAvailable incl. its Receive Updates action and the + no-update-when-current case, offlineDisabled's priority + verbatim reason, and the cloud + modal not appearing at rest). All 11 passing. `yarn eslint` clean (only the one + pre-existing, unrelated `checkInProgress` exhaustive-deps warning, confirmed present before + this change too via `git stash`). Re-ran the full `teamCollection` + `CollectionTopBarControls` + suites (7 files, 53 tests) — all green, no regressions. + Next action: implement step 5 (book thumbnails: subtle "newer version exists" marker) — + likely in the book-thumbnail/preview component that overlays the holder avatar (not yet + identified by file name; search for the existing holder-avatar-overlay code first). +- 7 Jul 2026 · Book thumbnails done. Found the holder-avatar overlay in + `collectionsTab/BookButton.tsx` (the per-book thumbnail button in the collection tab's grid, + NOT `TeamCollectionBookStatusPanel.tsx` which is the panel for the currently-selected book): + it renders `` as a sibling of the thumbnail ` + {props.children} +
+ ), + ConfigrPage: (props: React.PropsWithChildren) => ( +
{props.children}
+ ), + ConfigrGroup: (props: React.PropsWithChildren) => ( +
{props.children}
+ ), + ConfigrBoolean: (props: { + label: string; + path: string; + disabled?: boolean; + }) => ( + + ), + ConfigrInput: () => null, +})); + +import { AdvancedSettingsPanel } from "./AdvancedSettingsPanel"; + +function render(): HTMLDivElement { + return renderTestRoot(); +} + +// Mirrors the shape CollectionSettingsApi.GetAdvancedSettingsData returns. +function respondWith(overrides: { + allowCloudTeamCollection?: boolean; + allowCloudTeamCollectionEnabled?: boolean; +}) { + mockGet.mockImplementation( + (url: string, callback: (result: { data: unknown }) => void) => { + if (url === "settings/advancedProgramSettings") { + callback({ + data: { + values: { + autoUpdate: false, + showExperimentalBookSources: false, + allowTeamCollection: false, + allowCloudTeamCollection: + overrides.allowCloudTeamCollection ?? false, + allowAppBuilder: false, + showQrCode: false, + qrcodeCaption: "", + }, + showAutoUpdate: false, + showExperimentalBookSourcesOption: false, + allowTeamCollectionEnabled: true, + allowCloudTeamCollectionEnabled: + overrides.allowCloudTeamCollectionEnabled ?? true, + }, + }); + } + }, + ); +} + +afterEach(() => { + vi.clearAllMocks(); + mockUseGetFeatureStatus.mockReturnValue({ enabled: true }); +}); + +describe("AdvancedSettingsPanel: Cloud Team Collections (experimental) checkbox", () => { + it("renders the checkbox, labeled distinctly from the folder-based Team Collections one", () => { + respondWith({}); + + const container = render(); + + const checkbox = container.querySelector( + '[data-testid="configr-boolean-allowCloudTeamCollection"]', + ); + expect(checkbox).not.toBeNull(); + // The test-only localizationManager mock (vitest.setup.ts) resolves every l10nKey to the + // key itself rather than the English fallback (see JoinCloudCollectionDialog.test.tsx's + // own comment about this), so we assert on the l10n id here, not the English text. + expect(checkbox!.getAttribute("aria-label")).toBe( + "CollectionSettingsDialog.AdvancedTab.Experimental.CloudTeamCollections", + ); + }); + + it("is enabled when the subscription feature status allows it and the host dialog reports allowCloudTeamCollectionEnabled=true", () => { + respondWith({ allowCloudTeamCollectionEnabled: true }); + mockUseGetFeatureStatus.mockReturnValue({ enabled: true }); + + const container = render(); + + const checkbox = container.querySelector( + '[data-testid="configr-boolean-allowCloudTeamCollection"]', + ) as HTMLInputElement; + expect(checkbox.disabled).toBe(false); + }); + + it("is disabled when the CloudTeamCollection subscription feature status is not enabled", () => { + respondWith({ allowCloudTeamCollectionEnabled: true }); + mockUseGetFeatureStatus.mockImplementation( + (featureName: string | undefined) => + featureName === "CloudTeamCollection" + ? { enabled: false } + : { enabled: true }, + ); + + const container = render(); + + const checkbox = container.querySelector( + '[data-testid="configr-boolean-allowCloudTeamCollection"]', + ) as HTMLInputElement; + expect(checkbox.disabled).toBe(true); + }); + + it("is disabled when the host dialog reports allowCloudTeamCollectionEnabled=false (currently connected to a cloud collection)", () => { + respondWith({ allowCloudTeamCollectionEnabled: false }); + mockUseGetFeatureStatus.mockReturnValue({ enabled: true }); + + const container = render(); + + const checkbox = container.querySelector( + '[data-testid="configr-boolean-allowCloudTeamCollection"]', + ) as HTMLInputElement; + expect(checkbox.disabled).toBe(true); + }); + + it("posts the toggled value back to settings/advancedProgramSettings", () => { + respondWith({ allowCloudTeamCollection: false }); + + const container = render(); + + const toggleButton = container.querySelector( + '[data-testid="toggle-allowCloudTeamCollection"]', + ) as HTMLButtonElement; + act(() => { + toggleButton.click(); + }); + + expect(mockPostJson).toHaveBeenCalledWith( + "settings/advancedProgramSettings", + expect.objectContaining({ allowCloudTeamCollection: true }), + ); + }); +}); diff --git a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx index 4e3c6b4a8255..4c65faa9514e 100644 --- a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx +++ b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx @@ -19,6 +19,7 @@ interface IAdvancedSettings { autoUpdate?: boolean; showExperimentalBookSources?: boolean; allowTeamCollection?: boolean; + allowCloudTeamCollection?: boolean; allowAppBuilder?: boolean; showQrCode?: boolean; qrcodeCaption?: string; @@ -31,6 +32,10 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { const [showAutoUpdate, setShowAutoUpdate] = React.useState(false); const [allowTeamCollectionEnabled, setAllowTeamCollectionEnabled] = React.useState(false); + const [ + allowCloudTeamCollectionEnabled, + setAllowCloudTeamCollectionEnabled, + ] = React.useState(false); const [ showExperimentalBookSourcesOption, setShowExperimentalBookSourcesOption, @@ -60,6 +65,10 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { "Team Collections", "TeamCollection.TeamCollections", ); + const cloudTeamCollectionsLabel = useL10n( + "Cloud Team Collections", + "CollectionSettingsDialog.AdvancedTab.Experimental.CloudTeamCollections", + ); const appBuilderLabel = useL10n( "App Builder", "CollectionSettingsDialog.AdvancedTab.Experimental.AppBuilder", @@ -88,12 +97,21 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { const featureStatus = useGetFeatureStatus("TeamCollection"); const teamCollectionOptionEnabled = featureStatus === undefined ? true : featureStatus.enabled; + const cloudTeamCollectionFeatureStatus = useGetFeatureStatus( + "CloudTeamCollection", + ); + const cloudTeamCollectionOptionEnabled = + cloudTeamCollectionFeatureStatus === undefined + ? true + : cloudTeamCollectionFeatureStatus.enabled; const appBuilderFeatureStatus = useGetFeatureStatus("AppBuilder"); const appBuilderOptionEnabled = appBuilderFeatureStatus === undefined ? false : appBuilderFeatureStatus.enabled; const canChangeTeamCollectionOption = allowTeamCollectionEnabled !== false; + const canChangeCloudTeamCollectionOption = + allowCloudTeamCollectionEnabled !== false; const normalizeConfigrSettings = React.useCallback( ( @@ -125,6 +143,9 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { setAllowTeamCollectionEnabled( data["allowTeamCollectionEnabled"] ?? false, ); + setAllowCloudTeamCollectionEnabled( + data["allowCloudTeamCollectionEnabled"] ?? false, + ); setShowExperimentalBookSourcesOption( data["showExperimentalBookSourcesOption"] ?? false, ); @@ -236,6 +257,37 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { /> +
+ {" "} +
+ +
+
void; } // The "..." menu button sits in the top-right corner of the card. The title row @@ -148,14 +156,15 @@ export const CollectionCard: React.FunctionComponent = ( // The unpublished count is somewhat expensive to calculate, so it's not likely to be // included in the initial data we get. Instead, we use an effect to fetch it when the - // card is rendered. + // card is rendered. A join card has no local folder to ask about, so it never fetches this + // (or anything else per-card -- see the batch plan's "no per-card fetches for join cards"). React.useEffect(() => { - if (props.unpublishedCount !== undefined) return; + if (props.isJoinCard || props.unpublishedCount !== undefined) return; get( `collections/getUnpublishedCount?collectionPath=${encodeURIComponent(props.path)}`, (r) => setUnpublishedCount(r.data?.count), ); - }, [props.path, props.unpublishedCount]); + }, [props.isJoinCard, props.path, props.unpublishedCount]); const bookCountSingular = useL10n( "{0} book", @@ -187,6 +196,11 @@ export const CollectionCard: React.FunctionComponent = ( undefined, String(unpublishedCount ?? 0), ); + // Reuses the same "Get" wording the old "Get my Team Collections" sidebar used for its + // pull-down button, as the join cue for a join card (batch decision: "e.g. the existing + // 'Get'/join affordance"). + const joinCueText = useL10n("Get", "CollectionChooser.PullDown", undefined); + const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); setMoreMenuAnchorEl(event.currentTarget); @@ -206,11 +220,19 @@ export const CollectionCard: React.FunctionComponent = ( { - if (!moreMenuAnchorEl) - postString("workspace/openCollection", props.path); + if (moreMenuAnchorEl) return; + if (props.isJoinCard) { + props.onJoinClick?.( + props.collectionId ?? "", + props.title, + ); + return; + } + postString("workspace/openCollection", props.path); }} > @@ -245,74 +267,98 @@ export const CollectionCard: React.FunctionComponent = ( )}
- {bookCountText} - {props.checkedOutCount ? ( - - ) : null} - {unpublishedCount ? ( - - {unpublishedShortText} + {joinCueText} - ) : null} + ) : ( + + + {bookCountText} + + {props.checkedOutCount ? ( + + ) : null} + {unpublishedCount ? ( + + {unpublishedShortText} + + ) : null} + + )}
- {/* Outside CardActionArea so clicks don't bubble up and open the collection */} - - - - - -
- - {props.path} - -
+ {/* Outside CardActionArea so clicks don't bubble up and open the collection. + Omitted entirely for a join card: there is no local folder yet, so "Show in File + Explorer" and the path display below are both meaningless before joining. */} + {!props.isJoinCard && ( + + + + + + +
+ + {props.path} + +
+
+ )} ); }; diff --git a/src/BloomBrowserUI/collection/CollectionCardList.stories.tsx b/src/BloomBrowserUI/collection/CollectionCardList.stories.tsx index e00eb1570694..41da7360ae4c 100644 --- a/src/BloomBrowserUI/collection/CollectionCardList.stories.tsx +++ b/src/BloomBrowserUI/collection/CollectionCardList.stories.tsx @@ -16,3 +16,17 @@ export const Scrolls: Story = { collections: sampleCollections, }, }; + +// Dogfood batch 1, item 6: join cards for cloud collections the user belongs to but hasn't +// joined locally yet, appended after the regular collections (and NOT counted against their +// maxCardCount slice). Shows the reduced card content (title + team-collection icon + "Get" join +// cue only, no book/checked-out/unpublished counts) since none of that is available pre-join. +export const WithJoinCards: Story = { + args: { + collections: sampleCollections.slice(0, 3), + joinCollections: [ + { collectionId: "join-1", title: "Sunshine Readers" }, + { collectionId: "join-2", title: "Rainforest Books" }, + ], + }, +}; diff --git a/src/BloomBrowserUI/collection/CollectionCardList.test.tsx b/src/BloomBrowserUI/collection/CollectionCardList.test.tsx new file mode 100644 index 000000000000..9455565e17fc --- /dev/null +++ b/src/BloomBrowserUI/collection/CollectionCardList.test.tsx @@ -0,0 +1,131 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderTestRoot as render } from "../utils/testRender"; +import { CollectionCardList } from "./CollectionCardList"; +import { ICollectionInfo } from "./CollectionCard"; + +// Tests the card-list logic added for dogfood batch 1, item 6: join cards (cloud collections the +// user belongs to but has no local copy of) are appended AFTER the regular collections' maxCardCount +// (10) slice, so they never count against the MRU limit, and clicking one reports its +// collectionId/title rather than opening a local collection. Mocks bloomApi's get/postString (no +// network layer) so CollectionCard's own per-card fetch effect is observable. + +const { mockGet, mockPostString } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockPostString: vi.fn(), +})); + +vi.mock("../utils/bloomApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get: mockGet, + postString: mockPostString, + }; +}); + +function makeCollection(index: number): ICollectionInfo { + return { + path: `C:/Collections/Collection${index}`, + title: `Collection ${index}`, + bookCount: index, + // Set so CollectionCard's per-card unpublished-count effect doesn't fire an unmocked get(). + unpublishedCount: 0, + }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("CollectionCardList", () => { + it("renders all join cards even when regular collections already fill the maxCardCount slice", () => { + const collections = Array.from({ length: 12 }, (_, i) => + makeCollection(i + 1), + ); + const joinCollections = [ + { collectionId: "join-1", title: "Sunshine Books" }, + { collectionId: "join-2", title: "Rainforest Readers" }, + ]; + + const container = render( + , + ); + + // Only the first 10 (maxCardCount) regular collections should render. + expect( + container.querySelectorAll('[data-testid="join-collection-card"]') + .length, + ).toBe(2); + const allCardTitles = Array.from( + container.querySelectorAll(".MuiTypography-body1"), + ).map((el) => el.textContent); + expect(allCardTitles).not.toContain("Collection 11"); + expect(allCardTitles).not.toContain("Collection 12"); + expect(allCardTitles).toContain("Collection 10"); + expect(allCardTitles).toContain("Sunshine Books"); + expect(allCardTitles).toContain("Rainforest Readers"); + }); + + it("calls onJoinCardClick with the collectionId and title when a join card is clicked", () => { + const onJoinCardClick = vi.fn(); + const container = render( + , + ); + + const joinCard = container.querySelector( + '[data-testid="join-collection-card"]', + ) as HTMLElement; + expect(joinCard).not.toBeNull(); + // Click something INSIDE CardActionArea (its title), not the outer Card itself -- click + // events only bubble UP from the target through ancestors, so clicking the Card root + // would not reach CardActionArea's onClick handler. + act(() => + ( + joinCard.querySelector(".MuiTypography-body1") as HTMLElement + ).click(), + ); + + expect(onJoinCardClick).toHaveBeenCalledWith( + "join-1", + "Sunshine Books", + ); + // Clicking a join card must never try to open a local collection. + expect(mockPostString).not.toHaveBeenCalledWith( + "workspace/openCollection", + expect.anything(), + ); + }); + + it("never fetches an unpublished count for a join card (no local folder exists yet)", () => { + render( + , + ); + + expect(mockGet).not.toHaveBeenCalled(); + }); + + it("renders no join cards when joinCollections is omitted", () => { + const container = render( + , + ); + + expect( + container.querySelector('[data-testid="join-collection-card"]'), + ).toBeNull(); + }); +}); diff --git a/src/BloomBrowserUI/collection/CollectionCardList.tsx b/src/BloomBrowserUI/collection/CollectionCardList.tsx index b22979b57d9c..8752353570ac 100644 --- a/src/BloomBrowserUI/collection/CollectionCardList.tsx +++ b/src/BloomBrowserUI/collection/CollectionCardList.tsx @@ -1,11 +1,24 @@ import { css } from "@emotion/react"; import { CollectionCard, ICollectionInfo } from "./CollectionCard"; +// One entry from collections/getJoinCards: a cloud Team Collection the signed-in user belongs to +// but has no local copy of yet (see CollectionChooserApi.HandleGetJoinCards). +export interface IJoinCollectionInfo { + collectionId: string; + title: string; +} + export const CollectionCardList: React.FunctionComponent<{ collections: ICollectionInfo[]; + // Join cards (dogfood batch 1, item 6): always appended AFTER the maxCardCount slice below, so + // they never count against the MRU card limit. + joinCollections?: IJoinCollectionInfo[]; + onJoinCardClick?: (collectionId: string, title: string) => void; className?: string; }> = (props) => { const gap = "16px"; + const joinCollections = props.joinCollections ?? []; + const totalCardCount = props.collections.length + joinCollections.length; const gridStyle = css` display: grid; grid-template-columns: repeat(2, 1fr); @@ -15,18 +28,29 @@ export const CollectionCardList: React.FunctionComponent<{ padding-bottom: 20px; // Pad the right side if there is a scrollbar // Enhance: make this smarter by actually checking if there is a scrollbar - padding-right: ${props.collections.length > 6 ? gap : 0}; + padding-right: ${totalCardCount > 6 ? gap : 0}; `; const maxCardCount = 10; const itemsToShow = props.collections?.length ? props.collections.slice(0, maxCardCount) : []; + const joinCardInfos: ICollectionInfo[] = joinCollections.map((join) => ({ + // No local path exists yet; a unique placeholder just for React's key/CollectionCard's + // path prop (never used for opening -- join cards ignore it, see CollectionCard.tsx). + path: `join:${join.collectionId}`, + title: join.title, + bookCount: 0, + isTeamCollection: true, + isJoinCard: true, + collectionId: join.collectionId, + onJoinClick: props.onJoinCardClick, + })); return (
- {itemsToShow.map((cardInfo, _) => ( + {[...itemsToShow, ...joinCardInfos].map((cardInfo) => ( ))}
diff --git a/src/BloomBrowserUI/collection/CollectionChooser.test.tsx b/src/BloomBrowserUI/collection/CollectionChooser.test.tsx new file mode 100644 index 000000000000..eb3eb45a101e --- /dev/null +++ b/src/BloomBrowserUI/collection/CollectionChooser.test.tsx @@ -0,0 +1,197 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderTestRoot } from "../utils/testRender"; +import { CollectionChooser } from "./CollectionChooser"; +import { ISharingLoginState } from "../teamCollection/sharingApi"; +import { IJoinCollectionInfo } from "./CollectionCardList"; + +// Tests the wiring this task's rewrite adds (dogfood batch 1, item 6): join cards (fetched from +// collections/getJoinCards, gated on the cloud feature + being signed in) render in the main card +// list, and clicking one opens JoinCloudCollectionDialog for that exact card, closing again on +// onClose. Replaces the old MyCloudCollectionsSection sidebar wiring test (that component + its +// test file are deleted; see CollectionCardList.test.tsx for the append-after-slice card-list +// logic itself). Mocks every hook/endpoint (no network layer) plus JoinCloudCollectionDialog +// itself (already covered by its own JoinCloudCollectionDialog.test.tsx). + +const { + mockUseApiData, + mockGet, + mockUseIsCloudFeatureEnabled, + mockUseSharingLoginState, +} = vi.hoisted(() => ({ + mockUseApiData: vi.fn(), + mockGet: vi.fn(), + mockUseIsCloudFeatureEnabled: vi.fn(), + mockUseSharingLoginState: vi.fn(), +})); + +vi.mock("../utils/bloomApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useApiData: mockUseApiData, + get: mockGet, + }; +}); + +vi.mock("../teamCollection/sharingApi", () => ({ + useIsCloudTeamCollectionsExperimentalFeatureEnabled: + mockUseIsCloudFeatureEnabled, + useSharingLoginState: mockUseSharingLoginState, +})); + +vi.mock("../teamCollection/JoinCloudCollectionDialog", () => ({ + JoinCloudCollectionDialog: (props: { + collectionId: string; + collectionName: string; + signedIn: boolean; + onClose?: () => void; + }) => ( +
+ +
+ ), +})); + +function render(): HTMLDivElement { + return renderTestRoot(); +} + +const signedIn: ISharingLoginState = { + mode: "dev", + signedIn: true, + email: "me@example.com", +}; + +const joinCardA: IJoinCollectionInfo = { + collectionId: "aaa-111", + title: "Team A Collection", +}; +const joinCardB: IJoinCollectionInfo = { + collectionId: "bbb-222", + title: "Team B Collection", +}; + +// Makes mockGet respond to collections/getJoinCards with the given join cards; any other +// endpoint (e.g. sign-in state, if ever queried this way) gets an empty array. +function mockJoinCardsResponse(joinCards: IJoinCollectionInfo[]) { + mockGet.mockImplementation( + (url: string, callback: (r: unknown) => void) => { + if (url === "collections/getJoinCards") { + callback({ data: joinCards }); + } + }, + ); +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("CollectionChooser", () => { + it("opens JoinCloudCollectionDialog for the exact join card clicked, and closes it on onClose", () => { + mockUseApiData.mockReturnValue([]); + mockUseIsCloudFeatureEnabled.mockReturnValue(true); + mockUseSharingLoginState.mockReturnValue(signedIn); + mockJoinCardsResponse([joinCardA, joinCardB]); + + const container = render(); + + expect( + container.querySelector( + '[data-testid="join-cloud-collection-dialog-stub"]', + ), + ).toBeNull(); + + const joinCards = container.querySelectorAll( + '[data-testid="join-collection-card"]', + ); + expect(joinCards.length).toBe(2); + const cardB = Array.from(joinCards).find((card) => + card.textContent?.includes(joinCardB.title), + ) as HTMLElement; + act(() => + ( + cardB.querySelector(".MuiTypography-body1") as HTMLElement + ).click(), + ); + + const dialog = document.querySelector( + '[data-testid="join-cloud-collection-dialog-stub"]', + ); + expect(dialog).not.toBeNull(); + expect(dialog!.getAttribute("data-collection-id")).toBe( + joinCardB.collectionId, + ); + expect(dialog!.getAttribute("data-collection-name")).toBe( + joinCardB.title, + ); + expect(dialog!.getAttribute("data-signed-in")).toBe("true"); + + const closeButton = document.querySelector( + '[data-testid="join-cloud-collection-dialog-stub-close"]', + ) as HTMLButtonElement; + act(() => closeButton.click()); + + expect( + document.querySelector( + '[data-testid="join-cloud-collection-dialog-stub"]', + ), + ).toBeNull(); + }); + + it("does not query for join cards, render any, or show the dialog when the cloud feature is off", () => { + mockUseApiData.mockReturnValue([]); + mockUseIsCloudFeatureEnabled.mockReturnValue(false); + mockUseSharingLoginState.mockReturnValue({ + mode: "dev", + signedIn: false, + }); + mockJoinCardsResponse([joinCardA]); + + const container = render(); + + expect(mockGet).not.toHaveBeenCalledWith( + "collections/getJoinCards", + expect.anything(), + ); + expect( + container.querySelector('[data-testid="join-collection-card"]'), + ).toBeNull(); + expect( + container.querySelector( + '[data-testid="join-cloud-collection-dialog-stub"]', + ), + ).toBeNull(); + }); + + it("does not query for join cards or render any when the cloud feature is on but signed out", () => { + mockUseApiData.mockReturnValue([]); + mockUseIsCloudFeatureEnabled.mockReturnValue(true); + mockUseSharingLoginState.mockReturnValue({ + mode: "dev", + signedIn: false, + }); + mockJoinCardsResponse([joinCardA]); + + const container = render(); + + expect(mockGet).not.toHaveBeenCalledWith( + "collections/getJoinCards", + expect.anything(), + ); + expect( + container.querySelector('[data-testid="join-collection-card"]'), + ).toBeNull(); + }); +}); diff --git a/src/BloomBrowserUI/collection/CollectionChooser.tsx b/src/BloomBrowserUI/collection/CollectionChooser.tsx index 15166bf59c0d..d22f1c0eac2e 100644 --- a/src/BloomBrowserUI/collection/CollectionChooser.tsx +++ b/src/BloomBrowserUI/collection/CollectionChooser.tsx @@ -1,7 +1,32 @@ import { css } from "@emotion/react"; -import { useApiData } from "../utils/bloomApi"; -import { CollectionCardList } from "./CollectionCardList"; +import { useEffect, useState } from "react"; +import { get, useApiData } from "../utils/bloomApi"; +import { CollectionCardList, IJoinCollectionInfo } from "./CollectionCardList"; import { ICollectionInfo } from "./CollectionCard"; +import { + useIsCloudTeamCollectionsExperimentalFeatureEnabled, + useSharingLoginState, +} from "../teamCollection/sharingApi"; +import { JoinCloudCollectionDialog } from "../teamCollection/JoinCloudCollectionDialog"; + +// Fetches collections/getJoinCards (dogfood batch 1, item 6): one entry per cloud Team +// Collection the signed-in user belongs to but has no local copy of yet -- the server does the +// local-copy matching (CollectionChooserApi.ComputeJoinCards), so this hook is a thin fetch, no +// different in shape from useApiData except that it must NOT query at all when shouldQuery is +// false (folder-only or signed-out users must never trigger a cloud call from the chooser). +function useJoinCards(shouldQuery: boolean): IJoinCollectionInfo[] { + const [joinCards, setJoinCards] = useState([]); + useEffect(() => { + if (!shouldQuery) { + setJoinCards([]); + return; + } + get("collections/getJoinCards", (result) => { + setJoinCards((result.data as IJoinCollectionInfo[]) ?? []); + }); + }, [shouldQuery]); + return joinCards; +} export const CollectionChooser: React.FunctionComponent<{ collections?: ICollectionInfo[]; @@ -13,21 +38,76 @@ export const CollectionChooser: React.FunctionComponent<{ ); if (!props.collections?.length) collections = collectionsFromApi; + // Join cards (replaces the old "Get my Team Collections" sidebar -- see + // Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md item 6): cards appended to + // the main list for cloud collections the user belongs to but hasn't joined locally yet. + // Gated on the cloud-team-collections experimental feature (as the old sidebar was) and on + // being signed in, so folder-only and signed-out users never trigger a cloud call here. + const cloudFeatureEnabled = + useIsCloudTeamCollectionsExperimentalFeatureEnabled(); + const loginState = useSharingLoginState(); + const joinCards = useJoinCards(cloudFeatureEnabled && loginState.signedIn); + + // The join card clicked, or undefined when the join dialog should be hidden. Embedded + // directly here (not opened as a separate WinForms dialog -- see JoinCloudCollectionDialog's + // own comment) so it shares CollectionChooser's already-fetched `loginState`. + const [joinTarget, setJoinTarget] = useState< + { collectionId: string; name: string } | undefined + >(undefined); + return (
+ setJoinTarget({ collectionId, name: title }) + } css={css` flex-grow: 1; + min-width: 0; overflow-y: auto; `} /> + {joinTarget && ( + setJoinTarget(undefined)} + /> + )}
); }; diff --git a/src/BloomBrowserUI/collectionsTab/BookButton.test.tsx b/src/BloomBrowserUI/collectionsTab/BookButton.test.tsx new file mode 100644 index 000000000000..6d63f7f96526 --- /dev/null +++ b/src/BloomBrowserUI/collectionsTab/BookButton.test.tsx @@ -0,0 +1,171 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderTestRoot } from "../utils/testRender"; +import { BookButton } from "./BookButton"; +import { IBookInfo, ICollection } from "./BooksOfCollection"; +import { BookSelectionManager } from "./bookSelectionManager"; +import { + ITeamCollectionCapabilities, + initialBookStatus, +} from "../teamCollection/teamCollectionApi"; + +// Tests the placeholder rendering added for dogfood batch 1, item 7 (progressive join): a book +// with notYetDownloaded=true (a Cloud Team Collection repo book that hasn't finished downloading +// to this computer yet) renders as a simple, non-interactive-looking placeholder instead of the +// normal, fully-interactive book button -- no thumbnail request, no context menu (SAFETY: no +// dangerous action -- edit/checkout/publish/delete/rename -- must be reachable on a placeholder). +// Clicking it still posts to collections/selected-book, which is how CollectionApi's +// TryPrioritizeNotYetDownloadedBook bumps the book's background download to the front of the +// queue (see CollectionApiTests for the server-side merge logic and +// TeamCollectionAutoApplyTests/RemoteBookAutoApplyQueueTests for the queue itself). +// +// bloomApi's get/post/postString are mocked (no network layer); teamCollectionApi's +// useTColBookStatus/useTeamCollectionCapabilities are mocked to avoid needing a real server round +// trip for the per-book status badge (unrelated to this test). Websocket subscriptions are no-ops +// in tests via vitest.setup.ts's `_SKIP_WEBSOCKET_CREATION_`. + +const { mockUseTColBookStatus, mockUseTeamCollectionCapabilities } = vi.hoisted( + () => ({ + mockUseTColBookStatus: vi.fn(), + mockUseTeamCollectionCapabilities: vi.fn(), + }), +); + +vi.mock("../teamCollection/teamCollectionApi", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../teamCollection/teamCollectionApi") + >(); + return { + ...actual, + useTColBookStatus: mockUseTColBookStatus, + useTeamCollectionCapabilities: mockUseTeamCollectionCapabilities, + }; +}); + +const { mockGet, mockPost, mockPostString } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockPost: vi.fn(), + mockPostString: vi.fn(), +})); + +vi.mock("../utils/bloomApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get: mockGet, + post: mockPost, + postString: mockPostString, + }; +}); + +const folderCapabilities: ITeamCollectionCapabilities = { + supportsVersionHistory: false, + supportsSharingUi: false, + requiresSignIn: false, +}; + +function render(book: IBookInfo): HTMLDivElement { + const collection: ICollection = { + isEditableCollection: true, + isFactoryInstalled: false, + containsDownloadedBooks: false, + id: "C:/Collections/My Collection", + languageFont: "Andika", + }; + const manager = new BookSelectionManager(); + + return renderTestRoot( + , + ); +} + +function makeBook(overrides: Partial = {}): IBookInfo { + return { + id: "instance-1", + title: "My Book", + collectionId: "C:/Collections/My Collection", + folderName: "My Book", + folderPath: "C:/Collections/My Collection/My Book", + isFactory: false, + ...overrides, + }; +} + +afterEach(() => { + mockUseTColBookStatus.mockReset(); + mockUseTeamCollectionCapabilities.mockReset(); + mockGet.mockClear(); + mockPost.mockClear(); + mockPostString.mockClear(); +}); + +describe("BookButton: not-yet-downloaded placeholder (dogfood batch 1, item 7)", () => { + it("renders the placeholder look for a notYetDownloaded book, not the normal button", () => { + mockUseTColBookStatus.mockReturnValue(initialBookStatus); + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + + const container = render(makeBook({ notYetDownloaded: true })); + + expect(container.querySelector(".not-yet-downloaded")).not.toBeNull(); + // The normal interactive button (with its thumbnail image) must not render. + expect(container.querySelector(".bookButton")).toBeNull(); + expect(container.querySelector("img")).toBeNull(); + }); + + it("still shows the book's title on the placeholder", () => { + mockUseTColBookStatus.mockReturnValue(initialBookStatus); + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + + const container = render( + makeBook({ notYetDownloaded: true, title: "Not Yet Here" }), + ); + + expect(container.textContent).toContain("Not Yet Here"); + }); + + it("clicking the placeholder posts to collections/selected-book with the book's id (priority-bump plumbing)", () => { + mockUseTColBookStatus.mockReturnValue(initialBookStatus); + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + + const container = render( + makeBook({ notYetDownloaded: true, id: "instance-42" }), + ); + const placeholder = container.querySelector( + ".not-yet-downloaded", + ) as HTMLElement; + expect(placeholder).not.toBeNull(); + + act(() => placeholder.click()); + + expect(mockPostString).toHaveBeenCalledWith( + expect.stringContaining("collections/selected-book"), + "instance-42", + ); + }); + + it("renders the normal interactive button (not the placeholder) when notYetDownloaded is false", () => { + mockUseTColBookStatus.mockReturnValue(initialBookStatus); + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + + const container = render(makeBook({ notYetDownloaded: false })); + + expect(container.querySelector(".not-yet-downloaded")).toBeNull(); + expect(container.querySelector(".bookButton")).not.toBeNull(); + }); + + it("renders the normal interactive button when notYetDownloaded is undefined (folder TCs / ordinary collections)", () => { + mockUseTColBookStatus.mockReturnValue(initialBookStatus); + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + + const container = render(makeBook()); // notYetDownloaded omitted entirely + + expect(container.querySelector(".not-yet-downloaded")).toBeNull(); + expect(container.querySelector(".bookButton")).not.toBeNull(); + }); +}); diff --git a/src/BloomBrowserUI/collectionsTab/BookButton.tsx b/src/BloomBrowserUI/collectionsTab/BookButton.tsx index 023c71218ce3..f648ffe17c2f 100644 --- a/src/BloomBrowserUI/collectionsTab/BookButton.tsx +++ b/src/BloomBrowserUI/collectionsTab/BookButton.tsx @@ -9,7 +9,12 @@ import { } from "../utils/bloomApi"; import { Button, Menu } from "@mui/material"; import TruncateMarkup from "react-truncate-markup"; -import { useTColBookStatus } from "../teamCollection/teamCollectionApi"; +import { + isCloudTeamCollection, + useTColBookStatus, + useTeamCollectionCapabilities, +} from "../teamCollection/teamCollectionApi"; +import { NewerVersionAvailableMarker } from "../teamCollection/NewerVersionAvailableMarker"; import { BloomAvatar } from "../react_components/bloomAvatar"; import { kBloomBlue, @@ -28,6 +33,7 @@ import { makeMenuItems, MenuItemSpec } from "./menuHelpers"; import DeleteIcon from "@mui/icons-material/Delete"; import { useL10n } from "../react_components/l10nHooks"; import SettingsIcon from "@mui/icons-material/Settings"; +import CloudDownloadIcon from "@mui/icons-material/CloudDownload"; import { showBookSettingsDialog } from "../bookEdit/bookAndPageSettings/BookAndPageSettingsDialog"; import { BookOnBlorgBadge } from "../react_components/BookOnBlorgBadge"; @@ -126,6 +132,22 @@ export const BookButton: React.FunctionComponent<{ props.collection.isEditableCollection, ); + // Cloud Team Collections: gates the "newer version exists" thumbnail marker below. Branches + // on capability (never on concrete backend type); folder Team Collections never populate + // repoVersionSeq/localVersionSeq (see IBookTeamCollectionStatus's comment), so this is + // effectively always false there even without the explicit isCloud check, but the check is + // kept for the same reason it's used elsewhere in this task: gating must never rely solely + // on the mere presence of a field. + const capabilities = useTeamCollectionCapabilities(); + const isCloud = isCloudTeamCollection(capabilities); + const hasNewerVersionAvailable = + isCloud && + !teamCollectionStatus?.who && + typeof teamCollectionStatus?.repoVersionSeq === "number" && + typeof teamCollectionStatus?.localVersionSeq === "number" && + teamCollectionStatus.repoVersionSeq > + teamCollectionStatus.localVersionSeq; + // BL-16199 // Starting in 6.4, the Collection Tab no longer existing in the background when we // are in the Edit tab, so the BookButton mounts when we switch to the Collection Tab, @@ -450,6 +472,74 @@ export const BookButton: React.FunctionComponent<{ "This tooltip pops up when the user hovers over a disabled menu item.", ); + // Batch item 7 (progressive join): tooltip for a not-yet-downloaded placeholder book. Called + // unconditionally (with the other hooks above) even though it's only used in the early-return + // placeholder branch below, per the rules of hooks. + const notYetDownloadedTooltip = useL10n( + "This book hasn't been downloaded to this computer yet. It will download automatically in the background.", + "CollectionTab.BookNotYetDownloaded", + ); + + // Batch item 7 (progressive join): a repo book with no local folder yet renders as a simple, + // non-interactive-looking placeholder instead of the normal book button -- no thumbnail (there + // is no local file to make one from), no context menu, no rename/edit/delete (SAFETY: no + // dangerous action must be reachable on a placeholder). Clicking it still posts to + // collections/selected-book like a normal click; CollectionApi gracefully treats that as "bump + // this book's download to the front of the queue" instead of a real selection (see + // CollectionApi.TryPrioritizeNotYetDownloadedBook). + if (props.book.notYetDownloaded) { + return ( +
setSelectedBookIdWithApi(props.book.id)} + > + + + {bookLabel} + +
+ ); + } + // If relevant, compute the menu items for a right-click on this button. // contextMenuPoint has a value if this button has been right-clicked. // if it wasn't the selected button at the time, however, the menu will not show @@ -501,7 +591,14 @@ export const BookButton: React.FunctionComponent<{ {teamCollectionStatus?.who && ( )} +
} > diff --git a/src/BloomBrowserUI/collectionsTab/BooksOfCollection.tsx b/src/BloomBrowserUI/collectionsTab/BooksOfCollection.tsx index 39d0b29889cf..cab15362e0eb 100644 --- a/src/BloomBrowserUI/collectionsTab/BooksOfCollection.tsx +++ b/src/BloomBrowserUI/collectionsTab/BooksOfCollection.tsx @@ -21,6 +21,11 @@ export interface IBookInfo { folderName: string; folderPath: string; isFactory: boolean; + // Batch item 7 (progressive join): true for a repo book in a Cloud Team Collection that has + // no local folder yet -- BookButton renders these as a download-in-progress placeholder + // instead of the normal, fully-interactive book button. Always false/undefined for folder + // Team Collections and ordinary local collections. + notYetDownloaded?: boolean; } // A very minimal set of collection properties for now diff --git a/src/BloomBrowserUI/package.json b/src/BloomBrowserUI/package.json index 461afeb0acc9..52e83344d053 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -18,6 +18,8 @@ "dev": "node ./scripts/dev.mjs", "// 'go': starts pnpm dev on a random Vite port, waits for it to settle, then launches Bloom with that --vite-port": " ", "go": "node ./scripts/go.mjs", + "// 'run': like 'go' but BUILDS BloomExe once (Debug) and launches it directly, WITHOUT dotnet watch, so rebuilds aren't blocked. Invoke via `./run.sh` at the repo root, or `pnpm run run` ('pnpm run' alone lists scripts). Two windows share one build via a lock+freshness gate.": " ", + "run": "node ./scripts/run.mjs", "// Watching: pnpm dev starts vite + file watchers (LESS, pug, static assets, and key content folders)": " ", "// COMMENTS: make the action a space rather than empty string so `pnpm run` can list the scripts": " ", "test": "vitest run", diff --git a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx index a4debb7e4872..0562d09a9710 100644 --- a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx +++ b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/CollectionTopBarControls.tsx @@ -2,6 +2,7 @@ import { css } from "@emotion/react"; import * as React from "react"; import { TopBarButton } from "../../TopBarButton"; import { TeamCollectionButton } from "./TeamCollectionButton"; +import { ShareButton } from "../../../teamCollection/ShareButton"; import { TeamCollectionStatus } from "../../../teamCollection/TeamCollectionStatus"; import { getBloomApiPrefix, @@ -54,7 +55,16 @@ export const CollectionTopBarControls: React.FunctionComponent = () => { width: 100%; `} > - +
+ + +
({ + mockUseTeamCollectionStatusMetadata: vi.fn(), +})); + +vi.mock("../../../teamCollection/teamCollectionApi", () => ({ + useTeamCollectionStatusMetadata: mockUseTeamCollectionStatusMetadata, +})); + +vi.mock("../../l10nHooks", () => ({ + useL10n2: (options: { + english?: string; + key: string | null; + params?: string[]; + }) => + options.params && options.params.length > 0 + ? `${options.english} {${options.params.join(",")}}` + : (options.english ?? options.key ?? ""), +})); + +function renderButton(status: TeamCollectionStatus): HTMLDivElement { + return renderTestRoot(); +} + +afterEach(() => { + mockUseTeamCollectionStatusMetadata.mockReset(); +}); + +describe("TeamCollectionButton", () => { + it("renders nothing (an empty div) for status None", () => { + mockUseTeamCollectionStatusMetadata.mockReturnValue({}); + const container = renderButton("None"); + expect(container.querySelector("button")).toBeNull(); + }); + + it("shows the plain 'Updates Available' label when the count is unknown (folder Team Collection)", () => { + // The default mocked state: metadata never populated, matching a folder Team Collection + // or the cloud-team-collections experimental feature being off. + mockUseTeamCollectionStatusMetadata.mockReturnValue({}); + const container = renderButton("NewStuff"); + expect(container.textContent).toContain("Updates Available"); + expect(container.textContent).not.toContain("{"); + }); + + it("shows the book count in the label when the status metadata provides one", () => { + mockUseTeamCollectionStatusMetadata.mockReturnValue({ + updatesAvailableCount: 3, + }); + const container = renderButton("NewStuff"); + expect(container.textContent).toContain("{3}"); + }); + + it("does not use the count-driven label for a non-NewStuff status even if a count is present", () => { + // Sanity check that the count only ever affects the NewStuff label, guarding against a + // regression that would make e.g. a Nominal or Disconnected button show a stale count. + mockUseTeamCollectionStatusMetadata.mockReturnValue({ + updatesAvailableCount: 5, + }); + const container = renderButton("Disconnected"); + expect(container.textContent).not.toContain("{5}"); + expect(container.textContent).toContain("Disconnected"); + }); + + it.each([ + ["Nominal", "TeamCollection.TeamCollection"], + ["Disconnected", "Disconnected"], + ["Error", "Problems Encountered"], + ["ClobberPending", "Problems Encountered"], + ] as [TeamCollectionStatus, string][])( + "shows the expected label for status %s", + (status, expectedLabel) => { + mockUseTeamCollectionStatusMetadata.mockReturnValue({}); + const container = renderButton(status); + expect(container.textContent).toContain(expectedLabel); + }, + ); +}); diff --git a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/TeamCollectionButton.tsx b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/TeamCollectionButton.tsx index f3fad52ee9c4..5302945605c6 100644 --- a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/TeamCollectionButton.tsx +++ b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/TeamCollectionButton.tsx @@ -5,6 +5,7 @@ import { getBloomApiPrefix, post } from "../../../utils/bloomApi"; import { kBloomYellow } from "../../../bloomMaterialUITheme"; import { kBloomGray } from "../../../utils/colorUtils"; import { TeamCollectionStatus } from "../../../teamCollection/TeamCollectionStatus"; +import { useTeamCollectionStatusMetadata } from "../../../teamCollection/teamCollectionApi"; import { useL10n2 } from "../../l10nHooks"; const bloomApiPrefix = getBloomApiPrefix(false); @@ -49,6 +50,17 @@ export const TeamCollectionButton: React.FunctionComponent<{ // "Team Collection" has been internationalized thus far. const nominalLabel = useL10n2({ key: "TeamCollection.TeamCollection" }); + // Cloud Team Collections: when the backend can tell us how many books have a newer version + // in the repo, show that count instead of the generic "Updates Available" label. This hook + // only calls its (currently mocked) endpoint when the cloud-team-collections experimental + // feature is on, so folder Team Collections keep the exact label above unchanged. + const { updatesAvailableCount } = useTeamCollectionStatusMetadata(); + const updatesAvailableWithCountLabel = useL10n2({ + english: "Updates Available (%0 books)", + key: "TeamCollection.UpdatesAvailableWithCount", + params: [String(updatesAvailableCount ?? 0)], + }); + const handleTeamCollectionClick = React.useCallback(() => { post("teamCollection/showStatusDialog"); }, []); @@ -60,6 +72,12 @@ export const TeamCollectionButton: React.FunctionComponent<{ const statusColor = statusColors[props.status] || "white"; let statusLabel = statusLabels[props.status] || "Team Collection"; if (statusLabel === statusLabels["Nominal"]) statusLabel = nominalLabel; + if ( + props.status === "NewStuff" && + typeof updatesAvailableCount === "number" + ) { + statusLabel = updatesAvailableWithCountLabel; + } const iconPath = statusIcons[props.status] || kTeamCollectionIcon; return (
(props.initialInfo); - const mayChangeEmail = props.mayChangeEmail ?? true; + // For cloud Team Collections, identity is the signed-in account: the email field is always + // locked to it (never freely editable), regardless of mayChangeEmail. + const cloudAccountEmail = props.cloudAccountEmail; + const mayChangeEmail = cloudAccountEmail + ? false + : (props.mayChangeEmail ?? true); const emailRequiredForTeamCollection = props.emailRequiredForTeamCollection ?? false; @@ -83,6 +88,13 @@ export const RegistrationContents: React.FunctionComponent< setInfo(props.initialInfo); }, [props.initialInfo]); + // Keep the email locked to the cloud account's email, overriding anything else. + React.useEffect(() => { + if (cloudAccountEmail) { + setInfo((previous) => ({ ...previous, email: cloudAccountEmail })); + } + }, [cloudAccountEmail]); + const updateInfo = React.useCallback( (changes: Partial) => { setInfo((previous) => ({ ...previous, ...changes })); @@ -240,14 +252,18 @@ export const RegistrationContents: React.FunctionComponent< margin="normal" fullWidth={true} label={ - mayChangeEmail - ? "Email Address" - : "Check in to change email" + cloudAccountEmail + ? "Your Bloom account email" + : mayChangeEmail + ? "Email Address" + : "Check in to change email" } l10nKey={ - mayChangeEmail - ? "RegisterDialog.Email" - : "RegisterDialog.CheckInToChangeEmail" + cloudAccountEmail + ? "RegisterDialog.CloudAccountEmail" + : mayChangeEmail + ? "RegisterDialog.Email" + : "RegisterDialog.CheckInToChangeEmail" } value={info.email} disabled={!mayChangeEmail} @@ -263,6 +279,21 @@ export const RegistrationContents: React.FunctionComponent< submitAttempts={submitAttempts} data-testid="email" /> + {cloudAccountEmail && ( + + This copy of Bloom will be registered under your + signed-in Bloom account and can't be changed + here. + + )}
{ props.onSave?.(hasValidEmail); props.closeDialog(); diff --git a/src/BloomBrowserUI/react_components/registration/registrationDialogLauncher.tsx b/src/BloomBrowserUI/react_components/registration/registrationDialogLauncher.tsx index eba301eff105..19319eb192f7 100644 --- a/src/BloomBrowserUI/react_components/registration/registrationDialogLauncher.tsx +++ b/src/BloomBrowserUI/react_components/registration/registrationDialogLauncher.tsx @@ -21,6 +21,8 @@ export interface IRegistrationDialogProps { emailRequiredForTeamCollection?: boolean; onSave?: (hasValidEmail: boolean) => void; dialogEnvironment?: IBloomDialogEnvironmentParams; + /** See IRegistrationContentsProps.cloudAccountEmail. */ + cloudAccountEmail?: string; } // Module-level function that can be called from anywhere to show the dialog @@ -51,6 +53,7 @@ export const RegistrationDialogLauncher: React.FunctionComponent< emailRequiredForTeamCollection={ props.emailRequiredForTeamCollection } + cloudAccountEmail={props.cloudAccountEmail} onSave={props.onSave} /> ); @@ -66,6 +69,7 @@ export const RegistrationDialogEventLauncher: React.FunctionComponent = () => { const eventProps: IRegistrationDialogProps = { emailRequiredForTeamCollection: openingEvent?.emailRequiredForTeamCollection, + cloudAccountEmail: openingEvent?.cloudAccountEmail, onSave: openingEvent?.onSave, }; @@ -77,6 +81,7 @@ export const RegistrationDialogEventLauncher: React.FunctionComponent = () => { emailRequiredForTeamCollection={ eventProps.emailRequiredForTeamCollection } + cloudAccountEmail={eventProps.cloudAccountEmail} onSave={eventProps.onSave} /> ) : null; diff --git a/src/BloomBrowserUI/react_components/registration/registrationTypes.ts b/src/BloomBrowserUI/react_components/registration/registrationTypes.ts index 04ea5d3b37f4..4bac8009ea77 100644 --- a/src/BloomBrowserUI/react_components/registration/registrationTypes.ts +++ b/src/BloomBrowserUI/react_components/registration/registrationTypes.ts @@ -21,4 +21,12 @@ export interface IRegistrationContentsProps { onClose?: (hasValidEmail: boolean) => void; /** Override the delay (in seconds) before showing the opt-out button. Defaults to kInactivitySecondsBeforeShowingOptOut. */ optOutDelaySeconds?: number; + /** + * For cloud Team Collections, registration identity *is* the signed-in Bloom account: pass + * the account's (verified) email here to pre-fill and lock the email field to it, regardless + * of mayChangeEmail. Unlike the folder-TC "Check in to change email" lock (which just means + * "already registered"), this lock means the email can never diverge from the account you're + * signed into for this cloud collection. + */ + cloudAccountEmail?: string; } diff --git a/src/BloomBrowserUI/scripts/automationReady.mjs b/src/BloomBrowserUI/scripts/automationReady.mjs new file mode 100644 index 000000000000..ae267b2e2002 --- /dev/null +++ b/src/BloomBrowserUI/scripts/automationReady.mjs @@ -0,0 +1,51 @@ +/* eslint-env node */ + +// Parsing of the BLOOM_AUTOMATION_READY startup handshake for the cloud-TC +// build-once launcher (run.mjs). When Bloom is launched with --automation it +// prints a single stdout line of the form `BLOOM_AUTOMATION_READY {json}` +// (processId, httpPort, cdpPort, ...) once it is ready to be driven. (The +// repo-root scripts/watchBloomExe.mjs dotnet-watch launcher does its own +// equivalent scanning inline; run.mjs is a separate build-once launcher this +// cloud branch adds, so it keeps its scanner here.) + +export const automationReadyPrefix = "BLOOM_AUTOMATION_READY "; + +/** + * Create a chunk-fed scanner for the BLOOM_AUTOMATION_READY handshake line. + * + * The returned function accepts raw stdout/stderr text chunks (as forwarded by + * pipeChildOutput's onText, so chunks may split lines arbitrarily), buffers them + * into lines, and for each line starting with the handshake prefix parses the + * JSON payload and reports it via `onReady`. + * + * If parsing fails — or `onReady` itself throws (e.g. payload validation) — the + * error is reported via `onParseError` so each caller can format its own + * console message. + * + * @param {(info: object) => void} onReady - Called with the parsed payload of each handshake line. + * @param {(error: Error) => void} onParseError - Called when a handshake line's payload cannot be parsed or handled. + * @returns {(text: string) => void} The chunk-fed scanner. + */ +export const makeAutomationReadyScanner = (onReady, onParseError) => { + let buffered = ""; + return (text) => { + buffered += text; + let newlineIndex; + while ((newlineIndex = buffered.search(/\r\n|\r|\n/)) >= 0) { + const line = buffered.slice(0, newlineIndex); + buffered = buffered.slice( + newlineIndex + + (buffered.startsWith("\r\n", newlineIndex) ? 2 : 1), + ); + if (line.startsWith(automationReadyPrefix)) { + try { + onReady( + JSON.parse(line.slice(automationReadyPrefix.length)), + ); + } catch (error) { + onParseError(error); + } + } + } + }; +}; diff --git a/src/BloomBrowserUI/scripts/childOutput.mjs b/src/BloomBrowserUI/scripts/childOutput.mjs new file mode 100644 index 000000000000..993898e659af --- /dev/null +++ b/src/BloomBrowserUI/scripts/childOutput.mjs @@ -0,0 +1,82 @@ +/* eslint-env node */ +/* global process */ + +// Line-buffered, prefixed forwarding of a child process's stdout/stderr to our +// own streams. Extracted from go.mjs so both the watch launcher (go.mjs) and the +// build-once launcher (run.mjs) share one implementation. `onText` (optional) is +// invoked with each raw chunk before it is line-split, so a caller can watch the +// stream for readiness markers. + +const createPrefixedWriter = (prefix, target, onText) => { + let buffered = ""; + + const flushLines = (text) => { + buffered += text; + let lineStart = 0; + + for (let index = 0; index < buffered.length; index++) { + const current = buffered[index]; + if (current === "\n") { + target.write(`${prefix}${buffered.slice(lineStart, index)}\n`); + lineStart = index + 1; + continue; + } + + if (current !== "\r") { + continue; + } + + if (index === buffered.length - 1) { + break; + } + + target.write(`${prefix}${buffered.slice(lineStart, index)}\n`); + if (buffered[index + 1] === "\n") { + index++; + } + + lineStart = index + 1; + } + + buffered = buffered.slice(lineStart); + }; + + return { + write: (chunk) => { + const text = chunk.toString(); + onText?.(text); + flushLines(text); + }, + flush: () => { + const remainingLine = buffered.endsWith("\r") + ? buffered.slice(0, -1) + : buffered; + if (!remainingLine) { + buffered = ""; + return; + } + + target.write(`${prefix}${remainingLine}\n`); + buffered = ""; + }, + }; +}; + +/** + * Forward a child process's stdout and stderr to this process's stdout/stderr, + * prefixing every line with `prefix` (e.g. "[dev] ") so interleaved output from + * multiple children stays legible. Optionally calls `onText` with each raw chunk. + * + * @param {import("node:child_process").ChildProcess} child - The child whose output to forward. + * @param {string} prefix - Text prepended to every forwarded line. + * @param {(text: string) => void} [onText] - Optional observer of each raw chunk. + */ +export const pipeChildOutput = (child, prefix, onText) => { + const stdoutWriter = createPrefixedWriter(prefix, process.stdout, onText); + const stderrWriter = createPrefixedWriter(prefix, process.stderr, onText); + + child.stdout.on("data", stdoutWriter.write); + child.stderr.on("data", stderrWriter.write); + child.stdout.on("end", stdoutWriter.flush); + child.stderr.on("end", stderrWriter.flush); +}; diff --git a/src/BloomBrowserUI/scripts/run.mjs b/src/BloomBrowserUI/scripts/run.mjs new file mode 100644 index 000000000000..bd8ee93b393c --- /dev/null +++ b/src/BloomBrowserUI/scripts/run.mjs @@ -0,0 +1,526 @@ +/* eslint-env node */ +/* global clearTimeout, console, process, setTimeout */ +import { spawn } from "node:child_process"; +import { + existsSync, + mkdirSync, + openSync, + closeSync, + readFileSync, + writeSync, + statSync, + readdirSync, + unlinkSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + automationReadyPrefix, + makeAutomationReadyScanner, +} from "./automationReady.mjs"; +import { pipeChildOutput } from "./childOutput.mjs"; +import { startViteDevServer } from "./viteDevServer.mjs"; +import { killProcessTree, reapOrphanedBloomDevStacks } from "./processTree.mjs"; +import { findRunningStandardBloomInstances } from "../../../.github/skills/bloom-automation/bloomProcessCommon.mjs"; +import { getHelpfulStartupLabel } from "../../../scripts/watchBloomExeLabel.mjs"; + +// `pnpm run` / `./run.sh` — the BUILD-ONCE launcher (contrast with `go.sh`, which +// runs Bloom under `dotnet watch`). It builds BloomExe once (Debug), then launches +// the built Bloom.exe DIRECTLY. Rationale (dogfood, 14 Jul 2026): our C# changes +// are structural, so .NET Hot Reload can't apply them and `dotnet watch` forces a +// full rebuild+relaunch anyway — while the watcher holds the output tree's apphost +// locked so nobody else can rebuild. Dropping the watcher removes the lock fight; +// the front-end still hot-reloads because Vite runs exactly as it does under go. +// +// Two-window story (Alice + Bob against the same source): both windows run this +// same command. A build LOCK serializes the build so they don't race into the +// shared output tree, and a freshness check lets the second window skip the build +// entirely (the first already produced an up-to-date exe), so it never has to +// touch the apphost the first instance now holds open. + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const browserUIRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(browserUIRoot, "..", ".."); +process.env.feedback = "off"; + +const buildConfiguration = "Debug"; +const bloomExeCsproj = path.join( + repoRoot, + "src", + "BloomExe", + "BloomExe.csproj", +); +// BloomExe.csproj: OutputPath=..\..\output\$(Configuration)\$(Platform)\, +// AppendTargetFrameworkToOutputPath=false, AssemblyName=Bloom, Platform=AnyCPU. +const builtExePath = path.join( + repoRoot, + "output", + buildConfiguration, + "AnyCPU", + "Bloom.exe", +); +const outputRoot = path.join(repoRoot, "output"); +const buildLockPath = path.join(outputRoot, "run-build.lock"); + +const launchTimeoutMs = 120000; +const buildLockPollMs = 500; +// A build lock older than this is treated as abandoned (builder crashed without +// releasing it). A full clean build is well under this; a normal incremental build +// is seconds. Ten minutes is generous headroom over the slowest real build. +const staleBuildLockMs = 10 * 60 * 1000; +// Directories we never descend into when scanning C# sources for freshness — they +// hold build output, dependencies, or VCS metadata, none of which are edited. +const freshnessSkipDirs = new Set([ + "output", + "bin", + "obj", + "node_modules", + ".git", + "DistFiles", +]); + +const parseArgs = () => { + const args = process.argv.slice(2); + const options = { vitePort: undefined }; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + + if (arg === "--vite-port" || arg.startsWith("--vite-port=")) { + const raw = + arg === "--vite-port" ? args[++index] : arg.split("=")[1]; + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { + throw new Error( + `--vite-port must be an integer from 1 to 65535. Received: ${raw}`, + ); + } + options.vitePort = parsed; + continue; + } + + throw new Error( + `Unsupported option "${arg}". The only supported option is --vite-port.`, + ); + } + + return options; +}; + +let options; +try { + options = parseArgs(); +} catch (error) { + console.error(`[run] ${error instanceof Error ? error.message : error}`); + process.exit(1); +} + +const children = []; +let bloomChild; +let bloomProcessId; +let isShuttingDown = false; + +const delay = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const log = (message) => console.log(`[run] ${message}`); + +const shutdown = async (exitCode = 0) => { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + const normalizedExitCode = Number.isInteger(exitCode) ? exitCode : 1; + log(`Shutting down (exit ${normalizedExitCode})...`); + // killProcessTree takes down each child's WHOLE subtree (see its doc comment + // in processTree.mjs for why signal propagation can't be trusted on Windows). + await Promise.all(children.map((child) => killProcessTree(child.pid))); + process.exit(normalizedExitCode); +}; + +process.on("SIGINT", () => void shutdown(0)); +process.on("SIGTERM", () => void shutdown(0)); + +const isProcessAlive = (pid) => { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +// --- Build coordination --------------------------------------------------- + +// Newest modification time (ms) among C# sources and project files under src/, +// plus the repo-root MSBuild files (Directory.Build.props and friends), which +// also change build output. Used only to decide whether the existing build is +// already up to date; the build lock plus dotnet's own incrementality are the +// real correctness guarantees, so a slightly conservative answer (rebuild when +// unsure) is fine. +const newestSourceMtimeMs = () => { + let newest = 0; + for (const rootBuildFile of [ + "Directory.Build.props", + "Directory.Build.targets", + "Directory.Packages.props", + ]) { + try { + const mtime = statSync(path.join(repoRoot, rootBuildFile)).mtimeMs; + if (mtime > newest) { + newest = mtime; + } + } catch { + // Not every repo-root build file exists; that's fine. + } + } + const stack = [path.join(repoRoot, "src")]; + + while (stack.length > 0) { + const dir = stack.pop(); + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.isDirectory()) { + if (!freshnessSkipDirs.has(entry.name)) { + stack.push(path.join(dir, entry.name)); + } + continue; + } + + if (!/\.(cs|csproj)$/i.test(entry.name)) { + continue; + } + + try { + const mtime = statSync(path.join(dir, entry.name)).mtimeMs; + if (mtime > newest) { + newest = mtime; + } + } catch { + // Ignore files that vanish mid-scan. + } + } + } + + return newest; +}; + +const isBuiltExeFresh = () => { + if (!existsSync(builtExePath)) { + return false; + } + try { + // Compare against the newest BUILD OUTPUT, not just the apphost exe: an + // incremental `dotnet build` rewrites Bloom.dll but usually leaves the + // apphost Bloom.exe untouched (it only embeds the dll name), so the exe's + // mtime goes permanently stale after the first source edit and an + // exe-only check would make every launch rebuild forever. + const builtDllPath = path.join(path.dirname(builtExePath), "Bloom.dll"); + let newestOutput = statSync(builtExePath).mtimeMs; + try { + const dllMtime = statSync(builtDllPath).mtimeMs; + if (dllMtime > newestOutput) { + newestOutput = dllMtime; + } + } catch { + // No dll (very unusual); fall back to the exe mtime alone. + } + return newestOutput >= newestSourceMtimeMs(); + } catch { + return false; + } +}; + +// Acquire an exclusive, cross-process build lock. If another `run` is mid-build, +// wait until it releases (or its lock goes stale). Returns a release function. +const acquireBuildLock = async () => { + if (!existsSync(outputRoot)) { + mkdirSync(outputRoot, { recursive: true }); + } + + for (;;) { + try { + const fd = openSync(buildLockPath, "wx"); + writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() })); + closeSync(fd); + let released = false; + return () => { + if (released) { + return; + } + released = true; + try { + unlinkSync(buildLockPath); + } catch { + // Already gone; nothing to do. + } + }; + } catch (error) { + if (error.code !== "EEXIST") { + throw error; + } + } + + // Lock is held by someone else. Steal it if it is stale (crashed builder + // or dead pid); otherwise wait and retry. + let holder; + try { + holder = JSON.parse(readFileSync(buildLockPath, "utf8")); + } catch { + holder = undefined; + } + + const ageMs = holder?.at ? Date.now() - holder.at : Infinity; + const holderDead = holder?.pid && !isProcessAlive(holder.pid); + if (ageMs > staleBuildLockMs || holderDead) { + log( + `Removing a stale build lock (${holderDead ? `dead pid ${holder.pid}` : `age ${Math.round(ageMs / 1000)}s`}).`, + ); + try { + unlinkSync(buildLockPath); + } catch { + // Someone else cleaned it up; loop and retry. + } + continue; + } + + log( + `Another build is in progress (pid ${holder?.pid ?? "?"}). Waiting for it to finish...`, + ); + await delay(buildLockPollMs); + } +}; + +const runDotnetBuild = () => + new Promise((resolve, reject) => { + log(`Building BloomExe (${buildConfiguration})...`); + const child = spawn( + "dotnet", + ["build", bloomExeCsproj, "-c", buildConfiguration], + { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"], shell: false }, + ); + pipeChildOutput(child, "[build] "); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`dotnet build failed with exit code ${code}.`)); + }); + }); + +// Build the exe unless it is already up to date, serialized against any other +// `run` via the build lock so two windows never build the same tree at once. +const ensureBuilt = async () => { + if (isBuiltExeFresh()) { + log("Built Bloom.exe is already up to date; skipping build."); + return; + } + + const release = await acquireBuildLock(); + try { + // Re-check after acquiring: another window may have just built for us. + if (isBuiltExeFresh()) { + log("Another window already produced an up-to-date build."); + return; + } + await runDotnetBuild(); + if (!existsSync(builtExePath)) { + throw new Error( + `Build reported success but ${builtExePath} does not exist.`, + ); + } + } finally { + release(); + } +}; + +// --- Vite ----------------------------------------------------------------- + +// Reuse a Vite server already serving THIS worktree (from another `run`/`go` +// instance) so two windows don't run two Vites; otherwise start our own. +const ensureVite = async () => { + if (options.vitePort) { + log(`Using caller-provided Vite port ${options.vitePort}.`); + return options.vitePort; + } + + const normalizedRepoRoot = repoRoot.replace(/\//g, "\\").toLowerCase(); + const instances = await findRunningStandardBloomInstances(); + const inheritedPort = instances + .filter( + (instance) => + instance.detectedRepoRoot && + instance.detectedRepoRoot.toLowerCase() === + normalizedRepoRoot && + instance.vitePort, + ) + .map((instance) => instance.vitePort)[0]; + + if (inheritedPort) { + log( + `Reusing Vite port ${inheritedPort} from a running instance of this worktree.`, + ); + return inheritedPort; + } + + const dev = await startViteDevServer({ + repoRoot, + requestedPort: undefined, + registerChild: (child) => children.push(child), + isShuttingDown: () => isShuttingDown, + onUnexpectedExit: (exitCode) => void shutdown(exitCode), + log, + }); + log(`Vite is reachable and quiet on port ${dev.port}.`); + return dev.port; +}; + +// --- Bloom launch --------------------------------------------------------- + +const buildBloomArgs = (vitePort) => { + const args = ["--automation"]; + // Mirror `pnpm go`: a human is watching, so keep --automation's ready + // handshake but turn its unattended-UI policies (dialog auto-close, problem + // report suppression) back off. Set BLOOM_GO_UNATTENDED=1 to opt out. + if (process.env.BLOOM_GO_UNATTENDED !== "1") { + args.push("--attended"); + } + const label = getHelpfulStartupLabel(repoRoot); + if (label) { + args.push("--label", label); + } + args.push("--vite-port", String(vitePort)); + return args; +}; + +const launchBloom = (vitePort) => + new Promise((resolve) => { + const args = buildBloomArgs(vitePort); + log(`Launching ${builtExePath} ${args.join(" ")}`); + bloomChild = spawn(builtExePath, args, { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }); + children.push(bloomChild); + + let ready = false; + const launchTimer = setTimeout(() => { + if (!ready) { + console.error( + `[run] Bloom did not emit ${automationReadyPrefix.trim()} within ${launchTimeoutMs} ms.`, + ); + } + }, launchTimeoutMs); + + const scanner = makeAutomationReadyScanner( + (info) => { + ready = true; + clearTimeout(launchTimer); + bloomProcessId = Number(info?.processId) || bloomChild.pid; + log( + `Bloom ready. HTTP ${info?.httpPort}, CDP ${info?.cdpPort}, Bloom PID ${bloomProcessId}.`, + ); + }, + (error) => + console.error( + `[run] Could not parse ${automationReadyPrefix.trim()} payload: ${error.message}`, + ), + ); + + pipeChildOutput(bloomChild, "[bloom] ", scanner); + + bloomChild.on("error", (error) => { + clearTimeout(launchTimer); + console.error(`[run] Failed to start Bloom: ${error.message}`); + resolve(1); + }); + + bloomChild.on("exit", (code, signal) => { + clearTimeout(launchTimer); + const detail = signal ? `signal ${signal}` : `code ${code ?? 0}`; + log(`Bloom exited (${detail}).`); + bloomChild = undefined; + bloomProcessId = undefined; + resolve(code ?? 0); + }); + }); + +// After Bloom closes, wait for Enter to rebuild + relaunch (picking up any C# +// changes) while keeping Vite warm, or Ctrl+C to stop. +const relaunchPrompt = + "Bloom closed. Press Enter to rebuild & relaunch, or Ctrl+C to stop."; + +const runLaunchLoop = async (vitePort) => { + for (;;) { + try { + await ensureBuilt(); + } catch (error) { + console.error(`[run] ${error.message}`); + if (!process.stdin.isTTY) { + await shutdown(1); + return; + } + log("Fix the build error, then press Enter to try again."); + await waitForEnter(); + continue; + } + + await launchBloom(vitePort); + if (isShuttingDown) { + return; + } + + if (!process.stdin.isTTY) { + // No human to prompt (e.g. spawned by another tool): stop cleanly. + await shutdown(0); + return; + } + + log(relaunchPrompt); + await waitForEnter(); + } +}; + +let pendingEnterResolvers = []; +const waitForEnter = () => + new Promise((resolve) => pendingEnterResolvers.push(resolve)); + +if (process.stdin.readable) { + process.stdin.setEncoding("utf8"); + process.stdin.on("data", () => { + const resolvers = pendingEnterResolvers; + pendingEnterResolvers = []; + resolvers.forEach((resolve) => resolve()); + }); + process.stdin.resume(); +} + +const main = async () => { + // Reap dev-stack node processes orphaned by a hard-killed launcher before we + // start (same cleanup go.mjs does at startup; reuses master's shared helper). + await reapOrphanedBloomDevStacks({ + excludePids: [process.pid], + log, + }); + + const vitePort = await ensureVite(); + await runLaunchLoop(vitePort); +}; + +main().catch((error) => { + console.error(`[run] ${error.message}`); + void shutdown(1); +}); diff --git a/src/BloomBrowserUI/scripts/viteDevServer.mjs b/src/BloomBrowserUI/scripts/viteDevServer.mjs new file mode 100644 index 000000000000..248d41d01889 --- /dev/null +++ b/src/BloomBrowserUI/scripts/viteDevServer.mjs @@ -0,0 +1,340 @@ +/* eslint-env node */ +/* global AbortSignal, clearTimeout, console, fetch, process, setTimeout */ +import { spawn } from "node:child_process"; +import net from "node:net"; +import path from "node:path"; +import { pipeChildOutput } from "./childOutput.mjs"; + +// Starts (or waits on) the Vite dev server that both launchers need. This logic +// was extracted verbatim from go.mjs so the watch launcher (go.mjs) and the +// build-once launcher (run.mjs) share ONE well-tuned implementation. The tuning +// comments below are load-bearing — see the git history of go.mjs for why each +// value is what it is. + +const startupQuietMs = 1500; +const viteHealthTimeoutMs = 30000; +const viteHealthPollMs = 250; +// Per-request timeout for a single /@vite/client probe. This must comfortably +// exceed Vite's real cold-start response latency, NOT just its steady-state +// latency (a few ms). We probe at the most CPU-contended moment of startup: +// Vite is still pre-bundling deps (optimizeDeps for jquery/comicaljs), the 7 +// file watchers are doing their initial scans, and LESS is compiling ~180 +// stylesheets, so Vite's event loop stalls in bursts. Measured latency under +// that load reaches ~2.9s (p99), while steady state is <10ms. A 500ms timeout +// (an earlier value) spuriously aborted every probe during this window, so +// waitForViteClient never got its 2 consecutive successes and the whole launch +// failed. A slow-but-listening server is healthy, not broken; a genuinely dead +// server still fails fast via ECONNREFUSED, so this longer timeout only affects +// the busy-but-fine case. +const viteHealthRequestTimeoutMs = 3000; +const maxRandomVitePortAttempts = 10; +// Probe both loopback families: Vite may bind only IPv6 (::1) on some machines, +// and Node's fetch resolves "localhost" to IPv4 (127.0.0.1) first, so a +// localhost-only probe can spuriously report Vite as unreachable. +const viteLoopbackHosts = ["127.0.0.1", "[::1]"]; +const toViteOrigin = (host, port) => `http://${host}:${port}`; + +const delay = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const canListenOnLoopbackPort = (port) => + new Promise((resolve) => { + const server = net.createServer(); + let settled = false; + + const finish = (result) => { + if (settled) { + return; + } + + settled = true; + resolve(result); + }; + + server.once("error", () => { + server.close(() => finish(false)); + }); + + server.once("listening", () => { + server.close(() => finish(true)); + }); + + server.listen({ + host: "127.0.0.1", + port, + exclusive: true, + }); + }); + +const pickRandomAvailablePort = () => + new Promise((resolve, reject) => { + const server = net.createServer(); + + server.once("error", reject); + server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => { + const address = server.address(); + const port = + typeof address === "object" && address + ? address.port + : undefined; + + server.close((error) => { + if (error) { + reject(error); + return; + } + + if (!port) { + reject(new Error("Unable to choose a Vite dev port.")); + return; + } + + resolve(port); + }); + }); + }); + +const isViteClientReachable = async (port) => { + for (const host of viteLoopbackHosts) { + try { + const response = await fetch( + `${toViteOrigin(host, port)}/@vite/client`, + { + signal: AbortSignal.timeout(viteHealthRequestTimeoutMs), + }, + ); + if (response.ok) { + return true; + } + } catch { + // Try the next loopback host before giving up. + } + } + + return false; +}; + +const waitForViteClient = async (port, timeoutMs, isShuttingDown) => { + const deadline = Date.now() + timeoutMs; + let consecutiveSuccesses = 0; + + while (!isShuttingDown() && Date.now() < deadline) { + if (await isViteClientReachable(port)) { + consecutiveSuccesses++; + if (consecutiveSuccesses >= 2) { + return true; + } + } else { + consecutiveSuccesses = 0; + } + + await delay(viteHealthPollMs); + } + + return false; +}; + +const buildStartupError = (message, logTail) => { + const error = new Error(message); + error.portConflict = /already in use|EADDRINUSE/i.test(logTail); + return error; +}; + +const startDevServerOnPort = (context, port) => + new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [context.devScriptPath, "--port", String(port)], + { + cwd: context.browserUIRoot, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + env: { + ...process.env, + PORT: String(port), + }, + }, + ); + + context.registerChild(child); + + let quietTimer; + let startupFinished = false; + let logTail = ""; + let sawReady = false; + let sawInitialBuild = false; + let sawWatchersStarted = false; + let lastOutputAt = Date.now(); + + const scheduleQuiescenceCheck = () => { + if (startupFinished || context.isShuttingDown()) { + return; + } + + if (!(sawReady && sawInitialBuild && sawWatchersStarted)) { + return; + } + + clearTimeout(quietTimer); + quietTimer = setTimeout(async () => { + if (startupFinished || context.isShuttingDown()) { + return; + } + + if (Date.now() - lastOutputAt < startupQuietMs) { + scheduleQuiescenceCheck(); + return; + } + + const healthy = await waitForViteClient( + port, + viteHealthTimeoutMs, + context.isShuttingDown, + ); + if (!healthy) { + reject( + buildStartupError( + `Vite on port ${port} never became reachable at /@vite/client.`, + logTail, + ), + ); + return; + } + + if (Date.now() - lastOutputAt < startupQuietMs) { + scheduleQuiescenceCheck(); + return; + } + + startupFinished = true; + resolve({ child, port }); + }, startupQuietMs); + }; + + const observeText = (text) => { + lastOutputAt = Date.now(); + logTail = (logTail + text).slice(-12000); + + if (logTail.includes("ready in")) { + sawReady = true; + } + + if (logTail.includes("Initial build done")) { + sawInitialBuild = true; + } + + if (logTail.includes("Watching for changes:")) { + sawWatchersStarted = true; + } + + scheduleQuiescenceCheck(); + }; + + pipeChildOutput(child, "[dev] ", observeText); + + child.on("error", (error) => { + if (startupFinished || context.isShuttingDown()) { + return; + } + + clearTimeout(quietTimer); + reject( + buildStartupError( + `Failed to start Vite dev: ${error.message}`, + logTail, + ), + ); + }); + + child.on("exit", (code, signal) => { + clearTimeout(quietTimer); + + if (startupFinished) { + if (!context.isShuttingDown()) { + const detail = signal + ? `signal ${signal}` + : `code ${code ?? 0}`; + console.error( + `[go] Vite dev exited unexpectedly with ${detail}.`, + ); + context.onUnexpectedExit(code ?? 1); + } + return; + } + + if (context.isShuttingDown()) { + return; + } + + reject( + buildStartupError( + `Vite exited before becoming quiescent (code ${code ?? 0}${signal ? `, signal ${signal}` : ""}).`, + logTail, + ), + ); + }); + }); + +/** + * Ensure a Vite dev server is running and healthy, returning its child process + * and port. If `requestedPort` is given, Vite must be able to bind exactly that + * port; otherwise a random available port is chosen (retried on port conflicts). + * + * @param {object} params + * @param {string} params.repoRoot - Absolute path to the repo root. + * @param {number} [params.requestedPort] - A specific Vite port to require, or undefined for random. + * @param {(child: import("node:child_process").ChildProcess) => void} params.registerChild - + * Called with the spawned Vite child so the caller can shut it down. + * @param {() => boolean} params.isShuttingDown - Returns true once the caller is tearing down. + * @param {(exitCode: number) => void} params.onUnexpectedExit - Called if Vite dies after startup. + * @param {(message: string) => void} params.log - Progress logger. + * @returns {Promise<{child: import("node:child_process").ChildProcess, port: number}>} + */ +export const startViteDevServer = async (params) => { + const browserUIRoot = path.join(params.repoRoot, "src", "BloomBrowserUI"); + const context = { + browserUIRoot, + devScriptPath: path.join(browserUIRoot, "scripts", "dev.mjs"), + registerChild: params.registerChild, + isShuttingDown: params.isShuttingDown, + onUnexpectedExit: params.onUnexpectedExit, + }; + + if (params.requestedPort) { + const available = await canListenOnLoopbackPort(params.requestedPort); + if (!available) { + throw new Error( + `Requested Vite port ${params.requestedPort} is already in use.`, + ); + } + + params.log( + `Starting Vite on requested port ${params.requestedPort}...`, + ); + return startDevServerOnPort(context, params.requestedPort); + } + + let lastError; + + for (let attempt = 1; attempt <= maxRandomVitePortAttempts; attempt++) { + const port = await pickRandomAvailablePort(); + params.log( + `Starting Vite on random port ${port} (attempt ${attempt}/${maxRandomVitePortAttempts})...`, + ); + + try { + return await startDevServerOnPort(context, port); + } catch (error) { + lastError = error; + if (!error?.portConflict) { + throw error; + } + + params.log( + `Vite could not hold port ${port} through startup. Retrying with a new random port...`, + ); + } + } + + throw lastError ?? new Error("Unable to start Vite on a random port."); +}; diff --git a/src/BloomBrowserUI/teamCollection/CollectionHistoryTable.test.tsx b/src/BloomBrowserUI/teamCollection/CollectionHistoryTable.test.tsx new file mode 100644 index 000000000000..1b62bf109da3 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/CollectionHistoryTable.test.tsx @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderTestRoot } from "../utils/testRender"; +import { CollectionHistoryTable } from "./CollectionHistoryTable"; +import { ITeamCollectionCapabilities } from "./teamCollectionApi"; + +// Tests the Wave-2 addition to the Team Collection history tab: for a cloud Team Collection, +// history comes from the "sharing/history" server-events-feed endpoint while connected, and +// "sharing/historyCache" while disconnected, instead of the folder-TC "teamCollection/getHistory" +// endpoint (fetched via the shared useApiData hook, unchanged); incident event types +// (ForcedUnlock/SyncProblem) get a warning marker, but only for cloud Team Collections. +// +// bloomApi's `useApiData` and `getBoolean` are mocked directly (not just `get`, which they call +// internally within bloomApi.tsx itself — an intra-module call vi.mock cannot intercept; see +// TeamCollectionDialog.test.tsx's file comment for the same lesson re: getBoolean). `get` is +// mocked separately to capture the direct cross-module calls this component makes itself for the +// cloud events fetch. + +const { mockUseTeamCollectionCapabilities, mockUseCloudCollectionId } = + vi.hoisted(() => ({ + mockUseTeamCollectionCapabilities: vi.fn(), + mockUseCloudCollectionId: vi.fn(() => "collection-123"), + })); + +vi.mock("./teamCollectionApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTeamCollectionCapabilities: mockUseTeamCollectionCapabilities, + useCloudCollectionId: mockUseCloudCollectionId, + }; +}); + +const { mockGet, mockGetBoolean, mockUseApiData } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockGetBoolean: vi.fn(), + mockUseApiData: vi.fn(), +})); + +vi.mock("../utils/bloomApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get: mockGet, + getBoolean: mockGetBoolean, + useApiData: mockUseApiData, + }; +}); + +const folderCapabilities: ITeamCollectionCapabilities = { + supportsVersionHistory: false, + supportsSharingUi: false, + requiresSignIn: false, +}; + +const cloudCapabilities: ITeamCollectionCapabilities = { + supportsVersionHistory: true, + supportsSharingUi: true, + requiresSignIn: true, +}; + +const oneEvent = (type: number) => [ + { + Title: "My Book", + ThumbnailPath: "thumb.png", + When: "2026-07-07T00:00:00Z", + Message: "a comment", + Type: type, + UserId: "fred@example.com", + UserName: "Fred", + }, +]; + +function renderTable() { + return renderTestRoot(); +} + +afterEach(() => { + mockUseTeamCollectionCapabilities.mockReset(); + mockGet.mockReset(); + mockGetBoolean.mockReset(); + mockUseApiData.mockReset(); +}); + +describe("CollectionHistoryTable: folder Team Collection (unchanged by Wave 2)", () => { + it("uses the folder-TC useApiData result, makes no cloud-only requests, and shows no incident marker", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + mockUseApiData.mockReturnValue(oneEvent(5) /* Forced Unlock */); + + const container = renderTable(); + + expect(mockUseApiData).toHaveBeenCalledWith( + "teamCollection/getHistory", + [], + ); + // Folder Team Collections must make zero extra requests: neither the + // isDisconnected check nor a cloud history fetch should ever fire. + expect(mockGetBoolean).not.toHaveBeenCalled(); + expect(mockGet).not.toHaveBeenCalled(); + expect( + container.querySelector('[data-testid="history-incident-icon"]'), + ).toBeNull(); + expect(container.textContent).toContain("Forced Unlock"); + }); +}); + +describe("CollectionHistoryTable: cloud Team Collection additions (Wave 2)", () => { + it("connected: asks the sharing/history server-events-feed endpoint, scoped to the collection", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + mockUseApiData.mockReturnValue([]); + mockGetBoolean.mockImplementation( + (_url: string, cb: (v: boolean) => void) => cb(false), + ); + mockGet.mockImplementation((_url: string, cb: (r: unknown) => void) => + cb({ data: oneEvent(0) }), + ); + + renderTable(); + + expect(mockGet).toHaveBeenCalledTimes(1); + const url = mockGet.mock.calls[0][0] as string; + expect(url).toContain("sharing/history?"); + expect(url).toContain("collectionId=collection-123"); + }); + + it("disconnected: falls back to the sharing/historyCache endpoint instead of the live feed", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + mockUseApiData.mockReturnValue([]); + mockGetBoolean.mockImplementation( + (_url: string, cb: (v: boolean) => void) => cb(true), + ); + mockGet.mockImplementation((_url: string, cb: (r: unknown) => void) => + cb({ data: oneEvent(0) }), + ); + + renderTable(); + + expect(mockGet).toHaveBeenCalledTimes(1); + const url = mockGet.mock.calls[0][0] as string; + expect(url).toContain("sharing/historyCache?"); + }); + + it("marks an incident event (Forced Unlock) with a warning icon", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + mockUseApiData.mockReturnValue([]); + mockGetBoolean.mockImplementation( + (_url: string, cb: (v: boolean) => void) => cb(false), + ); + mockGet.mockImplementation((_url: string, cb: (r: unknown) => void) => + cb({ data: oneEvent(5) /* Forced Unlock */ }), + ); + + const container = renderTable(); + + expect( + container.querySelector('[data-testid="history-incident-icon"]'), + ).not.toBeNull(); + }); + + it("does not mark a routine event (Check Out) with a warning icon", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + mockUseApiData.mockReturnValue([]); + mockGetBoolean.mockImplementation( + (_url: string, cb: (v: boolean) => void) => cb(false), + ); + mockGet.mockImplementation((_url: string, cb: (r: unknown) => void) => + cb({ data: oneEvent(0) /* Check Out */ }), + ); + + const container = renderTable(); + + expect( + container.querySelector('[data-testid="history-incident-icon"]'), + ).toBeNull(); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/CollectionHistoryTable.tsx b/src/BloomBrowserUI/teamCollection/CollectionHistoryTable.tsx index d89f021d609b..4da83ed187be 100644 --- a/src/BloomBrowserUI/teamCollection/CollectionHistoryTable.tsx +++ b/src/BloomBrowserUI/teamCollection/CollectionHistoryTable.tsx @@ -1,10 +1,18 @@ import { css } from "@emotion/react"; import * as React from "react"; -import { useApiData } from "../utils/bloomApi"; +import { get, getBoolean, useApiData } from "../utils/bloomApi"; import { BloomAvatar } from "../react_components/bloomAvatar"; +import { BloomTooltip } from "../react_components/BloomToolTip"; +import WarningIcon from "@mui/icons-material/Warning"; +import { kBloomRed } from "../utils/colorUtils"; import { useEffect, useState } from "react"; import { useSubscribeToWebSocketForEvent } from "../utils/WebSocketManager"; +import { + isCloudTeamCollection, + useCloudCollectionId, + useTeamCollectionCapabilities, +} from "./teamCollectionApi"; interface IBookHistoryEvent { Title: string; @@ -68,6 +76,19 @@ const kEventTypes = [ "Moved", ]; // REVIEW maybe better to do this in c# and just send it over? +// Cloud Team Collection incident events use numeric values >= 100 (BookHistoryEventType in +// History\HistoryEvent.cs), so they fall OUTSIDE the positional kEventTypes array above and must +// be looked up by their exact numeric value -- otherwise they render blank in the table. +const kCloudEventTypeLabels: { [type: number]: string } = { + 100: "Work Preserved Locally", // BookHistoryEventType.WorkPreservedLocally +}; + +// Event types that represent an incident an admin should notice (per CONTRACTS.md: "recorded +// as a server-side incident event admins can see") rather than routine activity. Indices into +// kEventTypes above / BookHistoryEventType.cs: ForcedUnlock (5), SyncProblem (7) — the latter +// also covers the "repo won, local work saved to Lost & Found" case from the design doc. +const kIncidentEventTypes = new Set([5, 7]); + export const CollectionHistoryTable: React.FunctionComponent<{ selectedBook?: string; }> = (props) => { @@ -79,7 +100,11 @@ export const CollectionHistoryTable: React.FunctionComponent<{ useSubscribeToWebSocketForEvent("bookHistory", "eventAdded", () => setGeneration((gen) => gen + 1), ); - const events = useApiData( + + // Folder Team Collections: unchanged from before this task — same endpoint, same hook, same + // behavior. This hook call itself is unconditional (React's rules of hooks), but its result + // is only used below when !isCloud, so cloud Team Collections don't depend on it. + const folderEvents = useApiData( "teamCollection/getHistory" + (currentBookOnly ? "?currentBookOnly=true&generation=" + generation @@ -87,6 +112,48 @@ export const CollectionHistoryTable: React.FunctionComponent<{ [], ); + // Cloud Team Collections: history comes from the server events feed (CONTRACTS.md's + // `get_changes` RPC, surfaced here as the mocked "sharing/history" endpoint) instead of the + // local-file-derived endpoint folder Team Collections use above. While disconnected, fall + // back to a local cache of the last-known events (mocked "sharing/historyCache" for now; + // Wave 3 backs it with a real on-disk cache) rather than a live call that would just fail + // offline. Branches on capability, never on concrete backend type. + const capabilities = useTeamCollectionCapabilities(); + const isCloud = isCloudTeamCollection(capabilities); + const cloudCollectionId = useCloudCollectionId(); + // Folder Team Collections must make zero extra requests, so (unlike TeamCollectionDialog.tsx, + // which already called "teamCollection/isDisconnected" for folder Team Collections long + // before this project) this only queries it when isCloud, following the same + // guard-inside-the-effect pattern as teamCollectionApi.tsx's other Wave-2 hooks. + // `undefined` means "not yet known" so the cloud fetch below can wait for it, rather than + // firing an initial request against the wrong (live vs. cache) endpoint. + const [disconnected, setDisconnected] = useState( + undefined, + ); + useEffect(() => { + if (!isCloud) return; + getBoolean("teamCollection/isDisconnected", setDisconnected); + }, [isCloud]); + + const [cloudEvents, setCloudEvents] = useState([]); + useEffect(() => { + if (!isCloud || disconnected === undefined) return; + const cloudQuery = + (currentBookOnly ? "currentBookOnly=true&" : "") + + "collectionId=" + + encodeURIComponent(cloudCollectionId) + + "&generation=" + + generation; + get( + (disconnected ? "sharing/historyCache?" : "sharing/history?") + + cloudQuery, + (result) => + setCloudEvents((result.data as IBookHistoryEvent[]) ?? []), + ); + }, [isCloud, disconnected, cloudCollectionId, currentBookOnly, generation]); + + const events = isCloud ? cloudEvents : folderEvents; + return ( // The grand plan: https://www.figma.com/file/IlNPkoMn4Y8nlHMTCZrXfQSZ/Bloom-Collection-Tab?node-id=2707%3A6882
- {kEventTypes[e.Type]} + {isCloud && kIncidentEventTypes.has(e.Type) && ( + + + + )} + {kEventTypes[e.Type] ?? + kCloudEventTypeLabels[e.Type] ?? + ""} {e.Message} diff --git a/src/BloomBrowserUI/teamCollection/CreateCloudTeamCollection.test.tsx b/src/BloomBrowserUI/teamCollection/CreateCloudTeamCollection.test.tsx new file mode 100644 index 000000000000..33c21f983c29 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/CreateCloudTeamCollection.test.tsx @@ -0,0 +1,282 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { renderTestRoot as render, unmountTestRoot } from "../utils/testRender"; +import { CreateCloudTeamCollectionBody } from "./CreateCloudTeamCollection"; +import { ISharingLoginState } from "./sharingApi"; + +// Tests the presentational CreateCloudTeamCollectionBody directly with injected +// props/callbacks (no network layer), per Wave-1 scope: shells against mocked endpoints. +// Covers the step gating described in Design/CloudTeamCollections/tasks/07-ui-setup.md: +// sign-in (dev-mode form) -> immutable-name acknowledgement -> initial Send progress. + +// React tracks the previous value of native inputs internally; setting .value directly and +// dispatching a plain Event doesn't trigger React's onChange. Standard workaround (there is no +// @testing-library/react / user-event dependency in this project). +function setNativeValue(element: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("change", { bubbles: true })); + element.dispatchEvent(new Event("input", { bubbles: true })); +} + +const signedOutDev: ISharingLoginState = { mode: "dev", signedIn: false }; +const signedOutCloud: ISharingLoginState = { mode: "cloud", signedIn: false }; +const signedIn: ISharingLoginState = { + mode: "dev", + signedIn: true, + email: "me@example.com", + emailVerified: true, +}; + +function baseProps( + overrides: Partial< + React.ComponentProps + > = {}, +) { + return { + loginState: signedOutDev, + collectionName: "My Collection", + devEmail: "", + devPassword: "", + onDevEmailChange: vi.fn(), + onDevPasswordChange: vi.fn(), + onDevSignIn: vi.fn(), + signInSubmitAttempts: 0, + signInError: undefined, + onCloudSignInClick: vi.fn(), + nameAcknowledged: false, + onAcknowledgeNameChange: vi.fn(), + sendState: "notStarted" as const, + sendError: undefined, + onStartSend: vi.fn(), + onRetrySend: vi.fn(), + ...overrides, + }; +} + +describe("CreateCloudTeamCollectionBody", () => { + it("shows the dev-mode sign-in form when signed out in dev mode", () => { + const container = render( + , + ); + + expect( + container.querySelector('[data-testid="cloud-create-signin-step"]'), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="cloud-create-signin-email"]', + ), + ).not.toBeNull(); + // Gating: the confirm/name-acknowledgement step must not be reachable yet. + expect( + container.querySelector( + '[data-testid="cloud-create-confirm-step"]', + ), + ).toBeNull(); + }); + + it("shows the cloud-mode sign-in button (no email/password fields) when signed out in cloud mode", () => { + const onCloudSignInClick = vi.fn(); + const container = render( + , + ); + + expect( + container.querySelector( + '[data-testid="cloud-create-signin-email"]', + ), + ).toBeNull(); + const button = container.querySelector( + '[data-testid="cloud-create-cloud-signin-button"]', + ) as HTMLButtonElement; + expect(button).not.toBeNull(); + + act(() => button.click()); + expect(onCloudSignInClick).toHaveBeenCalled(); + }); + + it("reports typed email/password changes to the container via onDevEmailChange/onDevPasswordChange", () => { + const onDevEmailChange = vi.fn(); + const onDevPasswordChange = vi.fn(); + const container = render( + , + ); + + const emailInput = container.querySelector( + '[data-testid="cloud-create-signin-email"] input', + ) as HTMLInputElement; + act(() => setNativeValue(emailInput, "me@example.com")); + expect(onDevEmailChange).toHaveBeenCalledWith("me@example.com"); + + const passwordInput = container.querySelector( + '[data-testid="cloud-create-signin-password"] input', + ) as HTMLInputElement; + act(() => setNativeValue(passwordInput, "secret")); + expect(onDevPasswordChange).toHaveBeenCalledWith("secret"); + }); + + it("calls onDevSignIn with the entered credentials via the container callback", () => { + const onDevSignIn = vi.fn(); + const container = render( + , + ); + + const button = container.querySelector( + '[data-testid="cloud-create-signin-button"]', + ) as HTMLButtonElement; + act(() => button.click()); + + expect(onDevSignIn).toHaveBeenCalled(); + }); + + it("shows a sign-in error when provided", () => { + const container = render( + , + ); + + const errorEl = container.querySelector( + '[data-testid="cloud-create-signin-error"]', + ); + expect(errorEl).not.toBeNull(); + expect(errorEl?.textContent).toContain("Invalid credentials"); + }); + + it("gates the Share button on the immutable-name acknowledgement checkbox once signed in", () => { + const onStartSend = vi.fn(); + const container = render( + , + ); + + expect( + container.querySelector( + '[data-testid="cloud-create-confirm-step"]', + ), + ).not.toBeNull(); + const shareButton = container.querySelector( + '[data-testid="cloud-create-share-button"]', + ) as HTMLButtonElement; + expect(shareButton.disabled).toBe(true); + + act(() => shareButton.click()); + expect(onStartSend).not.toHaveBeenCalled(); + }); + + it("enables the Share button once the name is acknowledged, and starting send calls onStartSend", () => { + const onStartSend = vi.fn(); + const onAcknowledgeNameChange = vi.fn(); + const container = render( + , + ); + + const checkbox = container.querySelector( + "#cloud-create-name-ack-checkbox", + ) as HTMLInputElement; + expect(checkbox).not.toBeNull(); + expect(checkbox.checked).toBe(false); + act(() => checkbox.click()); + expect(onAcknowledgeNameChange).toHaveBeenCalledWith(true); + + // Re-render with the acknowledgement now true, as the container would after the callback. + unmountTestRoot(container); + const container2 = render( + , + ); + const shareButton = container2.querySelector( + '[data-testid="cloud-create-share-button"]', + ) as HTMLButtonElement; + expect(shareButton.disabled).toBe(false); + act(() => shareButton.click()); + expect(onStartSend).toHaveBeenCalled(); + }); + + it("shows send progress while sending", () => { + const container = render( + , + ); + + expect( + container.querySelector( + '[data-testid="cloud-create-sending-step"]', + ), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="cloud-create-progress"]'), + ).not.toBeNull(); + }); + + it("shows an error and a retry button when the send fails", () => { + const onRetrySend = vi.fn(); + const container = render( + , + ); + + const errorEl = container.querySelector( + '[data-testid="cloud-create-error"]', + ); + expect(errorEl?.textContent).toContain("Network error"); + const retryButton = container.querySelector( + '[data-testid="cloud-create-retry-button"]', + ) as HTMLButtonElement; + act(() => retryButton.click()); + expect(onRetrySend).toHaveBeenCalled(); + }); + + it("shows a done message when the send completes", () => { + const container = render( + , + ); + + expect( + container.querySelector('[data-testid="cloud-create-done-step"]'), + ).not.toBeNull(); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/CreateCloudTeamCollection.tsx b/src/BloomBrowserUI/teamCollection/CreateCloudTeamCollection.tsx new file mode 100644 index 000000000000..c5d10f040e3f --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/CreateCloudTeamCollection.tsx @@ -0,0 +1,286 @@ +import { css } from "@emotion/react"; + +import * as React from "react"; +import { useState } from "react"; + +import { get, post, useApiStringState } from "../utils/bloomApi"; +import BloomButton from "../react_components/bloomButton"; +import { P, Span } from "../react_components/l10nComponents"; +import LinearProgress from "@mui/material/LinearProgress"; +import { + BloomDialog, + DialogBottomButtons, + DialogMiddle, + DialogTitle, +} from "../react_components/BloomDialog/BloomDialog"; +import { DialogCancelButton } from "../react_components/BloomDialog/commonDialogComponents"; +import { useL10n } from "../react_components/l10nHooks"; +import { Checkbox } from "../react_components/checkbox"; +import { + IBloomDialogEnvironmentParams, + useSetupBloomDialog, +} from "../react_components/BloomDialog/BloomDialogPlumbing"; +import { ErrorBox } from "../react_components/boxes"; +import { showRegistrationDialog } from "../react_components/registration/registrationDialog"; +import { + ISharingLoginState, + createCloudTeamCollection, + useSharingLoginState, +} from "./sharingApi"; +import { DevSignInForm, useDevSignIn } from "./DevSignInForm"; + +// ----------------------------------------------------------------------------------------- +// Cloud Team Collection creation; see sharingApi.ts for the real SharingApi/TeamCollectionApi +// endpoints this drives. Unlike the folder-TC CreateTeamCollectionDialog +// (CreateTeamCollection.tsx), there is no folder chooser, no Dropbox checkboxes, and no +// restart: sign in, acknowledge the immutable name, then Bloom uploads (Sends) the current +// collection as the initial version of the new cloud Team Collection. +// This dialog is hosted by CreateTeamCollection.tsx's CreateTeamCollectionBundleDispatcher +// (the bundle's single WireUpForWinforms entry point); do NOT call WireUpForWinforms here. +// ----------------------------------------------------------------------------------------- + +export type CloudSendState = "notStarted" | "sending" | "done" | "error"; + +// Presentational: a pure function of its props, so the sign-in/acknowledge/send gating can be +// unit-tested without any network layer (same approach as SharingMembersList). +export const CreateCloudTeamCollectionBody: React.FunctionComponent<{ + loginState: ISharingLoginState; + collectionName: string; + devEmail: string; + devPassword: string; + onDevEmailChange: (value: string) => void; + onDevPasswordChange: (value: string) => void; + onDevSignIn: () => void; + signInSubmitAttempts: number; + signInError?: string; + onCloudSignInClick: () => void; + nameAcknowledged: boolean; + onAcknowledgeNameChange: (checked: boolean) => void; + sendState: CloudSendState; + sendError?: string; + onStartSend: () => void; + onRetrySend: () => void; +}> = (props) => { + if (!props.loginState.signedIn) { + return ( +
+

+ Sign in with your Bloom account to share this collection. +

+ {props.loginState.mode === "dev" ? ( + + ) : ( + // Production ("cloud") mode: the real BloomLibrary browser-based sign-in + // flow slots in later (task 06); for now this button just requests it. + + Sign in with your Bloom account + + )} +
+ ); + } + + if (props.sendState === "notStarted") { + return ( +
+ + I think the name, "%0", will be a good one for the whole + team. I understand that I will not be able to change this + name once this becomes a Team Collection. + + + Share Collection + +
+ ); + } + + if (props.sendState === "sending") { + return ( +
+

+ Sending your collection to the cloud sharing server. This + may take a while depending on the size of your collection. +

+ +
+ ); + } + + if (props.sendState === "error") { + return ( +
+
+ {props.sendError} +
+ + Try Again + +
+ ); + } + + // sendState === "done" + return ( +
+ + Your Team Collection is ready. Invite your team from the Team + Collection panel in Collection Settings. + +
+ ); +}; + +// Container: wires CreateCloudTeamCollectionBody up to sharingApi and the BloomDialog frame. +export const CreateCloudTeamCollectionDialog: React.FunctionComponent<{ + dialogEnvironment?: IBloomDialogEnvironmentParams; +}> = (props) => { + const loginState = useSharingLoginState(); + const [collectionName] = useApiStringState( + "teamCollection/getCollectionName", + "", + ); + const devSignIn = useDevSignIn(); + const [nameAcknowledged, setNameAcknowledged] = useState(false); + const [sendState, setSendState] = useState("notStarted"); + const [sendError, setSendError] = useState(undefined); + const { propsForBloomDialog } = useSetupBloomDialog( + props.dialogEnvironment, + ); + + const dialogTitle = useL10n( + "Share this Collection", + "TeamCollection.Sharing.ShareThisCollection", + undefined, + undefined, + undefined, + true, + ); + + const doSend = () => { + setSendState("sending"); + setSendError(undefined); + createCloudTeamCollection().then( + () => setSendState("done"), + (error) => { + setSendError(String(error?.message ?? error)); + setSendState("error"); + }, + ); + }; + + // Cloud TCs tie registration identity to the signed-in account (see registrationTypes.ts' + // cloudAccountEmail), so make sure this copy of Bloom is registered under that email before + // sending, same as the folder-TC dialog's tryToCreate() does for its own registration check. + const startSend = () => { + get("registration/userInfo", (userInfo) => { + if (userInfo?.data?.email) { + doSend(); + } else { + showRegistrationDialog({ + emailRequiredForTeamCollection: true, + cloudAccountEmail: loginState.email, + onSave: (hasValidEmail: boolean) => { + if (hasValidEmail) doSend(); + }, + }); + } + }); + }; + + return ( + + + + post("sharing/showSignIn")} + nameAcknowledged={nameAcknowledged} + onAcknowledgeNameChange={setNameAcknowledged} + sendState={sendState} + sendError={sendError} + onStartSend={startSend} + onRetrySend={startSend} + /> + + + {sendState === "done" ? ( + post("common/closeReactDialog")} + > + Close + + ) : ( + + post("common/closeReactDialog") + } + /> + )} + + + ); +}; diff --git a/src/BloomBrowserUI/teamCollection/CreateTeamCollection.entry.tsx b/src/BloomBrowserUI/teamCollection/CreateTeamCollection.entry.tsx index 7dd4f8570a26..301a10c0bee9 100644 --- a/src/BloomBrowserUI/teamCollection/CreateTeamCollection.entry.tsx +++ b/src/BloomBrowserUI/teamCollection/CreateTeamCollection.entry.tsx @@ -1,4 +1,9 @@ import { bootstrapReactComponent } from "../utils/entryPointBootstrap"; -import { CreateTeamCollectionDialog } from "./CreateTeamCollection"; +import { CreateTeamCollectionBundleDispatcher } from "./CreateTeamCollection"; -bootstrapReactComponent(CreateTeamCollectionDialog); +// Importing CreateTeamCollection.tsx already registers window.wireUpRootComponentFromWinforms +// (the dispatcher's own WireUpForWinforms call), which bootstrapReactComponent prefers when +// present -- see its own comment. Passing the dispatcher here too keeps the plain-Vite path +// (no `wireUpRootComponentFromWinforms`, e.g. a future non-WinForms host) selecting the same +// component instead of always defaulting to the folder dialog. +bootstrapReactComponent(CreateTeamCollectionBundleDispatcher); diff --git a/src/BloomBrowserUI/teamCollection/CreateTeamCollection.tsx b/src/BloomBrowserUI/teamCollection/CreateTeamCollection.tsx index 7e5906d9ca12..90f4015beecc 100644 --- a/src/BloomBrowserUI/teamCollection/CreateTeamCollection.tsx +++ b/src/BloomBrowserUI/teamCollection/CreateTeamCollection.tsx @@ -29,6 +29,8 @@ import { } from "../react_components/BloomDialog/BloomDialogPlumbing"; import { ErrorBox } from "../react_components/boxes"; import { showRegistrationDialog } from "../react_components/registration/registrationDialog"; +import { CreateCloudTeamCollectionDialog } from "./CreateCloudTeamCollection"; +import { SignInDialog } from "./SignInDialog"; // Contents of a dialog launched from TeamCollectionSettingsPanel Create Team Collection button. @@ -71,8 +73,9 @@ export const CreateTeamCollectionDialog: React.FunctionComponent<{ "", ); const [boxesChecked, setBoxesChecked] = useState(0); - const { showDialog, closeDialog, propsForBloomDialog } = - useSetupBloomDialog(props.dialogEnvironment); + const { propsForBloomDialog } = useSetupBloomDialog( + props.dialogEnvironment, + ); const checkChanged = (newVal: boolean) => { setBoxesChecked((oldCount) => (newVal ? oldCount + 1 : oldCount - 1)); @@ -204,7 +207,7 @@ export const CreateTeamCollectionDialog: React.FunctionComponent<{ l10nKey="TeamCollection.CreateAndRestart" hasText={true} enabled={ - !!repoFolderPath && !errorMessage && boxesChecked == 4 + !!repoFolderPath && !errorMessage && boxesChecked === 4 } temporarilyDisableI18nWarning={true} onClick={() => { @@ -221,4 +224,49 @@ export const CreateTeamCollectionDialog: React.FunctionComponent<{ ); }; -WireUpForWinforms(CreateTeamCollectionDialog); +// ----------------------------------------------------------------------------------------- +// This one bundle ("createTeamCollectionDialogBundle") hosts three distinct top-level +// dialogs -- the folder-TC create dialog above, the cloud-TC create dialog +// (CreateCloudTeamCollection.tsx), and the dedicated sign-in dialog (SignInDialog.tsx) -- +// because `WireUpForWinforms` sets a single +// global (`window.wireUpRootComponentFromWinforms`), so at most ONE component per bundle can +// ever call it: whichever call ran last at module load silently wins, breaking every other +// dialog in the bundle (this used to be a live bug -- the cloud dialog's own +// `WireUpForWinforms` call always overwrote the folder dialog's, so the folder-TC "Create Team +// Collection" dialog could no longer open). CreateTeamCollectionBundleDispatcher is the ONLY +// component in this bundle that may call WireUpForWinforms; C# selects which of the three to +// show via the `dialogKind` prop it now always passes (TeamCollectionApi.cs's +// HandleShowCreateTeamCollectionDialog/HandleShowCreateCloudTeamCollectionDialog and +// SharingApi.cs's HandleShowSignIn). +// ----------------------------------------------------------------------------------------- + +export type CreateTeamCollectionBundleDialogKind = + | "folder" + | "cloud" + | "signIn"; + +export const CreateTeamCollectionBundleDispatcher: React.FunctionComponent<{ + dialogKind?: CreateTeamCollectionBundleDialogKind; + errorForTesting?: string; + defaultRepoFolder?: string; + dialogEnvironment?: IBloomDialogEnvironmentParams; +}> = (props) => { + switch (props.dialogKind) { + case "cloud": + return ( + + ); + case "signIn": + return ; + case "folder": + default: + // Defaults to the folder dialog (today's only caller that predates the + // `dialogKind` prop existing) so this stays byte-identical for folder TCs even + // if some future caller forgets to pass it. + return ; + } +}; + +WireUpForWinforms(CreateTeamCollectionBundleDispatcher); diff --git a/src/BloomBrowserUI/teamCollection/CreateTeamCollectionBundleDispatcher.test.tsx b/src/BloomBrowserUI/teamCollection/CreateTeamCollectionBundleDispatcher.test.tsx new file mode 100644 index 000000000000..48ef38dfb8ad --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/CreateTeamCollectionBundleDispatcher.test.tsx @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { renderTestRoot as render } from "../utils/testRender"; +import { normalDialogEnvironmentForStorybook } from "../react_components/BloomDialog/BloomDialogPlumbing"; +import { CreateTeamCollectionBundleDispatcher } from "./CreateTeamCollection"; + +// Regression test for the bug this dispatcher fixes: "createTeamCollectionDialogBundle" is one +// shared bundle/entry hosting three top-level dialogs (folder create, cloud create, sign-in), +// but WireUpForWinforms sets a single global, so at most one component per bundle may call it. +// Before this dispatcher existed, the folder and cloud dialogs each called WireUpForWinforms +// directly, and whichever one's module-scope call ran last silently won -- breaking the other +// dialog (in practice, the folder-TC "Create Team Collection" dialog could no longer open). +// This test proves the dispatcher renders the right component for every dialogKind C# can send, +// including the case where dialogKind is omitted (must still be the folder dialog, since that's +// the one caller that predates the prop). + +describe("CreateTeamCollectionBundleDispatcher", () => { + it('renders the folder create dialog when dialogKind is "folder"', () => { + render( + , + ); + expect( + document.querySelector('[id="create-and-restart"]'), + ).not.toBeNull(); + }); + + it("renders the folder create dialog when dialogKind is omitted (default)", () => { + render( + , + ); + expect( + document.querySelector('[id="create-and-restart"]'), + ).not.toBeNull(); + }); + + it('renders the cloud create dialog when dialogKind is "cloud"', () => { + render( + , + ); + expect( + document.querySelector('[data-testid="cloud-create-signin-step"]'), + ).not.toBeNull(); + expect(document.querySelector('[id="create-and-restart"]')).toBeNull(); + }); + + it('renders the sign-in dialog when dialogKind is "signIn"', () => { + render( + , + ); + expect( + document.querySelector('[data-testid="signin-dev-form"]'), + ).not.toBeNull(); + expect(document.querySelector('[id="create-and-restart"]')).toBeNull(); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/DevSignInForm.tsx b/src/BloomBrowserUI/teamCollection/DevSignInForm.tsx new file mode 100644 index 000000000000..2b8e89302982 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/DevSignInForm.tsx @@ -0,0 +1,123 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { useState } from "react"; +import BloomButton from "../react_components/bloomButton"; +import { DialogControlGroup } from "../react_components/BloomDialog/commonDialogComponents"; +import { AttentionTextField } from "../react_components/AttentionTextField"; +import { ErrorBox } from "../react_components/boxes"; +import { isValidEmail } from "../utils/emailUtils"; +import { signIn as sharingSignIn } from "./sharingApi"; + +// The dev-auth-mode email/password sign-in form shared by the dedicated sign-in dialog +// (SignInDialog.tsx) and the cloud create-collection dialog's sign-in step +// (CreateCloudTeamCollection.tsx). Purely presentational (controlled by its props, no network +// layer) so both hosts stay unit-testable the same way; each host supplies its own +// data-testid prefix so existing tests keep their distinct ids. The matching container-side +// state + submit/validation logic the two hosts also shared lives in useDevSignIn() below. + +export const DevSignInForm: React.FunctionComponent<{ + // data-testids rendered are `${testIdPrefix}-email`, `-password`, `-error`, `-button`. + testIdPrefix: string; + email: string; + password: string; + onEmailChange: (value: string) => void; + onPasswordChange: (value: string) => void; + onSignIn: () => void; + submitAttempts: number; + signInError?: string; +}> = (props) => { + return ( + + isValidEmail(value.trim())} + submitAttempts={props.submitAttempts} + data-testid={`${props.testIdPrefix}-email`} + css={css` + margin-top: 5px; + `} + /> + value.length > 0} + submitAttempts={props.submitAttempts} + data-testid={`${props.testIdPrefix}-password`} + css={css` + margin-top: 5px; + `} + /> + {props.signInError && ( +
+ {props.signInError} +
+ )} + + Sign In + +
+ ); +}; + +// Container-side half of the shared dev sign-in flow: owns the email/password/submit-attempt/ +// error state and the validate-then-sharing/login submit handler that SignInDialog and +// CreateCloudTeamCollectionDialog previously duplicated. The returned values plug straight +// into DevSignInForm's props (usually via a presentational Body component in between). +// Note: onSignIn resolves via the "sharing"/"loginState" websocket event (watched by +// useSharingLoginState), not a return value; only a rejection is surfaced, as signInError. +export function useDevSignIn(): { + email: string; + setEmail: (value: string) => void; + password: string; + setPassword: (value: string) => void; + submitAttempts: number; + signInError?: string; + onSignIn: () => void; +} { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [submitAttempts, setSubmitAttempts] = useState(0); + const [signInError, setSignInError] = useState( + undefined, + ); + const onSignIn = () => { + if (!isValidEmail(email.trim()) || password.length === 0) { + setSubmitAttempts((old) => old + 1); + return; + } + setSignInError(undefined); + sharingSignIn(email.trim(), password).then(undefined, (error) => + setSignInError(String(error?.message ?? error)), + ); + }; + return { + email, + setEmail, + password, + setPassword, + submitAttempts, + signInError, + onSignIn, + }; +} diff --git a/src/BloomBrowserUI/teamCollection/ForceUnlockDialog.tsx b/src/BloomBrowserUI/teamCollection/ForceUnlockDialog.tsx index 17cf5bd76701..b27c796b413a 100644 --- a/src/BloomBrowserUI/teamCollection/ForceUnlockDialog.tsx +++ b/src/BloomBrowserUI/teamCollection/ForceUnlockDialog.tsx @@ -15,6 +15,10 @@ import { } from "../react_components/BloomDialog/BloomDialog"; import { kBloomRed } from "../utils/colorUtils"; import { WarningBox } from "../react_components/boxes"; +import { + isCloudTeamCollection, + useTeamCollectionCapabilities, +} from "./teamCollectionApi"; // Dialog shown (when props.open is true) in response to the "Force Unlock (Administrator Only)..." menu item // in the TeamCollectionBookStatusPanel. @@ -22,6 +26,13 @@ export const ForceUnlockDialog: React.FunctionComponent<{ open: boolean; close: () => void; }> = (props) => { + // Cloud Team Collections: force-unlock is an audited server RPC (CONTRACTS.md's + // `force_unlock(book_id)`, exposed here as the "sharing/forceUnlock" endpoint), not the + // plain local-file operation folder Team Collections use. Branches on capability, never on + // concrete backend type. + const capabilities = useTeamCollectionCapabilities(); + const isCloud = isCloudTeamCollection(capabilities); + const title = useL10n( "Force Unlock (Administrator Only)", "TeamCollection.ForceUnlockTitle", @@ -99,7 +110,11 @@ export const ForceUnlockDialog: React.FunctionComponent<{ onClick={() => { props.close(); // Do nothing here on either success or failure. (C# code will have already reported failure). - post("teamCollection/forceUnlock"); + post( + isCloud + ? "sharing/forceUnlock" + : "teamCollection/forceUnlock", + ); }} hasText={true} > diff --git a/src/BloomBrowserUI/teamCollection/JoinCloudCollectionDialog.test.tsx b/src/BloomBrowserUI/teamCollection/JoinCloudCollectionDialog.test.tsx new file mode 100644 index 000000000000..ee4a2c32e656 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/JoinCloudCollectionDialog.test.tsx @@ -0,0 +1,287 @@ +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderTestRoot } from "../utils/testRender"; +import { normalDialogEnvironmentForStorybook } from "../react_components/BloomDialog/BloomDialogPlumbing"; +import { JoinCloudCollectionDialog } from "./JoinCloudCollectionDialog"; + +// Tests the state-derivation logic (NotSignedIn / ApprovalRemoved / the six folder-TC-style +// scenarios) of JoinCloudCollectionDialog, the pull-down-join dialog opened from "Get my Team +// Collections" in the collection chooser. Per Wave-1 scope (shells against mocked endpoints), +// only sharingApi's pullDownCollection and bloomApi's post are mocked; everything else renders +// for real. MUI's Dialog renders via a portal to document.body, so assertions query +// document.body rather than a local render container (unlike the other tests in this task, +// which test presentational sub-components directly to avoid the portal). +// +// Note: the test-only localizationManager mock (vitest.setup.ts) resolves every l10nKey to the +// key itself rather than the English fallback (see the comment about this in +// SharingPanel.test.tsx), so text assertions here check for the l10nKey rather than the English +// string the component declares as a child. + +const { mockPullDownCollection, mockPost, mockPostString } = vi.hoisted(() => ({ + mockPullDownCollection: vi.fn(), + mockPost: vi.fn(), + mockPostString: vi.fn(), +})); + +vi.mock("./sharingApi", () => ({ + pullDownCollection: mockPullDownCollection, +})); + +vi.mock("../utils/bloomApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + post: mockPost, + postString: mockPostString, + }; +}); + +function renderDialog( + overrides: Partial>, +) { + renderTestRoot( + , + ); +} + +// Flushes the microtask queue so a clicked button's pullDownCollection().then(...) chain has +// run before the next assertion. +async function flushPromises() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function getActionButton(): HTMLButtonElement { + const button = document.querySelector( + '[data-testid="join-cloud-collection-action-button"]', + ) as HTMLButtonElement; + expect(button).not.toBeNull(); + return button; +} + +function getBodyText(): string { + const body = document.querySelector( + '[data-testid="join-cloud-collection-body"]', + ); + expect(body).not.toBeNull(); + return body!.textContent ?? ""; +} + +beforeEach(() => { + // Default: pullDownCollection succeeds. Individual tests override with mockRejectedValue + // to exercise the failure path. + mockPullDownCollection.mockResolvedValue(undefined); +}); + +afterEach(() => { + mockPullDownCollection.mockReset(); + mockPost.mockClear(); + mockPostString.mockClear(); +}); + +describe("JoinCloudCollectionDialog", () => { + it("NotSignedIn: prompts to sign in and the action button posts sharing/showSignIn (not pullDownCollection)", () => { + renderDialog({ signedIn: false }); + + expect(getBodyText()).toContain( + "TeamCollection.Sharing.MustSignInToJoin", + ); + + act(() => getActionButton().click()); + expect(mockPost).toHaveBeenCalledWith("sharing/showSignIn"); + expect(mockPullDownCollection).not.toHaveBeenCalled(); + }); + + it("ApprovalRemoved: disables the action button and explains why", () => { + renderDialog({ signedIn: true, isApproved: false }); + + expect(getBodyText()).toContain( + "TeamCollection.Sharing.ApprovalRemoved", + ); + expect(getActionButton().disabled).toBe(true); + + act(() => getActionButton().click()); + expect(mockPost).not.toHaveBeenCalled(); + expect(mockPullDownCollection).not.toHaveBeenCalled(); + }); + + it("CreateNewCollection: enabled action button calls pullDownCollection with the collectionId", () => { + renderDialog({ + signedIn: true, + isApproved: true, + existingCollection: false, + }); + + expect(getBodyText()).toContain( + "TeamCollection.Sharing.BloomWillPullDown", + ); + const button = getActionButton(); + expect(button.disabled).toBe(false); + act(() => button.click()); + expect(mockPullDownCollection).toHaveBeenCalledWith("collection-123"); + }); + + it("MatchesExistingTeamCollection: already-linked case offers to open, not pull down again", () => { + renderDialog({ + signedIn: true, + isApproved: true, + existingCollection: true, + isAlreadyTcCollection: true, + isSameCollection: true, + isCurrentCollection: true, + existingCollectionFolder: "C:\\Users\\me\\Bloom Collections\\Foo", + }); + + expect(getBodyText()).toContain("TeamCollection.AlreadyJoined"); + expect(getActionButton().disabled).toBe(false); + + act(() => getActionButton().click()); + expect(mockPullDownCollection).toHaveBeenCalledWith("collection-123"); + }); + + it("MatchesExistingTeamCollectionElsewhere: moved-local-copy case offers to fix up and open", () => { + renderDialog({ + signedIn: true, + isApproved: true, + existingCollection: true, + isAlreadyTcCollection: true, + isSameCollection: true, + isCurrentCollection: false, + existingCollectionFolder: "C:\\Users\\me\\Bloom Collections\\Foo", + }); + + expect(getBodyText()).toContain( + "TeamCollection.AlreadyJoinedElsewhere", + ); + expect(getActionButton().disabled).toBe(false); + }); + + it("MatchesExistingNonTeamCollection: offers to merge the existing local collection", () => { + renderDialog({ + signedIn: true, + isApproved: true, + existingCollection: true, + isAlreadyTcCollection: false, + existingCollectionFolder: "C:\\Users\\me\\Bloom Collections\\Foo", + }); + + expect(getBodyText()).toContain("TeamCollection.Merging"); + expect(getActionButton().disabled).toBe(false); + }); + + it("IncompleteLocalCopy: reports the problem and still allows retrying the pull-down", () => { + renderDialog({ + signedIn: true, + isApproved: true, + incompleteLocalCopy: true, + }); + + expect(getBodyText()).toContain( + "TeamCollection.Sharing.IncompleteLocalCopy", + ); + expect(getActionButton().disabled).toBe(false); + act(() => getActionButton().click()); + expect(mockPullDownCollection).toHaveBeenCalledWith("collection-123"); + }); + + it("MatchesDifferentTeamCollection: conflict disables the action button and offers Report", () => { + renderDialog({ + signedIn: true, + isApproved: true, + existingCollection: true, + isAlreadyTcCollection: true, + isSameCollection: false, + existingCollectionFolder: "C:\\Users\\me\\Bloom Collections\\Foo", + conflictingCollection: "cloud://sil.bloom/collection/other-id", + }); + + expect(getActionButton().disabled).toBe(true); + expect(getBodyText()).toContain("TeamCollection.ConflictingCollection"); + // The Report button (DialogBottomLeftButtons) is a sibling of the body, not inside it. + expect(document.body.textContent).toContain("ErrorReport.Report"); + }); + + it("calls onClose (so an embedding parent can unmount it) once pullDownCollection succeeds", async () => { + const onClose = vi.fn(); + renderDialog({ onClose }); + + act(() => getActionButton().click()); + await flushPromises(); + + expect(mockPullDownCollection).toHaveBeenCalledWith("collection-123"); + expect(onClose).toHaveBeenCalled(); + expect( + document.querySelector( + '[data-testid="join-cloud-collection-error"]', + ), + ).toBeNull(); + }); + + it("auto-opens the pulled-down .bloomCollection file the server returns, the same action the chooser's cards use (task 10)", async () => { + mockPullDownCollection.mockResolvedValue({ + data: { + collectionPath: + "C:\\Users\\me\\Bloom Collections\\Foo\\Foo.bloomCollection", + }, + }); + renderDialog({}); + + act(() => getActionButton().click()); + await flushPromises(); + + expect(mockPostString).toHaveBeenCalledWith( + "workspace/openCollection", + "C:\\Users\\me\\Bloom Collections\\Foo\\Foo.bloomCollection", + ); + }); + + it("does not try to auto-open anything when the server response carries no collectionPath", async () => { + mockPullDownCollection.mockResolvedValue(undefined); + renderDialog({}); + + act(() => getActionButton().click()); + await flushPromises(); + + expect(mockPostString).not.toHaveBeenCalled(); + }); + + it("shows the server's real error message and stays open when pullDownCollection fails", async () => { + mockPullDownCollection.mockRejectedValue( + new Error( + 'There is already a different Team Collection called "My Team\'s Collection" on this computer.', + ), + ); + const onClose = vi.fn(); + renderDialog({ onClose }); + + act(() => getActionButton().click()); + await flushPromises(); + + expect(onClose).not.toHaveBeenCalled(); + const error = document.querySelector( + '[data-testid="join-cloud-collection-error"]', + ); + expect(error).not.toBeNull(); + expect(error!.textContent).toContain( + "There is already a different Team Collection", + ); + // The action button is re-enabled so the user can retry (e.g. after picking a + // different name/removing the conflicting collection). + expect(getActionButton().disabled).toBe(false); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/JoinCloudCollectionDialog.tsx b/src/BloomBrowserUI/teamCollection/JoinCloudCollectionDialog.tsx new file mode 100644 index 000000000000..a76f90e71fa7 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/JoinCloudCollectionDialog.tsx @@ -0,0 +1,453 @@ +import * as React from "react"; +import { useState } from "react"; +import { AxiosResponse } from "axios"; +import { post, postString } from "../utils/bloomApi"; +import { Div, P, Span } from "../react_components/l10nComponents"; +import BloomButton from "../react_components/bloomButton"; + +import { + BloomDialog, + DialogBottomButtons, + DialogBottomLeftButtons, + DialogMiddle, + DialogTitle, +} from "../react_components/BloomDialog/BloomDialog"; +import { useL10n } from "../react_components/l10nHooks"; +import { + DialogCancelButton, + DialogReportButton, +} from "../react_components/BloomDialog/commonDialogComponents"; +import { + IBloomDialogEnvironmentParams, + useSetupBloomDialog, +} from "../react_components/BloomDialog/BloomDialogPlumbing"; +import { ErrorBox, NoteBoxSansBorder } from "../react_components/boxes"; +import { IPullDownResult, pullDownCollection } from "./sharingApi"; + +// The pull-down-join dialog for cloud Team Collections: same shape as the folder-TC +// JoinTeamCollectionDialog (see that file), extended with two states that only make sense for +// a cloud collection where "join" means "sign in, then pull down a copy from the server" rather +// than "point at a shared folder": NotSignedIn and ApprovalRemoved. Eight variations total. +// +// Embedded directly inside CollectionChooser (not opened as its own WinForms ReactDialog -- +// there is no C# call site or bundle entry for it, unlike the other top-level dialogs in this +// folder), so it deliberately does NOT call WireUpForWinforms itself: CollectionChooser lives +// in the same bundle as CollectionChooserDialog, which already owns that bundle's one +// WireUpForWinforms call, and a second call here would silently overwrite it (the exact bug +// item 1 of this task fixed elsewhere -- see CreateTeamCollection.tsx). +enum JoinCloudCollectionState { + // The signed-in user must sign in before Bloom can check their approval / pull anything down. + "NotSignedIn", + // Signed in, but this email is not (or no longer) on the collection's approved-accounts list. + "ApprovalRemoved", + // No local collection with the same name yet. Offer to pull down a fresh copy. + "CreateNewCollection", + // A local collection with the same name exists but isn't a Team Collection. Offer to merge. + "MatchesExistingNonTeamCollection", + // Already pulled down and linked to this same cloud collection, at the expected location. + "MatchesExistingTeamCollection", + // Already linked to this same cloud collection, but the local copy has moved. Offer to fix up. + "MatchesExistingTeamCollectionElsewhere", + // A local collection of the same name is linked to a *different* cloud collection. Conflict. + "MatchesDifferentTeamCollection", + // A previous pull-down left an incomplete/corrupt local cache. Needs a fresh pull-down. + "IncompleteLocalCopy", +} + +// In normal use (not storybook), this is a top-level component in a ReactDialog, opened when +// the user picks a cloud collection from "Get my Team Collections" in the collection chooser. +export const JoinCloudCollectionDialog: React.FunctionComponent<{ + collectionId: string; + collectionName: string; + signedIn: boolean; + isApproved: boolean; + incompleteLocalCopy?: boolean; + existingCollection: boolean; + isAlreadyTcCollection: boolean; + isSameCollection: boolean; // that is, linked to this same cloud collectionId + isCurrentCollection: boolean; // that is, it already points at the expected local location + existingCollectionFolder: string; // if there's an existing local collection, a path to it + conflictingCollection: string; // if there's a conflicting repo the existing collection is linked to + dialogEnvironment?: IBloomDialogEnvironmentParams; + // Called after the dialog closes, whether via Cancel or a successful pull-down. Lets an + // embedding parent (CollectionChooser) unmount/hide it; optional so storybook and existing + // tests that don't care about this can omit it. + onClose?: () => void; +}> = (props) => { + const { closeDialog, propsForBloomDialog } = useSetupBloomDialog( + props.dialogEnvironment, + ); + const [joining, setJoining] = useState(false); + const [joinError, setJoinError] = useState(undefined); + + const dialogTitle = useL10n( + 'Join the Bloom Team Collection "%0"', + "TeamCollection.JoinHeading", + undefined, + props.collectionName, + undefined, + true, // temporarilyDisableI18nWarning + ); + const dialogState = getDialogStateFromProps(); + const l10nJoinButtonKey = getL10nKeyForJoinButton(); + const joinButtonEnglish = getJoinButtonEnglish(); + + function getDialogStateFromProps(): JoinCloudCollectionState { + if (!props.signedIn) { + return JoinCloudCollectionState.NotSignedIn; + } + if (!props.isApproved) { + return JoinCloudCollectionState.ApprovalRemoved; + } + if (props.incompleteLocalCopy) { + return JoinCloudCollectionState.IncompleteLocalCopy; + } + if (!props.existingCollection) { + return JoinCloudCollectionState.CreateNewCollection; + } + if (!props.isAlreadyTcCollection) { + return JoinCloudCollectionState.MatchesExistingNonTeamCollection; + } + if (!props.isSameCollection) { + return JoinCloudCollectionState.MatchesDifferentTeamCollection; + } + if (props.isCurrentCollection) { + return JoinCloudCollectionState.MatchesExistingTeamCollection; + } else { + return JoinCloudCollectionState.MatchesExistingTeamCollectionElsewhere; + } + } + + function getL10nKeyForJoinButton(): string { + if (dialogState === JoinCloudCollectionState.NotSignedIn) { + return "TeamCollection.Sharing.SignIn"; + } + if ( + dialogState === + JoinCloudCollectionState.MatchesExistingNonTeamCollection + ) { + return "TeamCollection.JoinAndMerge"; + } + if ( + dialogState === + JoinCloudCollectionState.MatchesExistingTeamCollection + ) { + return "TeamCollection.Open"; + } + return "TeamCollection.Join"; + } + + function getJoinButtonEnglish(): string { + if (dialogState === JoinCloudCollectionState.NotSignedIn) { + return "Sign In"; + } + if ( + dialogState === + JoinCloudCollectionState.MatchesExistingNonTeamCollection + ) { + return "Join and Merge"; + } + if ( + dialogState === + JoinCloudCollectionState.MatchesExistingTeamCollection + ) { + return "Open"; + } + // Leaving it as "Join" for the pathological/disabled cases. + return "Join"; + } + + function getMatchingCollection() { + return ( +

+ + Matching local collection: + {" "} + {props.existingCollectionFolder} +

+ ); + } + + function getDialogBodyNotSignedIn(): JSX.Element { + return ( + +
+ Sign in with your Bloom account to join "%0". +
+
+ ); + } + + function getDialogBodyApprovalRemoved(): JSX.Element { + return ( + +
+ You are not currently on the approved list for "%0". Contact + an administrator of this Team Collection to be added. +
+
+ ); + } + + function getDialogBodyIncompleteLocalCopy(): JSX.Element { + return ( + +
+ Bloom found an incomplete local copy of this Team + Collection, probably left over from an earlier attempt to + join. Bloom will download a fresh copy. +
+
+ ); + } + + function getBloomWillPullDown() { + return ( +

+ Bloom will download this Team Collection so you can work + together with your team. +

+ ); + } + + function getDialogBodyExistingNonTC(): JSX.Element { + return ( + + {getBloomWillPullDown()} + + + You already have a collection with this same name. If + you continue, Bloom will merge your existing "%0" + collection with the Team Collection that you are + joining. + + + {getMatchingCollection()} + + ); + } + + // Existing local collection is already linked to this same cloud collection. + function getDialogBodyExistingTC(): JSX.Element { + return ( + + +
+ This computer is already connected to this collection. + Bloom will open it for you. +
+
+ {getMatchingCollection()} +
+ ); + } + + // No local collection of this name yet: just say Bloom will pull one down. + function getDialogBodyCreateNew(): JSX.Element { + return {getBloomWillPullDown()}; + } + + // Common content for the two pathological cases where the existing local collection is a TC + // but NOT already linked to the cloud collection we're trying to join. + function getConflictingCollectionCommon() { + return ( + + +
+ Bloom found another collection with this same name that + is already connected to a different Team Collection. + Click REPORT to get help from the Bloom team. +
+
+ {getMatchingCollection()} +

+ + Conflicting Team collection: + + {props.conflictingCollection} +

+
+ ); + } + + // Existing local collection is a TC linked to this same cloud collection but at a different + // local path (e.g. the local cache folder moved). + function getDialogBodyExistingTcElsewhere() { + return ( + + +
+ This computer is already connected to this collection, + which appears to have moved. Bloom will fix things up + and open it for you. +
+
+ {getMatchingCollection()} +
+ ); + } + + // Existing local collection is a TC for a different cloud collection (same name, different id). + function getDialogBodyDifferentTc() { + return ( + + {getConflictingCollectionCommon()} +

+ (Different TC IDs) +

+
+ ); + } + + function getBodyOfDialogByState(): JSX.Element { + switch (dialogState) { + case JoinCloudCollectionState.NotSignedIn: + return getDialogBodyNotSignedIn(); + case JoinCloudCollectionState.ApprovalRemoved: + return getDialogBodyApprovalRemoved(); + case JoinCloudCollectionState.IncompleteLocalCopy: + return getDialogBodyIncompleteLocalCopy(); + case JoinCloudCollectionState.MatchesExistingNonTeamCollection: + return getDialogBodyExistingNonTC(); + case JoinCloudCollectionState.MatchesExistingTeamCollection: + return getDialogBodyExistingTC(); + case JoinCloudCollectionState.CreateNewCollection: + return getDialogBodyCreateNew(); + case JoinCloudCollectionState.MatchesExistingTeamCollectionElsewhere: + return getDialogBodyExistingTcElsewhere(); + case JoinCloudCollectionState.MatchesDifferentTeamCollection: + return getDialogBodyDifferentTc(); + default: + return
; + } + } + + const wantReportButton = + dialogState === JoinCloudCollectionState.MatchesDifferentTeamCollection; + // ApprovalRemoved has no useful action to offer besides closing the dialog. + const joinButtonDisabled = + wantReportButton || + dialogState === JoinCloudCollectionState.ApprovalRemoved || + joining; + + function handleJoinClick() { + if (dialogState === JoinCloudCollectionState.NotSignedIn) { + post("sharing/showSignIn"); + return; + } + setJoining(true); + setJoinError(undefined); + // collections/pullDown (SharingApi.cs's HandlePullDown) either succeeds outright (the + // ordinary case: no local conflict) or fails with a human-readable message -- it does + // not report back which of the six local-vs-remote scenarios above applied ahead of + // time (that matching happens server-side, in CloudJoinFlow, only once the pull-down is + // actually attempted). So on failure we show the server's real message rather than + // guessing which specific state's copy to switch to. + pullDownCollection(props.collectionId).then( + (response) => { + setJoining(false); + closeDialog(); + props.onClose?.(); + // Auto-open the collection we just pulled down (task 10), the same action the + // chooser's own cards use (CollectionCard's onClick), instead of leaving the + // user to hunt for it themselves. + const result = ( + response as AxiosResponse | undefined + )?.data; + if (result?.collectionPath) { + postString( + "workspace/openCollection", + result.collectionPath, + ); + } + }, + (error) => { + setJoining(false); + setJoinError(String(error?.message ?? error)); + }, + ); + } + + return ( + + + +
+ {getBodyOfDialogByState()} +
+ {joinError && ( +
+ {joinError} +
+ )} +
+ + {wantReportButton && ( + + + // Not trying to be nice about this message. The user won't usually see it; + // it's buried in the report we send to YouTrack telling US what went wrong. + `trying to join cloud collection ${props.collectionId}, but local collection ${props.existingCollectionFolder} is linked to ${props.conflictingCollection}` + } + /> + + )} + + {joinButtonEnglish} + + { + closeDialog(); + props.onClose?.(); + }} + /> + +
+ ); +}; diff --git a/src/BloomBrowserUI/teamCollection/NewerVersionAvailableMarker.test.tsx b/src/BloomBrowserUI/teamCollection/NewerVersionAvailableMarker.test.tsx new file mode 100644 index 000000000000..925ce2959954 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/NewerVersionAvailableMarker.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { renderTestRoot } from "../utils/testRender"; +import { NewerVersionAvailableMarker } from "./NewerVersionAvailableMarker"; + +// Tests the Wave-2 "newer version exists" book-thumbnail marker in isolation: it's a pure +// function of its `show` prop (the gating/version-comparison logic lives in BookButton.tsx, +// which composes this marker the same way it already composes BookOnBlorgBadge). + +function render(show: boolean): HTMLDivElement { + return renderTestRoot(); +} + +describe("NewerVersionAvailableMarker", () => { + it("renders nothing when show is false", () => { + const container = render(false); + expect( + container.querySelector( + '[data-testid="newer-version-available-marker"]', + ), + ).toBeNull(); + }); + + it("renders the marker when show is true", () => { + const container = render(true); + expect( + container.querySelector( + '[data-testid="newer-version-available-marker"]', + ), + ).not.toBeNull(); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/NewerVersionAvailableMarker.tsx b/src/BloomBrowserUI/teamCollection/NewerVersionAvailableMarker.tsx new file mode 100644 index 000000000000..efd9270cdece --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/NewerVersionAvailableMarker.tsx @@ -0,0 +1,47 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import UpdateIcon from "@mui/icons-material/Update"; +import { BloomTooltip } from "../react_components/BloomToolTip"; +import { kBloomBlue } from "../bloomMaterialUITheme"; + +// A subtle marker overlaid on a book's thumbnail in the collection tab's book list (BookButton.tsx), +// shown for a cloud Team Collection book that isn't checked out to anyone but has a newer version +// in the repo than what's on this computer (same condition as the "updatesAvailable" state in +// TeamCollectionBookStatusPanel.tsx, which shows for the currently-selected book). Positioned +// top-right of the thumbnail so it doesn't collide with the holder-avatar overlay (top-left of +// the book button) or BookOnBlorgBadge (bottom-right of the thumbnail). +export const NewerVersionAvailableMarker: React.FunctionComponent<{ + show: boolean; +}> = (props) => { + if (!props.show) { + return null; + } + return ( +
+ + + +
+ ); +}; diff --git a/src/BloomBrowserUI/teamCollection/ShareButton.test.tsx b/src/BloomBrowserUI/teamCollection/ShareButton.test.tsx new file mode 100644 index 000000000000..0c49accf6619 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/ShareButton.test.tsx @@ -0,0 +1,162 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderTestRoot } from "../utils/testRender"; +import { ShareButton } from "./ShareButton"; +import { ITeamCollectionCapabilities } from "./teamCollectionApi"; + +// Tests the Wave-2 Share button shown beside the Team Collection status button: it must be +// invisible for folder Team Collections (no sharing concept there) and, for cloud Team +// Collections, open SharingPanel with the right collectionId/currentUserEmail/isAdmin props. +// SharingPanel itself is unit-tested separately (SharingPanel.test.tsx), so it's mocked here to +// a prop-recording stub; MUI's Popover renders via a portal to document.body, so assertions +// query document rather than the local render container (see JoinCloudCollectionDialog.test.tsx +// for the same pattern/rationale). + +const { mockUseTeamCollectionCapabilities } = vi.hoisted(() => ({ + mockUseTeamCollectionCapabilities: vi.fn(), +})); +const { mockUseCloudCollectionId, mockUseIsTeamCollectionAdmin } = vi.hoisted( + () => ({ + mockUseCloudCollectionId: vi.fn(), + mockUseIsTeamCollectionAdmin: vi.fn(), + }), +); +const { mockUseSharingLoginState } = vi.hoisted(() => ({ + mockUseSharingLoginState: vi.fn(), +})); +const { mockSharingPanel } = vi.hoisted(() => ({ + mockSharingPanel: vi.fn(() => ( +
mock sharing panel
+ )), +})); + +vi.mock("./teamCollectionApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTeamCollectionCapabilities: mockUseTeamCollectionCapabilities, + useCloudCollectionId: mockUseCloudCollectionId, + useIsTeamCollectionAdmin: mockUseIsTeamCollectionAdmin, + }; +}); + +vi.mock("./sharingApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSharingLoginState: mockUseSharingLoginState, + }; +}); + +vi.mock("./SharingPanel", () => ({ + SharingPanel: mockSharingPanel, +})); + +const folderCapabilities: ITeamCollectionCapabilities = { + supportsVersionHistory: false, + supportsSharingUi: false, + requiresSignIn: false, +}; + +const cloudCapabilities: ITeamCollectionCapabilities = { + supportsVersionHistory: true, + supportsSharingUi: true, + requiresSignIn: true, +}; + +function renderShareButton() { + renderTestRoot(); +} + +afterEach(() => { + mockUseTeamCollectionCapabilities.mockReset(); + mockUseCloudCollectionId.mockReset(); + mockUseIsTeamCollectionAdmin.mockReset(); + mockUseSharingLoginState.mockReset(); + mockSharingPanel.mockClear(); +}); + +describe("ShareButton", () => { + it("folder Team Collection: renders nothing", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + mockUseCloudCollectionId.mockReturnValue(""); + mockUseIsTeamCollectionAdmin.mockReturnValue(false); + mockUseSharingLoginState.mockReturnValue({ + mode: "dev", + signedIn: false, + }); + + renderShareButton(); + + expect(document.getElementById("teamCollectionShareButton")).toBeNull(); + expect(mockSharingPanel).not.toHaveBeenCalled(); + }); + + it("cloud Team Collection: shows the Share button, initially without opening the panel", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + mockUseCloudCollectionId.mockReturnValue("collection-123"); + mockUseIsTeamCollectionAdmin.mockReturnValue(true); + mockUseSharingLoginState.mockReturnValue({ + mode: "dev", + signedIn: true, + email: "admin@example.com", + }); + + renderShareButton(); + + expect( + document.getElementById("teamCollectionShareButton"), + ).not.toBeNull(); + expect(mockSharingPanel).not.toHaveBeenCalled(); + }); + + it("cloud Team Collection, admin: clicking Share opens SharingPanel with isAdmin true and the current user's email", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + mockUseCloudCollectionId.mockReturnValue("collection-123"); + mockUseIsTeamCollectionAdmin.mockReturnValue(true); + mockUseSharingLoginState.mockReturnValue({ + mode: "dev", + signedIn: true, + email: "admin@example.com", + }); + + renderShareButton(); + act(() => + document.getElementById("teamCollectionShareButton")!.click(), + ); + + expect(mockSharingPanel).toHaveBeenCalledWith( + { + collectionId: "collection-123", + currentUserEmail: "admin@example.com", + isAdmin: true, + }, + {}, + ); + }); + + it("cloud Team Collection, non-admin member: clicking Share opens SharingPanel with isAdmin false", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + mockUseCloudCollectionId.mockReturnValue("collection-123"); + mockUseIsTeamCollectionAdmin.mockReturnValue(false); + mockUseSharingLoginState.mockReturnValue({ + mode: "dev", + signedIn: true, + email: "member@example.com", + }); + + renderShareButton(); + act(() => + document.getElementById("teamCollectionShareButton")!.click(), + ); + + expect(mockSharingPanel).toHaveBeenCalledWith( + { + collectionId: "collection-123", + currentUserEmail: "member@example.com", + isAdmin: false, + }, + {}, + ); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/ShareButton.tsx b/src/BloomBrowserUI/teamCollection/ShareButton.tsx new file mode 100644 index 000000000000..def2d7af6674 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/ShareButton.tsx @@ -0,0 +1,74 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { useRef, useState } from "react"; +import ShareIcon from "@mui/icons-material/Share"; +import Popover from "@mui/material/Popover"; +import BloomButton from "../react_components/bloomButton"; +import { SharingPanel } from "./SharingPanel"; +import { useSharingLoginState } from "./sharingApi"; +import { + isCloudTeamCollection, + useCloudCollectionId, + useIsTeamCollectionAdmin, + useTeamCollectionCapabilities, +} from "./teamCollectionApi"; + +// The Share button shown beside the Team Collection status button in the collection tab. +// Cloud Team Collections only: it branches on capability (never on concrete backend type), so +// folder Team Collections render nothing here and see zero UI change. Clicking it opens the +// same SharingPanel used in Team Collection settings, anchored under the button; SharingPanel +// itself already renders the admin manage view or the member read-only view depending on the +// `isAdmin` prop. +export const ShareButton: React.FunctionComponent = () => { + const capabilities = useTeamCollectionCapabilities(); + const collectionId = useCloudCollectionId(); + const isAdmin = useIsTeamCollectionAdmin(); + const { email } = useSharingLoginState(); + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + + if (!isCloudTeamCollection(capabilities)) { + return null; + } + + return ( +
+ } + onClick={() => setOpen(true)} + > + Share + + setOpen(false)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + > +
+ +
+
+
+ ); +}; diff --git a/src/BloomBrowserUI/teamCollection/SharingPanel.test.tsx b/src/BloomBrowserUI/teamCollection/SharingPanel.test.tsx new file mode 100644 index 000000000000..e4e5796354a0 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/SharingPanel.test.tsx @@ -0,0 +1,348 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { renderTestRoot as render } from "../utils/testRender"; +import { SharingMembersList } from "./SharingPanel"; +import { IApprovedMember } from "./sharingApi"; + +// Tests the presentational SharingMembersList directly with injected members/callbacks +// (no network layer), per Wave-1 scope: shells against mocked endpoints. + +// React tracks the previous value of native inputs/selects internally; setting .value directly +// and dispatching a plain Event doesn't trigger React's onChange. This is the standard +// workaround (there is no @testing-library/react / user-event dependency in this project). +function setNativeValue( + element: HTMLInputElement | HTMLSelectElement, + value: string, +) { + const prototype = + element instanceof HTMLSelectElement + ? window.HTMLSelectElement.prototype + : window.HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("change", { bubbles: true })); + element.dispatchEvent(new Event("input", { bubbles: true })); +} + +const claimedAdmin: IApprovedMember = { + email: "admin@example.com", + name: "Ada Admin", + role: "admin", + claimed: true, +}; +const pendingMember: IApprovedMember = { + email: "pending@example.com", + role: "member", + claimed: false, +}; +const secondAdmin: IApprovedMember = { + email: "second-admin@example.com", + name: "Bea Boss", + role: "admin", + claimed: true, +}; + +function renderList( + members: IApprovedMember[], + overrides: { + isAdmin?: boolean; + onAdd?: (email: string, role: "admin" | "member") => void; + onRemove?: (email: string) => void; + onSetRole?: (email: string, role: "admin" | "member") => void; + onSetDisplayName?: (email: string, name: string) => void; + } = {}, +) { + const onAdd = overrides.onAdd ?? vi.fn(); + const onRemove = overrides.onRemove ?? vi.fn(); + const onSetRole = overrides.onSetRole ?? vi.fn(); + const onSetDisplayName = overrides.onSetDisplayName ?? vi.fn(); + const container = render( + , + ); + return { container, onAdd, onRemove, onSetRole, onSetDisplayName }; +} + +// The name edit commits on Enter (and blur) and cancels on Escape. +function pressKey(element: HTMLElement, key: string) { + element.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); +} + +function rowFor(container: HTMLElement, email: string): HTMLElement { + const row = Array.from( + container.querySelectorAll('[data-testid="sharing-member-row"]'), + ).find((r) => r.getAttribute("data-email") === email) as HTMLElement; + expect(row).not.toBeUndefined(); + return row; +} + +describe("SharingMembersList", () => { + it("renders one row per member, with claimed/pending status", () => { + const { container } = renderList([claimedAdmin, pendingMember]); + + const rows = container.querySelectorAll( + '[data-testid="sharing-member-row"]', + ); + expect(rows.length).toBe(2); + + // We check the data-claimed flag rather than the (localized) chip text, since + // localization in tests resolves to the l10n key rather than the English text. + const statuses = Array.from( + container.querySelectorAll('[data-testid="sharing-member-status"]'), + ).map((el) => el.getAttribute("data-claimed")); + expect(statuses).toEqual(["true", "false"]); + }); + + it("read-only view (non-admin) hides add/remove/role controls", () => { + const { container } = renderList([claimedAdmin, pendingMember], { + isAdmin: false, + }); + + expect( + container.querySelector('[data-testid="sharing-add-row"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="sharing-remove-button"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="sharing-role-select"]'), + ).toBeNull(); + }); + + it("admin can add a member with a chosen role", () => { + const { container, onAdd } = renderList([claimedAdmin]); + + const emailInput = container.querySelector( + '[data-testid="sharing-add-email-input"] input', + ) as HTMLInputElement; + expect(emailInput).not.toBeNull(); + + act(() => setNativeValue(emailInput, "newperson@example.com")); + + const roleSelect = container.querySelector( + '[data-testid="sharing-add-role-select"]', + ) as HTMLSelectElement; + act(() => setNativeValue(roleSelect, "admin")); + + const addButton = container.querySelector( + '[data-testid="sharing-add-button"]', + ) as HTMLButtonElement; + act(() => addButton.click()); + + expect(onAdd).toHaveBeenCalledWith("newperson@example.com", "admin"); + }); + + it("rejects an invalid email and does not call onAdd", () => { + const { container, onAdd } = renderList([claimedAdmin]); + + const emailInput = container.querySelector( + '[data-testid="sharing-add-email-input"] input', + ) as HTMLInputElement; + act(() => setNativeValue(emailInput, "not-an-email")); + + const addButton = container.querySelector( + '[data-testid="sharing-add-button"]', + ) as HTMLButtonElement; + act(() => addButton.click()); + + expect(onAdd).not.toHaveBeenCalled(); + }); + + it("remove requires confirmation before calling onRemove", () => { + const { container, onRemove } = renderList([ + claimedAdmin, + pendingMember, + ]); + + const rows = container.querySelectorAll( + '[data-testid="sharing-member-row"]', + ); + const pendingRow = Array.from(rows).find( + (row) => row.getAttribute("data-email") === pendingMember.email, + ) as HTMLElement; + const removeButton = pendingRow.querySelector( + '[data-testid="sharing-remove-button"]', + ) as HTMLButtonElement; + + act(() => removeButton.click()); + expect(onRemove).not.toHaveBeenCalled(); + expect( + container.querySelector( + '[data-testid="sharing-remove-confirmation"]', + ), + ).not.toBeNull(); + + const confirmButton = container.querySelector( + '[data-testid="sharing-confirm-remove-button"]', + ) as HTMLButtonElement; + act(() => confirmButton.click()); + + expect(onRemove).toHaveBeenCalledWith(pendingMember.email); + }); + + it("protects the last remaining admin from being demoted or removed", () => { + const { container, onSetRole } = renderList([ + claimedAdmin, + pendingMember, + ]); + + const rows = container.querySelectorAll( + '[data-testid="sharing-member-row"]', + ); + const adminRow = Array.from(rows).find( + (row) => row.getAttribute("data-email") === claimedAdmin.email, + ) as HTMLElement; + const roleSelect = adminRow.querySelector( + '[data-testid="sharing-role-select"]', + ) as HTMLSelectElement; + const removeButton = adminRow.querySelector( + '[data-testid="sharing-remove-button"]', + ) as HTMLButtonElement; + + expect(roleSelect.disabled).toBe(true); + expect(removeButton.disabled).toBe(true); + + // Sanity check: changing its value while disabled must not fire a change we'd act on. + act(() => setNativeValue(roleSelect, "member")); + expect(onSetRole).not.toHaveBeenCalled(); + }); + + it("allows demoting an admin when another admin remains", () => { + const { container, onSetRole } = renderList([ + claimedAdmin, + secondAdmin, + ]); + + const rows = container.querySelectorAll( + '[data-testid="sharing-member-row"]', + ); + const adminRow = Array.from(rows).find( + (row) => row.getAttribute("data-email") === claimedAdmin.email, + ) as HTMLElement; + const roleSelect = adminRow.querySelector( + '[data-testid="sharing-role-select"]', + ) as HTMLSelectElement; + + expect(roleSelect.disabled).toBe(false); + act(() => setNativeValue(roleSelect, "member")); + + expect(onSetRole).toHaveBeenCalledWith(claimedAdmin.email, "member"); + }); + + it("admin can edit a member's display name (commit with Enter)", () => { + const { container, onSetDisplayName } = renderList([ + claimedAdmin, + pendingMember, + ]); + + const row = rowFor(container, pendingMember.email); + const editButton = row.querySelector( + '[data-testid="sharing-edit-name-button"]', + ) as HTMLButtonElement; + expect(editButton).not.toBeNull(); + + act(() => editButton.click()); + const input = row.querySelector( + '[data-testid="sharing-name-input"]', + ) as HTMLInputElement; + // Sanity: pendingMember has no name yet, so the edit starts empty. + expect(input.value).toBe(""); + + act(() => setNativeValue(input, " Penny Pending ")); + act(() => pressKey(input, "Enter")); + + // Trimmed before committing. + expect(onSetDisplayName).toHaveBeenCalledWith( + pendingMember.email, + "Penny Pending", + ); + // The edit closes back to display mode. + expect( + row.querySelector('[data-testid="sharing-name-input"]'), + ).toBeNull(); + }); + + it("Escape cancels a name edit without committing", () => { + const { container, onSetDisplayName } = renderList([claimedAdmin]); + + const row = rowFor(container, claimedAdmin.email); + const editButton = row.querySelector( + '[data-testid="sharing-edit-name-button"]', + ) as HTMLButtonElement; + act(() => editButton.click()); + + const input = row.querySelector( + '[data-testid="sharing-name-input"]', + ) as HTMLInputElement; + // The edit starts from the current name. + expect(input.value).toBe(claimedAdmin.name); + + act(() => setNativeValue(input, "Changed But Abandoned")); + act(() => pressKey(input, "Escape")); + + expect(onSetDisplayName).not.toHaveBeenCalled(); + expect( + row.querySelector('[data-testid="sharing-name-input"]'), + ).toBeNull(); + }); + + it("committing an unchanged name does not call onSetDisplayName", () => { + const { container, onSetDisplayName } = renderList([claimedAdmin]); + + const row = rowFor(container, claimedAdmin.email); + act(() => + ( + row.querySelector( + '[data-testid="sharing-edit-name-button"]', + ) as HTMLButtonElement + ).click(), + ); + const input = row.querySelector( + '[data-testid="sharing-name-input"]', + ) as HTMLInputElement; + act(() => pressKey(input, "Enter")); + + expect(onSetDisplayName).not.toHaveBeenCalled(); + }); + + it("emptying the name commits an empty string (clears it)", () => { + const { container, onSetDisplayName } = renderList([claimedAdmin]); + + const row = rowFor(container, claimedAdmin.email); + act(() => + ( + row.querySelector( + '[data-testid="sharing-edit-name-button"]', + ) as HTMLButtonElement + ).click(), + ); + const input = row.querySelector( + '[data-testid="sharing-name-input"]', + ) as HTMLInputElement; + act(() => setNativeValue(input, " ")); + act(() => pressKey(input, "Enter")); + + expect(onSetDisplayName).toHaveBeenCalledWith(claimedAdmin.email, ""); + }); + + it("non-admins get no name-edit affordance", () => { + const { container } = renderList([claimedAdmin, pendingMember], { + isAdmin: false, + }); + + expect( + container.querySelector('[data-testid="sharing-edit-name-button"]'), + ).toBeNull(); + // ...but an existing name still displays. + expect(rowFor(container, claimedAdmin.email).textContent).toContain( + claimedAdmin.name, + ); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/SharingPanel.tsx b/src/BloomBrowserUI/teamCollection/SharingPanel.tsx new file mode 100644 index 000000000000..3a385bf6f163 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/SharingPanel.tsx @@ -0,0 +1,558 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { useRef, useState } from "react"; +import Chip from "@mui/material/Chip"; +import IconButton from "@mui/material/IconButton"; +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; +import { BloomAvatar } from "../react_components/bloomAvatar"; +import BloomButton from "../react_components/bloomButton"; +import { AttentionTextField } from "../react_components/AttentionTextField"; +import { Div, Span } from "../react_components/l10nComponents"; +import { useL10n } from "../react_components/l10nHooks"; +import { WarningBox } from "../react_components/boxes"; +import { isValidEmail } from "../utils/emailUtils"; +import { kBloomGray } from "../utils/colorUtils"; +import { + IApprovedMember, + SharingRole, + addApproval, + removeApproval, + setDisplayName as setMemberDisplayName, + setRole as setMemberRole, + useSharingMembers, +} from "./sharingApi"; + +// The Sharing panel for cloud Team Collections: shown in the Team Collection settings panel +// in place of the old free-text administrator-emails field. Folder Team Collections keep the +// old panel (see TeamCollectionSettingsPanel.tsx); this only applies when the collection is +// backed by the cloud (S3 + Supabase) repo. + +// Container: wires the presentational list up to the (Wave-3) SharingApi endpoints. +export const SharingPanel: React.FunctionComponent<{ + collectionId: string; + currentUserEmail: string; + isAdmin: boolean; +}> = (props) => { + const { members, reload } = useSharingMembers(props.collectionId); + return ( + { + addApproval(props.collectionId, email, role).then(reload); + }} + onRemove={(email) => { + removeApproval(props.collectionId, email).then(reload); + }} + onSetRole={(email, role) => { + setMemberRole(props.collectionId, email, role).then(reload); + }} + onSetDisplayName={(email, name) => { + setMemberDisplayName(props.collectionId, email, name).then( + reload, + ); + }} + /> + ); +}; + +// Presentational: pure function of its props, so it can be unit-tested without any network layer. +export const SharingMembersList: React.FunctionComponent<{ + members: IApprovedMember[]; + currentUserEmail: string; + isAdmin: boolean; + onAdd: (email: string, role: SharingRole) => void; + onRemove: (email: string) => void; + onSetRole: (email: string, role: SharingRole) => void; + // An empty string clears the display name (the display falls back to the email). + onSetDisplayName: (email: string, name: string) => void; +}> = (props) => { + const adminCount = props.members.filter((m) => m.role === "admin").length; + + return ( +
+ {!props.isAdmin && ( +
+ Only administrators can add, remove, or change the role of + team members. +
+ )} +
+ {props.members.map((member) => ( + props.onRemove(member.email)} + onSetRole={(role) => + props.onSetRole(member.email, role) + } + onSetDisplayName={(name) => + props.onSetDisplayName(member.email, name) + } + /> + ))} +
+ {props.isAdmin && ( + props.onAdd(email, role)} + /> + )} +
+ ); +}; + +// A plain HTML keeps the CRUD flows in SharingPanel.test.tsx simple and robust. +const RoleSelect: React.FunctionComponent<{ + value: SharingRole; + disabled?: boolean; + adminLabel: string; + memberLabel: string; + "data-testid": string; + onChange: (role: SharingRole) => void; +}> = (props) => { + return ( + . + if (props.disabled) return; + props.onChange(event.target.value as SharingRole); + }} + > + + + + ); +}; + +const MemberRow: React.FunctionComponent<{ + member: IApprovedMember; + isAdmin: boolean; + isLastAdmin: boolean; + onRemove: () => void; + onSetRole: (role: SharingRole) => void; + onSetDisplayName: (name: string) => void; +}> = (props) => { + const [confirmingRemove, setConfirmingRemove] = useState(false); + const [editingName, setEditingName] = useState(false); + const [nameDraft, setNameDraft] = useState(""); + // Set once Enter/Escape has already finished the edit, so the input's blur (which fires + // right after in a real browser) doesn't commit a second time. + const nameEditFinishedRef = useRef(false); + const claimedLabel = useL10n( + "Claimed", + "TeamCollection.Sharing.Claimed", + undefined, + undefined, + undefined, + true, + ); + const pendingLabel = useL10n( + "Pending", + "TeamCollection.Sharing.Pending", + "Shown next to an approved email address that no one has signed in with yet.", + undefined, + undefined, + true, + ); + const adminLabel = useL10n( + "Admin", + "TeamCollection.Sharing.RoleAdmin", + undefined, + undefined, + undefined, + true, + ); + const memberLabel = useL10n( + "Member", + "TeamCollection.Sharing.RoleMember", + undefined, + undefined, + undefined, + true, + ); + const lastAdminTooltip = useL10n( + "A Team Collection must always have at least one administrator.", + "TeamCollection.Sharing.LastAdminProtection", + undefined, + undefined, + undefined, + true, + ); + const editNameTooltip = useL10n( + "Edit name", + "TeamCollection.Sharing.EditName", + "Tooltip on the small pencil button an administrator uses to set the human-readable name shown for a team member.", + undefined, + undefined, + true, + ); + + const startNameEdit = () => { + setNameDraft(props.member.name ?? ""); + nameEditFinishedRef.current = false; + setEditingName(true); + }; + + const finishNameEdit = (commit: boolean) => { + if (nameEditFinishedRef.current) return; + nameEditFinishedRef.current = true; + setEditingName(false); + if (!commit) return; + const trimmed = nameDraft.trim(); + // An unchanged name is not an edit; an emptied one clears the stored name + // (the display falls back to the email). + if (trimmed !== (props.member.name ?? "")) { + props.onSetDisplayName(trimmed); + } + }; + + return ( +
div { + margin-bottom: 0 !important; + } + select, + button { + margin-top: 0; + margin-bottom: 0; + align-self: center; + } + `} + > + +
+ {/* Everything on this first line is a span/input/button on purpose: a nested + div would be re-hit by the settings page's div margin rule this row's own + css neutralizes only for direct children (see the comment below). */} + {editingName ? ( + setNameDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") finishNameEdit(true); + else if (event.key === "Escape") + finishNameEdit(false); + }} + onBlur={() => finishNameEdit(true)} + /> + ) : ( + (props.member.name || props.isAdmin) && ( + + {props.member.name && ( + {props.member.name} + )} + {props.isAdmin && ( + + + + )} + + ) + )} + + {props.member.email} + +
+ + {props.isAdmin ? ( + + ) : ( + + )} + {props.isAdmin && ( + <> + setConfirmingRemove(true)} + > + + + {confirmingRemove && ( + setConfirmingRemove(false)} + onConfirm={() => { + setConfirmingRemove(false); + props.onRemove(); + }} + /> + )} + + )} +
+ ); +}; + +const RemoveConfirmation: React.FunctionComponent<{ + email: string; + onCancel: () => void; + onConfirm: () => void; +}> = (props) => { + return ( +
+ + + Removing %0 will immediately force-unlock any books they + currently have checked out. Continue? + +
+ + Remove + + + Cancel + +
+
+
+ ); +}; + +const AddMemberRow: React.FunctionComponent<{ + onAdd: (email: string, role: SharingRole) => void; +}> = (props) => { + const [email, setEmail] = useState(""); + const [role, setRoleValue] = useState("member"); + const [submitAttempts, setSubmitAttempts] = useState(0); + const memberLabel = useL10n( + "Member", + "TeamCollection.Sharing.RoleMember", + undefined, + undefined, + undefined, + true, + ); + const adminLabel = useL10n( + "Admin", + "TeamCollection.Sharing.RoleAdmin", + undefined, + undefined, + undefined, + true, + ); + + const tryAdd = () => { + if (!isValidEmail(email.trim())) { + setSubmitAttempts((old) => old + 1); + return; + } + props.onAdd(email.trim(), role); + setEmail(""); + setSubmitAttempts(0); + }; + + return ( +
div { + margin-bottom: 0 !important; + } + select { + margin-top: 8px; + } + button { + margin-top: 2px; + } + `} + > + isValidEmail(value.trim())} + submitAttempts={submitAttempts} + data-testid="sharing-add-email-input" + /> + + + Add + +
+ ); +}; diff --git a/src/BloomBrowserUI/teamCollection/SignInDialog.test.tsx b/src/BloomBrowserUI/teamCollection/SignInDialog.test.tsx new file mode 100644 index 000000000000..87b9a094484b --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/SignInDialog.test.tsx @@ -0,0 +1,112 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { renderTestRoot as render } from "../utils/testRender"; +import { SignInDialogBody } from "./SignInDialog"; +import { ISharingLoginState } from "./sharingApi"; + +// Tests the presentational SignInDialogBody directly with injected props/callbacks (no +// network layer), same approach as CreateCloudTeamCollectionBody's own tests. Covers both +// sign-in modes (task 06's `sharing/loginState` mode field): dev-mode's email/password form, +// and "cloud" mode's browser-sign-in button (task 12; Option A decided 8 Jul 2026). + +const devMode: ISharingLoginState = { mode: "dev", signedIn: false }; +const cloudMode: ISharingLoginState = { mode: "cloud", signedIn: false }; + +function baseProps( + overrides: Partial> = {}, +) { + return { + loginState: devMode, + email: "", + password: "", + onEmailChange: vi.fn(), + onPasswordChange: vi.fn(), + onSignIn: vi.fn(), + onOpenBrowserSignIn: vi.fn(), + submitAttempts: 0, + signInError: undefined, + ...overrides, + }; +} + +describe("SignInDialogBody", () => { + it("dev mode: shows the email/password form, not the not-yet-available message", () => { + const container = render(); + + expect( + container.querySelector('[data-testid="signin-dev-form"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="signin-not-available"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="signin-email"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="signin-password"]'), + ).not.toBeNull(); + }); + + it("dev mode: clicking Sign In calls onSignIn", () => { + const onSignIn = vi.fn(); + const container = render( + , + ); + + const button = container.querySelector( + '[data-testid="signin-button"]', + ) as HTMLButtonElement; + expect(button).not.toBeNull(); + act(() => button.click()); + expect(onSignIn).toHaveBeenCalled(); + }); + + it("dev mode: shows the sign-in error when one is provided", () => { + const container = render( + , + ); + + const error = container.querySelector('[data-testid="signin-error"]'); + expect(error).not.toBeNull(); + expect(error!.textContent).toContain("Invalid credentials"); + }); + + it("cloud mode: shows the browser sign-in button, not the dev form or the not-yet-available message", () => { + const container = render( + , + ); + + expect( + container.querySelector('[data-testid="signin-cloud-browser"]'), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="signin-open-browser-button"]', + ), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="signin-dev-form"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="signin-not-available"]'), + ).toBeNull(); + }); + + it("cloud mode: clicking Sign In calls onOpenBrowserSignIn", () => { + const onOpenBrowserSignIn = vi.fn(); + const container = render( + , + ); + + const button = container.querySelector( + '[data-testid="signin-open-browser-button"]', + ) as HTMLButtonElement; + expect(button).not.toBeNull(); + act(() => button.click()); + expect(onOpenBrowserSignIn).toHaveBeenCalled(); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/SignInDialog.tsx b/src/BloomBrowserUI/teamCollection/SignInDialog.tsx new file mode 100644 index 000000000000..02d6b7734c7f --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/SignInDialog.tsx @@ -0,0 +1,157 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { post } from "../utils/bloomApi"; +import BloomButton from "../react_components/bloomButton"; +import { P } from "../react_components/l10nComponents"; +import { + BloomDialog, + DialogBottomButtons, + DialogMiddle, + DialogTitle, +} from "../react_components/BloomDialog/BloomDialog"; +import { DialogCancelButton } from "../react_components/BloomDialog/commonDialogComponents"; +import { useL10n } from "../react_components/l10nHooks"; +import { + IBloomDialogEnvironmentParams, + useSetupBloomDialog, +} from "../react_components/BloomDialog/BloomDialogPlumbing"; +import { + ISharingLoginState, + openBrowserSignIn, + useSharingLoginState, +} from "./sharingApi"; +import { DevSignInForm, useDevSignIn } from "./DevSignInForm"; + +// The dedicated sign-in dialog for cloud Team Collections, opened by `sharing/showSignIn` +// (see SharingApi.cs). Replaces the earlier placeholder, which reused the cloud +// create-collection dialog's sign-in step even in contexts that had nothing to do with +// creating a collection (e.g. signing in to see "Get my Team Collections", or to join one). +// In dev-auth mode this is a plain email/password form; in "cloud" mode (Option A, decided +// 8 Jul 2026) it is a single button that opens the real BloomLibrary browser-based sign-in +// flow (see onOpenBrowserSignIn/CONTRACTS.md's "Auth (Option A)" section) -- the dialog closes +// itself once that flow completes and useSharingLoginState() picks up the resulting +// "sharing"/"loginState" event, same as the dev-mode form below. + +// Presentational: a pure function of its props, so both modes can be unit-tested without any +// network layer (same approach as CreateCloudTeamCollectionBody). +export const SignInDialogBody: React.FunctionComponent<{ + loginState: ISharingLoginState; + email: string; + password: string; + onEmailChange: (value: string) => void; + onPasswordChange: (value: string) => void; + onSignIn: () => void; + onOpenBrowserSignIn: () => void; + submitAttempts: number; + signInError?: string; +}> = (props) => { + if (props.loginState.mode === "cloud") { + return ( +
+

+ Click "Sign In" to sign in with your Bloom account + in your web browser. Come back to this window when you're + done. +

+ + Sign In + +
+ ); + } + + if (props.loginState.mode !== "dev") { + // Defensive fallback: SharingLoginMode only ever declares "dev" | "cloud" today, so + // this is unreachable in practice, but keeps this component total over its prop type + // rather than silently rendering nothing if a third mode is ever added. + return ( +
+

+ Signing in with your Bloom account isn't available yet. + Check back in a future version of Bloom. +

+
+ ); + } + + return ( +
+ +
+ ); +}; + +// Container: wires SignInDialogBody up to sharingApi and the BloomDialog frame. Closes itself +// automatically once sign-in succeeds (useSharingLoginState picks up the "sharing"/"loginState" +// websocket event SharingApi.HandleLogin raises). +export const SignInDialog: React.FunctionComponent<{ + dialogEnvironment?: IBloomDialogEnvironmentParams; +}> = (props) => { + const loginState = useSharingLoginState(); + const devSignIn = useDevSignIn(); + const { closeDialog, propsForBloomDialog } = useSetupBloomDialog( + props.dialogEnvironment, + ); + + const dialogTitle = useL10n( + "Sign In", + "TeamCollection.Sharing.SignIn", + undefined, + undefined, + undefined, + true, + ); + + React.useEffect(() => { + if (loginState.signedIn) closeDialog(); + }, [loginState.signedIn, closeDialog]); + + return ( + + + + openBrowserSignIn()} + /> + + + post("common/closeReactDialog")} + /> + + + ); +}; diff --git a/src/BloomBrowserUI/teamCollection/TeamCollectionBookStatusPanel.test.tsx b/src/BloomBrowserUI/teamCollection/TeamCollectionBookStatusPanel.test.tsx new file mode 100644 index 000000000000..2c813ef1b042 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/TeamCollectionBookStatusPanel.test.tsx @@ -0,0 +1,260 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderTestRoot } from "../utils/testRender"; +import { TeamCollectionBookStatusPanel } from "./TeamCollectionBookStatusPanel"; +import { + IBookTeamCollectionStatus, + ITeamCollectionCapabilities, + initialBookStatus, +} from "./teamCollectionApi"; + +// Tests the Wave-2 additions to the per-book status panel's state matrix: the three new cloud-only +// states (signedOut/updatesAvailable/offlineDisabled) plus the existing folder-TC state matrix, +// to confirm the new capability-gated branches don't disturb it. bloomApi's `get`/`post` are +// mocked (this panel calls `get` for registration/userInfo when checking out); the panel's own +// websocket subscriptions are no-ops in tests via vitest.setup.ts's `_SKIP_WEBSOCKET_CREATION_`. + +const { mockUseTeamCollectionCapabilities } = vi.hoisted(() => ({ + mockUseTeamCollectionCapabilities: vi.fn(), +})); + +vi.mock("./teamCollectionApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTeamCollectionCapabilities: mockUseTeamCollectionCapabilities, + }; +}); + +const { mockPost, mockGet } = vi.hoisted(() => ({ + mockPost: vi.fn(), + mockGet: vi.fn(), +})); + +vi.mock("../utils/bloomApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + post: mockPost, + get: mockGet, + }; +}); + +const folderCapabilities: ITeamCollectionCapabilities = { + supportsVersionHistory: false, + supportsSharingUi: false, + requiresSignIn: false, +}; + +const cloudCapabilities: ITeamCollectionCapabilities = { + supportsVersionHistory: true, + supportsSharingUi: true, + requiresSignIn: true, +}; + +function renderPanel(status: IBookTeamCollectionStatus) { + return renderTestRoot(); +} + +afterEach(() => { + mockUseTeamCollectionCapabilities.mockReset(); + mockPost.mockClear(); + mockGet.mockClear(); +}); + +describe("TeamCollectionBookStatusPanel: folder Team Collection state matrix (unchanged by Wave 2)", () => { + it("unlocked: shows the checkout button", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + const container = renderPanel({ ...initialBookStatus }); + expect(container.querySelector(".checkout-button")).not.toBeNull(); + }); + + it("locked (by someone else): shows no checkout/checkin button", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + const container = renderPanel({ + ...initialBookStatus, + who: "fred@example.com", + whoFirstName: "Fred", + currentUser: "me@example.com", + currentMachine: "MyMachine", + where: "FredsMachine", + }); + expect(container.querySelector(".checkout-button")).toBeNull(); + expect(container.querySelector(".checkin-button")).toBeNull(); + }); + + it("lockedByMe: shows the checkin button and the note field, not a modal", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + const container = renderPanel({ + ...initialBookStatus, + who: "me@example.com", + currentUser: "me@example.com", + currentMachine: "MyMachine", + where: "MyMachine", + }); + expect(container.querySelector(".checkin-button")).not.toBeNull(); + expect(container.querySelector("input[type=text]")).not.toBeNull(); + // The cloud-only modal must never appear for a folder Team Collection. + expect( + document.querySelector('[data-testid="cloud-checkin-progress"]'), + ).toBeNull(); + }); + + it("needsReload (isChangedRemotely), folder: keeps the Reload Collection button (unchanged by batch item 4+5)", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + const container = renderPanel({ + ...initialBookStatus, + isChangedRemotely: true, + }); + const reloadButton = container.querySelector(".reload-button"); + expect(reloadButton).not.toBeNull(); + expect(container.querySelector(".sync-button")).toBeNull(); + act(() => (reloadButton as HTMLButtonElement).click()); + expect(mockPost).toHaveBeenCalledWith("common/reloadCollection"); + }); + + it("hasInvalidRepoData: shows the book-problem UI with the server's error message", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + const container = renderPanel({ + ...initialBookStatus, + invalidRepoDataErrorMsg: "corrupt zip", + }); + expect(container.textContent).toContain("corrupt zip"); + }); + + it("gating: cloud-shaped fields on a folder Team Collection (capabilities all false) are ignored", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + // These fields should never actually be populated for a folder TC (per + // teamCollectionApi.tsx's IBookTeamCollectionStatus comment), but even if they were, + // gating on capability - not on the fields' mere presence - keeps folder TC UI + // byte-identical. + const container = renderPanel({ + ...initialBookStatus, + requiresSignIn: true, + signedIn: false, + offlineDisabledReason: "never downloaded", + localVersionSeq: 1, + repoVersionSeq: 5, + }); + expect(container.querySelector(".checkout-button")).not.toBeNull(); + expect(container.textContent).not.toContain("TeamCollection.SignedOut"); + expect(container.textContent).not.toContain( + "TeamCollection.OfflineDisabled", + ); + expect(container.textContent).not.toContain( + "TeamCollection.UpdatesAvailableForBook", + ); + }); +}); + +describe("TeamCollectionBookStatusPanel: cloud Team Collection additions (Wave 2)", () => { + it("signedOut: book would otherwise be unlocked, but the user isn't signed in", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + const container = renderPanel({ + ...initialBookStatus, + requiresSignIn: true, + signedIn: false, + }); + expect(container.textContent).toContain("TeamCollection.SignedOut"); + expect(container.querySelector(".checkout-button")).toBeNull(); + const signInButton = container.querySelector(".sign-in-button"); + expect(signInButton).not.toBeNull(); + act(() => (signInButton as HTMLButtonElement).click()); + expect(mockPost).toHaveBeenCalledWith("sharing/showSignIn"); + }); + + it("signedIn: an otherwise-unlocked book with signedIn true shows the normal checkout button", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + const container = renderPanel({ + ...initialBookStatus, + requiresSignIn: true, + signedIn: true, + }); + expect(container.querySelector(".checkout-button")).not.toBeNull(); + }); + + it("updatesAvailable: unlocked book with a newer version in the repo", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + const container = renderPanel({ + ...initialBookStatus, + requiresSignIn: true, + signedIn: true, + localVersionSeq: 3, + repoVersionSeq: 5, + }); + expect(container.textContent).toContain( + "TeamCollection.UpdatesAvailableForBook", + ); + const syncButton = container.querySelector(".sync-button"); + expect(syncButton).not.toBeNull(); + act(() => (syncButton as HTMLButtonElement).click()); + expect(mockPost).toHaveBeenCalledWith("teamCollection/receiveUpdates"); + }); + + it("no updatesAvailable when the local version is already current", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + const container = renderPanel({ + ...initialBookStatus, + requiresSignIn: true, + signedIn: true, + localVersionSeq: 5, + repoVersionSeq: 5, + }); + expect(container.textContent).not.toContain( + "TeamCollection.UpdatesAvailableForBook", + ); + expect(container.querySelector(".checkout-button")).not.toBeNull(); + }); + + it("needsReload (isChangedRemotely), cloud: shows Sync instead of Reload Collection", () => { + // Batch item 4+5: for a cloud Team Collection this is purely a content-update state (a + // remote checkin not yet picked up here), not a settings-reload state, so it should offer + // the same in-place Sync as "updatesAvailable" rather than a full collection reload. + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + const container = renderPanel({ + ...initialBookStatus, + requiresSignIn: true, + signedIn: true, + isChangedRemotely: true, + }); + expect(container.textContent).toContain( + "TeamCollection.UpdatesAvailableForBook", + ); + expect(container.querySelector(".reload-button")).toBeNull(); + const syncButton = container.querySelector(".sync-button"); + expect(syncButton).not.toBeNull(); + act(() => (syncButton as HTMLButtonElement).click()); + expect(mockPost).toHaveBeenCalledWith("teamCollection/receiveUpdates"); + }); + + it("offlineDisabled: takes priority over other states and shows the server-supplied reason verbatim", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + const container = renderPanel({ + ...initialBookStatus, + who: "fred@example.com", + offlineDisabledReason: "This book has never been downloaded.", + }); + expect(container.textContent).toContain( + "TeamCollection.OfflineDisabled", + ); + expect(container.textContent).toContain( + "This book has never been downloaded.", + ); + }); + + it("lockedByMe, cloud: check-in progress shows in a modal instead of the inline bar", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + renderPanel({ + ...initialBookStatus, + who: "me@example.com", + currentUser: "me@example.com", + currentMachine: "MyMachine", + where: "MyMachine", + }); + // The modal only appears once check-in is actually in progress; at rest it's not shown + // per BloomDialog's `open` prop (MUI unmounts closed Dialog content by default). + expect( + document.querySelector('[data-testid="cloud-checkin-progress"]'), + ).toBeNull(); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/TeamCollectionBookStatusPanel.tsx b/src/BloomBrowserUI/teamCollection/TeamCollectionBookStatusPanel.tsx index b5cc2cba054b..dc8636627097 100644 --- a/src/BloomBrowserUI/teamCollection/TeamCollectionBookStatusPanel.tsx +++ b/src/BloomBrowserUI/teamCollection/TeamCollectionBookStatusPanel.tsx @@ -22,10 +22,20 @@ import { AvatarDialog } from "./AvatarDialog"; import { ForgetChangesDialog } from "./ForgetChangesDialog"; import { createTheme, adaptV4Theme } from "@mui/material/styles"; import WarningIcon from "@mui/icons-material/Warning"; -import { IBookTeamCollectionStatus } from "./teamCollectionApi"; +import LinearProgress from "@mui/material/LinearProgress"; +import { + IBookTeamCollectionStatus, + isCloudTeamCollection, + useTeamCollectionCapabilities, +} from "./teamCollectionApi"; import { ForceUnlockDialog } from "./ForceUnlockDialog"; import { kBloomRed } from "../utils/colorUtils"; import { showRegistrationDialog } from "../react_components/registration/registrationDialog"; +import { + BloomDialog, + DialogMiddle, + DialogTitle, +} from "../react_components/BloomDialog/BloomDialog"; interface CheckInProgressEvent extends IBloomWebSocketEvent { fraction: number; @@ -44,11 +54,23 @@ export type StatusPanelState = | "hasInvalidRepoData" // the book has a catastrophic problem: the repo version is unreadable. | "disconnected" // Can't tell what's going on, because we don't have a good connection to the repo | "lockedByMeDisconnected" // We're disconnected, but before that happened the book was checked out to me, here - | "error"; // we couldn't get the IBookTeamCollectionStatus; should never happen. + | "error" // we couldn't get the IBookTeamCollectionStatus; should never happen. + // --- Cloud Team Collections additions (see CONTRACTS.md, book-status JSON) --- + // Folder Team Collections never produce these: the fields that drive them + // (signedIn/requiresSignIn/offlineDisabledReason) are always undefined there. + | "signedOut" // cloud TC, book otherwise available, but the user isn't signed in yet + | "updatesAvailable" // cloud TC, book otherwise available, but a newer version is in the repo + | "offlineDisabled"; // cloud TC book that cannot be used at all while offline (e.g. never downloaded) export const TeamCollectionBookStatusPanel: React.FunctionComponent< IBookTeamCollectionStatus > = (props) => { + // Cloud Team Collections: gates the signedOut/updatesAvailable/offlineDisabled states and + // the modal check-in-progress dialog below. Branches on capability (never on concrete + // backend type); folder Team Collections keep every previous state/behavior unchanged. + const capabilities = useTeamCollectionCapabilities(); + const isCloud = isCloudTeamCollection(capabilities); + const [tcPanelState, setTcPanelState] = useState("initializing"); // Indicates how far along the check-in bar should be (0-100). @@ -69,7 +91,12 @@ export const TeamCollectionBookStatusPanel: React.FunctionComponent< props.who !== "" && props.who === props.currentUser && props.where === props.currentMachine; - const lockedByFullName = `${props.whoFirstName} ${props.whoSurname}`.trim(); + // Null-coalesce the name parts: cloud backends have no first/surname split (the server + // knows only the account email), and template-interpolating null renders a literal + // "null null" (seen in the first two-instance smoke test). Empty name falls back to + // the email in `who`. + const lockedByFullName = + `${props.whoFirstName ?? ""} ${props.whoSurname ?? ""}`.trim(); const lockedByDisplay = lockedByFullName !== "" ? lockedByFullName : props.who; @@ -81,6 +108,10 @@ export const TeamCollectionBookStatusPanel: React.FunctionComponent< ); } else if (props.invalidRepoDataErrorMsg) { setTcPanelState("hasInvalidRepoData"); + } else if (isCloud && props.offlineDisabledReason) { + // This book has never been downloaded (or is otherwise unusable offline); nothing + // else about lock state matters until that's resolved. + setTcPanelState("offlineDisabled"); } else if (props.hasConflictingChange) { setTcPanelState("conflictingChange"); } else if (props.isChangedRemotely) { @@ -96,10 +127,24 @@ export const TeamCollectionBookStatusPanel: React.FunctionComponent< : "locked", ); } + } else if (isCloud && props.requiresSignIn && !props.signedIn) { + // Book would otherwise be available, but checking it out requires an authenticated + // user. + setTcPanelState("signedOut"); + } else if ( + isCloud && + typeof props.repoVersionSeq === "number" && + typeof props.localVersionSeq === "number" && + props.repoVersionSeq > props.localVersionSeq + ) { + // Book is unlocked and available, but someone else's newer version hasn't been + // pulled down to this computer yet. + setTcPanelState("updatesAvailable"); } else { setTcPanelState("unlocked"); } }, [ + isCloud, props.isDisconnected, props.hasConflictingChange, props.isChangedRemotely, @@ -107,6 +152,11 @@ export const TeamCollectionBookStatusPanel: React.FunctionComponent< lockedByMe, props.currentUser, props.invalidRepoDataErrorMsg, + props.offlineDisabledReason, + props.requiresSignIn, + props.signedIn, + props.repoVersionSeq, + props.localVersionSeq, ]); React.useEffect(() => { @@ -317,6 +367,48 @@ export const TeamCollectionBookStatusPanel: React.FunctionComponent< true, ); + // --- Cloud Team Collections additions (only ever shown when isCloud is true) --- + const mainTitleSignedOut = useL10n( + "Sign in to work with this book", + "TeamCollection.SignedOut", + undefined, + undefined, + undefined, + true, + ); + const subTitleSignedOut = useL10n( + "You must sign in to your account before you can check out or edit this book.", + "TeamCollection.SignedOutDescription", + undefined, + undefined, + undefined, + true, + ); + const mainTitleUpdatesAvailableForBook = useL10n( + "A newer version of this book is available", + "TeamCollection.UpdatesAvailableForBook", + undefined, + undefined, + undefined, + true, + ); + const subTitleUpdatesAvailableForBook = useL10n( + "Sync to get the latest version before you check it out.", + "TeamCollection.UpdatesAvailableForBookDescription", + undefined, + undefined, + undefined, + true, + ); + const mainTitleOfflineDisabled = useL10n( + "Not available offline", + "TeamCollection.OfflineDisabled", + undefined, + undefined, + undefined, + true, + ); + const menuItems: (SimpleMenuItem | "-")[] = [ { text: "About my Avatar...", @@ -547,6 +639,12 @@ export const TeamCollectionBookStatusPanel: React.FunctionComponent< }} />
+ ) : isCloud ? ( + // Cloud Team Collections: the check-in ("Send") progress bar moves to + // a modal dialog (below, alongside the other dialogs) instead of this + // inline bar, since uploading to the cloud repo can take noticeably + // longer than a folder-TC check-in. +
) : (
); - case "conflictingChange": case "needsReload": + // Batch item 4+5: for a cloud Team Collection, this state is purely about + // content (isChangedRemotely -- someone else's checkin hasn't been picked up + // here yet), never about settings, so it gets the same Sync treatment as + // "updatesAvailable" instead of a full "Reload Collection". A folder TC (or any + // future cloud edge case not otherwise covered) falls through to share + // conflictingChange's Reload-Collection rendering below, unchanged. + if (isCloud) { + return ( + post("teamCollection/receiveUpdates"), + )} + menu={menu} + > + {getLockedInfoChild("")} + + ); + } + // falls through intentionally for folder Team Collections + // eslint-disable-next-line no-fallthrough + case "conflictingChange": return ( ); + // --- Cloud Team Collections additions (see the StatusPanelState comments above) --- + case "signedOut": + return ( + } + button={getBloomButton( + "Sign In", + "TeamCollection.Sharing.SignIn", + "sign-in-button", + undefined, + () => post("sharing/showSignIn"), + )} + /> + ); + case "updatesAvailable": + return ( + } + button={getBloomButton( + "Sync", + "TeamCollection.Sync", + "sync-button", + undefined, + () => post("teamCollection/receiveUpdates"), + )} + /> + ); + case "offlineDisabled": + return ( + + } + /> + ); } }; @@ -667,11 +839,62 @@ export const TeamCollectionBookStatusPanel: React.FunctionComponent< open={forceUnlockDialogOpen} close={() => setForceUnlockDialogOpen(false)} > + {/* Cloud Team Collections: check-in ("Send") progress shows as a modal instead of + the inline bar folder Team Collections use (see the "lockedByMe" case above). + No close button: like the inline bar, this just tracks an in-progress + operation the user already started. It is centered over the status panel + (the div this component renders into) rather than the whole window, so the + progress appears where the user just clicked Check in. */} + {isCloud && ( + 0 + } + onClose={() => {}} + css={css` + max-width: 400px; + `} + PaperProps={{ + style: getPaperStyleCenteredOnStatusPanel(), + }} + > + + + + + + )} ); }; +// Positions the cloud checkin-progress dialog's paper so it is centered on the TC status +// panel (the #teamCollection div this panel renders into) instead of the whole window. +// Returns undefined (= MUI's default whole-window centering) if the panel isn't in the DOM, +// which can happen in unit tests that render the panel standalone. +const getPaperStyleCenteredOnStatusPanel = (): + | React.CSSProperties + | undefined => { + const panel = document.getElementById("teamCollection"); + if (!panel) return undefined; + const rect = panel.getBoundingClientRect(); + return { + position: "fixed", + left: rect.left + rect.width / 2, + // The panel sits at the bottom of the window and the dialog is taller than it, so a + // raw center could push the dialog's lower edge off-screen; the clamp keeps the whole + // paper (~130px tall) visible while staying as close to the panel's center as it can. + top: `clamp(80px, ${rect.top + rect.height / 2}px, calc(100vh - 80px))`, + transform: "translate(-50%, -50%)", + margin: 0, + }; +}; + export const getBloomButton = ( english: string, l10nKey: string, diff --git a/src/BloomBrowserUI/teamCollection/TeamCollectionDialog.test.tsx b/src/BloomBrowserUI/teamCollection/TeamCollectionDialog.test.tsx new file mode 100644 index 000000000000..23c887f5a701 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/TeamCollectionDialog.test.tsx @@ -0,0 +1,131 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderTestRoot } from "../utils/testRender"; +import { TeamCollectionDialog } from "./TeamCollectionDialog"; +import { ITeamCollectionCapabilities } from "./teamCollectionApi"; + +// Tests the Wave-2 addition to the Team Collection status dialog: for a cloud Team Collection, +// "Check In All Books" becomes "Send All" and a new "Sync" button (batch item 4+5's rename of +// "Receive Updates", itself the successor of "Reload Collection") appears; folder Team +// Collections must keep the exact previous labels and endpoints. Per Wave-2 scope (shells against +// mocked endpoints), teamCollectionApi's capabilities +// hook is mocked; bloomApi's `post` is mocked to assert calls, and `getBoolean` is mocked so the +// dialog's tabs mount synchronously (see TeamCollectionDialog.tsx's defaultTabIndex dance) instead +// of waiting on a real (and here, unavailable) `teamCollection/logImportant` network call. +// +// MUI's Dialog renders via a portal to document.body, so assertions query document rather than a +// local render container (see JoinCloudCollectionDialog.test.tsx, which documents the same thing). +// Also as noted there: the test-only localizationManager mock (vitest.setup.ts) resolves every +// l10nKey to the key itself rather than the English fallback, so button lookups below use each +// button's stable `id` (present in TeamCollectionDialog.tsx) rather than its visible text. + +const { mockUseTeamCollectionCapabilities } = vi.hoisted(() => ({ + mockUseTeamCollectionCapabilities: vi.fn(), +})); + +vi.mock("./teamCollectionApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTeamCollectionCapabilities: mockUseTeamCollectionCapabilities, + }; +}); + +const { mockPost, mockGetBoolean } = vi.hoisted(() => ({ + mockPost: vi.fn(), + // Resolve "logImportant" as true so defaultTabIndex becomes 0 (Status tab), which is the tab + // that hosts the buttons under test; react-tabs does not render a non-selected TabPanel's + // children by default. + mockGetBoolean: vi.fn((_url: string, cb: (v: boolean) => void) => cb(true)), +})); + +vi.mock("../utils/bloomApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + post: mockPost, + getBoolean: mockGetBoolean, + }; +}); + +const folderCapabilities: ITeamCollectionCapabilities = { + supportsVersionHistory: false, + supportsSharingUi: false, + requiresSignIn: false, +}; + +const cloudCapabilities: ITeamCollectionCapabilities = { + supportsVersionHistory: true, + supportsSharingUi: true, + requiresSignIn: true, +}; + +function renderDialog(showReloadButton: boolean) { + renderTestRoot( + , + ); +} + +function getButtonById(id: string): HTMLButtonElement | null { + return document.getElementById(id) as HTMLButtonElement | null; +} + +afterEach(() => { + mockPost.mockClear(); + mockUseTeamCollectionCapabilities.mockReset(); +}); + +describe("TeamCollectionDialog", () => { + it("folder Team Collection: keeps 'Check In All Books' and posts the folder endpoint", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + renderDialog(false); + + const checkInAll = getButtonById("checkInAll"); + expect(checkInAll).not.toBeNull(); + expect(checkInAll!.textContent).toContain("TeamCollection.checkInAll"); + act(() => checkInAll!.click()); + expect(mockPost).toHaveBeenCalledWith("teamCollection/checkInAllBooks"); + }); + + it("folder Team Collection: never shows 'Sync'", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + renderDialog(false); + expect(getButtonById("sync")).toBeNull(); + }); + + it("cloud Team Collection: renames the button to 'Send All' and posts the cloud endpoint", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + renderDialog(false); + + const sendAll = getButtonById("checkInAll"); + expect(sendAll).not.toBeNull(); + expect(sendAll!.textContent).toContain("TeamCollection.SendAll"); + act(() => sendAll!.click()); + expect(mockPost).toHaveBeenCalledWith("teamCollection/sendAllBooks"); + }); + + it("cloud Team Collection without a pending reload: shows 'Sync' and posts its endpoint", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + renderDialog(false); + + const sync = getButtonById("sync"); + expect(sync).not.toBeNull(); + act(() => sync!.click()); + expect(mockPost).toHaveBeenCalledWith("teamCollection/receiveUpdates"); + }); + + it("cloud Team Collection with a pending settings reload: shows only 'Reload Collection', not 'Sync'", () => { + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + renderDialog(true); + + expect(getButtonById("reload")).not.toBeNull(); + expect(getButtonById("sync")).toBeNull(); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/TeamCollectionDialog.tsx b/src/BloomBrowserUI/teamCollection/TeamCollectionDialog.tsx index c771fdf1e5ad..5febd0188a36 100644 --- a/src/BloomBrowserUI/teamCollection/TeamCollectionDialog.tsx +++ b/src/BloomBrowserUI/teamCollection/TeamCollectionDialog.tsx @@ -27,6 +27,10 @@ import "react-tabs/style/react-tabs.less"; import { useEffect, useState } from "react"; import { BloomTabs } from "../react_components/BloomTabs"; import { useEventLaunchedBloomDialog } from "../react_components/BloomDialog/BloomDialogPlumbing"; +import { + isCloudTeamCollection, + useTeamCollectionCapabilities, +} from "./teamCollectionApi"; export const TeamCollectionDialogLauncher: React.FunctionComponent = () => { const { openingEvent, closeDialog, propsForBloomDialog } = @@ -41,7 +45,7 @@ export const TeamCollectionDialogLauncher: React.FunctionComponent = () => { ) : null; }; -const TeamCollectionDialog: React.FunctionComponent<{ +export const TeamCollectionDialog: React.FunctionComponent<{ showReloadButton: boolean; closeDialog: () => void; propsForBloomDialog: IBloomDialogProps; @@ -51,6 +55,12 @@ const TeamCollectionDialog: React.FunctionComponent<{ "TeamCollection.TeamCollection", ); + // Cloud Team Collections: gates the "Receive Updates"/"Send All" cloud terminology below. + // Branches on capability (never on concrete backend type), and defaults to false (today's + // folder-TC behavior) until the cloud-team-collections experimental feature is on. + const capabilities = useTeamCollectionCapabilities(); + const isCloud = isCloudTeamCollection(capabilities); + const events = useApiData( "teamCollection/getLog", [], @@ -153,7 +163,11 @@ const TeamCollectionDialog: React.FunctionComponent<{ /> - Check In All Books + {isCloud + ? "Send All" + : "Check In All Books"}
@@ -197,6 +215,28 @@ const TeamCollectionDialog: React.FunctionComponent<{ Reload Collection )} + {/* Cloud Team Collections: most remote changes now apply + automatically (batch item 4+5), so "Sync" (successor of "Receive + Updates", itself the successor of "Reload Collection") is really a + manual force-check: poll immediately, then pull anything still + outstanding. "Reload Collection" above is kept, but only ever shown + for the unrelated case of an applied collection-settings change + requiring a full app reload (folder or cloud), which is why the two + are mutually exclusive here. */} + {!props.showReloadButton && isCloud && ( + + post("teamCollection/receiveUpdates") + } + > + Sync + + )} ({ + mockUseApiStringState: vi.fn(), + mockGet: vi.fn(), + mockUseIsCloudFeatureEnabled: vi.fn(), + mockUseTeamCollectionCapabilities: vi.fn(), + mockUseCloudCollectionId: vi.fn(), + mockUseIsTeamCollectionAdmin: vi.fn(), + mockUseSharingLoginState: vi.fn(), +})); + +vi.mock("../utils/bloomApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useApiStringState: mockUseApiStringState, + get: mockGet, + }; +}); + +vi.mock("./sharingApi", () => ({ + useIsCloudTeamCollectionsExperimentalFeatureEnabled: + mockUseIsCloudFeatureEnabled, + useSharingLoginState: mockUseSharingLoginState, +})); + +vi.mock("./teamCollectionApi", () => ({ + isCloudTeamCollection: (capabilities: { + supportsVersionHistory: boolean; + supportsSharingUi: boolean; + requiresSignIn: boolean; + }) => + capabilities.supportsVersionHistory || + capabilities.supportsSharingUi || + capabilities.requiresSignIn, + useTeamCollectionCapabilities: mockUseTeamCollectionCapabilities, + useCloudCollectionId: mockUseCloudCollectionId, + useIsTeamCollectionAdmin: mockUseIsTeamCollectionAdmin, +})); + +vi.mock("./SharingPanel", () => ({ + SharingPanel: (props: { + collectionId: string; + currentUserEmail: string; + isAdmin: boolean; + }) => ( +
+ ), +})); + +function render(): HTMLDivElement { + return renderTestRoot(); +} + +const folderCapabilities = { + supportsVersionHistory: false, + supportsSharingUi: false, + requiresSignIn: false, +}; +const cloudCapabilities = { + supportsVersionHistory: true, + supportsSharingUi: true, + requiresSignIn: true, +}; + +afterEach(() => { + vi.clearAllMocks(); + mockUseIsCloudFeatureEnabled.mockReturnValue(false); + mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); + mockUseCloudCollectionId.mockReturnValue(""); + mockUseIsTeamCollectionAdmin.mockReturnValue(false); + mockUseSharingLoginState.mockReturnValue({ + mode: "dev", + signedIn: false, + }); + mockUseApiStringState.mockReturnValue([""]); + mockGet.mockImplementation(() => undefined); +}); + +// afterEach above doesn't run before the first test, so set the same defaults up front too. +mockUseIsCloudFeatureEnabled.mockReturnValue(false); +mockUseTeamCollectionCapabilities.mockReturnValue(folderCapabilities); +mockUseCloudCollectionId.mockReturnValue(""); +mockUseIsTeamCollectionAdmin.mockReturnValue(false); +mockUseSharingLoginState.mockReturnValue({ mode: "dev", signedIn: false }); +mockUseApiStringState.mockReturnValue([""]); +mockGet.mockImplementation(() => undefined); + +describe("TeamCollectionSettingsPanel", () => { + it("folder Team Collection: shows the old administrator-emails field, not SharingPanel", () => { + mockUseApiStringState.mockReturnValue([ + "\\\\server\\share\\MyCollection", + ]); + + const container = render(); + + expect( + container.querySelector('[id="adminstratorEmails"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="sharing-panel-stub"]'), + ).toBeNull(); + }); + + it("cloud Team Collection: shows SharingPanel wired to the real capability/login hooks, not the old field", () => { + mockUseApiStringState.mockReturnValue([ + "cloud://sil.bloom/collection/abc-123", + ]); + mockUseTeamCollectionCapabilities.mockReturnValue(cloudCapabilities); + mockUseCloudCollectionId.mockReturnValue("abc-123"); + mockUseIsTeamCollectionAdmin.mockReturnValue(true); + mockUseSharingLoginState.mockReturnValue({ + mode: "dev", + signedIn: true, + email: "me@example.com", + }); + + const container = render(); + + const stub = container.querySelector( + '[data-testid="sharing-panel-stub"]', + ); + expect(stub).not.toBeNull(); + expect(stub!.getAttribute("data-collection-id")).toBe("abc-123"); + expect(stub!.getAttribute("data-current-user-email")).toBe( + "me@example.com", + ); + expect(stub!.getAttribute("data-is-admin")).toBe("true"); + expect(container.querySelector('[id="adminstratorEmails"]')).toBeNull(); + }); + + it("not yet a Team Collection: shows neither the old field nor SharingPanel", () => { + const container = render(); + + expect( + container.querySelector('[data-testid="sharing-panel-stub"]'), + ).toBeNull(); + expect(container.querySelector('[id="adminstratorEmails"]')).toBeNull(); + expect( + container.querySelector('[data-testid="share-on-cloud-button"]'), + ).not.toBeNull(); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/TeamCollectionSettingsPanel.tsx b/src/BloomBrowserUI/teamCollection/TeamCollectionSettingsPanel.tsx index b750ec895093..d5392cfcd8e9 100644 --- a/src/BloomBrowserUI/teamCollection/TeamCollectionSettingsPanel.tsx +++ b/src/BloomBrowserUI/teamCollection/TeamCollectionSettingsPanel.tsx @@ -22,6 +22,17 @@ import { useEffect } from "react"; import { Label } from "../react_components/l10nComponents"; import { TextField } from "@mui/material"; import { DialogHelpButton } from "../react_components/BloomDialog/commonDialogComponents"; +import { + useIsCloudTeamCollectionsExperimentalFeatureEnabled, + useSharingLoginState, +} from "./sharingApi"; +import { + isCloudTeamCollection, + useCloudCollectionId, + useIsTeamCollectionAdmin, + useTeamCollectionCapabilities, +} from "./teamCollectionApi"; +import { SharingPanel } from "./SharingPanel"; // The contents of the Team Collection panel of the Settings dialog. @@ -40,6 +51,18 @@ export const TeamCollectionSettingsPanel: React.FunctionComponent = () => { }); }, []); + const cloudSharingExperimentalFeatureEnabled = + useIsCloudTeamCollectionsExperimentalFeatureEnabled(); + + // Cloud vs folder Team Collection: branch on capability, never on a concrete backend type + // (per the gating rule in Design/CloudTeamCollections). All these hooks are already gated + // internally on the experimental feature being on, so folder TCs (the overwhelming + // majority) make zero extra requests here, same as everywhere else this pattern is used. + const isCloud = isCloudTeamCollection(useTeamCollectionCapabilities()); + const cloudCollectionId = useCloudCollectionId(); + const isCloudAdmin = useIsTeamCollectionAdmin(); + const loginState = useSharingLoginState(); + const intro: JSX.Element = (
@@ -66,61 +89,78 @@ export const TeamCollectionSettingsPanel: React.FunctionComponent = () => { > This is a Team Collection

-

- Cloud Storage Folder Location: -

- { - e.preventDefault(); - postJson("fileIO/showInFolder", { - folderPath: repoFolderPath, - }); - }} - > - {repoFolderPath} - - - { - const newAdminString: string = event.target.value; - setAdminstratorEmail(newAdminString); - postString("settings/administrators", newAdminString); - }} - required={false} - css={css` - width: 100%; - margin-top: 5px; - `} - > - -

- Need help adding someone to your Team Collection? -

-
- -
+ ) : ( + +

+ Cloud Storage Folder Location: +

+ { + e.preventDefault(); + postJson("fileIO/showInFolder", { + folderPath: repoFolderPath, + }); + }} + > + {repoFolderPath} + + + { + const newAdminString: string = event.target.value; + setAdminstratorEmail(newAdminString); + postString( + "settings/administrators", + newAdminString, + ); + }} + required={false} + css={css` + width: 100%; + margin-top: 5px; + `} + > + +

+ Need help adding someone to your Team Collection? +

+
+ +
+
+ )}
); @@ -141,6 +181,32 @@ export const TeamCollectionSettingsPanel: React.FunctionComponent = () => { Create a Team Collection
+
+ + post( + "teamCollection/showCreateCloudTeamCollectionDialog", + ) + } + temporarilyDisableI18nWarning={true} + // Explains the gating when disabled; no tooltip is needed once enabled. + // Localized under TeamCollection.Sharing.ShareOnCloudServer.ToolTipWhenDisabled. + l10nTipEnglishDisabled="Turn on the 'cloud-team-collections' experimental feature (Settings > Advanced) to use this." + data-testid="share-on-cloud-button" + > + Share this collection on the Bloom sharing server + +

BloomLibraryAuthentication.LogIn). That page forwards the resulting tokens back to Bloom +// directly (CONTRACTS.md's "Auth (Option A)" token-receipt endpoint); the caller does not await +// a result here -- watch useSharingLoginState() (or the "sharing"/"loginState" event) instead, +// exactly as the "dev" mode signIn() callers already do. +export function openBrowserSignIn() { + return post("sharing/openBrowserSignIn"); +} + +// Fetches the approved-accounts list for a cloud Team Collection, refetching whenever the server +// fires "sharing"/"membersChanged" (or the caller invokes reload). No fetch until collectionId is +// known. +export function useSharingMembers(collectionId: string): { + members: IApprovedMember[]; + reload: () => void; +} { + const { data, reload } = useWatchApiDataWithReload( + collectionId + ? `sharing/members?collectionId=${encodeURIComponent(collectionId)}` + : undefined, + [], + "sharing", + "membersChanged", + ); + return { members: data, reload }; +} + +export function addApproval( + collectionId: string, + email: string, + role: SharingRole, +) { + return postJson("sharing/addApproval", { collectionId, email, role }); +} + +// Removing an approval force-unlocks any books that user currently has checked out. +export function removeApproval(collectionId: string, email: string) { + return postJson("sharing/removeApproval", { collectionId, email }); +} + +export function setRole( + collectionId: string, + email: string, + role: SharingRole, +) { + return postJson("sharing/setRole", { collectionId, email, role }); +} + +// Sets a member's human-readable display name (shown with the email as fallback wherever the +// member appears -- member list, checkout status, history). An empty string clears it. +// Server-side, an admin may set anyone's name and a claimed member their own. +export function setDisplayName( + collectionId: string, + email: string, + displayName: string, +) { + return postJson("sharing/setDisplayName", { + collectionId, + email, + displayName, + }); +} + +// The "Get my Team Collections" list on the collection chooser. +export function useMyCloudCollections(shouldQuery: boolean): { + collections: ICloudCollectionSummary[]; + loading: boolean; +} { + const [collections, setCollections] = useState( + [], + ); + const [loading, setLoading] = useState(false); + React.useEffect(() => { + if (!shouldQuery) return; + setLoading(true); + get("collections/mine", (result) => { + setCollections((result.data as ICloudCollectionSummary[]) ?? []); + setLoading(false); + }); + }, [shouldQuery]); + return { collections, loading }; +} + +// Result of a successful collections/pullDown: the local .bloomCollection file path the +// collection was pulled down to, so the caller can open it directly (see +// JoinCloudCollectionDialog's handleJoinClick) instead of leaving the user to find the new +// collection in the chooser themselves. A settings-file path, not a folder, because +// workspace/openCollection expects what the chooser's cards pass it. +export interface IPullDownResult { + collectionPath: string; +} + +export function pullDownCollection(collectionId: string) { + return postJson("collections/pullDown", { collectionId }); +} + +// Token used in Settings > Advanced Settings > Experimental Features to gate the cloud-sharing +// UI; must match ExperimentalFeatures.kCloudTeamCollections in the C# code. +const kCloudTeamCollectionsExperimentalFeatureToken = "cloud-team-collections"; + +// The enabled-experimental-features list is fetched at most ONCE per page load and shared by +// every caller of the hook below. This matters because the hook is used by per-book components +// (BookButton renders once per book, and remounts on every switch to the Collection tab) — an +// uncached per-mount request meant hundreds of identical HTTP calls. Changing experimental +// features requires reopening dialogs/pages anyway, so page-load granularity loses nothing. +function getEnabledExperimentalFeaturesOnce(): Promise { + return getApiDataOnce( + "app/enabledExperimentalFeatures", + (data) => (data as string) ?? "", + ); +} + +// Test-only: forget the cached experimental-features fetch so each test's endpoint mocks +// are observed. Call from beforeEach; production code must never call this. +export function resetSharingApiCachesForTests() { + resetApiDataOnceCacheForTests(); +} + +// Whether the user has turned on the "cloud-team-collections" experimental feature. Backed by +// the same `app/enabledExperimentalFeatures` endpoint the Talking Book toolbox already uses +// (a comma-separated list of enabled tokens), so no new C# is required for this Wave-1 gate. +export function useIsCloudTeamCollectionsExperimentalFeatureEnabled(): boolean { + const [enabled, setEnabled] = useState(false); + React.useEffect(() => { + let cancelled = false; + getEnabledExperimentalFeaturesOnce().then((tokens) => { + if (!cancelled) + setEnabled( + tokens.includes( + kCloudTeamCollectionsExperimentalFeatureToken, + ), + ); + }); + return () => { + cancelled = true; + }; + }, []); + return enabled; +} + +// Kicks off the (Wave-3) cloud Team Collection creation flow: uploads the current local +// collection as the initial version of a new cloud-backed Team Collection. +// Uses postJson (rather than post, which is fire-and-forget and does not return a promise) +// so callers can await/`.then()` completion to drive the initial-Send progress UI. +export function createCloudTeamCollection() { + return postJson("teamCollection/createCloudTeamCollection", {}); +} diff --git a/src/BloomBrowserUI/teamCollection/teamCollectionApi.tsx b/src/BloomBrowserUI/teamCollection/teamCollectionApi.tsx index b8e522eaff7b..ca1a2f152b3e 100644 --- a/src/BloomBrowserUI/teamCollection/teamCollectionApi.tsx +++ b/src/BloomBrowserUI/teamCollection/teamCollectionApi.tsx @@ -1,7 +1,13 @@ import * as React from "react"; import { useState } from "react"; -import { get, getBoolean } from "../utils/bloomApi"; +import { + get, + getApiDataOnce, + getBoolean, + resetApiDataOnceCacheForTests, +} from "../utils/bloomApi"; import { useSubscribeToWebSocketForEvent } from "../utils/WebSocketManager"; +import { useIsCloudTeamCollectionsExperimentalFeatureEnabled } from "./sharingApi"; // The TS end of various interactions with the TeamCollectionApi class in C# @@ -25,6 +31,14 @@ export interface IBookTeamCollectionStatus { error: string; // This one is not current sent from the C# side. checkInMessage: string; isUserAdmin: boolean; + // --- Cloud Team Collections additions (CONTRACTS.md "Book-status JSON", additive) --- + // Folder Team Collections never populate these fields, so `undefined` must always be + // treated as "behave exactly as before" everywhere they're read. + localVersionSeq?: number; // sequence number of the version currently on disk + repoVersionSeq?: number; // sequence number of the latest version in the repo (may be newer) + signedIn?: boolean; // whether the current user is signed in to the cloud account + requiresSignIn?: boolean; // true for cloud-backed collections, which need an authenticated user to check out/in + offlineDisabledReason?: string; // non-empty => this book can't be used at all while offline (e.g. it has never been downloaded to this computer) } export const initialBookStatus: IBookTeamCollectionStatus = { @@ -97,3 +111,131 @@ export function useIsTeamCollection() { }, []); return isTeamCollection; } + +// --- Cloud Team Collections (see task 08's shells, task 06's real endpoints, this task's wiring) --- +// The endpoints referenced below (teamCollection/capabilities, teamCollection/tcStatusMetadata, +// teamCollection/cloudCollectionId, teamCollection/isUserAdmin) are real, implemented in +// TeamCollectionApi.cs. Every hook here only calls its endpoint when the cloud-team-collections +// experimental feature is on, so folder Team Collections (the overwhelming majority of current +// usage) never make the extra request and never see any UI difference. + +// Backend capability flags (CONTRACTS.md, additive to the book-status JSON): tell the UI what the +// current Team Collection's backend can do, so components branch on capability rather than on +// concrete backend type (folder vs cloud). All default to false, which is exactly today's +// folder-TC behavior. +export interface ITeamCollectionCapabilities { + supportsVersionHistory: boolean; + supportsSharingUi: boolean; + requiresSignIn: boolean; +} + +export const initialTeamCollectionCapabilities: ITeamCollectionCapabilities = { + supportsVersionHistory: false, + supportsSharingUi: false, + requiresSignIn: false, +}; + +// Fetched at most ONCE per page load and shared by every caller: this hook runs in per-book +// components (BookButton — once per book, remounting on each Collection-tab switch), so an +// uncached per-mount request would mean hundreds of identical HTTP calls in large collections. +// Capabilities only change when the collection's backend changes, which restarts Bloom anyway. +function getTeamCollectionCapabilitiesOnce(): Promise { + return getApiDataOnce( + "teamCollection/capabilities", + (data) => + (data as ITeamCollectionCapabilities) ?? + initialTeamCollectionCapabilities, + ); +} + +// Test-only: forget the cached capabilities fetch so each test's endpoint mocks are +// observed. Call from beforeEach; production code must never call this. +export function resetTeamCollectionApiCachesForTests() { + resetApiDataOnceCacheForTests(); +} + +export function useTeamCollectionCapabilities(): ITeamCollectionCapabilities { + const cloudFeatureEnabled = + useIsCloudTeamCollectionsExperimentalFeatureEnabled(); + const [capabilities, setCapabilities] = useState( + initialTeamCollectionCapabilities, + ); + React.useEffect(() => { + if (!cloudFeatureEnabled) return; + let cancelled = false; + getTeamCollectionCapabilitiesOnce().then((result) => { + if (!cancelled) setCapabilities(result); + }); + return () => { + cancelled = true; + }; + }, [cloudFeatureEnabled]); + return capabilities; +} + +// True if any capability flag indicates a cloud (S3 + Supabase) backend rather than a folder one. +// Prefer this over checking a single flag, so callers that just want "is this a cloud TC" stay +// correct even if a future capability is added or the initial (pre-fetch) defaults change. +export function isCloudTeamCollection( + capabilities: ITeamCollectionCapabilities, +): boolean { + return ( + capabilities.supportsVersionHistory || + capabilities.supportsSharingUi || + capabilities.requiresSignIn + ); +} + +// Live metadata behind the status button/chip (e.g. "Updates Available (3 books)"). Kept separate +// from the plain `teamCollection/tcStatus` enum endpoint (owned by other tasks) so this task can +// add to it without touching that contract. +export interface ITeamCollectionStatusMetadata { + updatesAvailableCount?: number; +} + +export function useTeamCollectionStatusMetadata(): ITeamCollectionStatusMetadata { + const cloudFeatureEnabled = + useIsCloudTeamCollectionsExperimentalFeatureEnabled(); + const [metadata, setMetadata] = useState({}); + const [reload, setReload] = useState(0); + useSubscribeToWebSocketForEvent( + "teamCollection", + "statusMetadataChanged", + () => setReload((old) => old + 1), + ); + React.useEffect(() => { + if (!cloudFeatureEnabled) return; + get("teamCollection/tcStatusMetadata", (result) => { + setMetadata((result.data as ITeamCollectionStatusMetadata) ?? {}); + }); + }, [cloudFeatureEnabled, reload]); + return metadata; +} + +// The cloud collection id (server `collections.id`) of the currently open collection, needed to +// call SharingApi endpoints. Empty string for a folder Team Collection (or no collection open). +export function useCloudCollectionId(): string { + const cloudFeatureEnabled = + useIsCloudTeamCollectionsExperimentalFeatureEnabled(); + const [collectionId, setCollectionId] = useState(""); + React.useEffect(() => { + if (!cloudFeatureEnabled) return; + get("teamCollection/cloudCollectionId", (result) => { + setCollectionId((result.data as string) ?? ""); + }); + }, [cloudFeatureEnabled]); + return collectionId; +} + +// Whether the current user is an administrator of the currently open Team Collection. Used by the +// collection-tab Share button to decide whether SharingPanel opens in manage or read-only mode. +export function useIsTeamCollectionAdmin(): boolean { + const cloudFeatureEnabled = + useIsCloudTeamCollectionsExperimentalFeatureEnabled(); + const [isAdmin, setIsAdmin] = useState(false); + React.useEffect(() => { + if (!cloudFeatureEnabled) return; + getBoolean("teamCollection/isUserAdmin", setIsAdmin); + }, [cloudFeatureEnabled]); + return isAdmin; +} diff --git a/src/BloomBrowserUI/utils/bloomApi.ts b/src/BloomBrowserUI/utils/bloomApi.ts index 7d3fa1396e55..fd52899a4af2 100644 --- a/src/BloomBrowserUI/utils/bloomApi.ts +++ b/src/BloomBrowserUI/utils/bloomApi.ts @@ -281,6 +281,61 @@ export function useApiData(urlSuffix: string, defaultValue: T): T { return useApiDataInternal(urlSuffix, defaultValue); } +// Like useWatchApiData, but also hands back a `reload()` the caller can invoke to force a +// refetch (on top of the automatic refetch when the clientContext/eventId websocket event fires), +// and skips fetching entirely while `urlSuffix` is undefined -- for the common case of a query +// that can't run until some parameter is known (e.g. a collectionId). The value stays at +// defaultValue until a real url is supplied. Extracted from the several cloud-Team-Collection +// hooks that each hand-rolled this generation-counter + websocket + reload idiom. +export function useWatchApiDataWithReload( + urlSuffix: string | undefined, + defaultValue: T, + clientContext: string, + eventId: string, +): { data: T; reload: () => void } { + const [generation, setGeneration] = useState(0); + const [data, setData] = useState(defaultValue); + useSubscribeToWebSocketForEvent(clientContext, eventId, () => + setGeneration((old) => old + 1), + ); + useEffect(() => { + if (!urlSuffix) return; + get(urlSuffix, (result) => setData((result.data as T) ?? defaultValue)); + // defaultValue is intentionally out of the deps (see useApiDataInternal's note on why a + // fresh default object each render would loop); urlSuffix already encodes the parameters. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlSuffix, generation]); + return { data, reload: () => setGeneration((old) => old + 1) }; +} + +// Fetches `urlSuffix` at most ONCE per page load, caching the resolved promise so every caller +// shares a single HTTP request. `mapResult` turns the raw response data into T (and supplies the +// fallback for missing data). For per-mount/per-book components where an uncached fetch would mean +// hundreds of identical calls and the data is page-load-stable (changing it restarts Bloom or +// reopens the page). Extracted from the duplicate cached-promise idiom in sharingApi / +// teamCollectionApi. +const apiDataOnceCache = new Map>(); + +export function getApiDataOnce( + urlSuffix: string, + mapResult: (data: unknown) => T, +): Promise { + let cached = apiDataOnceCache.get(urlSuffix) as Promise | undefined; + if (!cached) { + cached = new Promise((resolve) => + get(urlSuffix, (result) => resolve(mapResult(result.data))), + ); + apiDataOnceCache.set(urlSuffix, cached); + } + return cached; +} + +// Test-only: forget all cached getApiDataOnce fetches so each test's endpoint mocks are observed. +// Call from beforeEach; production code must never call this. +export function resetApiDataOnceCacheForTests() { + apiDataOnceCache.clear(); +} + // Shared code for useApiData and useWatchApiData. If you are tempted to use it directly, // you should probably be using useWatchApiData. // The generation argument is a value that can be incremented to force redoing the main diff --git a/src/BloomBrowserUI/utils/testRender.ts b/src/BloomBrowserUI/utils/testRender.ts new file mode 100644 index 000000000000..12331071fcea --- /dev/null +++ b/src/BloomBrowserUI/utils/testRender.ts @@ -0,0 +1,50 @@ +// Test-only helper for the render-into-a-container idiom our vitest component tests share. +// (Not part of reactRender.ts because this registers a vitest afterEach hook on import, so +// it must only ever be imported from test files.) +// +// renderTestRoot() mounts an element into a fresh

appended to document.body, flushing +// React 18's asynchronous initial render with act() so the DOM is ready when the test +// asserts, and returns that container div for querying. Every container it creates is +// automatically unmounted and removed after each test — simply importing this module +// registers the cleanup — so test files need no unmount/afterEach boilerplate of their own. + +import { act } from "react"; +import type { ReactNode } from "react"; +import { afterEach } from "vitest"; +import { renderRoot, unmountRoot } from "./reactRender"; + +const renderedContainers: HTMLDivElement[] = []; + +// Renders the element into a new container div on document.body and returns the container. +export function renderTestRoot(element: ReactNode): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainers.push(container); + // React 18's createRoot renders asynchronously; act() flushes the initial render + // (and its effects) synchronously so the result is in the DOM when we return. + act(() => { + renderRoot(element, container); + }); + return container; +} + +// Unmounts and removes one renderTestRoot() container before the test ends. Rarely needed — +// the afterEach below already cleans everything up — but useful when a single test renders +// the same component twice with different props and wants the first copy gone. +export function unmountTestRoot(container: HTMLDivElement): void { + unmountRoot(container); + container.remove(); + const index = renderedContainers.indexOf(container); + if (index >= 0) renderedContainers.splice(index, 1); +} + +afterEach(() => { + for (const container of renderedContainers) { + unmountRoot(container); + container.remove(); + } + renderedContainers.length = 0; + // Dialogs/popovers (MUI) portal their content directly onto document.body, outside the + // mounted roots, so it must be cleaned up separately between tests. + document.body.innerHTML = ""; +}); diff --git a/src/BloomBrowserUI/vitest.setup.ts b/src/BloomBrowserUI/vitest.setup.ts index 93f62fbfe9de..b80912bdcfff 100644 --- a/src/BloomBrowserUI/vitest.setup.ts +++ b/src/BloomBrowserUI/vitest.setup.ts @@ -1,5 +1,5 @@ import "@testing-library/jest-dom/vitest"; -import { vi } from "vitest"; +import { beforeEach, vi } from "vitest"; // Some legacy specs use the Jasmine/Jest-style fail() helper, which vitest does // not provide. Define it globally (throwing an Error) so those assertions both @@ -130,6 +130,20 @@ vi.mock("./lib/localizationManager/localizationManager", () => { fail: () => promise, }); }, + // Mirrors the real localizationManager.processSimpleMarkdown (lib/localizationManager/localizationManager.ts): + // components built on l10nComponents.tsx's LocalizableElement (Div, Span, BloomButton, etc.) + // call this unconditionally while rendering, so it must exist here even in tests that never + // put markdown in their strings. + processSimpleMarkdown: (text: string): string => { + if (text.indexOf("*") < 0 && text.indexOf("[") < 0) return text; + const reStrong = /(^|[^*])\*\*([^*]+)\*\*([^*]|$)/g; + let newstr = text.replace(reStrong, "$1$2$3"); + const reEm = /(^|[^*])\*([^*]+)\*([^*]|$)/g; + newstr = newstr.replace(reEm, "$1$2$3"); + const reA = /\[([^\]]*)\]\(([^)]*)\)/g; + newstr = newstr.replace(reA, '$1'); + return newstr; + }, isBypassEnabled: () => true, // Bypass localization in tests to avoid server calls getVernacularLang: () => "en", // Default vernacular language for tests getLanguage2Code: () => "tpi", // Matches GetSettings currentCollectionLanguage2 @@ -508,3 +522,27 @@ globalThis.jQuery = jQuery; "Tradition", ], }); + +// Cloud Team Collections: the sharing/capability hooks cache their endpoint fetches at +// module scope (once per page load in production — see sharingApi.ts / teamCollectionApi.tsx). +// Tests mock those endpoints per-test, so the caches must be forgotten between tests or the +// first test's mock would poison every later one in the same file. +beforeEach(async () => { + // try/catch per module: a test that vi.mock()s one of these modules without the reset + // export gets a throwing proxy from vitest on ANY missing-export access; such a test has + // replaced the real module (and its cache) entirely, so there is nothing to reset anyway. + try { + const sharingApi = await import("./teamCollection/sharingApi"); + sharingApi.resetSharingApiCachesForTests(); + } catch { + // module fully mocked without the reset export - nothing to do + } + try { + const teamCollectionApi = await import( + "./teamCollection/teamCollectionApi" + ); + teamCollectionApi.resetTeamCollectionApiCachesForTests(); + } catch { + // module fully mocked without the reset export - nothing to do + } +}); diff --git a/src/BloomExe/ApplicationContainer.cs b/src/BloomExe/ApplicationContainer.cs index a20bb7cfae8a..969a1cc55d2f 100644 --- a/src/BloomExe/ApplicationContainer.cs +++ b/src/BloomExe/ApplicationContainer.cs @@ -76,6 +76,7 @@ public ApplicationContainer() typeof(NewCollectionWizardApi), typeof(CollectionChooserApi), typeof(I18NApi), + typeof(SharingApi), }.Contains(t) ); @@ -95,6 +96,11 @@ public ApplicationContainer() _container.Resolve().RegisterWithApiHandler(server.ApiHandler); _container.Resolve().RegisterWithApiHandler(server.ApiHandler); _container.Resolve().RegisterWithApiHandler(server.ApiHandler); + // Cloud Team Collections (task 06): SharingApi is application-level, not project-level + // (unlike TeamCollectionApi), because collections/mine, collections/pullDown, and + // sharing/loginState/login/logout must all work from the collection chooser BEFORE any + // project is loaded -- see SharingApi's own file comment. + _container.Resolve().RegisterWithApiHandler(server.ApiHandler); server.ApiHandler.RecordApplicationLevelHandlers(); } diff --git a/src/BloomExe/BloomExe.csproj b/src/BloomExe/BloomExe.csproj index ba533cb4d40a..677bd2e4f2d7 100644 --- a/src/BloomExe/BloomExe.csproj +++ b/src/BloomExe/BloomExe.csproj @@ -233,8 +233,8 @@ - - + + @@ -279,6 +279,7 @@ + all diff --git a/src/BloomExe/Collection/CollectionSettingsDialog.cs b/src/BloomExe/Collection/CollectionSettingsDialog.cs index 6634a1586b89..5047c7aa2011 100644 --- a/src/BloomExe/Collection/CollectionSettingsDialog.cs +++ b/src/BloomExe/Collection/CollectionSettingsDialog.cs @@ -7,6 +7,7 @@ using Bloom.Properties; using Bloom.SubscriptionAndFeatures; using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; using Bloom.Utils; using Bloom.web.controllers; using Bloom.WebLibraryIntegration; @@ -53,6 +54,11 @@ public string PendingDefaultBookshelf internal bool PendingAllowAppBuilder; internal bool AllowTeamCollectionOptionEnabled = false; + // The "Cloud Team Collections (experimental)" checkbox: gates the S3+Supabase-backed + // Team Collection UI, wired end-to-end the same way as PendingAllowTeamCollection above. + internal bool PendingAllowCloudTeamCollection; + internal bool AllowCloudTeamCollectionOptionEnabled = false; + // "Internal" so CollectionSettingsApi can update these. internal readonly string[] PendingFontSelections = new[] { "", "", "" }; internal string PendingNumberingStyle { get; set; } @@ -118,12 +124,23 @@ XMatterPackFinder xmatterPackFinder PendingAllowTeamCollection = ExperimentalFeatures.IsFeatureEnabled( ExperimentalFeatures.kTeamCollections ); + PendingAllowCloudTeamCollection = ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kCloudTeamCollections + ); PendingAllowAppBuilder = ExperimentalFeatures.IsFeatureEnabled( ExperimentalFeatures.kAppBuilder ); if ( !ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections) + // A user adopting ONLY the cloud flavor (leaving the folder-based + // "Team Collections" flag off) must still see this tab -- otherwise the + // "Share this collection on the Bloom sharing server" button + // (TeamCollectionSettingsPanel.tsx) would be unreachable. Found while writing + // the task 10 adoption-path user docs. + && !ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kCloudTeamCollections + ) && tcManager.CurrentCollectionEvenIfDisconnected == null ) { @@ -139,6 +156,12 @@ XMatterPackFinder xmatterPackFinder AllowTeamCollectionOptionEnabled = !( PendingAllowTeamCollection && tcManager.CurrentCollectionEvenIfDisconnected != null ); + // Same idea, but specifically for the cloud-backed flavor: don't allow disabling the + // cloud experimental flag while the currently-open collection is itself a cloud TC. + AllowCloudTeamCollectionOptionEnabled = !( + PendingAllowCloudTeamCollection + && tcManager.CurrentCollectionEvenIfDisconnected is CloudTeamCollection + ); if (AutoUpdateSupportedOnThisPlatform) PendingAutomaticallyUpdate = Settings.Default.AutoUpdate; @@ -409,6 +432,7 @@ private void _okButton_Click(object sender, EventArgs e) PendingAutomaticallyUpdate && AutoUpdateSupportedOnThisPlatform; UpdateExperimentalBookSources(); UpdateTeamCollectionAllowed(); + UpdateCloudTeamCollectionAllowed(); UpdateAppBuilderAllowed(); _collectionSettings.Country = _countryText.Text.Trim(); @@ -824,6 +848,21 @@ private void UpdateTeamCollectionAllowed() ChangeThatRequiresRestart(); } + private void UpdateCloudTeamCollectionAllowed() + { + var wasCloudTeamCollectionsEnabled = ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kCloudTeamCollections + ); + + ExperimentalFeatures.SetValue( + ExperimentalFeatures.kCloudTeamCollections, + PendingAllowCloudTeamCollection + ); + + if (wasCloudTeamCollectionsEnabled != PendingAllowCloudTeamCollection) + ChangeThatRequiresRestart(); + } + private void UpdateAppBuilderAllowed() { // NB: This change does not require a restart. diff --git a/src/BloomExe/ErrorReporter/HtmlErrorReporter.cs b/src/BloomExe/ErrorReporter/HtmlErrorReporter.cs index 11110ce88b34..8e17a638710c 100644 --- a/src/BloomExe/ErrorReporter/HtmlErrorReporter.cs +++ b/src/BloomExe/ErrorReporter/HtmlErrorReporter.cs @@ -394,6 +394,22 @@ Action onExtraButtonClicked // Before we do anything that might be "risky", put the problem in the log. ProblemReportApi.LogProblem(exception, messageText, severity); + if (Program.UnattendedAutomation) + { + // In UNATTENDED automation (E2E harnesses, CI -- --automation without + // --attended) there is no human to dismiss this modal, so showing it would hang + // the automated run forever (see the matching gates in + // ProblemReportApi.ShowProblemReactDialogWithFallbacks and + // BrowserProgressDialog). The problem is already in the log (above), which is + // how the driving test learns about it. Attended automation (`pnpm go`) shows + // the dialog normally. + Logger.WriteEvent( + "HtmlErrorReporter: notify dialog suppressed in automation mode (see the " + + "problem logged just before this)." + ); + return; + } + // ENHANCE: Allow the caller to pass in the control, which would be at the front of this. //System.Windows.Forms.Control control = Form.ActiveForm ?? FatalExceptionHandler.ControlOnUIThread; var control = GetControlToUse(); diff --git a/src/BloomExe/ExperimentalFeatures.cs b/src/BloomExe/ExperimentalFeatures.cs index fc8f2c61df57..6fa26a2461eb 100644 --- a/src/BloomExe/ExperimentalFeatures.cs +++ b/src/BloomExe/ExperimentalFeatures.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Bloom.Properties; namespace Bloom @@ -12,6 +13,13 @@ public static class ExperimentalFeatures public const string kTeamCollections = "team-collections"; public const string kAppBuilder = "app-builder"; + /// + /// Token for the cloud-backed Team Collections experimental feature. Wired to the + /// "Cloud Team Collections (experimental)" checkbox in Settings -> Advanced (see + /// CollectionSettingsDialog.PendingAllowCloudTeamCollection / CollectionSettingsApi). + /// + public const string kCloudTeamCollections = "cloud-team-collections"; + public static string TokensOfEnabledFeatures => Settings.Default.EnabledExperimentalFeatures; @@ -45,10 +53,15 @@ public static void SetValue(string featureName, bool isEnabled) } else { - // Replace does no harm if the feature is not found in the string. - Settings.Default.EnabledExperimentalFeatures = Settings - .Default.EnabledExperimentalFeatures.Replace(featureName, "") - .Replace(",,", ","); + // Remove the EXACT token, not a substring: a raw Replace() would turn + // "cloud-team-collections" into "cloud-" when disabling "team-collections" + // (the two feature tokens share a substring). + Settings.Default.EnabledExperimentalFeatures = string.Join( + ",", + (Settings.Default.EnabledExperimentalFeatures ?? "") + .Split(',') + .Where(token => token != featureName && token.Length > 0) + ); } Settings.Default.EnabledExperimentalFeatures = Settings.Default.EnabledExperimentalFeatures.Trim(','); @@ -56,7 +69,13 @@ public static void SetValue(string featureName, bool isEnabled) public static bool IsFeatureEnabled(string featureName) { - return Settings.Default.EnabledExperimentalFeatures.Contains(featureName); + // Exact-token match, not substring: the setting is a comma-separated list and one + // token can be a substring of another ("team-collections" is contained in + // "cloud-team-collections"), so a Contains() check reported the cloud feature as + // enabling the folder feature too. + return (Settings.Default.EnabledExperimentalFeatures ?? "") + .Split(',') + .Contains(featureName); } } } diff --git a/src/BloomExe/History/HistoryEvent.cs b/src/BloomExe/History/HistoryEvent.cs index b2e840d48582..9e751b08c0e3 100644 --- a/src/BloomExe/History/HistoryEvent.cs +++ b/src/BloomExe/History/HistoryEvent.cs @@ -25,8 +25,15 @@ public enum BookHistoryEventType SyncProblem, Deleted, Moved, // Moved from one collection to another + // NB: add them here, too: teamCollection\CollectionHistoryTable.tsx // and also add them to EventTypeEnumerationIsStable() in History\HistoryEventTests.cs + + // Cloud Team Collection incident events start at 100 so that ordinary events added + // above (always at the end, before 100) can never collide with them. These numeric + // values are shared with the server's tc.events.type check constraint + // (supabase/migrations) — keep the two in sync. + WorkPreservedLocally = 100, // local work saved to Lost & Found as a .bloomSource } /// diff --git a/src/BloomExe/MiscUI/BrowserProgressDialog.cs b/src/BloomExe/MiscUI/BrowserProgressDialog.cs index a465be02dded..ec86b0ea65e2 100644 --- a/src/BloomExe/MiscUI/BrowserProgressDialog.cs +++ b/src/BloomExe/MiscUI/BrowserProgressDialog.cs @@ -82,6 +82,10 @@ public static void DoWorkWithProgressDialog( // depending on the nature of the problem, we might want to do more or less than this. // But at least this lets the dialog reach one of the states where it can be closed, // and gives the user some idea things are not right. + SIL.Reporting.Logger.WriteError( + "BrowserProgressDialog: the work behind a progress dialog failed", + ex + ); socketServer.SendEvent(socketContext, "finished"); waitForUserToCloseDialogOrReportProblems = true; progress.MessageWithoutLocalizing( @@ -92,7 +96,25 @@ public static void DoWorkWithProgressDialog( // stop the spinner socketServer.SendEvent(socketContext, "finished"); - if (waitForUserToCloseDialogOrReportProblems) + if (waitForUserToCloseDialogOrReportProblems && Program.UnattendedAutomation) + { + // In UNATTENDED automation (E2E harnesses, CI -- --automation without + // --attended) there is no human to click the Close/Report buttons, so the + // wait-for-user state below would hang the run forever (diagnosed twice + // from dotnet-stack dumps of hung instances -- see + // Design/CloudTeamCollections/tasks/09-e2e.md finding 9). Log that + // problems were reported and close, so the failure surfaces to the + // driving test as an error it can read instead of a hang. An ATTENDED + // automation launch (a developer's `pnpm go`) keeps the buttons: bug #16 + // was this auto-close silently killing a human's startup over a warning. + SIL.Reporting.Logger.WriteEvent( + "BrowserProgressDialog: problems were reported during progress-dialog " + + "work; auto-closing because Bloom is in automation mode (see " + + "preceding log entries for the actual error)." + ); + dlg.Invoke((Action)(() => dlg.Close())); + } + else if (waitForUserToCloseDialogOrReportProblems) { // Now the user is allowed to close the dialog or report problems. // (ProgressDialog in JS-land is watching for this message, which causes it to turn @@ -223,6 +245,10 @@ public static async Task DoWorkWithProgressDialogAsync( // depending on the nature of the problem, we might want to do more or less than this. // But at least this lets the dialog reach one of the states where it can be closed, // and gives the user some idea things are not right. + SIL.Reporting.Logger.WriteError( + "BrowserProgressDialog: the work behind a progress dialog failed", + ex + ); socketServer.SendEvent(socketContext, "finished"); waitForUserToCloseDialogOrReportProblems = true; _progress.MessageWithoutLocalizing( @@ -233,7 +259,18 @@ public static async Task DoWorkWithProgressDialogAsync( // stop the spinner socketServer.SendEvent(socketContext, "finished"); - if (waitForUserToCloseDialogOrReportProblems) + if (waitForUserToCloseDialogOrReportProblems && Program.UnattendedAutomation) + { + // See the matching branch in DoWorkWithProgressDialog above: no human exists + // to click the buttons in UNATTENDED automation, so close instead of hanging. + SIL.Reporting.Logger.WriteEvent( + "BrowserProgressDialog: problems were reported during progress-dialog " + + "work; auto-closing because Bloom is in automation mode (see " + + "preceding log entries for the actual error)." + ); + socketServer.SendBundle(socketContext, "close-progress", new DynamicJson()); + } + else if (waitForUserToCloseDialogOrReportProblems) { // Now the user is allowed to close the dialog or report problems. // (ProgressDialog in JS-land is watching for this message, which causes it to turn diff --git a/src/BloomExe/NonFatalProblem.cs b/src/BloomExe/NonFatalProblem.cs index 0079cf9248f9..782f224dfd19 100644 --- a/src/BloomExe/NonFatalProblem.cs +++ b/src/BloomExe/NonFatalProblem.cs @@ -116,6 +116,22 @@ public static void Report( return; } + if (Program.UnattendedAutomation) + { + // In UNATTENDED automation (--automation without --attended) there is no + // human to dismiss a modal MessageBox, so a + // modal report silently hangs the whole instance -- possibly before any + // window or server exists (found via a live stack dump: an E2E-relaunched + // instance sat blocked in MessageBox.Show inside TeamCollectionManager's + // constructor for the full ready-timeout with EMPTY stdout, 10 Jul 2026). + // Report to stdout (the harness's per-instance log, the same channel + // BLOOM_AUTOMATION_READY uses) and keep going. + Console.WriteLine( + $"BLOOM_AUTOMATION_NONFATAL_PROBLEM {fullDetailedMessage}\n{exception}" + ); + return; + } + //just convert from PassiveIf to ModalIf so that we don't have to duplicate code var passive = (ModalIf)ModalIf.Parse(typeof(ModalIf), passiveThreshold.ToString()); var formForSynchronizing = Shell.GetShellOrOtherOpenForm(); diff --git a/src/BloomExe/Program.cs b/src/BloomExe/Program.cs index 217be3c5f120..1d6f5c380a59 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -106,6 +106,21 @@ static class Program internal static string StartupLabel { get; private set; } internal static bool StartupAutomation { get; private set; } + /// --attended modifies --automation: a HUMAN is at the machine (e.g. a + /// developer's `pnpm go` launch, which needs --automation's ready-handshake and + /// single-instance bypass but not its unattended-UI policies). See + /// for what it turns back on. + internal static bool StartupAttended { get; private set; } + + /// True only for machine-driven runs with NO human present (E2E harnesses, CI): + /// --automation without --attended. This -- not StartupAutomation -- gates the policies + /// that suppress or auto-close human-facing UI (progress-dialog auto-close, problem/notify + /// dialog suppression). Dogfood bug #16 (13 Jul 2026): those policies gated on plain + /// StartupAutomation while `pnpm go` always passes --automation, so a mere WARNING in the + /// team-collection startup sync auto-closed the dialog and silently killed a human's + /// Bloom at every launch. + internal static bool UnattendedAutomation => StartupAutomation && !StartupAttended; + internal static string StartupRequestedPortSummary => string.Join( ", ", @@ -365,8 +380,11 @@ static int Main(string[] args1) { if (IsLocalizationHarvestingLaunch(args)) LocalizationManager.IgnoreExistingEnglishTranslationFiles = true; - else - // This allows us to debug things like interpreting a URL. + else if (!StartupAutomation) + // This allows us to debug things like interpreting a URL. + // Suppressed under --automation (the build-once dev launcher run.sh, + // and the E2E harness): there is no human to dismiss this modal, so it + // would silently hang startup before any window or server exists. MessageBox.Show("Attach debugger now"); } var harvest = Environment.GetEnvironmentVariable("HARVEST_FOR_LOCALIZATION"); @@ -755,6 +773,7 @@ internal static string[] ParseStartupPortArguments(string[] args, out string err StartupVitePort = null; StartupLabel = null; StartupAutomation = false; + StartupAttended = false; var remainingArgs = new List(); @@ -786,6 +805,14 @@ out errorMessage value => StartupAutomation = value, out errorMessage ) + || TryHandleStartupFlagArgument( + args, + ref i, + "--attended", + () => StartupAttended, + value => StartupAttended = value, + out errorMessage + ) ) { if (errorMessage != null) @@ -1701,6 +1728,30 @@ private static void HandleErrorOpeningProjectWindow(Exception error, string proj string errorFilePath = FileException.GetFilePathIfPresent(error); // We want to skip over exceptions thrown by Autofac. originalError = MiscUtils.UnwrapUntilInterestingException(originalError); + + // Batch item 9 (account-switch behavior): this is an expected, handled refusal (the + // current logon is not a server member of this Team Collection), not a bug -- show + // the already-composed message plainly and return, instead of falling into the + // generic "Report this crash" flow below. OpenProjectWindow's caller (e.g. + // StartUpShellBasedOnMostRecentUsedIfPossible/ChooseACollection's own loop) already + // reopens the collection chooser whenever this method's caller returns false, exactly + // like any other failure to open a project. + if (originalError is TeamCollectionAccessRefusedException refusalException) + { + Logger.WriteEvent( + $"*** Refused to open collection {Path.GetFileNameWithoutExtension(projectPath)}: {refusalException.Message}" + ); + // In automation mode, stdout is the harness's per-instance log (the same channel + // BLOOM_AUTOMATION_READY uses) — the SIL Logger file above lives in a per-session + // temp folder a test can't reliably find. E2E-10 polls for this line. + if (StartupAutomation) + Console.WriteLine( + $"BLOOM_AUTOMATION_REFUSED_COLLECTION Refused to open collection: {refusalException.Message}" + ); + SIL.Reporting.ErrorReport.NotifyUserOfProblem(refusalException.Message); + return; + } + Logger.WriteError( $"*** Error loading collection {Path.GetFileNameWithoutExtension(projectPath)}, on filepath: {errorFilePath}", originalError diff --git a/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs b/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs index d12cc757a6a1..81b8fa9968bd 100644 --- a/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs +++ b/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs @@ -13,6 +13,7 @@ public enum FeatureName Widget, //HTML5 Widget Spreadsheet, TeamCollection, + CloudTeamCollection, // S3+Supabase backed; experimental until GA ViewBookHistory, // Sort of tied to team collections now, but nothing says it has to be in the future... Motion, Music, @@ -184,6 +185,14 @@ public static class FeatureRegistry SubscriptionTier = SubscriptionTier.LocalCommunity, }, new FeatureInfo + { + // Cloud-backed Team Collections (S3 + Supabase). Experimental until GA; + // the feature flag must be enabled before any cloud TC UI becomes visible. + Feature = FeatureName.CloudTeamCollection, + SubscriptionTier = SubscriptionTier.LocalCommunity, + ExperimentalFeatureToken = Bloom.ExperimentalFeatures.kCloudTeamCollections, + }, + new FeatureInfo { Feature = FeatureName.ViewBookHistory, SubscriptionTier = SubscriptionTier.LocalCommunity, diff --git a/src/BloomExe/TeamCollection/Cloud/BookVersionManifest.cs b/src/BloomExe/TeamCollection/Cloud/BookVersionManifest.cs new file mode 100644 index 000000000000..59662eddc939 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/BookVersionManifest.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Bloom.Book; +using Newtonsoft.Json.Linq; +using SIL.IO; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// One file entry in a : content hash, size, and (once + /// committed server-side) the S3 object version-id that pins the exact bytes stored under that + /// path. Matches CONTRACTS.md's `version_files` shape (path → sha256, size, s3VersionId). + /// + public class BookVersionManifestEntry + { + /// + /// Lower-case hex SHA-256 of the file's bytes. This convention (not base64) is chosen to + /// match the server side: supabase/functions/_shared/s3.ts's `hexToBase64` doc comment says + /// "the manifest's `sha256` field (matching the C# client, which uses + /// Convert.ToHexString/SHA256) is lowercase hex" — S3's own `x-amz-checksum-sha256` attribute + /// is base64, so callers must convert at the point they build the S3 request (see + /// ). + /// + public string Sha256 { get; set; } + + /// Size in bytes. + public long Size { get; set; } + + /// + /// The S3 object version-id for this exact (path, sha256). Null for a manifest that hasn't + /// been committed yet — e.g. the local proposed manifest built from disk before checkin-start/ + /// checkin-finish run, which by definition doesn't know the version-id S3 will assign. + /// + public string S3VersionId { get; set; } + + public BookVersionManifestEntry() { } + + public BookVersionManifestEntry(string sha256, long size, string s3VersionId = null) + { + Sha256 = sha256; + Size = size; + S3VersionId = s3VersionId; + } + } + + /// + /// An NFC-path-normalized map of relative path → (sha256, size, s3VersionId) for one book's + /// content, per CONTRACTS.md's S3 layout and `version_files` table. Two things build one of + /// these: the server (the currently-committed manifest, complete with s3VersionId, which + /// downloads by pinned version) and the client from a local book + /// folder (the proposed manifest for a Send, with no s3VersionId yet — see + /// ). Path separators are always '/' (S3 key style, and the wire + /// format's convention), independent of the local OS. + /// + public class BookVersionManifest + { + private readonly Dictionary _entriesByPath; + + /// NFC-normalized relative path → entry. Read-only: build a new manifest (via the + /// constructor, , or ) rather than mutating + /// one in place, so a manifest handed to another object (e.g. cached in + /// ) can't be changed out from under it. + public IReadOnlyDictionary Entries => _entriesByPath; + + public BookVersionManifest(IDictionary entries = null) + { + _entriesByPath = + entries == null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary( + entries, + StringComparer.Ordinal + ); + } + + /// + /// NFC-normalizes a relative path and forces '/' separators (CONTRACTS.md: paths in the S3 + /// layout and manifest are "NFC-normalized"). Safe to call on a path that's already in this + /// form. + /// + public static string NormalizePath(string relativePath) + { + return relativePath.Replace('\\', '/').Normalize(NormalizationForm.FormC); + } + + /// + /// Builds a manifest from the wire array shape `[{path, sha256, size, s3VersionId?}]`. + /// `s3VersionId` is absent on a client-proposed manifest (checkin-start's `files`) and present + /// on a committed one. + /// + public static BookVersionManifest FromJson(JToken filesArray) + { + var entries = new Dictionary(StringComparer.Ordinal); + if (filesArray != null) + { + foreach (var file in filesArray) + { + var path = NormalizePath((string)file["path"]); + entries[path] = new BookVersionManifestEntry( + (string)file["sha256"], + (long)file["size"], + (string)file["s3VersionId"] + ); + } + } + return new BookVersionManifest(entries); + } + + /// Serializes to the wire array shape (see ), in path order for + /// deterministic output. Omits `s3VersionId` for entries that don't have one yet. + public JArray ToJson() + { + var array = new JArray(); + foreach (var kvp in _entriesByPath.OrderBy(e => e.Key, StringComparer.Ordinal)) + { + var obj = new JObject + { + ["path"] = kvp.Key, + ["sha256"] = kvp.Value.Sha256, + ["size"] = kvp.Value.Size, + }; + if (kvp.Value.S3VersionId != null) + obj["s3VersionId"] = kvp.Value.S3VersionId; + array.Add(obj); + } + return array; + } + + /// + /// Builds the manifest that reflects a book folder's CURRENT content on disk (no + /// s3VersionId — those only exist once committed). Uses + /// configured exactly like the real Bloom Library upload-for-continued-editing path + /// (Bloom.WebLibraryIntegration.BookUpload.SetUpStagingAsync: IncludeFilesForContinuedEditing, + /// every narration language, video and music) so the same junk/derived files (placeholders, + /// anything outside the whitelist) are excluded from cloud sync as from a Bloom Library + /// upload — CONTRACTS.md's "junk-file exclusion reusing the publish path's filters". + /// + public static BookVersionManifest FromLocalFolder(string bookFolderPath) + { + var entries = new Dictionary(StringComparer.Ordinal); + var filter = new BookFileFilter(bookFolderPath) + { + IncludeFilesForContinuedEditing = true, + NarrationLanguages = null, // null = include every narration language actually used + WantVideo = true, + WantMusic = true, + }; + var prefixLength = bookFolderPath.Length + 1; + foreach (var fullPath in BookFileFilter.GetAllFilePaths(bookFolderPath)) + { + var relativePath = fullPath.Substring(prefixLength); + if (!filter.ShouldAllowRelativePath(relativePath)) + continue; + var normalizedPath = NormalizePath(relativePath); + var (sha256, size) = ComputeFileHash(fullPath); + entries[normalizedPath] = new BookVersionManifestEntry(sha256, size); + } + return new BookVersionManifest(entries); + } + + /// + /// Computes the lower-case hex SHA-256 and byte length of one file, streaming it in a + /// buffered fashion (like TeamCollection.MakeChecksumOnFilesInternal / Book.MakeVersionCode) + /// rather than reading it whole into memory, but per-file rather than combined across a whole + /// folder — the granularity CONTRACTS.md's `version_files` table needs. Also used by + /// to hash files immediately before upload and to verify + /// downloaded bytes against the pinned manifest entry. + /// + internal static (string sha256Hex, long size) ComputeFileHash(string filePath) + { + using (var sha = SHA256.Create()) + using (var input = RobustIO.GetFileStream(filePath, FileMode.Open, FileAccess.Read)) + { + var buffer = new byte[81920]; + int count; + long size = 0; + while ((count = input.Read(buffer, 0, buffer.Length)) > 0) + { + sha.TransformBlock(buffer, 0, count, buffer, 0); + size += count; + } + sha.TransformFinalBlock(Array.Empty(), 0, 0); + return (Convert.ToHexString(sha.Hash).ToLowerInvariant(), size); + } + } + + // NOTE: an earlier version of this class also carried a local manifest-diff API + // (DiffAgainst/DiffAgainstLocalFolder). It was removed as dead code: the server is + // authoritative about what changed (checkin-start returns changedPaths), and no product + // code ever consumed a locally-computed diff. + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudAuth.cs b/src/BloomExe/TeamCollection/Cloud/CloudAuth.cs new file mode 100644 index 000000000000..16719ca34e72 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudAuth.cs @@ -0,0 +1,409 @@ +using System; +using System.Net; +using System.Text; +using System.Threading; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using RestSharp; +using SIL.Reporting; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// An immutable snapshot of a signed-in session: the bearer token to send, the refresh token + /// to use when it expires, and who the caller is. + /// + public class CloudSession + { + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string Email { get; set; } + public string UserId { get; set; } + public DateTime ExpiresAtUtc { get; set; } + + /// + /// Whether the provider considers this identity's email verified, read from the + /// provider's own token/claims (never from anything a caller merely asserts). Null + /// means "this provider doesn't track the concept" (the dev provider's accounts are all + /// auto-confirmed, so it always sets true -- see DevCloudAuthProvider.ToSession). + /// + public bool? EmailVerified { get; set; } + } + + /// + /// Groundwork for the future `sharing/loginState` API endpoint (the actual endpoint + /// registration belongs to the UI tasks, which own the shared TeamCollectionApi.cs handler + /// file): reports enough that the client can decide whether to show a plain dev-mode + /// email/password form (AuthMode == "dev") instead of a real sign-in browser flow, plus the + /// current identity so the UI can show who is signed in. + /// + public class CloudLoginState + { + public string AuthMode { get; set; } + public bool SignedIn { get; set; } + public string Email { get; set; } + + /// False when signed out or when the current session's provider left it + /// unknown (see ). Mirrors what + /// tc.jwt_email_verified() decides server-side, so the client can show/withhold + /// approval-dependent UI without waiting on a round trip. + public bool EmailVerified { get; set; } + } + + /// Thrown by an when sign-in or refresh fails. + public class CloudAuthException : ApplicationException + { + public CloudAuthException(string message) + : base(message) { } + + public CloudAuthException(string message, Exception innerException) + : base(message, innerException) { } + } + + /// + /// The mechanics of actually obtaining/renewing a session. owns the + /// provider-agnostic session lifecycle (storage, proactive refresh, sign-out, account-switch + /// detection) and delegates the actual network exchange to one of these. There are two + /// implementations: (local GoTrue) and + /// (Option A, decided 8 Jul 2026). + /// + public interface ICloudAuthProvider + { + /// Signs in with an email/password, creating the account first if necessary. Throws CloudAuthException on failure. + CloudSession SignIn(string email, string password); + + /// Exchanges a still-valid refresh token for a new session. Throws CloudAuthException on failure. + CloudSession Refresh(string refreshToken); + + /// + /// Accepts an externally-obtained ID token + refresh token (e.g. the Firebase tokens + /// BloomLibrary2's login page forwards to Bloom's token-receipt endpoint -- see + /// CONTRACTS.md's "Auth (Option A)" section) as a brand-new session. Implementations + /// MUST derive identity (email/userId/emailVerified/expiry) from the token's own claims, + /// never from anything the caller separately asserts. Throws CloudAuthException on + /// failure. Default implementation is "not supported": only a provider that actually + /// receives externally-minted tokens (the Firebase/cloud provider) needs to override + /// this, so the dev password-based provider and any test doubles predating this method + /// don't have to change. + /// + CloudSession AcceptExternalSession(string idToken, string refreshToken) => + throw new NotSupportedException( + $"{GetType().Name} does not accept externally-obtained tokens." + ); + } + + /// + /// Where a signed-in session is remembered between sign-ins (e.g. so a single Bloom instance + /// can restart without asking the user to sign in again). + /// is process-lifetime only (used by the dev provider and by tests); the persistent, + /// DPAPI-backed implementation a real cloud session needs is + /// in CloudTokenStore.cs. Callers that use + /// InMemoryCloudTokenStore must not assume tokens survive a restart. + /// + public interface ICloudTokenStore + { + CloudSession Load(); + void Save(CloudSession session); + void Clear(); + } + + /// Process-lifetime-only token store. See remarks. + public class InMemoryCloudTokenStore : ICloudTokenStore + { + private CloudSession _session; + + public CloudSession Load() => _session; + + public void Save(CloudSession session) => _session = session; + + public void Clear() => _session = null; + } + + /// + /// Provider-agnostic session core for Cloud Team Collections: holds the current + /// , proactively refreshes it at ~80% of its TTL (and on-demand + /// when a request comes back 401), detects when a new sign-in switches the active account, + /// and exposes "who am I" / sign-out. Editing a checked-out book must never block on auth, + /// so is a pure in-memory read — it never makes a network + /// call; callers that get a 401 back from the server call and + /// abort their operation (surfacing "please sign in") if that also fails. + /// + public class CloudAuth : IDisposable + { + private readonly ICloudAuthProvider _provider; + private readonly ICloudTokenStore _tokenStore; + private readonly object _lock = new object(); + private CloudSession _session; + private Timer _refreshTimer; + + // NOTE: this class used to expose AccountSwitched/SignedOut events, but nothing ever + // subscribed. Account-switch consumers instead key their cached state on the signed-in + // email (e.g. CloudTeamCollection._membershipsClaimedForEmail), which self-invalidates. + + public CloudAuth(ICloudAuthProvider provider, ICloudTokenStore tokenStore = null) + { + _provider = provider; + _tokenStore = tokenStore ?? new InMemoryCloudTokenStore(); + } + + /// + /// The one shared construction sequence for a ready-to-use auth: builds a CloudAuth with + /// the provider matching and immediately establishes a + /// session where possible (env-override credentials or a stored token — see + /// ). Used everywhere a default auth is needed + /// (TeamCollectionManager.ConnectToCloudCollection, CloudTeamCollection's own default, + /// SharingApi's process-wide fallback). + /// + public static CloudAuth CreateInitialized(CloudEnvironment environment) + { + // Use the DPAPI-backed persistent store (not the in-memory default) so a real Cloud + // TC session survives a Bloom restart: InitializeAtStartup restores it from disk, and + // every sign-in/refresh re-saves it. Harmless for the dev env-override path, which + // re-signs from BLOOM_CLOUDTC_USER before ever consulting the store (see + // InitializeAtStartup). + var auth = new CloudAuth(CreateProvider(environment), new DpapiCloudTokenStore()); + auth.InitializeAtStartup(environment); + return auth; + } + + /// Builds the provider matching 's configured auth mode. + public static ICloudAuthProvider CreateProvider(CloudEnvironment environment) + { + switch (environment.AuthMode) + { + case CloudAuthMode.Cloud: + return new FirebaseCloudAuthProvider(environment); + case CloudAuthMode.Dev: + default: + return new DevCloudAuthProvider(environment); + } + } + + public bool IsSignedIn + { + get + { + lock (_lock) + return _session != null; + } + } + + /// The signed-in user's email, or null if not signed in. + public string CurrentEmail + { + get + { + lock (_lock) + return _session?.Email; + } + } + + /// The signed-in user's server-assigned id (the JWT `sub` claim), or null. + public string CurrentUserId + { + get + { + lock (_lock) + return _session?.UserId; + } + } + + /// See ; false when signed out or the + /// provider left it unknown. + public bool CurrentEmailVerified + { + get + { + lock (_lock) + return _session?.EmailVerified ?? false; + } + } + + /// + /// Explicit sign-in (e.g. the user submitted the dev-mode email/password form). Throws + /// CloudAuthException on failure; the caller decides how to surface that to the user. + /// + public void SignIn(string email, string password) + { + var newSession = _provider.SignIn(email, password); + ApplyNewSession(newSession); + } + + /// + /// Explicit sign-in from an externally-obtained token pair (the Bloom-side half of + /// BloomLibrary2's login forwarding -- see the token-receipt endpoint in + /// ExternalApi.cs and CONTRACTS.md's "Auth (Option A)" section). Throws + /// CloudAuthException on failure (e.g. a malformed token); the caller decides how to + /// surface that. + /// + public void SignInWithExternalTokens(string idToken, string refreshToken) + { + var newSession = _provider.AcceptExternalSession(idToken, refreshToken); + ApplyNewSession(newSession); + } + + /// + /// Called once at startup to establish a session without blocking on user interaction + /// where possible. `BLOOM_CLOUDTC_USER`/`BLOOM_CLOUDTC_PASSWORD` (when set) always win + /// over any stored session — that override, and only that override, is what lets two + /// Bloom instances on one machine run as two different users. Never throws: a failure + /// here just leaves the session unset, which is a normal "please sign in" state, not a + /// crash (editing a checked-out book must never block on auth). + /// + public void InitializeAtStartup(CloudEnvironment environment) + { + if (!string.IsNullOrEmpty(environment.DevUser)) + { + try + { + SignIn(environment.DevUser, environment.DevPassword ?? string.Empty); + } + catch (Exception e) + { + Logger.WriteError("CloudAuth: env-override sign-in failed", e); + } + return; + } + + var stored = _tokenStore.Load(); + if (stored?.RefreshToken == null) + return; + + try + { + RefreshWith(stored.RefreshToken); + } + catch (Exception e) + { + Logger.WriteError("CloudAuth: restoring the stored session failed", e); + _tokenStore.Clear(); + } + } + + /// + /// A pure in-memory read of the current bearer token (or null if not signed in). This + /// deliberately never triggers a network call so that nothing on the book-editing path + /// can block on auth; a stale token simply results in the server returning 401, which + /// callers handle via . + /// + public string GetAccessTokenOrNull() + { + lock (_lock) + return _session?.AccessToken; + } + + /// + /// Called by when a request comes back 401. Attempts + /// one synchronous refresh using the stored refresh token. Returns true if the caller + /// should retry its request with the (now-refreshed) token; false if there is no way to + /// recover, in which case the session has been cleared and the caller should abort its + /// operation and surface "please sign in" rather than retry indefinitely. + /// + public bool TryRefreshOn401() + { + string refreshToken; + lock (_lock) + refreshToken = _session?.RefreshToken; + if (refreshToken == null) + return false; + + try + { + RefreshWith(refreshToken); + return true; + } + catch (Exception e) + { + Logger.WriteError("CloudAuth: refresh-on-401 failed", e); + SignOutCore(); + return false; + } + } + + /// Clears the session (locally and in the token store) and cancels the refresh timer. + public void SignOut() => SignOutCore(); + + /// + /// Groundwork data for the `sharing/loginState` endpoint (see ). + /// + public CloudLoginState GetLoginState(CloudEnvironment environment) => + new CloudLoginState + { + AuthMode = environment.AuthMode == CloudAuthMode.Dev ? "dev" : "cloud", + SignedIn = IsSignedIn, + Email = CurrentEmail, + EmailVerified = IsSignedIn && CurrentEmailVerified, + }; + + private void RefreshWith(string refreshToken) + { + var newSession = _provider.Refresh(refreshToken); + ApplyNewSession(newSession); + } + + private void ApplyNewSession(CloudSession newSession) + { + string previousEmail; + lock (_lock) + { + previousEmail = _session?.Email; + _session = newSession; + } + _tokenStore.Save(newSession); + ScheduleProactiveRefresh(newSession); + } + + /// + /// Arms a one-shot timer that refreshes the session at ~80% of its remaining TTL, well + /// before the server would reject it — this is what lets a session survive indefinitely + /// (e.g. the >2h soak test in the task's acceptance criteria) without ever hitting a + /// user-visible 401 in the common case. + /// + private void ScheduleProactiveRefresh(CloudSession session) + { + _refreshTimer?.Dispose(); + + var ttl = session.ExpiresAtUtc - DateTime.UtcNow; + var due = TimeSpan.FromTicks((long)(ttl.Ticks * 0.8)); + if (due < TimeSpan.Zero) + due = TimeSpan.Zero; + + _refreshTimer = new Timer( + _ => OnProactiveRefreshDue(), + null, + due, + Timeout.InfiniteTimeSpan + ); + } + + private void OnProactiveRefreshDue() + { + string refreshToken; + lock (_lock) + refreshToken = _session?.RefreshToken; + if (refreshToken == null) + return; + + try + { + RefreshWith(refreshToken); + } + catch (Exception e) + { + Logger.WriteError("CloudAuth: proactive refresh failed", e); + SignOutCore(); + } + } + + private void SignOutCore() + { + lock (_lock) + _session = null; + _refreshTimer?.Dispose(); + _refreshTimer = null; + _tokenStore.Clear(); + } + + public void Dispose() => _refreshTimer?.Dispose(); + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudBookTransfer.cs b/src/BloomExe/TeamCollection/Cloud/CloudBookTransfer.cs new file mode 100644 index 000000000000..7747e3c8408c --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudBookTransfer.cs @@ -0,0 +1,582 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Bloom.WebLibraryIntegration; +using BloomTemp; +using SIL.IO; +using SIL.Reporting; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// Where and how to reach one S3 transaction's objects: the bucket/region/key-prefix plus the + /// scoped, time-limited credentials an edge function handed out (checkin-start's write-scoped + /// creds, or download-start's read-scoped ones) — CONTRACTS.md's `s3` response object. + /// + public class CloudS3Location + { + public string Bucket; + public string Region; + + /// Key prefix for this book/collection, e.g. + /// `tc/{collectionId}/books/{bookInstanceId}/` — CONTRACTS.md S3 layout. Every relative path + /// this class transfers is appended to this verbatim. + public string Prefix; + public string AccessKeyId; + public string SecretAccessKey; + public string SessionToken; + + /// When these temporary credentials expire (from the edge function's + /// `credentials.expiration`, ~1h out; see _shared/s3.ts). DateTime.MinValue when the + /// response carried no/invalid expiration, which callers treat as "don't cache". Lets + /// the download-credential cache re-fetch before the creds go stale rather than assuming + /// a fixed TTL. + public DateTime ExpiresAtUtc; + } + + /// Byte-level progress for one file within a multi-file transfer. + public class CloudTransferProgress + { + public string RelativePath; + public long BytesTransferred; + public long TotalBytes; + } + + /// + /// One file to download, pinned to an exact S3 object version. CONTRACTS.md hard invariant: + /// "Reads are ALWAYS by (path, s3VersionId) from the committed manifest — never 'latest'" — + /// enforces this by construction (see its class doc). + /// + public class PinnedFileDownload + { + public string RelativePath; + public string S3VersionId; + public string ExpectedSha256Hex; + public long ExpectedSize; + } + + /// Thrown when an upload or download can't complete after retrying. Carries the paths + /// that failed so a caller can report/retry them specifically. + public class CloudBookTransferException : ApplicationException + { + public IReadOnlyList FailedPaths { get; } + + public CloudBookTransferException( + string message, + IReadOnlyList failedPaths, + Exception innerException = null + ) + : base(message, innerException) + { + FailedPaths = failedPaths ?? Array.Empty(); + } + } + + public class CloudUploadResult + { + /// Paths actually PUT to S3 this call. + public List UploadedPaths { get; } = new List(); + + /// Paths NOT uploaded because their content already matches what's already + /// committed under that path (hash-skip), or was already uploaded earlier in a resumed + /// transaction. + public List SkippedPaths { get; } = new List(); + } + + public class CloudDownloadResult + { + /// Paths actually fetched from S3 and swapped into the destination folder. + public List DownloadedPaths { get; } = new List(); + + /// Paths NOT downloaded because the destination already has exactly the pinned + /// content (hash-skip). + public List SkippedPaths { get; } = new List(); + } + + /// + /// Uploads and downloads Cloud Team Collection book content, per CONTRACTS.md's S3 layout and + /// the design doc's Send/Receive flows. Reuses 's session-credential + /// client construction (, extracted to `internal static` for exactly this reuse) + /// rather than duplicating it; talks to directly (PutObject/ + /// GetObject with an explicit sha256 checksum) rather than through + /// , since that gives this class the per-file + /// checksum verification, byte-progress reporting, and mockability (via the constructor's + /// client-factory seam) the acceptance tests need, without touching the publish path at all. + /// + /// Hard invariant (CONTRACTS.md): downloads are ALWAYS by pinned (path, s3VersionId), never + /// "latest". is the only place in this class (indeed, in the whole + /// Cloud client) that constructs a , and it throws rather than + /// issuing a request if a lacks a version id — see + /// . + /// + public class CloudBookTransfer + { + /// Attempts per file before giving up and throwing. + public const int MaxAttemptsPerFile = 3; + + private readonly Func _clientFactory; + + public CloudBookTransfer() + : this(BuildDefaultClient) { } + + /// Test-only seam: lets unit tests substitute a fake/mocked + /// instead of one built from real credentials. + internal CloudBookTransfer(Func clientFactory) + { + _clientFactory = clientFactory; + } + + /// Builds the real S3 client for a location's scoped credentials against the + /// configured (always S3-compatible, never real AWS) endpoint. Internal so other cloud-TC + /// code with the same need (e.g. CloudTeamCollection's collection-file downloads) reuses + /// this rather than duplicating the config. + internal static IAmazonS3 BuildDefaultClient(CloudS3Location location) + { + var env = CloudEnvironment.Current; + var config = new AmazonS3Config + { + ServiceURL = env.S3Endpoint, + ForcePathStyle = env.S3ForcePathStyle, + AuthenticationRegion = location.Region, + // AWSSDK v4 defaults both of these to WHEN_SUPPORTED, which makes the SDK add its + // own CRC32/CRC64 request checksum (and validate a response checksum) on every + // supported operation. This endpoint is always MinIO (dev/sandbox) or another + // S3-compatible store (CloudEnvironment.S3Endpoint is never empty), and older/ + // differently-configured S3-compatible servers don't support the newer checksum + // trailer format the SDK sends by default -- forcing WHEN_REQUIRED restores the + // pre-v4 behavior (only compute/validate a checksum when the operation truly + // requires one) and keeps this class's own explicit x-amz-checksum-sha256 header + // (below, in UploadOneFileWithRetry) as the only checksum actually sent. + RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED, + ResponseChecksumValidation = ResponseChecksumValidation.WHEN_REQUIRED, + }; + var credentials = new AmazonS3Credentials + { + AccessKey = location.AccessKeyId, + SecretAccessKey = location.SecretAccessKey, + SessionToken = location.SessionToken, + }; + return BloomS3Client.CreateAmazonS3Client(config, credentials); + } + + /// + /// Uploads the given candidate paths (typically checkin-start's authoritative `changedPaths`) + /// from , skipping any whose current on-disk content exactly + /// matches (hash-skip — belt-and-suspenders + /// against re-sending unchanged bytes even if a candidate path turns out identical). Runs + /// PUTs in parallel up to , each carrying an + /// explicit `x-amz-checksum-sha256` header (CONTRACTS.md: "Uploads carry + /// x-amz-checksum-sha256"). (An earlier version also took a caller-held + /// already-uploaded-this-transaction set for cross-retry resume, but no caller ever + /// reused one across calls, so it was removed as dead complexity; an interrupted Send's + /// retry is still cheap because the hash-skip does the same job against the committed + /// manifest.) + /// + /// is the caller's already-computed manifest of the + /// CURRENT on-disk content (e.g. the one PutBookInRepo just built to drive checkin-start): + /// a candidate path found there uses that hash/size instead of re-hashing the file — + /// without it every Send hashed each changed file twice. May be null (or missing a path), + /// in which case the file is hashed here as before. + /// + public CloudUploadResult UploadChangedFiles( + CloudS3Location location, + string bookFolderPath, + IEnumerable changedRelativePaths, + BookVersionManifest previousCommittedManifest, + BookVersionManifest localManifest, + int maxDegreeOfParallelism, + IProgress progress, + CancellationToken cancellationToken + ) + { + var client = _clientFactory(location); + var result = new CloudUploadResult(); + var resultGate = new object(); + + var candidatePaths = changedRelativePaths + .Select(BookVersionManifest.NormalizePath) + .Distinct() + .ToList(); + + try + { + Parallel.ForEach( + candidatePaths, + new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism), + CancellationToken = cancellationToken, + }, + relativePath => + { + var localFilePath = ToLocalPath(bookFolderPath, relativePath); + string sha256; + long size; + if ( + localManifest != null + && localManifest.Entries.TryGetValue(relativePath, out var localEntry) + ) + { + // The caller already hashed this exact on-disk content (see the doc + // comment) — don't hash it a second time. + sha256 = localEntry.Sha256; + size = localEntry.Size; + } + else + { + (sha256, size) = BookVersionManifest.ComputeFileHash(localFilePath); + } + + if ( + previousCommittedManifest != null + && previousCommittedManifest.Entries.TryGetValue( + relativePath, + out var previousEntry + ) + && previousEntry.Sha256 == sha256 + && previousEntry.Size == size + ) + { + lock (resultGate) + result.SkippedPaths.Add(relativePath); + return; + } + + UploadOneFileWithRetry( + client, + location, + relativePath, + localFilePath, + sha256, + size, + progress, + cancellationToken + ); + + lock (resultGate) + result.UploadedPaths.Add(relativePath); + } + ); + } + catch (AggregateException aggregate) + { + throw Unwrap(aggregate); + } + + return result; + } + + private void UploadOneFileWithRetry( + IAmazonS3 client, + CloudS3Location location, + string relativePath, + string localFilePath, + string initialSha256Hex, + long initialSize, + IProgress progress, + CancellationToken cancellationToken + ) + { + Exception lastError = null; + for (var attempt = 1; attempt <= MaxAttemptsPerFile; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + // Re-hash on every retry: if the previous attempt failed because the bytes we + // sent didn't match the checksum we claimed (corruption in transit, or the file + // changed under us), we must send what's actually on disk NOW, not stale + // hash/bytes from a failed attempt. + var (sha256Hex, size) = + attempt == 1 + ? (initialSha256Hex, initialSize) + : BookVersionManifest.ComputeFileHash(localFilePath); + + using ( + var stream = RobustIO.GetFileStream( + localFilePath, + FileMode.Open, + FileAccess.Read + ) + ) + { + var request = new PutObjectRequest + { + BucketName = location.Bucket, + Key = location.Prefix + relativePath, + InputStream = stream, + }; + // The checksum is set as a plain request header rather than via the SDK's + // native ChecksumSHA256/ChecksumAlgorithm request properties. This dates + // from when the project pinned an AWSSDK.S3 that predated those properties; + // now that we're on v4 they exist, but the header form is kept deliberately: + // it is live-verified against the local MinIO dev stack (task 04) — S3/MinIO + // store and correctly return it via GetObjectAttributes/HeadObject + // (ChecksumMode: ENABLED) exactly as if the property had set it, which is + // what supabase/functions/_shared/s3.ts's verifyUploadedObject reads back at + // checkin-finish — and switching to the property would also flip the SDK + // into its trailing-checksum/chunked-encoding path, an unnecessary behavior + // change for S3-compatible endpoints (see the WHEN_REQUIRED config in + // BuildDefaultClient above). + request.Headers["x-amz-checksum-sha256"] = HexToBase64(sha256Hex); + client.PutObjectAsync(request, cancellationToken).GetAwaiter().GetResult(); + } + + progress?.Report( + new CloudTransferProgress + { + RelativePath = relativePath, + BytesTransferred = size, + TotalBytes = size, + } + ); + return; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Covers both a transport-level failure and S3 rejecting the PUT because the + // checksum we declared didn't match what it received. + lastError = ex; + Logger.WriteEvent( + $"CloudBookTransfer: upload attempt {attempt} of '{relativePath}' failed: {ex.Message}" + ); + } + } + + throw new CloudBookTransferException( + $"Failed to upload '{relativePath}' after {MaxAttemptsPerFile} attempts.", + new[] { relativePath }, + lastError + ); + } + + /// + /// Downloads (each pinned to an exact S3 object version) into + /// , skipping any whose destination copy already has + /// the pinned content (hash-skip — also what makes an interrupted-and-retried download + /// naturally resume where it left off, with no separate bookkeeping needed). Every file is + /// first downloaded into a private staging folder and verified against + /// /; + /// ONLY once every requested file has staged successfully are they moved into + /// (CONTRACTS.md: "download changed files ... into + /// temp → atomic swap per book"). If anything fails after retries, the staging folder is + /// discarded and is left byte-for-byte as it was — + /// nothing is ever partially written there. + /// + public CloudDownloadResult DownloadFiles( + CloudS3Location location, + IEnumerable files, + string destinationFolderPath, + int maxDegreeOfParallelism, + IProgress progress, + CancellationToken cancellationToken + ) + { + var client = _clientFactory(location); + var result = new CloudDownloadResult(); + var toDownload = new List(); + + foreach (var file in files) + { + var normalizedPath = BookVersionManifest.NormalizePath(file.RelativePath); + var destPath = ToLocalPath(destinationFolderPath, normalizedPath); + if (RobustFile.Exists(destPath)) + { + var (sha256, size) = BookVersionManifest.ComputeFileHash(destPath); + if (sha256 == file.ExpectedSha256Hex && size == file.ExpectedSize) + { + result.SkippedPaths.Add(normalizedPath); + continue; + } + } + toDownload.Add( + new PinnedFileDownload + { + RelativePath = normalizedPath, + S3VersionId = file.S3VersionId, + ExpectedSha256Hex = file.ExpectedSha256Hex, + ExpectedSize = file.ExpectedSize, + } + ); + } + + if (toDownload.Count == 0) + return result; + + // The staging folder name must be UNIQUE PER CALL: TemporaryFolder("fixed-name") is a + // fixed %TEMP% path shared by every download in every Bloom process, and its Dispose + // deletes the whole folder -- so two concurrent downloads (two Bloom instances on one + // machine, e.g. a shared-machine Team Collection or the two-instance E2E scenarios) + // clobbered each other mid-copy: e2e-4 failed with "Could not find file ...\ + // BloomCloudTCDownload\.htm" when the other instance's download completed first + // and swept the folder away (10 Jul 2026). + using ( + var staging = new TemporaryFolder( + "BloomCloudTCDownload-" + + Path.GetFileNameWithoutExtension(Path.GetRandomFileName()) + ) + ) + { + try + { + Parallel.ForEach( + toDownload, + new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism), + CancellationToken = cancellationToken, + }, + file => + { + var stagedPath = ToLocalPath(staging.FolderPath, file.RelativePath); + Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)); + DownloadOneFileWithRetry( + client, + location, + file, + stagedPath, + progress, + cancellationToken + ); + } + ); + } + catch (AggregateException aggregate) + { + // Nothing below has touched destinationFolderPath yet — the staging TemporaryFolder + // is deleted by its Dispose() as this using block unwinds, so an interrupted + // download leaves no trace anywhere. + throw Unwrap(aggregate); + } + + // Every file staged and verified successfully — only now do we touch the real + // destination, and only with files we know are byte-correct. + foreach (var file in toDownload) + { + var stagedPath = ToLocalPath(staging.FolderPath, file.RelativePath); + var destPath = ToLocalPath(destinationFolderPath, file.RelativePath); + var destDir = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destDir)) + Directory.CreateDirectory(destDir); + if (RobustFile.Exists(destPath)) + RobustFile.Delete(destPath); + RobustFile.Move(stagedPath, destPath); + result.DownloadedPaths.Add(file.RelativePath); + } + } + + return result; + } + + private void DownloadOneFileWithRetry( + IAmazonS3 client, + CloudS3Location location, + PinnedFileDownload file, + string stagedPath, + IProgress progress, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrEmpty(file.S3VersionId)) + throw new ArgumentException( + $"Pinned download for '{file.RelativePath}' is missing an S3 version id — " + + "CONTRACTS.md requires downloads to always be by pinned (path, s3VersionId), never 'latest'.", + nameof(file) + ); + + Exception lastError = null; + for (var attempt = 1; attempt <= MaxAttemptsPerFile; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + // The one and only place a GetObjectRequest is built in this class: always + // carries VersionId, per the hard invariant in the class doc comment. + var request = new GetObjectRequest + { + BucketName = location.Bucket, + Key = location.Prefix + file.RelativePath, + VersionId = file.S3VersionId, + }; + + using ( + var response = client + .GetObjectAsync(request, cancellationToken) + .GetAwaiter() + .GetResult() + ) + using (var fileStream = RobustIO.GetFileStream(stagedPath, FileMode.Create)) + { + response.ResponseStream.CopyTo(fileStream); + } + + var (actualSha256, actualSize) = BookVersionManifest.ComputeFileHash( + stagedPath + ); + if (actualSha256 != file.ExpectedSha256Hex || actualSize != file.ExpectedSize) + { + throw new IOException( + $"Checksum mismatch downloading '{file.RelativePath}': " + + $"expected sha256={file.ExpectedSha256Hex} size={file.ExpectedSize}, " + + $"got sha256={actualSha256} size={actualSize}." + ); + } + + progress?.Report( + new CloudTransferProgress + { + RelativePath = file.RelativePath, + BytesTransferred = actualSize, + TotalBytes = actualSize, + } + ); + return; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + lastError = ex; + if (RobustFile.Exists(stagedPath)) + RobustFile.Delete(stagedPath); + Logger.WriteEvent( + $"CloudBookTransfer: download attempt {attempt} of '{file.RelativePath}' failed: {ex.Message}" + ); + } + } + + throw new CloudBookTransferException( + $"Failed to download '{file.RelativePath}' after {MaxAttemptsPerFile} attempts.", + new[] { file.RelativePath }, + lastError + ); + } + + private static string ToLocalPath(string baseFolder, string relativePath) => + Path.Combine(baseFolder, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + /// S3's `x-amz-checksum-sha256` is base64; this manifest's convention is lower-case + /// hex (see ) — convert at the point of use. + private static string HexToBase64(string hex) => + Convert.ToBase64String(Convert.FromHexString(hex)); + + private static Exception Unwrap(AggregateException aggregate) + { + var flattened = aggregate.Flatten(); + return flattened.InnerExceptions.Count == 1 ? flattened.InnerExceptions[0] : flattened; + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudCollectionClient.cs b/src/BloomExe/TeamCollection/Cloud/CloudCollectionClient.cs new file mode 100644 index 000000000000..dd080d464d89 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudCollectionClient.cs @@ -0,0 +1,636 @@ +using System; +using System.Net; +using Newtonsoft.Json.Linq; +using RestSharp; +using SIL.Reporting; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// Server-reported error codes that Cloud Team Collection callers need to branch on, per + /// CONTRACTS.md. `Unknown` covers any error we could not classify (still surfaced with the + /// server's own message). `NotSignedIn` is synthesized locally (not a server code) when a + /// 401 survives a refresh attempt. + /// + public enum CloudErrorCode + { + Unknown, + NotSignedIn, + LockHeldByOther, + BaseVersionSuperseded, + NameConflict, + ClientOutOfDate, + MissingOrBadUploads, + VersionConflict, + TransactionExpired, + } + + /// + /// A typed error from a Cloud Team Collection RPC or edge function call. + /// lets callers branch (e.g. show "X is editing this book" for LockHeldByOther) without + /// string-matching; is the raw parsed JSON error body (e.g. the lock + /// holder's identity, or the list of bad paths for MissingOrBadUploads) for callers that need + /// more than the code. + /// + public class CloudCollectionClientException : ApplicationException + { + public CloudErrorCode Code { get; } + public JToken Details { get; } + + public CloudCollectionClientException( + CloudErrorCode code, + string message, + JToken details = null + ) + : base(message) + { + Code = code; + Details = details; + } + } + + /// + /// The one thing needs from a transport: execute a + /// request, get a response. Deliberately much smaller than RestSharp's own IRestClient + /// (which carries dozens of unrelated configuration members) so tests can substitute a fake + /// executor with a single method instead of stubbing an entire third-party interface. + /// + internal interface IRestExecutor + { + IRestResponse Execute(IRestRequest request); + } + + /// Production : a thin wrapper over a real RestSharp RestClient. + internal class RestSharpExecutor : IRestExecutor + { + private readonly RestClient _client; + + public RestSharpExecutor(string baseUrl) + { + _client = new RestClient(baseUrl); + } + + public IRestResponse Execute(IRestRequest request) => _client.Execute(request); + } + + /// + /// RestSharp client for Cloud Team Collection Postgres RPCs (PostgREST) and edge functions, + /// per CONTRACTS.md. Modeled on . + /// Injects the bearer token from on every call, retries once via + /// on a 401, and maps error responses to a + /// with a typed . + /// This class only owns the transport; the RPC/edge-function-specific methods (get_collection_state, + /// checkout_book, checkin-start, etc.) are built on top of it by later tasks. + /// + public class CloudCollectionClient + { + private readonly CloudEnvironment _environment; + private readonly CloudAuth _auth; + private IRestExecutor _restClient; + + public CloudCollectionClient(CloudEnvironment environment, CloudAuth auth) + { + _environment = environment; + _auth = auth; + } + + /// The signed-in cloud account's email, or null if signed out. Read-only + /// pass-through of the auth this client was built with; used by callers (e.g. + /// CloudJoinFlow) that only hold a client, not the auth itself. + public string CurrentUserEmail => _auth?.CurrentEmail; + + /// + /// Test-only seam: lets unit tests substitute a fake so error + /// mapping and header injection can be verified without a live server. Production code + /// never needs to call this; lazily creates a real one. + /// + internal void SetRestClientForTests(IRestExecutor restClient) => _restClient = restClient; + + private IRestExecutor RestClient => + _restClient ?? (_restClient = new RestSharpExecutor(_environment.SupabaseUrl)); + + /// + /// Calls a `tc`-schema Postgres RPC (PostgREST `/rest/v1/rpc/<name>`). Per + /// CONTRACTS.md v1.1, must already use the + /// `p_`-prefixed argument names the SQL functions declare (PostgREST matches JSON keys to + /// parameter names verbatim) — e.g. an anonymous object with a `p_collection_id` + /// property, not `collection_id`. Returns the parsed JSON result (or null for a 204/empty + /// body); throws on any error response. + /// + public JToken CallRpc(string rpcName, object parametersWithPPrefixedKeys) + { + return ExecuteWithAuthRetry(() => + { + var request = new RestRequest($"rest/v1/rpc/{rpcName}", Method.POST); + AddCommonHeaders(request); + // tc is a separate PostgREST-exposed schema (not the default "public"), so every + // call must say so on both the request and response side (CONTRACTS.md v1.1). + request.AddHeader("Content-Profile", "tc"); + request.AddHeader("Accept-Profile", "tc"); + AddJsonBody(request, parametersWithPPrefixedKeys); + return request; + }); + } + + /// + /// Calls a Cloud Team Collection edge function (`/functions/v1/<name>`), per + /// CONTRACTS.md. Returns the parsed JSON result; throws + /// on any error response (including the + /// 426 ClientOutOfDate and 409 LockHeldByOther/BaseVersionSuperseded/NameConflict/ + /// MissingOrBadUploads/VersionConflict shapes edge functions use). + /// + public JToken CallEdgeFunction(string functionName, object body) + { + return ExecuteWithAuthRetry(() => + { + var request = new RestRequest($"functions/v1/{functionName}", Method.POST); + AddCommonHeaders(request); + AddJsonBody(request, body); + return request; + }); + } + + /// + /// Serializes with Newtonsoft ourselves and attaches the resulting + /// JSON text as a raw request-body parameter, rather than using RestSharp's own + /// AddJsonBody (which defers serialization to RestSharp's own default JSON + /// serializer). That matters here because several typed wrapper methods below (e.g. + /// CheckinStart, CollectionFilesStart) embed a Newtonsoft / + /// directly inside the anonymous body object -- RestSharp's default + /// serializer does not know how to serialize a JToken as a native JSON array/object (it + /// reflects over JToken's own CLR properties instead), which silently produced a malformed + /// `files` payload and a cryptic Postgres "jsonb_to_recordset must be an array of objects" + /// error. Discovered via the live round-trip test against the real local dev stack -- the + /// FakeRestExecutor-based unit tests never actually serialize a request, so they couldn't + /// have caught this. + /// + private static void AddJsonBody(RestRequest request, object body) + { + var json = Newtonsoft.Json.JsonConvert.SerializeObject(body ?? new object()); + request.AddParameter("application/json", json, ParameterType.RequestBody); + } + + // --------------------------------------------------------------- + // Typed wrappers over CallRpc/CallEdgeFunction, one per RPC/edge + // function in CONTRACTS.md v1.2. Added by task 05 (CloudCollectionClient's + // own doc comment above said these belong here). Each just builds the + // p_-prefixed (RPC) or camelCase (edge function) body and casts the + // result to the shape callers expect; CloudTeamCollection/CloudCollectionMonitor/ + // CloudJoinFlow parse the returned JToken with Newtonsoft, matching the + // pattern CloudRepoCache already uses for JObject snapshots. + // --------------------------------------------------------------- + + /// Creates a new collection, with the caller as its sole claimed admin. + public JToken CreateCollection(string collectionId, string name) => + CallRpc("create_collection", new { p_id = collectionId, p_name = name }); + + /// Lists the collections where the caller's email is approved (claimed or not). + public JArray MyCollections() => + (JArray)(CallRpc("my_collections", new { }) ?? new JArray()); + + /// Fills user_id on membership rows matching the caller's verified email. + public JToken ClaimMemberships() => CallRpc("claim_memberships", new { }); + + /// + /// Full snapshot (sinceEventId null) or delta (sinceEventId set) of book rows, collection-file + /// group versions, and max_event_id. Feeds / + /// . + /// + public JObject GetCollectionState(string collectionId, long? sinceEventId = null) => + (JObject)CallRpc( + "get_collection_state", + new { p_collection_id = collectionId, p_since_event_id = sinceEventId } + ); + + /// Events + touched book rows since (polling/catch-up). + public JObject GetChanges(string collectionId, long sinceEventId) => + (JObject)CallRpc( + "get_changes", + new { p_collection_id = collectionId, p_since_event_id = sinceEventId } + ); + + /// + /// v1.2: per-file current manifest for the pinned-version Receive path. Never-committed + /// books are invisible except to their mid-Send lock holder. + /// + public JObject GetBookManifest(string bookId) => + (JObject)CallRpc("get_book_manifest", new { p_book_id = bookId }); + + /// E9: per-file current manifest for one collection-file group + /// (path, sha256, size, s3VersionId) + its version, so the download path fetches only + /// changed files pinned to their committed s3_version_id. A never-written group returns + /// version 0 / empty files. + public JObject GetCollectionFileManifest(string collectionId, string groupKey) => + (JObject)CallRpc( + "get_collection_file_manifest", + new { p_collection_id = collectionId, p_group_key = groupKey } + ); + + /// Conditional lock; result includes the winning holder's identity on failure. + /// v1.5 (20260711000003): also records which local copy of the collection ("seat") took + /// the lock — see CloudTeamCollection.SeatId. + public JObject CheckoutBook(string bookId, string machine, string seat) => + (JObject)CallRpc( + "checkout_book", + new + { + p_book_id = bookId, + p_machine = machine, + p_seat = seat, + } + ); + + /// Account-switch takeover (batch item 9, CONTRACTS.md v1.4/v1.5): atomically + /// reassigns a book's lock from a DIFFERENT account to the caller, but ONLY when the + /// existing lock is recorded for the SAME machine AND the SAME seat (local collection + /// copy — bug #0, John's ruling: two local copies on one computer are two seats). Returns + /// the same {success, locked_by, locked_by_machine, locked_seat, locked_at} shape as + /// checkout_book, so callers can reuse CloudRepoCache.RecordCheckoutResult unchanged. + public JObject CheckoutBookTakeover(string bookId, string machine, string seat) => + (JObject)CallRpc( + "checkout_book_takeover", + new + { + p_book_id = bookId, + p_machine = machine, + p_seat = seat, + } + ); + + /// Releases the caller's own lock (undo checkout; no content change). + public JObject UnlockBook(string bookId) => + (JObject)CallRpc("unlock_book", new { p_book_id = bookId }); + + /// Admin-only forced unlock; audited server-side, emits a ForcedUnlock event. + public JObject ForceUnlock(string bookId) => + (JObject)CallRpc("force_unlock", new { p_book_id = bookId }); + + /// Requires the caller holds the lock; sets deleted_at and emits a Deleted event. + public JObject DeleteBook(string bookId) => + (JObject)CallRpc("delete_book", new { p_book_id = bookId }); + + // NOTE: wrappers for the tc.undelete_book and tc.rename_check RPCs used to live here but + // were removed as dead code -- no client flow calls them (renames travel through + // checkin-start's proposedName/NameConflict retry). The SQL functions remain as contract + // surface for admin tooling. + + /// + /// Admin-only approved-accounts list. RPC name is our best reading of CONTRACTS.md's + /// "members: list/add/remove/set_role" shorthand (not spelled out precisely there) -- + /// flagged in the task 05 final report as a contract ambiguity to confirm with the server. + /// + public JArray MembersList(string collectionId) => + (JArray)( + CallRpc("members_list", new { p_collection_id = collectionId }) ?? new JArray() + ); + + /// + /// Adds an approved-account email (admin-only). defaults to + /// "member" server-side if omitted. Returns the new member row's id, or null when the + /// email was already approved (the RPC is idempotent: on conflict it does nothing and + /// returns SQL NULL). NOTE the live RPC returns a bare bigint scalar (a JValue), not an + /// object -- casting the result to JObject crashed the first real two-instance smoke + /// test (7 Jul 2026) even though the row committed fine. + /// + public long? MembersAdd(string collectionId, string email, string role = "member") + { + var result = CallRpc( + "members_add", + new + { + p_collection_id = collectionId, + p_email = email, + p_role = role, + } + ); + return result == null || result.Type == JTokenType.Null + ? (long?)null + : result.Value(); + } + + /// + /// Removes an approved-account (admin-only; force-unlocks their checkouts server-side). + /// Task 06 live-verification fix: the deployed RPC's real signature is + /// members_remove(p_collection_id, p_member_id bigint) -- task 05's original + /// p_email guess (flagged there as a "contract ambiguity") does not match and fails + /// with PGRST202 ("could not find the function"). is the row id + /// from ; callers that only have an email must resolve it via a + /// MembersList lookup first (see SharingApi). + /// + public JObject MembersRemove(string collectionId, long memberId) => + (JObject)CallRpc( + "members_remove", + new { p_collection_id = collectionId, p_member_id = memberId } + ); + + /// + /// Sets a member's display name (20260713000001 migration). Admin may set anyone's; + /// a claimed member may set their own. Blank/whitespace clears it server-side. + /// Returns nothing useful (the RPC is void); callers refresh via MembersList. + /// + public void MembersSetDisplayName(string collectionId, long memberId, string displayName) => + CallRpc( + "members_set_display_name", + new + { + p_collection_id = collectionId, + p_member_id = memberId, + p_display_name = displayName, + } + ); + + /// + /// Changes an approved-account's role (admin-only; last-admin guard enforced server-side). + /// Task 06 live-verification fix: same p_member_id mismatch as + /// -- the deployed RPC is + /// members_set_role(p_collection_id, p_member_id bigint, p_new_role). + /// + public JObject MembersSetRole(string collectionId, long memberId, string role) => + (JObject)CallRpc( + "members_set_role", + new + { + p_collection_id = collectionId, + p_member_id = memberId, + p_new_role = role, + } + ); + + /// Union merge (insert ... on conflict do nothing) of palette colors. + public JObject AddPaletteColors(string collectionId, string palette, string[] colors) => + (JObject)CallRpc( + "add_palette_colors", + new + { + p_collection_id = collectionId, + p_palette = palette, + p_colors = colors, + } + ); + + /// + /// Client-originated history entry. uses the same numeric + /// values as (extended server-side with + /// incident types such as WorkPreservedLocally). Exact parameter names are our best + /// reading of CONTRACTS.md's "log_event(...)" shorthand -- flagged as a contract + /// ambiguity in the task 05 final report. + /// + public JObject LogEvent( + string collectionId, + string bookId, + int eventType, + string comment = null + ) => + (JObject)CallRpc( + "log_event", + new + { + p_collection_id = collectionId, + p_book_id = bookId, + p_type = eventType, + // The deployed tc.log_event RPC's message parameter is `p_message`, NOT + // `p_comment` (CONTRACTS.md's "log_event(...)" shorthand was ambiguous -- this + // class's own doc comment flagged the guess). PostgREST matches functions by + // argument NAME, so the wrong key made every log_event call 404 (no function + // with that signature); the only caller (CloudTeamCollection. + // SaveLocalCopyForRecovery) wraps it in a Sentry-only catch, so the + // WorkPreservedLocally incident silently never reached the server's history. + // Found live by E2E-4's forced-check-in recovery scenario. + p_message = comment, + } + ); + + /// + /// Opens (or refreshes, if called again with the same open transaction) a check-in + /// transaction. null means "first Send of a new book". + /// is the diff (added/changed paths only) as + /// [{path,sha256,size}]. Throws with + /// LockHeldByOther/BaseVersionSuperseded/NameConflict/ClientOutOfDate on the documented + /// 409/426s. + /// + public JObject CheckinStart( + string collectionId, + string bookId, + string bookInstanceId, + string proposedName, + string baseVersionId, + string checksum, + string clientVersion, + JArray files + ) => + (JObject)CallEdgeFunction( + "checkin-start", + new + { + collectionId, + bookId, + bookInstanceId, + proposedName, + baseVersionId, + checksum, + clientVersion, + files, + } + ); + + /// + /// Commits a check-in transaction: verifies uploads, writes the version/manifest rows, + /// releases the lock (unless ), emits events. + /// + public JObject CheckinFinish( + string transactionId, + string comment = null, + bool keepCheckedOut = false + ) => + (JObject)CallEdgeFunction( + "checkin-finish", + new + { + transactionId, + comment, + keepCheckedOut, + } + ); + + /// Abandons an open check-in transaction (nothing was committed). + public void CheckinAbort(string transactionId) => + CallEdgeFunction("checkin-abort", new { transactionId }); + + /// Read-only STS creds (GetObject + GetObjectVersion) scoped to the collection prefix. + public JObject DownloadStart(string collectionId) => + (JObject)CallEdgeFunction("download-start", new { collectionId }); + + /// Phase 1 of the two-phase collection-files write (repo-wins on 409 VersionConflict). + public JObject CollectionFilesStart( + string collectionId, + string groupKey, + long expectedVersion, + JArray files + ) => + (JObject)CallEdgeFunction( + "collection-files-start", + new + { + collectionId, + groupKey, + expectedVersion, + files, + } + ); + + /// Phase 2: bumps the collection-file group version atomically. + public JObject CollectionFilesFinish(string transactionId) => + (JObject)CallEdgeFunction("collection-files-finish", new { transactionId }); + + /// + /// The apikey header is required by PostgREST/edge-functions regardless of sign-in state; + /// the bearer token (when we have one) is what RLS/edge functions actually authorize + /// against. Deliberately does NOT fail if there is no token yet — an anonymous call will + /// simply get a 401 from the server, which handles. + /// + private void AddCommonHeaders(RestRequest request) + { + request.AddHeader("apikey", _environment.AnonKey); + var token = _auth.GetAccessTokenOrNull(); + if (!string.IsNullOrEmpty(token)) + request.AddHeader("Authorization", $"Bearer {token}"); + } + + /// + /// Runs (called twice if a retry-after-refresh happens, so + /// it must build a fresh RestRequest each time rather than reusing one). On a 401, tries + /// exactly one and retries once; if that still + /// comes back 401 (or there was no session to refresh), aborts with a NotSignedIn error + /// rather than looping — per the task brief, a failed refresh mid-operation must abort + /// cleanly and surface "please sign in", not block or retry indefinitely. + /// + private JToken ExecuteWithAuthRetry(Func makeRequest) + { + var response = RestClient.Execute(makeRequest()); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + if (_auth.TryRefreshOn401()) + response = RestClient.Execute(makeRequest()); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new CloudCollectionClientException( + CloudErrorCode.NotSignedIn, + "Please sign in to continue." + ); + } + + return HandleResponse(response); + } + + private JToken HandleResponse(IRestResponse response) + { + var statusCode = (int)response.StatusCode; + if (statusCode >= 200 && statusCode < 300) + return string.IsNullOrWhiteSpace(response.Content) + ? null + : JToken.Parse(response.Content); + + throw MapError(response); + } + + /// + /// Classifies an error response into a . + /// Edge functions return CONTRACTS.md's standard envelope `{error: "", ...extra}` + /// (built by supabase/functions/_shared/errors.ts) for the documented 409s/426s; + /// Postgres RPC errors arrive as `{code, message, details, hint}` (PostgREST's shape, + /// where `code` is the SQLSTATE from the RAISE EXCEPTION in the SQL functions). Anything + /// we don't recognize maps to with the server's own + /// message preserved. + /// + private CloudCollectionClientException MapError(IRestResponse response) + { + JToken body = null; + string serverCode = null; + string message = response.Content; + try + { + if (!string.IsNullOrWhiteSpace(response.Content)) + { + body = JToken.Parse(response.Content); + // The `error` key is the CONTRACTS.md envelope every edge function actually + // uses; `code` is kept as a fallback for PostgREST RPC error bodies. An + // earlier version of this method read ONLY `code`, which classified every + // documented edge-function 409 (NameConflict/LockHeldByOther/ + // BaseVersionSuperseded/MissingOrBadUploads/VersionConflict) as Unknown -- + // found live by E2E-9's same-name race, where PutBookInRepo's NameConflict + // retry loop never engaged because its exception filter never matched. + serverCode = (string)body["error"] ?? (string)body["code"]; + message = (string)body["message"] ?? message; + } + } + catch (Exception e) + { + // Not JSON (or not an object) — fall back to the raw content/status as the message. + Logger.WriteEvent( + "CloudCollectionClient: error response was not parseable JSON: " + e.Message + ); + } + + if (response.StatusCode == (HttpStatusCode)426) + return new CloudCollectionClientException( + CloudErrorCode.ClientOutOfDate, + message ?? "This version of Bloom is out of date.", + body + ); + + switch (serverCode) + { + case "LockHeldByOther": + return new CloudCollectionClientException( + CloudErrorCode.LockHeldByOther, + message, + body + ); + case "BaseVersionSuperseded": + return new CloudCollectionClientException( + CloudErrorCode.BaseVersionSuperseded, + message, + body + ); + case "NameConflict": + return new CloudCollectionClientException( + CloudErrorCode.NameConflict, + message, + body + ); + case "MissingOrBadUploads": + return new CloudCollectionClientException( + CloudErrorCode.MissingOrBadUploads, + message, + body + ); + case "VersionConflict": + return new CloudCollectionClientException( + CloudErrorCode.VersionConflict, + message, + body + ); + case "ClientOutOfDate": + return new CloudCollectionClientException( + CloudErrorCode.ClientOutOfDate, + message, + body + ); + } + + if (response.StatusCode == HttpStatusCode.Gone) + return new CloudCollectionClientException( + CloudErrorCode.TransactionExpired, + message ?? "The upload transaction has expired.", + body + ); + + return new CloudCollectionClientException( + CloudErrorCode.Unknown, + message ?? response.StatusDescription, + body + ); + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudCollectionMonitor.cs b/src/BloomExe/TeamCollection/Cloud/CloudCollectionMonitor.cs new file mode 100644 index 000000000000..2b9d2119b10d --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudCollectionMonitor.cs @@ -0,0 +1,140 @@ +using System; +using System.Threading; +using Newtonsoft.Json.Linq; +using SIL.Reporting; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// Polls `get_changes` for one cloud collection, per Design/CloudTeamCollections.md's "polling + /// first; realtime is a later wave" and the task 05 brief ("polling only (get_changes, 60s + + /// on-activation); event-id self-echo suppression via last-seen cursor; catch-up-then-trust on + /// reconnect"). Owned and driven by , which supplies the + /// starting cursor and interprets each batch of changes -- this class has no knowledge of + /// TeamCollection's event model, it just fetches on a schedule and hands back the raw JSON. + /// + /// Self-echo suppression and catch-up-then-trust both fall out of the same mechanism: the + /// cursor (`last_seen_event_id`) only ever advances to the server's own reported max_event_id, + /// so a poll immediately following our own write already reflects that write (no separate event + /// to "notice" and suppress), and a poll after being offline for a while asks for everything + /// since the last cursor we actually incorporated -- there is no separate "reconnect" mode. + /// + public class CloudCollectionMonitor : IDisposable + { + public static readonly TimeSpan DefaultPollInterval = TimeSpan.FromSeconds(60); + + private readonly CloudCollectionClient _client; + private readonly string _collectionId; + private readonly Action _onChanges; + private readonly Action _onError; + private readonly TimeSpan _pollInterval; + private readonly object _gate = new object(); + private long _lastSeenEventId; + private Timer _timer; + private bool _pollInProgress; + private bool _disposed; + + /// + /// True while a poll is actually in flight. Internal, for tests that need to know whether a + /// triggered poll has settled before asserting on its effects. + /// + internal bool PollInProgressForTests + { + get + { + lock (_gate) + return _pollInProgress; + } + } + + public long LastSeenEventId => Interlocked.Read(ref _lastSeenEventId); + + public CloudCollectionMonitor( + CloudCollectionClient client, + string collectionId, + long initialLastSeenEventId, + Action onChanges, + Action onError = null, + TimeSpan? pollInterval = null + ) + { + _client = client; + _collectionId = collectionId; + _lastSeenEventId = initialLastSeenEventId; + _onChanges = onChanges; + _onError = onError; + _pollInterval = pollInterval ?? DefaultPollInterval; + } + + /// + /// Starts the periodic poll timer. Does not itself poll synchronously -- call + /// first if an immediate poll is wanted (e.g. right after joining a + /// collection, before the first 60s tick). + /// + public void Start() + { + lock (_gate) + { + if (_timer != null || _disposed) + return; + _timer = new Timer(_ => PollNow(), null, _pollInterval, _pollInterval); + } + } + + /// + /// Runs one poll cycle immediately: used by the periodic timer, and available for + /// "on-activation" callers (Bloom regaining focus, or a user-initiated "Receive Updates"). + /// Safe to call re-entrantly -- a poll already in flight is not duplicated, it just returns + /// without doing anything (the in-flight poll will pick up the same ground on its own next + /// run, or the next explicit call after it finishes will). + /// + public void PollNow() + { + lock (_gate) + { + if (_pollInProgress || _disposed) + return; + _pollInProgress = true; + } + + try + { + var since = LastSeenEventId; + var changes = _client.GetChanges(_collectionId, since); + if (changes == null) + return; + + var maxEventId = (long?)changes["max_event_id"]; + if (maxEventId.HasValue && maxEventId.Value > since) + Interlocked.Exchange(ref _lastSeenEventId, maxEventId.Value); + + _onChanges?.Invoke(changes); + } + catch (Exception e) + { + if (_onError != null) + _onError(e); + else + NonFatalProblem.ReportSentryOnly(e, "CloudCollectionMonitor.PollNow"); + } + finally + { + lock (_gate) + _pollInProgress = false; + } + } + + /// Terminal: stops the timer and permanently disables Start/PollNow (the + /// monitor can never poll again). There is deliberately no separate pausable Stop(); + /// the one owner (CloudTeamCollection.StopMonitoring) only ever tears down. + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _timer?.Dispose(); + _timer = null; + } + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudEnvironment.cs b/src/BloomExe/TeamCollection/Cloud/CloudEnvironment.cs new file mode 100644 index 000000000000..a95647ec0bb4 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudEnvironment.cs @@ -0,0 +1,151 @@ +using System; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// Which backend a Cloud Team Collection authenticates against. "Dev" talks to the local + /// GoTrue instance bundled with the local Supabase dev stack (accepts any email/password); + /// "Cloud" is the real BloomLibrary/Firebase sign-in (Option A, decided 8 Jul 2026 -- see + /// Design/CloudTeamCollections.md and GOING-LIVE.md Phase 3). The string value ("dev"/ + /// "cloud") is also what travels over the wire in CloudLoginState/sharing/loginState, and + /// must keep matching BloomBrowserUI's SharingLoginMode type in sharingApi.ts. + /// + public enum CloudAuthMode + { + Dev, + Cloud, + } + + /// + /// The single place that resolves Cloud Team Collection configuration (Supabase URL, anon + /// key, S3 endpoint/bucket/path-style, auth mode) from the `BLOOM_CLOUDTC_*` environment + /// variables documented in server/dev/README.md, falling back to compiled defaults that + /// point at the local dev stack. Nothing else in Bloom should read these environment + /// variables directly; switching local <-> sandbox <-> production is a matter of + /// setting environment variables, not changing code. + /// + public class CloudEnvironment + { + // Compiled defaults match the "Dev value" column of server/dev/README.md's environment + // variable table, so a plain checkout of Bloom talks to the local dev stack with no + // environment configuration at all. + private const string DefaultSupabaseUrl = "http://127.0.0.1:54321"; + private const string DefaultAnonKey = ""; + private const string DefaultS3Endpoint = "http://127.0.0.1:9000"; + private const CloudAuthMode DefaultAuthMode = CloudAuthMode.Dev; + + // Firebase Web API key (Option A): the compiled default is an empty placeholder (never + // call the securetoken API before an override is set); the production value is set via + // GOING-LIVE.md Phase 3.5's client-defaults change, sandbox/dev via the env-var + // override below. + private const string DefaultFirebaseApiKey = ""; + + /// The Supabase (or PostgREST/GoTrue-compatible) API base URL. + public string SupabaseUrl { get; } + + /// The Supabase anon/public JWT key, sent as the `apikey` header on every call. + public string AnonKey { get; } + + /// The S3-compatible endpoint used for book/collection-file storage. NOTE: + /// there is deliberately no bucket setting here — every bucket name arrives in server + /// responses (checkin-start/download-start's s3 blocks), never from client config. + public string S3Endpoint { get; } + + /// + /// True when the S3 endpoint requires path-style requests (http://host/bucket/key), as + /// MinIO does. Real AWS uses virtual-hosted style. Currently true whenever a non-empty + /// S3 endpoint override is configured (i.e. we are NOT talking to real AWS), since only + /// local/sandbox dev stacks set BLOOM_CLOUDTC_S3_ENDPOINT explicitly. + /// + public bool S3ForcePathStyle { get; } + + /// Which auth provider CloudAuth should use. See . + public CloudAuthMode AuthMode { get; } + + /// + /// The Firebase Web API key used to call the Google securetoken refresh endpoint + /// (`BLOOM_CLOUDTC_FIREBASE_API_KEY`). Firebase Web API keys are not secret (they only + /// identify the project to Google's client APIs; the actual authorization is the + /// refresh/ID token itself), so committing a real default here later is fine -- see + /// GOING-LIVE.md Phase 3.5. + /// + public string FirebaseApiKey { get; } + + /// + /// Optional email to silently auto-sign-in as, bypassing any stored session tokens. Set + /// via BLOOM_CLOUDTC_USER. This is what lets two Bloom instances on one machine run as + /// two different users (see server/dev/README.md "Two Bloom instances on one machine"). + /// + public string DevUser { get; } + + /// The password to pair with , from BLOOM_CLOUDTC_PASSWORD. + public string DevPassword { get; } + + /// + /// How often CloudCollectionMonitor polls the server for remote changes, from + /// BLOOM_CLOUDTC_POLL_SECONDS. The 60s default is right for real users (change + /// visibility within a minute at negligible server load); E2E tests and hands-on + /// testing of a freshly-deployed server want a much shorter interval so cross-instance + /// changes show up promptly. Fail-fast on an unparsable/non-positive value: a silently + /// ignored typo here would make tests subtly slow instead of obviously misconfigured. + /// + public TimeSpan PollInterval { get; } + + /// + /// The one process-wide instance, built from the real environment the first time it is + /// asked for. Tests should use the constructor directly (with a fake variable lookup) + /// rather than touching this singleton. + /// + private static CloudEnvironment _current; + public static CloudEnvironment Current => _current ?? (_current = FromEnvironment()); + + /// Rebuilds from the real process environment variables. + public static CloudEnvironment FromEnvironment() => + new CloudEnvironment(Environment.GetEnvironmentVariable); + + /// Test-only hook: force to a specific instance. + public static void SetCurrentForTests(CloudEnvironment environment) => + _current = environment; + + /// Test-only hook: forget any override so the next access reads real env vars again. + public static void ResetCurrentForTests() => _current = null; + + /// + /// Builds a CloudEnvironment by asking for each + /// BLOOM_CLOUDTC_* variable. Taking the lookup as a delegate (rather than reading + /// System.Environment directly) is what makes this class trivial to unit test. + /// + public CloudEnvironment(Func getEnvironmentVariable) + { + string Get(string name, string fallback) => + string.IsNullOrEmpty(getEnvironmentVariable(name)) + ? fallback + : getEnvironmentVariable(name); + + SupabaseUrl = Get("BLOOM_CLOUDTC_SUPABASE_URL", DefaultSupabaseUrl); + AnonKey = Get("BLOOM_CLOUDTC_ANON_KEY", DefaultAnonKey); + S3Endpoint = Get("BLOOM_CLOUDTC_S3_ENDPOINT", DefaultS3Endpoint); + + var pollSecondsRaw = Get("BLOOM_CLOUDTC_POLL_SECONDS", "60"); + if (!int.TryParse(pollSecondsRaw, out var pollSeconds) || pollSeconds <= 0) + throw new ApplicationException( + $"BLOOM_CLOUDTC_POLL_SECONDS must be a positive whole number of seconds; got '{pollSecondsRaw}'." + ); + PollInterval = TimeSpan.FromSeconds(pollSeconds); + // Real AWS never sets this override; only local/sandbox dev stacks (MinIO) do. + S3ForcePathStyle = !string.IsNullOrEmpty(S3Endpoint); + + var authModeRaw = Get( + "BLOOM_CLOUDTC_AUTH_MODE", + DefaultAuthMode == CloudAuthMode.Dev ? "dev" : "cloud" + ); + AuthMode = string.Equals(authModeRaw, "cloud", StringComparison.OrdinalIgnoreCase) + ? CloudAuthMode.Cloud + : CloudAuthMode.Dev; + + DevUser = Get("BLOOM_CLOUDTC_USER", null); + DevPassword = Get("BLOOM_CLOUDTC_PASSWORD", null); + FirebaseApiKey = Get("BLOOM_CLOUDTC_FIREBASE_API_KEY", DefaultFirebaseApiKey); + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudJoinFlow.cs b/src/BloomExe/TeamCollection/Cloud/CloudJoinFlow.cs new file mode 100644 index 000000000000..5e6fa6173cf1 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudJoinFlow.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Bloom.Collection; +using Bloom.CollectionCreating; +using Bloom.web; +using SIL.IO; + +namespace Bloom.TeamCollection.Cloud +{ + /// One row from `my_collections()`: a collection the signed-in user is approved for. + public class CloudCollectionSummary + { + public string Id; + public string Name; + } + + /// + /// Which of the six local-vs-remote situations + /// found for a proposed local collection folder name. Adapted from the four-boolean matrix + /// (isExistingCollection/isAlreadyTcCollection/isCurrentCollection/isSameCollection) that used + /// to live only inside , per the + /// task 05 brief ("six-scenario matching logic moved from FolderTeamCollection"). + /// + public enum JoinScenario + { + /// No local folder by this name exists yet -- the ordinary case. + FreshJoin, + + /// A local folder by this name exists and is already linked to this exact cloud + /// collection -- joining again is a safe, idempotent reconnect/refresh. + AlreadyJoinedSameCollection, + + /// A local folder by this name exists and is linked to a DIFFERENT cloud + /// collection -- a real name collision, needs a human decision. + LinkedToDifferentCloudCollection, + + /// A local folder by this name exists and is linked to a legacy folder-based Team + /// Collection -- a type mismatch, needs a human decision. + LinkedToFolderTeamCollection, + + /// A local folder by this name exists, is not a Team Collection at all, but its + /// CollectionId happens to already match this cloud collection's id (e.g. it's a local copy + /// that was made before this collection went cloud) -- safe to link up and Receive. + PlainCollectionSameGuid, + + /// A local folder by this name exists, is not a Team Collection, and its + /// CollectionId does not match -- an unrelated collection with a colliding name, needs a + /// human decision. + PlainCollectionDifferentGuid, + } + + /// Thrown by for any scenario that needs a + /// human decision rather than proceeding automatically. Wiring an interactive resolution dialog + /// (the cloud equivalent of 's + /// React dialog) is a UI-layer follow-up outside this task's file ownership; callers today can + /// at least show and ask the user to pick a different + /// local collection name. + public class CloudJoinConflictException : ApplicationException + { + public JoinScenario Scenario { get; } + public string LocalCollectionFolder { get; } + + public CloudJoinConflictException( + JoinScenario scenario, + string localCollectionFolder, + string message + ) + : base(message) + { + Scenario = scenario; + LocalCollectionFolder = localCollectionFolder; + } + } + + /// + /// Drives joining (or creating) a cloud Team Collection: lists the collections the signed-in + /// user is approved for (`my_collections`), resolves the local-vs-remote scenario for a proposed + /// local collection name, and performs the local collection creation + first Receive. See + /// Design/CloudTeamCollections.md's UI-changes summary ("cloud create dialog is sign-in -> + /// confirm immutable name -> initial Send (no folder chooser, no restart)") and CONTRACTS.md's + /// create_collection/my_collections RPCs. + /// + /// Note on collection-level files: a cloud collection's `.bloomCollection` file travels with it + /// in the "other" collection-file group (PutCollectionFiles uploads it as part of + /// RootLevelCollectionFilesIn, exactly like a folder TC's zipped settings) -- so joining does + /// NOT need to fabricate a fresh CollectionSettings/NewCollectionSettings the way + /// NewCollectionWizard does; downloading the "other" group (via the ordinary + /// CopyRepoCollectionFilesToLocal path) brings the real settings file down, the same way + /// FolderTeamCollection's join flow extracts one from the repo's project-files zip. + /// + public class CloudJoinFlow + { + private readonly CloudCollectionClient _client; + + public CloudJoinFlow(CloudCollectionClient client) + { + _client = client; + } + + /// Where a collection with this display name would live locally, mirroring + /// 's own naming + /// convention. + public static string DetermineLocalCollectionFolder(string collectionName) => + Path.Combine(NewCollectionWizard.DefaultParentDirectoryForCollections, collectionName); + + /// + /// Classifies the local-vs-remote situation for joining + /// under the local name would produce, without changing + /// anything on disk. + /// + public JoinScenario DetermineScenario( + string collectionId, + string collectionName, + out string localCollectionFolder + ) + { + localCollectionFolder = DetermineLocalCollectionFolder(collectionName); + if (!Directory.Exists(localCollectionFolder)) + return JoinScenario.FreshJoin; + + var tcLinkPath = TeamCollectionManager.GetTcLinkPathFromLcPath(localCollectionFolder); + if (RobustFile.Exists(tcLinkPath)) + { + var link = TeamCollectionLink.FromFile(tcLinkPath); + if (link != null && link.IsCloud) + return link.CloudCollectionId == collectionId + ? JoinScenario.AlreadyJoinedSameCollection + : JoinScenario.LinkedToDifferentCloudCollection; + return JoinScenario.LinkedToFolderTeamCollection; + } + + var localGuid = CollectionSettings.CollectionIdFromCollectionFolder( + localCollectionFolder + ); + return localGuid == collectionId + ? JoinScenario.PlainCollectionSameGuid + : JoinScenario.PlainCollectionDifferentGuid; + } + + /// + /// Joins : creates (or reuses) the local collection folder, + /// writes TeamCollectionLink.txt, downloads the collection-level files (which include the + /// .bloomCollection file itself) and every book. Throws + /// for any scenario needing a human decision. + /// + public CloudTeamCollection JoinCollection( + string collectionId, + string collectionName, + ITeamCollectionManager manager, + BookCollectionHolder bookCollectionHolder = null, + IWebSocketProgress progress = null + ) + { + // An approved-but-unclaimed member (admin added their EMAIL; members.user_id is + // still NULL) can SEE the collection via my_collections (email match) but is not + // yet a member for RLS purposes: every member-gated RPC the join needs fails with + // not_a_member until claim_memberships() stamps their user_id. Claiming here is + // idempotent and covers the by-design first-contact moment (CONTRACTS.md: claiming + // requires a verified email, which sign-in has already established). + // Found by the first live two-instance smoke test, 7 Jul 2026. + _client.ClaimMemberships(); + + var scenario = DetermineScenario( + collectionId, + collectionName, + out var localCollectionFolder + ); + switch (scenario) + { + case JoinScenario.FreshJoin: + case JoinScenario.PlainCollectionSameGuid: + Directory.CreateDirectory(localCollectionFolder); + break; + case JoinScenario.AlreadyJoinedSameCollection: + break; // just reconnect/refresh below -- idempotent. + case JoinScenario.LinkedToDifferentCloudCollection: + throw new CloudJoinConflictException( + scenario, + localCollectionFolder, + $"There is already a different Team Collection called \"{collectionName}\" on this computer. Please choose a different name, or remove the existing one first." + ); + case JoinScenario.LinkedToFolderTeamCollection: + throw new CloudJoinConflictException( + scenario, + localCollectionFolder, + $"\"{collectionName}\" is already linked to a different (folder-based) Team Collection on this computer. Please choose a different name." + ); + case JoinScenario.PlainCollectionDifferentGuid: + throw new CloudJoinConflictException( + scenario, + localCollectionFolder, + $"There is already a collection called \"{collectionName}\" on this computer. Please choose a different name." + ); + } + + progress?.MessageWithParams( + "JoiningCloudCollection", + "", + "Joining \"{0}\"...", + ProgressKind.Progress, + collectionName + ); + + var linkPath = TeamCollectionManager.GetTcLinkPathFromLcPath(localCollectionFolder); + if (!RobustFile.Exists(linkPath)) + TeamCollectionLink.ForCloud(collectionId).WriteToFile(linkPath); + // Account-switch behavior (batch item 9): record who joined, so a later refusal (if + // Bloom is ever reopened here signed in as a non-member) can name the last known team + // member. Harmless no-op if we don't know the signed-in email for some reason (e.g. + // a test double client with no auth attached). + TeamCollectionLastKnownUser.Record(localCollectionFolder, _client.CurrentUserEmail); + + var cloudTc = new CloudTeamCollection( + manager, + localCollectionFolder, + collectionId, + bookCollectionHolder: bookCollectionHolder + ); + cloudTc.HydrateFromServer(); + cloudTc.CopyRepoCollectionFilesToLocal(localCollectionFolder); + // Batch item 7 (progressive join): open the collection immediately instead of + // blocking here until every book has fully downloaded. Settings and the book list + // (titles) are already available from HydrateFromServer/CopyRepoCollectionFilesToLocal + // above; each repo book is queued for the same background queue SyncAtStartup and + // remote-change auto-apply already use (CollectionApi.HandleBooksRequest shows a + // not-yet-downloaded placeholder for each until its download completes). + foreach (var bookName in cloudTc.GetBookList()) + cloudTc.QueueBookForBackgroundDownload(bookName); + return cloudTc; + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudRepoCache.cs b/src/BloomExe/TeamCollection/Cloud/CloudRepoCache.cs new file mode 100644 index 000000000000..37f8ec31e232 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudRepoCache.cs @@ -0,0 +1,593 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Newtonsoft.Json.Linq; +using SIL.IO; +using SIL.Reporting; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// One book row as reported by the server (tc.books, per CONTRACTS.md's get_collection_state / + /// get_changes shapes: id/instance_id/name/current_version_id/current_version_seq/ + /// current_checksum/locked_by/locked_by_machine/locked_at/deleted_at[/created_at/created_by]). + /// This is a read-through CACHE of server state for cheap synchronous status queries (e.g. + /// rendering the book list's lock icons) — it is never consulted to decide whether a lock or + /// mutation is allowed; every RPC that changes state re-validates against the server's live row + /// regardless of what this says. + /// + public class CloudCachedBook + { + public string Id; // tc.books.id (uuid) + public string InstanceId; + public string Name; + public string CurrentVersionId; // uuid; null until the book's first checkin-finish + public long? CurrentVersionSeq; + public string CurrentChecksum; + public string LockedBy; // email; null when not checked out + public string LockedByMachine; + + /// Which local copy of the collection ("seat", 20260711000003: a stable hash of + /// the local collection folder path) holds the lock. Null = unknown (legacy lock, or one + /// acquired via checkin_start_tx's take-if-free path); a null seat can never be taken + /// over (fail-safe) — see CloudTeamCollection.SeatId and bug #0 in the batch doc. + public string LockedSeat; + public DateTime? LockedAt; + public DateTime? DeletedAt; + public DateTime? CreatedAt; + public string CreatedBy; + + /// Display-friendly resolution of (task 06's + /// 20260707000006 migration: tc.resolve_member_display, joined server-side since + /// LockedBy is the raw auth user id, not an email). Null when not locked or when the + /// server doesn't know a display name (common in dev-auth mode). + public string LockedByEmail; + public string LockedByDisplayName; + + /// + /// The version seq of what is CURRENTLY on this machine's disk for this book, as of the + /// last successful Send or Receive (task 06, CONTRACTS.md's book-status JSON + /// "localVersionSeq"). Null if this book has never been fetched/sent from this machine + /// this cache has known about -- e.g. a book a teammate created that we haven't Received + /// yet. Deliberately NOT touched by (which only carries + /// repo-side truth); only writes it, so + /// it survives repeated polling/hydration untouched until we actually move bytes. + /// + public long? LocalVersionSeq; + + /// + /// The book's current per-file manifest (path → sha256/size/s3VersionId), once known. + /// CONTRACTS.md v1.1 does not yet define an RPC that returns a book's per-file manifest (only + /// the aggregate current_checksum/current_version_seq come back from get_collection_state / + /// get_changes) — see the "contract gap" note in task 04's final report. Callers populate this + /// via whenever they obtain it by some other means + /// (e.g. right after their own checkin-finish, since they already built the manifest to drive + /// the upload). May be null. + /// + public BookVersionManifest Manifest; + + /// Deep-enough copy for handing out of the cache (the manifest itself is treated as + /// immutable once built — see — so sharing the reference is + /// safe). + internal CloudCachedBook Clone() => + new CloudCachedBook + { + Id = Id, + InstanceId = InstanceId, + Name = Name, + CurrentVersionId = CurrentVersionId, + CurrentVersionSeq = CurrentVersionSeq, + CurrentChecksum = CurrentChecksum, + LockedBy = LockedBy, + LockedByMachine = LockedByMachine, + LockedSeat = LockedSeat, + LockedAt = LockedAt, + DeletedAt = DeletedAt, + CreatedAt = CreatedAt, + CreatedBy = CreatedBy, + LockedByEmail = LockedByEmail, + LockedByDisplayName = LockedByDisplayName, + LocalVersionSeq = LocalVersionSeq, + Manifest = Manifest, + }; + + /// Applies the fields present in a server book row (get_collection_state / + /// get_changes shape). Leaves untouched — the server row never carries + /// it (see the field's own doc comment). + internal void ApplyServerRow(JObject row) + { + Id = (string)row["id"]; + InstanceId = (string)row["instance_id"]; + Name = (string)row["name"]; + CurrentVersionId = (string)row["current_version_id"]; + CurrentVersionSeq = (long?)row["current_version_seq"]; + CurrentChecksum = (string)row["current_checksum"]; + LockedBy = (string)row["locked_by"]; + LockedByMachine = (string)row["locked_by_machine"]; + // Present since the 20260711000003 migration; older rows leave it null (= unknown seat). + LockedSeat = (string)row["locked_seat"]; + LockedAt = (DateTime?)row["locked_at"]; + DeletedAt = (DateTime?)row["deleted_at"]; + if (row["created_at"] != null) + CreatedAt = (DateTime?)row["created_at"]; + if (row["created_by"] != null) + CreatedBy = (string)row["created_by"]; + // Present since the 20260707000006 migration; older cached snapshots / server + // versions simply leave these null, which is exactly "no display name available". + LockedByEmail = (string)row["locked_by_email"]; + LockedByDisplayName = (string)row["locked_by_name"]; + } + + internal JObject ToSnapshotJson() => + new JObject + { + ["id"] = Id, + ["instanceId"] = InstanceId, + ["name"] = Name, + ["currentVersionId"] = CurrentVersionId, + ["currentVersionSeq"] = CurrentVersionSeq, + ["currentChecksum"] = CurrentChecksum, + ["lockedBy"] = LockedBy, + ["lockedByMachine"] = LockedByMachine, + ["lockedSeat"] = LockedSeat, + ["lockedAt"] = LockedAt, + ["deletedAt"] = DeletedAt, + ["createdAt"] = CreatedAt, + ["createdBy"] = CreatedBy, + ["lockedByEmail"] = LockedByEmail, + ["lockedByDisplayName"] = LockedByDisplayName, + ["localVersionSeq"] = LocalVersionSeq, + ["manifest"] = Manifest?.ToJson(), + }; + + internal static CloudCachedBook FromSnapshotJson(JObject json) => + new CloudCachedBook + { + Id = (string)json["id"], + InstanceId = (string)json["instanceId"], + Name = (string)json["name"], + CurrentVersionId = (string)json["currentVersionId"], + CurrentVersionSeq = (long?)json["currentVersionSeq"], + CurrentChecksum = (string)json["currentChecksum"], + LockedBy = (string)json["lockedBy"], + LockedByMachine = (string)json["lockedByMachine"], + LockedSeat = (string)json["lockedSeat"], + LockedAt = (DateTime?)json["lockedAt"], + DeletedAt = (DateTime?)json["deletedAt"], + CreatedAt = (DateTime?)json["createdAt"], + CreatedBy = (string)json["createdBy"], + LockedByEmail = (string)json["lockedByEmail"], + LockedByDisplayName = (string)json["lockedByDisplayName"], + LocalVersionSeq = (long?)json["localVersionSeq"], + Manifest = json["manifest"] is JArray filesArray + ? BookVersionManifest.FromJson(filesArray) + : null, + }; + } + + /// One collection-file group's version (tc.collection_file_groups), per CONTRACTS.md's + /// collection-files-start/finish two-phase protocol. + public class CloudCachedCollectionFileGroup + { + public string GroupKey; // 'other' | 'allowed-words' | 'sample-texts' + public long Version; + public DateTime? UpdatedAt; + + internal void ApplyServerRow(JObject row) + { + GroupKey = (string)row["group_key"]; + Version = (long)row["version"]; + UpdatedAt = (DateTime?)row["updated_at"]; + } + + internal JObject ToSnapshotJson() => + new JObject + { + ["groupKey"] = GroupKey, + ["version"] = Version, + ["updatedAt"] = UpdatedAt, + }; + + internal static CloudCachedCollectionFileGroup FromSnapshotJson(JObject json) => + new CloudCachedCollectionFileGroup + { + GroupKey = (string)json["groupKey"], + Version = (long)json["version"], + UpdatedAt = (DateTime?)json["updatedAt"], + }; + } + + /// + /// Thread-safe, persisted cache of one cloud collection's server-reported state: the book/lock/ + /// version map, collection-file group versions, and the `last_seen_event_id` cursor + /// (CONTRACTS.md Realtime section). Makes the synchronous status calls the rest of TeamCollection + /// needs (e.g. "is this book locked, by whom") cheap, and hydrates Disconnected mode from the + /// last snapshot when offline (design doc: "CloudRepoCache ... hydrates Disconnected mode"). + /// Populated ONLY by (a) applying a full/delta snapshot from get_collection_state/get_changes, or + /// (b) write-through from this client's own successful mutating RPC calls. Never trusted to + /// authorize a mutation — every state-changing RPC re-validates against the server's live row. + /// + public class CloudRepoCache + { + /// + /// Filename of the persisted snapshot in the local collection folder. Deliberately not + /// matched by TeamCollection.RootLevelCollectionFilesIn's whitelist (so it's not swept up as + /// a collection-settings file), and — being a plain file, not a folder — automatically + /// invisible to whatever enumerates book subfolders. + /// + public const string SnapshotFileName = ".bloom-cloud-repo-cache.json"; + + private const int CurrentSnapshotVersion = 1; + + private readonly object _gate = new object(); + private readonly Dictionary _booksById = new Dictionary< + string, + CloudCachedBook + >(StringComparer.Ordinal); + private readonly Dictionary _groupsByKey = + new Dictionary(StringComparer.Ordinal); + private long _lastSeenEventId; + + /// Path to this cache's persisted snapshot file (local collection folder + + /// ). + public string SnapshotPath { get; } + + public CloudRepoCache(string localCollectionFolder) + { + SnapshotPath = Path.Combine(localCollectionFolder, SnapshotFileName); + } + + /// + /// The polling/reconnect cursor (CONTRACTS.md Realtime section: "Clients persist + /// last_seen_event_id"): the highest event id incorporated so far. Advances monotonically — + /// a smaller or equal incoming value (e.g. a duplicate or out-of-order response) is ignored. + /// + public long LastSeenEventId + { + get + { + lock (_gate) + return _lastSeenEventId; + } + } + + /// + /// Replaces the ENTIRE book/group map from a full get_collection_state(since_event_id: null) + /// response (`{books:[...], groups:[...], max_event_id}`, all snake_case row fields per the + /// SQL function). Books/groups not present in the snapshot are dropped — a full snapshot is + /// authoritative for "what currently exists" (deleted-but-tombstoned books still appear, with + /// deleted_at set; a book that's gone from the snapshot entirely no longer exists or is no + /// longer visible to this member). Any already-cached + /// is preserved across the swap for books that are still present, since the snapshot itself + /// carries no manifest data (see that field's doc comment). + /// + public void ApplyFullSnapshot(JObject state) + { + lock (_gate) + { + var oldManifests = _booksById.ToDictionary(kv => kv.Key, kv => kv.Value.Manifest); + // LocalVersionSeq is this-machine-only state (never carried in a server row), so it + // must survive the full-snapshot swap for books that still exist -- otherwise a + // resync wipes it, and every already-received book then looks like it has an + // un-downloaded newer version (repoVersionSeq > null), triggering a needless + // re-download. (Mirrors the Manifest carry-over just above.) + var oldLocalVersionSeqs = _booksById.ToDictionary( + kv => kv.Key, + kv => kv.Value.LocalVersionSeq + ); + _booksById.Clear(); + foreach (var row in AsObjectArray(state["books"])) + { + var book = new CloudCachedBook(); + book.ApplyServerRow(row); + if (oldManifests.TryGetValue(book.Id, out var manifest)) + book.Manifest = manifest; + if (oldLocalVersionSeqs.TryGetValue(book.Id, out var localSeq)) + book.LocalVersionSeq = localSeq; + _booksById[book.Id] = book; + } + + _groupsByKey.Clear(); + foreach (var row in AsObjectArray(state["groups"])) + { + var group = new CloudCachedCollectionFileGroup(); + group.ApplyServerRow(row); + _groupsByKey[group.GroupKey] = group; + } + + AdvanceCursorLocked(state["max_event_id"]); + } + } + + /// + /// Merges a delta response — either get_collection_state(since_event_id: N)'s `books` array + /// (only books touched since the cursor; everything else is untouched) or get_changes' + /// `books` (the touched-book rows accompanying its `events` list — the event log itself is + /// History-tab material, out of this cache's scope per the task file). Upserts each row (a + /// book id not already cached is added — e.g. a brand-new book just Sent by a teammate); + /// never removes a book, since unlike a full snapshot a delta doesn't enumerate "everything + /// that still exists" — tombstoning is via `deleted_at`, present on the row itself. + /// + /// true if the delta actually mutated cached state — at least one book row was + /// upserted or the event cursor advanced. false means an idle poll that changed nothing, so + /// the caller can skip persisting/re-indexing (E3). + public bool ApplyDelta(JObject changes) + { + lock (_gate) + { + var changed = false; + foreach (var row in AsObjectArray(changes["books"])) + { + changed = true; + var id = (string)row["id"]; + if (!_booksById.TryGetValue(id, out var book)) + { + book = new CloudCachedBook(); + _booksById[id] = book; + } + book.ApplyServerRow(row); + } + + changed |= AdvanceCursorLocked(changes["max_event_id"]); + return changed; + } + } + + private static IEnumerable AsObjectArray(JToken token) => + (token as JArray)?.OfType() ?? Enumerable.Empty(); + + // Caller must already hold _gate. Returns true if the cursor actually advanced. + private bool AdvanceCursorLocked(JToken maxEventIdToken) + { + if (maxEventIdToken == null || maxEventIdToken.Type == JTokenType.Null) + return false; + var maxEventId = (long)maxEventIdToken; + if (maxEventId > _lastSeenEventId) + { + _lastSeenEventId = maxEventId; + return true; + } + return false; + } + + /// + /// Write-through for a checkout_book RPC result (CONTRACTS.md: `{success, locked_by, + /// locked_by_machine, locked_at}` — present whether or not `success` is true, since a failed + /// checkout still reports who currently holds the lock). Updates the cached lock fields + /// immediately rather than waiting for the next poll/snapshot; never itself decides whether a + /// checkout is allowed (the RPC already did that). + /// + public void RecordCheckoutResult(string bookId, JObject checkoutResult) + { + lock (_gate) + { + var book = GetOrAddLocked(bookId); + book.LockedBy = (string)checkoutResult["locked_by"]; + book.LockedByMachine = (string)checkoutResult["locked_by_machine"]; + book.LockedSeat = (string)checkoutResult["locked_seat"]; + book.LockedAt = (DateTime?)checkoutResult["locked_at"]; + } + } + + /// Write-through for a successful unlock_book/force_unlock RPC: both simply clear + /// the lock (the difference between them is server-side auditing only). + public void RecordUnlock(string bookId) + { + lock (_gate) + { + if (_booksById.TryGetValue(bookId, out var book)) + { + book.LockedBy = null; + book.LockedByMachine = null; + book.LockedSeat = null; + book.LockedAt = null; + } + } + } + + /// + /// Write-through for a successful checkin-finish (CONTRACTS.md: `{versionId, seq}`), plus the + /// manifest the client just committed (it already has this in hand — it built it to drive the + /// upload). Adds the book if this was its first-ever commit (the checkin-start `bookId:null` + /// path) — and are only needed then. + /// + public void RecordCheckinFinish( + string bookId, + string instanceId, + string name, + string versionId, + long versionSeq, + string checksum, + BookVersionManifest manifest, + bool keptCheckedOut, + string lockedByEmail, + string lockedByMachine + ) + { + lock (_gate) + { + var book = GetOrAddLocked(bookId); + if (book.InstanceId == null) + book.InstanceId = instanceId; + if (book.Name == null) + book.Name = name; + book.CurrentVersionId = versionId; + book.CurrentVersionSeq = versionSeq; + book.CurrentChecksum = checksum; + book.Manifest = manifest; + if (keptCheckedOut) + { + book.LockedBy = lockedByEmail; + book.LockedByMachine = lockedByMachine; + } + else + { + book.LockedBy = null; + book.LockedByMachine = null; + book.LockedSeat = null; + book.LockedAt = null; + } + } + } + + /// + /// Write-through: records the version seq now actually present on THIS machine's disk for + /// a book, immediately after a successful Send or Receive (task 06, book-status JSON's + /// "localVersionSeq"). Adds the book row if it isn't cached yet (shouldn't normally happen, + /// since a Send/Receive implies we already know the book id, but mirrors the other + /// write-through methods' defensiveness). + /// + public void RecordLocalVersionSeq(string bookId, long versionSeq) + { + lock (_gate) + { + var book = GetOrAddLocked(bookId); + book.LocalVersionSeq = versionSeq; + } + } + + /// Write-through: record a book's current manifest once obtained by whatever means + /// (see 's doc comment on the contract gap this covers + /// for). No-op if the book id isn't cached yet. + public void RecordManifest(string bookId, BookVersionManifest manifest) + { + lock (_gate) + { + if (_booksById.TryGetValue(bookId, out var book)) + book.Manifest = manifest; + } + } + + /// Write-through for a successful collection-files-finish: bumps one group's cached + /// version (CONTRACTS.md's two-phase collection-files protocol). + public void RecordCollectionFilesFinish(string groupKey, long newVersion) + { + lock (_gate) + { + if (!_groupsByKey.TryGetValue(groupKey, out var group)) + { + group = new CloudCachedCollectionFileGroup { GroupKey = groupKey }; + _groupsByKey[groupKey] = group; + } + group.Version = newVersion; + group.UpdatedAt = DateTime.UtcNow; + } + } + + // Caller must already hold _gate. + private CloudCachedBook GetOrAddLocked(string bookId) + { + if (!_booksById.TryGetValue(bookId, out var book)) + { + book = new CloudCachedBook { Id = bookId }; + _booksById[bookId] = book; + } + return book; + } + + /// Returns a snapshot copy of the cached row for , or null + /// if not cached. A copy, not a live reference, so callers can't mutate cache state by + /// accident and don't need to hold any lock while reading it. + public CloudCachedBook TryGetBook(string bookId) + { + lock (_gate) + { + return _booksById.TryGetValue(bookId, out var book) ? book.Clone() : null; + } + } + + /// Returns a snapshot copy of every cached book row. + public IReadOnlyList GetAllBooks() + { + lock (_gate) + { + return _booksById.Values.Select(b => b.Clone()).ToList(); + } + } + + /// Returns a snapshot copy of the cached version for a collection-file group, or + /// null if not cached. + public CloudCachedCollectionFileGroup TryGetGroup(string groupKey) + { + lock (_gate) + { + return _groupsByKey.TryGetValue(groupKey, out var group) + ? new CloudCachedCollectionFileGroup + { + GroupKey = group.GroupKey, + Version = group.Version, + UpdatedAt = group.UpdatedAt, + } + : null; + } + } + + /// + /// Serializes the full cache to via a staged-temp-then-atomic-swap + /// write, so a crash mid-write never corrupts the previous snapshot (a reader always sees + /// either the complete old file or the complete new one, never a partial one). + /// + public void Save() + { + JObject json; + lock (_gate) + { + json = new JObject + { + ["version"] = CurrentSnapshotVersion, + ["lastSeenEventId"] = _lastSeenEventId, + ["books"] = new JArray(_booksById.Values.Select(b => b.ToSnapshotJson())), + ["groups"] = new JArray(_groupsByKey.Values.Select(g => g.ToSnapshotJson())), + }; + } + + var tempPath = SnapshotPath + ".tmp"; + RobustFile.WriteAllText(tempPath, json.ToString(), Encoding.UTF8); + if (RobustFile.Exists(SnapshotPath)) + RobustFile.Delete(SnapshotPath); + RobustFile.Move(tempPath, SnapshotPath); + } + + /// + /// Loads a previously-saved snapshot from , or + /// returns an empty cache (cursor 0, no books) if none exists yet or it can't be parsed — a + /// missing/corrupt cache file is not fatal, it just means the next full get_collection_state + /// call rebuilds it from scratch. + /// + public static CloudRepoCache LoadOrCreate(string localCollectionFolder) + { + var cache = new CloudRepoCache(localCollectionFolder); + if (!RobustFile.Exists(cache.SnapshotPath)) + return cache; + + try + { + var json = JObject.Parse(RobustFile.ReadAllText(cache.SnapshotPath, Encoding.UTF8)); + cache._lastSeenEventId = (long?)json["lastSeenEventId"] ?? 0; + foreach (var row in AsObjectArray(json["books"])) + { + var book = CloudCachedBook.FromSnapshotJson(row); + cache._booksById[book.Id] = book; + } + foreach (var row in AsObjectArray(json["groups"])) + { + var group = CloudCachedCollectionFileGroup.FromSnapshotJson(row); + cache._groupsByKey[group.GroupKey] = group; + } + } + catch (Exception e) + { + Logger.WriteEvent( + "CloudRepoCache: failed to load snapshot at " + + cache.SnapshotPath + + ": " + + e.Message + ); + } + + return cache; + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudTeamCollection.CollectionFiles.cs b/src/BloomExe/TeamCollection/Cloud/CloudTeamCollection.CollectionFiles.cs new file mode 100644 index 000000000000..e2bc53f6e79c --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudTeamCollection.CollectionFiles.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Bloom.Collection; +using Bloom.MiscUI; +using Newtonsoft.Json.Linq; +using SIL.IO; + +namespace Bloom.TeamCollection.Cloud +{ + public partial class CloudTeamCollection + { + // ------------------------------------------------------------------ + // Collection-level files (bloomCollection/customCollectionStyles.css/configuration.txt/ + // ReaderTools*.json via group "other"; Allowed Words/Sample Texts via their own groups) + // ------------------------------------------------------------------ + + public override void PutCollectionFiles(string[] names) + { + UploadCollectionFileGroup("other", names.ToList(), _localCollectionFolder); + } + + protected override void CopyLocalFolderToRepo(string folderName) + { + var sourceDir = Path.Combine(_localCollectionFolder, folderName); + if (!Directory.Exists(sourceDir)) + return; + var groupKey = MapFolderNameToGroupKey(folderName); + var relativeNames = Directory + .EnumerateFiles(sourceDir) + .Select(Path.GetFileName) + .ToList(); + UploadCollectionFileGroup(groupKey, relativeNames, sourceDir); + } + + private static string MapFolderNameToGroupKey(string folderName) => + folderName switch + { + "Allowed Words" => "allowed-words", + "Sample Texts" => "sample-texts", + _ => "other", + }; + + /// + /// collection-files-start/finish, the two-phase protocol CONTRACTS.md describes as "like + /// check-in" for one collection-file group. The exact response shape isn't spelled out in + /// CONTRACTS.md beyond "finish bumps the group version atomically"; this assumes a + /// checkin-start-like `{transactionId, s3}` / `{version}` shape -- flagged as a contract + /// ambiguity in the task 05 final report. + /// + private void UploadCollectionFileGroup( + string groupKey, + List relativeFileNames, + string sourceFolder + ) + { + var files = new List<(string path, string sha256, long size)>(); + foreach (var name in relativeFileNames) + { + var fullPath = Path.IsPathRooted(name) ? name : Path.Combine(sourceFolder, name); + if (!RobustFile.Exists(fullPath)) + continue; + var (sha256, size) = BookVersionManifest.ComputeFileHash(fullPath); + files.Add((Path.GetFileName(fullPath), sha256, size)); + } + if (files.Count == 0) + return; + + var filesJson = new JArray( + files.Select(f => + (JToken) + new JObject + { + ["path"] = f.path, + ["sha256"] = f.sha256, + ["size"] = f.size, + } + ) + ); + var expectedVersion = _cache.TryGetGroup(groupKey)?.Version ?? 0; + var startResult = _client.CollectionFilesStart( + _collectionId, + groupKey, + expectedVersion, + filesJson + ); + var transactionId = (string)startResult["transactionId"]; + var location = ParseS3Location(startResult); + // Hand the hashes we just computed (for the start call's files json) to the transfer + // so it doesn't hash every file a second time. + var localGroupHashes = new Dictionary(); + foreach (var f in files) + localGroupHashes[BookVersionManifest.NormalizePath(f.path)] = + new BookVersionManifestEntry(f.sha256, f.size); + _transfer.UploadChangedFiles( + location, + sourceFolder, + files.Select(f => f.path), + null, + new BookVersionManifest(localGroupHashes), + 4, + null, + CancellationToken.None + ); + var finishResult = _client.CollectionFilesFinish(transactionId); + var newVersion = (long?)(finishResult?["version"]) ?? (expectedVersion + 1); + _cache.RecordCollectionFilesFinish(groupKey, newVersion); + _cache.Save(); + } + + protected override void CopyRepoCollectionFilesToLocalImpl(string destFolder) + { + _collectionLock.UnlockFor(() => DownloadCollectionFileGroup("other", destFolder)); + DownloadCollectionFileGroup("allowed-words", Path.Combine(destFolder, "Allowed Words")); + DownloadCollectionFileGroup("sample-texts", Path.Combine(destFolder, "Sample Texts")); + } + + /// + /// Brings one collection-file group's local folder up to date with the repo. Fetches the + /// group's committed per-file manifest (path, sha256, size, s3VersionId) via + /// get_collection_file_manifest and downloads only the files whose content changed, each + /// pinned to its committed S3 version-id -- so this reads a CONSISTENT snapshot and never + /// re-downloads unchanged files (E9). DownloadFiles does the hash-skip + pinned/verified + /// transfer (the same path book content uses); files the manifest no longer lists are + /// removed by the delete-extras pass. A never-written group has an empty manifest, so the + /// group is left with only the delete-extras cleanup. + /// + private void DownloadCollectionFileGroup(string groupKey, string destFolder) + { + try + { + var manifestResponse = _client.GetCollectionFileManifest(_collectionId, groupKey); + var manifest = manifestResponse?["files"] is JArray filesArray + ? BookVersionManifest.FromJson(filesArray) + : new BookVersionManifest(); + + var collectionLocation = GetCollectionDownloadLocation(); + var location = new CloudS3Location + { + Bucket = collectionLocation.Bucket, + Region = collectionLocation.Region, + Prefix = $"{collectionLocation.Prefix}collectionFiles/{groupKey}/", + AccessKeyId = collectionLocation.AccessKeyId, + SecretAccessKey = collectionLocation.SecretAccessKey, + SessionToken = collectionLocation.SessionToken, + ExpiresAtUtc = collectionLocation.ExpiresAtUtc, + }; + + Directory.CreateDirectory(destFolder); + var pinnedFiles = manifest + .Entries.Select(kvp => new PinnedFileDownload + { + RelativePath = kvp.Key, + S3VersionId = kvp.Value.S3VersionId, + ExpectedSha256Hex = kvp.Value.Sha256, + ExpectedSize = kvp.Value.Size, + }) + .ToList(); + if (pinnedFiles.Count > 0) + _transfer.DownloadFiles( + location, + pinnedFiles, + destFolder, + 4, + null, + CancellationToken.None + ); + + // Mirrors FolderTeamCollection's ExtractFolder "delete extras" behavior -- + // but see FilesEligibleForDeleteExtras: for the "other" group the destination + // is the COLLECTION ROOT, which also holds files that are deliberately never + // shared (TeamCollectionLink.txt itself, the repo cache, sync bookkeeping, + // logs, PDFs...). A naive mirror-delete stripped TeamCollectionLink.txt on + // every join/Receive, silently un-teaming the collection on next open (found + // by the first two-instance smoke test, 7 Jul 2026). + var keptFileNames = new HashSet(manifest.Entries.Keys); + foreach ( + var doomed in FilesEligibleForDeleteExtras(groupKey, destFolder, keptFileNames) + ) + { + RobustFile.Delete(doomed); + } + } + catch (Exception e) + { + NonFatalProblem.Report( + ModalIf.None, + PassiveIf.All, + $"Bloom could not download the '{groupKey}' collection files from the Team Collection.", + exception: e + ); + } + } + + /// + /// Which local files the "delete extras" step of a collection-file-group download may + /// remove: files present locally, absent from the server's group, AND belonging to the + /// group's own domain. For allowed-words/sample-texts the destination folder belongs + /// entirely to the group, so every file qualifies. For "other" the destination is the + /// collection ROOT, so deletion is limited to the same shareable-file allowlist the + /// upload side uses () — anything + /// else there (TeamCollectionLink.txt, .bloom-cloud-repo-cache.json, log.txt, + /// lastCollectionFileSyncData.txt, generated PDFs...) is local-only and untouchable. + /// Internal static + pure so tests can pin the policy without an S3 server. + /// + internal static List FilesEligibleForDeleteExtras( + string groupKey, + string destFolder, + HashSet keptFileNames + ) + { + var candidates = Directory + .EnumerateFiles(destFolder) + .Where(path => !keptFileNames.Contains(Path.GetFileName(path))); + if (groupKey == "other") + { + var shareable = new HashSet(RootLevelCollectionFilesIn(destFolder)); + candidates = candidates.Where(path => shareable.Contains(Path.GetFileName(path))); + } + return candidates.ToList(); + } + + protected override DateTime LastRepoCollectionFileModifyTime + { + get + { + EnsureCacheHydrated(); + var times = new[] { "other", "allowed-words", "sample-texts" } + .Select(g => _cache.TryGetGroup(g)?.UpdatedAt) + .Where(t => t.HasValue) + .Select(t => t.Value) + .ToList(); + return times.Count == 0 ? DateTime.MinValue : times.Max(); + } + } + + /// + /// Approximates the repo colorPalettes.json modify time with the "other" group's whole-group + /// UpdatedAt, since CONTRACTS.md tracks collection-file freshness at group granularity, not + /// per-file -- see 's note on the same limitation. + /// + protected override DateTime GetRepoColorPaletteTime() + { + EnsureCacheHydrated(); + return _cache.TryGetGroup("other")?.UpdatedAt ?? DateTime.MinValue; + } + + /// + /// Pushes local color palette additions up via add_palette_colors' union merge. CONTRACTS.md + /// defines no RPC to read back a collection's full merged palette state (add_palette_colors + /// is write-only), so this is push-only for now: local additions reach the repo, but a + /// teammate's additions only reach us via the ordinary "other" group download (whole-file + /// replace, not merge) in -- flagged as a + /// contract gap (a `get_palette_colors` RPC is needed for a true two-way merge) in the task + /// 05 final report. + /// + protected override void SyncColorPaletteFileWithRepo(string localFolder) + { + try + { + var colorPaletteFile = Path.Combine(localFolder, "colorPalettes.json"); + if (!RobustFile.Exists(colorPaletteFile)) + return; + var localPalettes = new Dictionary(); + CollectionSettings.LoadColorPalettesFromJsonFile(localPalettes, colorPaletteFile); + foreach (var kvp in localPalettes) + { + var colors = kvp.Value?.Split( + new[] { ' ' }, + StringSplitOptions.RemoveEmptyEntries + ); + if (colors == null || colors.Length == 0) + continue; + _client.AddPaletteColors(_collectionId, kvp.Key, colors); + } + } + catch (Exception e) + { + NonFatalProblem.ReportSentryOnly(e); + } + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudTeamCollection.cs b/src/BloomExe/TeamCollection/Cloud/CloudTeamCollection.cs new file mode 100644 index 000000000000..5905191f0335 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudTeamCollection.cs @@ -0,0 +1,1983 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Windows.Forms; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Bloom.Book; +using Bloom.Collection; +using Bloom.MiscUI; +using Bloom.WebLibraryIntegration; +using Newtonsoft.Json.Linq; +using SIL.IO; +using SIL.Reporting; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// TeamCollection backend implementation for cloud-hosted (S3 + Supabase) Team Collections. + /// See Design/CloudTeamCollections.md (architecture) and CONTRACTS.md (wire contracts). + /// + /// Design summary: every abstract member that reads repo state reads from a locally-persisted + /// (populated by an initial call + /// and kept current by 's polling); every member that + /// changes repo state calls a RPC/edge function directly and + /// then write-throughs the result into the cache so subsequent reads are immediately consistent + /// without waiting for the next poll. Most abstract members are keyed by book folder *name*, + /// while the cache (and the server) key everything by the immutable server book id, so this + /// class maintains a name/instanceId -> id index alongside the cache. + /// + public partial class CloudTeamCollection : TeamCollection + { + private readonly string _collectionId; + private readonly CloudEnvironment _environment; + private readonly CloudAuth _auth; + private readonly CloudCollectionClient _client; + private readonly CloudBookTransfer _transfer; + private readonly CloudRepoCache _cache; + private readonly CollectionLock _collectionLock; + private CloudCollectionMonitor _monitor; + private bool _hydrated; + + // The account (email) claim_memberships was last called for (see CheckConnection): + // claiming converts an approved-by-email membership row into a claimed (user_id-filled) + // one that every data RPC's RLS gate accepts. Keyed by EMAIL, not a once-per-session + // bool (preflight review finding, 10 Jul 2026): this instance survives an in-session + // sign-out + sign-in as a DIFFERENT approved member (nothing resets it on an + // account switch), and a stale "already claimed" bool would skip claiming for + // the new account -- resurrecting the not_a_member startup failure this field exists to + // prevent. + private string _membershipsClaimedForEmail; + + // Most TeamCollection abstract members are keyed by book folder *name*; the cache (and the + // server) key everything by the immutable server "books.id". These two indexes translate. + // Guarded by _indexGate rather than being rebuilt from the (already thread-safe) cache on + // every lookup, since several base-class code paths call GetStatus/IsBookPresentInRepo/etc. + // in tight loops (e.g. SyncAtStartup). + private readonly object _indexGate = new object(); + private readonly Dictionary _bookIdByName = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + private readonly Dictionary _bookIdByInstanceId = new Dictionary< + string, + string + >(StringComparer.Ordinal); + + // Cache of local book folders' meta.json instance ids, keyed by folder name and + // revalidated against meta.json's write time + size (bug #15): ResolveBookId consults + // the local identity on every repo lookup, and status lookups run in tight per-book + // loops (SyncAtStartup, book-button rendering), so an uncached read would hammer disk. + private readonly Dictionary< + string, + (string instanceId, long writeTimeTicks, long fileLength) + > _localInstanceIdCache = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + + // Per-(book,file) cache of single-file repo fetches (GetRepoBookFile), valid for this + // process's lifetime -- avoids re-downloading e.g. meta.json for the same book repeatedly + // when SyncAtStartup's rename/id-conflict scan visits every book once per pass. + private readonly ConcurrentDictionary _repoFileCache = + new ConcurrentDictionary(); + + private const int kMaxNameConflictRetries = 10; + + /// Server-side event-type numeric for the "work preserved locally" incident. + /// Sourced from the shared enum so client and server stay in sync via one definition. + private const int kWorkPreservedLocallyEventType = (int) + Bloom.History.BookHistoryEventType.WorkPreservedLocally; + + /// This empty constructor allows the class to be mocked (matches + /// 's own pattern). + public CloudTeamCollection() + { + System.Diagnostics.Debug.Assert(Program.RunningUnitTests); + } + + public CloudTeamCollection( + ITeamCollectionManager manager, + string localCollectionFolder, + string collectionId, + TeamCollectionMessageLog tcLog = null, + BookCollectionHolder bookCollectionHolder = null, + CollectionLock collectionLock = null, + CloudEnvironment environment = null, + CloudAuth auth = null, + CloudCollectionClient client = null, + CloudBookTransfer transfer = null + ) + : base(manager, localCollectionFolder, tcLog, bookCollectionHolder) + { + _collectionId = collectionId; + CollectionId = collectionId; + _collectionLock = collectionLock ?? new CollectionLock(); + _environment = environment ?? CloudEnvironment.Current; + // Only when WE create the default auth (no auth was injected) do we also initialize + // it at startup. This fixes a real gap: TeamCollectionManager.CreateCloudTeamCollection + // (the "open an already-joined cloud collection" path, e.g. on ordinary Bloom startup) + // passes no auth, so without this the session would never pick up + // BLOOM_CLOUDTC_USER/PASSWORD or a stored token, and the collection would always open + // signed out. Callers that inject their own CloudAuth (ConnectToCloudCollection, which + // already calls InitializeAtStartup itself before constructing us; every unit test) + // are unaffected -- they own their auth's lifecycle already. + if (auth == null) + auth = CloudAuth.CreateInitialized(_environment); + _auth = auth; + _client = client ?? new CloudCollectionClient(_environment, _auth); + _transfer = transfer ?? new CloudBookTransfer(); + _cache = CloudRepoCache.LoadOrCreate(localCollectionFolder); + RefreshIndexFromCache(); + } + + /// The server-side collections.id this instance talks to (same value the UI's + /// useCloudCollectionId hook fetches; the base CollectionId field carries it too). + public string CloudCollectionId => _collectionId; + + /// In a cloud TC the identity that owns checkouts is the signed-in ACCOUNT + /// (the server stamps locks from the auth token), not Bloom's registration email. + /// Falls back to the base (registration) identity when signed out, where nothing can + /// be checked out here anyway. See the base property's doc for the smoke-test failure + /// this fixes (own checkout uneditable). + protected internal override string CurrentUserIdentity => + _auth?.CurrentEmail ?? base.CurrentUserIdentity; + + private string _currentUserDisplayName; + + // The account email _currentUserDisplayName was resolved for. Keying the cache by email + // (rather than a boolean) makes an ACCOUNT SWITCH (batch item 9: sign out, different + // member signs in, same instance) invalidate it automatically -- a boolean flag kept + // serving the PREVIOUS member's display name for the new member's new local books. + private string _currentUserDisplayNameForEmail; + + // Failure back-off for the display-name lookup (also keyed by email, for the same + // account-switch reason as above): when MembersList throws (offline, server down), don't + // re-attempt the network call for this TTL. Without it, every status render retried the + // lookup immediately -- a timeout storm while offline. 30s keeps the avatar self-healing + // soon after connectivity returns while being far longer than any render burst. + internal static readonly TimeSpan DisplayNameFailureRetryTtl = TimeSpan.FromSeconds(30); + private string _displayNameLookupFailedForEmail; + private DateTime _displayNameLookupFailedAtUtc; + + /// + /// The signed-in account's admin-editable display name (tc.members.display_name), e.g. + /// "Alice Admin", or null when signed out / not yet resolvable. Used so a brand-new + /// local book's avatar shows the same initials + full name as a real checkout by this + /// user (dogfood bug: new local books showed a single-letter, email-tooltip avatar while + /// real checkouts showed "AA"/"Alice Admin"). Resolved lazily from MembersList and cached + /// per signed-in email; a failed resolve is retried, but only after + /// (a per-call retry made every status render + /// pay a fresh network timeout while offline). Not refreshed after a successful + /// resolve while the same account stays signed in -- display-name changes are rare and + /// the avatar is cosmetic. Callers fall back to the email when this is null, matching + /// prior behavior. + /// + protected internal virtual string CurrentUserDisplayName + { + get + { + var email = _auth?.CurrentEmail; + if (string.IsNullOrEmpty(email)) + return null; + if ( + string.Equals( + _currentUserDisplayNameForEmail, + email, + StringComparison.OrdinalIgnoreCase + ) + ) + return _currentUserDisplayName; + if ( + string.Equals( + _displayNameLookupFailedForEmail, + email, + StringComparison.OrdinalIgnoreCase + ) + && DateTime.UtcNow - _displayNameLookupFailedAtUtc < DisplayNameFailureRetryTtl + ) + return null; // recent failure; don't hammer the server (see the TTL field's doc) + try + { + string resolved = null; + foreach (var member in _client.MembersList(CollectionId).OfType()) + { + if ( + string.Equals( + (string)member["email"], + email, + StringComparison.OrdinalIgnoreCase + ) + ) + { + resolved = (string)member["display_name"]; + break; + } + } + _currentUserDisplayName = resolved; + _currentUserDisplayNameForEmail = email; + _displayNameLookupFailedForEmail = null; + } + catch + { + // Network/permission hiccup; leave unresolved so a later call can retry -- + // but not before the TTL elapses (timeout-storm guard). + _displayNameLookupFailedForEmail = email; + _displayNameLookupFailedAtUtc = DateTime.UtcNow; + return null; + } + return _currentUserDisplayName; + } + } + + // ------------------------------------------------------------------ + // Accessors for task 06 (SharingApi / TeamCollectionApi): thin, read-only exposure of + // this collection's own auth/client/cache state, so those API classes can be simple + // pass-throughs instead of duplicating Cloud-backend business logic. Mirrors the existing + // pattern of TeamCollectionApi downcasting to FolderTeamCollection for folder-specific + // members (e.g. GetPathToBookFileInRepo). + // ------------------------------------------------------------------ + + /// The auth session actually driving this collection's own RPC/edge-function + /// calls -- the single source of truth for "am I signed in" while this collection is the + /// open one (see SharingApi's CurrentAuth/CurrentClient helpers). + public CloudAuth Auth => _auth; + + /// The RPC/edge-function client this collection uses for everything -- exposed so + /// SharingApi can call collection-scoped RPCs (members list/add/remove/setRole, force + /// unlock, history) without this class needing to grow business logic for them. + public CloudCollectionClient Client => _client; + + /// Test-only: resolve a book folder name to its server book id (null if unknown). + internal string GetBookIdByNameIndexForTests(string bookFolderName) => + TryGetBookId(bookFolderName); + + /// The version seq of what's currently on THIS machine's disk for a book, or null + /// if never Sent/Received here (book-status JSON's "localVersionSeq"). + public long? GetLocalVersionSeq(string bookFolderName) => + ResolveCachedBook(bookFolderName)?.LocalVersionSeq; + + /// The latest version seq known to be in the repo for a book, or null if the book + /// isn't cached at all (book-status JSON's "repoVersionSeq"). + public long? GetRepoVersionSeq(string bookFolderName) => + ResolveCachedBook(bookFolderName)?.CurrentVersionSeq; + + /// The server book id for a book folder name, or null if unknown -- lets + /// SharingApi's history endpoint match a "current book only" filter (Bloom's BookInfo has + /// no notion of the server's tc.books.id) without duplicating this class's private + /// name/instanceId -> id index. + public string TryGetBookIdForHistoryFilter(string bookFolderName) => + ResolveBookId(bookFolderName); + + /// + /// Count of live books whose repo version is newer than what's on this machine -- drives + /// the status button's "Updates Available (N books)" metadata. A book this machine has + /// never Received (LocalVersionSeq null) counts too: from this machine's point of view it + /// equally needs a Receive before it's current. + /// + /// This must count EXACTLY the books would receive, so the + /// two never disagree: a book checked out by ANOTHER user still counts (John, 16 Jul 2026) + /// -- a reviewer legitimately wants the latest checked-in version even though they can't + /// edit it, and it would be wrong for the badge to switch off (without the update ever + /// being received) merely because someone else took the book out. Only a book checked out + /// HERE is excluded -- the same book ReceiveAllUpdates skips, because receiving would + /// clobber local edits. "Checked out here" is computed via the identical + /// StatusFromCachedBook + IsCheckedOutHereBy path ReceiveAllUpdates uses, so they can't drift. + /// + public int GetUpdatesAvailableCount() + { + EnsureCacheHydrated(); + return _cache + .GetAllBooks() + .Count(b => + !b.DeletedAt.HasValue + && b.CurrentVersionSeq.HasValue + && !IsCheckedOutHereBy(StatusFromCachedBook(b, _collectionId)) + && (b.LocalVersionSeq ?? -1) < b.CurrentVersionSeq.Value + ); + } + + /// + /// The body of the "Sync" / Receive-Updates button (see + /// TeamCollectionApi.HandleReceiveUpdates): polls once, then downloads every book whose + /// repo version is newer than the local copy EXCEPT books checked out here (receiving + /// would clobber local edits). Any locally-modified copy is preserved before it is + /// overwritten (batch item 8). Returns (booksReceived, booksSkippedBecauseCheckedOutHere) + /// for the caller's progress/analytics summary. + /// + /// NOTE (flagged for John, 16 Jul 2026 file-org pass): the "which books" test here skips + /// only books checked out HERE, whereas above + /// excludes books locked by ANYONE -- so a book locked by another user counts as "no + /// update available" in the badge yet is still received by this loop. The two predicates + /// are deliberately NOT unified in this pure code-move; whether they SHOULD agree is a + /// separate behavior decision. + /// + public (int received, int skippedCheckedOutHere) ReceiveAllUpdates( + Bloom.web.IWebSocketProgress progress + ) + { + PollNow(); + var receivedCount = 0; + var skippedCheckedOutCount = 0; + foreach (var bookName in GetBookList()) + { + var status = GetStatus(bookName); + if (IsCheckedOutHereBy(status)) + { + skippedCheckedOutCount++; + continue; // Checked out here; Receive would conflict with local edits. + } + var repoSeq = GetRepoVersionSeq(bookName); + var localSeq = GetLocalVersionSeq(bookName); + if (!repoSeq.HasValue || (localSeq ?? -1) >= repoSeq.Value) + continue; // Already current. + progress.MessageWithoutLocalizing($"Receiving updates for '{bookName}'..."); + // Batch item 8: same preserve-before-overwrite guard as the auto-apply worker -- + // Sync must never silently discard local content that changed since the last sync. + PreserveLocalCopyIfModifiedSinceLastSync(bookName); + CopyBookFromRepoToLocal(bookName, dialogOnError: false); + receivedCount++; + } + return (receivedCount, skippedCheckedOutCount); + } + + // ------------------------------------------------------------------ + // Capability flags / simple identity members + // ------------------------------------------------------------------ + + public override string GetBackendType() => "Cloud"; + + public override string RepoDescription => $"cloud://sil.bloom/collection/{_collectionId}"; + + public override bool SupportsVersionHistory => true; + + public override bool SupportsSharingUi => true; + + public override bool RequiresSignIn => true; + + /// + /// Cloud Team Collections apply safe remote book changes automatically (batch item 4+5, + /// decision 9 Jul 2026): a poll that notices a checkin for a book that isn't checked out + /// here downloads and swaps it in without the user having to click anything, instead of + /// just showing a "click to get updates" message. See TeamCollection.HandleModifiedFile and + /// ProcessAutoApplyRemoteChange for the actual logic; this flag is the only thing that + /// differs from a folder Team Collection. + /// + protected override bool CanAutoApplyRemoteChanges => true; + + /// + /// Batch item 8 (John's recovery decision, 9 Jul 2026): before a sync overwrites a local + /// book copy that changed since the last sync, zip it to Lost and Found as a .bloomSource + /// and log the WorkPreservedLocally incident -- then the overwrite proceeds, making local + /// consistent with the Team Collection without silently losing anything. + /// + protected override void PreserveLocalCopyForRecoveryBeforeOverwrite(string bookFolderName) + { + SaveLocalCopyForRecovery( + Path.Combine(_localCollectionFolder, bookFolderName), + bookFolderName, + "LocalChangesOverwrittenBySync" + ); + } + + // ------------------------------------------------------------------ + // Cache hydration (get_collection_state) and the name/instanceId <-> id index + // ------------------------------------------------------------------ + + /// + /// Ensures the cache has been populated at least once this session. Cheap after the first + /// call (a plain bool check); the actual refresh is a synchronous network call, matching how + /// the base class's own repo-reading abstract members are documented as needing to be + /// synchronous and thread-safe. + /// + private void EnsureCacheHydrated() + { + if (_hydrated) + return; + HydrateFromServer(); + } + + /// + /// Fetches the full snapshot (first call / cursor 0) or a delta (subsequent calls) from + /// get_collection_state and applies it to the cache. Called at startup, after every + /// mutating call whose response doesn't carry enough information to write-through directly + /// (see PutBookInRepo's "checkin-start/finish don't return the server book id" note), and + /// can be called by after creating/joining a collection. + /// + internal void HydrateFromServer() + { + var sinceEventId = _cache.LastSeenEventId; + var state = + sinceEventId > 0 + ? _client.GetCollectionState(_collectionId, sinceEventId) + : _client.GetCollectionState(_collectionId); + if (state == null) + { + // A 2xx response with an empty body -- not an error the client maps (those throw), + // but not the contract shape either. Don't fail here (callers run on background + // threads and the poll will re-try), but never be SILENT about it: an empty cache + // wrongly marked hydrated makes every IsBookPresentInRepo answer false, which + // silently drops queued background downloads. + Logger.WriteEvent( + $"CloudTeamCollection: get_collection_state returned no data (since_event_id={sinceEventId}); cache left as-is with {_cache.GetAllBooks().Count()} book(s)." + ); + _hydrated = true; + return; + } + if (sinceEventId > 0) + _cache.ApplyDelta(state); + else + _cache.ApplyFullSnapshot(state); + _cache.Save(); + RefreshIndexFromCache(); + _hydrated = true; + } + + private void RefreshIndexFromCache() + { + lock (_indexGate) + { + _bookIdByName.Clear(); + _bookIdByInstanceId.Clear(); + foreach (var book in _cache.GetAllBooks()) + { + if (!string.IsNullOrEmpty(book.Name)) + _bookIdByName[book.Name] = book.Id; + if (!string.IsNullOrEmpty(book.InstanceId)) + _bookIdByInstanceId[book.InstanceId] = book.Id; + } + } + } + + /// Resolve a REPO book name (e.g. one returned by GetBookList) to its server book + /// id. This is pure name-index lookup; for anything that denotes a LOCAL book folder, use + /// , which resolves by the folder's own instance id. + private string TryGetBookId(string bookName) + { + lock (_indexGate) + { + return _bookIdByName.TryGetValue(bookName, out var id) ? id : null; + } + } + + /// + /// Resolve a book folder name to its server book id, IDENTITY FIRST (bug #15; John's + /// ruling, 13 Jul 2026: "the status of a particular record by instanceID in the database + /// is the source of truth for that book's state"). When a local folder exists, its + /// meta.json bookInstanceId -- never its folder name -- decides which server row (if any) + /// is this book. That keeps (a) a checked-out book that was renamed locally bound to its + /// own row until check-in carries the rename to the server, and (b) a local book that + /// merely shares a NAME with some other checked-in book (e.g. created offline while a + /// teammate checked in an unrelated book of the same name) from wearing that book's + /// status. A local folder whose id cannot be read resolves to null ("not in the repo") + /// rather than guessing by name -- fail-safe, degrading to "local-only book". The name + /// index is consulted only when there is NO local folder, i.e. the caller is asking about + /// a repo book (typically a name from GetBookList). + /// + private string ResolveBookId(string bookFolderName) + { + var folderPath = Path.Combine(_localCollectionFolder, bookFolderName); + if (Directory.Exists(folderPath)) + { + var instanceId = TryGetLocalBookInstanceId(bookFolderName); + return instanceId == null ? null : TryGetBookIdByInstanceId(instanceId); + } + return TryGetBookId(bookFolderName); + } + + private CloudCachedBook ResolveCachedBook(string bookFolderName) + { + var id = ResolveBookId(bookFolderName); + return id == null ? null : _cache.TryGetBook(id); + } + + /// Test-only: exposes 's identity-first semantics. + internal string ResolveBookIdForTests(string bookFolderName) => + ResolveBookId(bookFolderName); + + /// + /// Identity-exact remote-rename detection (bug #18). The base heuristic ("a local folder + /// with repo status can't be the rename source") assumes name-keyed status and is + /// INVERTED under this class's identity-first resolution: after a teammate's rename, the + /// old-name local folder resolves (by instance id) to the renamed repo row -- having + /// status is precisely what marks it as the rename source. Instead compare instance ids + /// directly: the repo book named is a rename of whichever + /// local folder carries the same meta.json id under a different name. Without this, + /// the receiving side downloaded the renamed book as a NEW book next to the old-name + /// folder -- two local folders with one instance id (both "selected" at once, phantom + /// checkout displays -- John's live report, 13 Jul 2026). + /// + protected internal override string NewBookRenamedFrom(string newBookName) + { + // A one-off call builds the index and throws it away; the per-poll bulk path uses the + // ref-scanState override below so it builds the index only once for the whole scan. + object scanState = null; + return NewBookRenamedFrom(newBookName, ref scanState); + } + + /// + /// Bulk-scan override for QueueMissingRepoBooksForBackgroundDownload. Finding the local + /// folder that shares a repo book's instance id means enumerating every local folder; the + /// per-book method does that once per missing book, which is O(missing x local) disk stats + /// per poll -- wasteful during a progressive join of a large collection (E7). Here we build + /// the instanceId -> folder index ONCE (the first missing book that needs it) and reuse it + /// for the rest of the pass, collapsing it to O(missing + local). scanState is a + /// caller-owned local (see the base pass), so it is rebuilt fresh each poll -- never stale + /// across polls -- and touched only by the polling thread. + /// + protected internal override string NewBookRenamedFrom( + string newBookName, + ref object scanState + ) + { + EnsureCacheHydrated(); + // Repo-name lookup on purpose: the caller is asking about a repo book's name. + var instanceId = TryGetBookInstanceIdForName(newBookName); + if (string.IsNullOrEmpty(instanceId)) + return null; + var index = + scanState as Dictionary + ?? (Dictionary)(scanState = BuildLocalInstanceIdToFolder()); + return + index.TryGetValue(instanceId, out var folder) + && !string.Equals(folder, newBookName, StringComparison.OrdinalIgnoreCase) + ? folder + : null; + } + + /// + /// Maps each local book folder's meta.json instance id to its folder name, by enumerating + /// the collection folder once. Per-folder id reads go through , so they hit the timestamp/size-validated cache. On + /// the (pathological, bug #18) case of two local folders sharing one id, first-enumerated + /// wins -- matching the per-book method's original first-match-returns loop. + /// + private Dictionary BuildLocalInstanceIdToFolder() + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var path in Directory.EnumerateDirectories(_localCollectionFolder)) + { + var folderName = Path.GetFileName(path); + var instanceId = TryGetLocalBookInstanceId(folderName); + if (!string.IsNullOrEmpty(instanceId) && !map.ContainsKey(instanceId)) + map[instanceId] = folderName; + } + return map; + } + + /// The meta.json bookInstanceId of the LOCAL folder, or null when the folder has + /// no readable id. Cached per folder name, revalidated by meta.json timestamp+size (the + /// id itself never legitimately changes in place, but the folder a name points at can -- + /// delete + recreate, rename shuffles). + private string TryGetLocalBookInstanceId(string bookFolderName) + { + try + { + var metaInfo = new FileInfo( + Path.Combine(_localCollectionFolder, bookFolderName, "meta.json") + ); + if (!metaInfo.Exists) + return null; + var writeTimeTicks = metaInfo.LastWriteTimeUtc.Ticks; + var fileLength = metaInfo.Length; + lock (_indexGate) + { + if ( + _localInstanceIdCache.TryGetValue(bookFolderName, out var cached) + && cached.writeTimeTicks == writeTimeTicks + && cached.fileLength == fileLength + ) + return cached.instanceId; + } + var instanceId = GetBookId(bookFolderName); // base helper: meta.json's Id + lock (_indexGate) + _localInstanceIdCache[bookFolderName] = ( + instanceId, + writeTimeTicks, + fileLength + ); + return instanceId; + } + catch (Exception) + { + // Unreadable folder/meta.json = unknown identity; callers treat that as + // "not a repo book" rather than misbinding by name. + return null; + } + } + + private string TryGetBookIdByInstanceId(string instanceId) + { + if (string.IsNullOrEmpty(instanceId)) + return null; + lock (_indexGate) + { + return _bookIdByInstanceId.TryGetValue(instanceId, out var id) ? id : null; + } + } + + private CloudCachedBook TryGetCachedBook(string bookName) + { + var id = TryGetBookId(bookName); + return id == null ? null : _cache.TryGetBook(id); + } + + /// + /// Batch item 7 (progressive join): the stable per-book instance id (matches BookInfo.Id + /// once the book is actually downloaded) for a repo book, keyed by its folder name. Used + /// by CollectionApi's HandleBooksRequest merge to give a not-yet-downloaded placeholder + /// entry a client-visible id that stays the same across the download, so React doesn't + /// remount the book button when the placeholder swaps for the real one. + /// + public string TryGetBookInstanceIdForName(string bookName) + { + EnsureCacheHydrated(); + return TryGetCachedBook(bookName)?.InstanceId; + } + + /// + /// Batch item 7 (progressive join): the reverse of + /// -- resolves a not-yet-downloaded placeholder's client-visible id back to its repo book + /// folder name. Used by CollectionApi's selected-book handler to find which book to + /// prioritize when the user clicks a placeholder (there is no local BookInfo to look it up + /// by folder name the normal way). + /// + public string TryGetBookNameForInstanceId(string instanceId) + { + EnsureCacheHydrated(); + var bookId = TryGetBookIdByInstanceId(instanceId); + return bookId == null ? null : _cache.TryGetBook(bookId)?.Name; + } + + /// + /// Batch item 7 (progressive join): bumps a not-yet-downloaded book's background download + /// to the front of the queue -- called when the user selects its placeholder, so the book + /// they're waiting for arrives before others that were merely queued in the background. A + /// no-op if the book already has a local folder (nothing left to prioritize). + /// + public void PrioritizeDownload(string bookName) + { + if (Directory.Exists(Path.Combine(LocalCollectionFolder, bookName))) + return; + PrioritizeBackgroundDownload(bookName); + } + + private BookStatus StatusFromCachedBook(CloudCachedBook book, string collectionId) + { + return new BookStatus + { + checksum = book.CurrentChecksum, + lockedBy = ResolveLockedByForDisplay(book), + // The server book row has no first/last-name split, only a whole display name + // (locked_by_name: since 20260713000001 the durable, admin-editable + // tc.members.display_name, with the older JWT-claim capture as fallback). The + // WHOLE name goes in the FirstName slot with Surname left null: both consumers + // (TeamCollectionBookStatusPanel's `${whoFirstName ?? ""} ${whoSurname ?? ""}` + // .trim() and BookButton's `whoFirstName || who`) render that cleanly, and they + // fall back to the email (lockedBy) when it is null. lockedBy itself must STAY + // the email -- the panel compares it with currentUser to decide lockedByMe. + lockedByFirstName = book.LockedByDisplayName, + lockedBySurname = null, + lockedWhen = book.LockedAt.HasValue + ? $"{book.LockedAt.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ss.fffZ}" + : null, + lockedWhere = book.LockedByMachine, + collectionId = collectionId, + }; + } + + /// + /// Live-testing discovery (see the task 05 final report): the server stamps + /// `locked_by`/`created_by` with the raw auth user id (JWT `sub`), not the email + /// CONTRACTS.md's identity model describes ("account email is the identity in cloud TCs"). + /// Task 06's 20260707000006 migration fixes this server-side for every member (not just the + /// caller) by joining tc.members in get_collection_state/get_changes and reporting the + /// result as -- preferred here whenever present. + /// The original client-side workaround (resolve only OUR OWN id, since that's all a plain + /// JWT comparison can do) is kept as a fallback for a cache snapshot saved before that + /// migration landed (an old .bloom-cloud-repo-cache.json with no LockedByEmail yet). + /// + private string ResolveLockedByForDisplay(CloudCachedBook book) + { + var lockedBy = book.LockedBy; + if (string.IsNullOrEmpty(lockedBy)) + return lockedBy; + if (!string.IsNullOrEmpty(book.LockedByEmail)) + return book.LockedByEmail; + if (lockedBy == _auth.CurrentUserId) + return _auth.CurrentEmail; + return lockedBy; + } + + // ------------------------------------------------------------------ + // Book status / list / presence (read from cache) + // ------------------------------------------------------------------ + + protected override bool TryGetBookStatusJsonFromRepo( + string bookFolderName, + out string status, + bool reportFailure = true + ) + { + EnsureCacheHydrated(); + // Identity-first (bug #15): a local folder resolves by its own instance id, so a + // locally-renamed checked-out book still reports ITS row's status, and a local book + // that merely shares a name with someone else's checked-in book reports none. + var cachedBook = ResolveCachedBook(bookFolderName); + if (cachedBook == null || !cachedBook.CurrentVersionSeq.HasValue) + { + // Either genuinely absent from the repo, or a never-committed new book (invisible to + // teammates per CONTRACTS.md) -- both are the valid "no repo file" case, not an error. + status = null; + return true; + } + status = StatusFromCachedBook(cachedBook, CollectionId).ToJson(); + return true; + } + + protected override string GetBookStatusJsonFromRepo(string bookFolderName) + { + TryGetBookStatusJsonFromRepo(bookFolderName, out var status); + return status; + } + + public override bool IsBookPresentInRepo(string bookFolderName) + { + EnsureCacheHydrated(); + var cachedBook = ResolveCachedBook(bookFolderName); + return cachedBook != null && cachedBook.CurrentVersionSeq.HasValue; + } + + public override bool KnownToHaveBeenDeleted(string oldName) + { + var cachedBook = ResolveCachedBook(oldName); + return cachedBook != null && cachedBook.DeletedAt.HasValue; + } + + public override string[] GetBookList() + { + EnsureCacheHydrated(); + return _cache + .GetAllBooks() + .Where(b => !b.DeletedAt.HasValue && b.CurrentVersionSeq.HasValue) + .Select(b => b.Name) + .Where(n => !string.IsNullOrEmpty(n)) + .ToArray(); + } + + /// + /// Cloud override: answered entirely from the repo cache, which already knows every + /// book's instance id (the server's books.instance_id IS meta.json's bookInstanceId). + /// The base implementation fetches each repo book's meta.json via GetRepoBookFile just to + /// read that same id -- for this backend, several network round trips PER BOOK at every + /// startup sync. Matches the base contract exactly: only live, committed repo books + /// (the same filter as ) with a readable id appear; value is + /// (repo book name, whether a local folder of that name exists). Nothing is ever added to + /// -- there are no repo zip files here to be unreadable, + /// and the base only adds names on zip/IO read failures. + /// + protected override Dictionary> GetRepoBooksByIdMap( + ICollection unreadableBooks = null + ) + { + EnsureCacheHydrated(); + var booksById = new Dictionary>(); + foreach (var book in _cache.GetAllBooks()) + { + if (book.DeletedAt.HasValue || !book.CurrentVersionSeq.HasValue) + continue; // not a live, committed repo book (mirrors GetBookList) + if (string.IsNullOrEmpty(book.Name) || string.IsNullOrEmpty(book.InstanceId)) + continue; // no usable name/id -- the base skips id-less books too + booksById[book.InstanceId] = Tuple.Create( + book.Name, + Directory.Exists(Path.Combine(_localCollectionFolder, book.Name)) + ); + } + return booksById; + } + + /// + /// Diff-dispatches to the narrowest RPC per Design/CloudTeamCollections/notes/ + /// write-book-status-audit.md. TryLockInRepo/UnlockInRepo (overridden below) already handle + /// the lock-changing callers (AttemptLock/UnlockBook/ForceUnlock); this method only needs to + /// handle the remaining base-class callers that call WriteBookStatus directly: + /// ForgetChangesCheckin (clears a lock -> unlock_book), and SyncAtStartup's three cases + /// (restore-our-checkout -> checkout_book; accept-remote-lock and update-checksum-only -> + /// local-only, no RPC, since the cloud repo is already authoritative for both). + /// + protected override void WriteBookStatusJsonToRepo(string bookName, string status) + { + var newStatus = BookStatus.FromJson(status); + var bookId = ResolveBookId(bookName); + if (bookId == null) + return; // never-committed book; checkin-start/finish already established repo state. + + var cachedBook = _cache.TryGetBook(bookId); + if (cachedBook == null) + return; + + if (string.IsNullOrEmpty(newStatus.lockedBy)) + { + // ForgetChangesCheckin: abandon-and-unlock. + if (!string.IsNullOrEmpty(cachedBook.LockedBy)) + UnlockInRepo(bookName, force: false); + return; + } + + if ( + string.IsNullOrEmpty(cachedBook.LockedBy) + && newStatus.lockedBy == CurrentUserIdentity + && newStatus.checksum == cachedBook.CurrentChecksum + ) + { + // SyncAtStartup restoring our own abandoned-remotely checkout, content unchanged. + TryLockInRepo(bookName, newStatus); + return; + } + + // SyncAtStartup's "accept remote lock" / "update checksum only" cases: the cloud repo is + // already authoritative for both lock and checksum, so there is nothing to write back. + } + + // ------------------------------------------------------------------ + // Locking + // ------------------------------------------------------------------ + + /// Conditional lock via a single RPC, per CONTRACTS.md's checkout_book (race-free: + /// "conditional UPDATE"). The RPC always returns 200 with `{success, locked_by, + /// locked_by_machine, locked_at}` -- present whether or not `success` is true, so we can + /// write-through the winner's identity into the cache even on a failed attempt (that's + /// exactly what lets AttemptLock's caller show "checked out by X" immediately). + protected override bool TryLockInRepo(string bookName, BookStatus newStatus) + { + var bookId = ResolveBookId(bookName); + if (bookId == null) + return true; // brand-new, never-committed local book; nothing to lock server-side yet. + + var result = _client.CheckoutBook(bookId, TeamCollectionManager.CurrentMachine, SeatId); + _cache.RecordCheckoutResult(bookId, result); + _cache.Save(); + return (bool?)result["success"] ?? false; + } + + /// Single RPC unlock/force-unlock, per CONTRACTS.md's unlock_book/force_unlock. + protected override void UnlockInRepo(string bookName, bool force) + { + var bookId = ResolveBookId(bookName); + if (bookId == null) + return; + if (force) + _client.ForceUnlock(bookId); + else + _client.UnlockBook(bookId); + _cache.RecordUnlock(bookId); + _cache.Save(); + } + + // ------------------------------------------------------------------ + // Account-switch takeover (batch item 9) + // ------------------------------------------------------------------ + + /// + /// The stable id of THIS local copy of the collection (its "seat", bug #0 — John's + /// ruling, 11 Jul 2026: editing/takeover of a checkout is only legitimate in the local + /// copy where the book is checked out; two copies on one machine are two seats). A hash + /// of the folder path rather than the path itself so the server rows never carry local + /// paths (privacy). + /// + internal string SeatId => _seatId ?? (_seatId = ComputeSeatId(LocalCollectionFolder)); + private string _seatId; + + /// First 16 hex digits of SHA256 of the normalized (full, lowercased, + /// no-trailing-separator) local collection folder path. + internal static string ComputeSeatId(string localCollectionFolder) + { + var normalized = Path.GetFullPath(localCollectionFolder) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .ToLowerInvariant(); + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(normalized)); + var builder = new StringBuilder(16); + for (var i = 0; i < 8; i++) + builder.Append(hash[i].ToString("x2")); + return builder.ToString(); + } + } + + /// + /// True when the repo lock's recorded seat is THIS local copy of the collection. + /// grandfathers locks with no recorded seat (taken + /// before the 20260711000003 migration, or via checkin_start_tx's take-if-free path, + /// which records no seat): the CURRENT USER's own such locks must keep working, but a + /// different account must never treat an unknown-seat lock as takeover-eligible + /// (fail-safe, mirroring the server-side takeover gate). + /// + private bool IsLockSeatHere(string bookName, bool allowUnknownSeat) + { + var seat = ResolveCachedBook(bookName)?.LockedSeat; + if (seat == null) + return allowUnknownSeat; + return seat == SeatId; + } + + /// + /// A book locked to a DIFFERENT account is still editable here (see IsEditableHere) as + /// long as that lock is recorded for THIS machine AND THIS local copy of the collection + /// (the "seat") -- John's decisions: local machine access is unrestricted (item 9), but + /// only where the book is actually checked out; a second copy of the collection on the + /// same machine risks conflicting changes and stays a genuine conflict (bug #0). The + /// same seat gate applies to the current user's OWN lock seen from another copy, except + /// that a lock with no recorded seat (pre-seat checkout) is grandfathered for its owner. + /// + protected internal override bool IsEditableHere(string bookName, BookStatus status) + { + if (status.lockedBy == FakeUserIndicatingNewBook) + return true; // a new local-only book is always editable + if ( + !status.IsCheckedOut() + || status.lockedWhere != TeamCollectionManager.CurrentMachine + ) + return false; + var isOwnLock = status.lockedBy == CurrentUserIdentity; + return IsLockSeatHere(bookName, allowUnknownSeat: isOwnLock); + } + + /// + protected internal override bool CanTakeOverLockOnThisMachine( + string bookName, + BookStatus repoStatus + ) => + !string.IsNullOrEmpty(repoStatus.lockedBy) + && repoStatus.lockedWhere == TeamCollectionManager.CurrentMachine + // Bug #0: takeover is only legitimate within the same local copy ("seat"); an + // unknown seat never qualifies, matching checkout_book_takeover's own gate. + && IsLockSeatHere(bookName, allowUnknownSeat: false); + + /// + /// Calls the tc.checkout_book_takeover RPC (CONTRACTS.md v1.4/v1.5) to atomically + /// reassign the book's server lock to the current user; the server only permits this + /// when the existing lock is recorded for THIS machine and THIS seat (local collection + /// copy). Purely additive server-side: checkin_start_tx itself is untouched, so calling + /// this before check-in is what lets its existing "LockHeldByOther" gate pass cleanly + /// for an account-switched checkin. + /// + protected internal override bool TryTakeOverLock(string bookName) + { + var bookId = ResolveBookId(bookName); + if (bookId == null) + return true; // brand-new, never-committed local book; nothing to take over. + + var result = _client.CheckoutBookTakeover( + bookId, + TeamCollectionManager.CurrentMachine, + SeatId + ); + _cache.RecordCheckoutResult(bookId, result); + _cache.Save(); + return (bool?)result["success"] ?? false; + } + + // ------------------------------------------------------------------ + // Send (PutBookInRepo) and the unified recovery path + // ------------------------------------------------------------------ + + protected override void PutBookInRepo( + string sourceBookFolderPath, + BookStatus newStatus, + bool inLostAndFound = false, + Action progressCallback = null, + string checkinComment = null + ) + { + var bookFolderName = Path.GetFileName(sourceBookFolderPath); + + // Account-switch takeover (batch item 9): if the repo still shows this book locked + // to a DIFFERENT account but for THIS machine, take the server lock over BEFORE + // checking in, so checkin_start_tx's existing (unmodified) "LockHeldByOther" gate + // sees OUR lock. This is the primary place the takeover actually happens in + // practice: there is no per-keystroke "book was just edited" hook anywhere in this + // codebase today (saves are local-only until check-in), so "on first edit" is + // implemented as "on first check-in of that edit" -- the earliest point a + // takeover has any observable effect on the shared system anyway. Also covers "B + // checks in without editing first": OkToCheckIn already allows it (via the same + // CanTakeOverLockOnThisMachine seam) and this call makes sure the server lock -- not + // just history attribution -- ends up correctly on B. + var repoStatusBeforeCheckin = GetStatus(bookFolderName); + if (CanTakeOverLockOnThisMachine(bookFolderName, repoStatusBeforeCheckin)) + TryTakeOverLock(bookFolderName); + + if (inLostAndFound) + { + // The base class uses inLostAndFound to mean "don't overwrite the repo, this content + // conflicts with what's there" (SyncAtStartup's ConflictingCheckout/ConflictingEdit + // cases). A cloud repo has no folder-based Lost & Found location to write to (unlike + // FolderTeamCollection's repo-side "Lost and Found" folder); per the design doc's + // unified recovery, we instead preserve the content locally and log an incident. + SaveLocalCopyForRecovery( + sourceBookFolderPath, + bookFolderName, + "ConflictingContent" + ); + return; + } + + var meta = BookMetaData.FromFolder(sourceBookFolderPath); + var bookInstanceId = meta?.Id; + if (string.IsNullOrEmpty(bookInstanceId)) + throw new ApplicationException( + $"Could not read the book id of \"{bookFolderName}\" from its meta.json; cannot send it to the cloud Team Collection." + ); + + // Identity ONLY -- never the folder name (bug #15, John's ruling). Resolving by name + // here could bind this check-in to a DIFFERENT book that happens to hold the name + // (e.g. a teammate checked one in while we were offline) and silently overwrite it; + // an unknown instance id correctly means "first-ever Send of a new book" instead, + // and the server's name-conflict retry below gives it a distinct name if needed. + var bookId = TryGetBookIdByInstanceId(bookInstanceId); + var cachedBook = bookId == null ? null : _cache.TryGetBook(bookId); + // CONTRACTS.md: checkin-start's `files` is the FULL proposed manifest; the SERVER + // diffs it against the current version and its response's changedPaths[] tells us + // what to upload. An earlier version of this method sent only a locally-computed + // changed-file list, which the server (correctly) treated as the complete manifest — + // committing versions whose manifests were missing every unchanged file (empty, in + // the first two-instance smoke test). The local diff is not a substitute: the server + // is authoritative about what its current version contains. + var localManifest = BookVersionManifest.FromLocalFolder(sourceBookFolderPath); + var filesJson = new JArray( + localManifest.Entries.Select(kvp => + (JToken) + new JObject + { + ["path"] = kvp.Key, + ["sha256"] = kvp.Value.Sha256, + ["size"] = kvp.Value.Size, + } + ) + ); + + var proposedName = GetBookNameWithoutSuffix(bookFolderName); + JObject startResult = null; + CloudCollectionClientException lastNameConflict = null; + for (var suffix = 1; suffix <= kMaxNameConflictRetries; suffix++) + { + try + { + startResult = _client.CheckinStart( + _collectionId, + bookId, + bookInstanceId, + proposedName, + cachedBook?.CurrentVersionId, + newStatus.checksum, + Application.ProductVersion, + filesJson + ); + lastNameConflict = null; + break; + } + catch (CloudCollectionClientException e) + when (e.Code == CloudErrorCode.NameConflict) + { + lastNameConflict = e; + // "name2" resolution, per the task brief and existing FolderTeamCollection + // convention for same-name collisions. + proposedName = GetBookNameWithoutSuffix(bookFolderName) + (suffix + 1); + } + } + if (startResult == null) + throw lastNameConflict + ?? new ApplicationException( + $"Could not check in \"{bookFolderName}\": the cloud Team Collection did not return a transaction." + ); + + var transactionId = (string)startResult["transactionId"]; + var location = ParseS3Location(startResult); + var keepCheckedOut = !string.IsNullOrEmpty(newStatus.lockedBy); + // Upload what the SERVER says changed relative to its current version (see the + // manifest comment above) — not a local guess. + var changedPaths = + ((JArray)startResult["changedPaths"])?.Select(t => (string)t).ToList() + ?? new List(); + + try + { + _transfer.UploadChangedFiles( + location, + sourceBookFolderPath, + changedPaths, + cachedBook?.Manifest, + // We just hashed the whole folder to build checkin-start's proposed manifest; + // passing it here keeps UploadChangedFiles from hashing each file again. + localManifest, + 4, + progressCallback == null + ? null + : new Progress(_ => progressCallback(-1f)), + CancellationToken.None + ); + // The comment must reach the server: unlike folder TCs (where the message rides + // inside history.db within the .bloom file), cloud history is displayed from the + // server's event log, so a comment we don't send here is invisible to everyone. + var finishResult = _client.CheckinFinish( + transactionId, + comment: string.IsNullOrEmpty(checkinComment) ? null : checkinComment, + keepCheckedOut: keepCheckedOut + ); + var versionId = (string)finishResult["versionId"]; + var seq = (long)finishResult["seq"]; + + // checkin-start/finish don't return the server-assigned book id for a first-ever + // Send (CONTRACTS.md gap -- see the task 05 final report); resolve it via a + // targeted state refresh matched on the stable, client-generated bookInstanceId. + if (bookId == null) + { + HydrateFromServer(); + bookId = TryGetBookIdByInstanceId(bookInstanceId); + } + + if (bookId != null) + { + _cache.RecordCheckinFinish( + bookId, + bookInstanceId, + proposedName, + versionId, + seq, + newStatus.checksum, + localManifest, + keepCheckedOut, + keepCheckedOut ? CurrentUserIdentity : null, + keepCheckedOut ? TeamCollectionManager.CurrentMachine : null + ); + // We just successfully uploaded this exact version, so the local folder IS this + // version now (task 06's "localVersionSeq"). + _cache.RecordLocalVersionSeq(bookId, seq); + _cache.Save(); + RefreshIndexFromCache(); + } + } + catch (Exception) + { + try + { + _client.CheckinAbort(transactionId); + } + catch (Exception abortException) + { + NonFatalProblem.ReportSentryOnly(abortException); + } + throw; + } + } + + /// + /// Unified recovery for the inLostAndFound branch of PutBookInRepo: saves the user's local + /// copy as a `.bloomSource` zip under a local "Lost and Found" folder (there is no repo-side + /// Lost & Found for a cloud collection, unlike FolderTeamCollection's), and posts a + /// `log_event` incident. The caller (SyncAtStartup, via PutBook(..., inLostAndFound: true)) + /// is responsible for then Receiving the current repo version into the local folder. + /// + /// Note on "distinct messages per sub-case" (task brief): SyncAtStartup itself already logs + /// a sub-case-specific message via ReportProblemSyncingBook before/around calling PutBook + /// with inLostAndFound (e.g. "ConflictingCheckout" vs "ConflictingEdit" -- see + /// TeamCollection.cs, ~line 2415/2539) -- that pre-existing, unchanged base-class logic + /// already provides the sub-case-specific text. PutBookInRepo's own `inLostAndFound` bool + /// parameter does NOT itself carry which sub-case triggered it (that would need a base-class + /// signature change we didn't make -- see the task 05 final report), so the ONE additional + /// message this method logs is deliberately sub-case-agnostic: it only adds the ".bloomSource + /// preserved" fact, which none of the existing base messages mention. + /// + private void SaveLocalCopyForRecovery( + string sourceBookFolderPath, + string bookFolderName, + string subCase + ) + { + try + { + var lostAndFoundDir = Path.Combine(_localCollectionFolder, "Lost and Found"); + // AvailablePath (hoisted to the base TeamCollection) creates the folder and finds + // a non-colliding "[.N].bloomSource" path. + var destPath = AvailablePath(bookFolderName, lostAndFoundDir, ".bloomSource"); + var zip = new Bloom.Utils.BloomZipFile(destPath); + zip.AddDirectory(sourceBookFolderPath, sourceBookFolderPath.Length + 1, null, null); + zip.Save(); + + var bookId = ResolveBookId(bookFolderName); + try + { + _client.LogEvent( + _collectionId, + bookId, + kWorkPreservedLocallyEventType, + subCase + ); + } + catch (Exception e) + { + NonFatalProblem.ReportSentryOnly(e); + } + + MessageLog.WriteMessage( + MessageAndMilestoneType.NewStuff, + "TeamCollection.Cloud.WorkPreservedLocally", + "Your changes to \"{0}\" have been saved to the \"Lost and Found\" folder in your collection, and you now have the latest version from the team.", + bookFolderName, + null + ); + } + catch (Exception e) + { + NonFatalProblem.Report( + ModalIf.All, + PassiveIf.All, + $"Bloom could not preserve your local changes to \"{bookFolderName}\" before receiving the version from the Team Collection.", + exception: e + ); + } + } + + // ------------------------------------------------------------------ + // Receive (FetchBookFromRepo) and single-file reads (GetRepoBookFile) + // ------------------------------------------------------------------ + + protected override string FetchBookFromRepo( + string destinationCollectionFolder, + string bookName + ) + { + EnsureCacheHydrated(); + var bookId = ResolveBookId(bookName); + if (bookId == null) + return $"Could not find the book \"{bookName}\" in the cloud Team Collection."; + + string stagingPath = null; + try + { + var manifest = FetchAndCacheManifest(bookId, out var fetchedVersionSeq); + if (manifest == null) + return $"Could not read the file list for \"{bookName}\" from the cloud Team Collection."; + + var collectionLocation = GetCollectionDownloadLocation(); + var instanceId = _cache.TryGetBook(bookId)?.InstanceId; + var location = BuildBookS3Location(collectionLocation, instanceId); + var pinnedFiles = manifest + .Entries.Select(kvp => new PinnedFileDownload + { + RelativePath = kvp.Key, + S3VersionId = kvp.Value.S3VersionId, + ExpectedSha256Hex = kvp.Value.Sha256, + ExpectedSize = kvp.Value.Size, + }) + .ToList(); + + var finalPath = Path.Combine( + destinationCollectionFolder, + GetBookNameWithoutSuffix(bookName) + ); + stagingPath = finalPath + ".cloudReceive-" + Guid.NewGuid().ToString("N"); + Directory.CreateDirectory(stagingPath); + + // Incremental Receive: seed the staging folder with the LOCAL copies of files that + // are still part of the target version, so DownloadFiles' hash-skip re-downloads + // ONLY the files that actually changed. A teammate's rename (or any Receive of a + // book we already have) then transfers a handful of changed files instead of the + // whole book. We seed only files that appear in the target manifest (by relative + // path), so files removed in the new version are simply never carried into staging, + // and local-only junk (never in the manifest) is left behind -- the swap below then + // makes finalPath exactly the target version. Seeding is a local copy (cheap next to + // a network download) and preserves the existing stage-then-atomic-swap guarantee. + if (Directory.Exists(finalPath)) + { + foreach (var relativePath in manifest.Entries.Keys) + { + var platformRelative = relativePath.Replace( + '/', + Path.DirectorySeparatorChar + ); + var sourceFile = Path.Combine(finalPath, platformRelative); + if (!RobustFile.Exists(sourceFile)) + continue; + var seededFile = Path.Combine(stagingPath, platformRelative); + Directory.CreateDirectory(Path.GetDirectoryName(seededFile)); + RobustFile.Copy(sourceFile, seededFile); + } + } + + _transfer.DownloadFiles( + location, + pinnedFiles, + stagingPath, + 4, + null, + CancellationToken.None + ); + + // Atomic-as-possible whole-directory swap. CloudBookTransfer.DownloadFiles stages + // and verifies every file before touching stagingPath at all, but its own final + // step is a per-file delete+move loop, not a single directory rename (merge log, + // 7 Jul). We do the actual swap of the BOOK folder here with two directory-rename + // moves (each atomic on the same volume), so a crash mid-swap leaves either the old + // folder or the new one intact under finalPath -- never a mix of old and new files. + string backupPath = null; + if (Directory.Exists(finalPath)) + { + backupPath = finalPath + ".cloudReceiveOld-" + Guid.NewGuid().ToString("N"); + RobustIO.MoveDirectory(finalPath, backupPath); + } + RobustIO.MoveDirectory(stagingPath, finalPath); + stagingPath = null; // successfully moved; nothing left to clean up + if (backupPath != null) + RobustIO.DeleteDirectoryAndContents(backupPath, true); + + // The whole-book folder now matches the version whose manifest we just fetched and + // downloaded from -- record it as this machine's local version (book-status JSON's + // "localVersionSeq"; task 06). Deliberately only done here (a completed whole-book + // swap), never in GetRepoBookFile's single-file peek, which doesn't update the local + // folder at all. + if (fetchedVersionSeq.HasValue) + { + _cache.RecordLocalVersionSeq(bookId, fetchedVersionSeq.Value); + _cache.Save(); + } + + return null; + } + catch (Exception e) + { + return $"Bloom could not download the book \"{bookName}\" from the Team Collection: {e.Message}"; + } + finally + { + if (stagingPath != null && Directory.Exists(stagingPath)) + RobustIO.DeleteDirectoryAndContents(stagingPath, true); + } + } + + public override string GetRepoBookFile(string bookName, string fileName) + { + var cacheKey = bookName + "|" + fileName; + if (_repoFileCache.TryGetValue(cacheKey, out var cachedContent)) + return cachedContent; + + var bookId = TryGetBookId(bookName); + if (bookId == null) + return null; + + string tempFolder = null; + try + { + var manifest = + _cache.TryGetBook(bookId)?.Manifest ?? FetchAndCacheManifest(bookId, out _); + if ( + manifest == null + || !manifest.Entries.TryGetValue( + BookVersionManifest.NormalizePath(fileName), + out var entry + ) + ) + return null; + + var collectionLocation = GetCollectionDownloadLocation(); + var instanceId = _cache.TryGetBook(bookId)?.InstanceId; + var location = BuildBookS3Location(collectionLocation, instanceId); + tempFolder = Path.Combine( + Path.GetTempPath(), + "BloomCloudFile-" + Guid.NewGuid().ToString("N") + ); + _transfer.DownloadFiles( + location, + new[] + { + new PinnedFileDownload + { + RelativePath = fileName, + S3VersionId = entry.S3VersionId, + ExpectedSha256Hex = entry.Sha256, + ExpectedSize = entry.Size, + }, + }, + tempFolder, + 1, + null, + CancellationToken.None + ); + var content = RobustFile.ReadAllText( + Path.Combine(tempFolder, fileName), + System.Text.Encoding.UTF8 + ); + _repoFileCache[cacheKey] = content; + return content; + } + catch (Exception e) + { + NonFatalProblem.ReportSentryOnly( + e, + $"CloudTeamCollection.GetRepoBookFile({bookName}, {fileName})" + ); + return null; + } + finally + { + if (tempFolder != null && Directory.Exists(tempFolder)) + RobustIO.DeleteDirectoryAndContents(tempFolder, true); + } + } + + /// Fetches and caches a book's current manifest, out-parameter also reporting the + /// version seq it belongs to (get_book_manifest's "seq", v1.2) so Receive can record what + /// ended up on disk (task 06's "localVersionSeq") without a second round trip. + private BookVersionManifest FetchAndCacheManifest(string bookId, out long? versionSeq) + { + var manifestResponse = _client.GetBookManifest(bookId); + if (manifestResponse == null) + { + versionSeq = null; + return null; + } + var manifest = BookVersionManifest.FromJson((JArray)manifestResponse["files"]); + versionSeq = (long?)manifestResponse["seq"]; + // Fail fast on a committed-but-empty manifest rather than "receiving" it, which + // would atomically swap an EMPTY folder over the local book. No real book has zero + // files; the only known way to get one is the fixed send-only-changed-paths client + // bug (7 Jul 2026), but any future cause deserves a loud stop, not silent data loss. + if ( + manifest.Entries.Count == 0 + && (long?)manifestResponse["seq"] != null + && manifestResponse["versionId"]?.Type != JTokenType.Null + ) + throw new ApplicationException( + $"The cloud Team Collection's current version of this book (seq {versionSeq}) has an empty file manifest. " + + "Refusing to Receive it over the local copy. Someone should check this book in again from a good copy." + ); + _cache.RecordManifest(bookId, manifest); + return manifest; + } + + private static CloudS3Location ParseS3Location(JObject response) + { + var s3 = (JObject)response["s3"]; + var creds = (JObject)s3["credentials"]; + return new CloudS3Location + { + Bucket = (string)s3["bucket"], + Region = (string)s3["region"], + Prefix = (string)s3["prefix"], + AccessKeyId = (string)creds["accessKeyId"], + SecretAccessKey = (string)creds["secretAccessKey"], + SessionToken = (string)creds["sessionToken"], + ExpiresAtUtc = ParseCredentialExpiration((string)creds["expiration"]), + }; + } + + /// Parses the edge function's ISO-8601 `credentials.expiration` to UTC, or + /// DateTime.MinValue when absent/unparseable (callers then treat the creds as + /// non-cacheable and simply fetch fresh ones per use). + private static DateTime ParseCredentialExpiration(string raw) + { + if (string.IsNullOrEmpty(raw)) + return DateTime.MinValue; + return DateTimeOffset.TryParse( + raw, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out var parsed + ) + ? parsed.UtcDateTime + : DateTime.MinValue; + } + + private readonly object _downloadLocationLock = new object(); + private CloudS3Location _cachedDownloadLocation; + private string _cachedDownloadLocationForUser; + + // Refetch once the cached download credentials are within this margin of their stated + // expiration, leaving ample headroom for any in-flight download to finish on them. + private static readonly TimeSpan kDownloadCredsExpiryMargin = TimeSpan.FromMinutes(5); + + /// + /// The collection-scoped, read-only S3 credentials from download-start (CONTRACTS.md: + /// GetObject/GetObjectVersion over the whole `tc/{cid}/` prefix, ~1h TTL), CACHED and + /// reused across every book and collection-file-group download instead of re-fetched per + /// download. Because the creds cover the entire collection prefix, one fetch serves a whole + /// join/Receive; without this a join did one edge-function call plus a server-side STS + /// AssumeRole for EACH book. Refetched when the cached creds approach their stated + /// expiration (so long joins refresh mid-way rather than assuming a fixed TTL), or when the + /// signed-in account changes (the creds were issued for the prior identity). Internal so a + /// test can drive it directly. + /// + internal CloudS3Location GetCollectionDownloadLocation() + { + lock (_downloadLocationLock) + { + if ( + _cachedDownloadLocation != null + && _cachedDownloadLocationForUser == CurrentUserIdentity + // Add the margin to "now" rather than subtracting it from the expiry, so an + // unknown expiry (DateTime.MinValue -> treated as always-stale) can't underflow. + && _cachedDownloadLocation.ExpiresAtUtc + > DateTime.UtcNow + kDownloadCredsExpiryMargin + ) + return _cachedDownloadLocation; + + var location = ParseS3Location(_client.DownloadStart(_collectionId)); + _cachedDownloadLocation = location; + _cachedDownloadLocationForUser = CurrentUserIdentity; + return location; + } + } + + /// + /// download-start's credentials are scoped to the WHOLE collection prefix (`tc/{cid}/`, + /// covering every book, per CONTRACTS.md: "read: the collection prefix incl. + /// GetObjectVersion") -- unlike checkin-start's creds, which are already scoped to one + /// book's own `tc/{cid}/books/{bookInstanceId}/` prefix. A book's manifest entries are bare + /// relative paths within that book's own folder (e.g. "meta.json"), so any Receive-path + /// download must insert the `books/{bookInstanceId}/` segment itself before combining with + /// the collection-level prefix -- CloudBookTransfer builds each S3 key as exactly + /// `location.Prefix + file.RelativePath`, with no other place that segment could come from. + /// Discovered via the live round-trip test (a mocked/unit test can't catch this, since it + /// would have to fake real S3 key-not-found semantics to notice the missing segment). + /// + private static CloudS3Location BuildBookS3Location( + CloudS3Location collectionLocation, + string bookInstanceId + ) + { + if (string.IsNullOrEmpty(bookInstanceId)) + throw new ApplicationException( + "Cannot build a book-scoped S3 location without a bookInstanceId (the book is not yet known to the local cache)." + ); + return new CloudS3Location + { + Bucket = collectionLocation.Bucket, + Region = collectionLocation.Region, + Prefix = $"{collectionLocation.Prefix}books/{bookInstanceId}/", + AccessKeyId = collectionLocation.AccessKeyId, + SecretAccessKey = collectionLocation.SecretAccessKey, + SessionToken = collectionLocation.SessionToken, + }; + } + + // ------------------------------------------------------------------ + // Delete / rename + // ------------------------------------------------------------------ + + public override void DeleteBookFromRepo(string bookFolderPath, bool makeTombstone = true) + { + // The cloud delete_book RPC always tombstones (sets deleted_at); there is no + // "delete without tombstone" mode server-side. The base class's own doc comment on + // makeTombstone notes we currently never pass false, so this is not a live gap today. + var bookFolderName = Path.GetFileName(bookFolderPath); + var bookId = ResolveBookId(bookFolderName); + if (bookId == null) + return; // never made it to the repo; nothing to delete there. + _client.DeleteBook(bookId); + HydrateFromServer(); + } + + public override void RenameBookInRepo(string newBookFolderPath, string oldName) + { + // Deliberately a no-op for the cloud backend. Cloud renames are carried implicitly: + // the caller (base PutBook) calls this and then immediately calls PutBookInRepo, + // whose checkin-start sends the NEW name as proposedName for the SAME book id -- the + // server updates the book row's name at checkin-finish + // (Design/CloudTeamCollections.md: "Folder keyed by instance id -> rename is a DB row + // update"). And since every repo lookup for a local folder resolves by the folder's + // meta.json instance id (bug #15, ResolveBookId), the renamed folder already binds to + // its row without any bridging state here (an earlier version kept a pending + // new-name -> id map for exactly that gap). + } + + protected override void MoveRepoBookToLostAndFound(string bookName) + { + // The only caller (SyncAtStartup) uses this for Dropbox-style conflict-marker file + // names, which cannot occur in a cloud-backed repo -- there is no filesystem-level + // conflict-copy mechanism to produce them (Design/CloudTeamCollections.md: "most + // Dropbox-era cases ... become structurally impossible"). Kept as a safety net that + // reports rather than crashing, since SyncAtStartup is shared, unchanged base logic. + NonFatalProblem.ReportSentryOnly( + new InvalidOperationException( + $"CloudTeamCollection.MoveRepoBookToLostAndFound('{bookName}') was called; this should be structurally impossible for a cloud-backed repo." + ) + ); + } + + // ------------------------------------------------------------------ + // Casing + // ------------------------------------------------------------------ + + public override bool DoLocalAndRemoteNamesDifferOnlyByCase(string bookBaseName) + { + var cachedBook = ResolveCachedBook(bookBaseName); + if (cachedBook == null || string.IsNullOrEmpty(cachedBook.Name)) + return false; + return !string.Equals(cachedBook.Name, bookBaseName, StringComparison.Ordinal) + && string.Equals(cachedBook.Name, bookBaseName, StringComparison.OrdinalIgnoreCase); + } + + public override void EnsureConsistentCasingInLocalName(string bookBaseName) + { + var cachedBook = ResolveCachedBook(bookBaseName); + if (cachedBook == null || !DoLocalAndRemoteNamesDifferOnlyByCase(bookBaseName)) + return; + var localFolderPath = Path.Combine(_localCollectionFolder, bookBaseName); + if (!Directory.Exists(localFolderPath)) + return; + + var tempName = Guid.NewGuid().ToString("N"); + var tempPath = Path.Combine(_localCollectionFolder, tempName); + RobustIO.MoveDirectory(localFolderPath, tempPath); + var finalPath = Path.Combine(_localCollectionFolder, cachedBook.Name); + RobustIO.MoveDirectory(tempPath, finalPath); + + var htmFileName = Path.Combine(finalPath, bookBaseName + ".htm"); + if (RobustFile.Exists(htmFileName)) + { + var newHtmFileName = Path.Combine(finalPath, cachedBook.Name + ".htm"); + var tempBookPath = Path.Combine(finalPath, tempName + ".htm"); + RobustFile.Move(htmFileName, tempBookPath); + RobustFile.Move(tempBookPath, newHtmFileName); + } + } + + // ------------------------------------------------------------------ + // Converting the current local collection into a fresh cloud Team Collection + // (TeamCollectionManager.ConnectToCloudCollection's counterpart to + // FolderTeamCollection.SetupTeamCollection/SetupTeamCollectionWithProgressDialog). + // ------------------------------------------------------------------ + + /// + /// Pushes every existing local book and collection-level file up to this (freshly-linked, + /// still-empty) cloud collection, then starts monitoring. Called once, right after + /// TeamCollectionManager.ConnectToCloudCollection creates the server-side row and links the + /// current local collection to it. + /// + public void SetupCloudTeamCollection(Bloom.web.IWebSocketProgress progress) + { + progress.Message( + "StartingCopy", + "", + "Starting to set up the Team Collection", + Bloom.web.ProgressKind.Progress + ); + CopyRepoCollectionFilesFromLocal(_localCollectionFolder); + SynchronizeBooksFromLocalToRepo(progress); + StartMonitoring(); + } + + /// Wraps with a progress dialog, mirroring + /// . + public void SetupCloudTeamCollectionWithProgressDialog() + { + var title = "Setting Up Team Collection"; // matches FolderTeamCollection's own (un-l10n'd) title. + ShowProgressDialog( + title, + (progress, worker) => + { + try + { + SetupCloudTeamCollection(progress); + } + catch (Exception ex) + { + // this will ensure that progress.HaveProblemsBeenReported is true. + progress.MessageWithoutLocalizing( + "Something went wrong: " + ex.Message, + Bloom.web.ProgressKind.Error + ); + } + progress.Message("Done", "Done"); + return progress.HaveProblemsBeenReported; + } + ); + } + + // ------------------------------------------------------------------ + // Connection check + // ------------------------------------------------------------------ + + public override TeamCollectionMessage CheckConnection() + { + if (!_auth.IsSignedIn) + return new TeamCollectionMessage( + MessageAndMilestoneType.Error, + "TeamCollection.Cloud.NotSignedIn", + "Please sign in to your Bloom account to use this Team Collection." + ); + try + { + var collections = _client.MyCollections(); + var isMember = collections.Any(c => (string)c["id"] == _collectionId); + if (!isMember) + { + // Account-switch behavior (batch item 9): the current logon is not a server + // member of this Team Collection -- e.g. it was joined under a different + // account. TeamCollectionManager.CheckConnection(allowHardRefusal: true), the + // ONLY caller that sets IsAccessRefusal-aware behavior, turns this into a + // hard "refuse to open" rather than the ordinary Disconnected fallback; a + // membership loss discovered later in the session (this same code path, + // called with allowHardRefusal defaulting to false) still just disconnects. + return new TeamCollectionMessage( + MessageAndMilestoneType.Error, + "TeamCollection.Cloud.NotAMemberRefusal", + "Bloom cannot open this Team Collection here because {0} is not a member of it. {1}", + _auth.CurrentEmail, + ComposeNotAMemberRefusalDetail( + ReadLocalAdministrators(), + TeamCollectionLastKnownUser.Read(_localCollectionFolder) + ) + ) + { + IsAccessRefusal = true, + }; + } + // Confirmed as a member: make sure the membership is CLAIMED (user_id filled on + // the membership row). my_collections above matches by EMAIL, approved-or-claimed, + // but every data RPC's RLS gate (get_collection_state etc.) matches by user_id -- + // an approved-but-never-claimed account passes this check and then throws + // not_a_member on the very first sync. That is exactly the batch item 9 + // shared-computer scenario: the account opening the collection never ran the join + // flow (which is where ClaimMemberships was otherwise called -- CloudJoinFlow), + // because a different account joined this folder. Found live: e2e-10's member + // reopen hit the not_a_member throw inside TeamCollectionManager's constructor. + // Idempotent and cheap; once per ACCOUNT (not per session -- an in-session + // sign-out + sign-in as a different approved member must claim again; see the + // field's own comment). + if ( + !string.Equals( + _membershipsClaimedForEmail, + _auth.CurrentEmail, + StringComparison.OrdinalIgnoreCase + ) + ) + { + _client.ClaimMemberships(); + _membershipsClaimedForEmail = _auth.CurrentEmail; + } + // Record ourselves as the last known local user of this + // collection on this machine, so a FUTURE non-member's refusal message (above) + // can name us. Doubles as "who joined" for a collection nobody has reopened + // since (see TeamCollectionLastKnownUser's own doc comment). + TeamCollectionLastKnownUser.Record(_localCollectionFolder, _auth.CurrentEmail); + } + catch (CloudCollectionClientException e) when (e.Code == CloudErrorCode.NotSignedIn) + { + return new TeamCollectionMessage( + MessageAndMilestoneType.Error, + "TeamCollection.Cloud.NotSignedIn", + "Please sign in to your Bloom account to use this Team Collection." + ); + } + catch (Exception e) + { + return new TeamCollectionMessage( + MessageAndMilestoneType.Error, + "TeamCollection.Cloud.NoConnection", + "Bloom could not reach the Team Collection server. Please check your internet connection. ({0})", + e.Message + ); + } + return null; + } + + /// + /// Best-effort read of the locally-known Administrators list (from the last-synced + /// .bloomCollection file), for use in the non-member refusal message: a non-member + /// cannot query the server's members/admin list (tc.members_list's RLS gate filters out + /// all rows for a non-member -- verified in the members_list migration), so this is + /// "whatever is locally known" as the batch item's spec anticipates. NOTE (documented + /// limitation, tracked separately -- see the batch file's "Also queued from dogfooding" + /// item): ConnectToCloudCollection currently stamps Administrators with the CREATOR's + /// Bloom REGISTRATION email rather than their signed-in cloud email, so this list may not + /// exactly match the admin's cloud logon; fixing that identity mismatch is out of scope + /// here and is tracked as a separate opportunistic fix. + /// + private string[] ReadLocalAdministrators() + { + try + { + var settingsPath = Bloom.Collection.CollectionSettings.GetSettingsFilePath( + _localCollectionFolder + ); + var settings = Bloom.ProjectContext.GetCollectionSettings(settingsPath); + return settings?.Administrators; + } + catch (Exception) + { + // No usable local .bloomCollection file yet (e.g. this machine has never + // synced collection files at all) -- ComposeNotAMemberRefusalDetail already + // handles "administrators unknown" gracefully, falling back to naming only + // the last known local user (if that's known) or a generic "an administrator". + return null; + } + } + + /// + /// Pure, unit-testable composition of the second sentence of the non-member refusal + /// message: names admin(s) to ask, and/or the last known local team member, depending on + /// what's actually known locally (both may be unavailable -- e.g. a legacy collection + /// with no recorded Administrators and no TeamCollectionLastKnownUser.txt yet). + /// + internal static string ComposeNotAMemberRefusalDetail( + IReadOnlyCollection administrators, + string lastKnownUser + ) + { + var adminList = + administrators == null + ? null + : string.Join(", ", administrators.Where(a => !string.IsNullOrWhiteSpace(a))); + var haveAdmins = !string.IsNullOrEmpty(adminList); + var haveLastKnownUser = !string.IsNullOrEmpty(lastKnownUser); + + if (haveAdmins && haveLastKnownUser) + return string.Format( + "Ask an administrator of this Team Collection ({0}) to add you as a member, or ask {1}, the last team member known to have used this collection on this computer.", + adminList, + lastKnownUser + ); + if (haveAdmins) + return string.Format( + "Ask an administrator of this Team Collection ({0}) to add you as a member.", + adminList + ); + if (haveLastKnownUser) + return string.Format( + "Ask {0}, the last team member known to have used this collection on this computer, or another administrator of this Team Collection, to add you as a member.", + lastKnownUser + ); + return "Ask an administrator of this Team Collection to add you as a member."; + } + + // ------------------------------------------------------------------ + // Monitoring (polling; see CloudCollectionMonitor) + // ------------------------------------------------------------------ + + protected internal override void StartMonitoring() + { + base.StartMonitoring(); + EnsureCacheHydrated(); + // Self-healing pass (see the method's own doc): monitoring starts right after the + // startup sync, so this catches any repo book the sync failed to queue for download + // (e.g. the in-memory queue lost to a kill/crash between a progressive join's pullDown + // and the relaunch). + QueueMissingRepoBooksForBackgroundDownload(); + _monitor = new CloudCollectionMonitor( + _client, + _collectionId, + _cache.LastSeenEventId, + OnPolledChanges, + pollInterval: _environment.PollInterval + ); + _monitor.Start(); + } + + protected internal override void StopMonitoring() + { + _monitor?.Dispose(); + _monitor = null; + base.StopMonitoring(); + } + + /// + /// Applies one batch of get_changes results to the cache and raises the same low-level + /// events FolderTeamCollection's FileSystemWatcher callbacks raise, so all the shared + /// base-class idle-time handling (HandleNewBook/HandleModifiedFile/HandleDeletedRepoFile/ + /// message log entries) works unchanged for the cloud backend too. Because + /// CloudCollectionMonitor's polling cursor is the same last_seen_event_id this class + /// persists, an event we caused ourselves (e.g. our own checkin) is already reflected in the + /// cache by the time the next poll's delta arrives, so comparing before/after cache state + /// here naturally suppresses raising a change notification for our own writes. + /// + private void OnPolledChanges(JObject changes) + { + // An idle poll (no touched books, cursor unchanged) makes ApplyDelta a no-op: the cache + // content and its index are already current and no Raise*/socket event below could + // fire. Skip the full-cache Save + index rebuild + book-event pass in that case (E3) -- + // otherwise every 60s poll rewrites the whole repo-cache file to disk while nothing has + // changed. The book snapshot is only needed to diff for those events, so only take it + // when the poll actually carried book rows. The group-file check and the self-healing + // download pass below run on EVERY poll regardless (see their own notes). + var hasBookRows = changes["books"] is JArray booksArray && booksArray.Count > 0; + var previousBooksById = hasBookRows + ? _cache.GetAllBooks().ToDictionary(b => b.Id) + : null; + + if (_cache.ApplyDelta(changes)) + { + _cache.Save(); + RefreshIndexFromCache(); + if (hasBookRows) + RaiseBookEventsForPolledChanges(previousBooksById); + } + + if (changes["groups"] is JArray groupsArray && groupsArray.Count > 0) + RaiseRepoCollectionFilesChanged(); + + // Self-healing pass: a repo book missing locally whose repo state did NOT change this + // poll raises none of the events above, so without this it would never be retried + // (found the hard way -- see QueueMissingRepoBooksForBackgroundDownload's doc). + QueueMissingRepoBooksForBackgroundDownload(); + } + + /// + /// Raises the per-book Raise*/socket notifications for a poll that actually touched books, + /// by diffing the current cache against (the snapshot + /// taken before ApplyDelta). Split out of OnPolledChanges so the idle-poll fast path (E3) + /// stays readable. + /// + private void RaiseBookEventsForPolledChanges( + Dictionary previousBooksById + ) + { + foreach (var book in _cache.GetAllBooks()) + { + if (string.IsNullOrEmpty(book.Name)) + continue; + // The Raise* event contract (and the base handlers behind it) use the folder + // backend's repo FILE name, i.e. WITH the ".bloom" suffix — HandleModifiedFile + // literally starts with EndsWith(".bloom") and silently ignores anything else. + // Passing the bare book name here meant every cloud change notification was + // discarded before reaching the UI (found by the first two-instance smoke test: + // teammates' screens never updated even though the cache/API had fresh data). + var bookFileName = book.Name + ".bloom"; + if (!previousBooksById.TryGetValue(book.Id, out var previous)) + { + if (book.CurrentVersionSeq.HasValue) + RaiseNewBook(bookFileName); + continue; + } + if (book.DeletedAt.HasValue && !previous.DeletedAt.HasValue) + { + RaiseDeleteRepoBookFile(bookFileName); + continue; + } + if ( + book.CurrentVersionSeq != previous.CurrentVersionSeq + || book.LockedBy != previous.LockedBy + || book.Name != previous.Name + ) + { + RaiseBookStateChange(bookFileName); + } + } + + // Task 06: the status button's "Updates Available (N books)" metadata + // (teamCollection/tcStatusMetadata) can only have changed if this poll actually touched + // any book -- and this method only runs when it did -- so push the same "reuse existing + // contexts" websocket plumbing the rest of this class already uses (RaiseBookStateChange + // etc. above) so the UI refreshes without waiting for its own next poll. + SocketServer?.SendEvent("teamCollection", "statusMetadataChanged"); + } + + /// Lets UI code (e.g. a "Receive Updates" button, or Bloom regaining focus) trigger + /// an immediate poll instead of waiting for the periodic timer. + public void PollNow() => _monitor?.PollNow(); + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudTokenStore.cs b/src/BloomExe/TeamCollection/Cloud/CloudTokenStore.cs new file mode 100644 index 000000000000..8ed0285ba4a4 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudTokenStore.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using SIL.IO; +using SIL.Reporting; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// Persists a (in particular its refresh token) to a small file + /// under Bloom's app-data folder, encrypted with Windows DPAPI (CurrentUser scope) so it + /// cannot be read by another Windows account on the same machine, nor casually by opening + /// the file in a text editor. This is the "persistent token store" GOING-LIVE.md Phase 3.4 + /// calls out as something the dev provider deliberately skips: it is what lets a real Cloud + /// Team Collection session survive a Bloom restart without asking the user to sign in again + /// (CloudAuth.InitializeAtStartup calls and, on success, refreshes it). + /// + /// DPAPI (System.Security.Cryptography.ProtectedData) is Windows-only, which is fine here: + /// Bloom itself is Windows-only (BloomExe.csproj targets net8.0-windows), so there is no + /// cross-platform concern to guard against, unlike a library that might run elsewhere. + /// + public class DpapiCloudTokenStore : ICloudTokenStore + { + private readonly string _filePath; + + public DpapiCloudTokenStore() + : this( + Path.Combine( + ProjectContext.GetBloomAppDataFolder(), + "CloudTeamCollectionSession.dat" + ) + ) { } + + /// Test-only: lets tests point at a temp file instead of the real app-data + /// folder, so tests never touch a real user's stored session. + internal DpapiCloudTokenStore(string filePath) + { + _filePath = filePath; + } + + /// + /// Reads and decrypts the stored session, or null if there is none or it could not be + /// read/decrypted (e.g. corrupted, or encrypted under a different Windows user profile). + /// Never throws: a failure here is exactly the "please sign in again" case + /// CloudAuth.InitializeAtStartup already handles gracefully for a missing/invalid + /// refresh token, so callers don't need a separate code path for it. + /// + public CloudSession Load() + { + if (!RobustFile.Exists(_filePath)) + return null; + + try + { + var encrypted = RobustFile.ReadAllBytes(_filePath); + var json = ProtectedData.Unprotect( + encrypted, + optionalEntropy: null, + scope: DataProtectionScope.CurrentUser + ); + return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(json)); + } + catch (Exception e) + { + Logger.WriteError( + "DpapiCloudTokenStore: failed to load the stored session; treating as signed out", + e + ); + return null; + } + } + + /// + /// Encrypts and writes the session, overwriting whatever was stored before. Best-effort: + /// a failure to persist must not break the sign-in that just succeeded in memory (the + /// user simply has to sign in again after a restart, no worse than today). + /// + public void Save(CloudSession session) + { + try + { + var json = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(session)); + var encrypted = ProtectedData.Protect( + json, + optionalEntropy: null, + scope: DataProtectionScope.CurrentUser + ); + RobustFile.WriteAllBytes(_filePath, encrypted); + } + catch (Exception e) + { + Logger.WriteError("DpapiCloudTokenStore: failed to persist the session", e); + } + } + + /// Deletes the stored session file, if any. Best-effort, same rationale as + /// : sign-out must succeed in memory regardless of disk state. + public void Clear() + { + try + { + if (RobustFile.Exists(_filePath)) + RobustFile.Delete(_filePath); + } + catch (Exception e) + { + Logger.WriteError("DpapiCloudTokenStore: failed to delete the stored session", e); + } + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/DevCloudAuthProvider.cs b/src/BloomExe/TeamCollection/Cloud/DevCloudAuthProvider.cs new file mode 100644 index 000000000000..876adf9fce91 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/DevCloudAuthProvider.cs @@ -0,0 +1,108 @@ +using System; +using System.Net; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using RestSharp; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// Dev auth provider (`BLOOM_CLOUDTC_AUTH_MODE=dev`): signs in against the local GoTrue + /// instance bundled with the local Supabase dev stack. Any email/password is accepted — + /// an unrecognized email is signed up (auto-confirmed, per server/dev/config.auth.toml.snippet) + /// then signed in — which is what makes ad-hoc dev identities possible with no seed change. + /// Deliberately tiny and self-contained: it can be deleted without touching CloudAuth's + /// session core once a real provider exists. + /// + public class DevCloudAuthProvider : ICloudAuthProvider + { + private readonly CloudEnvironment _environment; + private RestClient _restClient; + + public DevCloudAuthProvider(CloudEnvironment environment) + { + _environment = environment; + } + + private RestClient RestClient => + _restClient ?? (_restClient = new RestClient(_environment.SupabaseUrl)); + + public CloudSession SignIn(string email, string password) + { + var signInResponse = PostAuth("token?grant_type=password", email, password); + if (signInResponse.StatusCode == HttpStatusCode.OK) + return ToSession(signInResponse); + + // Unknown email: sign up (auto-confirmed by the dev stack's + // enable_confirmations=false), then the signup response itself is a valid session. + var signUpResponse = PostAuth("signup", email, password); + if (signUpResponse.StatusCode == HttpStatusCode.OK) + return ToSession(signUpResponse); + + throw new CloudAuthException( + $"Dev sign-in failed for {email}: sign-in gave {(int)signInResponse.StatusCode} " + + $"({signInResponse.Content}); sign-up gave {(int)signUpResponse.StatusCode} " + + $"({signUpResponse.Content})" + ); + } + + public CloudSession Refresh(string refreshToken) + { + var request = new RestRequest("auth/v1/token?grant_type=refresh_token", Method.POST); + request.AddHeader("apikey", _environment.AnonKey); + request.AddJsonBody(new { refresh_token = refreshToken }); + var response = RestClient.Execute(request); + + if (response.StatusCode != HttpStatusCode.OK) + throw new CloudAuthException( + $"Dev token refresh failed: {(int)response.StatusCode} ({response.Content})" + ); + return ToSession(response); + } + + private IRestResponse PostAuth(string endpoint, string email, string password) + { + var request = new RestRequest($"auth/v1/{endpoint}", Method.POST); + request.AddHeader("apikey", _environment.AnonKey); + request.AddJsonBody(new { email, password }); + return RestClient.Execute(request); + } + + private static CloudSession ToSession(IRestResponse response) + { + JObject json; + try + { + json = JObject.Parse(response.Content); + } + catch (JsonException e) + { + throw new CloudAuthException( + "Dev auth response was not valid JSON: " + response.Content, + e + ); + } + + var accessToken = (string)json["access_token"]; + var refreshToken = (string)json["refresh_token"]; + var expiresIn = (int?)json["expires_in"] ?? 3600; + var user = json["user"]; + if (string.IsNullOrEmpty(accessToken) || user == null) + throw new CloudAuthException( + "Dev auth response was missing access_token/user: " + response.Content + ); + + return new CloudSession + { + AccessToken = accessToken, + RefreshToken = refreshToken, + Email = (string)user["email"], + UserId = (string)user["id"], + ExpiresAtUtc = DateTime.UtcNow.AddSeconds(expiresIn), + // The dev stack auto-confirms every signup (server/dev/config.auth.toml.snippet), + // so every dev session is, by definition, verified. + EmailVerified = true, + }; + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/FirebaseCloudAuthProvider.cs b/src/BloomExe/TeamCollection/Cloud/FirebaseCloudAuthProvider.cs new file mode 100644 index 000000000000..0c84f362b14f --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/FirebaseCloudAuthProvider.cs @@ -0,0 +1,203 @@ +using System; +using System.Net; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using RestSharp; + +namespace Bloom.TeamCollection.Cloud +{ + /// + /// Real (Option A) auth provider (`BLOOM_CLOUDTC_AUTH_MODE=cloud`): the user signs in on the + /// BloomLibrary-hosted browser page (the same one Bloom already opens for BloomLibrary + /// account sign-in, see BloomLibraryAuthentication.LogIn/SharingApi.HandleShowSignIn), which + /// forwards the resulting Firebase ID + refresh tokens to Bloom's token-receipt endpoint + /// (ExternalApi.cs; CONTRACTS.md's "Auth (Option A)" section documents the exact shape). + /// There is no password flow -- Firebase already authenticated the user in the browser -- + /// so always throws; the only ways into a session are + /// (the token-receipt endpoint) and + /// (CloudAuth's proactive-refresh timer / 401 retry, restoring a persisted session, etc.). + /// Identity (email/userId/emailVerified/expiry) is always read from the token's own claims, + /// never trusted from a caller -- see . + /// + public class FirebaseCloudAuthProvider : ICloudAuthProvider + { + // Google's Identity Toolkit "securetoken" REST endpoint. Not a Supabase/GoTrue URL -- + // under Option A, Bloom talks to Firebase directly to keep the session alive; Supabase + // only ever sees the resulting Firebase ID token as a bearer credential, never mints or + // refreshes one itself. + private const string SecureTokenBaseUrl = "https://securetoken.googleapis.com"; + + private readonly CloudEnvironment _environment; + private IRestExecutor _restExecutor; + + public FirebaseCloudAuthProvider(CloudEnvironment environment) + { + _environment = environment; + } + + /// Test-only seam: lets unit tests substitute a fake + /// (the same one CloudCollectionClient's tests use) so Refresh's HTTP call can be + /// exercised without a live network. Production code never needs to call this. + internal void SetRestExecutorForTests(IRestExecutor executor) => _restExecutor = executor; + + private IRestExecutor RestExecutor => + _restExecutor ?? (_restExecutor = new RestSharpExecutor(SecureTokenBaseUrl)); + + public CloudSession SignIn(string email, string password) => + throw new CloudAuthException( + "Cloud Team Collections have no password sign-in; use the BloomLibrary " + + "browser sign-in (SharingApi.HandleShowSignIn) instead." + ); + + /// The Bloom-side half of the token-receipt endpoint: turns a freshly-forwarded + /// Firebase ID+refresh token pair into a session. See the class doc comment. + public CloudSession AcceptExternalSession(string idToken, string refreshToken) + { + if (string.IsNullOrEmpty(idToken) || string.IsNullOrEmpty(refreshToken)) + throw new CloudAuthException( + "AcceptExternalSession requires both a non-empty idToken and refreshToken." + ); + return SessionFromIdToken(idToken, refreshToken); + } + + /// + /// Exchanges a refresh token for a new ID token via Google's securetoken API + /// (https://firebase.google.com/docs/reference/rest/auth#section-refresh-token), the + /// mechanism CloudAuth's proactive-refresh timer and 401-retry rely on to keep a + /// long-lived session alive without ever prompting the user again. + /// + public CloudSession Refresh(string refreshToken) + { + if (string.IsNullOrEmpty(_environment.FirebaseApiKey)) + throw new CloudAuthException( + "BLOOM_CLOUDTC_FIREBASE_API_KEY is not configured; cannot refresh a " + + "Cloud Team Collection session." + ); + + var request = new RestRequest( + $"/v1/token?key={Uri.EscapeDataString(_environment.FirebaseApiKey)}", + Method.POST + ); + // Google's securetoken endpoint takes a form-encoded body, unlike the Supabase/ + // GoTrue JSON endpoints DevCloudAuthProvider talks to. + request.AddParameter("grant_type", "refresh_token"); + request.AddParameter("refresh_token", refreshToken); + var response = RestExecutor.Execute(request); + + if (response.StatusCode != HttpStatusCode.OK) + throw new CloudAuthException( + $"Firebase token refresh failed: {(int)response.StatusCode} ({response.Content})" + ); + + JObject json; + try + { + json = JObject.Parse(response.Content); + } + catch (JsonException e) + { + throw new CloudAuthException( + "Firebase token refresh response was not valid JSON: " + response.Content, + e + ); + } + + // The securetoken response's "id_token" is the refreshed JWT carrying the claims + // SessionFromIdToken reads identity from ("access_token" is documented to carry the + // same value, kept only as a fallback in case that ever changes). + var newIdToken = (string)json["id_token"] ?? (string)json["access_token"]; + var newRefreshToken = (string)json["refresh_token"]; + if (string.IsNullOrEmpty(newIdToken) || string.IsNullOrEmpty(newRefreshToken)) + throw new CloudAuthException( + "Firebase token refresh response was missing id_token/refresh_token: " + + response.Content + ); + + return SessionFromIdToken(newIdToken, newRefreshToken); + } + + /// + /// Builds a CloudSession entirely from the ID token's own claims -- per the class doc + /// comment, identity is never trusted from a caller. Deliberately does NOT verify the + /// token's signature: that would require fetching and caching Google's rotating public + /// certs for no real benefit here, because every actual USE of the resulting + /// AccessToken is independently verified server-side (Supabase, configured for Firebase + /// third-party auth, checks the signature on every request) -- a forged/expired token + /// would simply fail there with a 401, which CloudAuth already treats as "please sign + /// in". This method only trusts the token enough to populate local, display-only state + /// (whoami / sign-in status / the emailVerified flag CONTRACTS.md's loginState surfaces). + /// + private static CloudSession SessionFromIdToken(string idToken, string refreshToken) + { + JObject claims; + try + { + claims = DecodeJwtPayload(idToken); + } + catch (Exception e) when (!(e is CloudAuthException)) + { + throw new CloudAuthException("Could not parse the Firebase ID token.", e); + } + + var email = (string)claims["email"]; + var userId = (string)claims["sub"] ?? (string)claims["user_id"]; + if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(userId)) + throw new CloudAuthException( + "Firebase ID token is missing the required 'email'/'sub' claims." + ); + + var expSeconds = (long?)claims["exp"]; + var expiresAtUtc = expSeconds.HasValue + ? DateTimeOffset.FromUnixTimeSeconds(expSeconds.Value).UtcDateTime + : DateTime.UtcNow.AddHours(1); + + return new CloudSession + { + AccessToken = idToken, + RefreshToken = refreshToken, + Email = email, + UserId = userId, + ExpiresAtUtc = expiresAtUtc, + // Firebase ID tokens always carry a top-level boolean email_verified claim (the + // same shape tc.jwt_email_verified() already special-cases in + // 20260706000001_tc_schema.sql) -- absence would mean a malformed/unexpected + // token, not "unverified", so this only ever resolves to a real true/false here. + EmailVerified = (bool?)claims["email_verified"] ?? false, + }; + } + + /// Decodes a JWT's middle (payload) segment into its claims. Does not verify + /// the signature -- see 's doc comment for why that's + /// acceptable here. + private static JObject DecodeJwtPayload(string jwt) + { + var parts = jwt?.Split('.') ?? Array.Empty(); + if (parts.Length < 2) + throw new CloudAuthException( + "Malformed JWT: expected header.payload.signature, got " + + parts.Length + + " segment(s)." + ); + var payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[1])); + return JObject.Parse(payloadJson); + } + + /// Decodes JWT-flavored base64url (`-`/`_` in place of `+`/`/`, padding + /// stripped) into raw bytes. + private static byte[] Base64UrlDecode(string input) + { + var base64 = input.Replace('-', '+').Replace('_', '/'); + switch (base64.Length % 4) + { + case 2: + base64 += "=="; + break; + case 3: + base64 += "="; + break; + } + return Convert.FromBase64String(base64); + } + } +} diff --git a/src/BloomExe/TeamCollection/DisconnectedTeamCollection.cs b/src/BloomExe/TeamCollection/DisconnectedTeamCollection.cs index 5226e44b766f..6dfd281cfd59 100644 --- a/src/BloomExe/TeamCollection/DisconnectedTeamCollection.cs +++ b/src/BloomExe/TeamCollection/DisconnectedTeamCollection.cs @@ -60,7 +60,8 @@ protected override void PutBookInRepo( string sourceBookFolderPath, BookStatus newStatus, bool inLostAndFound = false, - Action progressCallback = null + Action progressCallback = null, + string checkinComment = null ) { throw new NotImplementedException(); diff --git a/src/BloomExe/TeamCollection/FolderTeamCollection.cs b/src/BloomExe/TeamCollection/FolderTeamCollection.cs index 154b603f3591..cfb8c66254a8 100644 --- a/src/BloomExe/TeamCollection/FolderTeamCollection.cs +++ b/src/BloomExe/TeamCollection/FolderTeamCollection.cs @@ -90,11 +90,14 @@ private string GetPathForTombstone(string bookFolderName) /// if necessary generating a unique name for it. If false, put it into the main repo /// folder, overwriting any existing book. /// The book's new status, with the new VersionCode + // checkinComment is unused here: for folder TCs the message is already inside the + // book's history.db, which travels within the .bloom file we are about to write. protected override void PutBookInRepo( string sourceBookFolderPath, BookStatus status, bool inLostAndFound = false, - Action progressCallback = null + Action progressCallback = null, + string checkinComment = null ) { var bookFolderName = Path.GetFileName(sourceBookFolderPath); @@ -256,28 +259,8 @@ private string AvailableLostAndFoundPath(string bookFolderName) return AvailablePath(bookFolderName, lfPath, ".bloom"); } - private static string AvailablePath( - string bookFolderName, - string folderName, - string extension - ) - { - string bookPath; - Directory.CreateDirectory(folderName); - int counter = 0; - do - { - counter++; - // Don't use ChangeExtension here, bookFolderName may have arbitrary period - bookPath = - Path.Combine( - folderName, - bookFolderName + (counter == 1 ? "" : counter.ToString()) - ) + extension; - } while (RobustFile.Exists(bookPath)); - - return bookPath; - } + // (The AvailablePath helper this class used to define here was hoisted to the base + // TeamCollection so CloudTeamCollection's local Lost and Found can share it.) protected override void MoveRepoBookToLostAndFound(string bookName) { diff --git a/src/BloomExe/TeamCollection/RemoteBookAutoApplyQueue.cs b/src/BloomExe/TeamCollection/RemoteBookAutoApplyQueue.cs new file mode 100644 index 000000000000..7c047730872d --- /dev/null +++ b/src/BloomExe/TeamCollection/RemoteBookAutoApplyQueue.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using SIL.Reporting; + +namespace Bloom.TeamCollection +{ + /// + /// A minimal single-consumer work queue used to apply automatically-detected remote book + /// changes off the UI thread (see ), and + /// (batch item 7, progressive join) to background-download repo books that have no local + /// folder at all yet, e.g. right after joining a cloud collection. + /// Enqueuing a book that is already queued or currently being processed is a no-op -- the + /// eventual processing pass re-reads whatever is CURRENT in the repo/local state itself (see + /// TeamCollection's re-verification in its auto-apply processing method), so no information is + /// lost by not queuing a duplicate. Books are otherwise processed strictly one at a time, in + /// the order they were first queued, so a big download for one book can't be interleaved with + /// (and possibly corrupted by) another book's download. + /// lets a caller (e.g. the user selecting a not-yet-downloaded book) + /// jump an already-pending book to the head of the line without disturbing whatever book is + /// currently being processed -- the in-flight book always runs to completion first. + /// + public class RemoteBookAutoApplyQueue + { + private readonly Action _processBook; + private readonly Action _runWorker; + private readonly object _gate = new object(); + private readonly LinkedList _pending = new LinkedList(); + private readonly HashSet _pendingOrInFlight = new HashSet( + StringComparer.OrdinalIgnoreCase + ); + private bool _workerRunning; + + /// + /// Called once per queued book, one at a time, in the order first queued. Never called on + /// the thread that called unless a synchronous + /// is supplied (tests only -- see below). + /// + /// + /// How to run the consumer loop. Defaults to a threadpool task (), + /// which is what keeps book downloads off the UI thread in production. Tests can pass a + /// synchronous runner (e.g. action => action()) so the queue's effects are + /// observable immediately, without waiting for a background thread to schedule. + /// + public RemoteBookAutoApplyQueue(Action processBook, Action runWorker = null) + { + _processBook = processBook; + _runWorker = runWorker ?? (action => Task.Run(action)); + } + + /// + /// Queues a book for processing unless it is already queued or currently being processed. + /// + public void Enqueue(string bookName) + { + EnqueueInternal(bookName, front: false); + } + + /// + /// Like , but a book that is merely PENDING (not yet the one being + /// processed) jumps to the front of the line instead of the back -- used when the user + /// explicitly asks for a book (e.g. selecting a not-yet-downloaded placeholder), so it + /// arrives before books that were only queued in the background. A book already being + /// processed right now is left to finish undisturbed (its dedupe entry is already in + /// , so this is a no-op for it, same as a plain + /// would be). + /// + public void EnqueueFront(string bookName) + { + EnqueueInternal(bookName, front: true); + } + + private void EnqueueInternal(string bookName, bool front) + { + lock (_gate) + { + if (!_pendingOrInFlight.Add(bookName)) + { + // Already queued or currently being processed. If it's only queued (not the + // book actually in flight right now, which was already removed from _pending + // when the worker dequeued it), honor the priority request by moving it to + // the front. + if (front) + { + var node = _pending.Find(bookName); + if (node != null) + { + _pending.Remove(node); + _pending.AddFirst(bookName); + } + } + return; // the eventual pass will see current state + } + if (front) + _pending.AddFirst(bookName); + else + _pending.AddLast(bookName); + if (_workerRunning) + return; // a worker loop is already draining the queue + _workerRunning = true; + } + _runWorker(RunLoop); + } + + /// For tests: how many books are currently queued or being processed. + internal int CountForTests + { + get + { + lock (_gate) + return _pendingOrInFlight.Count; + } + } + + private void RunLoop() + { + while (true) + { + string bookName; + lock (_gate) + { + if (_pending.Count == 0) + { + _workerRunning = false; + return; + } + bookName = _pending.First.Value; + _pending.RemoveFirst(); + } + try + { + _processBook(bookName); + } + catch (Exception e) + { + // One book's failure must not stop the queue from processing the rest, or + // from accepting new work afterwards. + Logger.WriteError( + $"RemoteBookAutoApplyQueue: error auto-applying remote change for '{bookName}'", + e + ); + } + finally + { + lock (_gate) + _pendingOrInFlight.Remove(bookName); + } + } + } + } +} diff --git a/src/BloomExe/TeamCollection/TeamCollection.AutoApply.cs b/src/BloomExe/TeamCollection/TeamCollection.AutoApply.cs new file mode 100644 index 000000000000..1f90e18a32b0 --- /dev/null +++ b/src/BloomExe/TeamCollection/TeamCollection.AutoApply.cs @@ -0,0 +1,288 @@ +using System; +using System.IO; +using SIL.Reporting; + +namespace Bloom.TeamCollection +{ + public abstract partial class TeamCollection + { + /// + /// True for backends where a remote change to a book that is safe to apply (not checked + /// out here, no local edits that would be clobbered) should be downloaded and swapped in + /// automatically, rather than merely reported via a "click Reload/Sync" message. False (the + /// default) preserves every folder-TC behavior exactly. See + /// 's override and 's + /// use of this flag. + /// + protected virtual bool CanAutoApplyRemoteChanges => false; + + private RemoteBookAutoApplyQueue _autoApplyQueue; + private bool _autoApplyQueueSynchronousForTests; + + /// + /// Lazily-created so a backend that never sets true + /// (every folder TC) never spins up any queueing machinery at all. + /// + private RemoteBookAutoApplyQueue AutoApplyQueue => + _autoApplyQueue ??= new RemoteBookAutoApplyQueue( + ProcessAutoApplyRemoteChange, + _autoApplyQueueSynchronousForTests ? (Action)(action => action()) : null + ); + + /// + /// For testing only. Makes the auto-apply queue's worker run synchronously (on the calling + /// thread, immediately, inside Enqueue) instead of via a background task, so a test can + /// assert on the outcome of HandleModifiedFile's auto-apply path without needing to wait + /// for a real background thread. Must be called before the first book is queued. + /// + internal void TestOnly_MakeAutoApplyQueueSynchronous() + { + _autoApplyQueueSynchronousForTests = true; + } + + /// + /// For testing only. Directly invokes the auto-apply worker's eligibility re-verification + /// and copy logic for one book, bypassing the queue entirely -- lets a test exercise + /// ProcessAutoApplyRemoteChange's behavior for a specific state without needing to win a + /// real race between queueing and background processing. + /// + internal void TestOnly_ProcessAutoApplyRemoteChange(string bookBaseName) + { + ProcessAutoApplyRemoteChange(bookBaseName); + } + + /// + /// Batch item 7 (progressive join): queues a repo book (by folder name) for background + /// download via the same single-consumer queue + /// backends use to auto-apply remote changes. Used by + /// right after joining (instead of blocking the join on every book's full download) and by + /// 's cloud rerouting for books that are still missing locally. + /// A no-op for backends that don't set true (every + /// folder TC): a folder TC has no use for background-downloading a book with no local + /// folder at all, so this simply does nothing rather than spinning up queueing machinery it + /// will never need. + /// + internal void QueueBookForBackgroundDownload(string bookName) + { + if (!CanAutoApplyRemoteChanges) + return; + AutoApplyQueue.Enqueue(bookName); + } + + /// + /// Like , but jumps the book to the front of + /// the queue (see ) -- used when the + /// user explicitly selects a not-yet-downloaded book, so it arrives before books that were + /// only queued in the background. + /// + internal void PrioritizeBackgroundDownload(string bookName) + { + if (!CanAutoApplyRemoteChanges) + return; + AutoApplyQueue.EnqueueFront(bookName); + } + + /// + /// Queues a background download for every repo book that has no local folder and is not + /// checked out by the current user. This is the self-healing safety net for the + /// progressive-join pipeline (batch item 7): the in-memory download queue does not survive + /// a Bloom restart (the join flow's pullDown-then-relaunch pattern, or a crash mid-join), + /// and a book the queue somehow dropped would otherwise never be retried, because the poll + /// only raises change events for books whose repo state CHANGED since the last poll. + /// Called when monitoring starts (right after the startup sync) and again after every + /// poll, so any locally-missing repo book is re-queued within one poll interval no matter + /// how it was missed. Enqueue's dedupe makes repeat calls cheap and safe. + /// Books locked by the CURRENT USER ON THIS MACHINE are skipped: that is the + /// local-rename-mid-checkin edge, where the OLD repo name intentionally has no local + /// folder and downloading it would resurrect the pre-rename book. Any other lock must NOT + /// suppress the download -- a teammate's lock (e2e-4, 10 Jul 2026: an any-lock skip + /// turned one transient download failure into "book missing for as long as the teammate + /// held the lock") and even the current user's own lock taken on a DIFFERENT machine + /// (preflight review finding, same day: the rename edge is machine-local, "checked out + /// here" everywhere else in this file means lockedBy AND lockedWhere match, and + /// SyncAtStartup happily fetches such a book on restart -- the retry pass must agree + /// with it) both describe committed content that is exactly what Receive would fetch. + /// + internal void QueueMissingRepoBooksForBackgroundDownload() + { + if (!CanAutoApplyRemoteChanges) + return; + // Caller-owned scan state for the rename check: a backend that would otherwise redo + // per-book work across this loop builds an index into it the first time it's needed + // and reuses it for the rest of this pass (see NewBookRenamedFrom(name, ref scanState)). + // A local, so it's fresh every poll and confined to this thread. + object renameScanState = null; + foreach (var bookName in GetBookList()) + { + if (Directory.Exists(Path.Combine(_localCollectionFolder, bookName))) + continue; + // A repo book with no folder of ITS name but which is a rename of an existing + // local book is NOT missing -- downloading it would create a duplicate of that + // book under the new name (bug #18). The rename itself is applied by the next + // sync's rename-from-remote pass; HandleModifiedFile/HandleNewBook report it. + var renamedFrom = NewBookRenamedFrom(bookName, ref renameScanState); + if (renamedFrom != null) + { + Logger.WriteEvent( + $"TeamCollection: repo book '{bookName}' is a rename of local book '{renamedFrom}'; not queueing a download." + ); + continue; + } + var lockedBy = WhoHasBookLocked(bookName); + if ( + !string.IsNullOrEmpty(lockedBy) + && string.Equals( + lockedBy, + CurrentUserIdentity, + StringComparison.OrdinalIgnoreCase + ) + && WhatComputerHasBookLocked(bookName) == TeamCollectionManager.CurrentMachine + ) + continue; + Logger.WriteEvent( + $"TeamCollection: repo book '{bookName}' has no local folder; queueing background download." + ); + QueueBookForBackgroundDownload(bookName); + } + } + + /// + /// Runs on a background thread (see and + /// ): re-verifies that it is still safe to apply this + /// book's remote change -- the state at the time this runs may differ from the state at the + /// time HandleModifiedFile queued it, since queueing and processing happen at different + /// times -- and if so, downloads and atomically swaps in the new content + /// (CopyBookFromRepoToLocal already stages to a temp folder and swaps via directory + /// renames, so there is no user-visible half-written state). On success, updates the book's + /// status icon and, if this book is the one currently selected, tells the preview to + /// refresh so the user sees the new content without reselecting. On failure, or if the book + /// is no longer eligible, falls back to exactly the same NewStuff message an + /// auto-apply-incapable backend (e.g. a folder TC) would have written instead. + /// + /// Batch item 7 (progressive join): this same queue also carries books that have NO local + /// folder at all yet (queued by from + /// or 's cloud rerouting). + /// That case is simpler -- there's no existing local content to re-verify eligibility + /// against or protect -- so it's handled by + /// instead of the auto-apply re-verification below. + /// + private void ProcessAutoApplyRemoteChange(string bookBaseName) + { + if (!Directory.Exists(Path.Combine(_localCollectionFolder, bookBaseName))) + { + DownloadMissingBookInBackground(bookBaseName); + return; + } + + // Re-verify eligibility: none of these should be true, or auto-applying now would be + // wrong -- e.g. the user might have checked the book out here, or a conflicting local + // edit might have appeared, since this book was queued. + if ( + !HasBeenChangedRemotely(bookBaseName) + || IsCheckedOutHereBy(GetLocalStatus(bookBaseName)) + || HasLocalChangesThatMustBeClobbered(bookBaseName) + || HasCheckoutConflict(bookBaseName) + ) + return; // no longer (or not yet) safe/needed; leave it for the normal handling + + // Batch item 8: if the local copy somehow changed since the last sync (e.g. a + // force-stolen checkout that had local edits -- a case the eligibility gates above + // cannot see for cloud, since cloud checkouts don't write the local status file), + // preserve it before the swap below discards it. + PreserveLocalCopyIfModifiedSinceLastSync(bookBaseName); + + var error = CopyBookFromRepoToLocal(bookBaseName); + if (error != null) + { + // Fall back to exactly the message-only path so the user at least knows to Sync/Reload. + _tcLog.WriteMessage( + MessageAndMilestoneType.NewStuff, + "TeamCollection.BookModifiedRemotely", + "One of your teammates has made changes to the book '{0}'", + bookBaseName, + null + ); + UpdateBookStatus(bookBaseName, true); + return; + } + + UpdateBookStatus(bookBaseName, true); + + // If the book we just updated is the one currently selected, refresh the preview so the + // user sees the new content without having to reselect the book. + var selectedFolder = _tcManager?.BookSelection?.CurrentSelection?.FolderPath; + if ( + !string.IsNullOrEmpty(selectedFolder) + && string.Equals( + Path.GetFileName(selectedFolder), + bookBaseName, + StringComparison.OrdinalIgnoreCase + ) + ) + { + _tcManager.SendBookContentReload(); + } + } + + /// + /// Batch item 7 (progressive join): downloads a repo book that has no local folder at all + /// yet (queued by /, + /// e.g. right after joining a cloud collection, or by 's cloud + /// rerouting for a half-joined collection's next open). Unlike + /// 's re-verification, there is no existing local + /// content to check eligibility against or protect -- the only thing that could have + /// changed since queueing is that the book itself vanished from the repo (e.g. deleted + /// before its background download ran), which catches. + /// On success, invalidates the cached book list and tells the collection-tab UI to reload + /// it so the not-yet-downloaded placeholder (see CollectionApi.HandleBooksRequest) swaps + /// for the real book button. + /// + private void DownloadMissingBookInBackground(string bookBaseName) + { + if (!IsBookPresentInRepo(bookBaseName)) + { + // Usually the book was deleted/renamed remotely between queueing and now; but a + // cache problem would look identical, so never skip SILENTLY (the first post-batch + // full matrix, 10 Jul 2026, lost a book to an undiagnosable silent drop here -- + // this log line plus the QueueMissingRepoBooksForBackgroundDownload retry pass are + // the fix). + Logger.WriteEvent( + $"Background download of '{bookBaseName}' skipped: the current repo cache does not list it (deleted remotely, renamed, or a cache problem)." + ); + return; + } + + // Guard against the queued-before-the-rename-landed race (bug #18): by the time this + // runs, the "missing" repo book may have turned out to be a rename of an existing + // local book -- downloading it would duplicate that book under its new name. (After + // the presence check on purpose: rename detection reads the repo book's meta.json, + // which a repo-deleted book no longer has.) + var renamedFrom = NewBookRenamedFrom(bookBaseName); + if (renamedFrom != null) + { + Logger.WriteEvent( + $"Background download of '{bookBaseName}' skipped: it is a rename of the local book '{renamedFrom}' (applied at the next sync)." + ); + return; + } + + var error = CopyBookFromRepoToLocal(bookBaseName); + if (error != null) + { + Logger.WriteEvent( + $"Background download of new book '{bookBaseName}' failed: {error}" + ); + return; + } + Logger.WriteEvent($"Background download of new book '{bookBaseName}' completed."); + UpdateBookStatus(bookBaseName, true); + + // Swap the placeholder for the real book button: the JSON collections/books merge + // (CollectionApi.HandleBooksRequest) only shows a placeholder while GetBookInfos() + // finds no matching local folder, so the cached book list must be invalidated before + // the client re-fetches it. + _bookCollectionHolder?.TheOneEditableCollection?.InvalidateBookList(); + SocketServer?.SendEvent("editableCollectionList", "reload:" + _localCollectionFolder); + } + } +} diff --git a/src/BloomExe/TeamCollection/TeamCollection.ErrorReporting.cs b/src/BloomExe/TeamCollection/TeamCollection.ErrorReporting.cs index 4ef30d61947c..6a177d5e2ce9 100644 --- a/src/BloomExe/TeamCollection/TeamCollection.ErrorReporting.cs +++ b/src/BloomExe/TeamCollection/TeamCollection.ErrorReporting.cs @@ -9,6 +9,7 @@ using Bloom.web.controllers; using DesktopAnalytics; using L10NSharp; +using SIL.Reporting; namespace Bloom.TeamCollection { @@ -34,6 +35,12 @@ void ReportProgressAndLog( ? message : string.Format(LocalizationManager.GetString(fullL10nId, message), param0, param1); progress.MessageWithoutLocalizing(msg, kind); + // Also record in the durable SIL log file: the progress dialog is transient and the + // TC message log lives in the collection folder (gone if the folder is deleted or, in + // E2E runs, wiped by the next scenario), so without this there is NO surviving record + // of what the startup sync decided (found diagnosing the 10 Jul 2026 silent + // background-download drop, where all three logs had vanished). + Logger.WriteEvent("TC sync: " + msg); _tcLog.WriteMessage( (kind == ProgressKind.Progress) ? MessageAndMilestoneType.History diff --git a/src/BloomExe/TeamCollection/TeamCollection.cs b/src/BloomExe/TeamCollection/TeamCollection.cs index 5a72eb5c8250..a43be47bfaec 100644 --- a/src/BloomExe/TeamCollection/TeamCollection.cs +++ b/src/BloomExe/TeamCollection/TeamCollection.cs @@ -149,7 +149,8 @@ protected abstract void PutBookInRepo( string sourceBookFolderPath, BookStatus newStatus, bool inLostAndFound = false, - Action progressCallback = null + Action progressCallback = null, + string checkinComment = null ); public abstract bool KnownToHaveBeenDeleted(string oldName); @@ -181,7 +182,7 @@ public bool OkToCheckIn(string bookName) } if ( - repoStatus.lockedBy == TeamCollectionManager.CurrentUser + repoStatus.lockedBy == CurrentUserIdentity && repoStatus.lockedWhere == TeamCollectionManager.CurrentMachine ) { @@ -195,6 +196,14 @@ public bool OkToCheckIn(string bookName) // the book. We can go ahead. return true; } + + // Account-switch takeover (batch item 9): it's locked to someone else, but that lock + // is for THIS machine, and this backend allows handing it over instead of blocking. + // PutBookInRepo performs the actual server-side handover before this check-in + // proceeds, so by the time the repo is touched the lock legitimately belongs to us. + if (CanTakeOverLockOnThisMachine(bookName, repoStatus)) + return true; + // It's checked out somewhere else according to the repo. They haven't changed it yet, // but the repo says they have the right to. return false; @@ -282,12 +291,17 @@ public List ForgetChangesCheckin(string bookName) /// If true, put the book into the Lost-and-found folder, /// if necessary generating a unique name for it. If false, put it into the main repo /// folder, overwriting any existing book. + /// The user's "what did you change?" message for a checkin. + /// Folder TCs ignore it (the message travels inside the book's history.db, which is + /// part of the .bloom file), but cloud TCs must send it explicitly because remote + /// users read history from the server's event log, not from history.db. /// Updated book status public BookStatus PutBook( string folderPath, bool checkin = false, bool inLostAndFound = false, - Action progressCallback = null + Action progressCallback = null, + string checkinComment = null ) { var bookFolderName = Path.GetFileName(folderPath); @@ -318,7 +332,7 @@ public BookStatus PutBook( // This is essential for the case when oldName is missing. See BL-16226. status = status.WithOldName(null); } - PutBookInRepo(folderPath, status, inLostAndFound, progressCallback); + PutBookInRepo(folderPath, status, inLostAndFound, progressCallback, checkinComment); // If this is true, we're about to delete or overwrite the book, so no point // in updating its status (and we never call with this true in regard to a rename). if (inLostAndFound) @@ -490,6 +504,12 @@ private void OnChanged(object sender, FileSystemEventArgs e) if (e.Name == kLastcollectionfilesynctimeTxt) return; // side effect of doing a sync! + if (e.Name == TeamCollectionLastKnownUser.FileName) + return; // local-only sidecar, written from CheckConnection -- which the idle sync + // this handler schedules itself calls, so reacting to it here creates an infinite + // idle-loop of network calls that starves the UI thread (found live, 10 Jul 2026). + // It must also never be treated as a syncable collection file: it is per-machine + // state, not shared content. if (Directory.Exists(e.FullPath)) return; // we seem to get frequent notifications that seem to be spurious for book folders. // We'll wait for the system to be idle before writing to the repo. This helps to ensure things @@ -514,15 +534,59 @@ private void OnChanged(object sender, FileSystemEventArgs e) /// /// Returns true if the book must be checked out before editing it (etc.), - /// that is, if it is NOT already checked out on this machine by this user. + /// that is, if it is NOT already editable here (see ). /// /// /// public bool NeedCheckoutToEdit(string bookFolderPath) { - return !IsCheckedOutHereBy(GetStatus(Path.GetFileName(bookFolderPath))); + var bookName = Path.GetFileName(bookFolderPath); + return !IsEditableHere(bookName, GetStatus(bookName)); } + /// + /// Virtual seam for "is this book editable by the CURRENT user right now" -- deliberately + /// distinct from , which asks "is it + /// checked out by exactly this identity" and is relied on elsewhere (sync-at-startup + /// conflict/clobber reconciliation, delete gating, etc.) for a strict identity match that + /// must NOT be loosened. The default here is the same strict check. CloudTeamCollection + /// overrides it for the account-switch scenario (batch item 9, + /// Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md): a book left checked out + /// HERE (this machine) by a DIFFERENT signed-in team member is still editable by the + /// current member -- John's decision: "local machine access is unrestricted; only + /// shared-data operations are gated by the CURRENT logon's server permissions." The + /// server-side lock itself only actually moves to the current user lazily, the first time + /// we need to push a change (see CanTakeOverLockOnThisMachine/TryTakeOverLock). + /// The bookName parameter lets the cloud override consult per-book state its BookStatus + /// doesn't carry (the lock's "seat" — which local copy of the collection holds it; bug #0). + /// + protected internal virtual bool IsEditableHere(string bookName, BookStatus status) => + IsCheckedOutHereBy(status); + + /// + /// Virtual seam: true if represents a lock this backend is + /// willing to atomically hand over to the current user instead of treating it as a + /// conflict. The base (folder) implementation never allows this. CloudTeamCollection + /// overrides it to allow same-machine, same-seat, different-account takeover (batch + /// item 9 + bug #0) -- never across machines or across local collection copies, which + /// remain genuine conflicts. Used by both + /// (so an account-switched check-in isn't blocked) and + /// (so an explicit "check out" click on such a book performs + /// the handover instead of silently failing). + /// + protected internal virtual bool CanTakeOverLockOnThisMachine( + string bookName, + BookStatus repoStatus + ) => false; + + /// + /// Virtual seam: actually perform the atomic same-machine lock takeover + /// judged eligible. Base (folder) does nothing + /// (and should never be asked to, since CanTakeOverLockOnThisMachine is always false + /// there). Returns true if the caller may proceed as lock holder (including the cloud case where a never-committed book has no server lock to take over). + /// + protected internal virtual bool TryTakeOverLock(string bookName) => false; + public abstract void DeleteBookFromRepo(string bookFolderPath, bool makeTombstone = true); public abstract void RenameBookInRepo(string newBookFolderPath, string oldName); @@ -558,6 +622,27 @@ protected virtual internal void StopMonitoring() /// public virtual bool IsDisconnected => false; + /// + /// True if this backend supports browsing/restoring version history. + /// The folder backend returns false; the cloud backend will return true once implemented. + /// UI should branch on this flag rather than testing the concrete type. + /// + public virtual bool SupportsVersionHistory => false; + + /// + /// True if this backend supports the Sharing panel (approved-accounts management). + /// The folder backend returns false; the cloud backend will return true once implemented. + /// UI should branch on this flag rather than testing the concrete type. + /// + public virtual bool SupportsSharingUi => false; + + /// + /// True if this backend requires the user to be signed in before performing TC operations. + /// The folder backend returns false; the cloud backend will return true once implemented. + /// UI should branch on this flag rather than testing the concrete type. + /// + public virtual bool RequiresSignIn => false; + /// /// Common part of getting book status as recorded in the repo, or if it is not in the repo /// but there is such a book locally, treat as locked by FakeUserIndicatingNewBook. @@ -696,14 +781,14 @@ public void CopyAllBooksFromRepoToLocalFolder(string destinationCollectionFolder // Unlock the book, making it available for anyone to edit. public void UnlockBook(string bookName) { - WriteBookStatus(bookName, GetStatus(bookName).WithLockedBy(null)); + UnlockInRepo(bookName, force: false); } // Lock the book, making it available for the specified user to edit. Return true if successful. // We must ensure that the user has registered a valid email address before making the call to the API endpoint which calls this. public bool AttemptLock(string bookName, string email = null) { - var whoBy = email ?? TeamCollectionManager.CurrentUser; + var whoBy = email ?? CurrentUserIdentity; Debug.Assert(!string.IsNullOrWhiteSpace(whoBy)); var status = GetStatus(bookName); @@ -714,7 +799,28 @@ public bool AttemptLock(string bookName, string email = null) TeamCollectionManager.CurrentUserFirstName, TeamCollectionManager.CurrentUserSurname ); - WriteBookStatus(bookName, status); + if (!TryLockInRepo(bookName, status)) + { + // The backend refused the lock (a cloud backend loses the race when someone + // else checked the book out first; the folder backend never refuses). Re-read + // so the status we report below reflects the repo's truth about who holds the + // lock rather than the optimistic lockedBy we stamped above. + status = GetStatus(bookName); + } + } + else if ( + !IsDisconnected + && status.lockedBy != whoBy + && CanTakeOverLockOnThisMachine(bookName, status) + ) + { + // Account-switch takeover (batch item 9): an explicit "check out" click on a book + // that's already editable-here-by-a-different-account (see IsEditableHere) + // performs the same atomic server-side handover that PutBookInRepo otherwise does + // lazily on first check-in. Re-read afterwards so the return value below reflects + // the repo's truth (whether we won the handover or someone else changed it first). + if (TryTakeOverLock(bookName)) + status = GetStatus(bookName); } // If we succeeded, we definitely want various things to update to show it. @@ -728,12 +834,43 @@ public bool AttemptLock(string bookName, string email = null) public void ForceUnlock(string bookName) { - var status = GetStatus(bookName); - status = status.WithLockedBy(null); - WriteBookStatus(bookName, status); + UnlockInRepo(bookName, force: true); UpdateBookStatus(bookName, true); } + /// + /// Virtual seam for the lock operation. The base (folder) implementation performs a + /// read-modify-write of the book status in the repo. Cloud subclasses will override + /// this with a single conditional RPC so the checkout is race-free. + /// + /// The folder name of the book to lock. + /// The status object with the lock fields already set. + /// + /// True if the lock was successfully recorded; false if the book was already locked + /// by someone else (the caller should re-read status to find out who won). + /// The folder implementation always returns true because it writes unconditionally. + /// + protected virtual bool TryLockInRepo(string bookName, BookStatus newStatus) + { + WriteBookStatus(bookName, newStatus); + return true; + } + + /// + /// Virtual seam for the unlock operation. The base (folder) implementation performs a + /// read-modify-write of the book status in the repo. Cloud subclasses will override + /// this with a single RPC. + /// + /// The folder name of the book to unlock. + /// + /// When true the unlock is a forced checkout reversal (admin operation); when false it + /// is a normal unlock/undo-checkout. + /// + protected virtual void UnlockInRepo(string bookName, bool force) + { + WriteBookStatus(bookName, GetStatus(bookName).WithLockedBy(null)); + } + // Get the email of the user, if any, who has the book locked. Returns null if not locked. // As a special case, if the book exists only locally, we return TeamRepo.kThisUser. public virtual string WhoHasBookLocked(string bookName) @@ -1259,7 +1396,7 @@ public bool AnyBooksCheckedOutHereByCurrentUser var localStatus = GetLocalStatus(Path.GetFileName(path)); if (localStatus.lockedBy == TeamCollection.FakeUserIndicatingNewBook) continue; - if (localStatus.IsCheckedOutHereBy(TeamCollectionManager.CurrentUser)) + if (localStatus.IsCheckedOutHereBy(CurrentUserIdentity)) return true; } catch (Exception) @@ -1293,7 +1430,7 @@ internal void HandleDeletedRepoFile(string fileName) } var status = GetLocalStatus(bookBaseName); - if (status.IsCheckedOutHereBy(TeamCollectionManager.CurrentUser)) + if (status.IsCheckedOutHereBy(CurrentUserIdentity)) { //Debug.WriteLine("Deleted checked out book: " + bookBaseName); // Argh! Somebody deleted the book I'm working on! This is an error, but Reloading the collection @@ -1563,6 +1700,16 @@ public void HandleModifiedFile(BookRepoChangeEventArgs args) ); } } + else if (CanAutoApplyRemoteChanges) + { + // This backend (currently only CloudTeamCollection) applies safe remote + // changes automatically instead of merely reporting them. Queue the actual + // download/swap on a background thread (HandleModifiedFile itself runs at + // Application.Idle, on the UI thread, and cloud downloads can be slow) -- + // see ProcessAutoApplyRemoteChange, which re-verifies eligibility before + // touching anything, since state may have moved on by the time it runs. + AutoApplyQueue.Enqueue(bookBaseName); + } else { _tcLog.WriteMessage( @@ -1581,6 +1728,42 @@ public void HandleModifiedFile(BookRepoChangeEventArgs args) } } + /// + /// A hook for backends that can preserve a doomed local copy before a sync overwrites it + /// (cloud saves a .bloomSource to Lost and Found and logs an incident; see + /// CloudTeamCollection's override). Base implementation does nothing: folder TCs never + /// reach the call sites (auto-apply and cloud Receive Updates are cloud-only paths). + /// + protected virtual void PreserveLocalCopyForRecoveryBeforeOverwrite( + string bookFolderName + ) { } + + /// + /// John's recovery decision (9 Jul 2026, dogfood batch item 8): when a sync is about to + /// replace the local copy of a book with the repo version, but the local copy has somehow + /// changed since the last sync (rare -- e.g. a force-stolen checkout that was edited, or + /// unexplained local drift), we still go ahead and make local consistent with the Team + /// Collection, but FIRST preserve the previous local content so nothing is silently lost. + /// "Changed since the last sync" is a pure content comparison (current checksum vs the + /// checksum the local status recorded), deliberately NOT gated on checked-out-here: cloud + /// checkouts never write the local status file, which is exactly why the older + /// HasLocalChangesThatMustBeClobbered gate can't see this case (tasks/09-e2e.md, E2E-4). + /// + public void PreserveLocalCopyIfModifiedSinceLastSync(string bookFolderName) + { + var bookPath = Path.Combine(_localCollectionFolder, bookFolderName); + if (!Directory.Exists(bookPath)) + return; // nothing local to preserve + var currentChecksum = MakeChecksum(bookPath); + if (string.IsNullOrEmpty(currentChecksum)) + return; // can't checksum it (e.g. no .htm); nothing meaningful to preserve + // A missing/empty recorded checksum counts as "modified": a local folder the sync + // never recorded is exactly the kind of content we must not silently discard. + if (currentChecksum == GetLocalStatus(bookFolderName).checksum) + return; // unchanged since last sync; overwriting loses nothing + PreserveLocalCopyForRecoveryBeforeOverwrite(bookFolderName); + } + /// /// Given that the specified book exists in both the repo and locally, /// if the names differ only by case, rename the local book to match the repo. @@ -1669,10 +1852,17 @@ private bool HandlePossibleRename(string bookBaseName) /// that does not occur locally, can we determine that it is a rename /// of a local book? If so, return the book it is renamed from, /// otherwise, return null. + /// Virtual (bug #18, 13 Jul 2026): this base implementation's "has repo status => + /// can't be the rename source" heuristic assumes name-keyed status, which is true for + /// folder TCs (unchanged) but inverted for CloudTeamCollection's identity-first + /// resolution -- there the OLD-name folder resolving to a (renamed) repo row is exactly + /// what marks it as the rename source, so the cloud backend overrides this with an + /// exact instance-id comparison. protected INTERNAL only so tests can exercise the + /// overrides directly. /// /// /// - private string NewBookRenamedFrom(string newBookName) + protected internal virtual string NewBookRenamedFrom(string newBookName) { var meta = GetRepoBookFile(newBookName, "meta.json"); if (string.IsNullOrEmpty(meta) || meta == "error") @@ -1700,6 +1890,23 @@ private string NewBookRenamedFrom(string newBookName) return null; } + /// + /// Bulk-scan variant of , called once per repo book + /// by . A backend that would + /// otherwise redo the same per-call work (e.g. re-enumerating every local book folder) may + /// stash a prebuilt index in : it is a caller-owned local (see + /// the pass), so it is naturally scoped to one scan and confined to the scanning thread — + /// no cross-poll staleness and no locking. The default ignores it and defers to the + /// per-book method. + /// + protected internal virtual string NewBookRenamedFrom( + string newBookName, + ref object scanState + ) + { + return NewBookRenamedFrom(newBookName); + } + /// /// Handle a new book we have detected from NewBook event. /// Might be a new book from remote user. If so unpack to local. @@ -1883,6 +2090,40 @@ static void AddIfExists(List paths, string path) } } + /// + /// Deletes per-book and per-collection artifacts left behind by a Team Collection that + /// this local collection folder used to belong to (typically a folder-based TC the user + /// has since disconnected from -- "un-teamed" -- by removing TeamCollectionLink.txt, but + /// whose local files were never cleaned up). Called before converting a plain local + /// collection into a fresh cloud Team Collection + /// (), so the very first + /// upload of each book starts from a clean slate rather than carrying over a stale + /// checksum or lockedBy value from the old TC: / + /// fall back to local status whenever the repo has no record for a book yet, which is + /// true for every book in the collection on its first cloud Send. + /// Deliberately does NOT touch TeamCollectionLink.txt -- callers decide separately + /// whether an existing link is a conflict that should block the conversion entirely. + /// Safe to call on a collection that was never a Team Collection (no matching files + /// found, so this is a no-op). + /// + public static void CleanStaleTeamCollectionArtifacts(string localCollectionFolder) + { + foreach (var bookFolder in Directory.EnumerateDirectories(localCollectionFolder)) + { + var statusFile = GetStatusFilePathFromBookFolderPath(bookFolder); + if (RobustFile.Exists(statusFile)) + RobustFile.Delete(statusFile); + } + + var lastSyncFile = Path.Combine(localCollectionFolder, kLastcollectionfilesynctimeTxt); + if (RobustFile.Exists(lastSyncFile)) + RobustFile.Delete(lastSyncFile); + + var logFile = Path.Combine(localCollectionFolder, "log.txt"); + if (RobustFile.Exists(logFile)) + RobustFile.Delete(logFile); + } + internal BookStatus GetLocalStatus(string bookFolderName, string collectionFolder = null) { var statusFilePath = GetStatusFilePath( @@ -1928,12 +2169,22 @@ internal static string MakeChecksum(string folderPath) /// internal bool IsCheckedOutHereBy(BookStatus status, string email = null) { - var whoBy = email ?? TeamCollectionManager.CurrentUser; + var whoBy = email ?? CurrentUserIdentity; if (whoBy == null) return false; return status.IsCheckedOutHereBy(whoBy); } + /// + /// The identity that checkout ownership is compared against (and stamped with, absent an + /// explicit email). Folder TCs use Bloom's registration email, as always. The cloud + /// backend overrides this with the signed-in ACCOUNT email: the server stamps locks from + /// the auth token, so comparing against anything else calls the user's own checkout + /// someone else's — which is exactly what disabled editing in the first two-instance + /// smoke test (registration john_thomson@sil.org vs lock owner alice@dev.local). + /// + protected internal virtual string CurrentUserIdentity => TeamCollectionManager.CurrentUser; + bool IsBloomBookFolder(string folderPath) { return !string.IsNullOrEmpty(BookStorage.FindBookHtmlInFolder(folderPath)); @@ -2087,6 +2338,83 @@ out string statusJson bookFolderName, _localCollectionFolder ); + + // Remote rename under identity-first resolution (cloud; bug B). The rename + // branch further down only runs when statusJson == null (a folder TC's renamed + // book has no repo file under its OLD name). But a cloud TC resolves a local + // folder to its repo row by the meta.json INSTANCE ID, so the old-name folder + // still gets a non-null status -- and that branch is skipped, leaving the folder + // un-renamed while a later pass downloads the new name as a SECOND folder with + // the same instance id (John's live report, 14 Jul 2026). Detect it here by + // instance id: the repo has this book under a DIFFERENT name with no local + // folder by that new name. Rename the local folder IN PLACE (keeps its history, + // avoids the old delete + full re-unzip), then bring its content up to date. + // Two guards before applying: + // - The REPO lock must not be ours-on-this-machine: that is the local-rename- + // mid-checkin edge (we renamed a checked-out book and haven't checked in; + // the repo's old name is expected), and renaming it back would clobber our + // checked-out work. The repo status is the authoritative test -- cloud + // checkouts do NOT stamp the LOCAL status (TryLockInRepo is RPC+cache only), + // and local status can carry a STALE teammate lock from an earlier + // accept-remote-lock sync, so a local-status guard fails in both directions. + // Same machine-local rule as QueueMissingRepoBooksForBackgroundDownload. + // - NewBookRenamedFrom(newName) must name THIS folder as the rename source. + // For the cloud backend that is the exact instance-id confirmation; for a + // folder TC the base implementation always answers null here (a folder with + // repo status "can't be the source of a rename"), so folder TCs provably + // never enter this block and keep their existing statusJson==null path. + if (statusJson != null) + { + var localIdForRename = BookMetaData.FromFolder(path)?.Id; + if ( + localIdForRename != null + && repoBooksByIdMap.TryGetValue( + localIdForRename, + out Tuple renameTarget + ) + && !renameTarget.Item2 // no local folder by the repo's (new) name yet + && !string.Equals( + renameTarget.Item1, + bookFolderName, + StringComparison.OrdinalIgnoreCase + ) + && !IsCheckedOutHereBy(BookStatus.FromJson(statusJson)) + && string.Equals( + NewBookRenamedFrom(renameTarget.Item1), + bookFolderName, + StringComparison.OrdinalIgnoreCase + ) + ) + { + var newName = renameTarget.Item1; + ReportProgressAndLog( + progress, + ProgressKind.Progress, + "RenameFromRemote", + "The book \"{0}\" has been renamed to \"{1}\" by a teammate.", + bookFolderName, + newName + ); + SIL.IO.RobustIO.MoveDirectory( + path, + Path.Combine(_localCollectionFolder, newName) + ); + remotelyRenamedBooks.Add(newName); + // Bring the renamed folder up to the current version. The cloud Receive + // seeds its staging folder from the local copy and re-downloads only the + // files that actually changed (see CloudTeamCollection.FetchBookFromRepo), + // so this is cheap even for a pure rename (nothing transfers) -- and it + // always refreshes the recorded local version, so the book never lingers + // in an "updates available" state after a rename. + hasProblems |= !CopyBookFromRepoToLocalAndReport( + progress, + newName, + () => { } // the rename message above already covers success + ); + continue; + } + } + if (statusJson == null) // includes cases where validRepoStatus is false { if (firstTimeJoin && validRepoStatus) @@ -2175,7 +2503,7 @@ out Tuple repoState continue; // It's now missing from the repo. Explore more options. if ( - statusLocal.lockedBy == TeamCollectionManager.CurrentUser + statusLocal.lockedBy == CurrentUserIdentity && statusLocal.lockedWhere == TeamCollectionManager.CurrentMachine ) { @@ -2309,25 +2637,51 @@ out Tuple repoState continue; } - // brand new book! Get it. - hasProblems |= !CopyBookFromRepoToLocalAndReport( - progress, - bookName, - () => + if (CanAutoApplyRemoteChanges) + { + // Cloud (batch item 7, progressive join): don't block the startup + // sync dialog waiting for a full download here -- hand it to the same + // background queue CloudJoinFlow uses for a fresh join and + // HandleModifiedFile uses to auto-apply remote changes, so a + // half-joined collection's next open stays fast and downloads keep + // resuming in the background (DownloadMissingBookInBackground). + // Folder TCs (CanAutoApplyRemoteChanges is always false there) are + // completely unaffected -- they keep the original synchronous fetch + // in the else branch below. + QueueBookForBackgroundDownload(bookName); + if (!remotelyRenamedBooks.Contains(bookName)) { - if (!remotelyRenamedBooks.Contains(bookName)) + ReportProgressAndLog( + progress, + ProgressKind.Progress, + "FetchingNewBookInBackground", + "The book '{0}' will finish downloading in the background", + bookName + ); + } + } + else + { + // brand new book! Get it. + hasProblems |= !CopyBookFromRepoToLocalAndReport( + progress, + bookName, + () => { - // Report the new book, unless we already reported it as a rename. - ReportProgressAndLog( - progress, - ProgressKind.Progress, - "FetchedNewBook", - "Fetching a new book '{0}' from the Team Collection", - bookName - ); + if (!remotelyRenamedBooks.Contains(bookName)) + { + // Report the new book, unless we already reported it as a rename. + ReportProgressAndLog( + progress, + ProgressKind.Progress, + "FetchedNewBook", + "Fetching a new book '{0}' from the Team Collection", + bookName + ); + } } - } - ); + ); + } continue; } @@ -2594,6 +2948,36 @@ protected string GetBookId(string bookFolderName) ?.Id; } + /// + /// Finds a path in (created if necessary) that does not yet + /// exist, named as similarly as possible to bookFolderName + extension: the bare name if + /// free, otherwise with 2, 3, ... appended. Shared by FolderTeamCollection (repo temp + /// files, Lost and Found .bloom files) and CloudTeamCollection (local Lost and Found + /// .bloomSource files). + /// + protected static string AvailablePath( + string bookFolderName, + string folderName, + string extension + ) + { + string bookPath; + Directory.CreateDirectory(folderName); + int counter = 0; + do + { + counter++; + // Don't use ChangeExtension here, bookFolderName may have arbitrary period + bookPath = + Path.Combine( + folderName, + bookFolderName + (counter == 1 ? "" : counter.ToString()) + ) + extension; + } while (RobustFile.Exists(bookPath)); + + return bookPath; + } + /// /// Get the local path we expect to use for the specified book id based on the current repo state. /// This helps restore selection after a remote rename before the local folder has been renamed. @@ -2616,7 +3000,10 @@ public string GetLikelyLocalPathForBookId(string bookId) /// /// If non-null, names of books whose repo files could not be /// read are added to this collection instead of being silently ignored. - private Dictionary> GetRepoBooksByIdMap( + /// Virtual so a backend that already knows every repo book's id (e.g. + /// CloudTeamCollection's repo cache) can answer without fetching each book's meta.json — + /// this base implementation costs a repo read per book. + protected virtual Dictionary> GetRepoBooksByIdMap( ICollection unreadableBooks = null ) { @@ -2728,10 +3115,13 @@ protected void ShowProgressDialog( /// /// Main entry point called before creating CollectionSettings; updates local folder to match - /// repo one, if any. Not unit tested, as it mainly handles wrapping SyncAtStartup with a - /// progress dialog. + /// repo one, if any. The real implementation itself isn't unit tested (it mainly handles + /// wrapping SyncAtStartup with a progress dialog), but it's virtual so a test-only + /// subclass can override it to pin down WorkspaceModel.HandleTeamStuffBeforeGetBookCollections' + /// call-ordering around it (see the "tier-timing" fix's ordering tests) without ever + /// showing a real dialog. /// - public void SynchronizeRepoAndLocal(Action whenDone = null) + public virtual void SynchronizeRepoAndLocal(Action whenDone = null) { Analytics.Track( "TeamCollectionOpen", diff --git a/src/BloomExe/TeamCollection/TeamCollectionAccessRefusedException.cs b/src/BloomExe/TeamCollection/TeamCollectionAccessRefusedException.cs new file mode 100644 index 000000000000..f922ba91c5d2 --- /dev/null +++ b/src/BloomExe/TeamCollection/TeamCollectionAccessRefusedException.cs @@ -0,0 +1,22 @@ +using System; + +namespace Bloom.TeamCollection +{ + /// + /// Thrown while opening a Team Collection to abort the open entirely -- as opposed to the + /// usual "fall back to Disconnected mode" behavior -- because the current signed-in account + /// is not allowed to open this collection at all (batch item 9, account-switch behavior: the + /// current cloud logon is not a server member of this Team Collection). Message is the full, + /// already-composed, user-facing text (see CloudTeamCollection.CheckConnection and + /// CloudTeamCollection.ComposeNotAMemberRefusalDetail); callers should show it directly rather + /// than treating it as an unexpected-crash report. Caught in + /// Program.HandleErrorOpeningProjectWindow, which shows the message and returns to the + /// collection chooser exactly as it already does for any other failure to open a project. + /// + public class TeamCollectionAccessRefusedException : Exception + { + /// + public TeamCollectionAccessRefusedException(string message) + : base(message) { } + } +} diff --git a/src/BloomExe/TeamCollection/TeamCollectionApi.cs b/src/BloomExe/TeamCollection/TeamCollectionApi.cs index ca843054a324..f9cdabe62dbb 100644 --- a/src/BloomExe/TeamCollection/TeamCollectionApi.cs +++ b/src/BloomExe/TeamCollection/TeamCollectionApi.cs @@ -31,6 +31,15 @@ public class TeamCollectionApi private BookSelection _bookSelection; // configured by autofac, tells us what book is selected private BookServer _bookServer; private string CurrentUser => TeamCollectionManager.CurrentUser; + + /// + /// The identity checkout ownership is compared against in status JSON: the collection's + /// own notion (cloud: the signed-in account; folder: the registration email). Must match + /// what TeamCollection.CurrentUserIdentity uses for the C#-side editability checks, or + /// the UI and the edit gate disagree about whose checkout this is. + /// + private string CurrentUserForStatus => + _tcManager?.CurrentCollectionEvenIfDisconnected?.CurrentUserIdentity ?? CurrentUser; private BloomWebSocketServer _socketServer; private readonly CurrentEditableCollectionSelection _currentBookCollectionSelection; private CollectionSettings _settings; @@ -62,6 +71,29 @@ CollectionModel collectionModel public WorkspaceView WorkspaceView { get; set; } + /// + /// Exposes this project's so the app-level + /// (which, unlike this class, is registered + /// once at the application level so it also works before any project is loaded -- see + /// SharingApi's own file comment) can reach whichever project happens to be currently open + /// via , without SharingApi needing its own project-scoped + /// dependencies. Read-only; task 06 addition. + /// + public ITeamCollectionManager TcManager => _tcManager; + + /// Same purpose as : lets the app-level SharingApi push + /// websocket events (e.g. "sharing"/"membersChanged") on the SAME socket connection the + /// currently-open project's UI actually listens on. BloomWebSocketServer is a per-project + /// singleton (see ProjectContext's registration), so SharingApi -- registered once at the + /// application level -- cannot resolve that same instance through DI; it must reach it via + /// whichever project is currently loaded, exactly like TcManager above. + public BloomWebSocketServer SocketServer => _socketServer; + + /// The folder name of the currently selected book, or null if none -- lets + /// SharingApi's history endpoint implement its "current book only" filter without its own + /// project-scoped BookSelection dependency. + public string CurrentBookFolderName => BookFolderName; + public void RegisterWithApiHandler(BloomApiHandler apiHandler) { apiHandler.RegisterEndpointHandler( @@ -182,6 +214,202 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) null, false ); + + // --- Cloud Team Collections (task 06, Wave 3): additive endpoints. Folder Team + // Collections continue to use every endpoint above completely unchanged. --- + // NOTE: teamCollection/capabilities is deliberately NOT registered here. It is an + // application-level endpoint (registered by SharingApi) because callers legitimately + // probe it when no project is open -- e.g. the E2E harness's readiness poll, or a + // late request from a closing collection tab while the chooser is on screen -- and a + // project-level registration made every such probe raise a "Cannot Find API Endpoint" + // toast (post-batch defect, 10 Jul 2026). + apiHandler.RegisterEndpointHandler( + "teamCollection/tcStatusMetadata", + HandleTcStatusMetadata, + false + ); + apiHandler.RegisterEndpointHandler( + "teamCollection/cloudCollectionId", + HandleCloudCollectionId, + false + ); + apiHandler.RegisterBooleanEndpointHandler( + "teamCollection/isUserAdmin", + HandleIsUserAdmin, + null, + false + ); + apiHandler.RegisterEndpointHandler( + "teamCollection/receiveUpdates", + HandleReceiveUpdates, + true + ); + // "Send All" (cloud terminology) is exactly checkInAllBooks under a name that matches + // the cloud UI's button label -- same handler, so there is exactly one implementation + // of "check in every checked-out book" to keep in sync. + apiHandler.RegisterEndpointHandler( + "teamCollection/sendAllBooks", + HandleCheckInAllBooks, + true + ); + apiHandler.RegisterEndpointHandler( + "teamCollection/createCloudTeamCollection", + HandleCreateCloudTeamCollection, + true + ); + apiHandler.RegisterEndpointHandler( + "teamCollection/showCreateCloudTeamCollectionDialog", + HandleShowCreateCloudTeamCollectionDialog, + true + ); + } + + // ------------------------------------------------------------------ + // Cloud Team Collections (task 06): additive endpoint handlers. Every one of these reads + // ONLY from the currently-open collection (never mutates TeamCollectionApi's own folder-TC + // code paths above), and every field/flag defaults to exactly today's folder-TC behavior + // (false/empty/null) when there is no cloud collection open -- see CONTRACTS.md's + // "Book-status JSON" section and ITeamCollectionCapabilities in teamCollectionApi.tsx. + // ------------------------------------------------------------------ + + // (teamCollection/capabilities' handler moved to the app-level SharingApi -- see the note + // in RegisterWithApiHandler above.) + + /// Live metadata behind the status button/chip (e.g. "Updates Available (3 + /// books)"). Cloud-only; a folder TC (or no collection) simply reports no count. + private void HandleTcStatusMetadata(ApiRequest request) + { + var updatesAvailableCount = ( + _tcManager.CurrentCollection as Cloud.CloudTeamCollection + )?.GetUpdatesAvailableCount(); + request.ReplyWithJson(new { updatesAvailableCount }); + } + + /// The cloud collection id (server `collections.id`) of the currently open + /// collection, needed by SharingApi calls. Empty string for a folder TC or none open. + private void HandleCloudCollectionId(ApiRequest request) + { + request.ReplyWithText( + (_tcManager.CurrentCollection as Cloud.CloudTeamCollection)?.CloudCollectionId ?? "" + ); + } + + /// Whether the current user administers the currently open Team Collection. + /// Reuses the same flag folder TCs already compute (collection-settings-edit permission), + /// since "administrator" means the same thing for both backends from the UI's point of + /// view (drives the collection-tab Share button's manage-vs-read-only mode). + private bool HandleIsUserAdmin(ApiRequest request) => _tcManager.OkToEditCollectionSettings; + + /// "Receive Updates": pulls every book with a newer repo version than what's on + /// this machine down to disk. Thin pass-through to the same Receive path folder TCs use + /// (CopyBookFromRepoToLocal) -- no cloud-specific business logic here; the branching + /// (what's newer) already lives in CloudTeamCollection's cache. + private void HandleReceiveUpdates(ApiRequest request) + { + // This can take a while (multiple book downloads), so reply immediately like + // checkInAllBooks does and report progress via the existing progress websocket. + request.PostSucceeded(); + + var collection = _tcManager.CurrentCollection; + var cloudCollection = collection as Cloud.CloudTeamCollection; + if (cloudCollection == null) + return; // Not a cloud TC (or disconnected); nothing to receive. + + var progress = new WebSocketProgress(_socketServer, "teamCollection-status"); + progress.LogAllMessages = true; + var (receivedCount, skippedCheckedOutCount) = cloudCollection.ReceiveAllUpdates( + progress + ); + progress.MessageWithoutLocalizing("Done receiving updates."); + UpdateUiForBook(); + // Tell javascript-land to re-query book statuses NOW (same event + // CollectionTabView/WorkspaceView send): the poll above also queued base-class + // change events, but those only surface at the next Application.Idle, and a user + // who just clicked "Receive Updates" deserves an immediate refresh. + _socketServer.SendEvent("bookTeamCollectionStatus", "reload"); + + // Analytics audit (task 10): "Receive Updates" had no analytics at all before this, + // unlike per-book checkout/checkin. Byte-level uploaded-vs-skipped counts would be a + // nice future enhancement, but the current download path (CopyBookFromRepoToLocal) + // doesn't cheaply expose per-book byte counts without extra S3 requests -- book + // counts are a cheap, still-useful proxy for now. + Analytics.Track( + "TeamCollectionReceiveUpdates", + new Dictionary() + { + { "CollectionId", _settings?.CollectionId }, + { "CollectionName", _settings?.CollectionName }, + { "Backend", collection.GetBackendType() }, + { "User", CurrentUser }, + { "BooksReceived", receivedCount.ToString() }, + { "BooksSkippedCheckedOutHere", skippedCheckedOutCount.ToString() }, + } + ); + } + + /// Kicks off the cloud Team Collection creation flow: uploads the current local + /// collection as the initial version of a new cloud-backed Team Collection, using this + /// collection's own CollectionId GUID as the server's `collections.id` (CONTRACTS.md). + /// Mirrors HandleCreateTeamCollection's folder-TC counterpart. + public void HandleCreateCloudTeamCollection(ApiRequest request) + { + try + { + Debug.Assert(!string.IsNullOrWhiteSpace(CurrentUser)); + + _tcManager.ConnectToCloudCollection(_settings.CollectionId); + _callbackToReopenCollection?.Invoke(); + + Analytics.Track( + "TeamCollectionCreate", + new Dictionary() + { + { "CollectionId", _settings?.CollectionId }, + { "CollectionName", _settings?.CollectionName }, + { "Backend", _tcManager?.CurrentCollection?.GetBackendType() }, + { "User", CurrentUser }, + } + ); + + request.PostSucceeded(); + } + catch (Exception e) + { + var msgEnglish = "Error creating cloud Team Collection: {0}"; + var msgFmt = LocalizationManager.GetString( + "TeamCollection.ErrorCreatingCloud", + msgEnglish + ); + ErrorReport.NotifyUserOfProblem(e, msgFmt, e.Message); + Logger.WriteError(String.Format(msgEnglish, e.Message), e); + NonFatalProblem.ReportSentryOnly( + e, + $"Something went wrong for {request.LocalPath()}" + ); + + // Since we have already informed the user above, it is better to just report a + // success here. Otherwise, they will also get a toast. + request.PostSucceeded(); + } + } + + /// Shows the cloud create-collection dialog (sign-in -> immutable-name ack -> + /// initial Send), the cloud counterpart of HandleShowCreateTeamCollectionDialog. Reuses the + /// same React bundle/entry file (CreateTeamCollection.tsx hosts the folder dialog, the + /// cloud dialog, and the sign-in dialog, selected via the dialogKind prop) -- see that + /// file's own CreateTeamCollectionBundleDispatcher/WireUpForWinforms wiring. + private void HandleShowCreateCloudTeamCollectionDialog(ApiRequest request) + { + ReactDialog.ShowOnIdle( + "createTeamCollectionDialogBundle", + new { dialogKind = "cloud" }, + 600, + 580, + null, + null, + "Create Team Collection" + ); + request.PostSucceeded(); } private bool HandleLogImportant(ApiRequest arg) @@ -196,7 +424,16 @@ private void HandleTeamCollectionStatus(ApiRequest request) request.ReplyWithEnum(_tcManager?.CollectionStatus ?? TeamCollectionStatus.None); } - private void HandleForceUnlock(ApiRequest request) + /// + /// Force-unlocks the currently selected book. Backend-agnostic: ForceUnlock already + /// dispatches to UnlockInRepo(force: true), which a cloud backend overrides to call + /// the audited force_unlock RPC (CONTRACTS.md) -- so this one implementation is + /// already correct for both. Internal (not private) so the app-level + /// can register the exact same handler under + /// "sharing/forceUnlock" (the endpoint name the cloud UI posts to; task 06) without + /// duplicating this logic. + /// + internal void HandleForceUnlock(ApiRequest request) { if (!_tcManager.CheckConnection()) { @@ -303,7 +540,14 @@ private void HandleShowCreateTeamCollectionDialog(ApiRequest request) { ReactDialog.ShowOnIdle( "createTeamCollectionDialogBundle", - new { defaultRepoFolder = DropboxUtils.GetDropboxFolderPath() }, + // dialogKind tells the shared bundle (see CreateTeamCollection.tsx's + // CreateTeamCollectionBundleDispatcher) which of its three top-level dialogs to + // render -- required now that the bundle hosts more than one. + new + { + dialogKind = "folder", + defaultRepoFolder = DropboxUtils.GetDropboxFolderPath(), + }, 600, 580, null, @@ -514,7 +758,7 @@ private string GetBookStatusJson(string bookFolderName, Book.Book book) if (bookFolderName == null) { - return JsonConvert.SerializeObject( + var noBookJson = JsonConvert.SerializeObject( new { // Keep this in sync with IBookTeamCollectionStatus defined in TeamCollectionApi.tsx @@ -523,7 +767,7 @@ private string GetBookStatusJson(string bookFolderName, Book.Book book) whoSurname = "", when = DateTime.Now.ToShortDateString(), where = "", - currentUser = CurrentUser, + currentUser = CurrentUserForStatus, currentUserName = TeamCollectionManager.CurrentUserFirstName, currentMachine = TeamCollectionManager.CurrentMachine, hasAProblem = false, @@ -536,6 +780,7 @@ private string GetBookStatusJson(string bookFolderName, Book.Book book) isUserAdmin = _tcManager.OkToEditCollectionSettings, } ); + return AddCloudBookStatusFields(noBookJson, null); } bool isNewLocalBook = false; @@ -564,7 +809,7 @@ private string GetBookStatusJson(string bookFolderName, Book.Book book) // of all books. In that case, book is null, but it's fairly safe to assume it's a new local book. if (book?.IsSaveable ?? true) { - whoHasBookLocked = CurrentUser; + whoHasBookLocked = CurrentUserForStatus; isNewLocalBook = true; } else @@ -592,7 +837,7 @@ _tcManager.CurrentCollection as FolderTeamCollection book == null || !Directory.Exists(book.FolderPath) ? "" : BookHistory.GetPendingCheckinMessage(book); - return JsonConvert.SerializeObject( + var bookJson = JsonConvert.SerializeObject( new { // Keep this in sync with IBookTeamCollectionStatus defined in TeamCollectionApi.tsx @@ -607,7 +852,7 @@ _tcManager.CurrentCollection as FolderTeamCollection where = _tcManager.CurrentCollectionEvenIfDisconnected?.WhatComputerHasBookLocked( bookFolderName ), - currentUser = CurrentUser, + currentUser = CurrentUserForStatus, currentUserName = TeamCollectionManager.CurrentUserFirstName, currentMachine = TeamCollectionManager.CurrentMachine, hasConflictingChange, @@ -622,6 +867,95 @@ _tcManager.CurrentCollection as FolderTeamCollection isUserAdmin = _tcManager.OkToEditCollectionSettings, } ); + return AddCloudBookStatusFields(bookJson, bookFolderName); + } + + /// + /// Additively merges CONTRACTS.md's cloud-only book-status JSON fields + /// (localVersionSeq/repoVersionSeq/signedIn/requiresSignIn/offlineDisabledReason) into an + /// already-serialized status JSON string. Returns completely + /// UNCHANGED (not even re-parsed) whenever the current collection isn't a cloud one, so + /// folder-TC responses stay byte-identical to before this task. + /// null means the "no book selected" shape, which only + /// gets the collection-wide signedIn/requiresSignIn flags (there's no specific book to + /// report a version/offline status for). + /// + internal string AddCloudBookStatusFields(string baseJson, string bookFolderName) + { + if (!(_tcManager.CurrentCollection is Cloud.CloudTeamCollection cloudCollection)) + return baseJson; + + var json = Newtonsoft.Json.Linq.JObject.Parse(baseJson); + json["signedIn"] = cloudCollection.Auth.IsSignedIn; + json["requiresSignIn"] = cloudCollection.RequiresSignIn; + // In a cloud TC, identity is the signed-in ACCOUNT, not Bloom's registration email + // (CONTRACTS.md identity model; the server stamps locks from the token). The base + // JSON's currentUser is the registration email, so the panel's who === currentUser + // check called the user's own checkout "someone else" in the first two-instance + // smoke test (registration was john_thomson@sil.org; the lock was alice@dev.local). + var accountEmail = cloudCollection.Auth.CurrentEmail; + if (!string.IsNullOrEmpty(accountEmail)) + { + // The base JSON stamps `who` from the same registration identity for books whose + // "checkout" is purely local -- a brand-new local book, or one not in the repo -- + // so those displayed as checked out to the REGISTRATION name (dogfood bug #17: + // Bob's new book said "checked out to John1"). Rewrite it to the account, and + // clear the registration first/surname that came with it; a real repo lock's + // `who` is a member email (never the registration email) and is left alone. + var registrationUser = (string)json["currentUser"]; + if ( + !string.IsNullOrEmpty(registrationUser) + && string.Equals( + (string)json["who"], + registrationUser, + StringComparison.OrdinalIgnoreCase + ) + && !string.Equals( + registrationUser, + accountEmail, + StringComparison.OrdinalIgnoreCase + ) + ) + { + json["who"] = accountEmail; + json["whoFirstName"] = null; + json["whoSurname"] = null; + } + json["currentUser"] = accountEmail; + // Same identity rule for the avatar dialog's display name (base stamps the + // registration first name). Prefer the account's admin-editable display name + // (e.g. "Alice Admin"); fall back to the email when it isn't resolvable. + json["currentUserName"] = cloudCollection.CurrentUserDisplayName ?? accountEmail; + } + // A new/local-only book's whoFirstName/whoSurname come from the REGISTRATION name + // (base WhoHasBookLockedFirstName's FakeUserIndicatingNewBook branch), and the + // status panel/book buttons prefer those name fields over `who` -- so such books + // displayed as "checked out to John1" even though `who` already carried the account + // identity (bug #17's real culprit; the who-rewrite above only covers a signed-out + // registration leak). Replace them with the current account's display name so the + // avatar shows the same initials + full name as a real checkout by this user (e.g. + // "AA"/"Alice Admin"); when the display name isn't resolvable this leaves whoFirstName + // null, so the display falls back to `who` (the account email) exactly as before. + if ((bool?)json["isNewLocalBook"] == true) + { + json["whoFirstName"] = cloudCollection.CurrentUserDisplayName; + json["whoSurname"] = null; + } + if (bookFolderName != null) + { + var repoVersionSeq = cloudCollection.GetRepoVersionSeq(bookFolderName); + var localVersionSeq = cloudCollection.GetLocalVersionSeq(bookFolderName); + json["repoVersionSeq"] = repoVersionSeq; + json["localVersionSeq"] = localVersionSeq; + if (cloudCollection.IsDisconnected && !localVersionSeq.HasValue) + { + // Non-localized, matching the precedent of this same method's pre-existing + // `error`/`invalidRepoDataErrorMsg` fields (see their own comments above). + json["offlineDisabledReason"] = + "This book has never been downloaded to this computer, so it can't be used while offline."; + } + } + return json.ToString(Newtonsoft.Json.Formatting.None); } public void HandleSelectedBookStatus(ApiRequest request) @@ -977,7 +1311,8 @@ private void CheckInOneBook( bookInfo.FolderPath, true, false, - reportProgressFraction + reportProgressFraction, + checkinComment: message ); } catch (Exception) @@ -1284,6 +1619,16 @@ private void UpdateUiForBook(bool reloadFromDisk = false, string renamedTo = nul return; } } + // E2E-9 discovery: if NO open form is the active one AND none of them is a Shell + // (observed for a Bloom window that has never received OS focus -- e.g. a + // background/automated instance, but plausible any time the app is minimized or + // another window has focus at the moment a check-in completes), the code used to + // fall through to `Form.ActiveForm.Invoke(...)` below with `Form.ActiveForm` still + // null, throwing a NullReferenceException. That NRE propagated out of a successful + // check-in (this method runs AFTER PutBook already committed) and was reported to + // the caller as a check-in FAILURE -- a real, misleading regression, not just a + // missed UI refresh. There is no window to update in this case, so just skip it. + return; } Form.ActiveForm.Invoke( (Action)( diff --git a/src/BloomExe/TeamCollection/TeamCollectionLastKnownUser.cs b/src/BloomExe/TeamCollection/TeamCollectionLastKnownUser.cs new file mode 100644 index 000000000000..c6a63a097484 --- /dev/null +++ b/src/BloomExe/TeamCollection/TeamCollectionLastKnownUser.cs @@ -0,0 +1,80 @@ +using System.IO; +using SIL.IO; + +namespace Bloom.TeamCollection +{ + /// + /// Durable, local-only record of which cloud account most recently confirmed membership + /// while using a given local Team Collection folder on THIS machine. Added for batch item 9 + /// (account-switch behavior, Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md): + /// when a local cloud Team Collection is opened by a different account than whoever used it + /// here before, the refusal message shown to a non-member needs to name "the last team + /// member who edited this collection on this machine", and nothing in the existing local + /// state (TeamCollectionLink.txt is a bare folder-path-or-cloud-URI; per-book BookStatus + /// files lose their lockedBy once a book is checked back in) recorded that. + /// + /// Design choice (documented per the batch item's instruction to "prefer the least invasive + /// durable record"): rather than extending TeamCollectionLink.txt's tightly-scoped, tested + /// parse format, or CollectionSettings.Administrators (which already has a known, + /// separately-tracked identity bug -- it stores the registration email, not the signed-in + /// cloud email, see the batch file's "Also queued from dogfooding" note), this is a tiny new + /// sidecar file living next to TeamCollectionLink.txt. We deliberately record more than just + /// the ORIGINAL joiner: every successful membership confirmation + /// (CloudTeamCollection.CheckConnection) overwrites this file with the CURRENT user, so it + /// doubles as "who joined" (for a collection nobody has reopened since) and "who was last + /// confirmed here" (the general case) with a single mechanism. This is an approximation -- + /// it records the last account CONFIRMED as a member here, not literally "last edited a + /// book" -- but it is the best locally-discoverable signal without new content-edit + /// plumbing, and degrades gracefully to "unknown" (this file simply won't exist yet) for + /// collections joined before this feature shipped, self-healing the first time this code + /// runs afterward with a member signed in. + /// + public static class TeamCollectionLastKnownUser + { + public const string FileName = "TeamCollectionLastKnownUser.txt"; + + /// Path to the sidecar file for the given local collection folder. + public static string GetPath(string localCollectionFolder) + { + return Path.Combine(localCollectionFolder, FileName); + } + + /// + /// The email of the last cloud account confirmed to be a member while using this + /// collection on this machine, or null if never recorded. + /// + public static string Read(string localCollectionFolder) + { + var path = GetPath(localCollectionFolder); + if (!RobustFile.Exists(path)) + return null; + var text = RobustFile.ReadAllText(path).Trim(); + return string.IsNullOrEmpty(text) ? null : text; + } + + /// + /// Records as the last confirmed member to use this collection + /// on this machine. Safe/cheap to call on every successful connection check; does + /// nothing if is null/empty (so a caller can pass through an + /// unresolved identity without special-casing it), and does NOT rewrite the file when + /// the recorded value is already current. That last point is load-bearing, not an + /// optimization: CheckConnection runs from the UI thread's idle-time collection-file + /// sync, and an unconditional write here retriggers the local FileSystemWatcher that + /// SCHEDULES that sync -- an infinite idle-loop of network calls that starved the UI + /// thread (found live, 10 Jul 2026: every checkInCurrentBook request timed out because + /// the UI thread never got free). TeamCollection.OnChanged also now ignores this file + /// by name; both guards are deliberate (either alone would break the loop, but the + /// watcher exclusion also stops the file from being treated as a syncable collection + /// file, and this check also protects any future non-watcher caller). + /// + public static void Record(string localCollectionFolder, string email) + { + if (string.IsNullOrEmpty(email)) + return; + var trimmed = email.Trim(); + if (Read(localCollectionFolder) == trimmed) + return; + RobustFile.WriteAllText(GetPath(localCollectionFolder), trimmed); + } + } +} diff --git a/src/BloomExe/TeamCollection/TeamCollectionLink.cs b/src/BloomExe/TeamCollection/TeamCollectionLink.cs new file mode 100644 index 000000000000..573ed47bf713 --- /dev/null +++ b/src/BloomExe/TeamCollection/TeamCollectionLink.cs @@ -0,0 +1,161 @@ +using System; +using SIL.IO; + +namespace Bloom.TeamCollection +{ + /// + /// Parses and writes the content of TeamCollectionLink.txt, which can contain either: + /// - A folder path (legacy folder-backed TC), e.g. "C:\Dropbox\MyCollection - TC" + /// - A cloud URI, e.g. "cloud://sil.bloom/collection/<collectionId>" (GUID) + /// + /// The two forms are distinguished by whether the content starts with "cloud://sil.bloom/collection/". + /// If the file is missing or empty this class treats the collection as not a TC. + /// If the content is ambiguous or unparseable it throws . + /// + public class TeamCollectionLink + { + /// URI scheme + authority prefix used by cloud-backed TCs. + public const string CloudUriPrefix = "cloud://sil.bloom/collection/"; + + /// The folder path; non-null only for folder-backed (legacy) TCs. + public string RepoFolderPath { get; } + + /// The collection ID GUID; non-null only for cloud-backed TCs. + public string CloudCollectionId { get; } + + /// True when this link points to a cloud-backed TC. + public bool IsCloud => CloudCollectionId != null; + + /// True when this link points to a folder-backed TC. + public bool IsFolder => RepoFolderPath != null; + + private TeamCollectionLink(string repoFolderPath, string cloudCollectionId) + { + RepoFolderPath = repoFolderPath; + CloudCollectionId = cloudCollectionId; + } + + /// + /// Creates a for a folder-backed TC. + /// + /// Absolute path to the shared repo folder. + public static TeamCollectionLink ForFolder(string folderPath) + { + return new TeamCollectionLink(folderPath, null); + } + + /// + /// Creates a for a cloud-backed TC. + /// + /// The collection GUID that identifies this TC on the server. + public static TeamCollectionLink ForCloud(string collectionId) + { + return new TeamCollectionLink(null, collectionId); + } + + /// + /// Read and parse the TeamCollectionLink.txt file at . + /// Returns null if the file does not exist. + /// Throws when the content is + /// present but cannot be classified as a valid folder path or cloud URI. + /// + /// Full path to TeamCollectionLink.txt. + public static TeamCollectionLink FromFile(string linkFilePath) + { + if (!RobustFile.Exists(linkFilePath)) + return null; + + var raw = RobustFile.ReadAllText(linkFilePath).Trim(); + return Parse(raw); + } + + /// + /// Parse a raw string from TeamCollectionLink.txt. + /// Returns null for empty/whitespace input (treated as "not a TC"). + /// Throws when the content is present + /// but cannot be classified as a valid folder path or cloud URI. + /// + /// Trimmed text content of the link file. + public static TeamCollectionLink Parse(string rawContent) + { + if (string.IsNullOrWhiteSpace(rawContent)) + return null; + + if (rawContent.StartsWith(CloudUriPrefix, StringComparison.Ordinal)) + { + var id = rawContent.Substring(CloudUriPrefix.Length).Trim(); + if (string.IsNullOrEmpty(id)) + throw new InvalidTeamCollectionLinkException( + $"Cloud TC link missing collection ID: '{rawContent}'" + ); + + // The ID must look like a non-empty string with no embedded whitespace. + // We do not enforce GUID format here so tests can use short IDs. + if (id.IndexOfAny(new[] { ' ', '\t', '\r', '\n' }) >= 0) + throw new InvalidTeamCollectionLinkException( + $"Cloud TC link has whitespace in collection ID: '{rawContent}'" + ); + + return new TeamCollectionLink(null, id); + } + + // Check for a "cloud://" URI with a different authority — that is unexpected + // content we should flag rather than silently treat as a folder path. + if (rawContent.StartsWith("cloud://", StringComparison.OrdinalIgnoreCase)) + throw new InvalidTeamCollectionLinkException( + $"Unrecognized cloud TC link format: '{rawContent}'" + ); + + // Treat everything else as a folder path (legacy behavior). + return new TeamCollectionLink(rawContent, null); + } + + /// + /// Serializes this link to the string form written into TeamCollectionLink.txt. + /// + public string ToFileContent() + { + if (IsCloud) + return CloudUriPrefix + CloudCollectionId; + return RepoFolderPath; + } + + /// + /// Write this link to the specified file, creating or overwriting it. + /// + /// Full path to TeamCollectionLink.txt. + public void WriteToFile(string linkFilePath) + { + RobustFile.WriteAllText(linkFilePath, ToFileContent()); + } + } + + /// + /// Thrown by or + /// when the link-file content is present + /// but cannot be interpreted as a valid folder path or cloud URI. + /// + public class InvalidTeamCollectionLinkException : Exception + { + /// + public InvalidTeamCollectionLinkException(string message) + : base(message) { } + } + + /// + /// Thrown by when the local + /// collection folder already has a TeamCollectionLink.txt pointing somewhere else (a folder + /// TC that hasn't actually been disconnected, or a different cloud collection). Since the + /// link file can only ever describe ONE Team Collection at a time, the two links can never + /// coexist -- this is not a race, it's a sign the "un-team" step wasn't finished before + /// trying to enable Cloud Team Collections. The message always includes concrete fix + /// instructions since it is shown to the end user verbatim (see + /// TeamCollectionApi.HandleCreateCloudTeamCollection's catch block). + /// + public class TeamCollectionLinkConflictException : Exception + { + /// + public TeamCollectionLinkConflictException(string message) + : base(message) { } + } +} diff --git a/src/BloomExe/TeamCollection/TeamCollectionManager.cs b/src/BloomExe/TeamCollection/TeamCollectionManager.cs index 4d1df054c6c9..25c2c1166ae3 100644 --- a/src/BloomExe/TeamCollection/TeamCollectionManager.cs +++ b/src/BloomExe/TeamCollection/TeamCollectionManager.cs @@ -21,6 +21,16 @@ public interface ITeamCollectionManager CollectionLock Lock { get; } bool CheckConnection(); void ConnectToTeamCollection(string repoFolderParentPath, string collectionId); + + /// + /// Connect the current collection to a new cloud-backed Team Collection, using + /// as both this Bloom collection's own CollectionId GUID + /// and the server's `collections.id` (CONTRACTS.md: "<collectionId> = the Bloom + /// CollectionId GUID (also the server collections.id)"). Creates the server-side row and + /// pushes every existing local book and collection-level file up. + /// + void ConnectToCloudCollection(string collectionId); + string PlannedRepoFolderPath(string repoFolderParentPath); bool OkToEditCollectionSettings { get; } @@ -227,6 +237,20 @@ public TeamCollectionManager( _localCollectionFolder = Path.GetDirectoryName(localCollectionPath); _bookCollectionHolder = bookCollectionHolder; BookSelection = bookSelection; + // For cloud TCs, poll the server the moment a book is selected, so its checkout + // status is current when the user is looking at it rather than up to a full poll + // interval stale. The poll runs on a background thread (GetChanges is a network + // round trip; SelectionChanged fires on the UI thread) and its results flow through + // the same change-event pipeline as the timer-driven polls. PollNow itself coalesces: + // a poll already in flight makes this a no-op, so rapid selection changes cannot + // stack up server requests. + // (bookSelection is null in several unit-test constructions of this class.) + if (bookSelection != null) + bookSelection.SelectionChanged += (sender, args) => + { + if (CurrentCollection is Cloud.CloudTeamCollection cloudCollection) + System.Threading.Tasks.Task.Run(() => cloudCollection.PollNow()); + }; collectionClosingEvent?.Subscribe( (x) => { @@ -264,15 +288,10 @@ is DisconnectedTeamCollection disconnectedTC { try { - var repoFolderPath = RepoFolderPathFromLinkPath( - tempCollectionLinkPath - ); - var tempCollection = new FolderTeamCollection( - this, - _localCollectionFolder, - repoFolderPath, - bookCollectionHolder: _bookCollectionHolder, - collectionLock: Lock + var tempLink = TeamCollectionLink.FromFile(tempCollectionLinkPath); + var tempCollection = CreateFolderTeamCollection( + tempLink, + bookCollectionHolder ); var problemWithConnection = tempCollection.CheckConnection(); if (problemWithConnection == null) @@ -335,22 +354,24 @@ is DisconnectedTeamCollection disconnectedTC var localCollectionLinkPath = GetTcLinkPathFromLcPath(_localCollectionFolder); if (RobustFile.Exists(localCollectionLinkPath)) { - string repoFolderPath = null; + // repoDescription is used when falling back to a DisconnectedTeamCollection. + // For folder TCs it is the folder path; for cloud TCs it is the cloud URI. + string repoDescription = null; try { - repoFolderPath = RepoFolderPathFromLinkPath(localCollectionLinkPath); - CurrentCollection = new FolderTeamCollection( - this, - _localCollectionFolder, - repoFolderPath, - bookCollectionHolder: _bookCollectionHolder, - collectionLock: Lock - ); // will be replaced if CheckConnection fails + var link = TeamCollectionLink.FromFile(localCollectionLinkPath); + repoDescription = link?.RepoFolderPath ?? link?.ToFileContent(); + CurrentCollection = CreateTeamCollectionFromLink(link, bookCollectionHolder); // will be replaced if CheckConnection fails // BL-10704: We set this to the CurrentCollection BEFORE checking the connection, // so that there will be a valid MessageLog if we need it during CheckConnection(). // If CheckConnection() fails, it will reset this to a DisconnectedTeamCollection. CurrentCollectionEvenIfDisconnected = CurrentCollection; - if (CheckConnection()) + // allowHardRefusal: true -- batch item 9 (account-switch behavior): opening a + // collection under an account that's not a server member of it must abort the + // open entirely (TeamCollectionAccessRefusedException, caught in + // Program.HandleErrorOpeningProjectWindow), not silently fall back to + // Disconnected mode like any other connection problem. + if (CheckConnection(allowHardRefusal: true)) { CurrentCollection.SocketServer = SocketServer; CurrentCollection.TCManager = this; @@ -370,6 +391,15 @@ is DisconnectedTeamCollection disconnectedTC } // else CheckConnection has set up a DisconnectedRepo if that is relevant. } + catch (TeamCollectionAccessRefusedException) + { + // Batch item 9 (account-switch behavior): let this propagate all the way up + // to Program.cs (through ProjectContext's constructor), which aborts opening + // the collection entirely and shows the composed refusal message -- unlike + // every other exception here, this one must NOT be swallowed into an ordinary + // "TC initialization failed, fall back to Disconnected mode" outcome. + throw; + } catch (Exception ex) { NonFatalProblem.Report( @@ -384,12 +414,12 @@ is DisconnectedTeamCollection disconnectedTC // Create a DisconnectedTeamCollection so we still have a TC object that prevents // undesirable operations like editing un-checked-out books. This handles cases where // the TC initialization fails, not just connection problems. - if (repoFolderPath != null) + if (repoDescription != null) { var disconnectedTC = new DisconnectedTeamCollection( this, _localCollectionFolder, - repoFolderPath + repoDescription ); disconnectedTC.SocketServer = SocketServer; disconnectedTC.TCManager = this; @@ -416,17 +446,104 @@ is DisconnectedTeamCollection disconnectedTC } } + /// + /// Reads the repo folder path from the link file at . + /// Preserves the existing trimming behavior. + /// public static string RepoFolderPathFromLinkPath(string localCollectionLinkPath) { return RobustFile.ReadAllText(localCollectionLinkPath).Trim(); } + /// + /// Factory: create the appropriate subclass for the given + /// parsed . For folder links this returns a + /// ; for cloud links, a + /// . + /// + /// Parsed link; must not be null. + /// Passed through to the collection constructor. + private TeamCollection CreateTeamCollectionFromLink( + TeamCollectionLink link, + BookCollectionHolder bookCollectionHolder + ) + { + if (link == null) + throw new ArgumentNullException(nameof(link)); + + if (link.IsFolder) + return CreateFolderTeamCollection(link, bookCollectionHolder); + + return CreateCloudTeamCollection(link, bookCollectionHolder); + } + + /// + /// Creates a for a folder-backed link. + /// + /// Must be a folder link. + /// Passed through to the collection constructor. + private FolderTeamCollection CreateFolderTeamCollection( + TeamCollectionLink link, + BookCollectionHolder bookCollectionHolder + ) + { + if (link == null) + throw new ArgumentNullException(nameof(link)); + if (!link.IsFolder) + throw new ArgumentException("Expected a folder-backed link.", nameof(link)); + + return new FolderTeamCollection( + this, + _localCollectionFolder, + link.RepoFolderPath, + bookCollectionHolder: bookCollectionHolder, + collectionLock: Lock + ); + } + + /// + /// Creates a for a cloud-backed + /// link. + /// + /// Must be a cloud link. + /// Passed through to the collection constructor. + private Bloom.TeamCollection.Cloud.CloudTeamCollection CreateCloudTeamCollection( + TeamCollectionLink link, + BookCollectionHolder bookCollectionHolder + ) + { + if (link == null) + throw new ArgumentNullException(nameof(link)); + if (!link.IsCloud) + throw new ArgumentException("Expected a cloud-backed link.", nameof(link)); + + return new Bloom.TeamCollection.Cloud.CloudTeamCollection( + this, + _localCollectionFolder, + link.CloudCollectionId, + bookCollectionHolder: bookCollectionHolder, + collectionLock: Lock + ); + } + /// /// Check that we are still connected to a current team collection. Answer false if we are not, /// as well as switching things to the disconnected state. /// /// - public bool CheckConnection() + public bool CheckConnection() => CheckConnection(allowHardRefusal: false); + + /// + /// As , but when is + /// true, a connection problem flagged as + /// (currently: CloudTeamCollection.CheckConnection's non-member case, batch item 9) throws + /// instead of falling back to + /// Disconnected mode. Only the initial open (this class's constructor, below) passes + /// true -- a membership loss discovered LATER in the session (e.g. via + /// TeamCollectionApi's ordinary CheckConnection() calls) must still just disconnect, not + /// crash the running app. + /// + public bool CheckConnection(bool allowHardRefusal) { if (CurrentCollection == null) return false; // we're already disconnected, or not a TC at all. @@ -445,6 +562,10 @@ public bool CheckConnection() if (connectionProblem != null) { + if (allowHardRefusal && connectionProblem.IsAccessRefusal) + throw new TeamCollectionAccessRefusedException( + connectionProblem.TextForDisplay + ); MakeDisconnected(connectionProblem, CurrentCollection.RepoDescription); return false; } @@ -497,6 +618,10 @@ public static string GetTcLinkPathFromLcPath(string localCollectionFolder) public BloomWebSocketServer SocketServer => _webSocketServer; + /// + /// Connect the current collection to a new folder-backed Team Collection stored under + /// . + /// public void ConnectToTeamCollection(string repoFolderParentPath, string collectionId) { var repoFolderPath = PlannedRepoFolderPath(repoFolderParentPath); @@ -504,17 +629,101 @@ public void ConnectToTeamCollection(string repoFolderParentPath, string collecti // The creator of a TC is its first and, for now, usually only administrator. Settings.Administrators = new[] { CurrentUser }; Settings.Save(); - var newTc = new FolderTeamCollection( + var link = TeamCollectionLink.ForFolder(repoFolderPath); + var newTc = CreateFolderTeamCollection(link, _bookCollectionHolder); + newTc.CollectionId = collectionId; + newTc.SocketServer = SocketServer; + newTc.TCManager = this; + newTc.SetupTeamCollectionWithProgressDialog(repoFolderPath); + CurrentCollection = newTc; + CurrentCollectionEvenIfDisconnected = newTc; + } + + /// + /// Throws if + /// already has a TeamCollectionLink.txt + /// describing some OTHER Team Collection (folder- or cloud-backed) -- see + /// 's doc comment for why this situation means the + /// "un-team" step wasn't finished. Static and file-system-only (no network calls) so it + /// can be unit tested directly against a temp folder, unlike ConnectToCloudCollection as + /// a whole, which also creates the server-side collection. + /// Does nothing if there is no link file (the ordinary case: a plain local collection, + /// or one already fully un-teamed). + /// + internal static void ThrowIfConflictingTeamCollectionLink(string localCollectionFolder) + { + var linkPath = GetTcLinkPathFromLcPath(localCollectionFolder); + var existingLink = TeamCollectionLink.FromFile(linkPath); + if (existingLink == null) + return; + + if (existingLink.IsFolder) + throw new TeamCollectionLinkConflictException( + "This collection still has an active Team Collection link to the " + + $"shared folder \"{existingLink.RepoFolderPath}\". Before enabling " + + "Cloud Team Collections, finish disconnecting from the old " + + $"(folder-based) Team Collection: delete \"{TeamCollectionLinkFileName}\" " + + "from this collection's folder, then try again." + ); + // existingLink.IsCloud: already linked to some cloud collection (possibly this + // very one, if this got called twice). Either way there's nothing to set up. + throw new TeamCollectionLinkConflictException( + "This collection is already linked to a cloud Team Collection " + + $"(id {existingLink.CloudCollectionId})." + ); + } + + /// + /// Connect the current collection to a new cloud-backed Team Collection, using + /// as both this Bloom collection's own CollectionId GUID + /// and the server's `collections.id` (CONTRACTS.md: "<collectionId> = the Bloom + /// CollectionId GUID (also the server collections.id)"). Creates the server-side row + /// (create_collection), links the local collection to it, and pushes every existing local + /// book and collection-level file up -- the cloud counterpart of + /// 's folder-backed flow. + /// + /// Guards the "adoption path" from a formerly-folder-based Team Collection (task 10): + /// throws if TeamCollectionLink.txt + /// still describes a different (folder or cloud) Team Collection -- a sign the user + /// hasn't finished "un-teaming" this local collection yet -- and otherwise cleans up any + /// stale per-book/per-collection artifacts the old TC left behind before pushing + /// everything to the new cloud collection (). + /// + public void ConnectToCloudCollection(string collectionId) + { + ThrowIfConflictingTeamCollectionLink(_localCollectionFolder); + // No conflicting link -- but the folder may still carry stale per-book status files, + // lastCollectionFileSyncData.txt, or log.txt from a Team Collection this local folder + // used to belong to before being un-teamed. Clear those before the first Send so + // every book uploads as a clean v1, not a stale checksum/lockedBy from the old TC. + TeamCollection.CleanStaleTeamCollectionArtifacts(_localCollectionFolder); + + // The creator of a TC is its first and, for now, usually only administrator. + Settings.Administrators = new[] { CurrentUser }; + Settings.Save(); + + var environment = Cloud.CloudEnvironment.Current; + var auth = Cloud.CloudAuth.CreateInitialized(environment); + var client = new Cloud.CloudCollectionClient(environment, auth); + client.CreateCollection(collectionId, Settings.CollectionName); + + var link = TeamCollectionLink.ForCloud(collectionId); + link.WriteToFile(GetTcLinkPathFromLcPath(_localCollectionFolder)); + + var newTc = new Cloud.CloudTeamCollection( this, _localCollectionFolder, - repoFolderPath, + collectionId, bookCollectionHolder: _bookCollectionHolder, - collectionLock: Lock + collectionLock: Lock, + environment: environment, + auth: auth, + client: client ); - newTc.CollectionId = collectionId; newTc.SocketServer = SocketServer; newTc.TCManager = this; - newTc.SetupTeamCollectionWithProgressDialog(repoFolderPath); + newTc.SetupCloudTeamCollectionWithProgressDialog(); CurrentCollection = newTc; CurrentCollectionEvenIfDisconnected = newTc; } @@ -567,16 +776,20 @@ public void SendBookContentReload() /// /// Disable most TC functionality under various conditions. Put a warning in - /// the log. + /// the log. Virtual only so a test-only subclass can record when it's called relative to + /// TeamCollection.SynchronizeRepoAndLocal, pinning WorkspaceModel's call-ordering fix for + /// the "tier-timing" bug (see the ordering tests alongside + /// TeamCollectionTierTimingTests). /// - public void CheckDisablingTeamCollections(CollectionSettings settings) + public virtual void CheckDisablingTeamCollections(CollectionSettings settings) { if (CurrentCollection == null) return; // already disabled, or not a TC string msg = null; string l10nId = null; + var subscriptionForCheck = GetSubscriptionForDisablingCheck(); var tcFeatureStatus = FeatureStatus.GetFeatureStatus( - Settings.Subscription, + subscriptionForCheck, FeatureName.TeamCollection ); if (!tcFeatureStatus.Enabled) @@ -601,7 +814,7 @@ public void CheckDisablingTeamCollections(CollectionSettings settings) ); if ( !FeatureStatus - .GetFeatureStatus(Settings.Subscription, FeatureName.TeamCollection) + .GetFeatureStatus(subscriptionForCheck, FeatureName.TeamCollection) .Enabled ) ( @@ -610,6 +823,58 @@ CurrentCollectionEvenIfDisconnected as DisconnectedTeamCollection } } + /// + /// The Subscription value to use for the tier check above. Ordinarily that's just + /// Settings.Subscription -- the in-memory CollectionSettings snapshot, which for a FOLDER + /// Team Collection is already trustworthy by the time this runs (its collection files live + /// in the same synchronously-read local folder with no sign-in or network round trip + /// standing between it and the shared file: see FolderTeamCollection's own + /// LastRepoCollectionFileModifyTime, a plain file-timestamp read). + /// + /// A cloud Team Collection is different: Settings is captured ONCE, synchronously, before + /// this check ever runs (ProjectContext resolves TeamCollectionManager, which pulls fresh + /// collection files from the repo, before it resolves the CollectionSettings that reads + /// them -- see ProjectContext's CollectionSettings registration), and is never reloaded for + /// the rest of the session. But pulling those collection files (the only thing that can + /// deliver an up-to-date SubscriptionCode into that snapshot) requires a signed-in cloud + /// session (CloudTeamCollection.CheckConnection short-circuits on !_auth.IsSignedIn) and a + /// successful S3 download that can silently no-op on failure + /// (CloudTeamCollection.DownloadCollectionFileGroup reports-and-swallows exceptions rather + /// than propagating them). Depending on ambient state at that moment (a persisted auth + /// token being ready yet, this machine's first-ever sync of this collection, a transient + /// network hiccup), Settings.Subscription can therefore still be blank/stale here even + /// though CurrentCollection is already non-null (CurrentCollection is deliberately set + /// BEFORE the connect-and-sync sequence completes; see CreateTeamCollectionFromLink's + /// caller). That is the "subscription-tier check timing" bug (GOING-LIVE.md Phase 5): + /// CheckDisablingTeamCollections' only readiness gate, CurrentCollection == null, does not + /// mean the same thing for cloud TCs that it does for folder ones. + /// + /// The fix: for a cloud TC, re-read the SubscriptionCode directly from the on-disk + /// .bloomCollection file instead of trusting the stale in-memory snapshot. Combined with + /// WorkspaceModel.HandleTeamStuffBeforeGetBookCollections deferring the cloud call of this + /// check until AFTER the collection-file sync (SynchronizeRepoAndLocal) has had a chance to + /// run, this makes the check deterministic: it reflects whatever the sync actually + /// delivered, not a snapshot that predates it. + /// + private Subscription GetSubscriptionForDisablingCheck() + { + if (!(CurrentCollection is Cloud.CloudTeamCollection)) + return Settings.Subscription; + try + { + var settingsPath = CollectionSettings.GetSettingsFilePath(_localCollectionFolder); + return ProjectContext.GetCollectionSettings(settingsPath).Subscription; + } + catch (Exception) + { + // No usable local .bloomCollection file to re-read (shouldn't normally happen once + // we get this far, since CurrentCollection being a CloudTeamCollection implies we + // already found one) -- fall back to the in-memory snapshot rather than crash a + // startup check. + return Settings.Subscription; + } + } + /// /// Returns true if registration is sufficient to use Team Collections; false otherwise /// diff --git a/src/BloomExe/TeamCollection/TeamCollectionMessage.cs b/src/BloomExe/TeamCollection/TeamCollectionMessage.cs index 9ab774893680..d706b2a5e75c 100644 --- a/src/BloomExe/TeamCollection/TeamCollectionMessage.cs +++ b/src/BloomExe/TeamCollection/TeamCollectionMessage.cs @@ -104,6 +104,19 @@ public TeamCollectionMessage( public string Param0 { get; set; } public string Param1 { get; set; } + /// + /// True for messages that must ABORT opening the collection entirely (show this message + /// and go back to the collection chooser) rather than falling back to the usual + /// Disconnected mode. Used by CloudTeamCollection.CheckConnection's NotAMember case + /// (batch item 9, account-switch behavior): only meaningful for the very first + /// CheckConnection call made while opening a collection (see + /// TeamCollectionManager.CheckConnection's allowHardRefusal parameter) -- a membership + /// loss discovered mid-session still just disconnects, it does not crash the running app. + /// Never round-tripped through ToPersistedForm/FromPersistedForm: a message carrying this + /// flag is acted on immediately and is never written into the persisted message log. + /// + public bool IsAccessRefusal { get; set; } + // Indicates message is important enough for us to show the Messages in preference to History public bool Important => MessageType == MessageAndMilestoneType.NewStuff diff --git a/src/BloomExe/WebLibraryIntegration/BloomS3Client.cs b/src/BloomExe/WebLibraryIntegration/BloomS3Client.cs index bee08debd12b..05e42fb3af02 100644 --- a/src/BloomExe/WebLibraryIntegration/BloomS3Client.cs +++ b/src/BloomExe/WebLibraryIntegration/BloomS3Client.cs @@ -117,7 +117,17 @@ protected virtual IAmazonS3 CreateAmazonS3Client(AmazonS3Config s3Config, string return CreateAmazonS3Client(s3Config, credentials); } - protected IAmazonS3 CreateAmazonS3Client( + /// + /// Builds an client from explicit credentials (with or without a + /// session token). Doesn't touch any instance state, so it's also the shared helper Cloud + /// Team Collection's reuses for its + /// own session-credentialed client construction (per-book STS/AssumeRole creds from the + /// checkin-start/download-start edge functions) rather than duplicating this logic — see + /// Design/CloudTeamCollections/tasks/04-client-core.md. Internal, not protected: unlike the + /// bucketName overload above (which bloom-harvester overrides), nothing here is meant to be + /// specialized by a subclass. + /// + internal static IAmazonS3 CreateAmazonS3Client( AmazonS3Config s3Config, AmazonS3Credentials credentials ) diff --git a/src/BloomExe/WebLibraryIntegration/S3Extensions.cs b/src/BloomExe/WebLibraryIntegration/S3Extensions.cs index 27be0b92d278..f50d1e5ca7f6 100644 --- a/src/BloomExe/WebLibraryIntegration/S3Extensions.cs +++ b/src/BloomExe/WebLibraryIntegration/S3Extensions.cs @@ -30,12 +30,15 @@ ListObjectsV2Request request do { matchingItemsResponse = s3.ListObjectsV2Async(request).GetAwaiter().GetResult(); - allMatchingItems.AddRange(matchingItemsResponse.S3Objects); + // AWSSDK v4: response collections default to null (not empty) when the server + // returns no items, and IsTruncated is now bool? — hence the null checks here. + if (matchingItemsResponse.S3Objects != null) + allMatchingItems.AddRange(matchingItemsResponse.S3Objects); // matchingItemsResponse.ContinuationToken indicates where the request that generated the response started // matchingItemsResponse.NextContinuationToken indicates where the next request (if needed) should start // request.ContinuationToken indicates where the request starts the next time it is used request.ContinuationToken = matchingItemsResponse.NextContinuationToken; - } while (matchingItemsResponse.IsTruncated); // IsTruncated returns true if it's not at the end + } while (matchingItemsResponse.IsTruncated == true); // IsTruncated returns true if it's not at the end return allMatchingItems; } diff --git a/src/BloomExe/Workspace/WorkspaceModel.cs b/src/BloomExe/Workspace/WorkspaceModel.cs index 11419c7d7088..c5b91c1b4c92 100644 --- a/src/BloomExe/Workspace/WorkspaceModel.cs +++ b/src/BloomExe/Workspace/WorkspaceModel.cs @@ -97,7 +97,21 @@ internal void HandleTeamStuffBeforeGetBookCollections(Action whenDone) // shared collection-level files each startup. So if the shared collection is updated with // new enterprise credentials, things will self-heal. We decided it's OK for that much // TC functionality to go on working even with enterprise disabled. - _tcManager.CheckDisablingTeamCollections(_collectionSettings); + // + // Cloud Team Collections are an exception to running this check right away: the + // collection-file sync a few lines below (not this call) is what actually refreshes the + // on-disk SubscriptionCode from the repo for a cloud TC, and that sync depends on cloud + // sign-in having completed -- so checking here, before sync, can intermittently judge a + // healthy cloud TC's subscription before it's known (see GOING-LIVE.md Phase 5's + // "Subscription-tier check timing" entry and CheckDisablingTeamCollections' + // GetSubscriptionForDisablingCheck). So for a cloud TC we defer this call until after + // that sync, in the whenDone wrapper below; folder TCs (and the no-TC case) keep the + // original, byte-identical timing since they have no such dependency. + var isCloudTeamCollection = + _tcManager.CurrentCollectionEvenIfDisconnected + is Bloom.TeamCollection.Cloud.CloudTeamCollection; + if (!isCloudTeamCollection) + _tcManager.CheckDisablingTeamCollections(_collectionSettings); // Before loading up the collection, update with anything new from any TeamCollection we are linked to. // To do this the TC if any needs to know the CollectionId. (We're not having autofac give it the // CollectionSettings because circular dependencies would result.) @@ -114,7 +128,15 @@ internal void HandleTeamStuffBeforeGetBookCollections(Action whenDone) // This won't do much if disabled, but it can clean out the status files for // books copied from another collection, and update checkout status for // an offline TC. - _tcManager.CurrentCollectionEvenIfDisconnected?.SynchronizeRepoAndLocal(whenDone); + _tcManager.CurrentCollectionEvenIfDisconnected?.SynchronizeRepoAndLocal(() => + { + // Deferred cloud tier check (see above): now that sync has had a chance to refresh + // the local SubscriptionCode from the repo, evaluate it -- its own cloud path + // re-reads that file fresh rather than trusting a snapshot older than the sync. + if (isCloudTeamCollection) + _tcManager.CheckDisablingTeamCollections(_collectionSettings); + whenDone(); + }); } // Alternative for GetBookCollections() that returns folder paths of all source collections. diff --git a/src/BloomExe/web/ReadersApi.cs b/src/BloomExe/web/ReadersApi.cs index 848ef4959d2a..1882c15462a8 100644 --- a/src/BloomExe/web/ReadersApi.cs +++ b/src/BloomExe/web/ReadersApi.cs @@ -7,7 +7,6 @@ using System.Threading; using System.Windows.Forms; using System.Xml; -using Amazon.Runtime.Internal.Util; using Bloom.Book; using Bloom.Collection; using Bloom.Edit; diff --git a/src/BloomExe/web/controllers/CollectionApi.cs b/src/BloomExe/web/controllers/CollectionApi.cs index 0209e75bb4d1..797278740021 100644 --- a/src/BloomExe/web/controllers/CollectionApi.cs +++ b/src/BloomExe/web/controllers/CollectionApi.cs @@ -18,6 +18,7 @@ using Bloom.Properties; using Bloom.SafeXml; using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; using Bloom.ToPalaso; using Bloom.Utils; using Bloom.WebLibraryIntegration; @@ -174,6 +175,22 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) try { newBookInfo = GetBookInfoFromPost(request); + if ( + newBookInfo == null + && TryPrioritizeNotYetDownloadedBook(request) + ) + { + // Batch item 7 (progressive join): the id belongs to a + // not-yet-downloaded Cloud Team Collection placeholder (no + // local BookInfo exists for it yet, so GetBookInfoFromPost + // found nothing). There's no book to select yet -- bump its + // background download to the front of the queue and reply + // gracefully instead of falling through to the null-reference + // below (SAFETY: no dangerous action -- edit/checkout/publish/ + // delete/rename -- is reachable on a placeholder). + request.PostSucceeded(); + break; + } var titleString = newBookInfo.QuickTitleUserDisplay; using ( PerformanceMeasurement.Global?.Measure( @@ -670,16 +687,21 @@ public void HandleBooksRequest(ApiRequest request) { title = info.ThumbnailLabel; } - return new + return new BookListEntry { id = info.Id, - title, + title = title, collectionId = collection.PathToDirectory, folderName = info.FolderName, folderPath = info.FolderPath, isFactory = collection.IsFactoryInstalled, + notYetDownloaded = false, }; }) + // Batch item 7 (progressive join): repo books in a Cloud Team Collection that have + // no local folder yet get a placeholder entry appended, so the collection can open + // immediately while they download in the background. + .Concat(GetNotYetDownloadedBookEntries(collection, bookInfos)) .ToArray(); // The goal here is to draw the book buttons before we tie up the UI thread for a long time loading @@ -703,6 +725,114 @@ public void HandleBooksRequest(ApiRequest request) request.ReplyWithJson(json); } + /// + /// One book's entry in the collections/books JSON. A named type (not just an anonymous + /// one, as this file's other DTOs usually are) so the real-book entries and the + /// not-yet-downloaded placeholder entries (batch item 7, progressive join) can be + /// concatenated into a single array. Property names are lowerCamelCase to match the JSON + /// shape the TypeScript side expects (BooksOfCollection.tsx's IBookInfo), matching this + /// file's existing convention for API DTOs. + /// + internal class BookListEntry + { + public string id { get; set; } + public string title { get; set; } + public string collectionId { get; set; } + public string folderName { get; set; } + public string folderPath { get; set; } + public bool isFactory { get; set; } + + /// True for a repo book that exists in a Cloud Team Collection but has no + /// local folder yet -- the client shows a download-in-progress placeholder for these + /// instead of the normal book button (batch item 7). + public bool notYetDownloaded { get; set; } + } + + /// + /// Batch item 7 (progressive join): repo books that exist in a Cloud Team Collection's + /// server-side book list but have no local folder yet get a placeholder entry, so the + /// collection can open immediately while they download in the background (see + /// CloudJoinFlow.JoinCollection and TeamCollection.SyncAtStartup's cloud rerouting). + /// Returns nothing for folder Team Collections and for any collection that isn't the + /// current project's one editable collection (a join always targets that collection, and + /// a placeholder for, say, the Templates collection would make no sense). + /// + private static List GetNotYetDownloadedBookEntries( + BookCollection collection, + IEnumerable localBookInfos + ) + { + if (collection.Type != BookCollection.CollectionType.TheOneEditableCollection) + return new List(); + if ( + !( + TeamCollectionApi.TheOneInstance?.TcManager?.CurrentCollection + is CloudTeamCollection cloudCollection + ) + ) + return new List(); + + var localNames = new HashSet( + localBookInfos.Select(i => i.FolderName), + StringComparer.OrdinalIgnoreCase + ); + // BookInfo.Id IS the meta.json bookInstanceId -- the same identity the server's + // tc.books.instance_id carries (bug #15). + var localInstanceIds = new HashSet( + localBookInfos.Select(i => i.Id).Where(id => !string.IsNullOrEmpty(id)), + StringComparer.OrdinalIgnoreCase + ); + var repoBooks = cloudCollection + .GetBookList() + .Select(name => + (name, instanceId: cloudCollection.TryGetBookInstanceIdForName(name)) + ); + return ComputeNotYetDownloadedBookEntries( + localNames, + localInstanceIds, + repoBooks, + collection.PathToDirectory + ); + } + + /// + /// Pure merge logic (unit-tested by CollectionApiTests, no filesystem/network/repo access): + /// given the local book folder names AND local book instance ids already scanned from disk + /// and the full repo book list (folder name, stable instance id) for a Cloud Team + /// Collection, returns placeholder entries for repo books that have no local presence yet. + /// A repo book is locally present when a folder has its NAME or -- bug #15 (John's ruling: + /// instance id is the book's identity) -- when any local book has its INSTANCE ID, e.g. a + /// checked-out book whose local folder was renamed ahead of check-in; without the id check + /// that book grew a phantom "not yet downloaded" placeholder next to its real button. + /// + internal static List ComputeNotYetDownloadedBookEntries( + ISet localBookFolderNames, + ISet localBookInstanceIds, + IEnumerable<(string name, string instanceId)> repoBooks, + string collectionPathToDirectory + ) + { + return repoBooks + .Where(b => + !localBookFolderNames.Contains(b.name) + && ( + string.IsNullOrEmpty(b.instanceId) + || !localBookInstanceIds.Contains(b.instanceId) + ) + ) + .Select(b => new BookListEntry + { + id = b.instanceId ?? b.name, + title = b.name, + collectionId = collectionPathToDirectory, + folderName = b.name, + folderPath = Path.Combine(collectionPathToDirectory, b.name), + isFactory = false, + notYetDownloaded = true, + }) + .ToList(); + } + // This needs to be thread-safe. private BookCollection GetCollectionOfRequest(ApiRequest request) { @@ -991,6 +1121,33 @@ private BookInfo GetBookInfoFromPost(ApiRequest request) return collection.GetBookInfos().FirstOrDefault(predicate); } + /// + /// Batch item 7 (progressive join): if the given collections/selected-book POST's id + /// matches a repo book in the current Cloud Team Collection that has no local folder yet, + /// bumps its background download to the front of the queue and returns true. Returns false + /// (no-op) for folder Team Collections, when there's no Team Collection at all, or when the + /// id doesn't match any known not-yet-downloaded repo book -- callers should fall back to + /// their normal handling (which will then hit the usual "book not found" error path) in + /// that case, so this never masks a genuinely bad id. + /// + private bool TryPrioritizeNotYetDownloadedBook(ApiRequest request) + { + if ( + !( + TeamCollectionApi.TheOneInstance?.TcManager?.CurrentCollection + is CloudTeamCollection cloudCollection + ) + ) + return false; + + var id = request.GetParamOrNull("id") ?? request.RequiredPostString(); + var bookName = cloudCollection.TryGetBookNameForInstanceId(id); + if (bookName == null) + return false; + cloudCollection.PrioritizeDownload(bookName); + return true; + } + private Book.Book GetBookObjectFromPost(ApiRequest request) { var info = GetBookInfoFromPost(request); diff --git a/src/BloomExe/web/controllers/CollectionChooserApi.cs b/src/BloomExe/web/controllers/CollectionChooserApi.cs index a11c06cfd192..f4241b23ad21 100644 --- a/src/BloomExe/web/controllers/CollectionChooserApi.cs +++ b/src/BloomExe/web/controllers/CollectionChooserApi.cs @@ -10,6 +10,7 @@ using Bloom.MiscUI; using Bloom.Properties; using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; using Bloom.ToPalaso; using Bloom.Utils; using Bloom.WebLibraryIntegration; @@ -114,6 +115,14 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) false, false ); + // Allow background thread; this makes a network call to the cloud server (when + // signed in -- see SharingApi.GetMyCollectionsForJoinCards). + apiHandler.RegisterEndpointHandler( + "collections/getJoinCards", + HandleGetJoinCards, + false, + false + ); } /// @@ -413,6 +422,118 @@ private static void HandleGetUnpublishedCount(ApiRequest request) } } + /// + /// Dogfood batch 1, item 6: returns one "join card" entry per cloud Team Collection the + /// signed-in user belongs to but has no local copy of yet, for the collection chooser to + /// append after its normal MRU card list. Degrades silently (empty list, no cloud call) + /// when signed out -- see SharingApi.GetMyCollectionsForJoinCards -- so this endpoint is + /// safe to call unconditionally from a folder-only user's chooser. + /// + private static void HandleGetJoinCards(ApiRequest request) + { + var myCloudCollections = SharingApi.GetMyCollectionsForJoinCards(); + var joinCards = + myCloudCollections.Count == 0 + ? new List() + : ComputeJoinCards( + myCloudCollections, + GetLocalCloudCopies(), + SharingApi.SignedInEmailForJoinCards() + ); + request.ReplyWithJson(joinCards); + } + + /// + /// Pure matching logic (unit-tested by CollectionChooserApiTests, no filesystem/network): + /// a cloud collection gets a join card unless a local copy of it exists that was most + /// recently used by the SIGNED-IN account (dogfood bug #11, John's ruling 13 Jul 2026). + /// Matching a copy by cloud id alone is not enough to suppress: on a shared machine (or a + /// multi-identity dev setup) the chooser's machine-wide collection list includes OTHER + /// accounts' copies, and those must not hide this account's invitation. A copy whose + /// last-known user is unknown (no TeamCollectionLastKnownUser.txt yet -- e.g. a manually + /// copied folder) does NOT suppress: "not known to be mine" shows the card, and + /// CloudJoinFlow's scenario matching handles any merge/conflict at actual join time. + /// A local folder with the same name that is not a cloud TC at all likewise still gets a + /// join card (unchanged from the original item-6 decision). + /// + internal static List ComputeJoinCards( + IEnumerable myCloudCollections, + IEnumerable<(string cloudCollectionId, string lastKnownUser)> localCloudCopies, + string signedInEmail + ) + { + var idsWithMyOwnLocalCopy = new HashSet( + localCloudCopies + .Where(copy => + copy.lastKnownUser != null + && string.Equals( + copy.lastKnownUser, + signedInEmail, + StringComparison.OrdinalIgnoreCase + ) + ) + .Select(copy => copy.cloudCollectionId) + ); + return myCloudCollections + .Where(c => !idsWithMyOwnLocalCopy.Contains(c.Id)) + .Select(c => (dynamic)new { collectionId = c.Id, title = c.Name }) + .ToList(); + } + + /// + /// Scans the same candidate collection folders + /// considers (MRU list + collections discovered in the default parent directory), reading + /// each one's TeamCollectionLink.txt (if any) to find which cloud collection ids already + /// have a local copy on this machine -- paired with that copy's last-known user + /// (TeamCollectionLastKnownUser.txt, null when never recorded) so ComputeJoinCards can + /// apply its identity-aware suppression. Uncapped (unlike the MRU display list's + /// maxMruItems) since a join card should be suppressed even if the local copy has + /// scrolled out of the visible MRU list. + /// + private static List<(string cloudCollectionId, string lastKnownUser)> GetLocalCloudCopies() + { + var copies = new List<(string cloudCollectionId, string lastKnownUser)>(); + foreach (var folderPath in GetCandidateCollectionFolders()) + { + var linkPath = TeamCollectionManager.GetTcLinkPathFromLcPath(folderPath); + if (!RobustFile.Exists(linkPath)) + continue; + TeamCollectionLink link; + try + { + link = TeamCollectionLink.FromFile(linkPath); + } + catch (InvalidTeamCollectionLinkException) + { + // Unparseable link content; treat as "not a recognizable cloud TC" rather than + // crashing the whole join-card computation over one bad folder. + continue; + } + if (link != null && link.IsCloud) + copies.Add( + (link.CloudCollectionId, TeamCollectionLastKnownUser.Read(folderPath)) + ); + } + return copies; + } + + /// + /// All local collection FOLDER paths worth checking for a TeamCollectionLink.txt: every + /// MRU entry plus every collection discovered in the default parent directory (the same two + /// sources draws from, but not capped at + /// maxMruItems -- see that method's own cap comment). + /// + private static IEnumerable GetCandidateCollectionFolders() + { + var mruFolders = Settings.Default.MruProjects.Paths.Select(Path.GetDirectoryName); + var discovered = Directory.Exists( + NewCollectionWizard.DefaultParentDirectoryForCollections + ) + ? Directory.GetDirectories(NewCollectionWizard.DefaultParentDirectoryForCollections) + : Array.Empty(); + return mruFolders.Concat(discovered).Distinct(); + } + /// /// Shows a file open dialog for .bloomCollection files and returns the selected path, /// or null if the user cancelled. diff --git a/src/BloomExe/web/controllers/CollectionSettingsApi.cs b/src/BloomExe/web/controllers/CollectionSettingsApi.cs index 0cc43ad69eb5..0f8e59eb3c7d 100644 --- a/src/BloomExe/web/controllers/CollectionSettingsApi.cs +++ b/src/BloomExe/web/controllers/CollectionSettingsApi.cs @@ -316,6 +316,10 @@ private object GetAdvancedSettingsData() ?? ExperimentalFeatures.IsFeatureEnabled( ExperimentalFeatures.kTeamCollections ), + allowCloudTeamCollection = dialog?.PendingAllowCloudTeamCollection + ?? ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kCloudTeamCollections + ), allowAppBuilder = dialog?.PendingAllowAppBuilder ?? ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kAppBuilder), showQrCode = dialog?.PendingShowQrCode @@ -327,6 +331,8 @@ private object GetAdvancedSettingsData() showExperimentalBookSourcesOption = dialog?.ShowExperimentalBookSourcesOption ?? false, allowTeamCollectionEnabled = dialog?.AllowTeamCollectionOptionEnabled ?? true, + allowCloudTeamCollectionEnabled = dialog?.AllowCloudTeamCollectionOptionEnabled + ?? true, }; } @@ -353,6 +359,16 @@ private void StoreAdvancedSettingsData(ApiRequest request, CollectionSettingsDia dialog.ChangeThatRequiresRestart(); } + var allowCloudTeamCollectionToken = data["allowCloudTeamCollection"]; + if (allowCloudTeamCollectionToken != null) + { + var allowCloudTeamCollection = allowCloudTeamCollectionToken.Value(); + var previousValue = dialog.PendingAllowCloudTeamCollection; + dialog.PendingAllowCloudTeamCollection = allowCloudTeamCollection; + if (allowCloudTeamCollection != previousValue) + dialog.ChangeThatRequiresRestart(); + } + var allowAppBuilderToken = data["allowAppBuilder"]; if (allowAppBuilderToken != null) { diff --git a/src/BloomExe/web/controllers/ExternalApi.cs b/src/BloomExe/web/controllers/ExternalApi.cs index e3e0d5b94899..55464d35da0e 100644 --- a/src/BloomExe/web/controllers/ExternalApi.cs +++ b/src/BloomExe/web/controllers/ExternalApi.cs @@ -4,6 +4,7 @@ using Bloom.Collection; using Bloom.CollectionTab; using Bloom.Edit; +using Bloom.TeamCollection.Cloud; using Bloom.web; using Bloom.WebLibraryIntegration; using Bloom.Workspace; @@ -172,6 +173,56 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) false ); + // This is called from the same BloomLibrary-hosted login page as external/login + // above, forwarding the Firebase ID + refresh tokens Cloud Team Collections need + // (Option A -- see CONTRACTS.md's "Auth (Option A)" section and + // SharingApi.HandleCloudLoginTokens, which owns the actual sign-in). A separate + // endpoint rather than adding fields to external/login because the two payloads are + // independent (a BloomLibrary/Parse sign-in does not imply a Cloud Team Collection + // one, and vice versa) and arrive from a page that may call either or both. + apiHandler.RegisterEndpointHandler( + "external/cloudLogin", + request => + { + if (request.HttpMethod == HttpMethods.Post) + { + string idToken = null; + string refreshToken = null; + try + { + var data = Newtonsoft.Json.Linq.JObject.Parse( + request.RequiredPostJson() + ); + idToken = (string)data["idToken"]; + refreshToken = (string)data["refreshToken"]; + if (string.IsNullOrEmpty(idToken) || string.IsNullOrEmpty(refreshToken)) + { + request.Failed( + "external/cloudLogin requires both 'idToken' and 'refreshToken'" + ); + return; + } + + SharingApi.HandleCloudLoginTokens(idToken, refreshToken); + request.PostSucceeded(); + + Shell.ComeToFront(); + } + catch (CloudAuthException e) + { + Logger.WriteError("external/cloudLogin: sign-in failed", e); + request.Failed(e.Message); + } + } + else if (request.HttpMethod == HttpMethods.Options) + { + // blorg will send an OPTIONS request; if we don't respond successfully, things go badly. + request.PostSucceeded(); + } + }, + false + ); + // This is called from outside Bloom (e.g. bloomlibrary.org) to bring the Bloom window to the front. apiHandler.RegisterEndpointHandler( "external/bringToFront", diff --git a/src/BloomExe/web/controllers/FileIOApi.cs b/src/BloomExe/web/controllers/FileIOApi.cs index e209f06d4683..35100c839beb 100644 --- a/src/BloomExe/web/controllers/FileIOApi.cs +++ b/src/BloomExe/web/controllers/FileIOApi.cs @@ -14,7 +14,6 @@ using Newtonsoft.Json; using SIL.IO; using SIL.PlatformUtilities; -using ThirdParty.Json.LitJson; namespace Bloom.web.controllers { diff --git a/src/BloomExe/web/controllers/ProblemReportApi.cs b/src/BloomExe/web/controllers/ProblemReportApi.cs index b1ea91e69b86..da00daa62f31 100644 --- a/src/BloomExe/web/controllers/ProblemReportApi.cs +++ b/src/BloomExe/web/controllers/ProblemReportApi.cs @@ -686,6 +686,22 @@ public static void ShowProblemReactDialogWithFallbacks( int height = 616 ) { + if (Program.UnattendedAutomation) + { + // In UNATTENDED automation (E2E harnesses, CI -- --automation without + // --attended) there is no human to dismiss a modal problem report, so showing + // one blocks the UI thread forever and every under-load error masquerades as a + // hang (diagnosed from a dotnet-stack dump of a hung instance -- see + // Design/CloudTeamCollections/tasks/09-e2e.md finding 9). Log the problem + // instead so the driving test surfaces a readable failure. The fallback + // dialogs are equally modal, so they are skipped too. Attended automation + // (`pnpm go`) shows the report normally. + SIL.Reporting.Logger.WriteError( + $"Problem report suppressed in automation mode: {reactDialogHeader}", + exception ?? new ApplicationException("(no exception provided)") + ); + return; + } SafeInvoke.InvokeIfPossible( "Show Problem Dialog", control, diff --git a/src/BloomExe/web/controllers/SharingApi.cs b/src/BloomExe/web/controllers/SharingApi.cs new file mode 100644 index 000000000000..c71813304d87 --- /dev/null +++ b/src/BloomExe/web/controllers/SharingApi.cs @@ -0,0 +1,709 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Bloom.Api; +using Bloom.Collection; +using Bloom.History; +using Bloom.MiscUI; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using Bloom.WebLibraryIntegration; +using DesktopAnalytics; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SIL.IO; +using SIL.Reporting; + +namespace Bloom.web.controllers +{ + /// + /// Cloud Team Collections (task 06): the "sharing/*" and "collections/*" endpoints the UI + /// (task 07's sharingApi.ts, task 08's teamCollectionApi.tsx) already calls against mocked + /// versions of. Thin pass-throughs to CloudCollectionClient/CloudJoinFlow/CloudAuth per + /// CONTRACTS.md and the task brief -- no Cloud-backend business logic lives here, only wire- + /// shape adaptation (RPC snake_case -> the camelCase shapes sharingApi.ts already committed + /// to) and dispatch to whichever collection is relevant. + /// + /// Registered at the APPLICATION level (ApplicationContainer.cs), like CollectionChooserApi -- + /// NOT per-project like TeamCollectionApi. This is required, not a style choice: + /// collections/mine, collections/pullDown, and sharing/loginState/login/logout must all work + /// from the collection chooser BEFORE any project is loaded (see MyCloudCollectionsSection / + /// useSharingLoginState, which the chooser renders pre-project-load). Endpoints that are only + /// meaningful for an already-open cloud collection (members/addApproval/removeApproval/ + /// setRole/forceUnlock/history) reach whichever project happens to be currently open via + /// -- the same static hook TeamCollectionApi + /// already exposes for exactly this purpose (TcManager/SocketServer/CurrentBookFolderName). + /// Since at most one project is ever open in a Bloom.exe process, this is a legitimate cross- + /// class hook-up, not a duplicate of TeamCollectionApi's own book-status logic. + /// + public class SharingApi + { + /// Called once at startup (app-level registration) to register every sharing/* + /// and collections/* endpoint this class serves. + public void RegisterWithApiHandler(BloomApiHandler apiHandler) + { + apiHandler.RegisterEndpointHandler("sharing/loginState", HandleLoginState, false); + apiHandler.RegisterEndpointHandler("sharing/login", HandleLogin, false); + apiHandler.RegisterEndpointHandler("sharing/logout", HandleLogout, false); + apiHandler.RegisterEndpointHandler("sharing/showSignIn", HandleShowSignIn, true); + apiHandler.RegisterEndpointHandler( + "sharing/openBrowserSignIn", + HandleOpenBrowserSignIn, + false + ); + + apiHandler.RegisterEndpointHandler("sharing/members", HandleMembers, false); + apiHandler.RegisterEndpointHandler("sharing/addApproval", HandleAddApproval, false); + apiHandler.RegisterEndpointHandler( + "sharing/removeApproval", + HandleRemoveApproval, + false + ); + apiHandler.RegisterEndpointHandler("sharing/setRole", HandleSetRole, false); + apiHandler.RegisterEndpointHandler( + "sharing/setDisplayName", + HandleSetDisplayName, + false + ); + + // Same handler TeamCollectionApi registers under "teamCollection/forceUnlock" -- + // ForceUnlock already dispatches to the audited RPC for a cloud backend via + // UnlockInRepo(force: true), so there is exactly one implementation either way. + apiHandler.RegisterEndpointHandler("sharing/forceUnlock", HandleForceUnlock, false); + + apiHandler.RegisterEndpointHandler("sharing/history", HandleHistory, false); + apiHandler.RegisterEndpointHandler("sharing/historyCache", HandleHistoryCache, false); + + apiHandler.RegisterEndpointHandler("collections/mine", HandleMyCollections, false); + apiHandler.RegisterEndpointHandler("collections/pullDown", HandlePullDown, true); + + // Application-level ON PURPOSE (moved out of the project-level TeamCollectionApi; + // post-batch defect, 10 Jul 2026): callers legitimately probe capabilities when no + // project is open -- the E2E harness's readiness poll, or a late request from a + // closing collection tab while the chooser is on screen -- and the project-level + // registration made every such probe raise a "Cannot Find API Endpoint" toast. + // Answering "no capabilities" (all false) is correct whenever no project is open. + apiHandler.RegisterEndpointHandler( + "teamCollection/capabilities", + HandleCapabilities, + false + ); + } + + /// Backend capability flags (CONTRACTS.md, additive): tells the UI what the + /// current Team Collection's backend can do, so components branch on capability rather + /// than concrete backend type. All false for a folder TC, no collection, or no open + /// project. Reaches the current project the same way this class's other handlers do + /// (TeamCollectionApi.TheOneInstance); like them, it can briefly report the LAST project's + /// capabilities between closing one collection and opening another, which is harmless for + /// boolean capability flags. + private void HandleCapabilities(ApiRequest request) + { + var collection = TeamCollectionApi.TheOneInstance?.TcManager?.CurrentCollection; + request.ReplyWithJson( + new + { + supportsVersionHistory = collection?.SupportsVersionHistory ?? false, + supportsSharingUi = collection?.SupportsSharingUi ?? false, + requiresSignIn = collection?.RequiresSignIn ?? false, + } + ); + } + + // ------------------------------------------------------------------ + // Identity: which CloudAuth/CloudCollectionClient to use for a given call. + // ------------------------------------------------------------------ + + // Process-wide fallback identity for chooser-time use (no project loaded, or the loaded + // project isn't itself a cloud TC). Lazily created on first use; deliberately NOT the same + // object as any open CloudTeamCollection's own auth (that one is preferred whenever it + // exists -- see CurrentAuth below), so this only matters before/between cloud collections. + private static CloudAuth _globalAuth; + private static CloudCollectionClient _globalClient; + private static readonly object _globalAuthLock = new object(); + + private static CloudTeamCollection CurrentCloudCollection() => + TeamCollectionApi.TheOneInstance?.TcManager?.CurrentCollection as CloudTeamCollection; + + /// The auth session to use: the CURRENTLY OPEN cloud collection's own (the single + /// source of truth for "am I signed in", since that's what's actually driving its book + /// locks), or a lazily-created process-wide fallback for chooser-time use before any cloud + /// collection is open. + private static CloudAuth CurrentAuth() => + CurrentCloudCollection()?.Auth ?? GetOrCreateGlobalAuth(); + + private static CloudCollectionClient CurrentClient() => + CurrentCloudCollection()?.Client ?? GetOrCreateGlobalClient(); + + private static CloudAuth GetOrCreateGlobalAuth() + { + lock (_globalAuthLock) + { + if (_globalAuth == null) + _globalAuth = CloudAuth.CreateInitialized(CloudEnvironment.Current); + return _globalAuth; + } + } + + private static CloudCollectionClient GetOrCreateGlobalClient() + { + lock (_globalAuthLock) + { + return _globalClient ??= new CloudCollectionClient( + CloudEnvironment.Current, + GetOrCreateGlobalAuth() + ); + } + } + + /// Pushes a websocket event on the SAME connection the currently-open project's UI + /// listens on (see TeamCollectionApi.SocketServer's own doc comment for why this indirect + /// path is necessary). A no-op when no project is loaded -- nothing is listening then + /// anyway. + private static void NotifyClients(string clientContext, string eventId) => + TeamCollectionApi.TheOneInstance?.SocketServer?.SendEvent(clientContext, eventId); + + // ------------------------------------------------------------------ + // Sign-in state + // ------------------------------------------------------------------ + + private void HandleLoginState(ApiRequest request) + { + var loginState = CurrentAuth().GetLoginState(CloudEnvironment.Current); + request.ReplyWithJson( + new + { + mode = loginState.AuthMode, + signedIn = loginState.SignedIn, + email = loginState.Email, + emailVerified = loginState.EmailVerified, + } + ); + } + + private class LoginBody + { + public string email; + public string password; + } + + private void HandleLogin(ApiRequest request) + { + var body = request.RequiredPostObject(); + try + { + CurrentAuth().SignIn(body.email, body.password); + NotifyClients("sharing", "loginState"); + request.PostSucceeded(); + } + catch (CloudAuthException e) + { + Logger.WriteError("SharingApi.HandleLogin: sign-in failed", e); + request.Failed(e.Message); + } + } + + private void HandleLogout(ApiRequest request) + { + CurrentAuth().SignOut(); + NotifyClients("sharing", "loginState"); + request.PostSucceeded(); + } + + /// + /// The Bloom-side half of the token-receipt endpoint (ExternalApi's + /// `external/cloudLogin`, task 12): turns a Firebase ID+refresh token pair -- forwarded + /// by the BloomLibrary-hosted login page after a real sign-in, per CONTRACTS.md's "Auth + /// (Option A)" section -- into a signed-in CloudAuth session, then notifies the UI + /// exactly like does for the dev-mode password flow. Public + /// and static (rather than an endpoint handler here) so ExternalApi.cs -- which already + /// owns the browser-facing `external/*` conventions (CORS OPTIONS handling, + /// Shell.ComeToFront) for the pre-existing Parse `external/login` -- can reuse this + /// class's CurrentAuth()/NotifyClients() identity plumbing without duplicating it. + /// Throws CloudAuthException on failure (e.g. a malformed token); the caller (ExternalApi) + /// decides how to surface that over HTTP. + /// + public static void HandleCloudLoginTokens(string idToken, string refreshToken) + { + CurrentAuth().SignInWithExternalTokens(idToken, refreshToken); + NotifyClients("sharing", "loginState"); + } + + /// Delegates to TeamCollectionApi's own HandleForceUnlock (see its doc comment): + /// exactly one implementation, registered under two endpoint names. + private void HandleForceUnlock(ApiRequest request) + { + var teamCollectionApi = TeamCollectionApi.TheOneInstance; + if (teamCollectionApi == null) + { + request.Failed("No project is currently open."); + return; + } + teamCollectionApi.HandleForceUnlock(request); + } + + /// Opens the dedicated SignInDialog (SignInDialog.tsx), which shares the + /// "createTeamCollectionDialogBundle" bundle/entry with the folder- and cloud-TC create + /// dialogs -- see CreateTeamCollection.tsx's CreateTeamCollectionBundleDispatcher, which + /// picks the right one via this dialogKind prop. + private void HandleShowSignIn(ApiRequest request) + { + ReactDialog.ShowOnIdle( + "createTeamCollectionDialogBundle", + new { dialogKind = "signIn" }, + 420, + 320, + null, + null, + "Sign In" + ); + request.PostSucceeded(); + } + + /// + /// The "cloud" (Option A) sign-in mode's entry point from SignInDialog.tsx: there is no + /// password form to submit, so the button there posts here instead, which just opens the + /// same BloomLibrary-hosted login page Bloom already opens for BloomLibrary account + /// sign-in (BloomLibraryAuthentication.LogIn -- see its own doc comment for the exact + /// URL). That page forwards the resulting Firebase tokens back to Bloom's + /// external/cloudLogin endpoint (CONTRACTS.md's "Auth (Option A)" section), which is what + /// actually completes the sign-in; SignInDialog closes itself once that lands, via the + /// same useSharingLoginState()/loginState websocket subscription every other sign-in path + /// already relies on. + /// + private void HandleOpenBrowserSignIn(ApiRequest request) + { + BloomLibraryAuthentication.LogIn(); + request.PostSucceeded(); + } + + // ------------------------------------------------------------------ + // Approved-accounts management (admin-only server-side; RLS/RPCs enforce it, so this + // class doesn't need its own permission check) + // ------------------------------------------------------------------ + + /// Maps one tc.members row (members_list's snake_case RPC shape) to the camelCase + /// IApprovedMember shape sharingApi.ts expects. name comes from the durable + /// tc.members.display_name column (20260713000001 migration; editable via + /// sharing/setDisplayName below) and is null until someone sets it -- the UI falls back to + /// the email. Internal (not private) so SharingApiTests can verify the mapping without a + /// live server. + internal static object ToApprovedMember(JObject row) => + new + { + email = (string)row["email"], + name = (string)row["display_name"], + role = (string)row["role"], + // The (string) cast maps a JSON-null token to a real null; a bare `!= null` + // check is TRUE for JTokenType.Null and made every pending invitation look + // claimed in the sharing panel. + claimed = (string)row["user_id"] != null, + }; + + private void HandleMembers(ApiRequest request) + { + var collectionId = request.RequiredParam("collectionId"); + var members = CurrentClient() + .MembersList(collectionId) + .OfType() + .Select(ToApprovedMember) + .ToList(); + request.ReplyWithJson(members); + } + + private class MemberApprovalBody + { + public string collectionId; + public string email; + public string role; + } + + private void HandleAddApproval(ApiRequest request) + { + var body = request.RequiredPostObject(); + CurrentClient().MembersAdd(body.collectionId, body.email, body.role ?? "member"); + NotifyClients("sharing", "membersChanged"); + request.PostSucceeded(); + } + + /// Resolves an email to its tc.members row id within a collection -- needed + /// because the deployed members_remove/members_set_role RPCs key off the numeric member + /// row id, not email (see CloudCollectionClient.MembersRemove/MembersSetRole's own doc + /// comments on this task-06 live-verification fix). Throws if the email isn't an approved + /// member of the collection -- a genuine caller error (AGENTS.md: fail fast rather than + /// silently swallow), not something to report as an ordinary "not found" JSON result. Takes + /// the already-fetched member list rather than a collectionId so SharingApiTests can + /// exercise the resolution logic without a live server. + /// + internal static long ResolveMemberIdFromList( + JArray members, + string collectionId, + string email + ) + { + var row = members + .OfType() + .FirstOrDefault(m => + string.Equals((string)m["email"], email, StringComparison.OrdinalIgnoreCase) + ); + if (row == null) + throw new ApplicationException( + $"'{email}' is not an approved member of collection {collectionId}." + ); + return (long)row["id"]; + } + + private static long ResolveMemberId(string collectionId, string email) => + ResolveMemberIdFromList(CurrentClient().MembersList(collectionId), collectionId, email); + + private void HandleRemoveApproval(ApiRequest request) + { + var body = request.RequiredPostObject(); + var memberId = ResolveMemberId(body.collectionId, body.email); + CurrentClient().MembersRemove(body.collectionId, memberId); + NotifyClients("sharing", "membersChanged"); + request.PostSucceeded(); + } + + private void HandleSetRole(ApiRequest request) + { + var body = request.RequiredPostObject(); + var memberId = ResolveMemberId(body.collectionId, body.email); + CurrentClient().MembersSetRole(body.collectionId, memberId, body.role); + NotifyClients("sharing", "membersChanged"); + request.PostSucceeded(); + } + + private class SetDisplayNameBody + { + public string collectionId; + public string email; + public string displayName; + } + + /// Sets a member's human-readable display name (shown wherever the member is + /// displayed -- checkout status, history, sharing panel -- with email as the fallback). + /// The server RPC (members_set_display_name, 20260713000001) allows an admin to set + /// anyone's name and a claimed member to set their own; a blank name clears it. + private void HandleSetDisplayName(ApiRequest request) + { + var body = request.RequiredPostObject(); + var memberId = ResolveMemberId(body.collectionId, body.email); + CurrentClient().MembersSetDisplayName(body.collectionId, memberId, body.displayName); + NotifyClients("sharing", "membersChanged"); + request.PostSucceeded(); + } + + // ------------------------------------------------------------------ + // History (CONTRACTS.md's get_changes RPC, surfaced for CollectionHistoryTable.tsx's cloud + // path). Maps events to the exact same BookHistoryEvent shape (Title/ThumbnailPath/When/ + // Message/Type/UserId/UserName) folder TCs already return from teamCollection/getHistory, + // so the UI's rendering code is unchanged either way. + // ------------------------------------------------------------------ + + internal static BookHistoryEvent ToBookHistoryEvent(JObject e) => + new BookHistoryEvent + { + BookId = (string)e["book_id"], + Title = (string)e["book_name"], + ThumbnailPath = "", // no local thumbnail for a server-originated event; see report. + Message = (string)e["message"], + Type = (BookHistoryEventType)(int)e["type"], + UserId = (string)e["by_user_id"], + // Preference order: the member's CURRENT durable display name (by_display_name, + // 20260713000001), then the JWT name claim frozen in at event time, then email. + UserName = + (string)e["by_display_name"] + ?? (string)e["by_user_name"] + ?? (string)e["by_email"], + When = (DateTime)e["occurred_at"], + BloomVersion = (string)e["bloom_version"], + }; + + /// On-disk history cache: the merged events plus the get_changes cursor (the max + /// event id they were fetched through). A later open fetches only NEW events via + /// get_changes(cursor) and appends them, instead of re-downloading the whole log every time + /// -- safe because the event log is append-only and get_changes' cursor is exclusive + /// (pgTAP 7c). Trade-off accepted with John (16 Jul 2026): already-cached rows keep the book + /// name / author display name they were fetched with, so a later rename or display-name edit + /// isn't reflected on old rows until the cache is rebuilt. + internal class CloudHistoryCache + { + public long MaxEventId; + public List Events = new List(); + } + + /// Reads the history cache, tolerating a missing file and the pre-incremental + /// on-disk format (a bare events array): an old-format file keeps its events (so the offline + /// view still works) but reports cursor 0, so the next online fetch does one full refetch and + /// re-saves in the new format. + internal static CloudHistoryCache LoadHistoryCache(string path) + { + if (string.IsNullOrEmpty(path) || !RobustFile.Exists(path)) + return new CloudHistoryCache(); + try + { + var token = JToken.Parse(RobustFile.ReadAllText(path)); + if (token is JArray oldFormatEvents) + return new CloudHistoryCache + { + MaxEventId = 0, + Events = oldFormatEvents.ToObject>(), + }; + return token.ToObject() ?? new CloudHistoryCache(); + } + catch (Exception e) + { + NonFatalProblem.ReportSentryOnly(e, "SharingApi: failed to read history cache"); + return new CloudHistoryCache(); + } + } + + /// Persists the merged history + cursor so can + /// serve it while disconnected and the next fetch can resume from the cursor. Best-effort: + /// a failure to write must never break the live history fetch that just succeeded. + internal static void SaveHistoryCache(string path, CloudHistoryCache cache) + { + if (string.IsNullOrEmpty(path)) + return; + try + { + RobustFile.WriteAllText(path, JsonConvert.SerializeObject(cache)); + } + catch (Exception e) + { + NonFatalProblem.ReportSentryOnly(e, "SharingApi: failed to write history cache"); + } + } + + /// Merges a get_changes response into the cached history. With a non-zero prior + /// cursor the response holds only events AFTER it (exclusive), so they append; with cursor 0 + /// the response is the whole log, so it replaces. Result is sorted newest-first, as the UI + /// expects. Pure/static so it can be unit-tested without a live client. + internal static CloudHistoryCache MergeHistory( + CloudHistoryCache cached, + JArray newEventsJson, + long? responseMaxEventId + ) + { + var newEvents = newEventsJson.OfType().Select(ToBookHistoryEvent); + var merged = (cached.MaxEventId > 0 ? cached.Events.Concat(newEvents) : newEvents) + .OrderByDescending(e => e.When) + .ToList(); + return new CloudHistoryCache + { + MaxEventId = responseMaxEventId ?? cached.MaxEventId, + Events = merged, + }; + } + + private List FetchAndCacheHistory( + string collectionId, + bool currentBookOnly + ) + { + var path = HistoryCachePath(collectionId); + var cached = LoadHistoryCache(path); + // Fetch only events newer than the cursor (whole log when cursor is 0); merge + persist. + var changes = CurrentClient().GetChanges(collectionId, cached.MaxEventId); + var updated = MergeHistory( + cached, + (JArray)changes["events"], + (long?)changes["max_event_id"] + ); + SaveHistoryCache(path, updated); + + var events = updated.Events; + if (currentBookOnly) + events = FilterToCurrentBook(events); + return events; + } + + private void HandleHistory(ApiRequest request) + { + var collectionId = request.RequiredParam("collectionId"); + var currentBookOnly = request.GetParamOrNull("currentBookOnly") == "true"; + request.ReplyWithJson( + JsonConvert.SerializeObject(FetchAndCacheHistory(collectionId, currentBookOnly)) + ); + } + + private static string HistoryCachePath(string collectionId) + { + var folder = CurrentCloudCollection()?.LocalCollectionFolder; + return folder == null ? null : Path.Combine(folder, ".bloom-cloud-history-cache.json"); + } + + /// Keeps only the currently-selected book's events (resolving its server book id) -- + /// the "current book only" filter shared by the live and cached history paths. + private static List FilterToCurrentBook(List events) + { + var bookId = CurrentCloudCollection() + ?.TryGetBookIdForHistoryFilter( + TeamCollectionApi.TheOneInstance?.CurrentBookFolderName + ); + return events.Where(e => e.BookId == bookId).ToList(); + } + + private void HandleHistoryCache(ApiRequest request) + { + var collectionId = request.RequiredParam("collectionId"); + var currentBookOnly = request.GetParamOrNull("currentBookOnly") == "true"; + var events = LoadHistoryCache(HistoryCachePath(collectionId)).Events; + if (currentBookOnly) + events = FilterToCurrentBook(events); + request.ReplyWithJson(JsonConvert.SerializeObject(events)); + } + + // ------------------------------------------------------------------ + // Collections (chooser) + // ------------------------------------------------------------------ + + /// Maps one my_collections() row (snake_case RPC shape, incl. its "my_role" alias) + /// to the camelCase ICloudCollectionSummary shape sharingApi.ts expects. + internal static object ToCollectionSummary(JObject row) => + new + { + collectionId = (string)row["id"], + name = (string)row["name"], + role = (string)row["my_role"], + }; + + private void HandleMyCollections(ApiRequest request) + { + var collections = CurrentClient() + .MyCollections() + .OfType() + .Select(ToCollectionSummary) + .ToList(); + request.ReplyWithJson(collections); + } + + /// The signed-in cloud account's email, or null when signed out. Used by + /// CollectionChooserApi's join-card dedup (dogfood bug #11, John's ruling 13 Jul 2026): + /// a local copy of a cloud collection only suppresses that collection's join card when + /// the copy was most recently used by THIS account. + internal static string SignedInEmailForJoinCards() => CurrentAuth().CurrentEmail; + + /// Used by CollectionChooserApi.HandleGetJoinCards to compute join cards for the + /// collection chooser (dogfood batch 1, item 6): the cloud collections the signed-in user + /// is approved for, in the minimal Id/Name shape CloudJoinFlow already defines. Returns an + /// empty list WITHOUT any network call when not signed in (IsSignedIn is a local check, + /// not an RPC) -- required so the chooser degrades silently for signed-out/folder-only + /// users instead of making a doomed cloud call on every chooser render. + public static IReadOnlyList GetMyCollectionsForJoinCards() + { + if (!CurrentAuth().IsSignedIn) + return Array.Empty(); + try + { + return CurrentClient() + .MyCollections() + .OfType() + .Select(o => new CloudCollectionSummary + { + Id = (string)o["id"], + Name = (string)o["name"], + }) + .ToList(); + } + catch (Exception e) + { + // Signed in but the server is unreachable (offline, outage, ...). Join cards are + // decorative: the chooser must render its normal card list regardless, so this is + // "no join cards right now", never an error surfaced to the user. + Logger.WriteError("SharingApi: could not fetch collections for join cards", e); + return Array.Empty(); + } + } + + private class PullDownBody + { + public string collectionId; + } + + /// Joins (pulls down) a cloud collection the signed-in user is approved for. + /// Looks up its name via the same my_collections list the chooser already showed (the UI + /// only sends the id), then delegates to CloudJoinFlow -- the six-scenario matching logic + /// (task 05) already handles "already joined"/name-collision cases by throwing + /// CloudJoinConflictException, surfaced here as a plain failure message pending the + /// dedicated resolution dialog noted in task 07's final report. + /// + /// Replies with the local .bloomCollection file path (task 10: "pull-down auto-open") so + /// the caller (JoinCloudCollectionDialog) can invoke the same "workspace/openCollection" + /// action the chooser's own cards use, instead of leaving the user to hunt for the newly + /// pulled-down collection themselves. It must be the settings FILE, not the folder -- + /// that path flows through Program.SwitchToCollection, which expects what the chooser's + /// MRU cards pass. + private void HandlePullDown(ApiRequest request) + { + var body = request.RequiredPostObject(); + var client = CurrentClient(); + var summary = client + .MyCollections() + .OfType() + .FirstOrDefault(c => (string)c["id"] == body.collectionId); + if (summary == null) + { + request.Failed( + "You are not approved for this Team Collection (or it no longer exists)." + ); + return; + } + + try + { + var joinFlow = new CloudJoinFlow(client); + // May be null if no project is currently loaded (pull-down from the pre-startup + // collection chooser) -- CloudJoinFlow.JoinCollection only stores this in the new + // CloudTeamCollection's base-class field for the narrow download-everything + // operation it performs here (never dereferenced along that path today), matching + // how it's also never exercised by any existing unit test with a real manager + // either. See the final report for a recommended live smoke test of this specific + // path. + var manager = TeamCollectionApi.TheOneInstance?.TcManager; + var cloudTc = joinFlow.JoinCollection( + body.collectionId, + (string)summary["name"], + manager + ); + + // Analytics audit (task 10): the folder-TC join path tracks "TeamCollectionJoin" + // from TeamCollectionApi.HandleJoinTeamCollection; this is that event's cloud + // counterpart -- pull-down had no analytics at all before this. + Analytics.Track( + "TeamCollectionJoin", + new Dictionary() + { + { "CollectionId", body.collectionId }, + { "CollectionName", (string)summary["name"] }, + { "Backend", cloudTc.GetBackendType() }, + { "User", CurrentAuth().GetLoginState(CloudEnvironment.Current).Email }, + { "JoinType", "pullDown" }, + } + ); + + request.ReplyWithJson( + new + { + collectionPath = CollectionSettings.FindSettingsFileInFolder( + cloudTc.LocalCollectionFolder + ), + } + ); + } + catch (CloudJoinConflictException e) + { + Logger.WriteError("SharingApi.HandlePullDown: join conflict", e); + request.Failed(e.Message); + } + catch (Exception e) + { + Logger.WriteError("SharingApi.HandlePullDown: failed", e); + NonFatalProblem.ReportSentryOnly(e, "SharingApi.HandlePullDown"); + request.Failed(e.Message); + } + } + } +} diff --git a/src/BloomTests/ExperimentalFeaturesTests.cs b/src/BloomTests/ExperimentalFeaturesTests.cs index 2303e32ad47c..d76c86cb4e67 100644 --- a/src/BloomTests/ExperimentalFeaturesTests.cs +++ b/src/BloomTests/ExperimentalFeaturesTests.cs @@ -68,5 +68,62 @@ public void SetValueWorksProperly() ); Assert.AreEqual("", ExperimentalFeatures.TokensOfEnabledFeatures); } + + /// + /// Regression (Devin review, 14 Jul 2026): the tokens "team-collections" and + /// "cloud-team-collections" share a substring, so a substring-based check/replace made + /// enabling the cloud feature read as the folder feature being enabled, and disabling the + /// folder feature corrupted the cloud token ("cloud-team-collections" -> "cloud-"). The + /// check/removal must be by EXACT token. + /// + [Test] + public void CloudAndFolderTeamCollectionTokensDoNotCollide() + { + // Start clean regardless of test order. + ExperimentalFeatures.SetValue(ExperimentalFeatures.kTeamCollections, false); + ExperimentalFeatures.SetValue(ExperimentalFeatures.kCloudTeamCollections, false); + Assert.IsFalse( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections), + "sanity: folder TC should start disabled" + ); + + // Enabling ONLY cloud must not make the folder feature read as enabled. + ExperimentalFeatures.SetValue(ExperimentalFeatures.kCloudTeamCollections, true); + Assert.IsTrue( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kCloudTeamCollections) + ); + Assert.IsFalse( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections), + "enabling cloud TC must NOT silently enable folder TC" + ); + + // Disabling the folder feature must leave the cloud token intact. + ExperimentalFeatures.SetValue(ExperimentalFeatures.kTeamCollections, false); + Assert.IsTrue( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kCloudTeamCollections), + "disabling folder TC must NOT corrupt/disable the cloud token" + ); + Assert.AreEqual( + ExperimentalFeatures.kCloudTeamCollections, + ExperimentalFeatures.TokensOfEnabledFeatures + ); + + // With both enabled, disabling folder removes exactly it and leaves cloud enabled. + ExperimentalFeatures.SetValue(ExperimentalFeatures.kTeamCollections, true); + Assert.IsTrue( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections) + ); + ExperimentalFeatures.SetValue(ExperimentalFeatures.kTeamCollections, false); + Assert.IsFalse( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections) + ); + Assert.IsTrue( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kCloudTeamCollections) + ); + + // cleanup + ExperimentalFeatures.SetValue(ExperimentalFeatures.kCloudTeamCollections, false); + Assert.AreEqual("", ExperimentalFeatures.TokensOfEnabledFeatures); + } } } diff --git a/src/BloomTests/ProgramTests.cs b/src/BloomTests/ProgramTests.cs index 5a721d9ec66e..29e73e0cf947 100644 --- a/src/BloomTests/ProgramTests.cs +++ b/src/BloomTests/ProgramTests.cs @@ -41,6 +41,53 @@ out var automationErrorMessage Assert.That(Program.StartupRequestedPortSummary, Is.EqualTo("automation=true")); } + [Test] + public void ParseStartupPortArguments_AutomationWithoutAttended_IsUnattended() + { + Program.ParseStartupPortArguments(new[] { "--automation" }, out var errorMessage); + + Assert.That(errorMessage, Is.Null); + Assert.That( + Program.UnattendedAutomation, + Is.True, + "E2E/CI launches pass plain --automation and must keep the no-human policies" + ); + } + + [Test] + public void ParseStartupPortArguments_AttendedAutomation_KeepsHandshakeButIsNotUnattended() + { + var remainingArgs = Program.ParseStartupPortArguments( + new[] { "--automation", "--attended" }, + out var errorMessage + ); + + Assert.That(errorMessage, Is.Null); + Assert.That( + Program.StartupAutomation, + Is.True, + "--attended must not disable the ready-handshake/single-instance behaviors" + ); + Assert.That( + Program.UnattendedAutomation, + Is.False, + "a human is present (pnpm go): dialogs must NOT auto-close (bug #16)" + ); + Assert.That(remainingArgs, Is.Empty); + } + + [Test] + public void ParseStartupPortArguments_AttendedIsResetBetweenParses() + { + Program.ParseStartupPortArguments(new[] { "--automation", "--attended" }, out _); + // Sanity check the flag really was set before we test the reset. + Assert.That(Program.UnattendedAutomation, Is.False); + + Program.ParseStartupPortArguments(new[] { "--automation" }, out _); + + Assert.That(Program.UnattendedAutomation, Is.True); + } + [Test] public void ParseStartupPortArguments_VitePortAloneDoesNotEnableAutomation() { diff --git a/src/BloomTests/TeamCollection/Cloud/BookVersionManifestTests.cs b/src/BloomTests/TeamCollection/Cloud/BookVersionManifestTests.cs new file mode 100644 index 000000000000..bfd62bb01676 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/BookVersionManifestTests.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using SIL.IO; + +namespace BloomTests.TeamCollection.Cloud +{ + [TestFixture] + public class BookVersionManifestTests + { + private TemporaryFolder _bookFolder; + private string _bookFolderPath; + + [SetUp] + public void SetUp() + { + _bookFolder = new TemporaryFolder("BookVersionManifestTests"); + _bookFolderPath = _bookFolder.FolderPath; + } + + [TearDown] + public void TearDown() + { + _bookFolder.Dispose(); + } + + private string BookName => Path.GetFileName(_bookFolderPath); + + private void WriteMinimalBook() + { + // A folder needs a matching .htm for BookFileFilter to find "the one html path"; + // content doesn't matter for these tests beyond that. + RobustFile.WriteAllText( + Path.Combine(_bookFolderPath, BookName + ".htm"), + "" + ); + } + + // ------------------------------------------------------------------ + // NFC normalization + // ------------------------------------------------------------------ + + [Test] + public void NormalizePath_ConvertsBackslashesToForwardSlashes() + { + Assert.That( + BookVersionManifest.NormalizePath(@"audio\sound.mp3"), + Is.EqualTo("audio/sound.mp3") + ); + } + + [Test] + public void NormalizePath_NormalizesToNfc() + { + // "é" as NFD (e + combining acute accent, 2 chars) must normalize to the same string + // as "é" as NFC (1 precomposed char) — otherwise two Bloom instances on different OSes + // (macOS tends to produce NFD from its filesystem) would disagree about a path. + var nfd = "é.png"; // e + combining acute + var nfc = "é.png"; // é (precomposed) + + Assert.That( + nfd, + Is.Not.EqualTo(nfc), + "sanity check: the two forms must differ as raw strings" + ); + Assert.That( + BookVersionManifest.NormalizePath(nfd), + Is.EqualTo(BookVersionManifest.NormalizePath(nfc)) + ); + Assert.That(BookVersionManifest.NormalizePath(nfc), Is.EqualTo(nfc)); + } + + // ------------------------------------------------------------------ + // JSON round-trip + // ------------------------------------------------------------------ + + [Test] + public void FromJson_ThenToJson_RoundTrips() + { + var json = JArray.Parse( + @"[ + { ""path"": ""book.htm"", ""sha256"": ""abc123"", ""size"": 42, ""s3VersionId"": ""v1"" }, + { ""path"": ""audio/one.mp3"", ""sha256"": ""def456"", ""size"": 7 } + ]" + ); + + var manifest = BookVersionManifest.FromJson(json); + + Assert.That(manifest.Entries.Count, Is.EqualTo(2), "sanity check: both entries parsed"); + Assert.That(manifest.Entries["book.htm"].Sha256, Is.EqualTo("abc123")); + Assert.That(manifest.Entries["book.htm"].Size, Is.EqualTo(42)); + Assert.That(manifest.Entries["book.htm"].S3VersionId, Is.EqualTo("v1")); + Assert.That( + manifest.Entries["audio/one.mp3"].S3VersionId, + Is.Null, + "no s3VersionId on the wire => null, not a proposed manifest yet" + ); + + var roundTripped = manifest.ToJson(); + Assert.That(roundTripped.Count, Is.EqualTo(2)); + var bookEntry = roundTripped.Single(f => (string)f["path"] == "book.htm"); + Assert.That((string)bookEntry["sha256"], Is.EqualTo("abc123")); + Assert.That((long)bookEntry["size"], Is.EqualTo(42)); + Assert.That((string)bookEntry["s3VersionId"], Is.EqualTo("v1")); + + var audioEntry = roundTripped.Single(f => (string)f["path"] == "audio/one.mp3"); + Assert.That( + audioEntry["s3VersionId"], + Is.Null, + "an entry with no version id must not gain one on re-serialization" + ); + } + + [Test] + public void FromJson_NullArray_ProducesEmptyManifest() + { + var manifest = BookVersionManifest.FromJson(null); + Assert.That(manifest.Entries, Is.Empty); + } + + // ------------------------------------------------------------------ + // FromLocalFolder / junk exclusion + // ------------------------------------------------------------------ + + [Test] + public void FromLocalFolder_IncludesWhitelistedFiles_ExcludesJunk() + { + WriteMinimalBook(); + RobustFile.WriteAllText( + Path.Combine(_bookFolderPath, "thumbnail.png"), + "fake-png-bytes" + ); + RobustFile.WriteAllText(Path.Combine(_bookFolderPath, "styles.css"), "body{}"); + // Not in BookFileFilter's whitelist (no recognized extension, not audio/video/template) — + // this is exactly the kind of "junk the user might have happened to put in the folder" + // BookFileFilter's own doc comment says it's designed to keep out. + RobustFile.WriteAllText(Path.Combine(_bookFolderPath, "notes.junk"), "scratch notes"); + // placeHolder files are explicitly excluded by BookFileFilter itself. + RobustFile.WriteAllText( + Path.Combine(_bookFolderPath, "placeHolder.png"), + "placeholder" + ); + + // Sanity check: all four files really are on disk before we ask the manifest to filter them. + Assert.That( + Directory.GetFiles(_bookFolderPath), + Has.Length.EqualTo( + 4 + 1 /* the .htm */ + ) + ); + + var manifest = BookVersionManifest.FromLocalFolder(_bookFolderPath); + + Assert.That(manifest.Entries.Keys, Has.Member(BookName + ".htm")); + Assert.That(manifest.Entries.Keys, Has.Member("thumbnail.png")); + Assert.That(manifest.Entries.Keys, Has.Member("styles.css")); + Assert.That(manifest.Entries.Keys, Has.None.EqualTo("notes.junk")); + Assert.That(manifest.Entries.Keys, Has.None.EqualTo("placeHolder.png")); + } + + [Test] + public void FromLocalFolder_ComputesRealSha256AndSize() + { + WriteMinimalBook(); + var contentPath = Path.Combine(_bookFolderPath, "styles.css"); + var content = "body { color: red; }"; + RobustFile.WriteAllText(contentPath, content); + + var manifest = BookVersionManifest.FromLocalFolder(_bookFolderPath); + + var (expectedSha256, expectedSize) = BookVersionManifest.ComputeFileHash(contentPath); + Assert.That( + expectedSize, + Is.EqualTo(content.Length), + "sanity check: ASCII content, byte length == char length" + ); + + var entry = manifest.Entries["styles.css"]; + Assert.That(entry.Sha256, Is.EqualTo(expectedSha256)); + Assert.That(entry.Size, Is.EqualTo(expectedSize)); + Assert.That( + entry.S3VersionId, + Is.Null, + "a local-folder manifest has no version ids yet" + ); + } + + [Test] + public void ComputeFileHash_DifferentContent_DifferentHash() + { + var pathA = Path.Combine(_bookFolderPath, "a.txt"); + var pathB = Path.Combine(_bookFolderPath, "b.txt"); + RobustFile.WriteAllText(pathA, "hello"); + RobustFile.WriteAllText(pathB, "world"); + + var (hashA, _) = BookVersionManifest.ComputeFileHash(pathA); + var (hashB, _) = BookVersionManifest.ComputeFileHash(pathB); + + Assert.That(hashA, Is.Not.EqualTo(hashB)); + Assert.That( + hashA, + Does.Match("^[0-9a-f]+$"), + "must be lower-case hex, per the server-side convention" + ); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudAccountSwitchTakeoverTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudAccountSwitchTakeoverTests.cs new file mode 100644 index 000000000000..cbc08427b245 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudAccountSwitchTakeoverTests.cs @@ -0,0 +1,449 @@ +using System; +using System.Linq; +using System.Net; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// Tests for the checkout-takeover half of batch item 9 (account-switch behavior, + /// Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md): once a signed-in account + /// is confirmed as a Team Collection member (see CloudTeamCollectionMemberTests for the + /// refusal-vs-member CheckConnection matrix), a book left checked out HERE (this machine) by + /// a DIFFERENT member must be editable, and the server lock must atomically move to the + /// current user the moment that matters (check-in). Uses the same FakeRestExecutor/ + /// StubCloudAuthProvider pattern as CloudTeamCollectionMemberTests/CloudSyncAtStartupTests. + /// + [TestFixture] + public class CloudAccountSwitchTakeoverTests + { + private const string kCollectionId = "22222222-2222-2222-2222-222222222222"; + private const string kOtherMachine = "SomeoneElsesMachine"; + private const string kCurrentUserEmail = "bob@dev.local"; + + // TeamCollectionManager.CurrentMachine is Environment.MachineName unless overridden via + // impersonate.txt (read by a real TeamCollectionManager's constructor, which these tests + // never construct) -- so "this machine" for test purposes must be whatever that static + // property actually resolves to right now, not an arbitrary literal. + private static string ThisMachine => TeamCollectionManager.CurrentMachine; + + private CloudTestHarness _harness; + private TemporaryFolder _collectionFolder; + private Mock _mockTcManager; + private CloudTeamCollection _collection; + private FakeRestExecutor _executor; + private CloudAuth _auth; + + [SetUp] + public void Setup() + { + // Signed in as the machine-local current user (kCurrentUserEmail); the account-switch + // scenarios then re-sign-in as other users via _auth within individual tests. + _harness = CloudTestHarness.Create( + "CloudAccountSwitchTakeoverTests", + kCollectionId, + currentUser: kCurrentUserEmail + ); + _collectionFolder = _harness.CollectionFolder; + _mockTcManager = _harness.MockTcManager; + _collection = _harness.Collection; + _executor = _harness.Executor; + _auth = _harness.Auth; + } + + [TearDown] + public void TearDown() + { + _harness.Dispose(); + } + + /// The seat id of THIS test's own collection copy — what the production code + /// computes for _collectionFolder. Locks scripted with this seat are "checked out in + /// this copy"; any other value (or null) simulates another copy / a pre-seat lock. + private string ThisSeat => CloudTeamCollection.ComputeSeatId(_collectionFolder.FolderPath); + + private void ScriptCollectionState( + string bookId, + string bookName, + string lockedBy, + string lockedByMachine, + string lockedSeat = null + ) + { + _executor.Handler = req => + { + Assert.That(req.Resource, Is.EqualTo("rest/v1/rpc/get_collection_state")); + var body = new JObject + { + ["books"] = new JArray( + new JObject + { + ["id"] = bookId, + ["instance_id"] = "instance-" + bookId, + ["name"] = bookName, + ["current_version_id"] = "v1", + ["current_version_seq"] = 1, + ["current_checksum"] = "checksum-" + bookId, + ["locked_by"] = lockedBy, + ["locked_by_machine"] = lockedByMachine, + ["locked_seat"] = lockedSeat, + ["locked_at"] = + lockedBy == null ? null : (JToken)DateTime.UtcNow.ToString("o"), + ["deleted_at"] = null, + } + ), + ["groups"] = new JArray(), + ["max_event_id"] = 1, + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + }; + } + + // ------------------------------------------------------------------ + // IsEditableHere / NeedCheckoutToEdit + // ------------------------------------------------------------------ + + [Test] + public void NeedCheckoutToEdit_LockedByOtherAccount_SameMachineAndSeat_ReturnsFalse_IsEditable() + { + ScriptCollectionState("book-1", "My Book", "some-other-user-id", ThisMachine, ThisSeat); + + var bookFolderPath = _collectionFolder.Combine("My Book"); + Assert.That( + _collection.NeedCheckoutToEdit(bookFolderPath), + Is.False, + "a book locked to a different account in THIS copy on THIS machine must be editable without an explicit checkout" + ); + } + + [Test] + public void NeedCheckoutToEdit_LockedByOtherAccount_DifferentMachine_ReturnsTrue() + { + ScriptCollectionState( + "book-1", + "My Book", + "some-other-user-id", + kOtherMachine, + ThisSeat + ); + + var bookFolderPath = _collectionFolder.Combine("My Book"); + Assert.That( + _collection.NeedCheckoutToEdit(bookFolderPath), + Is.True, + "a lock held on a DIFFERENT machine must remain a genuine conflict, not editable" + ); + } + + [Test] + public void NeedCheckoutToEdit_LockedByOtherAccount_SameMachineDifferentSeat_ReturnsTrue() + { + // Bug #0 (e2e-4's scenario): same machine, but the lock belongs to a DIFFERENT local + // copy of the collection. Editing here risks conflicting changes — not editable. + ScriptCollectionState( + "book-1", + "My Book", + "some-other-user-id", + ThisMachine, + "someone-elses-seat" + ); + + var bookFolderPath = _collectionFolder.Combine("My Book"); + Assert.That( + _collection.NeedCheckoutToEdit(bookFolderPath), + Is.True, + "a lock held in a DIFFERENT local copy (seat) must remain a genuine conflict even on the same machine" + ); + } + + [Test] + public void NeedCheckoutToEdit_LockedByOtherAccount_SameMachineUnknownSeat_ReturnsTrue() + { + // Fail-safe: a lock with no recorded seat (pre-seat checkout) is never treated as + // takeover-eligible for a DIFFERENT account, matching the server-side gate. + ScriptCollectionState("book-1", "My Book", "some-other-user-id", ThisMachine, null); + + var bookFolderPath = _collectionFolder.Combine("My Book"); + Assert.That(_collection.NeedCheckoutToEdit(bookFolderPath), Is.True); + } + + [Test] + public void NeedCheckoutToEdit_OwnLock_SameMachineUnknownSeat_ReturnsFalse_Grandfathered() + { + // The CURRENT USER's own pre-seat lock keeps working (otherwise the seat migration + // would brick every checkout taken before it). + ScriptCollectionState("book-1", "My Book", kCurrentUserEmail, ThisMachine, null); + + var bookFolderPath = _collectionFolder.Combine("My Book"); + Assert.That(_collection.NeedCheckoutToEdit(bookFolderPath), Is.False); + } + + [Test] + public void NeedCheckoutToEdit_OwnLock_SameMachineDifferentSeat_ReturnsTrue() + { + // John's ruling covers the same user's OTHER copy too: the book is being worked on + // in the copy that holds the lock, not this one. + ScriptCollectionState( + "book-1", + "My Book", + kCurrentUserEmail, + ThisMachine, + "my-other-copys-seat" + ); + + var bookFolderPath = _collectionFolder.Combine("My Book"); + Assert.That(_collection.NeedCheckoutToEdit(bookFolderPath), Is.True); + } + + [Test] + public void NeedCheckoutToEdit_Unlocked_ReturnsTrue_StillNeedsCheckout() + { + ScriptCollectionState("book-1", "My Book", null, null); + + var bookFolderPath = _collectionFolder.Combine("My Book"); + Assert.That(_collection.NeedCheckoutToEdit(bookFolderPath), Is.True); + } + + // ------------------------------------------------------------------ + // OkToCheckIn: same-machine takeover is allowed; cross-machine is not + // ------------------------------------------------------------------ + + /// Creates the local folder for a scripted repo book, carrying the meta.json + /// instance id ScriptCollectionState gives it ("instance-" + bookId). Required since the + /// bug-#15 identity-first resolution: a local folder binds to its repo row by its + /// meta.json id, so a bare folder with no meta.json reads as an unrelated local-only + /// book, not as the repo book of the same name. + private void CreateLocalBookFolder(string bookName, string bookId) + { + var folderPath = _collectionFolder.Combine(bookName); + System.IO.Directory.CreateDirectory(folderPath); + System.IO.File.WriteAllText( + System.IO.Path.Combine(folderPath, "meta.json"), + $"{{\"bookInstanceId\":\"instance-{bookId}\"}}" + ); + } + + [Test] + public void OkToCheckIn_LockedByOtherAccount_SameMachine_ReturnsTrue() + { + ScriptCollectionState("book-1", "My Book", "some-other-user-id", ThisMachine, ThisSeat); + // OkToCheckIn compares repo checksum to LOCAL status checksum; make them match so + // that check doesn't independently fail this test. (Local status's own lockedBy is + // irrelevant to OkToCheckIn -- it only reads its checksum -- so it's left at + // whatever GetStatus/WithChecksum produces, matching the DifferentMachine test + // below.) WriteLocalStatus needs the book's own folder to already exist. + CreateLocalBookFolder("My Book", "book-1"); + var localStatus = _collection.GetStatus("My Book").WithChecksum("checksum-book-1"); + _collection.WriteLocalStatus("My Book", localStatus); + + Assert.That(_collection.OkToCheckIn("My Book"), Is.True); + } + + [Test] + public void OkToCheckIn_LockedByOtherAccount_DifferentMachine_ReturnsFalse() + { + ScriptCollectionState( + "book-1", + "My Book", + "some-other-user-id", + kOtherMachine, + ThisSeat + ); + CreateLocalBookFolder("My Book", "book-1"); + var localStatus = _collection.GetStatus("My Book").WithChecksum("checksum-book-1"); + _collection.WriteLocalStatus("My Book", localStatus); + + Assert.That(_collection.OkToCheckIn("My Book"), Is.False); + } + + [Test] + public void OkToCheckIn_LockedByOtherAccount_SameMachineDifferentSeat_ReturnsFalse() + { + // Bug #0: the takeover path must not unblock a check-in when the lock belongs to a + // different local copy of the collection on this same machine. + ScriptCollectionState( + "book-1", + "My Book", + "some-other-user-id", + ThisMachine, + "someone-elses-seat" + ); + CreateLocalBookFolder("My Book", "book-1"); + var localStatus = _collection.GetStatus("My Book").WithChecksum("checksum-book-1"); + _collection.WriteLocalStatus("My Book", localStatus); + + Assert.That(_collection.OkToCheckIn("My Book"), Is.False); + } + + // ------------------------------------------------------------------ + // TryTakeOverLock / the new RPC wiring + // ------------------------------------------------------------------ + + [Test] + public void TryTakeOverLock_ServerAccepts_UpdatesStatusToCurrentUser() + { + ScriptCollectionState("book-1", "My Book", "some-other-user-id", ThisMachine, ThisSeat); + // Force hydration/index so TryGetBookId can resolve "My Book" -> "book-1". + _collection.IsBookPresentInRepo("My Book"); + + _executor.Handler = req => + { + Assert.That(req.Resource, Is.EqualTo("rest/v1/rpc/checkout_book_takeover")); + var body = new JObject + { + ["success"] = true, + ["locked_by"] = kCurrentUserEmail, + ["locked_by_machine"] = ThisMachine, + ["locked_seat"] = ThisSeat, + ["locked_at"] = DateTime.UtcNow.ToString("o"), + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + }; + + var result = _collection.TryTakeOverLock("My Book"); + + Assert.That(result, Is.True); + Assert.That(_collection.GetStatus("My Book").lockedBy, Is.EqualTo(kCurrentUserEmail)); + } + + [Test] + public void TryTakeOverLock_ServerRefuses_ReturnsFalse_StatusUnchanged() + { + ScriptCollectionState("book-1", "My Book", "some-other-user-id", kOtherMachine); + _collection.IsBookPresentInRepo("My Book"); + + _executor.Handler = req => + { + Assert.That(req.Resource, Is.EqualTo("rest/v1/rpc/checkout_book_takeover")); + var body = new JObject + { + ["success"] = false, + ["locked_by"] = "some-other-user-id", + ["locked_by_machine"] = kOtherMachine, + ["locked_at"] = DateTime.UtcNow.ToString("o"), + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + }; + + var result = _collection.TryTakeOverLock("My Book"); + + Assert.That(result, Is.False); + Assert.That( + _collection.GetStatus("My Book").lockedBy, + Is.EqualTo("some-other-user-id") + ); + } + + // ------------------------------------------------------------------ + // AttemptLock: an explicit "check out" click on a takeover-eligible book performs the + // handover instead of silently failing. + // ------------------------------------------------------------------ + + [Test] + public void AttemptLock_LockedByOtherAccount_SameMachine_TakesOverAndSucceeds() + { + ScriptCollectionState("book-1", "My Book", "some-other-user-id", ThisMachine, ThisSeat); + _collection.IsBookPresentInRepo("My Book"); + + _executor.Handler = req => + { + if (req.Resource == "rest/v1/rpc/checkout_book_takeover") + { + var body = new JObject + { + ["success"] = true, + ["locked_by"] = kCurrentUserEmail, + ["locked_by_machine"] = ThisMachine, + ["locked_seat"] = ThisSeat, + ["locked_at"] = DateTime.UtcNow.ToString("o"), + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + } + Assert.Fail($"Unexpected request: {req.Resource}"); + return null; + }; + + var success = _collection.AttemptLock("My Book"); + + Assert.That(success, Is.True); + Assert.That(_collection.GetStatus("My Book").lockedBy, Is.EqualTo(kCurrentUserEmail)); + } + + [Test] + public void AttemptLock_LockedByOtherAccount_SameMachineDifferentSeat_DoesNotAttemptTakeover() + { + // Bug #0 (e2e-4's exact scenario): an explicit checkout attempt on a book locked in + // a DIFFERENT local copy on this same machine must not fire the takeover RPC at all + // (previously it did — and the server, gating only on machine, silently reassigned + // the lock even as AttemptLock reported false). + ScriptCollectionState( + "book-1", + "My Book", + "some-other-user-id", + ThisMachine, + "someone-elses-seat" + ); + _collection.IsBookPresentInRepo("My Book"); + + _executor.Handler = req => + { + Assert.Fail( + $"Should not have called any RPC for a different-seat lock; got {req.Resource}" + ); + return null; + }; + + var success = _collection.AttemptLock("My Book"); + + Assert.That(success, Is.False); + } + + [Test] + public void AttemptLock_LockedByOtherAccount_DifferentMachine_DoesNotAttemptTakeover() + { + ScriptCollectionState("book-1", "My Book", "some-other-user-id", kOtherMachine); + _collection.IsBookPresentInRepo("My Book"); + + _executor.Handler = req => + { + Assert.Fail( + $"Should not have called any RPC for a cross-machine lock; got {req.Resource}" + ); + return null; + }; + + var success = _collection.AttemptLock("My Book"); + + Assert.That(success, Is.False); + } + + [Test] + public void AttemptLock_AlreadyLockedByMe_DoesNotAttemptTakeover() + { + ScriptCollectionState("book-1", "My Book", null, null); + _collection.IsBookPresentInRepo("My Book"); + + _executor.Handler = req => + { + Assert.That(req.Resource, Is.EqualTo("rest/v1/rpc/checkout_book")); + var body = new JObject + { + ["success"] = true, + ["locked_by"] = kCurrentUserEmail, + ["locked_by_machine"] = ThisMachine, + ["locked_at"] = DateTime.UtcNow.ToString("o"), + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + }; + + var success = _collection.AttemptLock("My Book"); + + Assert.That(success, Is.True); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudAuthTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudAuthTests.cs new file mode 100644 index 000000000000..34de4520fc67 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudAuthTests.cs @@ -0,0 +1,556 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Bloom.TeamCollection.Cloud; +using NUnit.Framework; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// A scriptable for exercising 's + /// session core without any network access. Each call records what it was asked to do so + /// tests can assert on call counts, and either returns the next queued session or throws the + /// next queued exception. + /// + internal class FakeCloudAuthProvider : ICloudAuthProvider + { + public int SignInCallCount; + public int RefreshCallCount; + public int AcceptExternalSessionCallCount; + public List RefreshTokensSeen = new List(); + + // When set, SignIn returns this session (ignoring the email/password given) unless + // NextSignInException is also set, in which case the exception wins. + public Func SignInHandler; + public Func RefreshHandler; + public Func AcceptExternalSessionHandler; + + public CloudSession SignIn(string email, string password) + { + SignInCallCount++; + return SignInHandler(email, password); + } + + public CloudSession Refresh(string refreshToken) + { + RefreshCallCount++; + RefreshTokensSeen.Add(refreshToken); + return RefreshHandler(refreshToken); + } + + public CloudSession AcceptExternalSession(string idToken, string refreshToken) + { + AcceptExternalSessionCallCount++; + return AcceptExternalSessionHandler(idToken, refreshToken); + } + } + + /// Test-only token store that lets a test seed a "previously stored" session. + internal class FakeCloudTokenStore : ICloudTokenStore + { + public CloudSession Stored; + public int ClearCallCount; + + public CloudSession Load() => Stored; + + public void Save(CloudSession session) => Stored = session; + + public void Clear() + { + ClearCallCount++; + Stored = null; + } + } + + [TestFixture] + public class CloudAuthTests + { + private static CloudSession MakeSession( + string email, + string userId = "user-1", + string accessToken = "access-1", + string refreshToken = "refresh-1", + double ttlSeconds = 3600 + ) => + new CloudSession + { + AccessToken = accessToken, + RefreshToken = refreshToken, + Email = email, + UserId = userId, + ExpiresAtUtc = DateTime.UtcNow.AddSeconds(ttlSeconds), + }; + + [Test] + public void SignIn_Success_SetsIdentityAndAccessToken() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => MakeSession(email), + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + + // Sanity check: nothing signed in yet. + Assert.That( + auth.IsSignedIn, + Is.False, + "should not be signed in before SignIn is called" + ); + + auth.SignIn("alice@dev.local", "BloomDev123!"); + + Assert.That(auth.IsSignedIn, Is.True); + Assert.That(auth.CurrentEmail, Is.EqualTo("alice@dev.local")); + Assert.That(auth.GetAccessTokenOrNull(), Is.EqualTo("access-1")); + Assert.That(provider.SignInCallCount, Is.EqualTo(1)); + } + + [Test] + public void SignIn_Failure_LeavesAuthSignedOut() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => + throw new CloudAuthException("bad credentials"), + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + + Assert.Throws(() => auth.SignIn("bob@dev.local", "wrong")); + + Assert.That(auth.IsSignedIn, Is.False); + Assert.That(auth.GetAccessTokenOrNull(), Is.Null); + } + + [Test] + public void TryRefreshOn401_WithValidRefreshToken_ReplacesSessionAndReturnsTrue() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => + MakeSession(email, accessToken: "access-1", refreshToken: "refresh-1"), + RefreshHandler = refreshToken => + MakeSession( + "alice@dev.local", + accessToken: "access-2", + refreshToken: "refresh-2" + ), + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + auth.SignIn("alice@dev.local", "BloomDev123!"); + + // Sanity check on the pre-refresh state before we exercise the 401 path. + Assert.That(auth.GetAccessTokenOrNull(), Is.EqualTo("access-1")); + + var refreshed = auth.TryRefreshOn401(); + + Assert.That(refreshed, Is.True); + Assert.That(auth.GetAccessTokenOrNull(), Is.EqualTo("access-2")); + Assert.That(provider.RefreshCallCount, Is.EqualTo(1)); + Assert.That(provider.RefreshTokensSeen, Is.EqualTo(new[] { "refresh-1" })); + } + + [Test] + public void TryRefreshOn401_WhenRefreshFails_SignsOutAndReturnsFalse() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => MakeSession(email), + RefreshHandler = refreshToken => + throw new CloudAuthException("refresh token expired"), + }; + var tokenStore = new FakeCloudTokenStore(); + var auth = new CloudAuth(provider, tokenStore); + auth.SignIn("alice@dev.local", "BloomDev123!"); + Assert.That( + auth.IsSignedIn, + Is.True, + "must be signed in before we can test refresh failure" + ); + + var refreshed = auth.TryRefreshOn401(); + + // This is the "refresh failure mid-operation aborts cleanly and surfaces 'please + // sign in'" acceptance criterion: the caller (e.g. CloudCollectionClient) sees a + // clean false rather than an exception, and the session is fully cleared so the + // next access-token read is null (the caller's cue to show "please sign in"). + Assert.That(refreshed, Is.False); + Assert.That(auth.IsSignedIn, Is.False); + Assert.That(auth.GetAccessTokenOrNull(), Is.Null); + Assert.That(tokenStore.ClearCallCount, Is.EqualTo(1)); + } + + [Test] + public void TryRefreshOn401_WhenNeverSignedIn_ReturnsFalseWithoutCallingProvider() + { + var provider = new FakeCloudAuthProvider(); + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + + var refreshed = auth.TryRefreshOn401(); + + Assert.That(refreshed, Is.False); + Assert.That(provider.RefreshCallCount, Is.EqualTo(0)); + } + + [Test] + public void SignIn_WithDifferentAccount_ReplacesIdentity() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => MakeSession(email), + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + auth.SignIn("alice@dev.local", "BloomDev123!"); + // Sanity check the starting identity before switching. + Assert.That(auth.CurrentEmail, Is.EqualTo("alice@dev.local")); + + auth.SignIn("bob@dev.local", "BloomDev123!"); + + Assert.That(auth.CurrentEmail, Is.EqualTo("bob@dev.local")); + } + + [Test] + public void InitializeAtStartup_WithEnvOverride_SignsInAsOverrideUserIgnoringStoredToken() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => MakeSession(email), + RefreshHandler = refreshToken => + throw new InvalidOperationException( + "the stored token must not be used when an env override is present" + ), + }; + var tokenStore = new FakeCloudTokenStore + { + // A stored session for a completely different user; the env override must win + // and this must never be consulted (Refresh throws above if it is). + Stored = MakeSession("stored-user@dev.local", refreshToken: "stored-refresh"), + }; + var auth = new CloudAuth(provider, tokenStore); + var env = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_USER" ? "override@dev.local" + : name == "BLOOM_CLOUDTC_PASSWORD" ? "pw" + : null + ); + + auth.InitializeAtStartup(env); + + Assert.That(auth.IsSignedIn, Is.True); + Assert.That(auth.CurrentEmail, Is.EqualTo("override@dev.local")); + Assert.That(provider.RefreshCallCount, Is.EqualTo(0)); + } + + [Test] + public void InitializeAtStartup_WithoutEnvOverride_RestoresFromStoredRefreshToken() + { + var provider = new FakeCloudAuthProvider + { + RefreshHandler = refreshToken => MakeSession("stored-user@dev.local"), + }; + var tokenStore = new FakeCloudTokenStore + { + Stored = MakeSession("stored-user@dev.local", refreshToken: "stored-refresh"), + }; + var auth = new CloudAuth(provider, tokenStore); + var env = new CloudEnvironment(name => null); // no overrides configured + + auth.InitializeAtStartup(env); + + Assert.That(auth.IsSignedIn, Is.True); + Assert.That(auth.CurrentEmail, Is.EqualTo("stored-user@dev.local")); + Assert.That(provider.RefreshTokensSeen, Is.EqualTo(new[] { "stored-refresh" })); + } + + [Test] + public void InitializeAtStartup_NoOverrideNoStoredSession_LeavesSignedOutWithoutThrowing() + { + var provider = new FakeCloudAuthProvider(); + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + var env = new CloudEnvironment(name => null); + + Assert.DoesNotThrow(() => auth.InitializeAtStartup(env)); + Assert.That(auth.IsSignedIn, Is.False); + } + + [Test] + public void RealDpapiTokenStore_SessionSurvivesSimulatedRestart() + { + // End-to-end proof (with the REAL DpapiCloudTokenStore, not the fake) that a Cloud TC + // login survives a Bloom restart: a session persisted by one CloudAuth is restored by a + // fresh CloudAuth reading the same on-disk store — exactly what CreateInitialized wires + // up in the app. The dev/fake provider stands in for Firebase, whose refresh follows the + // identical Load-then-Refresh path. + var tempFile = Path.Combine( + Path.GetTempPath(), + "BloomCloudAuthRestartTest-" + Guid.NewGuid().ToString("N") + ".dat" + ); + try + { + // First run: signing in persists the session (with its refresh token) to disk. + var firstRun = new CloudAuth( + new FakeCloudAuthProvider + { + SignInHandler = (email, password) => + MakeSession(email, refreshToken: "refresh-A"), + }, + new DpapiCloudTokenStore(tempFile) + ); + firstRun.SignIn("alice@dev.local", "BloomDev123!"); + Assert.That( + firstRun.IsSignedIn, + Is.True, + "sanity: the first run must be signed in before we simulate a restart" + ); + + // Second run (simulated restart): a brand-new CloudAuth over the SAME store file + // must restore the session via InitializeAtStartup's Load + refresh. + var secondRunProvider = new FakeCloudAuthProvider + { + RefreshHandler = refreshToken => + MakeSession("alice@dev.local", refreshToken: refreshToken), + }; + var secondRun = new CloudAuth( + secondRunProvider, + new DpapiCloudTokenStore(tempFile) + ); + Assert.That( + secondRun.IsSignedIn, + Is.False, + "sanity: a fresh CloudAuth is signed out until it restores from the store" + ); + + secondRun.InitializeAtStartup(new CloudEnvironment(name => null)); + + Assert.That( + secondRun.IsSignedIn, + Is.True, + "the session must survive the restart via the refresh token saved to disk" + ); + Assert.That(secondRun.CurrentEmail, Is.EqualTo("alice@dev.local")); + Assert.That( + secondRunProvider.RefreshTokensSeen, + Does.Contain("refresh-A"), + "the restart must refresh using the refresh token the first run wrote to disk" + ); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + + [Test] + public void SignOut_ClearsSessionAndTokenStore() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => MakeSession(email), + }; + var tokenStore = new FakeCloudTokenStore(); + var auth = new CloudAuth(provider, tokenStore); + auth.SignIn("alice@dev.local", "BloomDev123!"); + Assert.That(auth.IsSignedIn, Is.True, "must be signed in before testing sign-out"); + + auth.SignOut(); + + Assert.That(auth.IsSignedIn, Is.False); + Assert.That(auth.CurrentEmail, Is.Null); + Assert.That(tokenStore.ClearCallCount, Is.EqualTo(1)); + } + + [Test] + public void ProactiveRefreshTimer_FiresBeforeExpiry_ReplacesSessionAutomatically() + { + var provider = new FakeCloudAuthProvider + { + // A very short TTL so the ~80%-of-TTL proactive-refresh timer fires almost + // immediately, keeping this test fast without needing to fake the clock. + SignInHandler = (email, password) => + MakeSession( + email, + accessToken: "access-1", + refreshToken: "refresh-1", + ttlSeconds: 0.2 + ), + RefreshHandler = refreshToken => + MakeSession( + "alice@dev.local", + accessToken: "access-2", + refreshToken: "refresh-2", + ttlSeconds: 3600 + ), + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + auth.SignIn("alice@dev.local", "BloomDev123!"); + Assert.That(auth.GetAccessTokenOrNull(), Is.EqualTo("access-1")); + + // 80% of 200ms is 160ms; give it generous headroom without making the suite slow. + var deadline = DateTime.UtcNow.AddSeconds(3); + while (provider.RefreshCallCount == 0 && DateTime.UtcNow < deadline) + System.Threading.Thread.Sleep(25); + + Assert.That( + provider.RefreshCallCount, + Is.GreaterThanOrEqualTo(1), + "the proactive-refresh timer should have fired on its own" + ); + Assert.That(auth.GetAccessTokenOrNull(), Is.EqualTo("access-2")); + } + + /// + /// Live-verifies against a real local Supabase dev + /// stack (see server/dev/README.md) — the only thing the mocked tests above cannot + /// cover, since they substitute entirely. [Explicit] + /// because it requires `supabase start` to be running; run manually with + /// `dotnet test --filter FullyQualifiedName~LiveDevProvider`. Exercises exactly the + /// "two Bloom instances on one machine run as two different users" acceptance + /// criterion's precondition: two independent CloudAuth instances signing in as two + /// different dev users concurrently must each hold their own distinct, valid session. + /// + [Test] + [Explicit("Requires the local Supabase dev stack (`supabase start`) to be running.")] + public void LiveDevProvider_TwoUsersSignInConcurrently_HoldDistinctSessions() + { + var env = CloudEnvironment.FromEnvironment(); + var aliceAuth = new CloudAuth(new DevCloudAuthProvider(env)); + var bobAuth = new CloudAuth(new DevCloudAuthProvider(env)); + + aliceAuth.SignIn("alice@dev.local", "BloomDev123!"); + bobAuth.SignIn("bob@dev.local", "BloomDev123!"); + + Assert.That(aliceAuth.CurrentEmail, Is.EqualTo("alice@dev.local")); + Assert.That(bobAuth.CurrentEmail, Is.EqualTo("bob@dev.local")); + Assert.That(aliceAuth.CurrentUserId, Is.Not.EqualTo(bobAuth.CurrentUserId)); + Assert.That( + aliceAuth.GetAccessTokenOrNull(), + Is.Not.EqualTo(bobAuth.GetAccessTokenOrNull()) + ); + + // Both sessions must independently survive a refresh (the mechanism the >2h soak + // test in the task's acceptance criteria relies on), without interfering with each + // other's identity. + Assert.That(aliceAuth.TryRefreshOn401(), Is.True); + Assert.That(bobAuth.TryRefreshOn401(), Is.True); + Assert.That(aliceAuth.CurrentEmail, Is.EqualTo("alice@dev.local")); + Assert.That(bobAuth.CurrentEmail, Is.EqualTo("bob@dev.local")); + } + + [Test] + public void GetLoginState_ReportsAuthModeAndCurrentIdentity() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => MakeSession(email), + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + var env = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_AUTH_MODE" ? "dev" : null + ); + + var loggedOutState = auth.GetLoginState(env); + Assert.That(loggedOutState.AuthMode, Is.EqualTo("dev")); + Assert.That(loggedOutState.SignedIn, Is.False); + Assert.That(loggedOutState.Email, Is.Null); + + auth.SignIn("alice@dev.local", "BloomDev123!"); + var signedInState = auth.GetLoginState(env); + + Assert.That(signedInState.SignedIn, Is.True); + Assert.That(signedInState.Email, Is.EqualTo("alice@dev.local")); + } + + [Test] + public void GetLoginState_CloudMode_ReportsCloudNotReal() + { + // Regression guard for the "real" vs "cloud" naming mismatch found while + // implementing task 12: BloomBrowserUI's sharingApi.ts SharingLoginMode type only + // ever declared "dev" | "cloud", so the C# side must actually send "cloud". + var auth = new CloudAuth(new FakeCloudAuthProvider(), new FakeCloudTokenStore()); + var env = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_AUTH_MODE" ? "cloud" : null + ); + + Assert.That(auth.GetLoginState(env).AuthMode, Is.EqualTo("cloud")); + } + + [Test] + public void GetLoginState_EmailVerifiedReflectsSessionAndClearsOnSignOut() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => + { + var session = MakeSession(email); + session.EmailVerified = false; + return session; + }, + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + var env = new CloudEnvironment(name => null); + + // Sanity check: signed-out state must never claim a verified email. + Assert.That(auth.GetLoginState(env).EmailVerified, Is.False); + + auth.SignIn("alice@dev.local", "BloomDev123!"); + Assert.That( + auth.GetLoginState(env).EmailVerified, + Is.False, + "the fake session was built with EmailVerified=false" + ); + + auth.SignOut(); + Assert.That(auth.GetLoginState(env).EmailVerified, Is.False); + } + + [Test] + public void SignInWithExternalTokens_Success_SetsIdentityAndAccessToken() + { + var provider = new FakeCloudAuthProvider + { + AcceptExternalSessionHandler = (idToken, refreshToken) => + new CloudSession + { + AccessToken = idToken, + RefreshToken = refreshToken, + Email = "alice@example.com", + UserId = "firebase-uid-1", + ExpiresAtUtc = DateTime.UtcNow.AddHours(1), + EmailVerified = true, + }, + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + + Assert.That( + auth.IsSignedIn, + Is.False, + "should not be signed in before SignInWithExternalTokens is called" + ); + + auth.SignInWithExternalTokens("fake-id-token", "fake-refresh-token"); + + Assert.That(auth.IsSignedIn, Is.True); + Assert.That(auth.CurrentEmail, Is.EqualTo("alice@example.com")); + Assert.That(auth.CurrentEmailVerified, Is.True); + Assert.That(auth.GetAccessTokenOrNull(), Is.EqualTo("fake-id-token")); + Assert.That(provider.AcceptExternalSessionCallCount, Is.EqualTo(1)); + } + + [Test] + public void SignInWithExternalTokens_Failure_LeavesAuthSignedOut() + { + var provider = new FakeCloudAuthProvider + { + AcceptExternalSessionHandler = (idToken, refreshToken) => + throw new CloudAuthException("malformed token"), + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + + Assert.Throws(() => + auth.SignInWithExternalTokens("bad-token", "bad-refresh") + ); + + Assert.That(auth.IsSignedIn, Is.False); + Assert.That(auth.GetAccessTokenOrNull(), Is.Null); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudBookTransferTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudBookTransferTests.cs new file mode 100644 index 000000000000..df93108d219e --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudBookTransferTests.cs @@ -0,0 +1,638 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Amazon.S3; +using Amazon.S3.Model; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using Moq; +using NUnit.Framework; +using SIL.IO; + +namespace BloomTests.TeamCollection.Cloud +{ + [TestFixture] + public class CloudBookTransferTests + { + private TemporaryFolder _bookFolder; + private string _bookFolderPath; + private TemporaryFolder _destFolder; + private string _destFolderPath; + private Mock _s3Mock; + private CloudBookTransfer _transfer; + private CloudS3Location _location; + + [SetUp] + public void SetUp() + { + _bookFolder = new TemporaryFolder("CloudBookTransferTests_Book"); + _bookFolderPath = _bookFolder.FolderPath; + _destFolder = new TemporaryFolder("CloudBookTransferTests_Dest"); + _destFolderPath = _destFolder.FolderPath; + _s3Mock = new Mock(); + _transfer = new CloudBookTransfer(_ => _s3Mock.Object); + _location = new CloudS3Location + { + Bucket = "test-bucket", + Region = "us-east-1", + Prefix = "tc/collection-1/books/instance-1/", + AccessKeyId = "AK", + SecretAccessKey = "SK", + SessionToken = "ST", + }; + } + + [TearDown] + public void TearDown() + { + _bookFolder.Dispose(); + _destFolder.Dispose(); + } + + private string WriteBookFile(string relativePath, string content) + { + var fullPath = Path.Combine(_bookFolderPath, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); + RobustFile.WriteAllText(fullPath, content); + return fullPath; + } + + private string WriteDestFile(string relativePath, string content) + { + var fullPath = Path.Combine(_destFolderPath, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); + RobustFile.WriteAllText(fullPath, content); + return fullPath; + } + + private static PinnedFileDownload MakePinnedFile( + string relativePath, + string content, + string versionId + ) + { + var bytes = Encoding.UTF8.GetBytes(content); + return new PinnedFileDownload + { + RelativePath = relativePath, + S3VersionId = versionId, + ExpectedSha256Hex = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), + ExpectedSize = bytes.Length, + }; + } + + /// Configures the mock so GetObjectAsync serves back whatever content was written to + /// the matching local book file (keyed off the object key's trailing path segment) — used by + /// tests where the actual returned bytes don't matter beyond round-tripping correctly. + private void SetupGetObjectToReturnContent(Dictionary contentByRelativePath) + { + _s3Mock + .Setup(s => + s.GetObjectAsync(It.IsAny(), It.IsAny()) + ) + .Returns( + (req, ct) => + { + var relativePath = req.Key.Substring(_location.Prefix.Length); + var bytes = Encoding.UTF8.GetBytes(contentByRelativePath[relativePath]); + return Task.FromResult( + new GetObjectResponse + { + ResponseStream = new MemoryStream(bytes), + HttpStatusCode = HttpStatusCode.OK, + VersionId = req.VersionId, + } + ); + } + ); + } + + // ------------------------------------------------------------------ + // Upload: hash-skip + // ------------------------------------------------------------------ + + [Test] + public void UploadChangedFiles_SkipsFileMatchingPreviouslyCommittedManifest() + { + var localPath = WriteBookFile("book.htm", "same content"); + var (sha256, size) = BookVersionManifest.ComputeFileHash(localPath); + var previousManifest = new BookVersionManifest( + new Dictionary + { + ["book.htm"] = new BookVersionManifestEntry(sha256, size, "v-old"), + } + ); + + var result = _transfer.UploadChangedFiles( + _location, + _bookFolderPath, + new[] { "book.htm" }, + previousManifest, + null, + 2, + null, + CancellationToken.None + ); + + Assert.That(result.SkippedPaths, Has.Member("book.htm")); + Assert.That(result.UploadedPaths, Is.Empty); + _s3Mock.Verify( + s => s.PutObjectAsync(It.IsAny(), It.IsAny()), + Times.Never, + "identical content under an unchanged path must never be re-uploaded" + ); + } + + [Test] + public void UploadChangedFiles_UsesLocalManifestHashInsteadOfReHashing() + { + // The whole point of the localManifest parameter (E4): when the caller has already + // hashed the current on-disk content, UploadChangedFiles must use THAT hash rather + // than hashing the file a second time. We prove it by handing in a localManifest whose + // hash equals the previous commit while the real bytes on disk are different: a + // re-hashing implementation would see the changed bytes and upload; the hash-reuse + // path trusts the manifest, matches the previous commit, and skips. + WriteBookFile("book.htm", "the actual on-disk content"); + var (realSha, _) = BookVersionManifest.ComputeFileHash( + Path.Combine(_bookFolderPath, "book.htm") + ); + const string staleSha = + "0000000000000000000000000000000000000000000000000000000000000000"; + const long staleSize = 5; + Assert.That( + realSha, + Is.Not.EqualTo(staleSha), + "test setup: the stale manifest hash must differ from the real file's hash" + ); + var manifest = new BookVersionManifest( + new Dictionary + { + ["book.htm"] = new BookVersionManifestEntry(staleSha, staleSize, "v-old"), + } + ); + + var result = _transfer.UploadChangedFiles( + _location, + _bookFolderPath, + new[] { "book.htm" }, + manifest, // previousCommittedManifest + manifest, // localManifest — claims the file still matches the old commit + 2, + null, + CancellationToken.None + ); + + Assert.That( + result.SkippedPaths, + Has.Member("book.htm"), + "the caller-supplied localManifest hash (== previous) must be trusted over the changed on-disk bytes" + ); + Assert.That(result.UploadedPaths, Is.Empty); + _s3Mock.Verify( + s => s.PutObjectAsync(It.IsAny(), It.IsAny()), + Times.Never, + "no PUT when the localManifest says the content is unchanged (no second hash of the file)" + ); + } + + [Test] + public void UploadChangedFiles_UploadsChangedFile_WithChecksumHeaderAndCorrectKey() + { + WriteBookFile("book.htm", "changed content"); + var capturedRequests = new List(); + _s3Mock + .Setup(s => + s.PutObjectAsync(It.IsAny(), It.IsAny()) + ) + .Callback( + (req, ct) => capturedRequests.Add(req) + ) + .ReturnsAsync( + new PutObjectResponse + { + HttpStatusCode = HttpStatusCode.OK, + VersionId = "v-new", + } + ); + + var result = _transfer.UploadChangedFiles( + _location, + _bookFolderPath, + new[] { "book.htm" }, + null, + null, + 2, + null, + CancellationToken.None + ); + + Assert.That(result.UploadedPaths, Has.Member("book.htm")); + Assert.That( + capturedRequests, + Has.Count.EqualTo(1), + "sanity check: exactly one PUT happened" + ); + Assert.That( + capturedRequests[0].Key, + Is.EqualTo("tc/collection-1/books/instance-1/book.htm") + ); + Assert.That( + capturedRequests[0].Headers["x-amz-checksum-sha256"], + Is.Not.Null.And.Not.Empty, + "CONTRACTS.md: uploads carry x-amz-checksum-sha256" + ); + } + + // ------------------------------------------------------------------ + // Upload: checksum-mismatch / transient-failure retry + // ------------------------------------------------------------------ + + [Test] + public void UploadChangedFiles_TransientFailure_RetriesThenSucceeds() + { + WriteBookFile("book.htm", "content"); + var callCount = 0; + _s3Mock + .Setup(s => + s.PutObjectAsync(It.IsAny(), It.IsAny()) + ) + .Returns( + (req, ct) => + { + callCount++; + if (callCount == 1) + throw new AmazonS3Exception("simulated checksum mismatch"); + return Task.FromResult( + new PutObjectResponse + { + HttpStatusCode = HttpStatusCode.OK, + VersionId = "v", + } + ); + } + ); + + var result = _transfer.UploadChangedFiles( + _location, + _bookFolderPath, + new[] { "book.htm" }, + null, + null, + 1, + null, + CancellationToken.None + ); + + Assert.That( + callCount, + Is.EqualTo(2), + "must retry exactly once after the simulated failure" + ); + Assert.That(result.UploadedPaths, Has.Member("book.htm")); + } + + [Test] + public void UploadChangedFiles_AllAttemptsFail_ThrowsWithFailedPathAfterMaxAttempts() + { + WriteBookFile("book.htm", "content"); + _s3Mock + .Setup(s => + s.PutObjectAsync(It.IsAny(), It.IsAny()) + ) + .ThrowsAsync(new AmazonS3Exception("boom")); + + var ex = Assert.Throws(() => + _transfer.UploadChangedFiles( + _location, + _bookFolderPath, + new[] { "book.htm" }, + null, + null, + 1, + null, + CancellationToken.None + ) + ); + + Assert.That(ex.FailedPaths, Has.Member("book.htm")); + _s3Mock.Verify( + s => s.PutObjectAsync(It.IsAny(), It.IsAny()), + Times.Exactly(CloudBookTransfer.MaxAttemptsPerFile) + ); + } + + // ------------------------------------------------------------------ + // Download: pinned-version invariant + // ------------------------------------------------------------------ + + [Test] + public void DownloadFiles_PinnedFileMissingVersionId_NeverCallsS3() + { + var files = new[] + { + new PinnedFileDownload + { + RelativePath = "book.htm", + S3VersionId = null, + ExpectedSha256Hex = "abc", + ExpectedSize = 1, + }, + }; + + Assert.Throws(() => + _transfer.DownloadFiles( + _location, + files, + _destFolderPath, + 1, + null, + CancellationToken.None + ) + ); + + _s3Mock.Verify( + s => s.GetObjectAsync(It.IsAny(), It.IsAny()), + Times.Never, + "a pinned download with no version id must never reach S3 at all" + ); + } + + [Test] + public void DownloadFiles_EveryGetObjectRequest_CarriesTheExactPinnedVersionId() + { + var fileA = MakePinnedFile("a.txt", "AAAA", "v-a"); + var fileB = MakePinnedFile("b.txt", "BBBB", "v-b"); + var seenRequests = new List(); + var gate = new object(); + _s3Mock + .Setup(s => + s.GetObjectAsync(It.IsAny(), It.IsAny()) + ) + .Returns( + (req, ct) => + { + lock (gate) + seenRequests.Add(req); + var content = req.Key.EndsWith("a.txt") ? "AAAA" : "BBBB"; + return Task.FromResult( + new GetObjectResponse + { + ResponseStream = new MemoryStream(Encoding.UTF8.GetBytes(content)), + HttpStatusCode = HttpStatusCode.OK, + VersionId = req.VersionId, + } + ); + } + ); + + _transfer.DownloadFiles( + _location, + new[] { fileA, fileB }, + _destFolderPath, + 2, + null, + CancellationToken.None + ); + + Assert.That( + seenRequests, + Has.Count.EqualTo(2), + "sanity check: both files were actually fetched" + ); + Assert.That( + seenRequests, + Has.All.Matches(r => !string.IsNullOrEmpty(r.VersionId)), + "no code path may issue an unversioned GET" + ); + Assert.That( + seenRequests.Select(r => r.VersionId), + Is.EquivalentTo(new[] { "v-a", "v-b" }) + ); + } + + // ------------------------------------------------------------------ + // Download: hash-skip / resume + // ------------------------------------------------------------------ + + [Test] + public void DownloadFiles_SkipsFileAlreadyCorrectAtDestination() + { + var destPath = WriteDestFile("book.htm", "same content"); + var (sha256, size) = BookVersionManifest.ComputeFileHash(destPath); + var files = new[] + { + new PinnedFileDownload + { + RelativePath = "book.htm", + S3VersionId = "v1", + ExpectedSha256Hex = sha256, + ExpectedSize = size, + }, + }; + + var result = _transfer.DownloadFiles( + _location, + files, + _destFolderPath, + 2, + null, + CancellationToken.None + ); + + Assert.That(result.SkippedPaths, Has.Member("book.htm")); + _s3Mock.Verify( + s => s.GetObjectAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public void DownloadFiles_ResumeAfterPartialSuccess_SkipsAlreadyCorrectFilesOnRetry() + { + var fileA = MakePinnedFile("a.txt", "AAAA", "v-a"); + var fileB = MakePinnedFile("b.txt", "BBBB", "v-b"); + SetupGetObjectToReturnContent( + new Dictionary { ["a.txt"] = "AAAA", ["b.txt"] = "BBBB" } + ); + + var firstAttempt = _transfer.DownloadFiles( + _location, + new[] { fileA, fileB }, + _destFolderPath, + 2, + null, + CancellationToken.None + ); + Assert.That( + firstAttempt.DownloadedPaths, + Is.EquivalentTo(new[] { "a.txt", "b.txt" }), + "sanity check on the first, uninterrupted attempt" + ); + _s3Mock.Invocations.Clear(); + + var resumedAttempt = _transfer.DownloadFiles( + _location, + new[] { fileA, fileB }, + _destFolderPath, + 2, + null, + CancellationToken.None + ); + + Assert.That(resumedAttempt.SkippedPaths, Is.EquivalentTo(new[] { "a.txt", "b.txt" })); + _s3Mock.Verify( + s => s.GetObjectAsync(It.IsAny(), It.IsAny()), + Times.Never, + "already-correct files from a prior attempt must not be re-fetched on resume" + ); + } + + // ------------------------------------------------------------------ + // Download: checksum-mismatch retry + // ------------------------------------------------------------------ + + [Test] + public void DownloadFiles_ChecksumMismatch_RetriesThenSucceeds() + { + var goodBytes = Encoding.UTF8.GetBytes("correct bytes"); + var badBytes = Encoding.UTF8.GetBytes("WRONG bytes!!"); + var expectedSha256 = Convert.ToHexString(SHA256.HashData(goodBytes)).ToLowerInvariant(); + var callCount = 0; + + _s3Mock + .Setup(s => + s.GetObjectAsync(It.IsAny(), It.IsAny()) + ) + .Returns( + (req, ct) => + { + callCount++; + var bytes = callCount == 1 ? badBytes : goodBytes; + return Task.FromResult( + new GetObjectResponse + { + ResponseStream = new MemoryStream(bytes), + HttpStatusCode = HttpStatusCode.OK, + VersionId = req.VersionId, + } + ); + } + ); + + var files = new[] + { + new PinnedFileDownload + { + RelativePath = "book.htm", + S3VersionId = "v1", + ExpectedSha256Hex = expectedSha256, + ExpectedSize = goodBytes.Length, + }, + }; + + var result = _transfer.DownloadFiles( + _location, + files, + _destFolderPath, + 1, + null, + CancellationToken.None + ); + + Assert.That( + callCount, + Is.EqualTo(2), + "must retry after the first attempt's checksum mismatch" + ); + Assert.That(result.DownloadedPaths, Has.Member("book.htm")); + Assert.That( + RobustFile.ReadAllBytes(Path.Combine(_destFolderPath, "book.htm")), + Is.EqualTo(goodBytes) + ); + } + + // ------------------------------------------------------------------ + // Download: interrupted transfer leaves the destination untouched + // ------------------------------------------------------------------ + + [Test] + public void DownloadFiles_OneFileFailsAfterRetries_LeavesDestinationFolderCompletelyUntouched() + { + // A pre-existing file proves the destination folder is untouched, not merely that the + // two requested files are absent from it. + WriteDestFile("sentinel.txt", "pre-existing"); + var goodBytes = Encoding.UTF8.GetBytes("good"); + var goodSha256 = Convert.ToHexString(SHA256.HashData(goodBytes)).ToLowerInvariant(); + + _s3Mock + .Setup(s => + s.GetObjectAsync( + It.Is(r => r.Key.EndsWith("good.txt")), + It.IsAny() + ) + ) + .ReturnsAsync(() => + new GetObjectResponse + { + ResponseStream = new MemoryStream(goodBytes), + HttpStatusCode = HttpStatusCode.OK, + VersionId = "v-good", + } + ); + _s3Mock + .Setup(s => + s.GetObjectAsync( + It.Is(r => r.Key.EndsWith("bad.txt")), + It.IsAny() + ) + ) + .ThrowsAsync(new AmazonS3Exception("simulated failure")); + + var files = new[] + { + new PinnedFileDownload + { + RelativePath = "good.txt", + S3VersionId = "v-good", + ExpectedSha256Hex = goodSha256, + ExpectedSize = goodBytes.Length, + }, + new PinnedFileDownload + { + RelativePath = "bad.txt", + S3VersionId = "v-bad", + ExpectedSha256Hex = "irrelevant-because-the-get-itself-always-throws", + ExpectedSize = 1, + }, + }; + + Assert.Throws(() => + _transfer.DownloadFiles( + _location, + files, + _destFolderPath, + 2, + null, + CancellationToken.None + ) + ); + + var remainingFiles = Directory + .GetFiles(_destFolderPath, "*", SearchOption.AllDirectories) + .Select(Path.GetFileName) + .ToList(); + Assert.That( + remainingFiles, + Is.EquivalentTo(new[] { "sentinel.txt" }), + "neither the failed file nor the one that staged successfully may appear — the " + + "whole batch is all-or-nothing from the destination folder's point of view" + ); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudCollectionClientTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudCollectionClientTests.cs new file mode 100644 index 000000000000..37f7bf9448d7 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudCollectionClientTests.cs @@ -0,0 +1,407 @@ +using System; +using System.Collections.Generic; +using System.Net; +using Bloom.TeamCollection.Cloud; +using NUnit.Framework; +using RestSharp; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// A scriptable that returns whatever + /// produces instead of making a real HTTP call, so 's + /// header injection, 401-retry, and error-mapping logic can be unit tested without a live + /// server. Records every request it was asked to execute so tests can assert on headers. + /// + internal class FakeRestExecutor : IRestExecutor + { + public List RequestsSeen = new List(); + public Func Handler; + + public IRestResponse Execute(IRestRequest request) + { + RequestsSeen.Add(request); + return Handler(request); + } + } + + /// An that never actually calls out; used only to build a signed-in CloudAuth for these tests. + internal class StubCloudAuthProvider : ICloudAuthProvider + { + public Func RefreshHandler; + + public CloudSession SignIn(string email, string password) => + new CloudSession + { + AccessToken = "access-1", + RefreshToken = "refresh-1", + Email = email, + UserId = "user-1", + ExpiresAtUtc = DateTime.UtcNow.AddHours(1), + }; + + public CloudSession Refresh(string refreshToken) => + RefreshHandler != null + ? RefreshHandler(refreshToken) + : throw new CloudAuthException("refresh not configured for this test"); + } + + internal static class FakeResponses + { + public static IRestResponse Make(HttpStatusCode statusCode, string content) => + new RestResponse + { + StatusCode = statusCode, + Content = content, + ResponseStatus = ResponseStatus.Completed, + }; + } + + [TestFixture] + public class CloudCollectionClientTests + { + private static CloudEnvironment MakeEnvironment() => + new CloudEnvironment(name => name == "BLOOM_CLOUDTC_ANON_KEY" ? "test-anon-key" : null); + + private static ( + CloudCollectionClient client, + FakeRestExecutor executor, + CloudAuth auth + ) MakeSignedInClient(StubCloudAuthProvider provider = null) + { + provider = provider ?? new StubCloudAuthProvider(); + var auth = new CloudAuth(provider, new InMemoryCloudTokenStore()); + auth.SignIn("alice@dev.local", "BloomDev123!"); + var client = new CloudCollectionClient(MakeEnvironment(), auth); + var executor = new FakeRestExecutor(); + client.SetRestClientForTests(executor); + return (client, executor, auth); + } + + [Test] + public void CallRpc_AttachesApiKeyBearerAndContentProfileHeaders() + { + var (client, executor, _) = MakeSignedInClient(); + executor.Handler = req => FakeResponses.Make(HttpStatusCode.OK, "[]"); + + client.CallRpc("my_collections", new { }); + + Assert.That(executor.RequestsSeen, Has.Count.EqualTo(1)); + var request = executor.RequestsSeen[0]; + var headers = request.Parameters.FindAll(p => p.Type == ParameterType.HttpHeader); + + Assert.That( + headers, + Has.Some.Matches(h => + h.Name == "apikey" && (string)h.Value == "test-anon-key" + ) + ); + Assert.That( + headers, + Has.Some.Matches(h => + h.Name == "Authorization" && (string)h.Value == "Bearer access-1" + ) + ); + Assert.That( + headers, + Has.Some.Matches(h => + h.Name == "Content-Profile" && (string)h.Value == "tc" + ) + ); + Assert.That( + headers, + Has.Some.Matches(h => + h.Name == "Accept-Profile" && (string)h.Value == "tc" + ) + ); + } + + [Test] + public void CallRpc_Success_ReturnsParsedJson() + { + var (client, executor, _) = MakeSignedInClient(); + executor.Handler = req => + FakeResponses.Make( + HttpStatusCode.OK, + "[{\"id\":\"abc\",\"name\":\"My Collection\"}]" + ); + + var result = client.CallRpc("my_collections", new { }); + + Assert.That(result, Is.Not.Null); + Assert.That((string)result[0]["id"], Is.EqualTo("abc")); + } + + [Test] + public void CallRpc_NoContent_ReturnsNull() + { + var (client, executor, _) = MakeSignedInClient(); + executor.Handler = req => FakeResponses.Make(HttpStatusCode.NoContent, ""); + + var result = client.CallRpc("unlock_book", new { p_book_id = "abc" }); + + Assert.That(result, Is.Null); + } + + [Test] + public void CallRpc_401ThenRefreshSucceeds_RetriesWithNewTokenAndSucceeds() + { + var provider = new StubCloudAuthProvider + { + RefreshHandler = refreshToken => new CloudSession + { + AccessToken = "access-2", + RefreshToken = "refresh-2", + Email = "alice@dev.local", + UserId = "user-1", + ExpiresAtUtc = DateTime.UtcNow.AddHours(1), + }, + }; + var (client, executor, auth) = MakeSignedInClient(provider); + + var callCount = 0; + executor.Handler = req => + { + callCount++; + if (callCount == 1) + { + // Sanity check: the first (failing) call really did use the original token. + Assert.That( + HeaderValue(req, "Authorization"), + Is.EqualTo("Bearer access-1"), + "first attempt should use the pre-refresh token" + ); + return FakeResponses.Make( + HttpStatusCode.Unauthorized, + "{\"message\":\"JWT expired\"}" + ); + } + + Assert.That( + HeaderValue(req, "Authorization"), + Is.EqualTo("Bearer access-2"), + "retry should use the refreshed token" + ); + return FakeResponses.Make(HttpStatusCode.OK, "[]"); + }; + + var result = client.CallRpc("my_collections", new { }); + + Assert.That( + callCount, + Is.EqualTo(2), + "should retry exactly once after a successful refresh" + ); + Assert.That(result, Is.Not.Null); + Assert.That(auth.GetAccessTokenOrNull(), Is.EqualTo("access-2")); + } + + [Test] + public void CallRpc_401AndRefreshFails_ThrowsNotSignedInWithoutLooping() + { + var provider = new StubCloudAuthProvider + { + RefreshHandler = refreshToken => + throw new CloudAuthException("refresh token expired"), + }; + var (client, executor, auth) = MakeSignedInClient(provider); + + var callCount = 0; + executor.Handler = req => + { + callCount++; + return FakeResponses.Make( + HttpStatusCode.Unauthorized, + "{\"message\":\"JWT expired\"}" + ); + }; + + var ex = Assert.Throws(() => + client.CallRpc("my_collections", new { }) + ); + + // This is the "refresh failure mid-operation aborts cleanly and surfaces 'please + // sign in'" acceptance criterion at the client layer. + Assert.That(ex.Code, Is.EqualTo(CloudErrorCode.NotSignedIn)); + Assert.That( + callCount, + Is.EqualTo(1), + "must not retry when there is nothing to retry with" + ); + Assert.That( + auth.IsSignedIn, + Is.False, + "the failed refresh should have signed the user out" + ); + } + + [TestCase("LockHeldByOther", CloudErrorCode.LockHeldByOther)] + [TestCase("BaseVersionSuperseded", CloudErrorCode.BaseVersionSuperseded)] + [TestCase("NameConflict", CloudErrorCode.NameConflict)] + [TestCase("MissingOrBadUploads", CloudErrorCode.MissingOrBadUploads)] + [TestCase("VersionConflict", CloudErrorCode.VersionConflict)] + public void CallEdgeFunction_TypedErrorCodes_MapToExpectedCloudErrorCode( + string serverCode, + CloudErrorCode expected + ) + { + // `{error: ""}` is CONTRACTS.md's standard envelope, and the shape every edge + // function REALLY sends (supabase/functions/_shared/errors.ts's errorResponse). An + // earlier version of this test asserted against `{code: ""}` -- a shape the + // real server never produces -- which is exactly how MapError's matching `code`-only + // lookup shipped broken: the test validated the client's wrong assumption instead of + // the server's actual contract. E2E-9's same-name race caught it live (the + // NameConflict retry loop in PutBookInRepo never engaged). + var (client, executor, _) = MakeSignedInClient(); + executor.Handler = req => + FakeResponses.Make( + HttpStatusCode.Conflict, + $"{{\"error\":\"{serverCode}\",\"message\":\"conflict\"}}" + ); + + var ex = Assert.Throws(() => + client.CallEdgeFunction("checkin-start", new { }) + ); + + Assert.That(ex.Code, Is.EqualTo(expected)); + Assert.That(ex.Details, Is.Not.Null); + } + + [Test] + public void CallEdgeFunction_CodeShapedErrorBody_StillMapsAsFallback() + { + // MapError keeps `code` as a fallback key (PostgREST RPC bodies use it, and any + // hypothetical old server build might); make sure that path still classifies. + var (client, executor, _) = MakeSignedInClient(); + executor.Handler = req => + FakeResponses.Make( + HttpStatusCode.Conflict, + "{\"code\":\"NameConflict\",\"message\":\"conflict\"}" + ); + + var ex = Assert.Throws(() => + client.CallEdgeFunction("checkin-start", new { }) + ); + + Assert.That(ex.Code, Is.EqualTo(CloudErrorCode.NameConflict)); + } + + [Test] + public void CallEdgeFunction_426_MapsToClientOutOfDate() + { + var (client, executor, _) = MakeSignedInClient(); + executor.Handler = req => + FakeResponses.Make((HttpStatusCode)426, "{\"message\":\"upgrade Bloom\"}"); + + var ex = Assert.Throws(() => + client.CallEdgeFunction("checkin-start", new { }) + ); + + Assert.That(ex.Code, Is.EqualTo(CloudErrorCode.ClientOutOfDate)); + Assert.That(ex.Message, Is.EqualTo("upgrade Bloom")); + } + + [Test] + public void LogEvent_PostsMessageUnderPMessageNotPComment() + { + // Regression guard for the E2E-4 finding: tc.log_event's message parameter is + // `p_message`; posting `p_comment` (the original guess) makes PostgREST reject the + // call (no function with that argument name), silently dropping the + // WorkPreservedLocally incident. Assert on the actual serialized request body. + var (client, executor, _) = MakeSignedInClient(); + // LogEvent casts the RPC result to JObject, so hand back an object body. + executor.Handler = req => FakeResponses.Make(HttpStatusCode.OK, "{\"id\":1}"); + + client.LogEvent("col-1", "book-1", 100, "recovery note"); + + var request = executor.RequestsSeen[0]; + var body = (string) + request.Parameters.Find(p => p.Type == ParameterType.RequestBody).Value; + Assert.That(body, Does.Contain("p_message"), "must post the RPC's real parameter name"); + Assert.That(body, Does.Contain("recovery note")); + Assert.That( + body, + Does.Not.Contain("p_comment"), + "the old wrong parameter name must be gone" + ); + } + + [Test] + public void CallRpc_PostgrestStyleError_MapsToUnknownWithServerMessagePreserved() + { + // This is the actual shape live-verified against the local Supabase stack: RAISE + // EXCEPTION 'book_not_found' USING ERRCODE = 'P0002' comes back as this JSON body. + var (client, executor, _) = MakeSignedInClient(); + executor.Handler = req => + FakeResponses.Make( + HttpStatusCode.InternalServerError, + "{\"code\":\"P0002\",\"details\":null,\"hint\":null,\"message\":\"book_not_found\"}" + ); + + var ex = Assert.Throws(() => + client.CallRpc("checkout_book", new { p_book_id = "abc", p_machine = "m" }) + ); + + Assert.That(ex.Code, Is.EqualTo(CloudErrorCode.Unknown)); + Assert.That(ex.Message, Is.EqualTo("book_not_found")); + } + + [Test] + public void CallRpc_NotSignedIn_StillSendsApiKeyButNoAuthorizationHeader() + { + var auth = new CloudAuth(new StubCloudAuthProvider(), new InMemoryCloudTokenStore()); + var client = new CloudCollectionClient(MakeEnvironment(), auth); + var executor = new FakeRestExecutor(); + client.SetRestClientForTests(executor); + executor.Handler = req => + FakeResponses.Make(HttpStatusCode.Unauthorized, "{\"message\":\"no token\"}"); + + Assert.That( + auth.IsSignedIn, + Is.False, + "sanity check: this test is specifically the not-signed-in case" + ); + + var ex = Assert.Throws(() => + client.CallRpc("my_collections", new { }) + ); + + Assert.That(ex.Code, Is.EqualTo(CloudErrorCode.NotSignedIn)); + var request = executor.RequestsSeen[0]; + Assert.That(HeaderValue(request, "apikey"), Is.EqualTo("test-anon-key")); + Assert.That(HeaderValue(request, "Authorization"), Is.Null); + } + + private static string HeaderValue(IRestRequest request, string name) + { + var param = request.Parameters.Find(p => + p.Type == ParameterType.HttpHeader && p.Name == name + ); + return param == null ? null : (string)param.Value; + } + + // Regression for the first two-instance smoke test (7 Jul 2026): the live members_add + // RPC returns a bare bigint scalar (PostgREST serializes it as e.g. "3"), and the + // wrapper's old (JObject) cast crashed with InvalidCastException AFTER the row had + // already committed server-side. Mocked shapes must match the live wire shape. + [Test] + public void MembersAdd_ScalarBigintResponse_ReturnsId() + { + var (client, executor, _) = MakeSignedInClient(); + executor.Handler = _ => FakeResponses.Make(HttpStatusCode.OK, "3"); + + Assert.That(client.MembersAdd("c-1", "bob@dev.local"), Is.EqualTo(3L)); + } + + [Test] + public void MembersAdd_NullResponseForAlreadyApprovedEmail_ReturnsNull() + { + // members_add is idempotent: on conflict it inserts nothing and returns SQL NULL, + // which PostgREST serializes as the JSON literal null. + var (client, executor, _) = MakeSignedInClient(); + executor.Handler = _ => FakeResponses.Make(HttpStatusCode.OK, "null"); + + Assert.That(client.MembersAdd("c-1", "bob@dev.local"), Is.Null); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudCollectionMonitorTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudCollectionMonitorTests.cs new file mode 100644 index 000000000000..1665faf4a367 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudCollectionMonitorTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Net; +using Bloom.TeamCollection.Cloud; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// Tests for CloudCollectionMonitor's polling: cursor advancement (self-echo suppression falls + /// out of always polling "since" the last max_event_id seen) and that PollNow can be triggered + /// on demand (the "on-activation" path) without waiting for the periodic timer. + /// + [TestFixture] + public class CloudCollectionMonitorTests + { + private static CloudEnvironment MakeEnvironment() => + new CloudEnvironment(name => name == "BLOOM_CLOUDTC_ANON_KEY" ? "test-anon-key" : null); + + private (CloudCollectionClient client, FakeRestExecutor executor) MakeClient() + { + var auth = new CloudAuth(new StubCloudAuthProvider(), new InMemoryCloudTokenStore()); + auth.SignIn("test@somewhere.org", "irrelevant"); + var client = new CloudCollectionClient(MakeEnvironment(), auth); + var executor = new FakeRestExecutor(); + client.SetRestClientForTests(executor); + return (client, executor); + } + + [Test] + public void PollNow_FetchesSinceLastCursor_AndAdvancesCursor() + { + var (client, executor) = MakeClient(); + long? sentSinceEventId = null; + executor.Handler = req => + { + var bodyParam = req.Parameters.Find(p => + p.Type == RestSharp.ParameterType.RequestBody + ); + var sentBody = JObject.Parse((string)bodyParam.Value); + sentSinceEventId = (long?)sentBody["p_since_event_id"]; + var response = new JObject { ["books"] = new JArray(), ["max_event_id"] = 42 }; + return FakeResponses.Make(HttpStatusCode.OK, response.ToString()); + }; + + JObject received = null; + var monitor = new CloudCollectionMonitor( + client, + "collection-1", + initialLastSeenEventId: 10, + onChanges: changes => received = changes + ); + + monitor.PollNow(); + + Assert.That(sentSinceEventId, Is.EqualTo(10), "should poll since the initial cursor"); + Assert.That(received, Is.Not.Null); + Assert.That( + monitor.LastSeenEventId, + Is.EqualTo(42), + "cursor should advance to max_event_id" + ); + } + + [Test] + public void PollNow_CalledTwice_SecondCallUsesAdvancedCursor() + { + var (client, executor) = MakeClient(); + var sinceValuesSeen = new System.Collections.Generic.List(); + var nextMaxEventId = 5L; + executor.Handler = req => + { + var bodyParam = req.Parameters.Find(p => + p.Type == RestSharp.ParameterType.RequestBody + ); + var sentBody = JObject.Parse((string)bodyParam.Value); + sinceValuesSeen.Add((long?)sentBody["p_since_event_id"]); + var response = new JObject + { + ["books"] = new JArray(), + ["max_event_id"] = nextMaxEventId, + }; + nextMaxEventId += 5; + return FakeResponses.Make(HttpStatusCode.OK, response.ToString()); + }; + + var monitor = new CloudCollectionMonitor(client, "collection-1", 0, changes => { }); + + monitor.PollNow(); + monitor.PollNow(); + + Assert.That(sinceValuesSeen, Is.EqualTo(new long?[] { 0, 5 })); + } + + [Test] + public void PollNow_OnError_InvokesErrorCallbackRatherThanThrowing() + { + var (client, executor) = MakeClient(); + executor.Handler = req => FakeResponses.Make(HttpStatusCode.InternalServerError, "{}"); + + Exception caught = null; + var monitor = new CloudCollectionMonitor( + client, + "collection-1", + 0, + onChanges: changes => Assert.Fail("onChanges should not be called on error"), + onError: e => caught = e + ); + + Assert.DoesNotThrow(() => monitor.PollNow()); + Assert.That(caught, Is.Not.Null); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudEnvironmentTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudEnvironmentTests.cs new file mode 100644 index 000000000000..9a6b78ad0f63 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudEnvironmentTests.cs @@ -0,0 +1,139 @@ +using Bloom.TeamCollection.Cloud; +using NUnit.Framework; + +namespace BloomTests.TeamCollection.Cloud +{ + [TestFixture] + public class CloudEnvironmentTests + { + [Test] + public void NoEnvironmentVariablesSet_FallsBackToLocalDevStackDefaults() + { + var env = new CloudEnvironment(name => null); + + // These defaults must match server/dev/README.md's "Dev value" column so a plain + // checkout of Bloom talks to the local dev stack with zero configuration. + Assert.That(env.SupabaseUrl, Is.EqualTo("http://127.0.0.1:54321")); + Assert.That(env.S3Endpoint, Is.EqualTo("http://127.0.0.1:9000")); + Assert.That(env.AuthMode, Is.EqualTo(CloudAuthMode.Dev)); + Assert.That(env.DevUser, Is.Null); + Assert.That(env.DevPassword, Is.Null); + // The real-user default: change visibility within a minute at negligible load. + Assert.That(env.PollInterval, Is.EqualTo(System.TimeSpan.FromSeconds(60))); + } + + [Test] + public void PollSeconds_Override_ShortensThePollInterval() + { + var env = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_POLL_SECONDS" ? "5" : null + ); + + Assert.That(env.PollInterval, Is.EqualTo(System.TimeSpan.FromSeconds(5))); + } + + [TestCase("abc")] + [TestCase("0")] + [TestCase("-3")] + public void PollSeconds_Invalid_ThrowsInsteadOfSilentlyIgnoring(string badValue) + { + // Fail fast: a typo here silently reverting to 60s would make E2E runs subtly + // slow instead of obviously misconfigured. + Assert.Throws(() => + new CloudEnvironment(name => name == "BLOOM_CLOUDTC_POLL_SECONDS" ? badValue : null) + ); + } + + [Test] + public void EnvironmentVariables_OverrideCompiledDefaults() + { + var values = new System.Collections.Generic.Dictionary + { + ["BLOOM_CLOUDTC_SUPABASE_URL"] = "https://sandbox.example.org", + ["BLOOM_CLOUDTC_ANON_KEY"] = "sandbox-anon-key", + ["BLOOM_CLOUDTC_S3_ENDPOINT"] = "https://s3.sandbox.example.org", + ["BLOOM_CLOUDTC_AUTH_MODE"] = "cloud", + ["BLOOM_CLOUDTC_USER"] = "override@dev.local", + ["BLOOM_CLOUDTC_PASSWORD"] = "pw", + ["BLOOM_CLOUDTC_FIREBASE_API_KEY"] = "sandbox-firebase-key", + }; + + var env = new CloudEnvironment(name => + values.TryGetValue(name, out var value) ? value : null + ); + + Assert.That(env.SupabaseUrl, Is.EqualTo("https://sandbox.example.org")); + Assert.That(env.AnonKey, Is.EqualTo("sandbox-anon-key")); + Assert.That(env.S3Endpoint, Is.EqualTo("https://s3.sandbox.example.org")); + Assert.That(env.AuthMode, Is.EqualTo(CloudAuthMode.Cloud)); + Assert.That(env.DevUser, Is.EqualTo("override@dev.local")); + Assert.That(env.DevPassword, Is.EqualTo("pw")); + Assert.That(env.FirebaseApiKey, Is.EqualTo("sandbox-firebase-key")); + } + + [Test] + public void NoFirebaseEnvironmentVariablesSet_FallsBackToEmptyPlaceholders() + { + var env = new CloudEnvironment(name => null); + + // Sanity check: the placeholders must be empty (not null) so callers can safely + // build a URL query string without a null-check, and so it's obvious in a log that + // no override was configured yet. + Assert.That(env.FirebaseApiKey, Is.EqualTo("")); + } + + [TestCase("dev", CloudAuthMode.Dev)] + [TestCase("DEV", CloudAuthMode.Dev)] + [TestCase("cloud", CloudAuthMode.Cloud)] + [TestCase("CLOUD", CloudAuthMode.Cloud)] + [TestCase("", CloudAuthMode.Dev)] + [TestCase("garbage", CloudAuthMode.Dev)] + public void AuthMode_ParsesCaseInsensitivelyAndDefaultsToDev( + string raw, + CloudAuthMode expected + ) + { + var env = new CloudEnvironment(name => name == "BLOOM_CLOUDTC_AUTH_MODE" ? raw : null); + + Assert.That(env.AuthMode, Is.EqualTo(expected)); + } + + [Test] + public void S3ForcePathStyle_TrueWhenEndpointConfigured() + { + // Every configuration this class ever sees has a non-empty S3 endpoint (either the + // compiled MinIO default or an explicit override) — path-style is what MinIO + // requires, and real AWS is reached the same way once its endpoint is configured. + var withDefault = new CloudEnvironment(name => null); + Assert.That(withDefault.S3ForcePathStyle, Is.True); + + var withOverride = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_S3_ENDPOINT" ? "https://s3.example.org" : null + ); + Assert.That(withOverride.S3ForcePathStyle, Is.True); + } + + [Test] + public void Current_ReflectsSetCurrentForTestsUntilReset() + { + CloudEnvironment.ResetCurrentForTests(); + try + { + var fake = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_SUPABASE_URL" ? "https://fake.example.org" : null + ); + CloudEnvironment.SetCurrentForTests(fake); + + Assert.That(CloudEnvironment.Current, Is.SameAs(fake)); + Assert.That( + CloudEnvironment.Current.SupabaseUrl, + Is.EqualTo("https://fake.example.org") + ); + } + finally + { + CloudEnvironment.ResetCurrentForTests(); + } + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudIdentityFirstLookupTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudIdentityFirstLookupTests.cs new file mode 100644 index 000000000000..86771c3ffac0 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudIdentityFirstLookupTests.cs @@ -0,0 +1,210 @@ +using System.IO; +using System.Net; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// Tests for CloudTeamCollection's identity-first book resolution (bug #15; John's ruling, + /// 13 Jul 2026: "the status of a particular record by instanceID in the database is the + /// source of truth for that book's state"). When a local folder exists, its meta.json + /// bookInstanceId -- never its folder name -- decides which server row is this book: + /// a checked-out book renamed locally stays bound to its own row, and a local book that + /// merely shares a NAME with someone else's checked-in book must not wear that book's + /// status. Name lookup applies only when there is no local folder (repo-name queries, + /// e.g. names from GetBookList). + /// + [TestFixture] + public class CloudIdentityFirstLookupTests + { + private const string kCollectionId = "22222222-2222-2222-2222-222222222222"; + private CloudTestHarness _harness; + private TemporaryFolder _collectionFolder; + private Mock _mockTcManager; + private CloudTeamCollection _collection; + private FakeRestExecutor _executor; + + [SetUp] + public void Setup() + { + _harness = CloudTestHarness.Create("CloudIdentityFirstLookupTests", kCollectionId); + _collectionFolder = _harness.CollectionFolder; + _mockTcManager = _harness.MockTcManager; + _collection = _harness.Collection; + _executor = _harness.Executor; + + // Hydrate the cache with one committed book, checked out by a TEAMMATE on another + // machine: "The Moon and the Cap" / instance-moon (the live bug-#15 book). + _executor.Handler = req => + { + var body = new JObject + { + ["books"] = new JArray( + new JObject + { + ["id"] = "book-moon", + ["instance_id"] = "instance-moon", + ["name"] = "The Moon and the Cap", + ["current_version_id"] = "v1", + ["current_version_seq"] = 1, + ["current_checksum"] = "cs1", + ["locked_by"] = "user-bob", + ["locked_by_email"] = "bob@dev.local", + ["locked_by_machine"] = "BOBS-MACHINE", + ["locked_at"] = "2026-07-13T20:15:38Z", + ["deleted_at"] = null, + } + ), + ["groups"] = new JArray(), + ["max_event_id"] = 1, + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + }; + _collection.IsBookPresentInRepo("The Moon and the Cap"); // forces hydration + } + + [TearDown] + public void TearDown() + { + _harness.Dispose(); + } + + /// Creates a local book folder with a meta.json carrying the given instance id + /// (the identity every lookup should resolve by), plus the .htm that makes it look like a + /// real Bloom book folder. + private void MakeLocalBook(string folderName, string instanceId) + { + var folderPath = Path.Combine(_collectionFolder.FolderPath, folderName); + Directory.CreateDirectory(folderPath); + File.WriteAllText( + Path.Combine(folderPath, "meta.json"), + $"{{\"bookInstanceId\":\"{instanceId}\"}}" + ); + File.WriteAllText(Path.Combine(folderPath, folderName + ".htm"), ""); + } + + [Test] + public void RenamedCheckedOutBook_StillBindsToItsOwnRepoRow() + { + // Bob checked out "The Moon and the Cap" and retitled it; the local folder renamed + // ahead of check-in. Identity (instance-moon) must keep it bound to its row. + MakeLocalBook("Tetun moon and cap", "instance-moon"); + + Assert.That( + _collection.ResolveBookIdForTests("Tetun moon and cap"), + Is.EqualTo("book-moon") + ); + Assert.That( + _collection.IsBookPresentInRepo("Tetun moon and cap"), + Is.True, + "the renamed folder is the same book, not a new local one" + ); + Assert.That( + _collection.WhoHasBookLocked("Tetun moon and cap"), + Is.EqualTo("bob@dev.local"), + "the checkout must survive the local rename (bug #15's lost avatar)" + ); + } + + [Test] + public void LocalBookSharingNameWithSomeoneElsesBook_DoesNotWearItsStatus() + { + // John's conflict scenario: while Alice was offline she created a book whose name + // happens to match a book Bob checked in. Her book's own instance id is different, + // so it must NOT report Bob's book's status as its own. + MakeLocalBook("The Moon and the Cap", "instance-alices-own"); + + Assert.That( + _collection.ResolveBookIdForTests("The Moon and the Cap"), + Is.Null, + "a local book resolves by ITS identity; a name match with someone else's book is not a binding" + ); + Assert.That(_collection.IsBookPresentInRepo("The Moon and the Cap"), Is.False); + // Sanity: the repo row itself is untouched and still Bob's -- only the local folder's + // binding changed. (A name query with no local folder still finds it; see next test.) + var status = _collection.GetStatus("The Moon and the Cap"); + Assert.That( + status.lockedBy, + Is.Not.EqualTo("bob@dev.local"), + "Alice's local book must not show as checked out to Bob" + ); + } + + [Test] + public void RepoBookWithNoLocalFolder_ResolvesByName() + { + // No local folder at all (e.g. a name straight from GetBookList during copy-down): + // name lookup is the correct and only possible binding. + Assert.That( + _collection.ResolveBookIdForTests("The Moon and the Cap"), + Is.EqualTo("book-moon") + ); + Assert.That(_collection.IsBookPresentInRepo("The Moon and the Cap"), Is.True); + Assert.That( + _collection.WhoHasBookLocked("The Moon and the Cap"), + Is.EqualTo("bob@dev.local") + ); + } + + [Test] + public void LocalFolderWithNoReadableId_DoesNotBindByName() + { + // Fail-safe: a local folder whose identity cannot be read must not guess by name -- + // it degrades to "local-only book" rather than wearing a repo book's status. + var folderPath = Path.Combine(_collectionFolder.FolderPath, "The Moon and the Cap"); + Directory.CreateDirectory(folderPath); // no meta.json at all + + Assert.That(_collection.ResolveBookIdForTests("The Moon and the Cap"), Is.Null); + Assert.That(_collection.IsBookPresentInRepo("The Moon and the Cap"), Is.False); + } + + [Test] + public void RenamedCheckedOutBook_KnownToHaveBeenDeletedConsultsItsOwnRow() + { + // KnownToHaveBeenDeleted also resolves by identity: the renamed local book asks about + // ITS row (not deleted), not about a name that no longer matches anything. + MakeLocalBook("Tetun moon and cap", "instance-moon"); + Assert.That(_collection.KnownToHaveBeenDeleted("Tetun moon and cap"), Is.False); + } + + // Bug #18 (13 Jul 2026, John's live report): a teammate's rename arrives as a repo book + // name with no local folder. The base rename-source heuristic ("has repo status => can't + // be the rename source") is inverted under identity-first resolution, so the receiving + // side downloaded the renamed book as a NEW book next to its old-name folder -- two local + // folders with one instance id. The cloud override compares instance ids exactly. + [Test] + public void NewBookRenamedFrom_LocalFolderWithSameIdUnderOldName_IsDetected() + { + // The repo book is named "The Moon and the Cap" (instance-moon); locally it still + // sits under the name the teammate renamed it FROM. + MakeLocalBook("Old Local Name", "instance-moon"); + + Assert.That( + _collection.NewBookRenamedFrom("The Moon and the Cap"), + Is.EqualTo("Old Local Name") + ); + } + + [Test] + public void NewBookRenamedFrom_NoLocalFolderWithThatId_ReturnsNull() + { + MakeLocalBook("Unrelated Book", "instance-unrelated"); + + Assert.That(_collection.NewBookRenamedFrom("The Moon and the Cap"), Is.Null); + } + + [Test] + public void NewBookRenamedFrom_LocalFolderUnderTheSameName_IsNotARenameSource() + { + // The book already exists locally under the repo's own name: nothing was renamed. + MakeLocalBook("The Moon and the Cap", "instance-moon"); + + Assert.That(_collection.NewBookRenamedFrom("The Moon and the Cap"), Is.Null); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudRepoCacheTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudRepoCacheTests.cs new file mode 100644 index 000000000000..c7b087d8ce5d --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudRepoCacheTests.cs @@ -0,0 +1,560 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace BloomTests.TeamCollection.Cloud +{ + [TestFixture] + public class CloudRepoCacheTests + { + private TemporaryFolder _collectionFolder; + private string _collectionFolderPath; + + [SetUp] + public void SetUp() + { + _collectionFolder = new TemporaryFolder("CloudRepoCacheTests"); + _collectionFolderPath = _collectionFolder.FolderPath; + } + + [TearDown] + public void TearDown() + { + _collectionFolder.Dispose(); + } + + private static JObject MakeBookRow( + string id, + string instanceId = "inst-1", + string name = "Book", + long? versionSeq = 1, + string checksum = "chk-1", + string lockedBy = null, + string lockedByMachine = null, + DateTime? deletedAt = null + ) => + new JObject + { + ["id"] = id, + ["instance_id"] = instanceId, + ["name"] = name, + ["current_version_id"] = versionSeq.HasValue ? "v-" + versionSeq : null, + ["current_version_seq"] = versionSeq, + ["current_checksum"] = checksum, + ["locked_by"] = lockedBy, + ["locked_by_machine"] = lockedByMachine, + ["locked_at"] = null, + ["deleted_at"] = deletedAt, + }; + + // ------------------------------------------------------------------ + // Full snapshot + // ------------------------------------------------------------------ + + [Test] + public void ApplyFullSnapshot_PopulatesBooksGroupsAndCursor() + { + var cache = new CloudRepoCache(_collectionFolderPath); + var state = new JObject + { + ["books"] = new JArray(MakeBookRow("book-1"), MakeBookRow("book-2")), + ["groups"] = new JArray( + new JObject + { + ["group_key"] = "other", + ["version"] = 3, + ["updated_at"] = DateTime.UtcNow, + } + ), + ["max_event_id"] = 42, + }; + + cache.ApplyFullSnapshot(state); + + Assert.That(cache.GetAllBooks(), Has.Count.EqualTo(2), "sanity check on the fixture"); + Assert.That(cache.TryGetBook("book-1"), Is.Not.Null); + Assert.That(cache.TryGetBook("book-1").Name, Is.EqualTo("Book")); + Assert.That(cache.TryGetGroup("other").Version, Is.EqualTo(3)); + Assert.That(cache.LastSeenEventId, Is.EqualTo(42)); + } + + [Test] + public void ApplyFullSnapshot_RemovesBooksNotInTheNewSnapshot() + { + var cache = new CloudRepoCache(_collectionFolderPath); + cache.ApplyFullSnapshot( + new JObject { ["books"] = new JArray(MakeBookRow("stale-book")) } + ); + Assert.That( + cache.TryGetBook("stale-book"), + Is.Not.Null, + "sanity check: it's there before the second snapshot" + ); + + cache.ApplyFullSnapshot( + new JObject { ["books"] = new JArray(MakeBookRow("fresh-book")) } + ); + + Assert.That( + cache.TryGetBook("stale-book"), + Is.Null, + "a full snapshot is authoritative for what exists" + ); + Assert.That(cache.TryGetBook("fresh-book"), Is.Not.Null); + } + + [Test] + public void ApplyFullSnapshot_PreservesManifestForBooksStillPresent() + { + var cache = new CloudRepoCache(_collectionFolderPath); + cache.ApplyFullSnapshot(new JObject { ["books"] = new JArray(MakeBookRow("book-1")) }); + var manifest = new BookVersionManifest( + new Dictionary + { + ["book.htm"] = new BookVersionManifestEntry("sha", 1, "v1"), + } + ); + cache.RecordManifest("book-1", manifest); + Assert.That( + cache.TryGetBook("book-1").Manifest, + Is.Not.Null, + "sanity check before re-snapshotting" + ); + + cache.ApplyFullSnapshot( + new JObject { ["books"] = new JArray(MakeBookRow("book-1", versionSeq: 2)) } + ); + + var afterBook = cache.TryGetBook("book-1"); + Assert.That( + afterBook.CurrentVersionSeq, + Is.EqualTo(2), + "the server row's fields must still update" + ); + Assert.That( + afterBook.Manifest, + Is.Not.Null, + "manifest data (not carried on the row) must survive a re-snapshot" + ); + Assert.That(afterBook.Manifest.Entries.Keys, Has.Member("book.htm")); + } + + // ------------------------------------------------------------------ + // Delta application + // ------------------------------------------------------------------ + + [Test] + public void ApplyDelta_UpsertsTouchedBooksAndAdvancesCursor_WithoutRemovingUntouchedOnes() + { + var cache = new CloudRepoCache(_collectionFolderPath); + cache.ApplyFullSnapshot( + new JObject + { + ["books"] = new JArray(MakeBookRow("book-1", versionSeq: 1)), + ["max_event_id"] = 10, + } + ); + + cache.ApplyDelta( + new JObject + { + ["books"] = new JArray( + MakeBookRow("book-1", versionSeq: 2), + MakeBookRow("book-2", versionSeq: 1) + ), + ["max_event_id"] = 15, + } + ); + + Assert.That( + cache.TryGetBook("book-1").CurrentVersionSeq, + Is.EqualTo(2), + "existing book updated" + ); + Assert.That( + cache.TryGetBook("book-2"), + Is.Not.Null, + "a book new to a delta (never seen before) is added" + ); + Assert.That(cache.LastSeenEventId, Is.EqualTo(15)); + } + + [Test] + public void ApplyDelta_TombstonedBook_KeepsRowButRecordsDeletedAt() + { + var cache = new CloudRepoCache(_collectionFolderPath); + cache.ApplyFullSnapshot(new JObject { ["books"] = new JArray(MakeBookRow("book-1")) }); + + var deletedAt = DateTime.UtcNow; + cache.ApplyDelta( + new JObject { ["books"] = new JArray(MakeBookRow("book-1", deletedAt: deletedAt)) } + ); + + var book = cache.TryGetBook("book-1"); + Assert.That( + book, + Is.Not.Null, + "delta never removes a row; tombstoning is via deleted_at" + ); + Assert.That(book.DeletedAt, Is.Not.Null); + } + + [Test] + public void ApplyDelta_ReportsWhetherAnythingChanged() + { + // OnPolledChanges relies on this return value to skip the full-cache Save + index + // rebuild on idle polls (E3), so pin the contract: true iff a book row was upserted + // OR the cursor advanced; false for a poll that touched neither. + var cache = new CloudRepoCache(_collectionFolderPath); + cache.ApplyFullSnapshot( + new JObject + { + ["books"] = new JArray(MakeBookRow("book-1", versionSeq: 1)), + ["max_event_id"] = 10, + } + ); + + Assert.That( + cache.ApplyDelta(new JObject { ["books"] = new JArray(), ["max_event_id"] = 10 }), + Is.False, + "idle poll: no book rows and the cursor did not advance -> nothing changed" + ); + Assert.That( + cache.ApplyDelta(new JObject { ["books"] = new JArray(), ["max_event_id"] = 11 }), + Is.True, + "cursor advanced -> changed, even with no book rows" + ); + Assert.That( + cache.ApplyDelta( + new JObject + { + ["books"] = new JArray(MakeBookRow("book-1", versionSeq: 2)), + ["max_event_id"] = 11, + } + ), + Is.True, + "a book row was upserted -> changed, even though the cursor held at 11" + ); + } + + // ------------------------------------------------------------------ + // Cursor monotonicity + // ------------------------------------------------------------------ + + [Test] + public void Cursor_NeverGoesBackward() + { + var cache = new CloudRepoCache(_collectionFolderPath); + cache.ApplyDelta(new JObject { ["max_event_id"] = 20 }); + Assert.That(cache.LastSeenEventId, Is.EqualTo(20), "sanity check"); + + cache.ApplyDelta(new JObject { ["max_event_id"] = 5 }); // stale/out-of-order response + + Assert.That( + cache.LastSeenEventId, + Is.EqualTo(20), + "a smaller cursor value must be ignored" + ); + } + + [Test] + public void Cursor_NullMaxEventId_LeavesCursorUnchanged() + { + var cache = new CloudRepoCache(_collectionFolderPath); + cache.ApplyDelta(new JObject { ["max_event_id"] = 7 }); + + cache.ApplyDelta(new JObject { ["max_event_id"] = null }); // e.g. get_changes with no new events + + Assert.That(cache.LastSeenEventId, Is.EqualTo(7)); + } + + // ------------------------------------------------------------------ + // Write-through + // ------------------------------------------------------------------ + + [Test] + public void RecordCheckoutResult_UpdatesLockFieldsRegardlessOfSuccess() + { + var cache = new CloudRepoCache(_collectionFolderPath); + cache.ApplyFullSnapshot(new JObject { ["books"] = new JArray(MakeBookRow("book-1")) }); + + cache.RecordCheckoutResult( + "book-1", + new JObject + { + ["success"] = false, + ["locked_by"] = "other@example.com", + ["locked_by_machine"] = "OtherMachine", + ["locked_at"] = DateTime.UtcNow, + } + ); + + var book = cache.TryGetBook("book-1"); + Assert.That( + book.LockedBy, + Is.EqualTo("other@example.com"), + "even a failed checkout tells us who holds the lock" + ); + Assert.That(book.LockedByMachine, Is.EqualTo("OtherMachine")); + } + + [Test] + public void RecordUnlock_ClearsLockFields() + { + var cache = new CloudRepoCache(_collectionFolderPath); + cache.ApplyFullSnapshot( + new JObject + { + ["books"] = new JArray( + MakeBookRow( + "book-1", + lockedBy: "me@example.com", + lockedByMachine: "MyMachine" + ) + ), + } + ); + Assert.That( + cache.TryGetBook("book-1").LockedBy, + Is.EqualTo("me@example.com"), + "sanity check" + ); + + cache.RecordUnlock("book-1"); + + var book = cache.TryGetBook("book-1"); + Assert.That(book.LockedBy, Is.Null); + Assert.That(book.LockedByMachine, Is.Null); + } + + [Test] + public void RecordCheckinFinish_NewBook_AddsRowAndStoresManifest() + { + var cache = new CloudRepoCache(_collectionFolderPath); + var manifest = new BookVersionManifest( + new Dictionary + { + ["book.htm"] = new BookVersionManifestEntry("sha", 1, "v1"), + } + ); + + cache.RecordCheckinFinish( + "new-book-id", + "inst-9", + "New Book", + "version-9", + 1, + "checksum-9", + manifest, + keptCheckedOut: false, + lockedByEmail: null, + lockedByMachine: null + ); + + var book = cache.TryGetBook("new-book-id"); + Assert.That( + book, + Is.Not.Null, + "checkin-finish for a bookId:null Send must create the row" + ); + Assert.That(book.InstanceId, Is.EqualTo("inst-9")); + Assert.That(book.CurrentVersionSeq, Is.EqualTo(1)); + Assert.That(book.Manifest.Entries.Keys, Has.Member("book.htm")); + Assert.That(book.LockedBy, Is.Null, "keptCheckedOut=false must release the lock"); + } + + [Test] + public void RecordCheckinFinish_KeepCheckedOut_KeepsLock() + { + var cache = new CloudRepoCache(_collectionFolderPath); + + cache.RecordCheckinFinish( + "book-1", + "inst-1", + "Book", + "version-1", + 1, + "checksum-1", + new BookVersionManifest(), + keptCheckedOut: true, + lockedByEmail: "me@example.com", + lockedByMachine: "MyMachine" + ); + + var book = cache.TryGetBook("book-1"); + Assert.That(book.LockedBy, Is.EqualTo("me@example.com")); + Assert.That(book.LockedByMachine, Is.EqualTo("MyMachine")); + } + + // ------------------------------------------------------------------ + // Snapshot persistence round-trip + // ------------------------------------------------------------------ + + [Test] + public void SaveThenLoadOrCreate_RoundTripsBooksGroupsAndCursor() + { + var cache = new CloudRepoCache(_collectionFolderPath); + var manifest = new BookVersionManifest( + new Dictionary + { + ["book.htm"] = new BookVersionManifestEntry("sha-1", 100, "v-1"), + } + ); + cache.ApplyFullSnapshot( + new JObject + { + ["books"] = new JArray(MakeBookRow("book-1", versionSeq: 3)), + ["groups"] = new JArray( + new JObject + { + ["group_key"] = "other", + ["version"] = 2, + ["updated_at"] = DateTime.UtcNow, + } + ), + ["max_event_id"] = 99, + } + ); + cache.RecordManifest("book-1", manifest); + + cache.Save(); + Assert.That( + File.Exists(cache.SnapshotPath), + Is.True, + "sanity check: Save actually wrote the file" + ); + + var reloaded = CloudRepoCache.LoadOrCreate(_collectionFolderPath); + + Assert.That(reloaded.LastSeenEventId, Is.EqualTo(99)); + var book = reloaded.TryGetBook("book-1"); + Assert.That(book, Is.Not.Null); + Assert.That(book.CurrentVersionSeq, Is.EqualTo(3)); + Assert.That(book.Manifest, Is.Not.Null); + Assert.That(book.Manifest.Entries["book.htm"].S3VersionId, Is.EqualTo("v-1")); + Assert.That(reloaded.TryGetGroup("other").Version, Is.EqualTo(2)); + } + + [Test] + public void LoadOrCreate_NoSnapshotFile_ReturnsEmptyCache() + { + var cache = CloudRepoCache.LoadOrCreate(_collectionFolderPath); + + Assert.That(cache.LastSeenEventId, Is.EqualTo(0)); + Assert.That(cache.GetAllBooks(), Is.Empty); + } + + [Test] + public void LoadOrCreate_CorruptSnapshotFile_ReturnsEmptyCacheRatherThanThrowing() + { + Directory.CreateDirectory(_collectionFolderPath); + File.WriteAllText( + Path.Combine(_collectionFolderPath, CloudRepoCache.SnapshotFileName), + "{ not valid json" + ); + + CloudRepoCache cache = null; + Assert.DoesNotThrow(() => cache = CloudRepoCache.LoadOrCreate(_collectionFolderPath)); + Assert.That(cache.GetAllBooks(), Is.Empty); + } + + [Test] + public void SnapshotFileName_IsNotMatchedByRootLevelCollectionFileWhitelist() + { + // TeamCollection.RootLevelCollectionFilesIn only picks up specific named files + // (the .bloomCollection file, customCollectionStyles.css, configuration.txt, and + // ReaderTools*.json). The cache file must not accidentally match that last glob. + Assert.That( + CloudRepoCache.SnapshotFileName, + Does.Not.StartWith("ReaderTools"), + "must not be swept up as a collection-settings file to sync to the repo" + ); + } + + // ------------------------------------------------------------------ + // Concurrency + // ------------------------------------------------------------------ + + [Test] + public void ConcurrentReadsAndWrites_DoNotCorruptOrThrow() + { + var cache = new CloudRepoCache(_collectionFolderPath); + const int bookCount = 20; + const int iterationsPerBook = 25; + + var writers = Enumerable + .Range(0, bookCount) + .Select(i => + Task.Run(() => + { + var bookId = "book-" + i; + for (var iteration = 0; iteration < iterationsPerBook; iteration++) + { + cache.ApplyDelta( + new JObject + { + ["books"] = new JArray( + MakeBookRow(bookId, versionSeq: iteration) + ), + ["max_event_id"] = i * iterationsPerBook + iteration, + } + ); + cache.RecordCheckoutResult( + bookId, + new JObject + { + ["locked_by"] = "user@example.com", + ["locked_by_machine"] = "M", + ["locked_at"] = DateTime.UtcNow, + } + ); + cache.RecordUnlock(bookId); + } + }) + ) + .ToArray(); + + var readers = Enumerable + .Range(0, 10) + .Select(_ => + Task.Run(() => + { + for (var iteration = 0; iteration < iterationsPerBook; iteration++) + { + // Must never throw (e.g. a torn read of the dictionary) and must never + // return a partially-constructed row. + foreach (var book in cache.GetAllBooks()) + { + Assert.That(book.Id, Is.Not.Null); + } + var _ = cache.LastSeenEventId; + } + }) + ) + .ToArray(); + + Assert.DoesNotThrow(() => Task.WaitAll(writers.Concat(readers).ToArray())); + + Assert.That( + cache.GetAllBooks(), + Has.Count.EqualTo(bookCount), + "every book's writer thread must have registered its row exactly once" + ); + foreach (var book in cache.GetAllBooks()) + { + Assert.That( + book.CurrentVersionSeq, + Is.EqualTo(iterationsPerBook - 1), + "last write for each book should win" + ); + Assert.That(book.LockedBy, Is.Null, "each writer's loop ends with an unlock"); + } + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudSyncAtStartupTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudSyncAtStartupTests.cs new file mode 100644 index 000000000000..e1051273c819 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudSyncAtStartupTests.cs @@ -0,0 +1,565 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using Bloom.Book; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using BloomTests.DataBuilders; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using RestSharp; +using SIL.IO; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// Exercises the (unchanged, shared) base-class SyncAtStartup logic against a CloudTeamCollection + /// with a scripted server, per the task 05 acceptance criteria ("CloudSyncAtStartupTests (ported + /// matrix; asserts .bloomSource + incident events)"). Scope note: this ports the single highest- + /// value scenario from the folder-backend SyncAtStartupTests suite -- "checked out and modified + /// locally, but also modified remotely" (that suite's "Update content and status and warn" case) + /// -- which is exactly the case that drives CloudTeamCollection's unified-recovery path + /// (PutBookInRepo's inLostAndFound branch). Porting the full ~15-case matrix from + /// SyncAtStartupTests.cs would need a fuller cloud server fake (id-conflict scans, rename + /// detection via GetRepoBookFile, etc.) -- left as follow-up work; see the task 05 final report. + /// + [TestFixture] + public class CloudSyncAtStartupTests + { + private const string kCollectionId = "11111111-1111-1111-1111-111111111111"; + private const string kBookId = "book-id-1"; + private CloudTestHarness _harness; + private TemporaryFolder _collectionFolder; + private Mock _mockTcManager; + private CloudTeamCollection _collection; + private FakeRestExecutor _executor; + private string _bookInstanceId; + + [SetUp] + public void Setup() + { + // The scripted manifests carry one real file entry (empty manifests are now rejected by + // FetchAndCacheManifest's fail-fast guard -- no real book has zero files), so the S3 + // mock serves fixed bytes matching that entry's sha256. + _harness = CloudTestHarness.Create( + "CloudSyncAtStartupTests", + kCollectionId, + s3Factory: _ => MakeScriptedS3().Object + ); + _collectionFolder = _harness.CollectionFolder; + _mockTcManager = _harness.MockTcManager; + _collection = _harness.Collection; + _executor = _harness.Executor; + } + + /// The fixed content the scripted S3 serves for every key; the scripted + /// get_book_manifest entries carry its sha256 so pinned-download verification passes. + internal const string kRemoteFileContent = "remote content from scripted S3"; + + private static Mock MakeScriptedS3() + { + var mock = new Mock(); + mock.Setup(x => + x.GetObjectAsync( + It.IsAny(), + It.IsAny() + ) + ) + .Returns( + (req, ct) => + System.Threading.Tasks.Task.FromResult( + new Amazon.S3.Model.GetObjectResponse + { + ResponseStream = new MemoryStream( + System.Text.Encoding.UTF8.GetBytes(kRemoteFileContent) + ), + HttpStatusCode = HttpStatusCode.OK, + VersionId = req.VersionId, + } + ) + ); + return mock; + } + + [TearDown] + public void TearDown() + { + _harness.Dispose(); + } + + private IRestResponse HandleServerRequest(IRestRequest req) + { + switch (req.Resource) + { + case "rest/v1/rpc/get_collection_state": + { + var body = new JObject + { + ["books"] = new JArray( + new JObject + { + ["id"] = kBookId, + ["instance_id"] = _bookInstanceId, + ["name"] = "Conflict book", + ["current_version_id"] = "v2", + ["current_version_seq"] = 2, + ["current_checksum"] = "server-side-checksum-after-remote-edit", + ["locked_by"] = null, + ["locked_by_machine"] = null, + ["locked_at"] = null, + ["deleted_at"] = null, + } + ), + ["groups"] = new JArray(), + ["max_event_id"] = 5, + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + } + case "rest/v1/rpc/get_book_manifest": + { + var body = new JObject + { + ["bookId"] = kBookId, + ["versionId"] = "v2", + ["seq"] = 2, + ["checksum"] = "server-side-checksum-after-remote-edit", + ["files"] = new JArray( + new JObject + { + ["path"] = "New remote book.htm", + ["sha256"] = + "3baef9a5f9108b41fff904fdcd328c6cb666712e9779d168ac2f7ffbdfc32372", + ["size"] = 31, + ["s3VersionId"] = "sv1", + } + ), + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + } + case "functions/v1/download-start": + { + var body = new JObject + { + ["s3"] = new JObject + { + ["bucket"] = "test-bucket", + ["region"] = "us-east-1", + ["prefix"] = "tc/x/", + ["credentials"] = new JObject + { + ["accessKeyId"] = "a", + ["secretAccessKey"] = "b", + ["sessionToken"] = "c", + }, + }, + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + } + case "rest/v1/rpc/log_event": + return FakeResponses.Make(HttpStatusCode.OK, "{}"); + default: + return FakeResponses.Make(HttpStatusCode.OK, "{}"); + } + } + + [Test] + public void SyncAtStartup_LocalEditConflictsWithRemoteChange_PreservesLocallyAndReceivesLatest() + { + // Arrange a book checked out and edited locally, whose repo checksum has ALSO changed + // (someone else's Send landed while we were offline) -- SyncAtStartup's "book changed + // remotely AND edited locally" case, which drives PutBook(..., inLostAndFound: true). + var folderPath = new BookFolderBuilder() + .WithRootFolder(_collectionFolder.FolderPath) + .WithTitle("Conflict book") + .WithHtm("original content") + .Build() + .BuiltBookFolderPath; + _bookInstanceId = BookMetaData.FromFolder(folderPath).Id; + + var recordedChecksum = Bloom.TeamCollection.TeamCollection.MakeChecksum(folderPath); + var localStatus = new BookStatus() + .WithLockedBy("test@somewhere.org") + .WithChecksum(recordedChecksum); + _collection.WriteLocalStatus("Conflict book", localStatus); + + // Simulate a local edit made after that status was recorded: the actual on-disk content + // (and therefore its checksum) now differs from localStatus.checksum. + RobustFile.WriteAllText( + Path.Combine(folderPath, "Conflict book.htm"), + "edited locally, not yet checked in" + ); + + _executor.Handler = HandleServerRequest; + var progressSpy = new ProgressSpy(); + + // Act + _collection.SyncAtStartup(progressSpy, firstTimeJoin: false); + + // Assert: the local edit was preserved as a .bloomSource file in a local Lost and Found... + var lostAndFoundDir = Path.Combine(_collectionFolder.FolderPath, "Lost and Found"); + Assert.That( + Directory.Exists(lostAndFoundDir), + Is.True, + "Lost and Found folder should have been created" + ); + var preserved = Directory.EnumerateFiles(lostAndFoundDir, "*.bloomSource").ToList(); + Assert.That(preserved, Is.Not.Empty, "a .bloomSource file should have been saved"); + + // ...an incident was posted to the server (log_event, type 100/WorkPreservedLocally)... + var logEventRequest = FindLogEventRequest(); + Assert.That(logEventRequest, Is.Not.Null, "log_event RPC should have been called"); + + // ...and a message was logged locally for the user to see. + Assert.That( + _collection.MessageLog.Messages, + Has.Some.Matches(m => + m.L10NId == "TeamCollection.Cloud.WorkPreservedLocally" + && m.Param0 == "Conflict book" + ) + ); + } + + /// An S3 mock that serves different bytes per requested key, unlike + /// 's fixed content -- needed by the rename tests, whose + /// GetRepoBooksByIdMap pass downloads the repo book's real meta.json (it must parse and + /// carry the right instance id). + private static Mock MakeScriptedS3PerKey( + Func bytesForKey + ) + { + var mock = new Mock(); + mock.Setup(x => + x.GetObjectAsync( + It.IsAny(), + It.IsAny() + ) + ) + .Returns( + (req, ct) => + System.Threading.Tasks.Task.FromResult( + new Amazon.S3.Model.GetObjectResponse + { + ResponseStream = new MemoryStream(bytesForKey(req.Key)), + HttpStatusCode = HttpStatusCode.OK, + VersionId = req.VersionId, + } + ) + ); + return mock; + } + + /// Builds a second CloudTeamCollection over the same local folder whose S3 mock + /// serves per-key content (see ). Reassigns _executor so + /// the test scripts this instance's server. + private CloudTeamCollection MakeCollectionWithPerKeyS3(Func bytesForKey) + { + var environment = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_ANON_KEY" ? "test-anon-key" : null + ); + var auth = new CloudAuth(new StubCloudAuthProvider(), new InMemoryCloudTokenStore()); + auth.SignIn("test@somewhere.org", "irrelevant"); + var client = new CloudCollectionClient(environment, auth); + _executor = new FakeRestExecutor(); + client.SetRestClientForTests(_executor); + return new CloudTeamCollection( + _mockTcManager.Object, + _collectionFolder.FolderPath, + kCollectionId, + environment: environment, + auth: auth, + client: client, + transfer: new CloudBookTransfer(_ => MakeScriptedS3PerKey(bytesForKey).Object) + ); + } + + /// Scripts get_collection_state/get_book_manifest/download-start for ONE repo + /// book whose manifest exactly matches the given local folder's content (so an incremental + /// Receive of it downloads nothing except what a test's S3 mock serves). + private void ScriptSingleBookServer( + string repoBookName, + string instanceId, + string folderPathForManifest, + bool lockedByCurrentUserHere + ) + { + var manifest = BookVersionManifest.FromLocalFolder(folderPathForManifest); + // A LOCAL manifest has no s3VersionId (files were never committed), but the server's + // version manifest always carries one and the pinned-download path fails fast without + // it -- so stamp one per file. + var manifestFiles = new JArray( + manifest.Entries.Select(kvp => + (JToken) + new JObject + { + ["path"] = kvp.Key, + ["sha256"] = kvp.Value.Sha256, + ["size"] = kvp.Value.Size, + ["s3VersionId"] = "sv-" + kvp.Key, + } + ) + ); + var stateBody = new JObject + { + ["books"] = new JArray( + new JObject + { + ["id"] = kBookId, + ["instance_id"] = instanceId, + ["name"] = repoBookName, + ["current_version_id"] = "v1", + ["current_version_seq"] = 1, + ["current_checksum"] = "cs1", + // StubCloudAuthProvider signs every session in as user id "user-1", which + // ResolveLockedByForDisplay maps back to the signed-in email -- so this + // row reads as "checked out by the current user on this machine". + ["locked_by"] = lockedByCurrentUserHere ? "user-1" : null, + ["locked_by_machine"] = lockedByCurrentUserHere + ? TeamCollectionManager.CurrentMachine + : null, + ["locked_at"] = lockedByCurrentUserHere ? "2026-07-15T00:00:00Z" : null, + ["deleted_at"] = null, + } + ), + ["groups"] = new JArray(), + ["max_event_id"] = 5, + }; + var manifestBody = new JObject + { + ["bookId"] = kBookId, + ["versionId"] = "v1", + ["seq"] = 1, + ["checksum"] = "cs1", + ["files"] = manifestFiles, + }; + var downloadStartBody = new JObject + { + ["s3"] = new JObject + { + ["bucket"] = "test-bucket", + ["region"] = "us-east-1", + ["prefix"] = "tc/x/", + ["credentials"] = new JObject + { + ["accessKeyId"] = "a", + ["secretAccessKey"] = "b", + ["sessionToken"] = "c", + }, + }, + }; + _executor.Handler = req => + { + switch (req.Resource) + { + case "rest/v1/rpc/get_collection_state": + return FakeResponses.Make(HttpStatusCode.OK, stateBody.ToString()); + case "rest/v1/rpc/get_book_manifest": + return FakeResponses.Make(HttpStatusCode.OK, manifestBody.ToString()); + case "functions/v1/download-start": + return FakeResponses.Make(HttpStatusCode.OK, downloadStartBody.ToString()); + default: + return FakeResponses.Make(HttpStatusCode.OK, "{}"); + } + }; + } + + // Regression for bug B (14 Jul 2026): a teammate's rename must be applied by renaming the + // local folder IN PLACE -- not leaving the old-name folder in "newer version available" + // limbo while a later pass downloads the new name as a duplicate with the same instance id. + [Test] + public void SyncAtStartup_TeammateRenamedBook_RenamesLocalFolderInPlace() + { + var folderPath = new BookFolderBuilder() + .WithRootFolder(_collectionFolder.FolderPath) + .WithTitle("Old book name") + .WithHtm("same content both sides") + .Build() + .BuiltBookFolderPath; + var instanceId = BookMetaData.FromFolder(folderPath).Id; + // Capture the meta.json bytes NOW: the folder gets renamed mid-test, and the repo's + // meta.json must carry this instance id for the id-map rename detection to bind. + var metaBytes = RobustFile.ReadAllBytes(Path.Combine(folderPath, "meta.json")); + // Sanity: the scripted manifest is built from the local folder and MUST include + // meta.json (rename detection reads the repo book's meta.json via the manifest). + var localManifest = BookVersionManifest.FromLocalFolder(folderPath); + Assert.That( + localManifest.Entries.Keys, + Does.Contain("meta.json"), + "setup: manifest lacks meta.json; entries: " + + string.Join(", ", localManifest.Entries.Keys) + ); + + var collection = MakeCollectionWithPerKeyS3(key => + key.EndsWith("meta.json") + ? metaBytes + : System.Text.Encoding.UTF8.GetBytes(kRemoteFileContent) + ); + collection.TestOnly_MakeAutoApplyQueueSynchronous(); + // The repo has the SAME book (by instance id) under a NEW name, not locked; its + // manifest matches the local content exactly (a pure rename). + ScriptSingleBookServer( + "New book name", + instanceId, + folderPath, + lockedByCurrentUserHere: false + ); + + // Sanity: the local folder starts under the old name. + Assert.That(Directory.Exists(folderPath), Is.True); + // Sanity: rename detection depends on reading the REPO book's meta.json (for its + // instance id) through the scripted manifest + S3; prove that path works before + // blaming the rename logic itself. (Any call hydrates the cache first, as SyncAtStartup + // itself would.) + Assert.That(collection.IsBookPresentInRepo("New book name"), Is.True); + Assert.That( + collection.GetRepoBookFile("New book name", "meta.json"), + Does.Contain(instanceId), + "setup: the repo book's meta.json must be fetchable and carry the instance id" + ); + + collection.SyncAtStartup(new ProgressSpy(), firstTimeJoin: false); + + var newFolderPath = Path.Combine(_collectionFolder.FolderPath, "New book name"); + Assert.That( + Directory.Exists(newFolderPath), + Is.True, + "the local folder should have been renamed to the repo's new name" + ); + Assert.That( + Directory.Exists(folderPath), + Is.False, + "the old-name folder must be gone (a lingering copy is the duplicate-id bug)" + ); + var htmPath = Directory.EnumerateFiles(newFolderPath, "*.htm").Single(); + Assert.That( + RobustFile.ReadAllText(htmPath), + Does.Contain("same content both sides"), + "a pure rename must keep the local content" + ); + } + + // Regression for the guard on the bug B fix (15 Jul 2026 review): the local-rename-mid- + // checkin edge. When the CURRENT USER has the book checked out ON THIS MACHINE and renamed + // it locally (not yet checked in), the repo intentionally still shows the OLD name -- the + // rename-from-remote pass must NOT "correct" the local folder back to the repo name, which + // would clobber the checked-out work. Cloud checkouts never stamp the LOCAL status + // (TryLockInRepo is RPC + cache only), so this guard must read the REPO lock. + [Test] + public void SyncAtStartup_OwnRenameMidCheckin_DoesNotRevertRenameOrClobber() + { + var folderPath = new BookFolderBuilder() + .WithRootFolder(_collectionFolder.FolderPath) + .WithTitle("My renamed book") + .WithHtm("my precious checked-out edits") + .Build() + .BuiltBookFolderPath; + var instanceId = BookMetaData.FromFolder(folderPath).Id; + var metaBytes = RobustFile.ReadAllBytes(Path.Combine(folderPath, "meta.json")); + + var collection = MakeCollectionWithPerKeyS3(key => + key.EndsWith("meta.json") + ? metaBytes + : System.Text.Encoding.UTF8.GetBytes(kRemoteFileContent) + ); + // Synchronous queue: if anything wrongly routes the repo's old-name book to the + // background download path, it happens inline where the asserts below can see it. + collection.TestOnly_MakeAutoApplyQueueSynchronous(); + // The repo shows the OLD name, checked out to the current user on this machine -- + // exactly the state after "check out, retitle, restart Bloom before checking in". + ScriptSingleBookServer( + "My old book", + instanceId, + folderPath, + lockedByCurrentUserHere: true + ); + + collection.SyncAtStartup(new ProgressSpy(), firstTimeJoin: false); + + Assert.That( + Directory.Exists(folderPath), + Is.True, + "the checked-out, locally-renamed folder must be left alone" + ); + Assert.That( + Directory.Exists(Path.Combine(_collectionFolder.FolderPath, "My old book")), + Is.False, + "the repo's old name must not be resurrected as a second folder" + ); + var htmPath = Directory.EnumerateFiles(folderPath, "*.htm").Single(); + Assert.That( + RobustFile.ReadAllText(htmPath), + Does.Contain("my precious checked-out edits"), + "checked-out local edits must not be overwritten from the repo" + ); + } + + private JObject FindLogEventRequest() + { + // The FakeRestExecutor doesn't record request bodies against a specific resource by + // itself, so re-derive it from the recorded requests list. + var request = _executor.RequestsSeen.LastOrDefault(r => + r.Resource == "rest/v1/rpc/log_event" + ); + if (request == null) + return null; + var bodyParam = request.Parameters.Find(p => p.Type == ParameterType.RequestBody); + return bodyParam == null ? new JObject() : JObject.Parse((string)bodyParam.Value); + } + + [Test] + public void SyncAtStartup_NewBookOnlyInRepo_IsFetchedToLocal() + { + // A book that exists in the repo but not locally at all (the ordinary "Add me" case). + // + // Batch item 7 (progressive join) changed this scenario's WIRING: cloud SyncAtStartup + // now reroutes a repo-only book to the background auto-apply queue instead of fetching + // it synchronously inline (so a half-joined collection's next open stays fast). This + // test's ASSERTION ("the book ends up on disk") is deliberately kept unchanged -- + // TestOnly_MakeAutoApplyQueueSynchronous makes the queue's worker run inline instead of + // via a background Task.Run, so the download still completes, deterministically, before + // SyncAtStartup returns. See TeamCollectionAutoApplyTests for tests that exercise the + // genuinely-asynchronous, non-blocking behavior this rerouting exists for. + _collection.TestOnly_MakeAutoApplyQueueSynchronous(); + _bookInstanceId = Guid.NewGuid().ToString(); + _executor.Handler = req => + { + if (req.Resource == "rest/v1/rpc/get_collection_state") + { + var body = new JObject + { + ["books"] = new JArray( + new JObject + { + ["id"] = kBookId, + ["instance_id"] = _bookInstanceId, + ["name"] = "New remote book", + ["current_version_id"] = "v1", + ["current_version_seq"] = 1, + ["current_checksum"] = "cs1", + ["locked_by"] = null, + ["locked_by_machine"] = null, + ["locked_at"] = null, + ["deleted_at"] = null, + } + ), + ["groups"] = new JArray(), + ["max_event_id"] = 1, + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + } + return HandleServerRequest(req); + }; + + var progressSpy = new ProgressSpy(); + _collection.SyncAtStartup(progressSpy, firstTimeJoin: false); + + Assert.That( + Directory.Exists(Path.Combine(_collectionFolder.FolderPath, "New remote book")), + Is.True, + "the new remote book's folder should have been fetched locally" + ); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionLiveTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionLiveTests.cs new file mode 100644 index 000000000000..403f0f26b014 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionLiveTests.cs @@ -0,0 +1,240 @@ +using System; +using System.IO; +using System.Linq; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using BloomTests.DataBuilders; +using Moq; +using NUnit.Framework; +using SIL.IO; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// [Explicit] live-infrastructure tests against the real local dev stack (Supabase Postgres/ + /// PostgREST + edge functions + MinIO), per task 05's brief ("NUnit live tests against the stack + /// are strongly encouraged for the Send/Receive round trip; see CloudAuthTests' LiveDevProvider + /// test for the pattern"). Requires: + /// 1. `supabase start` (Postgres/PostgREST/GoTrue up). + /// 2. `supabase functions serve --env-file server/dev/functions.env` (edge functions up -- + /// NOT running by default; must be started separately, in the background). + /// 3. BLOOM_CLOUDTC_ANON_KEY (and friends) exported per server/dev/README.md, so + /// CloudEnvironment.FromEnvironment() picks up the real local stack's anon key. + /// Run manually: `dotnet test --filter FullyQualifiedName~CloudTeamCollectionLiveTests`. + /// + /// Uses a single dev account (alice) for both "sides" of the round trip -- two separate local + /// collection folders and two separate CloudTeamCollection instances against the SAME cloud + /// collection, simulating two Bloom instances on one machine (the manual test scenario in the + /// task file's Acceptance section). Using a second account (bob) would additionally need a + /// members_add call to approve bob for the freshly-created collection first; CONTRACTS.md's + /// exact `members` RPC names are a guess in this task (see the final report), so that's left as + /// a follow-up rather than guessed at here. + /// + [TestFixture] + public class CloudTeamCollectionLiveTests + { + [Test] + [Explicit( + "Requires the local Supabase dev stack running, INCLUDING `supabase functions serve --env-file server/dev/functions.env` (not started by `supabase start` alone)." + )] + public void LiveStack_SendThenReceive_RoundTripsBookContentAndLock() + { + var environment = CloudEnvironment.FromEnvironment(); + var senderAuth = new CloudAuth(new DevCloudAuthProvider(environment)); + senderAuth.SignIn("alice@dev.local", "BloomDev123!"); + var senderClient = new CloudCollectionClient(environment, senderAuth); + + var collectionId = Guid.NewGuid().ToString(); + var collectionName = "Live round trip " + collectionId.Substring(0, 8); + senderClient.CreateCollection(collectionId, collectionName); + + TeamCollectionManager.ForceCurrentUserForTests("alice@dev.local"); + var mockManager = new Mock(); + + using var senderFolder = new TemporaryFolder("CloudLive_Sender"); + var sender = new CloudTeamCollection( + mockManager.Object, + senderFolder.FolderPath, + collectionId, + environment: environment, + auth: senderAuth, + client: senderClient + ); + + var bookFolderPath = new BookFolderBuilder() + .WithRootFolder(senderFolder.FolderPath) + .WithTitle("Live book") + .WithHtm("hello from the live round-trip test") + .Build() + .BuiltBookFolderPath; + + // Act: Send (checked in, so the lock is released -- a teammate should see it immediately). + // The checkinComment must reach the SERVER's event log: cloud history is displayed + // from get_changes, not from the local history.db (found live 9 Jul 2026 -- the + // comment was silently dropped because PutBookInRepo never forwarded it). + const string checkinComment = "live-test checkin comment"; + var sentStatus = sender.PutBook( + bookFolderPath, + checkin: true, + checkinComment: checkinComment + ); + + Assert.That(sentStatus.checksum, Is.Not.Null.And.Not.Empty); + Assert.That( + sentStatus.lockedBy, + Is.Null.Or.Empty, + "checked-in Send should leave the book unlocked" + ); + + var checkinEvents = ( + (Newtonsoft.Json.Linq.JArray)senderClient.GetChanges(collectionId, 0)["events"] + ) + .OfType() + .Select(Bloom.web.controllers.SharingApi.ToBookHistoryEvent) + .Where(e => e.Type == Bloom.History.BookHistoryEventType.CheckIn) + .ToList(); + Assert.That( + checkinEvents, + Has.Count.EqualTo(1), + "the Send should have produced exactly one server-side check-in event" + ); + Assert.That( + checkinEvents[0].Message, + Is.EqualTo(checkinComment), + "the user's checkin comment must appear in the server-side history event, " + + "since that is what the cloud history UI displays" + ); + + // Act: Receive, from a second local folder / CloudTeamCollection instance (simulating a + // second machine, same account -- see class doc for why not a second account). + using var receiverFolder = new TemporaryFolder("CloudLive_Receiver"); + var receiverAuth = new CloudAuth(new DevCloudAuthProvider(environment)); + receiverAuth.SignIn("alice@dev.local", "BloomDev123!"); + var receiverClient = new CloudCollectionClient(environment, receiverAuth); + var receiver = new CloudTeamCollection( + mockManager.Object, + receiverFolder.FolderPath, + collectionId, + environment: environment, + auth: receiverAuth, + client: receiverClient + ); + + receiver.CopyAllBooksFromRepoToLocalFolder(receiverFolder.FolderPath); + + var receivedHtmlPath = Path.Combine( + receiverFolder.FolderPath, + "Live book", + "Live book.htm" + ); + Assert.That( + RobustFile.Exists(receivedHtmlPath), + Is.True, + "the Received book folder should contain the .htm file" + ); + Assert.That( + RobustFile.ReadAllText(receivedHtmlPath), + Does.Contain("hello from the live round-trip test") + ); + Assert.That( + receiver.GetChecksum("Live book"), + Is.EqualTo(sentStatus.checksum), + "Received checksum should match what was Sent" + ); + + // Act + Assert: lock round trip -- checkout on the receiver side should be visible from + // the sender side after a fresh hydrate. + var checkedOut = receiver.AttemptLock("Live book"); + Assert.That(checkedOut, Is.True); + sender.HydrateFromServer(); + Assert.That(sender.WhoHasBookLocked("Live book"), Is.EqualTo("alice@dev.local")); + + receiver.UnlockBook("Live book"); + } + + // Replicates the first two-instance smoke test's check-in failure (7 Jul 2026): an + // UPDATE Send of an existing book, performed by a FRESH CloudTeamCollection whose cache + // was hydrated from the server (so cached books have no per-file Manifest) -- i.e. + // exactly what happens when a user reopens a cloud collection and checks in an edit. + // The original round-trip test above only exercises the first-ever Send of a new book. + [Test] + [Explicit( + "Requires the local Supabase dev stack running, INCLUDING `supabase functions serve --env-file server/dev/functions.env` (not started by `supabase start` alone)." + )] + public void LiveStack_UpdateSendAfterFreshHydrate_Succeeds() + { + var environment = CloudEnvironment.FromEnvironment(); + var auth = new CloudAuth(new DevCloudAuthProvider(environment)); + auth.SignIn("alice@dev.local", "BloomDev123!"); + var client = new CloudCollectionClient(environment, auth); + + var collectionId = Guid.NewGuid().ToString(); + client.CreateCollection( + collectionId, + "Live update send " + collectionId.Substring(0, 8) + ); + + TeamCollectionManager.ForceCurrentUserForTests("alice@dev.local"); + var mockManager = new Mock(); + + using var folder = new TemporaryFolder("CloudLive_UpdateSend"); + var firstSession = new CloudTeamCollection( + mockManager.Object, + folder.FolderPath, + collectionId, + environment: environment, + auth: auth, + client: client + ); + var bookFolderPath = new BookFolderBuilder() + .WithRootFolder(folder.FolderPath) + .WithTitle("Update book") + .WithHtm("version one") + .Build() + .BuiltBookFolderPath; + firstSession.PutBook(bookFolderPath, checkin: true); + + // A brand-new instance over the same local folder: rebuilds its cache from the + // server (books have version rows but NO per-file manifests), like reopening Bloom. + var secondSession = new CloudTeamCollection( + mockManager.Object, + folder.FolderPath, + collectionId, + environment: environment, + auth: auth, + client: client + ); + secondSession.HydrateFromServer(); + + Assert.That(secondSession.AttemptLock("Update book"), Is.True, "sanity: checkout"); + Assert.That( + secondSession.OkToCheckIn("Update book"), + Is.True, + "OkToCheckIn must recognize the signed-in account's own checkout " + + "(it compared against the registration email in the smoke-test failure)" + ); + var htmPath = Path.Combine(bookFolderPath, "Update book.htm"); + RobustFile.WriteAllText(htmPath, "version two"); + + var status = secondSession.PutBook(bookFolderPath, checkin: true); + + Assert.That(status.lockedBy, Is.Null.Or.Empty, "check-in should release the lock"); + var manifest = secondSession.Client.GetBookManifest( + secondSession.GetBookIdByNameIndexForTests("Update book") + ); + Assert.That((long)manifest["seq"], Is.EqualTo(2), "the update should be version 2"); + // The manifest must contain the FULL file list, not just what changed: the + // send-only-changed-paths client bug committed a version whose manifest was + // EMPTY (nothing had changed vs the stale local diff) in the smoke test, and + // this assertion is what would have caught it. + var files = (Newtonsoft.Json.Linq.JArray)manifest["files"]; + Assert.That( + files.Count, + Is.GreaterThanOrEqualTo(2), + "v2's manifest must carry the unchanged files too, not just the edited one" + ); + Assert.That(files.Select(f => (string)f["path"]), Does.Contain("Update book.htm")); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionLockTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionLockTests.cs new file mode 100644 index 000000000000..c456b7405c33 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionLockTests.cs @@ -0,0 +1,176 @@ +using System.Net; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// Tests for CloudTeamCollection's lock overrides (TryLockInRepo/UnlockInRepo, exercised via + /// the base class's public AttemptLock/UnlockBook/ForceUnlock), verifying they dispatch to + /// checkout_book/unlock_book/force_unlock and write-through the result into the cache + /// immediately (so a second call in the same session sees the update without a network call). + /// + [TestFixture] + public class CloudTeamCollectionLockTests + { + private const string kCollectionId = "11111111-1111-1111-1111-111111111111"; + private const string kBookId = "book-id-1"; + private CloudTestHarness _harness; + private CloudTeamCollection _collection; + private FakeRestExecutor _executor; + + [SetUp] + public void Setup() + { + _harness = CloudTestHarness.Create("CloudTeamCollectionLockTests", kCollectionId); + _collection = _harness.Collection; + _executor = _harness.Executor; + + // Hydrate the cache with one committed, unlocked book so the name/id index knows it. + _executor.Handler = req => + { + var body = new JObject + { + ["books"] = new JArray( + new JObject + { + ["id"] = kBookId, + ["instance_id"] = "instance-1", + ["name"] = "My book", + ["current_version_id"] = "v1", + ["current_version_seq"] = 1, + ["current_checksum"] = "cs1", + ["locked_by"] = null, + ["locked_by_machine"] = null, + ["locked_at"] = null, + ["deleted_at"] = null, + } + ), + ["groups"] = new JArray(), + ["max_event_id"] = 1, + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + }; + _collection.IsBookPresentInRepo("My book"); // forces hydration + } + + [TearDown] + public void TearDown() + { + _harness.Dispose(); + } + + [Test] + public void AttemptLock_ServerGrants_ReturnsTrueAndUpdatesStatus() + { + _executor.Handler = req => + { + Assert.That(req.Resource, Is.EqualTo("rest/v1/rpc/checkout_book")); + var body = new JObject + { + ["success"] = true, + ["locked_by"] = "test@somewhere.org", + ["locked_by_machine"] = TeamCollectionManager.CurrentMachine, + ["locked_at"] = System.DateTime.UtcNow.ToString("o"), + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + }; + + var result = _collection.AttemptLock("My book"); + + Assert.That(result, Is.True); + Assert.That(_collection.WhoHasBookLocked("My book"), Is.EqualTo("test@somewhere.org")); + } + + /// + /// TryLockInRepo itself correctly reports the server's denial (see + /// CloudTeamCollectionMemberTests and TryLockInRepo's own logic), but note what this test + /// does NOT assert: TeamCollection.AttemptLock (base class, unchanged) discards + /// TryLockInRepo's bool return value and returns based on the LOCAL status object it + /// optimistically set to "locked by me" before calling TryLockInRepo -- it never re-reads + /// status afterward. So AttemptLock's own return value is only reliable when the race was + /// already visible in cache/repo BEFORE this call (the normal case for FolderTeamCollection, + /// whose TryLockInRepo can't fail); for a genuinely-raced cloud lock like this one, + /// AttemptLock can return true even though the server denied it. The base class's own doc + /// comment on TryLockInRepo anticipates this ("the caller should re-read status to find out + /// who won") -- WhoHasBookLocked below is that re-read, and IS reliable. Flagged in the task + /// 05 final report as a base-class behavior worth revisiting (AttemptLock could use + /// TryLockInRepo's return value instead of discarding it), not fixed here since + /// TeamCollection.cs is read-only for this task. + /// + [Test] + public void AttemptLock_ServerDenies_CacheAndWhoHasBookLockedReflectTheActualWinner() + { + _executor.Handler = req => + { + var body = new JObject + { + ["success"] = false, + ["locked_by"] = "someoneelse@somewhere.org", + ["locked_by_machine"] = "THEIR-MACHINE", + ["locked_at"] = System.DateTime.UtcNow.ToString("o"), + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + }; + + _collection.AttemptLock("My book"); + + Assert.That( + _collection.WhoHasBookLocked("My book"), + Is.EqualTo("someoneelse@somewhere.org") + ); + } + + [Test] + public void UnlockBook_CallsUnlockRpcAndClearsLock() + { + // First grant a lock so there's something to release. + _executor.Handler = req => + FakeResponses.Make( + HttpStatusCode.OK, + new JObject + { + ["success"] = true, + ["locked_by"] = "test@somewhere.org", + ["locked_by_machine"] = TeamCollectionManager.CurrentMachine, + ["locked_at"] = System.DateTime.UtcNow.ToString("o"), + }.ToString() + ); + _collection.AttemptLock("My book"); + + var unlockCalled = false; + _executor.Handler = req => + { + if (req.Resource == "rest/v1/rpc/unlock_book") + unlockCalled = true; + return FakeResponses.Make(HttpStatusCode.OK, "{}"); + }; + + _collection.UnlockBook("My book"); + + Assert.That(unlockCalled, Is.True); + Assert.That(_collection.WhoHasBookLocked("My book"), Is.Null.Or.Empty); + } + + [Test] + public void ForceUnlock_CallsServerForceUnlock() + { + var forceUnlockCalled = false; + _executor.Handler = req => + { + if (req.Resource == "rest/v1/rpc/force_unlock") + forceUnlockCalled = true; + return FakeResponses.Make(HttpStatusCode.OK, "{}"); + }; + + _collection.ForceUnlock("My book"); + + Assert.That(forceUnlockCalled, Is.True); + Assert.That(_collection.WhoHasBookLocked("My book"), Is.Null.Or.Empty); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionMemberTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionMemberTests.cs new file mode 100644 index 000000000000..ac62b1911bbb --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionMemberTests.cs @@ -0,0 +1,563 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using RestSharp; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// Tests for CloudTeamCollection's simple identity/capability members and its cache-backed + /// "member" reads (status/presence/list), using the FakeRestExecutor/StubCloudAuthProvider + /// pattern from CloudCollectionClientTests to script get_collection_state/my_collections + /// responses instead of a live server. + /// + [TestFixture] + public class CloudTeamCollectionMemberTests + { + private const string kCollectionId = "11111111-1111-1111-1111-111111111111"; + private CloudTestHarness _harness; + private TemporaryFolder _collectionFolder; + private Mock _mockTcManager; + private CloudTeamCollection _collection; + private FakeRestExecutor _executor; + private CloudAuth _auth; + + [SetUp] + public void Setup() + { + // These tests drive the member-listing RPCs against a signed-OUT auth, so signIn:false. + _harness = CloudTestHarness.Create( + "CloudTeamCollectionMemberTests", + kCollectionId, + signIn: false + ); + _collectionFolder = _harness.CollectionFolder; + _mockTcManager = _harness.MockTcManager; + _collection = _harness.Collection; + _executor = _harness.Executor; + _auth = _harness.Auth; + } + + [TearDown] + public void TearDown() + { + _harness.Dispose(); + } + + [Test] + public void GetBackendType_IsCloud() + { + Assert.That(_collection.GetBackendType(), Is.EqualTo("Cloud")); + } + + [Test] + public void RepoDescription_ContainsCollectionId() + { + Assert.That(_collection.RepoDescription, Does.Contain(kCollectionId)); + } + + [Test] + public void CapabilityFlags_AllTrueForCloud() + { + Assert.That(_collection.SupportsVersionHistory, Is.True); + Assert.That(_collection.SupportsSharingUi, Is.True); + Assert.That(_collection.RequiresSignIn, Is.True); + } + + private void ScriptCollectionState(params (string id, string name, bool deleted)[] books) + { + _executor.Handler = req => + { + Assert.That(req.Resource, Is.EqualTo("rest/v1/rpc/get_collection_state")); + var booksArray = new JArray( + books.Select(b => + (JToken) + new JObject + { + ["id"] = b.id, + ["instance_id"] = "instance-" + b.id, + ["name"] = b.name, + ["current_version_id"] = "v1", + ["current_version_seq"] = 1, + ["current_checksum"] = "checksum-" + b.id, + ["locked_by"] = null, + ["locked_by_machine"] = null, + ["locked_at"] = null, + ["deleted_at"] = b.deleted + ? (JToken)DateTime.UtcNow.ToString("o") + : null, + } + ) + ); + var body = new JObject + { + ["books"] = booksArray, + ["groups"] = new JArray(), + ["max_event_id"] = 1, + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + }; + } + + [Test] + public void GetBookList_ReturnsOnlyNonDeletedCommittedBooks() + { + ScriptCollectionState(("id-1", "Alive book", false), ("id-2", "Deleted book", true)); + + var list = _collection.GetBookList(); + + Assert.That(list, Contains.Item("Alive book")); + Assert.That(list, Does.Not.Contain("Deleted book")); + } + + [Test] + public void IsBookPresentInRepo_TrueForCommittedBook_FalseForUnknown() + { + ScriptCollectionState(("id-1", "Alive book", false)); + + Assert.That(_collection.IsBookPresentInRepo("Alive book"), Is.True); + Assert.That(_collection.IsBookPresentInRepo("No such book"), Is.False); + } + + [Test] + public void KnownToHaveBeenDeleted_TrueOnlyForTombstonedBook() + { + ScriptCollectionState(("id-1", "Alive book", false), ("id-2", "Deleted book", true)); + // Force hydration so the name index knows about both books. + _collection.IsBookPresentInRepo("Alive book"); + + Assert.That(_collection.KnownToHaveBeenDeleted("Deleted book"), Is.True); + Assert.That(_collection.KnownToHaveBeenDeleted("Alive book"), Is.False); + } + + [Test] + public void GetStatus_ForCommittedBook_ReflectsRepoChecksum() + { + ScriptCollectionState(("id-1", "Alive book", false)); + + var status = _collection.GetStatus("Alive book"); + + Assert.That(status.checksum, Is.EqualTo("checksum-id-1")); + Assert.That(status.collectionId, Is.EqualTo(kCollectionId)); + } + + [Test] + public void CheckConnection_NotSignedIn_ReturnsMessage() + { + var message = _collection.CheckConnection(); + + Assert.That(message, Is.Not.Null); + Assert.That(message.L10NId, Is.EqualTo("TeamCollection.Cloud.NotSignedIn")); + } + + [Test] + public void CheckConnection_SignedInButNotAMember_ReturnsRefusalMessage() + { + // Batch item 9 (account-switch behavior): non-membership is now a hard refusal + // (IsAccessRefusal), not just an ordinary Disconnected-mode message. The renamed + // L10NId ("NotAMemberRefusal") reflects that this message text changed shape (it now + // composes admin/last-known-user detail, see ComposeNotAMemberRefusalDetail's own + // tests below) -- there was no existing XLF entry for the old id to migrate. + _auth.SignIn("test@somewhere.org", "irrelevant"); + _executor.Handler = req => FakeResponses.Make(HttpStatusCode.OK, "[]"); // my_collections: empty + + var message = _collection.CheckConnection(); + + Assert.That(message, Is.Not.Null); + Assert.That(message.L10NId, Is.EqualTo("TeamCollection.Cloud.NotAMemberRefusal")); + Assert.That(message.IsAccessRefusal, Is.True); + Assert.That(message.Param0, Is.EqualTo("test@somewhere.org")); + } + + [Test] + public void CheckConnection_SignedInAndAMember_ReturnsNull() + { + _auth.SignIn("test@somewhere.org", "irrelevant"); + _executor.Handler = req => + FakeResponses.Make( + HttpStatusCode.OK, + new JArray( + new JObject { ["id"] = kCollectionId, ["name"] = "Some Collection" } + ).ToString() + ); + + var message = _collection.CheckConnection(); + + Assert.That(message, Is.Null); + } + + [Test] + public void CheckConnection_SignedInAndAMember_RecordsLastKnownUser() + { + // Batch item 9 (account-switch behavior): every successful membership confirmation + // records the current user as the last known local user of this collection, so a + // FUTURE non-member's refusal message can name them. + Assert.That( + TeamCollectionLastKnownUser.Read(_collectionFolder.FolderPath), + Is.Null, + "sanity check: nothing recorded before any successful connection check" + ); + + _auth.SignIn("test@somewhere.org", "irrelevant"); + _executor.Handler = req => + FakeResponses.Make( + HttpStatusCode.OK, + new JArray( + new JObject { ["id"] = kCollectionId, ["name"] = "Some Collection" } + ).ToString() + ); + + _collection.CheckConnection(); + + Assert.That( + TeamCollectionLastKnownUser.Read(_collectionFolder.FolderPath), + Is.EqualTo("test@somewhere.org") + ); + } + + [Test] + public void CheckConnection_SignedInAndAMember_ClaimsMembershipsOncePerSession() + { + // Post-batch defect fix (10 Jul 2026): my_collections matches by EMAIL + // (approved-or-claimed) but every data RPC's RLS gate matches by user_id, so an + // approved-but-never-claimed account (batch item 9's shared-computer reopen, which + // never runs the join flow that otherwise calls ClaimMemberships) passed + // CheckConnection's member check and then threw not_a_member on the very first + // sync's get_collection_state call -- inside TeamCollectionManager's constructor. + _auth.SignIn("test@somewhere.org", "irrelevant"); + var requestedResources = new List(); + _executor.Handler = req => + { + requestedResources.Add(req.Resource); + return FakeResponses.Make( + HttpStatusCode.OK, + req.Resource.EndsWith("claim_memberships") + ? "{}" + : new JArray( + new JObject { ["id"] = kCollectionId, ["name"] = "Some Collection" } + ).ToString() + ); + }; + + _collection.CheckConnection(); + _collection.CheckConnection(); + + Assert.That( + requestedResources.Count(r => r.EndsWith("claim_memberships")), + Is.EqualTo(1), + "claim_memberships should be called on the first successful member check and " + + "not repeated within the session (it is idempotent server-side)" + ); + } + + [Test] + public void CheckConnection_AccountSwitchMidSession_ClaimsAgainForTheNewAccount() + { + // Preflight review finding (10 Jul 2026): the claimed-flag must be per ACCOUNT, not + // per instance -- this CloudTeamCollection survives an in-session sign-out + + // sign-in as a different approved member (nothing resets it on an + // account switch), and skipping the second account's claim resurrects + // the not_a_member startup failure the claim call exists to prevent. + var requestedResources = new List(); + _executor.Handler = req => + { + requestedResources.Add(req.Resource); + return FakeResponses.Make( + HttpStatusCode.OK, + req.Resource.EndsWith("claim_memberships") + ? "{}" + : new JArray( + new JObject { ["id"] = kCollectionId, ["name"] = "Some Collection" } + ).ToString() + ); + }; + + _auth.SignIn("first@somewhere.org", "irrelevant"); + _collection.CheckConnection(); + _auth.SignOut(); + _auth.SignIn("second@somewhere.org", "irrelevant"); + _collection.CheckConnection(); + + Assert.That( + requestedResources.Count(r => r.EndsWith("claim_memberships")), + Is.EqualTo(2), + "each distinct signed-in account must claim its own memberships" + ); + } + + // Regression for the first two-instance smoke test (7 Jul 2026): poll-detected changes + // must be raised with the folder backend's repo FILE name (".bloom" suffix) — the base + // HandleModifiedFile starts with EndsWith(".bloom") and silently DISCARDS anything else, + // which left teammates' UIs permanently stale even though the cache had fresh data. + [Test] + public void PolledChanges_RaiseBookStateChange_WithBloomSuffixedFileName() + { + ScriptCollectionState(("book-1", "My Book", false)); + _collection.HydrateFromServer(); + + string raisedFileName = null; + _collection.BookRepoChange += (sender, args) => raisedFileName = args.BookFileName; + + // Script the next get_changes poll: same book, now at seq 2 (someone checked in). + // Answer anything else blandly instead of asserting: raising the change event queues + // a background auto-apply download (RemoteBookAutoApplyQueue.RunLoop), which may + // reach get_book_manifest on its own thread while this handler is still installed -- + // that side effect is not what this test pins, and a strict assert here made it + // flaky under full-suite timing. + _executor.Handler = req => + { + if (req.Resource != "rest/v1/rpc/get_changes") + return FakeResponses.Make(HttpStatusCode.OK, "{}"); + var body = new JObject + { + ["events"] = new JArray( + new JObject + { + ["id"] = 2, + ["type"] = 1, + ["book_id"] = "book-1", + } + ), + ["books"] = new JArray( + new JObject + { + ["id"] = "book-1", + ["instance_id"] = "instance-book-1", + ["name"] = "My Book", + ["current_version_id"] = "v2", + ["current_version_seq"] = 2, + ["current_checksum"] = "checksum-2", + ["locked_by"] = null, + ["locked_by_machine"] = null, + ["locked_at"] = null, + ["deleted_at"] = null, + } + ), + ["max_event_id"] = 2, + }; + return FakeResponses.Make(HttpStatusCode.OK, body.ToString()); + }; + + try + { + _collection.StartMonitoring(); + _collection.PollNow(); + } + finally + { + _collection.StopMonitoring(); + } + + Assert.That( + raisedFileName, + Is.EqualTo("My Book.bloom"), + "the event contract wants the repo file name incl. suffix; the bare name is ignored by HandleModifiedFile" + ); + } + + // Regression for the first two-instance smoke test (7 Jul 2026): the "other" + // collection-file group downloads into the COLLECTION ROOT, and its naive + // mirror-delete removed TeamCollectionLink.txt (never uploaded, by design), + // silently un-teaming the collection for the next Bloom session. + [Test] + public void FilesEligibleForDeleteExtras_OtherGroup_NeverTouchesUnsharedRootFiles() + { + using (var folder = new TemporaryFolder("DeleteExtrasPolicy")) + { + System.IO.File.WriteAllText( + folder.Combine("My Collection.bloomCollection"), + "" + ); + System.IO.File.WriteAllText(folder.Combine("customCollectionStyles.css"), "x"); + System.IO.File.WriteAllText(folder.Combine("TeamCollectionLink.txt"), "cloud://x"); + System.IO.File.WriteAllText(folder.Combine(".bloom-cloud-repo-cache.json"), "{}"); + System.IO.File.WriteAllText(folder.Combine("lastCollectionFileSyncData.txt"), "x"); + System.IO.File.WriteAllText(folder.Combine("log.txt"), "x"); + + // Server group contains only the .bloomCollection: the css was deleted on + // the server, so it (and ONLY it) is eligible for delete-extras. + var kept = new System.Collections.Generic.HashSet + { + "My Collection.bloomCollection", + }; + + var doomed = CloudTeamCollection + .FilesEligibleForDeleteExtras("other", folder.FolderPath, kept) + .Select(System.IO.Path.GetFileName) + .ToList(); + + Assert.That(doomed, Is.EquivalentTo(new[] { "customCollectionStyles.css" })); + } + } + + [Test] + public void FilesEligibleForDeleteExtras_DedicatedGroupFolder_MirrorsExactly() + { + using (var folder = new TemporaryFolder("DeleteExtrasAllowedWords")) + { + System.IO.File.WriteAllText(folder.Combine("words1.txt"), "x"); + System.IO.File.WriteAllText(folder.Combine("words2.txt"), "x"); + var kept = new System.Collections.Generic.HashSet { "words1.txt" }; + + var doomed = CloudTeamCollection + .FilesEligibleForDeleteExtras("allowed-words", folder.FolderPath, kept) + .Select(System.IO.Path.GetFileName) + .ToList(); + + // Allowed Words/Sample Texts folders belong wholly to their group: a file the + // server no longer has really should be removed locally. + Assert.That(doomed, Is.EquivalentTo(new[] { "words2.txt" })); + } + } + + // ------------------------------------------------------------------ + // Batch item 9 (account-switch behavior): ComposeNotAMemberRefusalDetail matrix. + // Pure function -- no fake server needed. + // ------------------------------------------------------------------ + + [Test] + public void ComposeNotAMemberRefusalDetail_BothKnown_NamesBothAdminsAndLastKnownUser() + { + var detail = CloudTeamCollection.ComposeNotAMemberRefusalDetail( + new[] { "admin1@example.com", "admin2@example.com" }, + "alice@example.com" + ); + + Assert.That(detail, Does.Contain("admin1@example.com")); + Assert.That(detail, Does.Contain("admin2@example.com")); + Assert.That(detail, Does.Contain("alice@example.com")); + } + + [Test] + public void ComposeNotAMemberRefusalDetail_OnlyAdminsKnown_NamesOnlyAdmins() + { + var detail = CloudTeamCollection.ComposeNotAMemberRefusalDetail( + new[] { "admin1@example.com" }, + null + ); + + Assert.That(detail, Does.Contain("admin1@example.com")); + } + + [Test] + public void ComposeNotAMemberRefusalDetail_OnlyLastKnownUserKnown_NamesThem() + { + var detail = CloudTeamCollection.ComposeNotAMemberRefusalDetail( + null, + "alice@example.com" + ); + + Assert.That(detail, Does.Contain("alice@example.com")); + } + + [Test] + public void ComposeNotAMemberRefusalDetail_NeitherKnown_StillProducesUsableSentence() + { + var detail = CloudTeamCollection.ComposeNotAMemberRefusalDetail(null, null); + + Assert.That(detail, Is.Not.Null.And.Not.Empty); + Assert.That(detail, Does.Contain("administrator")); + } + + [Test] + public void ComposeNotAMemberRefusalDetail_EmptyAdministratorsArray_TreatedAsUnknown() + { + // A legacy/empty Administrators array (not null, but no entries) should behave the + // same as "not known" rather than producing an empty "()" list in the message. + var detail = CloudTeamCollection.ComposeNotAMemberRefusalDetail( + new string[0], + "alice@example.com" + ); + + Assert.That(detail, Does.Contain("alice@example.com")); + Assert.That(detail, Does.Not.Contain("()")); + } + + [Test] + public void GetCollectionDownloadLocation_WithinExpiry_FetchesOnceAndReuses() + { + _executor.Handler = req => DownloadStartResponse(DateTime.UtcNow.AddHours(1)); + + var first = _collection.GetCollectionDownloadLocation(); + var second = _collection.GetCollectionDownloadLocation(); + + Assert.That(first.SessionToken, Is.EqualTo("c"), "sanity: credentials were parsed"); + Assert.That( + second, + Is.SameAs(first), + "collection-scoped download credentials should be reused, not re-fetched per download" + ); + Assert.That( + DownloadStartRequestCount(), + Is.EqualTo(1), + "download-start should be hit once for many downloads within the credential lifetime" + ); + } + + [Test] + public void GetCollectionDownloadLocation_Expired_Refetches() + { + // Credentials already past their expiration must not be reused. + _executor.Handler = req => DownloadStartResponse(DateTime.UtcNow.AddMinutes(-1)); + + _collection.GetCollectionDownloadLocation(); + _collection.GetCollectionDownloadLocation(); + + Assert.That( + DownloadStartRequestCount(), + Is.EqualTo(2), + "expired download credentials must be re-fetched rather than reused" + ); + } + + [Test] + public void GetCollectionDownloadLocation_NoExpiration_RefetchesWithoutThrowing() + { + // A response with no `expiration` is treated as non-cacheable: it must be re-fetched + // every time, and must not underflow DateTime.MinValue when checking staleness. + _executor.Handler = req => DownloadStartResponse(expiresUtc: null); + + Assert.DoesNotThrow(() => + { + _collection.GetCollectionDownloadLocation(); + _collection.GetCollectionDownloadLocation(); + }); + Assert.That(DownloadStartRequestCount(), Is.EqualTo(2)); + } + + private int DownloadStartRequestCount() => + _executor.RequestsSeen.Count(r => r.Resource == "functions/v1/download-start"); + + private static IRestResponse DownloadStartResponse(DateTime? expiresUtc) + { + var credentials = new JObject + { + ["accessKeyId"] = "a", + ["secretAccessKey"] = "b", + ["sessionToken"] = "c", + }; + if (expiresUtc.HasValue) + credentials["expiration"] = expiresUtc.Value.ToString("o"); + return FakeResponses.Make( + HttpStatusCode.OK, + new JObject + { + ["s3"] = new JObject + { + ["bucket"] = "test-bucket", + ["region"] = "us-east-1", + ["prefix"] = "tc/x/", + ["credentials"] = credentials, + }, + }.ToString() + ); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudTestHarness.cs b/src/BloomTests/TeamCollection/Cloud/CloudTestHarness.cs new file mode 100644 index 000000000000..e08ffb548b90 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudTestHarness.cs @@ -0,0 +1,94 @@ +using System; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using Moq; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// Builds the CloudTeamCollection unit-test fixture that nearly every cloud test used to set up + /// by hand: a temp collection folder, a mock , a stubbed with a test anon key, a over a + /// StubCloudAuthProvider + in-memory token store, and a + /// whose REST calls are intercepted by a . The pieces are handed + /// back individually so each fixture stays explicit about what it drives (script the Executor, + /// inspect the Auth, seed the folder...) and can inject its own S3 behaviour. undoes the two process-global test seams (the temp folder and the forced + /// current user), matching the identical TearDown every one of these fixtures had. + /// + /// Deliberately NOT used by CloudCollectionClientTests / CloudCollectionMonitorTests: those + /// test the client and monitor directly (the former with a custom auth provider) and never + /// need a whole CloudTeamCollection, so sharing this would couple them to a fixture they don't + /// exercise. + /// + internal sealed class CloudTestHarness : IDisposable + { + public TemporaryFolder CollectionFolder { get; private set; } + public string CollectionFolderPath => CollectionFolder.FolderPath; + public Mock MockTcManager { get; private set; } + public CloudEnvironment Environment { get; private set; } + public CloudAuth Auth { get; private set; } + public CloudCollectionClient Client { get; private set; } + public FakeRestExecutor Executor { get; private set; } + public CloudTeamCollection Collection { get; private set; } + + /// Temp-folder label (conventionally the fixture's own name). + /// The cloud collection id the CloudTeamCollection binds to. + /// Value for TeamCollectionManager.ForceCurrentUserForTests -- the + /// Bloom "current user"; reset to null on . + /// When true (the default) also signs CloudAuth in as ; pass false for tests that exercise the signed-out state. + /// Factory the CloudBookTransfer uses to make its S3 client; + /// defaults to a bare Moq IAmazonS3 (so no real transfer happens). Sync tests pass a + /// scripted one that serves fixed bytes. + public static CloudTestHarness Create( + string folderName, + string collectionId, + string currentUser = "test@somewhere.org", + bool signIn = true, + Func s3Factory = null + ) + { + var harness = new CloudTestHarness + { + CollectionFolder = new TemporaryFolder(folderName), + MockTcManager = new Mock(), + }; + TeamCollectionManager.ForceCurrentUserForTests(currentUser); + + harness.Environment = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_ANON_KEY" ? "test-anon-key" : null + ); + harness.Auth = new CloudAuth( + new StubCloudAuthProvider(), + new InMemoryCloudTokenStore() + ); + if (signIn) + harness.Auth.SignIn(currentUser, "irrelevant"); + + harness.Client = new CloudCollectionClient(harness.Environment, harness.Auth); + harness.Executor = new FakeRestExecutor(); + harness.Client.SetRestClientForTests(harness.Executor); + + harness.Collection = new CloudTeamCollection( + harness.MockTcManager.Object, + harness.CollectionFolderPath, + collectionId, + environment: harness.Environment, + auth: harness.Auth, + client: harness.Client, + transfer: new CloudBookTransfer( + s3Factory ?? (_ => new Mock().Object) + ) + ); + return harness; + } + + public void Dispose() + { + CollectionFolder.Dispose(); + TeamCollectionManager.ForceCurrentUserForTests(null); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudTokenStoreTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudTokenStoreTests.cs new file mode 100644 index 000000000000..b898e7487d9a --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudTokenStoreTests.cs @@ -0,0 +1,127 @@ +using System; +using System.IO; +using Bloom.TeamCollection.Cloud; +using NUnit.Framework; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// Unit tests for (task 12's persistent token store). + /// Runs against a real temp file + real Windows DPAPI -- there is nothing to mock here (no + /// network, no server), and both BloomExe and BloomTests are Windows-only builds + /// (net8.0-windows), so this is a genuine, always-applicable unit test rather than a + /// platform-guarded one. + /// + [TestFixture] + public class DpapiCloudTokenStoreTests + { + private string _tempFilePath; + + [SetUp] + public void Setup() + { + _tempFilePath = Path.Combine( + Path.GetTempPath(), + "BloomDpapiCloudTokenStoreTests-" + Guid.NewGuid().ToString("N") + ".dat" + ); + } + + [TearDown] + public void TearDown() + { + if (File.Exists(_tempFilePath)) + File.Delete(_tempFilePath); + } + + private static CloudSession MakeSession() => + new CloudSession + { + AccessToken = "access-1", + RefreshToken = "refresh-1", + Email = "alice@example.com", + UserId = "firebase-uid-1", + ExpiresAtUtc = new DateTime(2026, 7, 8, 12, 0, 0, DateTimeKind.Utc), + EmailVerified = true, + }; + + [Test] + public void Load_NoFileYet_ReturnsNull() + { + var store = new DpapiCloudTokenStore(_tempFilePath); + + Assert.That(store.Load(), Is.Null); + } + + [Test] + public void SaveThenLoad_RoundTripsAllFields() + { + var store = new DpapiCloudTokenStore(_tempFilePath); + var original = MakeSession(); + + store.Save(original); + var loaded = store.Load(); + + Assert.That(loaded, Is.Not.Null, "a session was just saved; loading it must not fail"); + Assert.That(loaded.AccessToken, Is.EqualTo(original.AccessToken)); + Assert.That(loaded.RefreshToken, Is.EqualTo(original.RefreshToken)); + Assert.That(loaded.Email, Is.EqualTo(original.Email)); + Assert.That(loaded.UserId, Is.EqualTo(original.UserId)); + Assert.That(loaded.ExpiresAtUtc, Is.EqualTo(original.ExpiresAtUtc)); + Assert.That(loaded.EmailVerified, Is.EqualTo(original.EmailVerified)); + } + + [Test] + public void Save_EncryptsOnDisk_PlainRefreshTokenNotRecoverableFromRawBytes() + { + var store = new DpapiCloudTokenStore(_tempFilePath); + store.Save(MakeSession()); + + var rawBytes = File.ReadAllBytes(_tempFilePath); + var rawText = System.Text.Encoding.UTF8.GetString(rawBytes); + + // The whole point of DPAPI here: the refresh token must not appear in the clear + // anywhere in the file (this is the "NOT plain text, NOT in user.config" requirement + // from the task brief). + Assert.That(rawText, Does.Not.Contain("refresh-1")); + Assert.That(rawText, Does.Not.Contain("alice@example.com")); + } + + [Test] + public void Clear_DeletesFile_SubsequentLoadReturnsNull() + { + var store = new DpapiCloudTokenStore(_tempFilePath); + store.Save(MakeSession()); + Assert.That( + File.Exists(_tempFilePath), + Is.True, + "sanity check: save must create the file" + ); + + store.Clear(); + + Assert.That(File.Exists(_tempFilePath), Is.False); + Assert.That(store.Load(), Is.Null); + } + + [Test] + public void Clear_NoFileYet_DoesNotThrow() + { + var store = new DpapiCloudTokenStore(_tempFilePath); + + Assert.DoesNotThrow(() => store.Clear()); + } + + [Test] + public void Load_CorruptedFile_ReturnsNullRatherThanThrowing() + { + File.WriteAllBytes(_tempFilePath, new byte[] { 1, 2, 3, 4, 5 }); + var store = new DpapiCloudTokenStore(_tempFilePath); + + Assert.That( + store.Load(), + Is.Null, + "corrupted/undecryptable data must be treated as 'no stored session', not crash" + ); + } + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/FirebaseCloudAuthProviderTests.cs b/src/BloomTests/TeamCollection/Cloud/FirebaseCloudAuthProviderTests.cs new file mode 100644 index 000000000000..9ba4a1c93b4e --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/FirebaseCloudAuthProviderTests.cs @@ -0,0 +1,347 @@ +using System; +using System.Linq; +using System.Net; +using System.Text; +using Bloom.TeamCollection.Cloud; +using Newtonsoft.Json; +using NUnit.Framework; +using RestSharp; + +namespace BloomTests.TeamCollection.Cloud +{ + /// + /// Unit tests for (task 12's Option A real auth + /// provider). All HTTP is mocked via the same / + /// helpers uses -- no + /// live Google/Firebase calls are ever made. + /// + [TestFixture] + public class FirebaseCloudAuthProviderTests + { + private static CloudEnvironment MakeEnvironment(string firebaseApiKey = "test-api-key") => + new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_FIREBASE_API_KEY" ? firebaseApiKey : null + ); + + /// + /// Builds a JWT-shaped string (header.payload.signature) whose payload is the given + /// claims, base64url-encoded exactly like a real Firebase ID token. The signature + /// segment is a meaningless placeholder: FirebaseCloudAuthProvider never verifies it + /// (see its own doc comment for why), only decodes the payload. + /// + private static string MakeIdToken(object claims) + { + string EncodeSegment(object value) + { + var json = JsonConvert.SerializeObject(value); + var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + var header = EncodeSegment(new { alg = "RS256", typ = "JWT" }); + var payload = EncodeSegment(claims); + return $"{header}.{payload}.fake-signature"; + } + + private static object ValidClaims( + string email = "alice@example.com", + string sub = "firebase-uid-1", + bool emailVerified = true, + long? exp = null + ) => + new + { + email, + email_verified = emailVerified, + sub, + exp = exp ?? DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(), + }; + + // ------------------------------------------------------------------ + // AcceptExternalSession (the token-receipt endpoint's entry point) + // ------------------------------------------------------------------ + + [Test] + public void AcceptExternalSession_ValidToken_ExtractsIdentityFromClaims() + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment()); + var idToken = MakeIdToken(ValidClaims()); + + var session = provider.AcceptExternalSession(idToken, "a-refresh-token"); + + Assert.That(session.Email, Is.EqualTo("alice@example.com")); + Assert.That(session.UserId, Is.EqualTo("firebase-uid-1")); + Assert.That(session.EmailVerified, Is.True); + Assert.That(session.AccessToken, Is.EqualTo(idToken)); + Assert.That(session.RefreshToken, Is.EqualTo("a-refresh-token")); + Assert.That(session.ExpiresAtUtc, Is.GreaterThan(DateTime.UtcNow)); + } + + [Test] + public void AcceptExternalSession_UnverifiedEmail_ReportsEmailVerifiedFalse() + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment()); + var idToken = MakeIdToken(ValidClaims(emailVerified: false)); + + var session = provider.AcceptExternalSession(idToken, "a-refresh-token"); + + Assert.That( + session.EmailVerified, + Is.False, + "must reflect the token's own email_verified=false claim, not default to true" + ); + } + + [TestCase(null)] + [TestCase("")] + public void AcceptExternalSession_MissingIdTokenOrRefreshToken_Throws(string missing) + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment()); + var idToken = MakeIdToken(ValidClaims()); + + Assert.Throws(() => + provider.AcceptExternalSession(missing, "a-refresh-token") + ); + Assert.Throws(() => + provider.AcceptExternalSession(idToken, missing) + ); + } + + [Test] + public void AcceptExternalSession_MalformedToken_ThrowsCloudAuthException() + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment()); + + Assert.Throws(() => + provider.AcceptExternalSession("not-a-jwt", "a-refresh-token") + ); + } + + [Test] + public void AcceptExternalSession_TokenMissingEmailClaim_ThrowsCloudAuthException() + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment()); + var idToken = MakeIdToken(new { sub = "firebase-uid-1", email_verified = true }); + + var ex = Assert.Throws(() => + provider.AcceptExternalSession(idToken, "a-refresh-token") + ); + Assert.That(ex.Message, Does.Contain("email")); + } + + [Test] + public void AcceptExternalSession_NeverCallsNetwork() + { + // Sanity check on the design itself: parsing the caller's own freshly-issued idToken + // is a pure local operation. Wire up an executor that fails the test if it's ever + // invoked, so a future regression that adds an unwanted network hop is caught here. + var provider = new FirebaseCloudAuthProvider(MakeEnvironment()); + provider.SetRestExecutorForTests( + new FakeRestExecutor + { + Handler = req => + throw new InvalidOperationException( + "AcceptExternalSession must not make network calls" + ), + } + ); + + Assert.DoesNotThrow(() => + provider.AcceptExternalSession(MakeIdToken(ValidClaims()), "a-refresh-token") + ); + } + + // ------------------------------------------------------------------ + // SignIn: no password flow exists for Firebase/BloomLibrary accounts. + // ------------------------------------------------------------------ + + [Test] + public void SignIn_AlwaysThrows_NoPasswordFlow() + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment()); + + Assert.Throws(() => provider.SignIn("alice@example.com", "pw")); + } + + // ------------------------------------------------------------------ + // Refresh: Google's securetoken API (mocked). + // ------------------------------------------------------------------ + + [Test] + public void Refresh_Success_PostsFormEncodedGrantAndReturnsNewSession() + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment("my-firebase-api-key")); + var executor = new FakeRestExecutor(); + provider.SetRestExecutorForTests(executor); + var newIdToken = MakeIdToken(ValidClaims(email: "bob@example.com", sub: "uid-bob")); + + executor.Handler = req => + FakeResponses.Make( + HttpStatusCode.OK, + JsonConvert.SerializeObject( + new + { + id_token = newIdToken, + refresh_token = "new-refresh-token", + expires_in = "3600", + } + ) + ); + + var session = provider.Refresh("old-refresh-token"); + + Assert.That(session.Email, Is.EqualTo("bob@example.com")); + Assert.That(session.UserId, Is.EqualTo("uid-bob")); + Assert.That(session.RefreshToken, Is.EqualTo("new-refresh-token")); + Assert.That(session.AccessToken, Is.EqualTo(newIdToken)); + + Assert.That(executor.RequestsSeen, Has.Count.EqualTo(1)); + var request = executor.RequestsSeen[0]; + var queryParams = request + .Parameters.Where(p => + p.Type == ParameterType.QueryString + || p.Type == ParameterType.QueryStringWithoutEncode + ) + .ToList(); + Assert.That( + queryParams, + Has.Some.Matches(p => + p.Name == "key" && (string)p.Value == "my-firebase-api-key" + ), + "the Firebase Web API key must be sent as the 'key' query parameter" + ); + var bodyParams = request + .Parameters.Where(p => p.Type == ParameterType.GetOrPost) + .ToList(); + Assert.That( + bodyParams, + Has.Some.Matches(p => + p.Name == "grant_type" && (string)p.Value == "refresh_token" + ) + ); + Assert.That( + bodyParams, + Has.Some.Matches(p => + p.Name == "refresh_token" && (string)p.Value == "old-refresh-token" + ) + ); + } + + [Test] + public void Refresh_NonOkResponse_ThrowsCloudAuthException() + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment()); + var executor = new FakeRestExecutor + { + Handler = req => + FakeResponses.Make( + HttpStatusCode.BadRequest, + "{\"error\":{\"message\":\"TOKEN_EXPIRED\"}}" + ), + }; + provider.SetRestExecutorForTests(executor); + + Assert.Throws(() => provider.Refresh("expired-refresh-token")); + } + + [Test] + public void Refresh_ResponseMissingTokens_ThrowsCloudAuthException() + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment()); + var executor = new FakeRestExecutor + { + Handler = req => FakeResponses.Make(HttpStatusCode.OK, "{}"), + }; + provider.SetRestExecutorForTests(executor); + + Assert.Throws(() => provider.Refresh("some-refresh-token")); + } + + [Test] + public void Refresh_NoFirebaseApiKeyConfigured_ThrowsWithoutCallingNetwork() + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment(firebaseApiKey: "")); + var executor = new FakeRestExecutor + { + Handler = req => + throw new InvalidOperationException( + "must not call the network without an API key" + ), + }; + provider.SetRestExecutorForTests(executor); + + Assert.Throws(() => provider.Refresh("some-refresh-token")); + } + + // ------------------------------------------------------------------ + // End-to-end through CloudAuth's session core: proves the provider's Refresh really + // does keep a session alive via CloudAuth's generic ~80%-of-TTL proactive-refresh timer + // (CloudAuthTests already covers that timer mechanism in isolation with a fake + // provider; this closes the loop with the real FirebaseCloudAuthProvider wired in). + // ------------------------------------------------------------------ + + [Test] + public void CloudAuth_WithFirebaseProvider_ProactivelyRefreshesNearExpiry() + { + var provider = new FirebaseCloudAuthProvider(MakeEnvironment("my-firebase-api-key")); + var executor = new FakeRestExecutor(); + provider.SetRestExecutorForTests(executor); + executor.Handler = req => + FakeResponses.Make( + HttpStatusCode.OK, + JsonConvert.SerializeObject( + new + { + id_token = MakeIdToken( + ValidClaims( + email: "alice@example.com", + exp: DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + ) + ), + refresh_token = "refreshed-refresh-token", + expires_in = "3600", + } + ) + ); + var auth = new CloudAuth(provider, new InMemoryCloudTokenStore()); + + // A near-expired (0.2s TTL) ID token so the ~80%-of-TTL proactive-refresh timer + // fires almost immediately, keeping this test fast. + var almostExpiredIdToken = MakeIdToken( + ValidClaims( + email: "alice@example.com", + exp: DateTimeOffset.UtcNow.AddSeconds(0.2).ToUnixTimeSeconds() + ) + ); + auth.SignInWithExternalTokens(almostExpiredIdToken, "original-refresh-token"); + Assert.That(auth.CurrentEmail, Is.EqualTo("alice@example.com")); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (executor.RequestsSeen.Count == 0 && DateTime.UtcNow < deadline) + System.Threading.Thread.Sleep(25); + + Assert.That( + executor.RequestsSeen, + Has.Count.GreaterThanOrEqualTo(1), + "CloudAuth's proactive-refresh timer should have called FirebaseCloudAuthProvider.Refresh on its own" + ); + Assert.That(auth.IsSignedIn, Is.True, "the refreshed session must still be signed in"); + } + + // ------------------------------------------------------------------ + // CloudAuth.CreateProvider wiring + // ------------------------------------------------------------------ + + [Test] + public void CreateProvider_CloudAuthMode_ReturnsFirebaseProvider() + { + var env = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_AUTH_MODE" ? "cloud" : null + ); + + var provider = CloudAuth.CreateProvider(env); + + Assert.That(provider, Is.InstanceOf()); + } + } +} diff --git a/src/BloomTests/TeamCollection/RemoteBookAutoApplyQueueTests.cs b/src/BloomTests/TeamCollection/RemoteBookAutoApplyQueueTests.cs new file mode 100644 index 000000000000..6ffa72d83295 --- /dev/null +++ b/src/BloomTests/TeamCollection/RemoteBookAutoApplyQueueTests.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Bloom.TeamCollection; +using NUnit.Framework; + +namespace BloomTests.TeamCollection +{ + // Unit tests for the small single-consumer queue that TeamCollection.HandleModifiedFile uses + // to apply auto-appliable remote book changes off the UI thread (batch item 4+5). These tests + // use a synchronous runWorker (action => action()) so behavior is deterministic and doesn't + // depend on real background-thread timing, except where a test specifically wants to verify + // real single-consumer-thread behavior (marked below). + public class RemoteBookAutoApplyQueueTests + { + [Test] + public void Enqueue_OneBook_ProcessesIt() + { + var processed = new List(); + var queue = new RemoteBookAutoApplyQueue(processed.Add, runWorker: action => action()); + + queue.Enqueue("My Book"); + + Assert.That(processed, Is.EqualTo(new[] { "My Book" })); + } + + [Test] + public void Enqueue_SameBookTwiceBeforeProcessing_DedupesToOneCall() + { + // Sanity check on the test setup: we want the SECOND Enqueue call to happen while the + // first book is still considered "in flight" from the queue's point of view, i.e. + // before the (synchronous, in this test) processing callback has returned. We arrange + // that by re-entrantly calling Enqueue for the SAME name from inside the processing + // callback itself. + var callCount = 0; + RemoteBookAutoApplyQueue queue = null; + queue = new RemoteBookAutoApplyQueue( + bookName => + { + callCount++; + if (callCount == 1) + { + // Still "in flight" per the queue's own bookkeeping (removed only in the + // caller's finally, after this callback returns) -- must be deduped. + queue.Enqueue(bookName); + } + }, + runWorker: action => action() + ); + + queue.Enqueue("Dup Book"); + + Assert.That( + callCount, + Is.EqualTo(1), + "Re-entrant Enqueue of a book already being processed must be a no-op" + ); + } + + [Test] + public void Enqueue_SameBookAfterProcessingCompletes_ProcessesAgain() + { + var processed = new List(); + var queue = new RemoteBookAutoApplyQueue(processed.Add, runWorker: action => action()); + + queue.Enqueue("Book A"); + queue.Enqueue("Book A"); // first call has already fully completed by now + + Assert.That( + processed, + Is.EqualTo(new[] { "Book A", "Book A" }), + "Once a book's processing has finished, a later change for the same book should be queued again" + ); + } + + [Test] + public void Enqueue_MultipleDifferentBooks_ProcessesAllInOrder() + { + var processed = new List(); + var queue = new RemoteBookAutoApplyQueue(processed.Add, runWorker: action => action()); + + queue.Enqueue("Book One"); + queue.Enqueue("Book Two"); + queue.Enqueue("Book Three"); + + Assert.That(processed, Is.EqualTo(new[] { "Book One", "Book Two", "Book Three" })); + } + + [Test] + public void Enqueue_ProcessingThrows_DoesNotStopLaterBooksFromBeingProcessed() + { + var processed = new List(); + var queue = new RemoteBookAutoApplyQueue( + bookName => + { + if (bookName == "Bad Book") + throw new InvalidOperationException("simulated failure"); + processed.Add(bookName); + }, + runWorker: action => action() + ); + + queue.Enqueue("Bad Book"); + queue.Enqueue("Good Book"); + + Assert.That( + processed, + Is.EqualTo(new[] { "Good Book" }), + "One book's processing failure must not prevent later books (or a later requeue) from being processed" + ); + } + + [Test] + public void Enqueue_ProcessingThrows_SameBookCanBeQueuedAgainAfterwards() + { + var attempts = 0; + var queue = new RemoteBookAutoApplyQueue( + _ => + { + attempts++; + if (attempts == 1) + throw new InvalidOperationException("simulated failure"); + }, + runWorker: action => action() + ); + + queue.Enqueue("Flaky Book"); + queue.Enqueue("Flaky Book"); + + Assert.That( + attempts, + Is.EqualTo(2), + "A book must be re-queueable after a prior processing attempt threw" + ); + } + + // ------------------------------------------------------------------ + // Batch item 7 (progressive join): EnqueueFront lets a caller (e.g. the user selecting a + // not-yet-downloaded placeholder) jump a book to the head of the line. + // ------------------------------------------------------------------ + + [Test] + public void EnqueueFront_BookNotYetQueued_ProcessesBeforeOthersQueuedFirst() + { + // Simulate: two books already queued in the background, then the user selects a third + // book that was never queued at all -- it should still jump ahead of the first two. + var processed = new List(); + RemoteBookAutoApplyQueue queue = null; + queue = new RemoteBookAutoApplyQueue( + bookName => + { + processed.Add(bookName); + // Queue the other background books from inside the first book's processing so + // they're PENDING (not yet started) when EnqueueFront is called below, mirroring + // production timing (the whole point of a priority queue only matters while + // something is still pending). + if (bookName == "Background Book One") + { + queue.Enqueue("Background Book Two"); + queue.EnqueueFront("Prioritized Book"); + } + }, + runWorker: action => action() + ); + + queue.Enqueue("Background Book One"); + + Assert.That( + processed, + Is.EqualTo( + new[] { "Background Book One", "Prioritized Book", "Background Book Two" } + ), + "a book bumped to the front should be processed before books merely queued earlier" + ); + } + + [Test] + public void EnqueueFront_BookAlreadyPending_MovesToFrontRatherThanDuplicating() + { + var processed = new List(); + RemoteBookAutoApplyQueue queue = null; + queue = new RemoteBookAutoApplyQueue( + bookName => + { + processed.Add(bookName); + if (bookName == "First") + { + queue.Enqueue("Already Pending"); + queue.Enqueue("Other"); + // "Already Pending" is queued (not in flight) at this point -- bumping it + // should move it ahead of "Other" without adding a second entry. + queue.EnqueueFront("Already Pending"); + } + }, + runWorker: action => action() + ); + + queue.Enqueue("First"); + + Assert.That( + processed, + Is.EqualTo(new[] { "First", "Already Pending", "Other" }), + "moving an already-pending book to the front must not process it twice" + ); + } + + [Test] + public void EnqueueFront_BookCurrentlyInFlight_DoesNotInterruptOrDuplicate() + { + // Per the queue's contract: the book actually being processed right now always runs to + // completion first, even if EnqueueFront is called for it re-entrantly. + var callCount = 0; + RemoteBookAutoApplyQueue queue = null; + queue = new RemoteBookAutoApplyQueue( + bookName => + { + callCount++; + if (callCount == 1) + queue.EnqueueFront(bookName); // re-entrant: this book is "in flight" now + }, + runWorker: action => action() + ); + + queue.EnqueueFront("In Flight Book"); + + Assert.That( + callCount, + Is.EqualTo(1), + "EnqueueFront for a book already being processed must be a no-op, not interrupt or duplicate it" + ); + } + + [Test] + public void EnqueueFront_RealBackgroundWorker_EventuallyProcessesTheBook() + { + // Sanity check against the REAL default (Task.Run) worker, not just the synchronous + // test double used above. + var processed = new List(); + var gate = new object(); + var queue = new RemoteBookAutoApplyQueue(bookName => + { + lock (gate) + processed.Add(bookName); + }); + + queue.EnqueueFront("Prioritized Async Book"); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + lock (gate) + { + if (processed.Count >= 1) + break; + } + Thread.Sleep(20); + } + + lock (gate) + { + Assert.That(processed, Is.EqualTo(new[] { "Prioritized Async Book" })); + } + } + + [Test] + public void Enqueue_RealBackgroundWorker_EventuallyProcessesAllQueuedBooks() + { + // Uses the REAL default (Task.Run) worker to sanity-check actual background-thread + // behavior, not just the synchronous test double used above. + var processed = new List(); + var gate = new object(); + var queue = new RemoteBookAutoApplyQueue(bookName => + { + lock (gate) + processed.Add(bookName); + }); + + queue.Enqueue("Async Book One"); + queue.Enqueue("Async Book Two"); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + lock (gate) + { + if (processed.Count >= 2) + break; + } + Thread.Sleep(20); + } + + lock (gate) + { + Assert.That( + processed, + Is.EquivalentTo(new[] { "Async Book One", "Async Book Two" }) + ); + } + } + } +} diff --git a/src/BloomTests/TeamCollection/TeamCollectionAccountSwitchRefusalTests.cs b/src/BloomTests/TeamCollection/TeamCollectionAccountSwitchRefusalTests.cs new file mode 100644 index 000000000000..c5b78f85d5fb --- /dev/null +++ b/src/BloomTests/TeamCollection/TeamCollectionAccountSwitchRefusalTests.cs @@ -0,0 +1,146 @@ +using System.Reflection; +using Bloom.Api; +using Bloom.Collection; +using Bloom.TeamCollection; +using BloomTemp; +using Moq; +using NUnit.Framework; + +namespace BloomTests.TeamCollection +{ + /// + /// Tests for TeamCollectionManager.CheckConnection(bool allowHardRefusal) (batch item 9, + /// account-switch behavior): a connection problem flagged IsAccessRefusal must throw + /// TeamCollectionAccessRefusedException when allowHardRefusal is true (the collection-open + /// path), but must fall back to the ordinary Disconnected mode -- exactly as before -- for + /// every other caller (allowHardRefusal false/default), so a membership loss discovered mid- + /// session never crashes the running app. + /// + /// CurrentCollection has a private setter (by design -- nothing outside TeamCollectionManager + /// itself should replace it), so these tests use reflection to install a scripted fake + /// TeamCollection, the same way TeamCollection's own "empty constructor... only for mocking + /// purposes" comment anticipates. + /// + [TestFixture] + public class TeamCollectionAccountSwitchRefusalTests + { + private TemporaryFolder _localCollection; + private TeamCollectionManager _tcManager; + + [SetUp] + public void Setup() + { + _localCollection = new TemporaryFolder("TeamCollectionAccountSwitchRefusalTests"); + var collectionPath = CollectionSettings.GetSettingsFilePath( + _localCollection.FolderPath + ); + // No TeamCollectionLink.txt in this folder, so the constructor's own TC-loading + // logic is a no-op; CurrentCollection starts null and we install a fake via + // reflection below. + _tcManager = new TeamCollectionManager( + collectionPath, + new BloomWebSocketServer(), + null, + null, + null, + null + ); + } + + [TearDown] + public void TearDown() + { + _localCollection.Dispose(); + } + + private void InstallFakeCollection(Bloom.TeamCollection.TeamCollection fake) + { + typeof(TeamCollectionManager) + .GetProperty( + nameof(TeamCollectionManager.CurrentCollection), + BindingFlags.Public | BindingFlags.Instance + ) + .SetValue(_tcManager, fake); + } + + [Test] + public void CheckConnection_AllowHardRefusalTrue_AccessRefusalMessage_Throws() + { + var fake = new Mock(); + fake.Setup(c => c.CheckConnection()) + .Returns( + new TeamCollectionMessage( + MessageAndMilestoneType.Error, + "TeamCollection.Cloud.NotAMemberRefusal", + "Bloom cannot open this Team Collection here because {0} is not a member of it. {1}", + "bob@dev.local", + "Ask an administrator to add you as a member." + ) + { + IsAccessRefusal = true, + } + ); + InstallFakeCollection(fake.Object); + + Assert.That( + () => _tcManager.CheckConnection(allowHardRefusal: true), + Throws + .TypeOf() + .With.Message.Contains("bob@dev.local") + ); + } + + [Test] + public void CheckConnection_AllowHardRefusalFalse_AccessRefusalMessage_FallsBackToDisconnected() + { + // Same scripted refusal message, but the DEFAULT (allowHardRefusal: false) caller -- + // used everywhere except the initial collection-open constructor call -- must NOT + // throw, so a membership loss discovered mid-session just disconnects as before. + var fake = new Mock(); + fake.Setup(c => c.CheckConnection()) + .Returns( + new TeamCollectionMessage( + MessageAndMilestoneType.Error, + "TeamCollection.Cloud.NotAMemberRefusal", + "Bloom cannot open this Team Collection here because {0} is not a member of it. {1}", + "bob@dev.local", + "Ask an administrator to add you as a member." + ) + { + IsAccessRefusal = true, + } + ); + InstallFakeCollection(fake.Object); + + bool result = false; + Assert.That(() => result = _tcManager.CheckConnection(), Throws.Nothing); + Assert.That(result, Is.False, "the connection check should report failure..."); + Assert.That( + _tcManager.CurrentCollection, + Is.Null, + "...and fall back to Disconnected mode (CurrentCollection cleared), not crash" + ); + } + + [Test] + public void CheckConnection_AllowHardRefusalTrue_OrdinaryProblem_FallsBackToDisconnected() + { + // A connection problem that is NOT flagged IsAccessRefusal (e.g. NoConnection) must + // still just disconnect even when allowHardRefusal is true -- only a genuine + // access-refusal aborts the open. + var fake = new Mock(); + fake.Setup(c => c.CheckConnection()) + .Returns( + new TeamCollectionMessage( + MessageAndMilestoneType.Error, + "TeamCollection.Cloud.NoConnection", + "Bloom could not reach the Team Collection server." + ) + ); + InstallFakeCollection(fake.Object); + + Assert.That(() => _tcManager.CheckConnection(allowHardRefusal: true), Throws.Nothing); + Assert.That(_tcManager.CurrentCollection, Is.Null); + } + } +} diff --git a/src/BloomTests/TeamCollection/TeamCollectionApiCloudTests.cs b/src/BloomTests/TeamCollection/TeamCollectionApiCloudTests.cs new file mode 100644 index 000000000000..9721633d95db --- /dev/null +++ b/src/BloomTests/TeamCollection/TeamCollectionApiCloudTests.cs @@ -0,0 +1,533 @@ +using System.Net; +using Bloom.Api; +using Bloom.Book; +using Bloom.Collection; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using Bloom.web; +using BloomTemp; +using BloomTests.DataBuilders; +using BloomTests.TeamCollection.Cloud; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace BloomTests.TeamCollection +{ + /// + /// Task 06: tests for TeamCollectionApi's additive cloud-only endpoints/fields + /// (capabilities, tcStatusMetadata, cloudCollectionId, isUserAdmin, and the book-status JSON's + /// localVersionSeq/repoVersionSeq/signedIn/requiresSignIn) and the byte-identical guarantee for + /// no collection at all. + /// + /// Uses a REAL CloudTeamCollection (fake REST executor, no live network -- same pattern as + /// CloudTeamCollectionLockTests) rather than a Moq mock, because the new task-06 accessors + /// (GetLocalVersionSeq/GetRepoVersionSeq/GetUpdatesAvailableCount/Auth/Client) are plain + /// members Moq cannot intercept, and their whole point is to read real cache state. + /// + /// The mock ITeamCollectionManager's CurrentCollection getter reads a lazy field + /// (_cloudCollection) that starts, and STAYS, null until a test explicitly calls + /// EnsureCloudCollection()/HydrateWith() -- so "no collection" tests never construct one at + /// all (avoiding both TeamCollectionApi's constructor-time SetupMonitoringBehavior side effect + /// and any lazy-hydration attempt against an unconfigured fake executor). + /// + [TestFixture] + public class TeamCollectionApiCloudTests + { + private const string kCollectionId = "22222222-2222-2222-2222-222222222222"; + private TemporaryFolder _collectionFolder; + private CloudTeamCollection _cloudCollection; // null until EnsureCloudCollection() is called + private CloudEnvironment _environment; + private CloudAuth _auth; + private CloudCollectionClient _client; + private FakeRestExecutor _executor; + private BloomServer _server; + private TeamCollectionApi _api; + + // One shared BloomServer for the whole fixture (matching TeamCollectionApiServerTests' + // established pattern below in TeamCollectionApiTests.cs) -- BloomServer binds to a + // process-wide port, so creating/disposing a fresh one per test is unreliable; instead + // each [SetUp] clears and re-registers handlers on the same server. + [OneTimeSetUp] + public void OneTimeSetUp() + { + _server = new BloomServer(new BookSelection()); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _server?.Dispose(); + _server = null; + } + + [SetUp] + public void Setup() + { + // NUnit's default fixture lifecycle is ONE SHARED instance across every [Test] in this + // class (SetUp/TearDown run per-test, but instance fields persist between them) -- so + // this reset matters even though it looks redundant with the field initializer. + _cloudCollection = null; + _server.ApiHandler.ClearEndpointHandlers(); + _collectionFolder = new TemporaryFolder("TeamCollectionApiCloudTests"); + TeamCollectionManager.ForceCurrentUserForTests("test@somewhere.org"); + + var mockTcManager = new Mock(); + // Lazy: re-evaluated on every access, so it reflects _cloudCollection's value AT CALL + // TIME -- null for every "no collection" test (which never calls + // EnsureCloudCollection()), and the real object for every other test. + mockTcManager.SetupGet(m => m.CurrentCollection).Returns(() => _cloudCollection); + mockTcManager + .SetupGet(m => m.CurrentCollectionEvenIfDisconnected) + .Returns(() => _cloudCollection); + mockTcManager.SetupGet(m => m.OkToEditCollectionSettings).Returns(true); + + var apiBuilder = new TeamCollectionApiBuilder() + .WithTeamCollectionManager(mockTcManager.Object) + .WithCollectionSettings(new CollectionSettings()) + .WithBookSelection(new BookSelection()); + _api = apiBuilder.Build(); // _cloudCollection is null here regardless -- see above. + _api.RegisterWithApiHandler(_server.ApiHandler); + // teamCollection/capabilities moved to the app-level SharingApi (post-batch defect + // fix, 10 Jul 2026 -- see the note in TeamCollectionApi.RegisterWithApiHandler). + // SharingApi's handler reaches this fixture's collection through + // TeamCollectionApi.TheOneInstance, which building _api above just set. + new Bloom.web.controllers.SharingApi().RegisterWithApiHandler(_server.ApiHandler); + + _environment = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_ANON_KEY" ? "test-anon-key" : null + ); + _auth = new CloudAuth(new StubCloudAuthProvider(), new InMemoryCloudTokenStore()); + _client = new CloudCollectionClient(_environment, _auth); + _executor = new FakeRestExecutor(); + _client.SetRestClientForTests(_executor); + + _mockTcManagerForTests = mockTcManager; + } + + // Kept only so EnsureCloudCollection() (called from within a [Test] body, well after + // Setup returns) can pass the SAME manager object CloudTeamCollection's constructor wants. + private Mock _mockTcManagerForTests; + + [TearDown] + public void TearDown() + { + _collectionFolder.Dispose(); + TeamCollectionManager.ForceCurrentUserForTests(null); + } + + /// Constructs the real CloudTeamCollection on first use (idempotent). Until a test + /// calls this (directly or via HydrateWith), CurrentCollection stays null throughout the + /// ENTIRE test, including its Setup -- required for the "no collection" tests below. + private CloudTeamCollection EnsureCloudCollection() + { + if (_cloudCollection == null) + { + _cloudCollection = new CloudTeamCollection( + _mockTcManagerForTests.Object, + _collectionFolder.FolderPath, + kCollectionId, + environment: _environment, + auth: _auth, + client: _client + ); + } + return _cloudCollection; + } + + private void HydrateWith(params JObject[] books) + { + _executor.Handler = req => + FakeResponses.Make( + HttpStatusCode.OK, + new JObject + { + ["books"] = new JArray(books), + ["groups"] = new JArray(), + ["max_event_id"] = 1, + }.ToString() + ); + EnsureCloudCollection().IsBookPresentInRepo("force hydration"); // any call triggers it + } + + private static JObject Book( + string id, + string name, + long? currentVersionSeq, + string lockedBy = null, + string lockedByMachine = null + ) => + new JObject + { + ["id"] = id, + ["instance_id"] = id + "-instance", + ["name"] = name, + ["current_version_id"] = currentVersionSeq.HasValue + ? "v-" + currentVersionSeq + : null, + ["current_version_seq"] = currentVersionSeq, + ["current_checksum"] = "cs", + ["locked_by"] = lockedBy, + ["locked_by_machine"] = + lockedBy == null ? null : (lockedByMachine ?? "SomeMachine"), + ["locked_at"] = null, + ["deleted_at"] = null, + }; + + [Test] + public void Capabilities_NoCollection_AllFalse() + { + var result = ApiTest.GetString(_server, endPoint: "teamCollection/capabilities"); + Assert.That(result, Does.Contain("\"supportsVersionHistory\":false")); + Assert.That(result, Does.Contain("\"supportsSharingUi\":false")); + Assert.That(result, Does.Contain("\"requiresSignIn\":false")); + } + + [Test] + public void Capabilities_CloudCollection_AllTrue() + { + HydrateWith(); + var result = ApiTest.GetString(_server, endPoint: "teamCollection/capabilities"); + Assert.That(result, Does.Contain("\"supportsVersionHistory\":true")); + Assert.That(result, Does.Contain("\"supportsSharingUi\":true")); + Assert.That(result, Does.Contain("\"requiresSignIn\":true")); + } + + [Test] + public void CloudCollectionId_CloudCollection_ReturnsId() + { + HydrateWith(); + var result = ApiTest.GetString(_server, endPoint: "teamCollection/cloudCollectionId"); + Assert.That(result, Is.EqualTo(kCollectionId)); + } + + [Test] + public void CloudCollectionId_NoCollection_ReturnsEmptyString() + { + var result = ApiTest.GetString(_server, endPoint: "teamCollection/cloudCollectionId"); + Assert.That(result, Is.EqualTo("")); + } + + [Test] + public void IsUserAdmin_ReflectsOkToEditCollectionSettings() + { + var result = ApiTest.GetString(_server, endPoint: "teamCollection/isUserAdmin"); + Assert.That(result, Is.EqualTo("true")); + } + + [Test] + public void TcStatusMetadata_CountsNewerBooks_IncludingCheckedOutElsewhere_ExcludingCheckedOutHere() + { + HydrateWith( + // Never locally received, unlocked -> counts. + Book("book-1", "Book One", currentVersionSeq: 3), + // Checked out by ANOTHER user -> counts (John, 16 Jul 2026): a reviewer can still + // receive the latest checked-in version, and the badge must not switch off without + // the update ever being received just because someone else took the book out. + Book("book-2", "Book Two", currentVersionSeq: 1, lockedBy: "someone-else"), + // Checked out HERE (this user + this machine) -> excluded, matching what + // ReceiveAllUpdates would skip (receiving would clobber local edits). + Book( + "book-3", + "Book Three", + currentVersionSeq: 5, + lockedBy: "test@somewhere.org", + lockedByMachine: TeamCollectionManager.CurrentMachine + ) + ); + + var result = ApiTest.GetString(_server, endPoint: "teamCollection/tcStatusMetadata"); + + Assert.That( + result, + Does.Contain("\"updatesAvailableCount\":2"), + "newer repo books count even when checked out by someone else; only a book checked out HERE is excluded (matching ReceiveAllUpdates)" + ); + } + + [Test] + public void TcStatusMetadata_NoCollection_NullCount() + { + var result = ApiTest.GetString(_server, endPoint: "teamCollection/tcStatusMetadata"); + Assert.That(result, Does.Contain("\"updatesAvailableCount\":null")); + } + + [Test] + public void AddCloudBookStatusFields_NoCollection_ReturnsInputByteIdentical() + { + const string original = "{\"who\":\"someone\",\"isUserAdmin\":true}"; + + var result = _api.AddCloudBookStatusFields(original, "SomeBook"); + + Assert.That( + result, + Is.SameAs(original), + "must return the exact same string instance, not even a re-parsed copy, " + + "when the current collection isn't a CloudTeamCollection" + ); + } + + [Test] + public void AddCloudBookStatusFields_CloudCollection_AddsVersionSeqAndSignedInFields() + { + HydrateWith(Book("book-1", "My Book", currentVersionSeq: 5)); + const string original = "{\"who\":null,\"isUserAdmin\":true}"; + + var result = _api.AddCloudBookStatusFields(original, "My Book"); + + var json = JObject.Parse(result); + Assert.That((long)json["repoVersionSeq"], Is.EqualTo(5)); + Assert.That( + json["localVersionSeq"].Type, + Is.EqualTo(JTokenType.Null), + "book-1 has never been Sent/Received on this machine" + ); + Assert.That((bool)json["requiresSignIn"], Is.True); + Assert.That( + (bool)json["signedIn"], + Is.False, + "the test's CloudAuth was constructed but SignIn() was never called" + ); + // Pre-existing fields must survive untouched. + Assert.That((bool)json["isUserAdmin"], Is.True); + } + + // Regression for the first two-instance smoke test (7 Jul 2026): the base status JSON's + // currentUser is Bloom's REGISTRATION email, but cloud locks are stamped with the + // signed-in account -- so the panel's who === currentUser check called the user's own + // checkout "someone else". For cloud TCs, currentUser must be the account email. + [Test] + public void AddCloudBookStatusFields_SignedIn_OverridesCurrentUserWithAccountEmail() + { + HydrateWith(Book("book-1", "My Book", currentVersionSeq: 5)); + _auth.SignIn("alice@dev.local", "irrelevant"); + const string original = + "{\"who\":\"alice@dev.local\",\"currentUser\":\"registration@example.com\"}"; + + var result = _api.AddCloudBookStatusFields(original, "My Book"); + + var json = JObject.Parse(result); + Assert.That((string)json["currentUser"], Is.EqualTo("alice@dev.local")); + } + + // Dogfood bug #17 (13 Jul 2026): a brand-new local book (and any book not in the repo) + // gets `who` stamped from Bloom's REGISTRATION identity by the base status JSON, so in a + // cloud TC it displayed as "checked out to John1" instead of the signed-in account. + [Test] + public void AddCloudBookStatusFields_WhoIsRegistrationIdentity_RewritesWhoToAccount() + { + HydrateWith(Book("book-1", "My Book", currentVersionSeq: 5)); + _auth.SignIn("bob@dev.local", "irrelevant"); + const string original = + "{\"who\":\"registration@example.com\",\"whoFirstName\":\"John\"," + + "\"whoSurname\":\"One\",\"currentUser\":\"registration@example.com\"," + + "\"currentUserName\":\"John\"}"; + + var result = _api.AddCloudBookStatusFields(original, "My Book"); + + var json = JObject.Parse(result); + Assert.That((string)json["who"], Is.EqualTo("bob@dev.local")); + Assert.That( + json["whoFirstName"].Type, + Is.EqualTo(JTokenType.Null), + "the registration first name must not survive as the display name" + ); + Assert.That(json["whoSurname"].Type, Is.EqualTo(JTokenType.Null)); + Assert.That((string)json["currentUser"], Is.EqualTo("bob@dev.local")); + Assert.That((string)json["currentUserName"], Is.EqualTo("bob@dev.local")); + } + + // Bug #17's real culprit (13 Jul 2026, second report): for a new/local-only book the + // base JSON's `who` already carries the account identity (CurrentUserForStatus resolves + // to CloudTeamCollection.CurrentUserIdentity), but whoFirstName/whoSurname come from the + // REGISTRATION name -- and the UI prefers the name fields over `who`, so the book still + // displayed as "checked out to John1". + [Test] + public void AddCloudBookStatusFields_NewLocalBook_ClearsRegistrationNameFields() + { + HydrateWith(Book("book-1", "My Book", currentVersionSeq: 5)); + _auth.SignIn("bob@dev.local", "irrelevant"); + const string original = + "{\"who\":\"bob@dev.local\",\"whoFirstName\":\"John\",\"whoSurname\":\"One\"," + + "\"currentUser\":\"bob@dev.local\",\"isNewLocalBook\":true}"; + + var result = _api.AddCloudBookStatusFields(original, "Some Brand New Book"); + + var json = JObject.Parse(result); + Assert.That((string)json["who"], Is.EqualTo("bob@dev.local")); + Assert.That( + json["whoFirstName"].Type, + Is.EqualTo(JTokenType.Null), + "the registration name must not win over the account identity in the display" + ); + Assert.That(json["whoSurname"].Type, Is.EqualTo(JTokenType.Null)); + } + + // Dogfood follow-up (14 Jul 2026): clearing the name fields (test above) fixed the + // "checked out to John1" text, but left a new local book's avatar showing a single-letter, + // email-tooltip badge (A / bob@dev.local) while a real checkout by the same user showed + // initials + the display name (BH / Bob Helper). The status must carry the account's + // display name so both look the same. Here members_list resolves it, unlike the test above + // where the fake returns no usable member row (so CurrentUserDisplayName is null and the + // fields fall back to the email). + [Test] + public void AddCloudBookStatusFields_NewLocalBook_UsesAccountDisplayNameForAvatar() + { + _executor.Handler = req => + { + if (req.Resource == "rest/v1/rpc/members_list") + return FakeResponses.Make( + HttpStatusCode.OK, + new JArray( + new JObject + { + ["id"] = 1, + ["email"] = "bob@dev.local", + ["display_name"] = "Bob Helper", + ["role"] = "editor", + ["user_id"] = "user-bob", + } + ).ToString() + ); + // get_collection_state (hydration) and anything else. + return FakeResponses.Make( + HttpStatusCode.OK, + new JObject + { + ["books"] = new JArray(), + ["groups"] = new JArray(), + ["max_event_id"] = 1, + }.ToString() + ); + }; + EnsureCloudCollection().IsBookPresentInRepo("force hydration"); + _auth.SignIn("bob@dev.local", "irrelevant"); + const string original = + "{\"who\":\"bob@dev.local\",\"whoFirstName\":\"John\",\"whoSurname\":\"One\"," + + "\"currentUser\":\"bob@dev.local\",\"isNewLocalBook\":true}"; + + var result = _api.AddCloudBookStatusFields(original, "Some Brand New Book"); + + var json = JObject.Parse(result); + Assert.That( + (string)json["whoFirstName"], + Is.EqualTo("Bob Helper"), + "a new local book's avatar should use the account's display name, not the email" + ); + Assert.That(json["whoSurname"].Type, Is.EqualTo(JTokenType.Null)); + Assert.That((string)json["currentUserName"], Is.EqualTo("Bob Helper")); + } + + // Regression (15 Jul 2026 review): the display name is cached per SESSION, but the same + // instance supports switching accounts (batch item 9) -- after Bob signs out and Alice + // signs in, a new local book must NOT keep showing Bob's display name. + [Test] + public void AddCloudBookStatusFields_AccountSwitch_RefreshesDisplayName() + { + _executor.Handler = req => + { + if (req.Resource == "rest/v1/rpc/members_list") + return FakeResponses.Make( + HttpStatusCode.OK, + new JArray( + new JObject + { + ["id"] = 1, + ["email"] = "bob@dev.local", + ["display_name"] = "Bob Helper", + ["role"] = "editor", + ["user_id"] = "user-bob", + }, + new JObject + { + ["id"] = 2, + ["email"] = "alice@dev.local", + ["display_name"] = "Alice Admin", + ["role"] = "admin", + ["user_id"] = "user-alice", + } + ).ToString() + ); + return FakeResponses.Make( + HttpStatusCode.OK, + new JObject + { + ["books"] = new JArray(), + ["groups"] = new JArray(), + ["max_event_id"] = 1, + }.ToString() + ); + }; + EnsureCloudCollection().IsBookPresentInRepo("force hydration"); + const string original = + "{\"who\":\"x\",\"whoFirstName\":null,\"whoSurname\":null," + + "\"currentUser\":\"x\",\"isNewLocalBook\":true}"; + + _auth.SignIn("bob@dev.local", "irrelevant"); + var bobJson = JObject.Parse(_api.AddCloudBookStatusFields(original, "Bob's new book")); + // Sanity: Bob's name resolved and is now cached. + Assert.That((string)bobJson["whoFirstName"], Is.EqualTo("Bob Helper")); + + _auth.SignIn("alice@dev.local", "irrelevant"); + var aliceJson = JObject.Parse( + _api.AddCloudBookStatusFields(original, "Alice's new book") + ); + Assert.That( + (string)aliceJson["whoFirstName"], + Is.EqualTo("Alice Admin"), + "after an account switch the previous member's cached display name must not leak" + ); + Assert.That((string)aliceJson["currentUserName"], Is.EqualTo("Alice Admin")); + } + + [Test] + public void AddCloudBookStatusFields_WhoIsAnotherMember_LeavesWhoAndNamesAlone() + { + HydrateWith(Book("book-1", "My Book", currentVersionSeq: 5)); + _auth.SignIn("bob@dev.local", "irrelevant"); + // A real repo lock: who is a member email, with the durable display name fields. + const string original = + "{\"who\":\"carol@dev.local\",\"whoFirstName\":\"Carol the Editor\"," + + "\"whoSurname\":null,\"currentUser\":\"registration@example.com\"}"; + + var result = _api.AddCloudBookStatusFields(original, "My Book"); + + var json = JObject.Parse(result); + Assert.That((string)json["who"], Is.EqualTo("carol@dev.local")); + Assert.That((string)json["whoFirstName"], Is.EqualTo("Carol the Editor")); + } + + [Test] + public void AddCloudBookStatusFields_SignedOut_LeavesCurrentUserAlone() + { + HydrateWith(Book("book-1", "My Book", currentVersionSeq: 5)); + const string original = "{\"currentUser\":\"registration@example.com\"}"; + + var result = _api.AddCloudBookStatusFields(original, "My Book"); + + var json = JObject.Parse(result); + Assert.That( + (string)json["currentUser"], + Is.EqualTo("registration@example.com"), + "with no account email available there is nothing better to report" + ); + } + + [Test] + public void AddCloudBookStatusFields_NoBookFolderName_OnlyAddsCollectionWideFlags() + { + HydrateWith(); + const string original = "{\"who\":\"\"}"; + + var result = _api.AddCloudBookStatusFields(original, null); + + var json = JObject.Parse(result); + Assert.That(json["requiresSignIn"], Is.Not.Null); + Assert.That( + json["repoVersionSeq"], + Is.Null, + "there's no specific book to report a version for" + ); + } + } +} diff --git a/src/BloomTests/TeamCollection/TeamCollectionAutoApplyTests.cs b/src/BloomTests/TeamCollection/TeamCollectionAutoApplyTests.cs new file mode 100644 index 000000000000..492a42ab4b12 --- /dev/null +++ b/src/BloomTests/TeamCollection/TeamCollectionAutoApplyTests.cs @@ -0,0 +1,660 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using Bloom.TeamCollection; +using BloomTemp; +using BloomTests.DataBuilders; +using Moq; +using NUnit.Framework; +using SIL.IO; + +namespace BloomTests.TeamCollection +{ + // Batch item 4+5 (auto-apply remote changes): unit tests for the eligibility/re-verification + // logic in TeamCollection.HandleModifiedFile / ProcessAutoApplyRemoteChange, exercised through + // TestFolderTeamCollection's test-only CanAutoApplyRemoteChanges toggle (a real FolderTeamCollection + // never sets this true; CloudTeamCollection is the only production backend that does, but it's + // far too heavy to construct in a unit test). See RemoteBookAutoApplyQueueTests for the queue + // class itself, and TeamCollectionTests for the folder-TC message-only path this must leave + // completely unchanged when CanAutoApplyRemoteChanges is false (the default). + public class TeamCollectionAutoApplyTests + { + private TemporaryFolder _sharedFolder; + private TemporaryFolder _collectionFolder; + private TestFolderTeamCollection _collection; + private Mock _mockTcManager; + private TeamCollectionMessageLog _tcLog; + + [SetUp] + public void Setup() + { + TeamCollectionManager.ForceCurrentUserForTests("me@somewhere.org"); + + _sharedFolder = new TemporaryFolder("TeamCollectionAutoApply_Shared"); + _collectionFolder = new TemporaryFolder("TeamCollectionAutoApply_Local"); + _tcLog = new TeamCollectionMessageLog( + TeamCollectionManager.GetTcLogPathFromLcPath(_collectionFolder.FolderPath) + ); + FolderTeamCollection.CreateTeamCollectionLinkFile( + _collectionFolder.FolderPath, + _sharedFolder.FolderPath + ); + + _mockTcManager = new Mock(); + _collection = new TestFolderTeamCollection( + _mockTcManager.Object, + _collectionFolder.FolderPath, + _sharedFolder.FolderPath, + _tcLog + ); + _collection.CollectionId = Bloom.TeamCollection.TeamCollection.GenerateCollectionId(); + _collection.TestOnly_MakeAutoApplyQueueSynchronous(); + } + + [TearDown] + public void TearDown() + { + TeamCollectionManager.ForceCurrentUserForTests(null); + _collectionFolder.Dispose(); + _sharedFolder.Dispose(); + } + + // Puts a book in the repo, then rewrites its local content and local status record so + // HasBeenChangedRemotely(bookFolderName) is true (mirrors + // TeamCollectionTests.HandleModifiedFile_NoConflictBookChangedNotCheckedOut...'s setup). + private void SetUpBookChangedRemotely(string bookFolderName, out string bookFolderPath) + { + var bookBuilder = new BookFolderBuilder() + .WithRootFolder(_collectionFolder.FolderPath) + .WithTitle(bookFolderName) + .WithHtm("This is pretending to be new content from remote") + .Build(); + bookFolderPath = bookBuilder.BuiltBookFolderPath; + var status = _collection.PutBook(bookFolderPath); + RobustFile.WriteAllText( + bookBuilder.BuiltBookHtmPath, + "This is pretending to be old content" + ); + // pretending this (the pre-change checksum) is what local status recorded before + // the remote change described above. + _collection.WriteLocalStatus( + bookFolderName, + status.WithChecksum( + Bloom.TeamCollection.TeamCollection.MakeChecksum(bookFolderPath) + ) + ); + } + + [Test] + public void HandleModifiedFile_AutoApplyEnabled_UnlockedBookChangedRemotely_CopiesContentAndWritesNoNewStuffMessage() + { + const string bookFolderName = "Auto Apply Book"; + SetUpBookChangedRemotely(bookFolderName, out var bookFolderPath); + _collection.AutoApplyRemoteChangesForTests = true; + + Assert.That( + _collection.HasBeenChangedRemotely(bookFolderName), + Is.True, + "sanity: book should look changed remotely before the auto-apply pass runs" + ); + var prevMessages = _tcLog.Messages.Count; + + // System Under Test // (queue is synchronous per Setup, so this call fully completes + // the auto-apply pass before returning) + _collection.HandleModifiedFile( + new BookRepoChangeEventArgs() { BookFileName = $"{bookFolderName}.bloom" } + ); + + // Verification + Assert.That( + _tcLog.Messages.Count, + Is.EqualTo(prevMessages), + "auto-apply should succeed silently -- no 'click to see updates' message" + ); + Assert.That( + _collection.HasBeenChangedRemotely(bookFolderName), + Is.False, + "local content should now match the repo" + ); + var htmlPath = Path.Combine(bookFolderPath, bookFolderName + ".htm"); + Assert.That( + RobustFile.ReadAllText(htmlPath), + Does.Contain("new content from remote"), + "the book folder's actual content should have been overwritten with the repo version" + ); + } + + [Test] + public void HandleModifiedFile_AutoApplyDisabled_FolderTcDefault_LeavesContentAloneAndWritesNewStuffMessage() + { + // AutoApplyRemoteChangesForTests defaults to false -- this is exactly today's + // production folder-TC behavior (see TeamCollectionTests for the equivalent test + // against a real, non-test FolderTeamCollection). + const string bookFolderName = "No Auto Apply Book"; + SetUpBookChangedRemotely(bookFolderName, out var bookFolderPath); + var prevMessages = _tcLog.Messages.Count; + + // System Under Test // + _collection.HandleModifiedFile( + new BookRepoChangeEventArgs() { BookFileName = $"{bookFolderName}.bloom" } + ); + + // Verification + Assert.That( + _tcLog.Messages[prevMessages].MessageType, + Is.EqualTo(MessageAndMilestoneType.NewStuff) + ); + Assert.That( + _tcLog.Messages[prevMessages].L10NId, + Is.EqualTo("TeamCollection.BookModifiedRemotely") + ); + Assert.That( + _collection.HasBeenChangedRemotely(bookFolderName), + Is.True, + "without auto-apply, the local content must be left untouched until the user acts" + ); + } + + [Test] + public void ProcessAutoApplyRemoteChange_CheckedOutHereSinceQueueing_SkipsApply() + { + // Simulates the race the task calls out explicitly: HandleModifiedFile queues the book + // while it looked safe, but by the time the worker actually runs, the user has checked + // it out here. Re-verification must catch this and back off rather than clobbering + // whatever the user is about to do with their checkout. + const string bookFolderName = "Raced Checkout Book"; + SetUpBookChangedRemotely(bookFolderName, out _); + _collection.AutoApplyRemoteChangesForTests = true; + _collection.AttemptLock(bookFolderName); // checks it out here, to the current test user + + var prevMessages = _tcLog.Messages.Count; + var prevInvocations = _mockTcManager.Invocations.Count; + + // System Under Test // (bypasses the queue -- see the method's own doc comment) + _collection.TestOnly_ProcessAutoApplyRemoteChange(bookFolderName); + + // Verification: no copy attempted (repo checksum still doesn't match a book we never + // actually fetched), and no NEW message or status notification beyond whatever the + // AttemptLock call above itself already produced (this is a silent back-off, not an + // error -- see the method's doc comment). + Assert.That( + _tcLog.Messages.Count, + Is.EqualTo(prevMessages), + "re-verification should silently decline to apply, not write any message" + ); + Assert.That( + _mockTcManager.Invocations.Count, + Is.EqualTo(prevInvocations), + "declining to apply should not touch book status either" + ); + } + + [Test] + public void ProcessAutoApplyRemoteChange_AlreadyUpToDate_SkipsApply() + { + // If the book is no longer considered changed remotely (e.g. a previous pass, or the + // user, already brought it up to date), the worker must not redundantly re-copy it. + const string bookFolderName = "Already Current Book"; + var bookBuilder = new BookFolderBuilder() + .WithRootFolder(_collectionFolder.FolderPath) + .WithTitle(bookFolderName) + .Build(); + _collection.PutBook(bookBuilder.BuiltBookFolderPath); // local status matches repo already + _collection.AutoApplyRemoteChangesForTests = true; + + Assert.That( + _collection.HasBeenChangedRemotely(bookFolderName), + Is.False, + "sanity: nothing changed remotely for this book" + ); + var prevMessages = _tcLog.Messages.Count; + var prevInvocations = _mockTcManager.Invocations.Count; + + // System Under Test // + _collection.TestOnly_ProcessAutoApplyRemoteChange(bookFolderName); + + Assert.That(_tcLog.Messages.Count, Is.EqualTo(prevMessages)); + Assert.That( + _mockTcManager.Invocations.Count, + Is.EqualTo(prevInvocations), + "a book that's already current should not trigger any status notification" + ); + } + + [Test] + public void ProcessAutoApplyRemoteChange_CopyFails_FallsBackToNewStuffMessage() + { + // Simulates a copy failure (e.g. a corrupt/missing repo file) at the moment the worker + // actually tries to apply the change. Even with auto-apply enabled, the user must still + // end up with exactly the same fallback notification a non-auto-apply backend gives. + const string bookFolderName = "Copy Fails Book"; + SetUpBookChangedRemotely(bookFolderName, out _); + _collection.AutoApplyRemoteChangesForTests = true; + + // Make the repo copy fail while still leaving HasBeenChangedRemotely true: corrupt the + // repo's zip file (rather than deleting it -- a MISSING repo file makes GetStatus fall + // back to the local status record, which would make the book look already up to date + // instead of exercising the copy-failure path this test wants). + var repoBookPath = _collection.GetPathToBookFileInRepo(bookFolderName); + RobustFile.WriteAllText(repoBookPath, "this is not a valid zip file"); + + Assert.That( + _collection.HasBeenChangedRemotely(bookFolderName), + Is.True, + "sanity: a corrupt repo file should still look different from the recorded local status" + ); + + var prevMessages = _tcLog.Messages.Count; + + // System Under Test // + _collection.TestOnly_ProcessAutoApplyRemoteChange(bookFolderName); + + // Verification: the fallback message-only behavior appears among whatever else got + // logged (a corrupt zip may also log its own ErrorNoReload as a side effect of the + // eligibility checks reading repo status; this test only cares that the user still + // ends up with the same fallback notification a non-auto-apply backend would give). + var newMessages = _tcLog.Messages.Skip(prevMessages).ToList(); + Assert.That( + newMessages.Any(m => + m.MessageType == MessageAndMilestoneType.NewStuff + && m.L10NId == "TeamCollection.BookModifiedRemotely" + ), + Is.True, + "a copy failure must still leave the user with the fallback 'click to see updates' message" + ); + } + + // ------------------------------------------------------------------ + // Batch item 8 (John's recovery decision, 9 Jul 2026): before a sync overwrite, local + // content that changed since the last sync is preserved (cloud: .bloomSource in Lost and + // Found); clean local content is overwritten without ceremony. The preserve DECISION is + // base-class logic, recorded here via TestFolderTeamCollection's hook override. + // ------------------------------------------------------------------ + + [Test] + public void ProcessAutoApplyRemoteChange_LocalModifiedSinceLastSync_PreservesBeforeApplying() + { + const string bookFolderName = "Locally Drifted Book"; + SetUpBookChangedRemotely(bookFolderName, out var bookFolderPath); + // On top of the remote change, the LOCAL copy has also drifted from what the last sync + // recorded (the force-stolen-checkout shape: local edits the status file knows nothing + // about, since cloud checkouts never write it). + RobustFile.WriteAllText( + Path.Combine(bookFolderPath, bookFolderName + ".htm"), + "precious local work the sync must not silently discard" + ); + _collection.AutoApplyRemoteChangesForTests = true; + + // System Under Test // + _collection.TestOnly_ProcessAutoApplyRemoteChange(bookFolderName); + + Assert.That( + _collection.PreservedForRecovery, + Is.EqualTo(new[] { bookFolderName }), + "the drifted local copy must be preserved exactly once, before the overwrite" + ); + Assert.That( + RobustFile.ReadAllText(Path.Combine(bookFolderPath, bookFolderName + ".htm")), + Does.Contain("new content from remote"), + "after preserving, the sync must still make local consistent with the repo (John's decision: apply, don't block)" + ); + } + + [Test] + public void ProcessAutoApplyRemoteChange_LocalCleanSinceLastSync_DoesNotPreserve() + { + const string bookFolderName = "Clean Local Book"; + // SetUpBookChangedRemotely leaves the local copy EXACTLY matching its recorded local + // status checksum (only the repo differs), i.e. the everyday "teammate checked in a + // change" case -- overwriting loses nothing, so nothing should go to Lost and Found. + SetUpBookChangedRemotely(bookFolderName, out var bookFolderPath); + _collection.AutoApplyRemoteChangesForTests = true; + Assert.That( + _collection.HasBeenChangedRemotely(bookFolderName), + Is.True, + "sanity: the repo version differs, so the apply itself must still happen" + ); + + // System Under Test // + _collection.TestOnly_ProcessAutoApplyRemoteChange(bookFolderName); + + Assert.That( + _collection.PreservedForRecovery, + Is.Empty, + "an unmodified local copy must be overwritten without a Lost and Found entry" + ); + Assert.That( + RobustFile.ReadAllText(Path.Combine(bookFolderPath, bookFolderName + ".htm")), + Does.Contain("new content from remote"), + "the apply itself must still have happened" + ); + } + + // ------------------------------------------------------------------ + // Batch item 7 (progressive join): a book that exists in the repo but has NO local folder + // at all (as opposed to the "changed remotely" scenarios above, which all start from an + // existing local folder) goes through DownloadMissingBookInBackground instead of the + // auto-apply re-verification -- there's no existing local content to check eligibility + // against or protect, just a straightforward fetch. + // ------------------------------------------------------------------ + + private string PutBookThenRemoveLocalFolder(string bookFolderName) + { + var bookBuilder = new BookFolderBuilder() + .WithRootFolder(_collectionFolder.FolderPath) + .WithTitle(bookFolderName) + .WithHtm("Content that only exists in the repo") + .Build(); + var folderPath = bookBuilder.BuiltBookFolderPath; + _collection.PutBook(folderPath); // gets it into the repo + SIL.IO.RobustIO.DeleteDirectoryAndContents(folderPath); // simulate "never downloaded here" + return folderPath; + } + + [Test] + public void ProcessAutoApplyRemoteChange_NoLocalFolderAtAll_DownloadsTheBook() + { + const string bookFolderName = "Never Downloaded Book"; + var folderPath = PutBookThenRemoveLocalFolder(bookFolderName); + _collection.AutoApplyRemoteChangesForTests = true; + + Assert.That( + Directory.Exists(folderPath), + Is.False, + "sanity: the local folder must not exist before the System Under Test call" + ); + + // System Under Test // (bypasses the queue -- TestOnly_ProcessAutoApplyRemoteChange + // exercises the same worker method the queue would eventually call) + _collection.TestOnly_ProcessAutoApplyRemoteChange(bookFolderName); + + Assert.That( + Directory.Exists(folderPath), + Is.True, + "the book should have been downloaded from the repo into a fresh local folder" + ); + Assert.That( + RobustFile.ReadAllText(Path.Combine(folderPath, bookFolderName + ".htm")), + Does.Contain("Content that only exists in the repo") + ); + } + + [Test] + public void ProcessAutoApplyRemoteChange_NoLocalFolderAndGoneFromRepoToo_DoesNothing() + { + // Simulates the book vanishing from the repo (e.g. deleted by a teammate) between the + // moment it was queued and the moment the background worker actually got to it. + const string bookFolderName = "Deleted Before Download Book"; + var folderPath = PutBookThenRemoveLocalFolder(bookFolderName); + _collection.DeleteBookFromRepo(folderPath); // also removes it from _repoFolder + _collection.AutoApplyRemoteChangesForTests = true; + + // System Under Test // + _collection.TestOnly_ProcessAutoApplyRemoteChange(bookFolderName); + + Assert.That( + Directory.Exists(folderPath), + Is.False, + "a book that's gone from the repo by the time the worker runs must not be recreated" + ); + } + + // ------------------------------------------------------------------ + // Batch item 7 (progressive join): SyncAtStartup's "brand new book!" branch reroutes to + // the same background queue for a backend with CanAutoApplyRemoteChanges (cloud), instead + // of blocking the startup sync dialog on every missing book's full download. Folder TCs + // (CanAutoApplyRemoteChanges always false) must be completely unaffected. + // ------------------------------------------------------------------ + + [Test] + public void SyncAtStartup_FolderTcDefault_NewRepoBookOnly_FetchesSynchronously() + { + const string bookFolderName = "Folder TC New Book"; + var folderPath = PutBookThenRemoveLocalFolder(bookFolderName); + // AutoApplyRemoteChangesForTests defaults to false: real folder-TC behavior, unchanged + // by this batch item. + + // System Under Test // + _collection.SyncAtStartup(new ProgressSpy(), firstTimeJoin: false); + + Assert.That( + Directory.Exists(folderPath), + Is.True, + "a folder TC must still fetch a brand-new repo book synchronously, inside SyncAtStartup itself" + ); + } + + [Test] + public void SyncAtStartup_AutoApplyEnabled_NewRepoBookOnly_QueuesInsteadOfFetchingSynchronously() + { + const string bookFolderName = "Cloud-Like New Book"; + var folderPath = PutBookThenRemoveLocalFolder(bookFolderName); + _collection.AutoApplyRemoteChangesForTests = true; + _collection.TestOnly_MakeAutoApplyQueueSynchronous(); + + // System Under Test // (queue is synchronous, so the whole background pass completes + // inline before SyncAtStartup returns -- this test asserts the REROUTED path still gets + // the book, deterministically; see the async test below for the non-blocking behavior) + _collection.SyncAtStartup(new ProgressSpy(), firstTimeJoin: false); + + Assert.That( + Directory.Exists(folderPath), + Is.True, + "the rerouted background download should still successfully fetch the book" + ); + } + + // ------------------------------------------------------------------ + // Post-batch defect fix (10 Jul 2026): QueueMissingRepoBooksForBackgroundDownload is the + // self-healing retry pass -- the in-memory queue does not survive a Bloom restart (e.g. + // the join flow's pullDown-then-relaunch pattern), and the poll only raises events for + // books whose repo state CHANGED, so a locally-missing repo book that slipped past the + // startup sync was previously never retried. CloudTeamCollection calls this when + // monitoring starts and after every poll. + // ------------------------------------------------------------------ + + [Test] + public void QueueMissingRepoBooks_AutoApplyEnabled_DownloadsMissingBook() + { + const string bookFolderName = "Dropped Download Book"; + var folderPath = PutBookThenRemoveLocalFolder(bookFolderName); + _collection.AutoApplyRemoteChangesForTests = true; + _collection.TestOnly_MakeAutoApplyQueueSynchronous(); + + // System Under Test // + _collection.QueueMissingRepoBooksForBackgroundDownload(); + + Assert.That( + Directory.Exists(folderPath), + Is.True, + "a repo book with no local folder should be queued and downloaded by the retry pass" + ); + Assert.That( + RobustFile.ReadAllText(Path.Combine(folderPath, bookFolderName + ".htm")), + Does.Contain("Content that only exists in the repo") + ); + } + + // Puts a book in the repo, locks it as `lockedBy`, then removes the local folder -- + // the "missing locally but checked out" setup both lock-guard tests below need. + private string PutLockedBookThenRemoveLocalFolder(string bookFolderName, string lockedBy) + { + var bookBuilder = new BookFolderBuilder() + .WithRootFolder(_collectionFolder.FolderPath) + .WithTitle(bookFolderName) + .WithHtm("Content that only exists in the repo") + .Build(); + var folderPath = bookBuilder.BuiltBookFolderPath; + _collection.PutBook(folderPath); + _collection.AttemptLock(bookFolderName, lockedBy); + SIL.IO.RobustIO.DeleteDirectoryAndContents(folderPath); + Assert.That( + _collection.WhoHasBookLocked(bookFolderName), + Is.EqualTo(lockedBy), + "sanity: the repo copy must be locked before the System Under Test call" + ); + return folderPath; + } + + [Test] + public void QueueMissingRepoBooks_BookLockedByCurrentUser_SkipsIt() + { + // Locked BY ME + no local folder = the local-rename-mid-checkin edge (the old repo + // name intentionally has no local folder); downloading it would resurrect the + // pre-rename book. + const string bookFolderName = "My Locked Missing Book"; + var folderPath = PutLockedBookThenRemoveLocalFolder( + bookFolderName, + "me@somewhere.org" // the ForceCurrentUserForTests identity from Setup + ); + _collection.AutoApplyRemoteChangesForTests = true; + _collection.TestOnly_MakeAutoApplyQueueSynchronous(); + + // System Under Test // + _collection.QueueMissingRepoBooksForBackgroundDownload(); + + Assert.That( + Directory.Exists(folderPath), + Is.False, + "a repo book locked by the current user must not be downloaded (rename-mid-checkin edge)" + ); + } + + [Test] + public void QueueMissingRepoBooks_BookLockedByCurrentUserOnAnotherMachine_StillDownloadsIt() + { + // Preflight review finding (10 Jul 2026): the rename-mid-checkin edge the skip + // protects is MACHINE-local. A book the current user has checked out on a DIFFERENT + // machine must still be downloaded here (SyncAtStartup already fetches it on + // restart; the retry pass must agree). + const string bookFolderName = "My Other Machine Book"; + var bookBuilder = new BookFolderBuilder() + .WithRootFolder(_collectionFolder.FolderPath) + .WithTitle(bookFolderName) + .WithHtm("Content that only exists in the repo") + .Build(); + var folderPath = bookBuilder.BuiltBookFolderPath; + var status = _collection.PutBook(folderPath); + // Stamp a lock held by the current user but recorded for a DIFFERENT machine + // (WithLockedBy always stamps CurrentMachine, so overwrite lockedWhere directly). + var lockedStatus = status.WithLockedBy("me@somewhere.org"); + lockedStatus.lockedWhere = "SomeOtherComputer"; + _collection.WriteBookStatus(bookFolderName, lockedStatus); + SIL.IO.RobustIO.DeleteDirectoryAndContents(folderPath); + Assert.That( + _collection.WhoHasBookLocked(bookFolderName), + Is.EqualTo("me@somewhere.org"), + "sanity: the repo copy must be locked by the current user" + ); + Assert.That( + _collection.WhatComputerHasBookLocked(bookFolderName), + Is.EqualTo("SomeOtherComputer"), + "sanity: the lock must be recorded for a different machine" + ); + _collection.AutoApplyRemoteChangesForTests = true; + _collection.TestOnly_MakeAutoApplyQueueSynchronous(); + + // System Under Test // + _collection.QueueMissingRepoBooksForBackgroundDownload(); + + Assert.That( + Directory.Exists(folderPath), + Is.True, + "a book the current user checked out on a DIFFERENT machine must still download here" + ); + } + + [Test] + public void QueueMissingRepoBooks_BookLockedBySomeoneElse_StillDownloadsIt() + { + // A teammate's lock must NOT block downloading the committed content (that is + // exactly what Receive fetches for a locked book). e2e-4 (10 Jul 2026): an any-lock + // skip turned one transient download failure into "book missing for as long as the + // teammate held the lock". + const string bookFolderName = "Teammate Locked Missing Book"; + var folderPath = PutLockedBookThenRemoveLocalFolder( + bookFolderName, + "fred@somewhere.org" + ); + _collection.AutoApplyRemoteChangesForTests = true; + _collection.TestOnly_MakeAutoApplyQueueSynchronous(); + + // System Under Test // + _collection.QueueMissingRepoBooksForBackgroundDownload(); + + Assert.That( + Directory.Exists(folderPath), + Is.True, + "a repo book locked by someone ELSE must still be downloaded by the retry pass" + ); + Assert.That( + RobustFile.ReadAllText(Path.Combine(folderPath, bookFolderName + ".htm")), + Does.Contain("Content that only exists in the repo") + ); + } + + [Test] + public void QueueMissingRepoBooks_BookAlreadyLocal_LeavesItAlone() + { + const string bookFolderName = "Already Local Book"; + SetUpBookChangedRemotely(bookFolderName, out var bookFolderPath); + // Local content now deliberately differs from the repo ("old content" vs "new content + // from remote") -- if the retry pass wrongly re-downloaded an EXISTING book, the local + // text would change, which the assertion below would catch. + _collection.AutoApplyRemoteChangesForTests = true; + _collection.TestOnly_MakeAutoApplyQueueSynchronous(); + + // System Under Test // + _collection.QueueMissingRepoBooksForBackgroundDownload(); + + Assert.That( + RobustFile.ReadAllText(Path.Combine(bookFolderPath, bookFolderName + ".htm")), + Does.Contain("pretending to be old content"), + "a book that already has a local folder must not be touched by the missing-books pass" + ); + } + + [Test] + public void QueueMissingRepoBooks_AutoApplyDisabled_FolderTcDefault_DoesNothing() + { + const string bookFolderName = "Folder TC Missing Book"; + var folderPath = PutBookThenRemoveLocalFolder(bookFolderName); + // AutoApplyRemoteChangesForTests defaults to false: folder TCs have no background + // download pipeline, so the retry pass must be a complete no-op there. + _collection.TestOnly_MakeAutoApplyQueueSynchronous(); + + // System Under Test // + _collection.QueueMissingRepoBooksForBackgroundDownload(); + + Assert.That( + Directory.Exists(folderPath), + Is.False, + "a folder TC (CanAutoApplyRemoteChanges false) must not background-download anything" + ); + } + + [Test] + public void SyncAtStartup_AutoApplyEnabled_RealBackgroundWorker_EventuallyFetchesWithoutBlocking() + { + const string bookFolderName = "Cloud-Like Async New Book"; + var folderPath = PutBookThenRemoveLocalFolder(bookFolderName); + _collection.AutoApplyRemoteChangesForTests = true; + // Real (default Task.Run) worker this time -- proves the download genuinely happens on + // a background thread rather than merely being deterministic-but-still-synchronous. + + // System Under Test // + _collection.SyncAtStartup(new ProgressSpy(), firstTimeJoin: false); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline && !Directory.Exists(folderPath)) + Thread.Sleep(20); + + Assert.That( + Directory.Exists(folderPath), + Is.True, + "the book should eventually be downloaded in the background even though SyncAtStartup itself didn't fetch it" + ); + } + } +} diff --git a/src/BloomTests/TeamCollection/TeamCollectionLinkTests.cs b/src/BloomTests/TeamCollection/TeamCollectionLinkTests.cs new file mode 100644 index 000000000000..1d1061ab0258 --- /dev/null +++ b/src/BloomTests/TeamCollection/TeamCollectionLinkTests.cs @@ -0,0 +1,270 @@ +using System; +using System.IO; +using Bloom.TeamCollection; +using BloomTemp; +using NUnit.Framework; +using SIL.IO; + +namespace BloomTests.TeamCollection +{ + /// + /// Tests for — parse/write round-trips, + /// folder form, cloud form, garbage content, missing file, both-forms-present error. + /// + [TestFixture] + public class TeamCollectionLinkTests + { + // ----------------------------------------------------------------------- + // Parse: folder form + // ----------------------------------------------------------------------- + + [Test] + public void Parse_FolderPath_ReturnsIsFolder() + { + var link = TeamCollectionLink.Parse(@"C:\Users\Alice\Dropbox\MyCollection - TC"); + Assert.IsNotNull(link); + Assert.IsTrue(link.IsFolder); + Assert.IsFalse(link.IsCloud); + } + + [Test] + public void Parse_FolderPath_RepoFolderPathPreserved() + { + const string folderPath = @"C:\Users\Alice\Dropbox\MyCollection - TC"; + var link = TeamCollectionLink.Parse(folderPath); + Assert.AreEqual(folderPath, link.RepoFolderPath); + Assert.IsNull(link.CloudCollectionId); + } + + [Test] + public void Parse_FolderPath_WithLeadingAndTrailingWhitespace_TrimsWhitespace() + { + // The legacy RepoFolderPathFromLinkPath trims; Parse must too. + const string folderPath = @"C:\Users\Alice\Dropbox\MyCollection - TC"; + var link = TeamCollectionLink.Parse(" " + folderPath + " \r\n"); + Assert.IsNotNull(link); + // Parse receives the already-trimmed content; trimming is done by FromFile. + // If the caller passes whitespace, Parse treats the whole thing as a folder path. + // The test therefore just verifies it does not throw. + } + + // ----------------------------------------------------------------------- + // Parse: cloud form + // ----------------------------------------------------------------------- + + [Test] + public void Parse_CloudUri_ReturnsIsCloud() + { + const string collId = "550e8400-e29b-41d4-a716-446655440000"; + var link = TeamCollectionLink.Parse(TeamCollectionLink.CloudUriPrefix + collId); + Assert.IsNotNull(link); + Assert.IsTrue(link.IsCloud); + Assert.IsFalse(link.IsFolder); + } + + [Test] + public void Parse_CloudUri_CloudCollectionIdExtracted() + { + const string collId = "550e8400-e29b-41d4-a716-446655440000"; + var link = TeamCollectionLink.Parse(TeamCollectionLink.CloudUriPrefix + collId); + Assert.AreEqual(collId, link.CloudCollectionId); + Assert.IsNull(link.RepoFolderPath); + } + + [Test] + public void Parse_CloudUri_ShortId_Accepted() + { + // We do not enforce GUID format; short IDs must work for unit-test scenarios. + const string collId = "abc123"; + var link = TeamCollectionLink.Parse(TeamCollectionLink.CloudUriPrefix + collId); + Assert.AreEqual(collId, link.CloudCollectionId); + } + + // ----------------------------------------------------------------------- + // Parse: garbage / invalid content + // ----------------------------------------------------------------------- + + [Test] + public void Parse_NullOrEmpty_ReturnsNull() + { + Assert.IsNull(TeamCollectionLink.Parse(null)); + Assert.IsNull(TeamCollectionLink.Parse("")); + Assert.IsNull(TeamCollectionLink.Parse(" ")); + } + + [Test] + public void Parse_CloudUriMissingId_ThrowsInvalidLinkException() + { + // "cloud://sil.bloom/collection/" with nothing after the trailing slash + Assert.Throws(() => + TeamCollectionLink.Parse(TeamCollectionLink.CloudUriPrefix) + ); + } + + [Test] + public void Parse_CloudUriWrongAuthority_ThrowsInvalidLinkException() + { + // A "cloud://" URI with a different authority is clearly wrong. + Assert.Throws(() => + TeamCollectionLink.Parse("cloud://unknown.host/collection/some-id") + ); + } + + [Test] + public void Parse_CloudUriWithWhitespaceInId_ThrowsInvalidLinkException() + { + Assert.Throws(() => + TeamCollectionLink.Parse( + TeamCollectionLink.CloudUriPrefix + "invalid id with spaces" + ) + ); + } + + // ----------------------------------------------------------------------- + // Round-trip: ToFileContent + // ----------------------------------------------------------------------- + + [Test] + public void ToFileContent_FolderLink_RoundTrips() + { + const string folderPath = @"\\server\share\MyCollection - TC"; + var link = TeamCollectionLink.ForFolder(folderPath); + var content = link.ToFileContent(); + // Sanity: content is the folder path verbatim. + Assert.AreEqual(folderPath, content); + // Re-parse must survive the round-trip. + var reparsed = TeamCollectionLink.Parse(content); + Assert.IsTrue(reparsed.IsFolder); + Assert.AreEqual(folderPath, reparsed.RepoFolderPath); + } + + [Test] + public void ToFileContent_CloudLink_RoundTrips() + { + const string collId = "550e8400-e29b-41d4-a716-446655440000"; + var link = TeamCollectionLink.ForCloud(collId); + var content = link.ToFileContent(); + // Sanity: content starts with the expected prefix. + Assert.IsTrue(content.StartsWith(TeamCollectionLink.CloudUriPrefix)); + // Re-parse must survive the round-trip. + var reparsed = TeamCollectionLink.Parse(content); + Assert.IsTrue(reparsed.IsCloud); + Assert.AreEqual(collId, reparsed.CloudCollectionId); + } + + // ----------------------------------------------------------------------- + // FromFile: file I/O + // ----------------------------------------------------------------------- + + [Test] + public void FromFile_MissingFile_ReturnsNull() + { + var result = TeamCollectionLink.FromFile(@"C:\nonexistent\path\TeamCollectionLink.txt"); + Assert.IsNull(result); + } + + [Test] + public void FromFile_FolderLinkFile_ParsesCorrectly() + { + using var tempFolder = new TemporaryFolder("TCLinkTests_FromFile_Folder"); + const string folderPath = @"C:\Shared\MyCollection - TC"; + var linkFilePath = Path.Combine( + tempFolder.FolderPath, + TeamCollectionManager.TeamCollectionLinkFileName + ); + RobustFile.WriteAllText(linkFilePath, folderPath); + + var link = TeamCollectionLink.FromFile(linkFilePath); + Assert.IsNotNull(link); + Assert.IsTrue(link.IsFolder); + Assert.AreEqual(folderPath, link.RepoFolderPath); + } + + [Test] + public void FromFile_CloudLinkFile_ParsesCorrectly() + { + using var tempFolder = new TemporaryFolder("TCLinkTests_FromFile_Cloud"); + const string collId = "550e8400-e29b-41d4-a716-446655440000"; + var linkFilePath = Path.Combine( + tempFolder.FolderPath, + TeamCollectionManager.TeamCollectionLinkFileName + ); + RobustFile.WriteAllText(linkFilePath, TeamCollectionLink.CloudUriPrefix + collId); + + var link = TeamCollectionLink.FromFile(linkFilePath); + Assert.IsNotNull(link); + Assert.IsTrue(link.IsCloud); + Assert.AreEqual(collId, link.CloudCollectionId); + } + + [Test] + public void FromFile_TrimsWhitespaceFromFileContent() + { + // Legacy files may have trailing newlines. + using var tempFolder = new TemporaryFolder("TCLinkTests_FromFile_Trim"); + const string folderPath = @"C:\Shared\MyCollection - TC"; + var linkFilePath = Path.Combine( + tempFolder.FolderPath, + TeamCollectionManager.TeamCollectionLinkFileName + ); + RobustFile.WriteAllText(linkFilePath, folderPath + "\r\n"); + + var link = TeamCollectionLink.FromFile(linkFilePath); + Assert.IsNotNull(link); + Assert.AreEqual(folderPath, link.RepoFolderPath); + } + + // ----------------------------------------------------------------------- + // WriteToFile + // ----------------------------------------------------------------------- + + [Test] + public void WriteToFile_FolderLink_WritesCorrectContent() + { + using var tempFolder = new TemporaryFolder("TCLinkTests_WriteFile_Folder"); + const string folderPath = @"C:\Shared\MyCollection - TC"; + var link = TeamCollectionLink.ForFolder(folderPath); + var linkFilePath = Path.Combine( + tempFolder.FolderPath, + TeamCollectionManager.TeamCollectionLinkFileName + ); + + link.WriteToFile(linkFilePath); + + Assert.IsTrue(RobustFile.Exists(linkFilePath)); + var written = RobustFile.ReadAllText(linkFilePath); + Assert.AreEqual(folderPath, written); + } + + [Test] + public void WriteToFile_CloudLink_WritesCorrectContent() + { + using var tempFolder = new TemporaryFolder("TCLinkTests_WriteFile_Cloud"); + const string collId = "550e8400-e29b-41d4-a716-446655440000"; + var link = TeamCollectionLink.ForCloud(collId); + var linkFilePath = Path.Combine( + tempFolder.FolderPath, + TeamCollectionManager.TeamCollectionLinkFileName + ); + + link.WriteToFile(linkFilePath); + + Assert.IsTrue(RobustFile.Exists(linkFilePath)); + var written = RobustFile.ReadAllText(linkFilePath); + Assert.AreEqual(TeamCollectionLink.CloudUriPrefix + collId, written); + } + + // ----------------------------------------------------------------------- + // Both-forms-present detection + // ----------------------------------------------------------------------- + + [Test] + public void Parse_CloudUriPrefix_WithoutTrailingId_ThrowsInvalidLinkException() + { + // Explicitly verify that the prefix alone (no ID) is rejected. + Assert.Throws(() => + TeamCollectionLink.Parse("cloud://sil.bloom/collection/") + ); + } + } +} diff --git a/src/BloomTests/TeamCollection/TeamCollectionManagerTests.cs b/src/BloomTests/TeamCollection/TeamCollectionManagerTests.cs index 6bf8b4ca5f4a..7284d5e281ac 100644 --- a/src/BloomTests/TeamCollection/TeamCollectionManagerTests.cs +++ b/src/BloomTests/TeamCollection/TeamCollectionManagerTests.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Bloom; using Bloom.TeamCollection; using BloomTemp; using NUnit.Framework; @@ -12,5 +6,152 @@ namespace BloomTests.TeamCollection { - public class TeamCollectionManagerTests { } + // Covers task 10's "un-team adoption" guard rails, which live in TeamCollectionManager but + // don't need a full TeamCollectionManager (or any network access) to unit test: + // - ThrowIfConflictingTeamCollectionLink: the "simultaneous folder-link + cloud-link" + // conflict check ConnectToCloudCollection runs before doing anything else. + // - TeamCollection.CleanStaleTeamCollectionArtifacts: the stale-artifact cleanup that same + // method runs before the collection's first cloud Send. + public class TeamCollectionManagerTests + { + private TemporaryFolder _collectionFolder; + + [SetUp] + public void Setup() + { + _collectionFolder = new TemporaryFolder("TeamCollectionManagerTests"); + } + + [TearDown] + public void TearDown() + { + _collectionFolder.Dispose(); + } + + [Test] + public void ThrowIfConflictingTeamCollectionLink_NoLinkFile_DoesNotThrow() + { + // Sanity check: this collection folder really doesn't have a link file yet. + var linkPath = Path.Combine(_collectionFolder.FolderPath, "TeamCollectionLink.txt"); + Assert.That(RobustFile.Exists(linkPath), Is.False); + + Assert.DoesNotThrow(() => + Bloom.TeamCollection.TeamCollectionManager.ThrowIfConflictingTeamCollectionLink( + _collectionFolder.FolderPath + ) + ); + } + + [Test] + public void ThrowIfConflictingTeamCollectionLink_ExistingFolderLink_ThrowsWithFixInstructions() + { + var linkPath = Path.Combine(_collectionFolder.FolderPath, "TeamCollectionLink.txt"); + var sharedFolderPath = @"C:\Dropbox\SomeTeam\MyCollection - TC"; + RobustFile.WriteAllText(linkPath, sharedFolderPath); + // Sanity check our setup actually produced a folder-type link. + var parsedLink = TeamCollectionLink.FromFile(linkPath); + Assert.That(parsedLink, Is.Not.Null); + Assert.That(parsedLink.IsFolder, Is.True); + + var ex = Assert.Throws(() => + Bloom.TeamCollection.TeamCollectionManager.ThrowIfConflictingTeamCollectionLink( + _collectionFolder.FolderPath + ) + ); + + Assert.That(ex.Message, Does.Contain(sharedFolderPath)); + Assert.That(ex.Message, Does.Contain("TeamCollectionLink.txt")); + } + + [Test] + public void ThrowIfConflictingTeamCollectionLink_ExistingCloudLink_Throws() + { + var linkPath = Path.Combine(_collectionFolder.FolderPath, "TeamCollectionLink.txt"); + TeamCollectionLink + .ForCloud("11111111-1111-1111-1111-111111111111") + .WriteToFile(linkPath); + + var ex = Assert.Throws(() => + Bloom.TeamCollection.TeamCollectionManager.ThrowIfConflictingTeamCollectionLink( + _collectionFolder.FolderPath + ) + ); + + Assert.That(ex.Message, Does.Contain("11111111-1111-1111-1111-111111111111")); + } + + [Test] + public void CleanStaleTeamCollectionArtifacts_DeletesPerBookStatusFiles() + { + var book1 = Directory.CreateDirectory( + Path.Combine(_collectionFolder.FolderPath, "Book One") + ); + var book2 = Directory.CreateDirectory( + Path.Combine(_collectionFolder.FolderPath, "Book Two") + ); + var status1 = Path.Combine(book1.FullName, "TeamCollection.status"); + var status2 = Path.Combine(book2.FullName, "TeamCollection.status"); + RobustFile.WriteAllText(status1, "{\"checksum\":\"stale-checksum-from-old-tc\"}"); + RobustFile.WriteAllText(status2, "{\"checksum\":\"another-stale-checksum\"}"); + // Sanity check the fixture actually has the files we're about to assert get removed. + Assert.That(RobustFile.Exists(status1), Is.True); + Assert.That(RobustFile.Exists(status2), Is.True); + + Bloom.TeamCollection.TeamCollection.CleanStaleTeamCollectionArtifacts( + _collectionFolder.FolderPath + ); + + Assert.That(RobustFile.Exists(status1), Is.False); + Assert.That(RobustFile.Exists(status2), Is.False); + } + + [Test] + public void CleanStaleTeamCollectionArtifacts_DeletesCollectionLevelSyncAndLogFiles() + { + var lastSyncFile = Path.Combine( + _collectionFolder.FolderPath, + "lastCollectionFileSyncData.txt" + ); + var logFile = Path.Combine(_collectionFolder.FolderPath, "log.txt"); + RobustFile.WriteAllText(lastSyncFile, "stale sync data"); + RobustFile.WriteAllText(logFile, "stale log content"); + Assert.That(RobustFile.Exists(lastSyncFile), Is.True); + Assert.That(RobustFile.Exists(logFile), Is.True); + + Bloom.TeamCollection.TeamCollection.CleanStaleTeamCollectionArtifacts( + _collectionFolder.FolderPath + ); + + Assert.That(RobustFile.Exists(lastSyncFile), Is.False); + Assert.That(RobustFile.Exists(logFile), Is.False); + } + + [Test] + public void CleanStaleTeamCollectionArtifacts_LeavesTeamCollectionLinkAlone() + { + // The link file itself is a separate decision (ThrowIfConflictingTeamCollectionLink + // above, and then ConnectToCloudCollection deliberately overwriting it with a fresh + // cloud link) -- cleanup must not delete it out from under either of those. + var linkPath = Path.Combine(_collectionFolder.FolderPath, "TeamCollectionLink.txt"); + RobustFile.WriteAllText(linkPath, @"C:\Dropbox\SomeTeam\MyCollection - TC"); + + Bloom.TeamCollection.TeamCollection.CleanStaleTeamCollectionArtifacts( + _collectionFolder.FolderPath + ); + + Assert.That(RobustFile.Exists(linkPath), Is.True); + } + + [Test] + public void CleanStaleTeamCollectionArtifacts_NeverBeenATeamCollection_IsANoOp() + { + Directory.CreateDirectory(Path.Combine(_collectionFolder.FolderPath, "Plain Book")); + + Assert.DoesNotThrow(() => + Bloom.TeamCollection.TeamCollection.CleanStaleTeamCollectionArtifacts( + _collectionFolder.FolderPath + ) + ); + } + } } diff --git a/src/BloomTests/TeamCollection/TeamCollectionTierTimingTests.cs b/src/BloomTests/TeamCollection/TeamCollectionTierTimingTests.cs new file mode 100644 index 000000000000..0f8aad67164f --- /dev/null +++ b/src/BloomTests/TeamCollection/TeamCollectionTierTimingTests.cs @@ -0,0 +1,243 @@ +using System.Reflection; +using Bloom.Api; +using Bloom.Collection; +using Bloom.SubscriptionAndFeatures; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using BloomTemp; +using BloomTests.TeamCollection.Cloud; +using Moq; +using NUnit.Framework; +using SIL.IO; + +namespace BloomTests.TeamCollection +{ + /// + /// Pins down the fix for the "subscription-tier check timing" bug (GOING-LIVE.md Phase 5): + /// TeamCollectionManager.CheckDisablingTeamCollections' only readiness gate is + /// CurrentCollection == null, which for a cloud Team Collection does not reliably mean + /// "Settings.Subscription reflects the repo's authoritative value" (see + /// GetSubscriptionForDisablingCheck's own doc comment for the full mechanism). These tests + /// exercise CheckDisablingTeamCollections directly against a real TeamCollectionManager + /// (constructed with no TeamCollectionLink.txt present, so its constructor's own TC-loading + /// logic is a no-op and CurrentCollection starts null -- the same pattern used by + /// TeamCollectionAccountSwitchRefusalTests) with a fake TeamCollection installed via + /// reflection (CurrentCollection has a private setter by design). + /// + [TestFixture] + public class TeamCollectionTierTimingTests + { + private const string kSufficientCode = "Fake-LC-006273-1463"; // parses to LocalCommunity tier + private TemporaryFolder _localCollection; + private string _collectionSettingsPath; + private TeamCollectionManager _tcManager; + + [SetUp] + public void Setup() + { + _localCollection = new TemporaryFolder("TeamCollectionTierTimingTests"); + _collectionSettingsPath = CollectionSettings.GetSettingsFilePath( + _localCollection.FolderPath + ); + // No TeamCollectionLink.txt in this folder, so the constructor's own TC-loading + // logic is a no-op; CurrentCollection starts null and each test installs whatever + // fake it needs via reflection (see InstallCurrentCollection). + _tcManager = new TeamCollectionManager( + _collectionSettingsPath, + new BloomWebSocketServer(), + null, + null, + null, + null + ); + TeamCollectionManager.ForceCurrentUserForTests("test@somewhere.org"); + } + + [TearDown] + public void TearDown() + { + TeamCollectionManager.ForceCurrentUserForTests(null); + _localCollection.Dispose(); + } + + private void InstallCurrentCollection(Bloom.TeamCollection.TeamCollection collection) + { + // CurrentCollection has a private setter (by design -- nothing outside + // TeamCollectionManager itself should replace it); TeamCollectionAccountSwitchRefusalTests + // already established this reflection pattern for exactly this reason. + typeof(TeamCollectionManager) + .GetProperty( + nameof(TeamCollectionManager.CurrentCollection), + BindingFlags.Public | BindingFlags.Instance + ) + .SetValue(_tcManager, collection); + } + + /// + /// A real CloudTeamCollection wired with the same StubCloudAuthProvider/FakeRestExecutor/ + /// InMemoryCloudTokenStore fakes CloudSyncAtStartupTests and CloudTeamCollectionMemberTests + /// use, so its constructor never attempts real network access. No network method is ever + /// called on it here -- the point is only that CurrentCollection is genuinely a + /// Cloud.CloudTeamCollection, matching what GetSubscriptionForDisablingCheck type-checks + /// for. + /// + private CloudTeamCollection MakeFakeCloudCollection() + { + var environment = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_ANON_KEY" ? "test-anon-key" : null + ); + var auth = new CloudAuth(new StubCloudAuthProvider(), new InMemoryCloudTokenStore()); + var client = new CloudCollectionClient(environment, auth); + client.SetRestClientForTests(new FakeRestExecutor()); + return new CloudTeamCollection( + new Mock().Object, + _localCollection.FolderPath, + "11111111-1111-1111-1111-111111111111", + environment: environment, + auth: auth, + client: client, + transfer: new CloudBookTransfer(_ => new Mock().Object) + ); + } + + private Mock MakeFakeNonCloudCollection() + { + // Any non-CloudTeamCollection TeamCollection exercises the "else" branch of + // GetSubscriptionForDisablingCheck; a bare mock of the abstract base class is enough + // since folder-specific behavior isn't in play for this check. + var fake = new Mock(); + fake.Setup(c => c.RepoDescription).Returns("fake-folder-repo"); + return fake; + } + + private void WriteOnDiskSubscriptionCode(string code) + { + // Minimal .bloomCollection XML -- CollectionSettings.Load only needs SubscriptionCode + // for what GetSubscriptionForDisablingCheck's cloud path re-reads. + var xml = + "\r\n" + + "\r\n" + + $"\t{code}\r\n" + + ""; + RobustFile.WriteAllText(_collectionSettingsPath, xml); + } + + private static CollectionSettings SettingsWithSubscription(SubscriptionTier tier) + { + return new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(tier), + }; + } + + [Test] + public void CheckDisablingTeamCollections_CloudTc_StaleInMemorySettingsButFreshDiskSufficient_DoesNotDisable() + { + // The diagnosed misfire: Settings.Subscription (captured once, at ProjectContext + // startup, and never reloaded mid-session) is stale/insufficient, but the on-disk + // .bloomCollection file -- what the cloud collection-file sync actually refreshes -- + // already carries a sufficient, valid code by the time this check runs. Before the + // fix, this scenario intermittently and permanently disabled a healthy cloud TC. + WriteOnDiskSubscriptionCode(kSufficientCode); + _tcManager.Settings = SettingsWithSubscription(SubscriptionTier.Basic); + InstallCurrentCollection(MakeFakeCloudCollection()); + + _tcManager.CheckDisablingTeamCollections(_tcManager.Settings); + + Assert.That( + _tcManager.CurrentCollection, + Is.Not.Null, + "the cloud TC should not have been disabled: the fresh on-disk subscription is " + + "sufficient, even though the stale in-memory snapshot was not" + ); + } + + [Test] + public void CheckDisablingTeamCollections_CloudTc_GenuinelyInsufficientOnDisk_StillDisables() + { + // Control case: a cloud TC whose freshly-synced on-disk subscription really IS + // insufficient must still be disabled -- the fix must not turn into "never disable a + // cloud TC for subscription reasons." Note the in-memory snapshot is (implausibly) + // sufficient here, to prove the cloud path really does trust the disk read over it. + WriteOnDiskSubscriptionCode(""); + _tcManager.Settings = SettingsWithSubscription(SubscriptionTier.Enterprise); + InstallCurrentCollection(MakeFakeCloudCollection()); + + _tcManager.CheckDisablingTeamCollections(_tcManager.Settings); + + Assert.That( + _tcManager.CurrentCollection, + Is.Null, + "a genuinely insufficient cloud subscription must still disable the TC" + ); + Assert.That( + _tcManager.CurrentCollectionEvenIfDisconnected, + Is.InstanceOf() + ); + Assert.That( + ( + (DisconnectedTeamCollection)_tcManager.CurrentCollectionEvenIfDisconnected + ).DisconnectedBecauseOfSubscriptionTier, + Is.True + ); + } + + [Test] + public void CheckDisablingTeamCollections_NonCloudTc_UsesInMemorySettings_IgnoringDisk() + { + // Folder TCs (and anything else that isn't a CloudTeamCollection) must be + // byte-identical to the pre-fix behavior: only Settings.Subscription (in-memory) is + // consulted; the on-disk file is never re-read for this check. + WriteOnDiskSubscriptionCode(kSufficientCode); // sufficient on disk... + _tcManager.Settings = SettingsWithSubscription(SubscriptionTier.Basic); // ...but not in memory + InstallCurrentCollection(MakeFakeNonCloudCollection().Object); + + _tcManager.CheckDisablingTeamCollections(_tcManager.Settings); + + Assert.That( + _tcManager.CurrentCollection, + Is.Null, + "the in-memory (insufficient) Settings.Subscription should have disabled it, " + + "exactly as before this fix -- the sufficient on-disk file must be ignored " + + "for a non-cloud collection" + ); + Assert.That( + ( + (DisconnectedTeamCollection)_tcManager.CurrentCollectionEvenIfDisconnected + ).DisconnectedBecauseOfSubscriptionTier, + Is.True + ); + } + + [Test] + public void CheckDisablingTeamCollections_NonCloudTc_SufficientInMemorySettings_NotDisabled_EvenIfDiskInsufficient() + { + WriteOnDiskSubscriptionCode(""); // insufficient on disk -- must be ignored for non-cloud + _tcManager.Settings = SettingsWithSubscription(SubscriptionTier.LocalCommunity); + InstallCurrentCollection(MakeFakeNonCloudCollection().Object); + + _tcManager.CheckDisablingTeamCollections(_tcManager.Settings); + + Assert.That( + _tcManager.CurrentCollection, + Is.Not.Null, + "a sufficient in-memory subscription must not be overridden by an insufficient " + + "on-disk file for a non-cloud collection" + ); + } + + [Test] + public void CheckDisablingTeamCollections_CurrentCollectionNull_NoOp() + { + // Sanity check matching the pre-existing early-return: with no TC at all (or already + // disabled), the check must do nothing, regardless of Settings. + _tcManager.Settings = SettingsWithSubscription(SubscriptionTier.Basic); + + Assert.That(_tcManager.CurrentCollection, Is.Null, "sanity check: no TC installed"); + Assert.DoesNotThrow(() => + _tcManager.CheckDisablingTeamCollections(_tcManager.Settings) + ); + Assert.That(_tcManager.CurrentCollectionEvenIfDisconnected, Is.Null); + } + } +} diff --git a/src/BloomTests/TeamCollection/TestFolderTeamCollection.cs b/src/BloomTests/TeamCollection/TestFolderTeamCollection.cs index 2e4dae80f64c..c75f40b47df3 100644 --- a/src/BloomTests/TeamCollection/TestFolderTeamCollection.cs +++ b/src/BloomTests/TeamCollection/TestFolderTeamCollection.cs @@ -39,5 +39,27 @@ protected override void OnCollectionFilesChanged(object sender, FileSystemEventA base.OnCollectionFilesChanged(sender, e); OnCollectionChangedCalled?.Invoke(); } + + /// + /// Test-only toggle so a FolderTeamCollection-based test can exercise the auto-apply code + /// path in TeamCollection.HandleModifiedFile (normally only CloudTeamCollection sets this + /// true) without needing the whole cloud stack. + /// + public bool AutoApplyRemoteChangesForTests { get; set; } + + protected override bool CanAutoApplyRemoteChanges => AutoApplyRemoteChangesForTests; + + /// + /// Records the books passed to the preserve-before-overwrite hook (batch item 8), so tests + /// can assert it fires exactly when the local copy changed since the last sync. The real + /// cloud override zips a .bloomSource to Lost and Found; recording is enough here because + /// the DECISION (fire or not) is the base-class logic under test. + /// + public List PreservedForRecovery = new List(); + + protected override void PreserveLocalCopyForRecoveryBeforeOverwrite(string bookFolderName) + { + PreservedForRecovery.Add(bookFolderName); + } } } diff --git a/src/BloomTests/TeamCollection/WorkspaceModelTierTimingOrderingTests.cs b/src/BloomTests/TeamCollection/WorkspaceModelTierTimingOrderingTests.cs new file mode 100644 index 000000000000..ecd7088c7ae4 --- /dev/null +++ b/src/BloomTests/TeamCollection/WorkspaceModelTierTimingOrderingTests.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Bloom.Api; +using Bloom.Book; +using Bloom.Collection; +using Bloom.SubscriptionAndFeatures; +using Bloom.TeamCollection; +using Bloom.TeamCollection.Cloud; +using Bloom.Workspace; +using BloomTemp; +using BloomTests.TeamCollection.Cloud; +using Moq; +using NUnit.Framework; + +namespace BloomTests.TeamCollection +{ + /// + /// Pins the OTHER half of the "tier-timing" fix: WorkspaceModel.HandleTeamStuffBeforeGetBookCollections' + /// call ordering between TeamCollectionManager.CheckDisablingTeamCollections and + /// TeamCollection.SynchronizeRepoAndLocal. Folder TCs must keep the original order (check, + /// then sync); cloud TCs must have the check deferred to run after sync (see WorkspaceModel.cs + /// and TeamCollectionTierTimingTests for the companion tests of the check's own logic). + /// + /// TeamCollectionManager.CheckDisablingTeamCollections and TeamCollection.SynchronizeRepoAndLocal + /// were both made virtual (from a previously non-virtual "public void") purely so these + /// recording subclasses could observe call order without ever invoking the real + /// SynchronizeRepoAndLocal implementation, which pops a real modal progress dialog -- unsafe in + /// a unit test. + /// + [TestFixture] + public class WorkspaceModelTierTimingOrderingTests + { + private TemporaryFolder _localCollection; + private string _collectionSettingsPath; + private RecordingTeamCollectionManager _tcManager; + private CollectionSettings _collectionSettings; + private WorkspaceModel _workspaceModel; + + private class RecordingTeamCollectionManager : TeamCollectionManager + { + public readonly List CallOrder = new List(); + + public RecordingTeamCollectionManager( + string localCollectionPath, + BloomWebSocketServer ws + ) + : base(localCollectionPath, ws, null, null, null, null) { } + + public override void CheckDisablingTeamCollections(CollectionSettings settings) + { + CallOrder.Add("check"); + base.CheckDisablingTeamCollections(settings); + } + } + + private class RecordingFolderTeamCollection : FolderTeamCollection + { + private readonly List _callOrder; + + public RecordingFolderTeamCollection( + List callOrder, + ITeamCollectionManager tcManager, + string localCollectionFolder, + string repoFolderPath + ) + : base(tcManager, localCollectionFolder, repoFolderPath) + { + _callOrder = callOrder; + } + + public override void SynchronizeRepoAndLocal(Action whenDone = null) + { + _callOrder.Add("sync"); + whenDone?.Invoke(); + } + } + + private class RecordingCloudTeamCollection : CloudTeamCollection + { + private readonly List _callOrder; + + public RecordingCloudTeamCollection( + List callOrder, + ITeamCollectionManager tcManager, + string localCollectionFolder, + string collectionId, + CloudEnvironment environment, + CloudAuth auth, + CloudCollectionClient client + ) + : base( + tcManager, + localCollectionFolder, + collectionId, + environment: environment, + auth: auth, + client: client, + transfer: new CloudBookTransfer(_ => new Mock().Object) + ) + { + _callOrder = callOrder; + } + + public override void SynchronizeRepoAndLocal(Action whenDone = null) + { + _callOrder.Add("sync"); + whenDone?.Invoke(); + } + } + + [SetUp] + public void Setup() + { + _localCollection = new TemporaryFolder("WorkspaceModelTierTimingOrderingTests"); + _collectionSettingsPath = CollectionSettings.GetSettingsFilePath( + _localCollection.FolderPath + ); + _tcManager = new RecordingTeamCollectionManager( + _collectionSettingsPath, + new BloomWebSocketServer() + ); + TeamCollectionManager.ForceCurrentUserForTests("test@somewhere.org"); + // Sufficient in every case here -- these tests are about ORDER, not about whether the + // check disables anything (TeamCollectionTierTimingTests already covers the + // disable/not-disable logic in isolation). Keeping the collection enabled throughout + // also matters mechanically: if the check DID disable it, CurrentCollectionEvenIfDisconnected + // would be replaced by a plain (non-recording) DisconnectedTeamCollection before + // SynchronizeRepoAndLocal is reached, and that real implementation shows a live dialog. + _collectionSettings = new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier( + SubscriptionTier.LocalCommunity + ), + }; + _tcManager.Settings = _collectionSettings; + _workspaceModel = new WorkspaceModel( + new BookSelection(), + _localCollection.FolderPath, + _tcManager, + _collectionSettings, + new Bloom.SourceCollectionsList() + ); + } + + [TearDown] + public void TearDown() + { + TeamCollectionManager.ForceCurrentUserForTests(null); + _localCollection.Dispose(); + } + + private void InstallCollection(Bloom.TeamCollection.TeamCollection collection) + { + foreach ( + var propName in new[] + { + nameof(TeamCollectionManager.CurrentCollection), + nameof(TeamCollectionManager.CurrentCollectionEvenIfDisconnected), + } + ) + { + typeof(TeamCollectionManager) + .GetProperty(propName, BindingFlags.Public | BindingFlags.Instance) + .SetValue(_tcManager, collection); + } + } + + [Test] + public void FolderTc_ChecksTierBeforeSync_OrderUnchanged() + { + var fake = new RecordingFolderTeamCollection( + _tcManager.CallOrder, + _tcManager, + _localCollection.FolderPath, + Path.Combine(_localCollection.FolderPath, "repo") + ); + InstallCollection(fake); + bool doneCalled = false; + + _workspaceModel.HandleTeamStuffBeforeGetBookCollections(() => doneCalled = true); + + Assert.That( + _tcManager.CallOrder, + Is.EqualTo(new[] { "check", "sync" }), + "folder-TC ordering must be unchanged: the tier check runs BEFORE sync, exactly as before this fix" + ); + Assert.That(doneCalled, Is.True, "whenDone must still fire"); + } + + [Test] + public void CloudTc_DefersTierCheckUntilAfterSync() + { + var environment = new CloudEnvironment(name => + name == "BLOOM_CLOUDTC_ANON_KEY" ? "test-anon-key" : null + ); + var auth = new CloudAuth(new StubCloudAuthProvider(), new InMemoryCloudTokenStore()); + var client = new CloudCollectionClient(environment, auth); + client.SetRestClientForTests(new FakeRestExecutor()); + var fake = new RecordingCloudTeamCollection( + _tcManager.CallOrder, + _tcManager, + _localCollection.FolderPath, + "11111111-1111-1111-1111-111111111111", + environment, + auth, + client + ); + InstallCollection(fake); + bool doneCalled = false; + + _workspaceModel.HandleTeamStuffBeforeGetBookCollections(() => doneCalled = true); + + Assert.That( + _tcManager.CallOrder, + Is.EqualTo(new[] { "sync", "check" }), + "cloud-TC ordering must be deferred: sync runs BEFORE the tier check, so the check " + + "can see whatever the sync just refreshed on disk instead of racing it" + ); + Assert.That(doneCalled, Is.True, "whenDone must still fire"); + } + } +} diff --git a/src/BloomTests/WebLibraryIntegration/BloomS3ClientTests.cs b/src/BloomTests/WebLibraryIntegration/BloomS3ClientTests.cs index c459f4605389..82ad4f20b82e 100644 --- a/src/BloomTests/WebLibraryIntegration/BloomS3ClientTests.cs +++ b/src/BloomTests/WebLibraryIntegration/BloomS3ClientTests.cs @@ -184,6 +184,9 @@ public async System.Threading.Tasks.Task DeleteFromUnitTestBucketAsync(string pr matchingFilesResponse = await amazonS3.ListObjectsV2Async( listMatchingObjectsRequest ); + // AWSSDK v4: an empty result gives a null S3Objects collection, not an empty one. + if (matchingFilesResponse.S3Objects == null) + return; if (matchingFilesResponse.S3Objects.Count == 0) return; @@ -196,12 +199,15 @@ public async System.Threading.Tasks.Task DeleteFromUnitTestBucketAsync(string pr }; var response = await amazonS3.DeleteObjectsAsync(deleteObjectsRequest); - System.Diagnostics.Debug.Assert(response.DeleteErrors.Count == 0); + // AWSSDK v4: response collections may be null instead of empty. + System.Diagnostics.Debug.Assert( + response.DeleteErrors == null || response.DeleteErrors.Count == 0 + ); // Prep the next request (if needed) listMatchingObjectsRequest.ContinuationToken = matchingFilesResponse.NextContinuationToken; - } while (matchingFilesResponse.IsTruncated); // Returns true if haven't reached the end yet + } while (matchingFilesResponse.IsTruncated == true); // Returns true if haven't reached the end yet (AWSSDK v4: bool?) } } } diff --git a/src/BloomTests/e2e/.gitignore b/src/BloomTests/e2e/.gitignore new file mode 100644 index 000000000000..7b569b8435da --- /dev/null +++ b/src/BloomTests/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +dist/ +playwright-report/ diff --git a/src/BloomTests/e2e/README.md b/src/BloomTests/e2e/README.md new file mode 100644 index 000000000000..f0c49bd291b6 --- /dev/null +++ b/src/BloomTests/e2e/README.md @@ -0,0 +1,220 @@ +# Cloud Team Collections E2E harness + +Playwright-over-CDP tests that drive **real Bloom.exe instances** against the local dev stack +(local Supabase + MinIO, see `server/dev/README.md`). This automates the Wave-3 manual +two-instance smoke test and pins the bugs found during it. + +(For what the *unit* tests need — much less — see +`Design/CloudTeamCollections/docs/unit-test-setup.md`.) + +## Machine setup (fresh dev machine) + +Everything below is once-per-machine. Windows 10/11 assumed (the harness launches the real +WinForms/WebView2 Bloom.exe, so Windows is required). + +1. **Normal Bloom dev setup** — clone, then `./init.sh` at the repo root (fetches C# + dependencies, yarn installs, initial front-end build). Provides the .NET SDK usage, + Volta-managed node + yarn 1.22, and the WebView2 runtime that a working Bloom dev machine + already has. If `dotnet build src/BloomExe/BloomExe.csproj -c Release` succeeds, this + layer is done. +2. **Container runtime** — either Docker Desktop or **Podman** (what this harness was built + against: Podman 5.8.3, Podman Desktop, a *rootful* WSL2 podman machine, with the + Docker-compatibility named pipe enabled so `docker-compose` works). WSL2 must be enabled + (needs virtualization; on a VM that means nested virtualization). See + `server/dev/README.md` for the exact Podman recipe and its gotchas (bind-mount dirs must + pre-exist; edge-runtime containers must reach MinIO as `bloom-minio:9000`, never + `host.containers.internal`, which hangs). +3. **Supabase CLI + docker-compose** — `npm i -g supabase` (Volta shim lands on PATH) and + `winget install Docker.DockerCompose` (or use `podman compose`). +4. **Bring the stack up** (each boot / when containers are down): + ```powershell + supabase start # local Postgres/GoTrue/PostgREST/edge runtime + docker-compose -f server/dev/docker-compose.yml up -d # MinIO on the supabase network + ``` + Verify with `server/dev/smoke.ps1`. Dev users (alice@dev.local etc., password + BloomDev123!) come from `server/dev/seed.sql` automatically. +5. **Interactive, UNLOCKED desktop session.** Hard requirement: a locked session (or a + service session with no interactive desktop) stalls WebView2 at `about:blank` + indefinitely and every scenario times out. Check: `Get-Process LogonUI + -ErrorAction SilentlyContinue` — if it returns a process, the session is locked. Keep the + machine unlocked for the whole run (set screen lock/sleep policy accordingly). +6. **(Recommended) Defender exclusions** for the repo folder, `C:\BloomE2E\`, and the WSL2 + VHD — real-time scanning of WebView2 profile churn and container disk was measured making + launches dramatically slower on the original dev machine. + +Nothing else is needed: the harness itself builds Bloom (Release) once per run, enables the +`cloud-team-collections` experimental flag in user.config idempotently, and resets the +DB/bucket/scratch folders per scenario. There are no secrets — the committed dev credentials +are local-only constants. + +## Prerequisites (per run) + +- The local dev stack must already be up: `supabase start`, MinIO (`docker compose -f + server/dev/docker-compose.yml up -d` — actually run via `podman compose` on this machine, + see below), and `supabase functions serve` (or the Podman-managed + `supabase_edge_runtime_*` container, which serves the same role). +- `podman` (or `docker`) on PATH — used to clear the MinIO bucket between scenarios via a + throwaway `mc` container on the same network as `bloom-minio` (see `harness/reset.ts`; the + host-gateway route is a known hang, so this never uses `host.containers.internal`). +- `supabase` CLI on PATH (Volta shim on Windows — see the shell-quoting note below). +- `dotnet` SDK (builds `src/BloomExe/BloomExe.csproj`). +- Yarn 1.22 (`yarn install` in this directory once). +- No Bloom.exe from THIS repo tree running (the harness fails loudly if one is; instances + from other worktrees are tolerated with a warning). + +## Running + +```powershell +cd src/BloomTests/e2e +yarn install # once +yarn test # builds Bloom once (globalSetup), then runs every scenario, workers=1 +``` + +Single file: `yarn playwright test tests/e2e-1-create-share.spec.ts`. + +**Confining Bloom windows to one monitor**: set `BLOOM_E2E_SCREEN` to a screen number +(1-based, counting monitors left-to-right by X coordinate) and every launched instance's +windows — including the splash, the post-reopen replacement main window, and WinForms +dialogs — get kept on that screen by a per-instance watcher (`harness/windowPlacement.ts` + +`watchWindowScreen.ps1`). List your screens in that order with: + +```powershell +powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::AllScreens | Sort-Object {$_.Bounds.X} | ForEach-Object {$i=1} { \"$i. $($_.DeviceName) $($_.Bounds) primary=$($_.Primary)\"; $i++ }" +``` + +Caveat: Bloom saves window position to the shared user.config on exit, so your own next +manual Bloom launch may open on the E2E screen once — just drag it back. Test behavior is +unaffected by window position (CDP input is page-relative). + +## Design + +- **Build once, launch many** (`harness/launch.ts`): `globalSetup` runs `dotnet build + src/BloomExe/BloomExe.csproj -c Release` exactly once for the whole run. Each scenario then + launches the built exe directly (`output/Release/AnyCPU/Bloom.exe --automation --label + `), with per-instance `BLOOM_CLOUDTC_*` env vars for identity. Bloom + picks its own HTTP/CDP ports and reports them via the `BLOOM_AUTOMATION_READY {...}` stdout + line (see `.github/skills/bloom-automation/SKILL.md`). +- **RELEASE, not Debug, is mandatory** — not just faster. A Debug build shows a blocking modal + `MessageBox` ("Attach debugger now", `Program.cs` `#if DEBUG`, fires whenever + `args.Length > 0`) on every launch that passes a collection-file path, which every harness + launch does. With no one at the keyboard to click it, the instance hangs forever with no + HTTP/CDP port ever opening. This cost real time to diagnose (see progress log) — the process + looked "stuck starting up" with `Responding: True` and no error, until a `PrintWindow` + screen-capture of its (title-less) window revealed the dialog. Building `-c Release` + compiles that branch out entirely. +- **Experimental flag** (`harness/experimentalFlag.ts`): Cloud Team Collections is gated by + the `cloud-team-collections` token in `EnabledExperimentalFeatures`, stored in the *shared, + per-machine* `user.config` at `%LOCALAPPDATA%\SIL\Bloom\\user.config` (there is no + env-var override; this is the same manual hack recorded in the Wave-3 merge log). Ensured + idempotently (never overwrites the developer's real settings file wholesale) once per + session, in `globalSetup`. +- **Per-scenario reset** (`harness/reset.ts`): `supabase db reset` (replays migrations + + seed) + clears the MinIO bucket (via a throwaway `mc` container on the `bloom-minio` + network — NOT `host.containers.internal`, which hangs indefinitely per + `server/dev/README.md`'s documented gvproxy gotcha) + wipes `C:\BloomE2E\` (this harness's + scratch-collection root, kept outside the repo so a stray leftover can never be committed). +- **ReactDialog-hosted WebView2 controls are not reliably CDP-reachable** (`harness/rawCdp.ts` + documents the investigation; `harness/cdp.ts`'s `waitForPage` was an earlier, now-unused + attempt at the same problem via Playwright). The "share on cloud" flow alone spans three + separate WinForms-hosted WebView2 controls in the same process (Collection tab → Settings + dialog's Team Collection tab → Create Team Collection dialog), each its own environment with + a distinct `UserDataFolder` (confirmed via Bloom's log) but ALL requesting the identical + `--remote-debugging-port`. Only one such browser process can ever bind that port, so at most + one control's content is CDP-visible at a time, and empirically it isn't reliably the one + you just opened — confirmed both through Playwright's `browser.contexts()` and the raw CDP + `/json/list` endpoint. **Every scenario in this harness therefore drives state-changing + actions whose UI lives in one of these secondary dialogs via the same backend API endpoint + the dialog's button would call**, rather than automating the dialog UI — see E2E-1 and E2E-2's + header comments for the specific endpoints. CDP clicks are used only against the main + Collection-tab page, which does NOT have this problem (it's the one control alive from + startup) — except see the checkout/check-in button finding below, which turned out to have a + similar unreliability for a different, undiagnosed reason. +- **Reconnect timing around `createCloudTeamCollection`**: that call's reopen-collection + callback reloads the main window's WebView2 control in place. If a Playwright connection is + already attached and watching before the reopen, it follows the same CDP target through the + reload correctly (confirmed: body content grows as the reload happens, same target id + throughout). A **fresh** `connectOverCDP` call made *after* the reopen has already happened + intermittently finds only an `about:blank` target (confirmed via raw `/json/list`, not just + Playwright's page cache) even after retrying the page-lookup for 60+ seconds. The fix used + throughout: connect to a page *before* triggering any action that might cause a reopen, and + keep reusing that same `Page` object afterward instead of reconnecting. +- **Checkout/check-in buttons don't respond to CDP clicks.** Playwright reports a successful + `.click()` on the visible, enabled "CHECK OUT BOOK"/"CHECK IN BOOK" button (a + `getByRole("button", {name: ...})` locator resolving to exactly one element), but + before/after screenshots show no state change and `teamCollection/bookStatus` still reports + `who: null` afterward. Calling the same endpoint the button posts to + (`teamCollection/attemptLockOfCurrentBook` / `checkInCurrentBook`) directly succeeds + immediately. Root cause undiagnosed (a ripple/overlay intercepting the hit-test is one + candidate) — E2E-2 uses the direct API call for checkout/check-in themselves, but still uses + a real CDP click to *select* the book first (that part works fine). +- **`CloudCollectionMonitor.DefaultPollInterval` is 60 seconds.** A second instance sitting + idle takes up to a minute to notice a remote checkout/check-in organically. + `teamCollection/receiveUpdates` calls `CloudTeamCollection.PollNow()` internally before doing + anything else, so scenarios that need to observe another instance's change promptly should + call `receiveUpdates` to force an immediate poll rather than waiting out the timer or padding + `expect.poll` timeouts past 60s. +- **DB verification** (`harness/db.ts`): connects directly to the local Postgres + (`postgresql://postgres:postgres@localhost:54322/postgres`, stable across `db reset`) via + `pg`, not the `supabase db query` CLI — that CLI is a Volta `.cmd` shim on Windows, which + Node can only spawn with `shell: true`, and shell-mode argument concatenation (not real + escaping) mangled quoted SQL containing spaces in practice. + +## Known environment gotchas hit while building this harness + +- `execFile("supabase", ...)` fails with `EINVAL` on Windows unless `shell: true` — it's a + `.cmd` shim, not a real `.exe`. Fine for `db reset` (fixed args); avoided entirely for ad hoc + SQL (see `harness/db.ts`). +- `podman run --entrypoint /bin/sh ...` needs `MSYS_NO_PATHCONV=1` (or `//bin/sh`) under Git + Bash, otherwise MSYS rewrites `/bin/sh` into a bogus Windows path before it reaches Podman. +- `BLOOM_AUTOMATION_READY`'s CDP port can be printed a beat before the WebView2 remote- + debugging listener actually accepts connections — `harness/launch.ts`'s `connectOverCdp` + retries for up to 15s rather than treating the first `ECONNREFUSED` as fatal. + +## Known flakiness on a loaded machine + +E2E-2 (two-instance) intermittently times out at varying steps (Bloom launch, `connectOverCdp`, +or an individual API call) when the machine is under heavy concurrent load — observed with only +~18-20% free RAM while multiple other VS Code + language-server + browser processes were +running (e.g. other agents working in parallel `.claude/worktrees/`). Every individual step has +been independently verified correct in isolation; see the task's progress log for detail. If +this harness is flaky in CI, check available memory/CPU before assuming a logic regression. + +`globalSetup` also clears leaked `%TEMP%\Bloom WV2-*` profile folders (Bloom itself never +cleans these up — see `harness/reset.ts`'s `resetLeakedWebView2Profiles` doc comment) since 117 +of them had accumulated by the end of this harness's development session and were measurably +slow to even enumerate. This is a real, worth-keeping fix, but on its own it did not resolve +the memory-pressure-driven flakiness above. + +## TeamCity build agent — feasibility notes + +Running this harness on a TeamCity agent is feasible but has non-standard requirements, all +stemming from the fact that it launches a real GUI app with WebView2: + +- **The agent must run as an interactive desktop process, NOT a Windows service.** Install + the TeamCity agent in "start via Windows logon" mode with automatic logon of a dedicated + build user, and keep that session unlocked (group policy: no lock screen / no screensaver + lock; if RDP is ever used to inspect the box, disconnect with `tscon /dest:console` + rather than plain disconnect, which locks the console session and stalls WebView2 — see + "Machine setup" item 5). +- **Virtualization**: the agent machine needs WSL2 for Podman/Docker; on a VM host that means + nested virtualization enabled. +- **Resources**: ≥16 GB RAM recommended. The known flakiness mode of this harness is memory + pressure (see "Known flakiness" above); an agent that also runs other heavy builds + concurrently will produce false failures. +- **Runtime**: the full matrix is roughly 35–45 minutes at workers=1 (mandatory — scenarios + share the DB/bucket and launch multiple Bloom instances). Budget the build configuration + accordingly; consider a nightly schedule rather than per-commit. +- **State**: per-machine mutable state the harness touches: `%LOCALAPPDATA%\SIL\Bloom\\ + user.config` (experimental flag, edited idempotently), `%MyDocuments%\Bloom\BloomE2E-*` + (pull-down destinations, cleaned by prefix), `C:\BloomE2E\` (scratch), `%TEMP%\Bloom WV2-*` + (WebView2 profiles, cleaned in globalSetup). A dedicated build user keeps all of this away + from human users' real settings. +- **No secrets**: everything runs against the local stack with committed dev-only constants. +- **Stack bring-up**: the build script must ensure `supabase start` + the MinIO compose file + are up before `yarn test` (they survive between builds on a persistent agent; a + bring-up-if-down step plus `server/dev/smoke.ps1` as a health gate is the robust shape). + +## Scenario status + +See the Progress log in `Design/CloudTeamCollections/tasks/09-e2e.md` for current status per +scenario (green / blocked / deferred + exact next action). diff --git a/src/BloomTests/e2e/harness/bloomApi.ts b/src/BloomTests/e2e/harness/bloomApi.ts new file mode 100644 index 000000000000..6836323e64a0 --- /dev/null +++ b/src/BloomTests/e2e/harness/bloomApi.ts @@ -0,0 +1,180 @@ +// Thin wrapper around Bloom's local HTTP API (`/bloom/api/...`). +// +// DISCOVERED RACE: immediately after BLOOM_AUTOMATION_READY, some API endpoints +// (`teamCollection/showCreateCloudTeamCollectionDialog` observed concretely) can still 404 +// with Bloom's own "Cannot Find API Endpoint" NonFatalProblem for roughly a second — endpoint +// registration apparently isn't fully complete the instant the HTTP listener starts accepting +// connections. `postApi`/`getApi` retry on 404 specifically (a real 404 for a nonexistent +// route would retry pointlessly for the same reason, but this harness only calls known-good +// routes, so that's an acceptable tradeoff for robustness against the startup race). +import { expect } from "@playwright/test"; + +const DEFAULT_REGISTRATION_TIMEOUT_MS = 10_000; +// Per-attempt request timeout: some Bloom operations (e.g. createCloudTeamCollection) tear down +// and recreate the workspace's WebView2 controls on the UI thread, and a handler that needs to +// marshal onto that thread can stall until it's free. Without a client-side abort, a genuinely +// stuck server call would hang `fetch` forever and only surface as Playwright's blunt whole-test +// timeout, with no indication of which call was the culprit. 30s is generous but bounded. +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + +const fetchWithTimeout = async ( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch (error) { + if (controller.signal.aborted) { + throw new Error( + `Request to ${url} did not respond within ${timeoutMs}ms.`, + ); + } + throw error; + } finally { + clearTimeout(timer); + } +}; + +/** POSTs to `route` (relative to `/bloom/api/`) with an empty body (Bloom's API requires a + * Content-Length, so a plain POST with no body 411s — pass `""` explicitly). Retries on 404 for + * up to `registrationTimeoutMs` to ride out the post-startup endpoint-registration race + * described above; each individual attempt is bounded by `requestTimeoutMs`. */ +export const postApi = async ( + httpPort: number, + route: string, + body = "", + registrationTimeoutMs = DEFAULT_REGISTRATION_TIMEOUT_MS, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, +): Promise => { + const url = `http://localhost:${httpPort}/bloom/api/${route}`; + const deadline = Date.now() + registrationTimeoutMs; + let lastResponse: Response; + do { + lastResponse = await fetchWithTimeout( + url, + { method: "POST", body }, + requestTimeoutMs, + ); + if (lastResponse.status !== 404) return lastResponse; + await new Promise((resolve) => setTimeout(resolve, 300)); + } while (Date.now() < deadline); + return lastResponse; +}; + +/** POSTs teamCollection/createCloudTeamCollection with a much longer per-attempt timeout + * (120s vs the default 30s). This one call runs create_collection + the FULL initial upload + + * a workspace-reopen on the UI thread; during the first full-matrix run it twice exceeded 30s + * on a healthy instance (E2E-5, E2E-6) while six sibling scenarios' creates were fine — + * sporadic slowness, not deadlock. The genuine deadlock modes we know (locked desktop, + * pre-workspace-init call) hang FOREVER, so a 120s bound still fails loudly on those. Do not + * retry on timeout: the server side keeps processing after a client abort, so a retry would + * race its own first attempt. + * + * A timeout has also been seen ONCE at the full 120s on a warm, unlocked machine (E2E-9, + * third full-matrix run) — an as-yet-undiagnosed intermittent. Because we can't reproduce it + * on demand, this helper self-documents the next occurrence: before failing, it captures a + * managed thread dump of the still-hung Bloom via `dotnet-stack` (if installed: + * `dotnet tool install -g dotnet-stack`) next to the instance's log. That dump is exactly + * what diagnosed the original create deadlock (see tasks/09-e2e.md finding #7). */ +export const postCreateCloudTeamCollection = async (instance: { + httpPort: number; + processId: number; + logPath: string; +}): Promise => { + try { + return await postApi( + instance.httpPort, + "teamCollection/createCloudTeamCollection", + "{}", + DEFAULT_REGISTRATION_TIMEOUT_MS, + 120_000, + ); + } catch (error) { + const dumpPath = `${instance.logPath}.create-timeout-stacks.txt`; + try { + const { execFileSync } = await import("node:child_process"); + const dump = execFileSync( + "dotnet-stack", + ["report", "-p", String(instance.processId)], + { timeout: 30_000, encoding: "utf8" }, + ); + const { writeFileSync } = await import("node:fs"); + writeFileSync(dumpPath, dump); + throw new Error( + `${(error as Error).message} — managed stack dump of the hung Bloom saved to ${dumpPath}`, + ); + } catch (dumpError) { + if ( + dumpError instanceof Error && + dumpError.message.includes("stack dump") + ) + throw dumpError; // the enriched error above — pass it through + throw error; // dotnet-stack missing/failed: report the original timeout + } + } +}; + +/** GETs `route`, retrying on 404 for the same post-startup registration race `postApi` + * guards against, with the same per-attempt timeout. */ +export const getApi = async ( + httpPort: number, + route: string, + registrationTimeoutMs = DEFAULT_REGISTRATION_TIMEOUT_MS, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, +): Promise => { + const url = `http://localhost:${httpPort}/bloom/api/${route}`; + const deadline = Date.now() + registrationTimeoutMs; + let lastResponse: Response; + do { + lastResponse = await fetchWithTimeout(url, {}, requestTimeoutMs); + if (lastResponse.status !== 404) return lastResponse; + await new Promise((resolve) => setTimeout(resolve, 300)); + } while (Date.now() < deadline); + return lastResponse; +}; + +/** Waits (up to `timeoutMs`, default 20s) until `teamCollection/capabilities` reports + * `supportsSharingUi: true` — the harness's one readiness signal that the instance is genuinely + * inside a live, connected cloud Team Collection. + * + * WHY this gate exists — POLL, don't single-shot: `teamCollection/capabilities` is an + * application-level endpoint (post-batch defect 3 fix) that answers truthfully-false while the + * project is still opening, and BLOOM_AUTOMATION_READY fires when the HTTP server starts + * listening — which can be BEFORE the Team Collection has finished (re)connecting. UI-thread + * endpoints gated on `_tcManager.CheckConnection()` (e.g. `teamCollection/checkInCurrentBook`) + * 503 with an EMPTY body if called before that connection is live. This is a different + * post-launch race than this module's 404 endpoint-registration retry (that one is about routes + * existing at all; this one is about the cloud connection itself being live yet), so every + * fresh launch, join, or mid-test relaunch that immediately needs a working cloud connection + * must wait for this explicitly. + * + * A fetch failure counts as "not ready yet" rather than an immediate test failure: an in-place + * collection switch (`workspace/openCollection`) reopens the workspace and can leave the + * instance momentarily unreachable mid-poll (observed in join-auto-open.spec.ts). */ +export const waitForSharingReady = async ( + httpPort: number, + timeoutMs = 20_000, +): Promise => { + await expect + .poll( + async () => { + try { + const caps = (await ( + await getApi(httpPort, "teamCollection/capabilities") + ).json()) as { supportsSharingUi: boolean }; + return caps.supportsSharingUi; + } catch { + return false; // momentarily unreachable (e.g. during a workspace reopen) + } + }, + { + timeout: timeoutMs, + message: + "teamCollection/capabilities never reported supportsSharingUi: true (instance never became a connected cloud Team Collection)", + }, + ) + .toBe(true); +}; diff --git a/src/BloomTests/e2e/harness/bookStatus.ts b/src/BloomTests/e2e/harness/bookStatus.ts new file mode 100644 index 000000000000..992adefc0cb1 --- /dev/null +++ b/src/BloomTests/e2e/harness/bookStatus.ts @@ -0,0 +1,40 @@ +// Small, shared `teamCollection/bookStatus` + "force an immediate poll" helpers, factored out of +// E2E-2 (the two-instance collaboration loop) once E2E-3 needed the exact same pair. Kept here +// rather than duplicated per-spec so a future change to either shape only needs one edit. +import { expect } from "@playwright/test"; +import { postApi, getApi } from "./bloomApi"; + +/** The subset of the `teamCollection/bookStatus` payload (see IBookTeamCollectionStatus in + * TeamCollectionApi.tsx / GetBookStatusJson in TeamCollectionApi.cs) that the e2e specs consume. */ +export interface BookTeamCollectionStatus { + who: string; + where: string; + currentUser: string; + isChangedRemotely: boolean; +} + +export const bookStatus = async ( + httpPort: number, + folderName: string, +): Promise => { + const response = await getApi( + httpPort, + `teamCollection/bookStatus?folderName=${encodeURIComponent(folderName)}`, + ); + expect(response.status).toBe(200); + return (await response.json()) as BookTeamCollectionStatus; +}; + +// CloudCollectionMonitor only polls the server every 60s by default +// (CloudCollectionMonitor.DefaultPollInterval) — an instance sitting idle would take up to a +// minute to notice a remote change organically. `teamCollection/receiveUpdates` internally calls +// `CloudTeamCollection.PollNow()` before doing anything else, so calling it is the harness's way +// of forcing an immediate poll instead of waiting out the timer. +export const pollNowViaReceiveUpdates = async (httpPort: number) => { + const response = await postApi( + httpPort, + "teamCollection/receiveUpdates", + "{}", + ); + expect(response.status).toBe(200); +}; diff --git a/src/BloomTests/e2e/harness/cdp.ts b/src/BloomTests/e2e/harness/cdp.ts new file mode 100644 index 000000000000..c1ddf267c72b --- /dev/null +++ b/src/BloomTests/e2e/harness/cdp.ts @@ -0,0 +1,35 @@ +// Cloud TC's "share on cloud" flow spans THREE separate WinForms-hosted WebView2 controls in +// the same Bloom.exe process (Collection tab -> Settings dialog's Team Collection tab -> Create +// Team Collection dialog), each a distinct CDP target/page under the SAME debug port. This +// helper polls for a new page matching a URL substring, since Playwright's `browser.on("page")` +// can race the WinForms dialog's WebView2 control finishing navigation. +import { Browser, Page } from "@playwright/test"; + +/** Waits for (and returns) a page whose URL contains `urlSubstring`, polling the browser's + * existing contexts/pages. Use when a WinForms action (e.g. clicking a button that opens a new + * dialog) is expected to bring up a brand-new WebView2 host. */ +export const waitForPage = async ( + browser: Browser, + urlSubstring: string, + timeoutMs = 15_000, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const pages = browser.contexts().flatMap((context) => context.pages()); + const match = pages.find((candidate) => + candidate.url().includes(urlSubstring), + ); + if (match) { + await match.waitForLoadState("domcontentloaded"); + return match; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + const seenUrls = browser + .contexts() + .flatMap((context) => context.pages()) + .map((p) => p.url()); + throw new Error( + `Timed out waiting for a page containing '${urlSubstring}'. Pages currently open: ${JSON.stringify(seenUrls)}`, + ); +}; diff --git a/src/BloomTests/e2e/harness/collectionFixture.ts b/src/BloomTests/e2e/harness/collectionFixture.ts new file mode 100644 index 000000000000..0f2eca5a19a1 --- /dev/null +++ b/src/BloomTests/e2e/harness/collectionFixture.ts @@ -0,0 +1,212 @@ +// Creates scratch folder-collections for E2E scenarios to open, checkout/share, etc. +// +// Rather than checking a duplicate set of book images into this new e2e directory, each fixture +// is stamped out at runtime from the existing, already-known-good, source-controlled sample +// collection at src/BloomVisualRegressionTests/collections/basic/ (kept for visual-regression +// testing) — we only copy its "A5 Portrait" book (dropping the second book and the stale +// history.db files) to keep each scratch collection small and single-book-simple. +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import { repoRoot } from "./paths"; +import { + E2E_SCRATCH_ROOT, + JOINED_COLLECTION_NAME_PREFIX, + documentsBloomFolder, +} from "./reset"; + +const templateCollectionDir = path.join( + repoRoot, + "src", + "BloomVisualRegressionTests", + "collections", + "basic", +); +const templateBookName = "A5 Portrait"; + +export interface ScratchCollection { + /** Folder containing the .bloomCollection file (== the collection folder Bloom opens). */ + collectionFolder: string; + /** Full path to the .bloomCollection file itself (what you pass to launchBloom). */ + collectionFilePath: string; + collectionId: string; + collectionName: string; + /** Name of the one book copied in (folder name == book title, "A5 Portrait"). */ + bookName: string; +} + +const copyDirExcluding = async ( + source: string, + destination: string, + excludeNames: string[], +): Promise => { + await fs.mkdir(destination, { recursive: true }); + const entries = await fs.readdir(source, { withFileTypes: true }); + for (const entry of entries) { + if (excludeNames.includes(entry.name)) continue; + const sourcePath = path.join(source, entry.name); + const destPath = path.join(destination, entry.name); + if (entry.isDirectory()) { + await copyDirExcluding(sourcePath, destPath, excludeNames); + } else { + await fs.copyFile(sourcePath, destPath); + } + } +}; + +/** + * Creates a fresh scratch collection folder under `E2E_SCRATCH_ROOT///` + * with a unique CollectionId and the single "A5 Portrait" template book. `groupName` should be + * the scenario name (so a human can tell scratch folders apart / find them after a failure) and + * `instanceName` a per-Bloom-instance discriminator (e.g. "alice", "bob"). + */ +export const createScratchCollection = async ( + groupName: string, + instanceName: string, + // Defaults to a name carrying JOINED_COLLECTION_NAME_PREFIX: any collection this harness + // shares to the cloud might later be pulled down by another instance, which always lands + // in the real `%MyDocuments%\Bloom\` folder (see reset.ts) — the prefix is what lets + // cleanup find and remove it without ever touching a developer's real collections. + collectionName = `${JOINED_COLLECTION_NAME_PREFIX}${groupName}`, +): Promise => { + const instanceRoot = path.join(E2E_SCRATCH_ROOT, groupName, instanceName); + const collectionFolder = path.join(instanceRoot, collectionName); + await fs.mkdir(collectionFolder, { recursive: true }); + + // Collection-level files (skip the sibling book folders and the collection's own history.db). + const topEntries = await fs.readdir(templateCollectionDir, { + withFileTypes: true, + }); + for (const entry of topEntries) { + if (entry.isDirectory()) continue; // book folders copied explicitly below + if (entry.name === "history.db") continue; + if (entry.name.toLowerCase().endsWith(".bloomcollection")) continue; // written fresh below + await fs.copyFile( + path.join(templateCollectionDir, entry.name), + path.join(collectionFolder, entry.name), + ); + } + + // The one book. + await copyDirExcluding( + path.join(templateCollectionDir, templateBookName), + path.join(collectionFolder, templateBookName), + ["history.db"], + ); + + // Fresh .bloomCollection with a unique CollectionId (avoids collisions if two scratch + // collections both somehow end up sharing state) and the requested display name. + const templateXml = await fs.readFile( + path.join(templateCollectionDir, "basic.bloomCollection"), + "utf8", + ); + const collectionId = randomUUID(); + const stampedXml = templateXml + .replace( + /[^<]*<\/CollectionId>/, + `${collectionId}`, + ) + // Stamp a valid LocalCommunity-tier subscription code. This is load-bearing, not + // decorative: TeamCollectionManager.CheckDisablingTeamCollections disconnects ANY Team + // Collection when FeatureName.TeamCollection isn't enabled for the collection's + // subscription tier (requires LocalCommunity; the template's empty code = Basic). + // DISCOVERED LIVE (E2E-9 name-race): whether that check actually fires for a CLOUD TC is + // a startup RACE -- the check early-returns when TCManager.CurrentCollection is still + // null, and the cloud connection usually isn't established yet at that point in workspace + // load, so most launches sail through... but a launch where sign-in/connection completes + // fast enough gets disconnected with "Team Collections require a Bloom subscription tier + // of at least 'LocalCommunity'" (observed once in ~40 launches; broke every subsequent + // teamCollection/* call in that instance with empty-body 503s). The same test code the + // unit suite uses ("Fake-LC-006273-1463", SubscriptionTests.cs) encodes an expiry of + // ~Sep 2026 -- when it expires, SubscriptionTests will start failing too and both must + // be updated together. + .replace( + /|[^<]*<\/SubscriptionCode>/, + "Fake-LC-006273-1463", + ); + const collectionFilePath = path.join( + collectionFolder, + `${collectionName}.bloomCollection`, + ); + await fs.writeFile(collectionFilePath, stampedXml, "utf8"); + + return { + collectionFolder, + collectionFilePath, + collectionId, + collectionName, + bookName: templateBookName, + }; +}; + +/** + * Seeds a SECOND book folder named `bookName` directly into an already-existing collection + * folder, by copying the same template book used for the collection's original book, stamped + * with a fresh bookInstanceId and its main .htm file renamed to match (mirroring + * `BookStorage.Duplicate`'s own folder-name/htm-name convention). + * + * Used by E2E-9's concurrent-same-name-creation scenario: `CollectionModel.DuplicateBook` + * deliberately gives every duplicate a GUID-suffixed folder name specifically so two + * Team-Collection members' independent duplicates of the SAME source book never collide + * locally (see BookStorage.Duplicate's own comment) -- which means it can't be used to + * construct a genuine same-*proposed*-name race. Seeding two collections' book folders with + * the IDENTICAL name directly on disk (each with its own distinct bookInstanceId, so they are + * unrelated books that merely happen to share a display name) reproduces the race + * `CloudTeamCollection.PutBookInRepo`'s name-conflict retry loop exists to resolve. Must be + * done while the collection's Bloom instance is NOT running (or before its first launch) -- + * the caller is responsible for relaunching afterward so the new folder is picked up by the + * normal collection-load scan, since this only writes files and does not talk to any running + * Bloom process. + */ +export const seedAdditionalBookIntoCollection = async ( + collectionFolder: string, + bookName: string, +): Promise<{ bookInstanceId: string }> => { + const bookFolder = path.join(collectionFolder, bookName); + await copyDirExcluding( + path.join(templateCollectionDir, templateBookName), + bookFolder, + ["history.db"], + ); + + // Rename the main .htm file to match the new folder name (BookStorage.Duplicate's own + // convention -- Bloom expects /.htm) AND stamp the book's TITLE (the + // data-book="bookTitle" divs in the htm, plus meta.json's "title") to match too. + // The title stamping is load-bearing, not cosmetic: Bloom renames a book's folder to match + // its title on save (Book.Save -> SetBookName), and checkInCurrentBook saves first -- so a + // seeded folder named "RaceBook" whose content still says "A5 Portrait" would get renamed + // (uniquified against the collection's existing "A5 Portrait" book) BEFORE the Send, and + // the server would never see the "RaceBook" name this helper's caller is trying to race + // (discovered live: E2E-9's name-race test committed title-derived names instead). + const htmPath = path.join(bookFolder, `${bookName}.htm`); + await fs.rename(path.join(bookFolder, `${templateBookName}.htm`), htmPath); + const htmContent = await fs.readFile(htmPath, "utf8"); + await fs.writeFile( + htmPath, + htmContent.split(templateBookName).join(bookName), + "utf8", + ); + + // Stamp a fresh bookInstanceId so this counts as an unrelated book that merely shares a + // display name with its counterpart on the other side, not a duplicate/re-import of it. + const metaPath = path.join(bookFolder, "meta.json"); + const meta = JSON.parse(await fs.readFile(metaPath, "utf8")); + const bookInstanceId = randomUUID(); + meta.bookInstanceId = bookInstanceId; + meta.title = bookName; + await fs.writeFile(metaPath, JSON.stringify(meta, null, 2), "utf8"); + + return { bookInstanceId }; +}; + +/** Where `collections/pullDown` puts a collection named `collectionName` (Bloom's own fixed, + * non-configurable destination — see reset.ts's `documentsBloomFolder` doc comment). */ +export const pulledDownCollectionFilePath = async ( + collectionName: string, +): Promise => { + const bloomDocsFolder = path.join( + await documentsBloomFolder(), + collectionName, + ); + return path.join(bloomDocsFolder, `${collectionName}.bloomCollection`); +}; diff --git a/src/BloomTests/e2e/harness/db.ts b/src/BloomTests/e2e/harness/db.ts new file mode 100644 index 000000000000..582277f25a5b --- /dev/null +++ b/src/BloomTests/e2e/harness/db.ts @@ -0,0 +1,41 @@ +// Ad-hoc SQL verification against the local Supabase Postgres instance, for assertions like +// "a tc.collections row now exists" that the UI alone can't easily confirm. Connects directly +// via `pg` (not the `supabase db query` CLI) because shelling out to `supabase` — a Volta .cmd +// shim on Windows — needs `shell: true`, and Node's shell-arg concatenation (not proper +// escaping — see the child_process shell-option deprecation warning) mangled quoted SQL +// containing spaces in practice. A direct TCP connection sidesteps all of that. +import { Client } from "pg"; + +// Local Supabase's fixed dev Postgres connection (see `supabase status` / server/dev/README.md). +// Stable across `supabase db reset` — reset replays migrations, it doesn't change credentials. +const CONNECTION_STRING = + "postgresql://postgres:postgres@localhost:54322/postgres"; + +/** Runs `sql` against the local stack and returns the result rows. Opens and closes a fresh + * connection per call — verification queries are infrequent enough that pooling isn't worth + * the complexity of also closing a shared pool at the end of a test run. */ +export const queryDb = async < + T extends Record = Record, +>( + sql: string, + params: unknown[] = [], +): Promise => { + const client = new Client({ connectionString: CONNECTION_STRING }); + await client.connect(); + try { + const result = await client.query(sql, params); + return result.rows as T[]; + } finally { + await client.end(); + } +}; + +/** Opens a connection the caller keeps open across many queries. Only worth the extra + * connect()/end() bookkeeping when a scenario needs a TIGHT polling loop (e.g. E2E-9's + * "kill mid-Send" race) where `queryDb`'s per-call connect overhead (tens of ms) would itself + * dominate the narrow window between a checkin transaction's row-insert and its completion. */ +export const openPersistentClient = async (): Promise => { + const client = new Client({ connectionString: CONNECTION_STRING }); + await client.connect(); + return client; +}; diff --git a/src/BloomTests/e2e/harness/devStack.ts b/src/BloomTests/e2e/harness/devStack.ts new file mode 100644 index 000000000000..ddad519d7fbb --- /dev/null +++ b/src/BloomTests/e2e/harness/devStack.ts @@ -0,0 +1,52 @@ +// Constants describing the local dev stack (server/dev/README.md) that this harness drives +// against. These match the seeded values from server/dev/seed.sql and `supabase start`'s +// fixed local demo keys, which are stable across `supabase db reset` (they are baked into +// supabase/config.toml, not randomly generated per-reset). + +export const SUPABASE_URL = "http://localhost:54321"; + +// Local Supabase's fixed demo anon key (see supabase status / supabase/config.toml). Stable +// across `supabase db reset`. +export const ANON_KEY = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"; + +export const S3_ENDPOINT = "http://localhost:9000"; +export const S3_BUCKET = "bloom-teams-local"; + +// Dev users seeded by server/dev/seed.sql. All share this password. +export const DEV_PASSWORD = "BloomDev123!"; + +export interface DevUser { + email: string; + password: string; +} + +export const ALICE: DevUser = { + email: "alice@dev.local", + password: DEV_PASSWORD, +}; +export const BOB: DevUser = { email: "bob@dev.local", password: DEV_PASSWORD }; +export const ADMIN: DevUser = { + email: "admin@dev.local", + password: DEV_PASSWORD, +}; + +// Builds the BLOOM_CLOUDTC_* environment block for a Bloom.exe instance signed in as `user`. +export const cloudTcEnv = (user?: DevUser): NodeJS.ProcessEnv => ({ + BLOOM_CLOUDTC_SUPABASE_URL: SUPABASE_URL, + BLOOM_CLOUDTC_ANON_KEY: ANON_KEY, + BLOOM_CLOUDTC_S3_ENDPOINT: S3_ENDPOINT, + BLOOM_CLOUDTC_S3_BUCKET: S3_BUCKET, + BLOOM_CLOUDTC_AUTH_MODE: "dev", + // Fast change-propagation for tests: real users poll every 60s, but scenarios that wait + // for a second instance to notice a remote change converge in seconds instead of relying + // on explicit pollNow calls racing the server commit (see server/dev/README.md's env + // table). The 90s expect.poll budgets stay as generous ceilings. + BLOOM_CLOUDTC_POLL_SECONDS: "5", + ...(user + ? { + BLOOM_CLOUDTC_USER: user.email, + BLOOM_CLOUDTC_PASSWORD: user.password, + } + : {}), +}); diff --git a/src/BloomTests/e2e/harness/duplicateBook.ts b/src/BloomTests/e2e/harness/duplicateBook.ts new file mode 100644 index 000000000000..97070d2b0ac2 --- /dev/null +++ b/src/BloomTests/e2e/harness/duplicateBook.ts @@ -0,0 +1,58 @@ +// Creates a brand-new, never-committed local book via the real `collections/duplicateBook/` API +// (CollectionModel.DuplicateBook) rather than the "New Book" wizard, whose picker UI lives in yet +// another ReactDialog-hosted WebView2 (see README.md's CDP-reachability notes) -- duplicating the +// template book gives us a genuinely new bookInstanceId and folder without touching any dialog. +// `external/add-book` was considered and rejected: it explicitly refuses Team Collections +// (ExternalApi.RefuseIfTeamCollection), which `duplicateBook/` does not. +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { expect } from "@playwright/test"; +import { postApi } from "./bloomApi"; +import { readBookInstanceId } from "./selectBook"; + +/** Lists the top-level book folder names directly under a collection folder (directories only, + * skipping the harness's own "Lost and Found" recovery folder so callers can diff before/after). */ +const listBookFolders = async (collectionFolder: string): Promise => { + const entries = await fs.readdir(collectionFolder, { withFileTypes: true }); + return entries + .filter( + (entry) => entry.isDirectory() && entry.name !== "Lost and Found", + ) + .map((entry) => entry.name); +}; + +/** Duplicates the book identified by `sourceBookInstanceId` (read from its own meta.json by the + * caller, e.g. via `readBookInstanceId`) on the instance at `httpPort`, and returns the new + * duplicate's folder name and bookInstanceId. Detected by diffing `collectionFolder`'s book-folder + * listing before/after the call -- CollectionModel.DuplicateBook's own naming convention (today: + * " Copy", numbered on a second collision) is deliberately not hard-coded here. */ +export const duplicateBook = async ( + httpPort: number, + collectionFolder: string, + sourceBookInstanceId: string, +): Promise<{ folderName: string; bookInstanceId: string }> => { + const before = new Set(await listBookFolders(collectionFolder)); + const response = await postApi( + httpPort, + "collections/duplicateBook/", + sourceBookInstanceId, + ); + expect(response.status).toBe(200); + + const after = await listBookFolders(collectionFolder); + const newFolders = after.filter((name) => !before.has(name)); + if (newFolders.length !== 1) { + throw new Error( + `Expected exactly one new book folder under ${collectionFolder} after duplicateBook, ` + + `found ${newFolders.length}: ${JSON.stringify(newFolders)}`, + ); + } + const folderName = newFolders[0]; + const bookInstanceId = await readBookInstanceId( + collectionFolder, + folderName, + ); + return { folderName, bookInstanceId }; +}; + +export { listBookFolders }; diff --git a/src/BloomTests/e2e/harness/experimentalFlag.ts b/src/BloomTests/e2e/harness/experimentalFlag.ts new file mode 100644 index 000000000000..9a1108bc8a40 --- /dev/null +++ b/src/BloomTests/e2e/harness/experimentalFlag.ts @@ -0,0 +1,115 @@ +// Automates the "experimental feature flag must be set in user.config BEFORE launching +// instances" step called out in src/BloomTests/e2e/README.md +// (the same hack recorded in IMPLEMENTATION.md's Wave-3 merge log: "missing experimental- +// feature checkbox — flag set via user.config for now — proper Advanced-settings checkbox +// still owed"). +// +// Cloud Team Collections is gated behind ExperimentalFeatures.kCloudTeamCollections +// ("cloud-team-collections", src/BloomExe/ExperimentalFeatures.cs), stored as a comma- +// separated list in the *shared, per-machine* SIL.Settings CrossPlatformSettingsProvider +// user.config at `%LOCALAPPDATA%\SIL\\\user.config` (Product/Version come +// from BloomExe.csproj's /, currently "Bloom"/"6.5.0.0" — NOT the unrelated +// plain .NET "Bloom.exe_Url_" config folder). This file is shared across ALL Bloom +// instances under this Windows user account (it is not per-collection or per-identity), so +// it only needs to be ensured once per session, not per-launched-instance. +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { repoRoot } from "./paths"; + +const FEATURE_TOKEN = "cloud-team-collections"; + +const readCsprojField = async ( + field: "Product" | "Version", +): Promise => { + const csprojPath = path.join( + repoRoot, + "src", + "BloomExe", + "BloomExe.csproj", + ); + const content = await fs.readFile(csprojPath, "utf8"); + const match = content.match(new RegExp(`<${field}>([^<]+)`)); + if (!match) { + throw new Error(`Could not find <${field}> in ${csprojPath}.`); + } + return match[1]; +}; + +const userConfigPath = async (): Promise => { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + throw new Error( + "LOCALAPPDATA is not set — cannot locate Bloom's user.config.", + ); + } + const [product, version] = await Promise.all([ + readCsprojField("Product"), + readCsprojField("Version"), + ]); + return path.join(localAppData, "SIL", product, version, "user.config"); +}; + +/** Idempotently ensures the "cloud-team-collections" token is present in + * EnabledExperimentalFeatures. Reads the existing user.config (created by any prior Bloom run; + * if it doesn't exist yet, a real Bloom launch creates it, which the harness's first launch + * will have already done for other settings) and does a minimal string edit — this file is the + * developer's real settings (MRU list, window bounds, etc.), so we never overwrite it wholesale. */ +export const ensureExperimentalFeatureEnabled = async (): Promise => { + const configPath = await userConfigPath(); + + let content: string; + try { + content = await fs.readFile(configPath, "utf8"); + } catch { + // No user.config yet on this machine/version. Bloom creates one with defaults on its + // very first run; a bare-bones one that Bloom will happily merge with its schema also + // works, and avoids a chicken-and-egg dependency on having already launched once. + content = MINIMAL_USER_CONFIG; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + } + + const settingMatch = content.match( + /]*>\s*([^<]*)<\/value>/, + ); + + if (settingMatch) { + const tokens = settingMatch[1] + .split(",") + .map((token) => token.trim()) + .filter(Boolean); + if (tokens.includes(FEATURE_TOKEN)) { + return; // already enabled — nothing to do + } + tokens.push(FEATURE_TOKEN); + const updated = content.replace( + /(]*>\s*)([^<]*)(<\/value>)/, + `$1${tokens.join(",")}$3`, + ); + await fs.writeFile(configPath, updated, "utf8"); + return; + } + + // Setting element doesn't exist yet in this user.config — insert one into + // Bloom.Properties.Settings (or, for a from-scratch minimal file, it's already present). + if (!content.includes("")) { + throw new Error( + `${configPath} has no section to add EnabledExperimentalFeatures to. ` + + `Launch Bloom manually once on this machine first, then re-run the harness.`, + ); + } + const insertion = `\n \n ${FEATURE_TOKEN}\n `; + const updated = content.replace("", insertion); + await fs.writeFile(configPath, updated, "utf8"); +}; + +const MINIMAL_USER_CONFIG = ` + + + + + ${FEATURE_TOKEN} + + + + +`; diff --git a/src/BloomTests/e2e/harness/globalSetup.ts b/src/BloomTests/e2e/harness/globalSetup.ts new file mode 100644 index 000000000000..bb153c3bedd6 --- /dev/null +++ b/src/BloomTests/e2e/harness/globalSetup.ts @@ -0,0 +1,24 @@ +// Playwright globalSetup: builds Bloom exactly once for the whole test run (HARD-WON RULE #1 +// — never rebuild while instances from earlier tests might still be starting/stopping). +// Individual spec files are responsible for resetting the stack and launching/killing their +// own instances per scenario. +import { buildBloomOnce } from "./launch"; +import { resetLeakedWebView2Profiles } from "./reset"; + +export default async function globalSetup(): Promise { + // See reset.ts's doc comment: Bloom never cleans up its own WebView2 profile temp folders, + // and letting them accumulate across a long session measurably slows down later launches. + // eslint-disable-next-line no-console + console.log( + "[globalSetup] Clearing leaked WebView2 profile temp folders...", + ); + await resetLeakedWebView2Profiles(); + + // eslint-disable-next-line no-console + console.log( + "[globalSetup] Building Bloom.exe (Release) once for this test session...", + ); + await buildBloomOnce(); + // eslint-disable-next-line no-console + console.log("[globalSetup] Build complete."); +} diff --git a/src/BloomTests/e2e/harness/launch.ts b/src/BloomTests/e2e/harness/launch.ts new file mode 100644 index 000000000000..63d6fdba79db --- /dev/null +++ b/src/BloomTests/e2e/harness/launch.ts @@ -0,0 +1,536 @@ +// Builds and launches Bloom.exe instances for the E2E harness. +// +// HARD-WON ENVIRONMENT RULES this file encodes (see +// src/BloomTests/e2e/README.md, "Environment rules"): +// 1. Build ONCE per test session (`buildBloomOnce`), then launch the built exe directly N +// times with per-instance environment. Never run `dotnet watch`/go.sh concurrently with +// the harness — concurrent rebuilds into the shared `output/` tree produce stale binaries. +// 2. Building while any Bloom.exe runs fails on the locked apphost, so this module kills all +// harness-owned instances before rebuilding, and FAILS LOUDLY if a foreign (non-harness) +// Bloom.exe is already running at session start instead of silently testing stale code. +// 3. Kill via the known-good pattern from .github/skills/bloom-automation: killBloomProcess.mjs +// by exact HTTP port, then verify the port went dark, falling back to a raw taskkill (via +// PowerShell Stop-Process, NOT `taskkill /PID` in Git Bash — that gets mangled by MSYS). +// 4. RELEASE CONFIGURATION IS MANDATORY, not merely preferred: a Debug build shows a modal, +// blocking "Attach debugger now" MessageBox (Program.cs, `#if DEBUG`, fires whenever +/// `args.Length > 0`) on EVERY launch that passes a positional argument — which every +// harness launch does (the .bloomCollection path). This has no on-screen owner in a +// headless/automated session, so it hangs forever with no HTTP/CDP port ever opening. +// Discovered empirically (see progress log) by screen-capturing the launched window with +// PrintWindow after Bloom sat with no listening port for 7+ minutes. Building +// `-c Release` compiles that block out entirely — the one other DEBUG-gated behavior +// (`HARVEST_FOR_LOCALIZATION` env passthrough) is unrelated to Cloud TC and not needed here. +// 5. Bloom picks its own HTTP/CDP ports; there is no `--remote-debugging-port` flag to set. +// Readiness + ports come from parsing the `BLOOM_AUTOMATION_READY {...}` JSON line Bloom +// prints to stdout once `--automation` is passed (see BloomServer.WriteAutomationStartupInfo). +import { spawn, execFile, ChildProcess } from "node:child_process"; +import { promisify } from "node:util"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { chromium, Browser, Page } from "@playwright/test"; +import { repoRoot, bloomExeCsproj, bloomAutomationSkillDir } from "./paths"; +import { DevUser, cloudTcEnv } from "./devStack"; +import { ensureExperimentalFeatureEnabled } from "./experimentalFlag"; +import { startWindowPlacementWatcher } from "./windowPlacement"; + +const execFileAsync = promisify(execFile); + +export const releaseExePath = path.join( + repoRoot, + "output", + "Release", + "AnyCPU", + "Bloom.exe", +); + +/** Builds BloomExe.csproj in Release config exactly once. Call this a single time per test + * session (e.g. Playwright globalSetup) — never per-scenario, and never concurrently with a + * running instance (rule #2 above). */ +export const buildBloomOnce = async (): Promise => { + await assertNoForeignBloomRunning(); + await execFileAsync("dotnet", ["build", bloomExeCsproj, "-c", "Release"], { + cwd: repoRoot, + timeout: 300_000, + windowsHide: true, + maxBuffer: 64 * 1024 * 1024, + }); + if (!fs.existsSync(releaseExePath)) { + throw new Error( + `Build reported success but ${releaseExePath} does not exist. Something is wrong with the build output path.`, + ); + } + await ensureExperimentalFeatureEnabled(); +}; + +/** FAILS LOUDLY (throws) if a Bloom.exe from THIS repo tree is already running at session + * start. Never silently proceed against such an instance — the caller could end up testing + * against a stale binary or clobbering someone else's debugging session, and its process locks + * this tree's build output. An instance from a DIFFERENT worktree (a developer debugging in + * e.g. BloomDesktop.worktrees/CodeReview while the harness runs here) is only WARNED about: + * it locks its own output tree, serves its own port block, and per + * .claude/skills/run-bloom/SKILL.md is not ours to kill. */ +export const assertNoForeignBloomRunning = async (): Promise => { + // This probe is fragile about HOW it is spawned (documented in the automation skills, + // rediscovered the hard way killing three matrix runs in globalSetup): + // - when its stdout is a PIPE (execFile's default), it can die instantly with exit 1 + // and ZERO output — the same run succeeds with inherited/console stdio (verified + // empirically 8 Jul 2026: node-parent + pipe = silent exit 1 every time, while + // stdio:'inherit' and a PowerShell parent both work); + // - it can also crash with a libuv assertion AFTER printing complete valid JSON. + // Both are dodged the same way: never give it a pipe. Redirect its stdout to a temp + // FILE via cmd, then read the file; parse errors after that are genuine failures. + // Generous 60s timeout + one retry for the slow-first-run-after-idle mode. + const runProbe = async (): Promise => { + const os = await import("node:os"); + const tempOut = path.join( + os.tmpdir(), + `bloom-probe-${process.pid}-${Math.random().toString(36).slice(2)}.json`, + ); + const script = path.join( + bloomAutomationSkillDir, + "bloomProcessStatus.mjs", + ); + const outHandle = await fsp.open(tempOut, "w"); + try { + // Real file descriptor for stdout — no pipe (the crash trigger), no shell + // (whose argument re-quoting mangled a cmd-redirect variant of this). + await new Promise((resolve) => { + const child = spawn( + "node", + [script, "--running-bloom", "--json"], + { + cwd: repoRoot, + stdio: ["ignore", outHandle.fd, "ignore"], + windowsHide: true, + }, + ); + const timer = setTimeout(() => child.kill(), 60_000); + // Exit code deliberately ignored: non-zero is fine IF the JSON landed in + // the file (the post-print libuv crash); the parse below is the arbiter. + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + child.once("error", () => { + clearTimeout(timer); + resolve(); + }); + }); + await outHandle.close(); + const output = await fsp.readFile(tempOut, "utf8"); + JSON.parse(output); // throws -> caller retries/fails + return output; + } finally { + await outHandle.close().catch(() => {}); + await fsp.rm(tempOut, { force: true }).catch(() => {}); + } + }; + const normalize = (p?: string) => + (p ?? "") + .replace(/[\\/]+/g, "\\") + .replace(/\\$/, "") + .toLowerCase(); + const thisRepo = normalize(repoRoot); + + let running: Array<{ detectedRepoRoot?: string; executablePath?: string }>; + try { + const status = JSON.parse(await runProbe()); + running = status.runningBloomInstances ?? []; + } catch { + // FALLBACK (8 Jul 2026): on an idling machine (scheduled AV scan crawling process + // enumeration is the leading suspect) the probe never completes when spawned + // without a console, in any stdio configuration tried — it burned four matrix-run + // attempts. The safety property only needs "which Bloom.exe processes exist and + // where from", which Get-Process answers directly. If even THIS fails, something + // is genuinely wrong and we still fail loudly. + const { stdout: psOut } = await execFileAsync( + "powershell", + [ + "-NoProfile", + "-Command", + "Get-Process Bloom -ErrorAction SilentlyContinue | ForEach-Object { $_.Path }", + ], + { cwd: repoRoot, timeout: 60_000, windowsHide: true }, + ); + running = psOut + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((exePath) => ({ executablePath: exePath })); + console.warn( + `[harness] NOTE: bloomProcessStatus.mjs probe failed; used Get-Process fallback ` + + `(${running.length} Bloom.exe found).`, + ); + } + const sameRepo = running.filter( + (instance) => + normalize(instance.detectedRepoRoot) === thisRepo || + normalize(instance.executablePath).startsWith(thisRepo + "\\"), + ); + if (sameRepo.length > 0) { + throw new Error( + `Refusing to build/launch: ${sameRepo.length} Bloom.exe instance(s) from THIS repo tree already running ` + + `(${JSON.stringify(sameRepo)}). Kill them first (see .github/skills/bloom-automation/SKILL.md) ` + + `so the harness never tests against a stale binary or fights the build for locked output files.`, + ); + } + const foreign = running.filter((instance) => !sameRepo.includes(instance)); + if (foreign.length > 0) { + console.warn( + `[harness] NOTE: ${foreign.length} Bloom.exe instance(s) from OTHER worktrees are running ` + + `(${foreign.map((instance) => instance.detectedRepoRoot).join(", ")}). ` + + `Leaving them alone; they hold their own port blocks, so harness instances will take later ports.`, + ); + } +}; + +export interface LaunchedBloom { + processId: number; + httpPort: number; + cdpPort: number; + collectionFolder: string; + logPath: string; + kill: () => Promise; + connect: () => Promise<{ browser: Browser; page: Page }>; +} + +export interface LaunchOptions { + /** Path to the .bloomCollection file to open (skips the interactive chooser). */ + collectionFilePath: string; + /** Dev user this instance signs in as (BLOOM_CLOUDTC_USER/_PASSWORD). Omit for + * "not signed in yet" scenarios (e.g. exercising the in-app sign-in dialog). */ + user?: DevUser; + /** Window-title label, also used to name the log file. Keep distinct per instance. */ + label: string; + /** Directory to write this instance's stdout/stderr log into. */ + logDir: string; + /** Milliseconds to wait for BLOOM_AUTOMATION_READY before failing. */ + readyTimeoutMs?: number; +} + +/** Launches one Bloom.exe (Release build) instance non-interactively against the given + * collection file, waits for the BLOOM_AUTOMATION_READY line, and returns its ports plus + * kill()/connect() helpers. Multiple instances may run concurrently (each gets its own + * HTTP/CDP port; the collection folder and BLOOM_CLOUDTC_USER give each its own identity). */ +export const launchBloom = async ( + options: LaunchOptions, +): Promise => { + if (!fs.existsSync(releaseExePath)) { + throw new Error( + `${releaseExePath} does not exist. Call buildBloomOnce() before launching any instance.`, + ); + } + + await fsp.mkdir(options.logDir, { recursive: true }); + const logPath = path.join( + options.logDir, + `${sanitizeFileName(options.label)}.log`, + ); + const logStream = fs.createWriteStream(logPath, { flags: "w" }); + + const child: ChildProcess = spawn( + releaseExePath, + ["--automation", "--label", options.label, options.collectionFilePath], + { + cwd: repoRoot, + env: { ...process.env, ...cloudTcEnv(options.user) }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: false, + }, + ); + + // Start the window-placement watcher at SPAWN time, not at ready: Bloom.exe is spawned + // directly (child.pid IS the Bloom PID), and the splash screen / collection chooser can + // appear well before BLOOM_AUTOMATION_READY — starting late left those early windows on + // the developer's screens for seconds (reported live, 10 Jul 2026). + const stopWindowPlacement = child.pid + ? startWindowPlacementWatcher( + child.pid, + path.join( + options.logDir, + `${sanitizeFileName(options.label)}.windowPlacement.log`, + ), + ) + : () => {}; + + let stdoutBuffer = ""; + let ready: + | { processId: number; httpPort: number; cdpPort: number } + | undefined; + const readyPromise = new Promise((resolve, reject) => { + const onData = (chunk: Buffer) => { + const text = chunk.toString("utf8"); + stdoutBuffer += text; + logStream.write(chunk); + const match = stdoutBuffer.match(/BLOOM_AUTOMATION_READY (\{.*\})/); + if (match && !ready) { + try { + ready = JSON.parse(match[1]); + resolve(); + } catch (error) { + reject( + new Error( + `Could not parse BLOOM_AUTOMATION_READY JSON for '${options.label}': ${error}`, + ), + ); + } + } + }; + child.stdout?.on("data", onData); + child.stderr?.on("data", (chunk: Buffer) => logStream.write(chunk)); + child.once("exit", (code, signal) => { + if (!ready) { + reject( + new Error( + `Bloom instance '${options.label}' exited (code=${code}, signal=${signal}) ` + + `before reporting ready. Log: ${logPath}`, + ), + ); + } + }); + child.once("error", reject); + }); + + const timeoutMs = options.readyTimeoutMs ?? 90_000; + await withTimeout( + readyPromise, + timeoutMs, + `Bloom instance '${options.label}' did not print BLOOM_AUTOMATION_READY within ${timeoutMs}ms. Log: ${logPath}`, + ); + + if (!ready) { + // Unreachable in practice (withTimeout throws first) but keeps TS happy. + throw new Error( + `Bloom instance '${options.label}' failed to report ready.`, + ); + } + + const readyInfo = ready; + // (The window-placement watcher was already started at spawn time, above — child.pid is + // the Bloom PID since Bloom.exe is spawned directly; readyInfo.processId always matches.) + return { + processId: readyInfo.processId, + httpPort: readyInfo.httpPort, + cdpPort: readyInfo.cdpPort, + collectionFolder: path.dirname(options.collectionFilePath), + logPath, + kill: () => { + stopWindowPlacement(); + return killByHttpPort(readyInfo.httpPort, readyInfo.processId); + }, + connect: () => connectOverCdp(readyInfo.cdpPort), + }; +}; + +/** Kills the exact Bloom instance bound to `httpPort` using the skill's HTTP-based exact-target + * kill (never a broad kill-everything, so concurrent harness instances aren't disturbed), then + * verifies the port went dark AND — when the caller knows it — that `processId` itself is gone. + * The PID check matters: a Bloom stuck showing a modal error dialog (E2E-7's guard test + * provokes one deliberately) can have its port go dark while Bloom.exe survives holding open + * file handles, which broke the NEXT scenario's scratch-folder wipe with EBUSY during the + * first full-matrix run. Port-dark is necessary but not sufficient; process-dead is the real + * postcondition. (killBloomProcess.mjs has also plain under-killed before — see + * .claude/skills/run-bloom/SKILL.md Gotchas.) */ +export const killByHttpPort = async ( + httpPort: number, + processId?: number, +): Promise => { + let killedProcessIds: number[] = []; + try { + const { stdout } = await execFileAsync( + "node", + [ + path.join(bloomAutomationSkillDir, "killBloomProcess.mjs"), + "--http-port", + String(httpPort), + "--json", + ], + { cwd: repoRoot, timeout: 30_000, windowsHide: true }, + ); + killedProcessIds = JSON.parse(stdout).killedProcessIds ?? []; + } catch { + // fall through to the direct-PID / port-dark verification below + } + + // Belt and braces: whatever the script reported, make sure the actual Bloom PID dies. + const pidsToEnsureDead = [ + ...(processId ? [processId] : []), + ...killedProcessIds, + ]; + for (const pid of pidsToEnsureDead) { + await execFileAsync("powershell", [ + "-NoProfile", + "-Command", + `Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`, + ]).catch(() => {}); + } + + const wentDark = await waitForPortDark(httpPort, 10_000); + if (!wentDark) { + throw new Error( + `Bloom instance on HTTP port ${httpPort} would not die (pid=${processId}, killedProcessIds=${JSON.stringify(killedProcessIds)}).`, + ); + } + if (processId) { + const gone = await waitForProcessGone(processId, 10_000); + if (!gone) { + throw new Error( + `Bloom PID ${processId} (port ${httpPort}) survived Stop-Process — it will hold ` + + `file handles and break later scenarios' resets. Investigate before rerunning.`, + ); + } + } +}; + +const waitForProcessGone = async ( + pid: number, + timeoutMs: number, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); // signal 0 = existence probe, kills nothing + } catch { + return true; // ESRCH: no such process + } + await sleep(500); + } + return false; +}; + +/** Kills every Bloom.exe the harness can find (used at session start/end to guarantee a clean + * slate, and between scenarios if a test fails mid-way and leaves instances running). */ +export const killAllBloomInstances = async (): Promise => { + await execFileAsync( + "node", + [path.join(bloomAutomationSkillDir, "killBloomProcess.mjs"), "--json"], + { cwd: repoRoot, timeout: 30_000, windowsHide: true }, + ).catch(() => {}); +}; + +const waitForPortDark = async ( + httpPort: number, + timeoutMs: number, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const alive = await probeHttp(httpPort); + if (!alive) return true; + await sleep(500); + } + return !(await probeHttp(httpPort)); +}; + +const probeHttp = async (httpPort: number): Promise => { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1500); + const response = await fetch( + `http://localhost:${httpPort}/bloom/api/common/instanceInfo`, + { + signal: controller.signal, + }, + ); + clearTimeout(timer); + return response.ok; + } catch { + return false; + } +}; + +/** Connects Playwright over CDP to a specific instance's CDP port and returns its Bloom page + * (the top-level `/bloom/...` document — NOT a devtools:// page). Mirrors + * react_components/component-tester/bloomExeCdp.ts but parameterized per-instance instead of + * reading a single global env var, since this harness runs several instances at once. */ +export const connectOverCdp = async ( + cdpPort: number, +): Promise<{ browser: Browser; page: Page }> => { + const endpoint = `http://127.0.0.1:${cdpPort}`; + // BLOOM_AUTOMATION_READY can print a beat before the WebView2/Edge remote-debugging + // listener actually accepts connections (observed empirically: immediate connectOverCDP + // right after the ready line sometimes gets ECONNREFUSED). Retry briefly rather than + // failing on that harmless startup race. + const browser = await retry( + () => chromium.connectOverCDP(endpoint), + 15_000, + `Could not connect over CDP to ${endpoint}`, + ); + // Actions that make Bloom tear down and recreate its workspace WebView2 controls (e.g. + // createCloudTeamCollection's reopen-collection callback) leave a window where no matching + // page exists yet even though the browser-level CDP connection itself succeeds. Retry the + // page lookup rather than failing on the first miss. + const findPage = () => { + const pages = browser.contexts().flatMap((context) => context.pages()); + return pages.find( + (candidate) => + candidate.url().includes("/bloom/") && + !candidate.url().startsWith("devtools://"), + ); + }; + let page = findPage(); + // Generous: under load (multiple Bloom instances + a live local Supabase/MinIO stack, and + // possibly other processes competing for the machine's CPU/disk in a shared dev + // environment) both fresh-launch startup and the workspace-reopen churn a cloud-collection + // action triggers have been observed taking well over a minute before the main page + // finishes navigating away from `about:blank` and becomes attachable. + const deadline = Date.now() + 120_000; + while (!page && Date.now() < deadline) { + await sleep(500); + page = findPage(); + } + if (!page) { + await browser.close(); + // Diagnostic: dump the raw CDP target list (bypassing Playwright entirely) so a + // failure here shows whether the target is genuinely gone or Playwright just isn't + // attaching to it. + const rawTargets = await fetch(`${endpoint}/json/list`) + .then((response) => response.json()) + .catch((error) => ``); + throw new Error( + `Could not find a Bloom WebView2 target on ${endpoint}. Raw CDP targets: ${JSON.stringify(rawTargets)}`, + ); + } + await page.waitForLoadState("domcontentloaded"); + return { browser, page }; +}; + +const sanitizeFileName = (name: string): string => + name.replace(/[^a-zA-Z0-9_-]+/g, "_"); + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +const retry = async ( + fn: () => Promise, + timeoutMs: number, + message: string, +): Promise => { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + return await fn(); + } catch (error) { + lastError = error; + await sleep(500); + } + } + throw new Error(`${message}: ${lastError}`); +}; + +const withTimeout = async ( + promise: Promise, + ms: number, + message: string, +): Promise => { + let timer: NodeJS.Timeout; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer!); + } +}; diff --git a/src/BloomTests/e2e/harness/paths.ts b/src/BloomTests/e2e/harness/paths.ts new file mode 100644 index 000000000000..ac0304722f27 --- /dev/null +++ b/src/BloomTests/e2e/harness/paths.ts @@ -0,0 +1,30 @@ +// Absolute paths to the repo, the built Bloom.exe, and shared tool locations used across the +// E2E harness. +import * as path from "node:path"; + +// This file lives at src/BloomTests/e2e/harness/paths.ts — repo root is four levels up. +export const repoRoot = path.resolve(__dirname, "..", "..", "..", ".."); + +export const bloomExePath = path.join( + repoRoot, + "output", + "Debug", + "AnyCPU", + "Bloom.exe", +); + +export const bloomExeCsproj = path.join( + repoRoot, + "src", + "BloomExe", + "BloomExe.csproj", +); + +// Skill helper scripts this harness reuses for process discovery/kill (see +// .github/skills/bloom-automation/SKILL.md) rather than reimplementing wmic/taskkill logic. +export const bloomAutomationSkillDir = path.join( + repoRoot, + ".github", + "skills", + "bloom-automation", +); diff --git a/src/BloomTests/e2e/harness/rawCdp.ts b/src/BloomTests/e2e/harness/rawCdp.ts new file mode 100644 index 000000000000..4d99803ecaa7 --- /dev/null +++ b/src/BloomTests/e2e/harness/rawCdp.ts @@ -0,0 +1,204 @@ +// Direct (non-Playwright) CDP client for WebView2 targets that Playwright's connectOverCDP +// cannot see. +// +// DISCOVERED LIMITATION (see progress log / README): Bloom's "Create Team Collection" and +// other ReactDialog-hosted WebView2 controls (opened via `ReactDialog.ShowOnIdle`, a brand-new +// native Form + WebView2 control distinct from the main Collection/Edit/Publish window) show up +// in the raw CDP `/json/list` endpoint but NEVER appear in Playwright's `browser.contexts()` / +// `.pages()` after `chromium.connectOverCDP()` — confirmed empirically by polling for 5+ +// seconds after connecting while the dialog target was independently confirmed still present +// via `/json/list`. This looks like a Playwright/WebView2 auto-attach gap (each WinForms-hosted +// WebView2 control may register as a separate target group that Playwright's default browser +// session doesn't subscribe to), not a timing race. Rather than depending on a fix upstream, +// this harness talks CDP directly over the target's own `webSocketDebuggerUrl` for any page +// that Playwright can't see, using `Runtime.evaluate` to read/manipulate the DOM. This is still +// exercising the real, running WebView2 control — just via a lower-level protocol client. +import { randomUUID } from "node:crypto"; + +export interface CdpTargetInfo { + id: string; + title: string; + url: string; + type: string; + webSocketDebuggerUrl: string; +} + +/** Lists every CDP target Bloom's remote-debugging endpoint currently knows about (includes + * targets Playwright's connectOverCDP fails to auto-attach — see module doc above). */ +export const listCdpTargets = async ( + cdpPort: number, +): Promise => { + const response = await fetch(`http://localhost:${cdpPort}/json/list`); + if (!response.ok) { + throw new Error( + `CDP /json/list failed: ${response.status} ${response.statusText}`, + ); + } + return (await response.json()) as CdpTargetInfo[]; +}; + +/** Polls `/json/list` until a target's title or url contains `substring`, then returns it. */ +export const waitForCdpTarget = async ( + cdpPort: number, + substring: string, + timeoutMs = 15_000, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + // Swallow transient connection failures: opening a brand-new WinForms+WebView2 dialog + // can make the shared CDP endpoint briefly refuse connections (observed empirically), + // which is not the same as "the target doesn't exist yet" and shouldn't abort the poll. + const targets = await listCdpTargets(cdpPort).catch( + () => [] as CdpTargetInfo[], + ); + const match = targets.find( + (target) => + target.title.includes(substring) || + target.url.includes(substring), + ); + if (match) return match; + await new Promise((resolve) => setTimeout(resolve, 300)); + } + const seen = (await listCdpTargets(cdpPort)).map( + (target) => `${target.title} ${target.url}`, + ); + throw new Error( + `Timed out waiting for a CDP target containing '${substring}'. Seen: ${JSON.stringify(seen)}`, + ); +}; + +/** A thin client for a single CDP target's WebSocket endpoint. Only implements what this + * harness needs: Runtime.evaluate (with awaitPromise so async expressions work) and disconnect. */ +export class RawCdpPage { + private ws: WebSocket; + private nextId = 1; + private pending = new Map< + number, + { resolve: (value: any) => void; reject: (error: any) => void } + >(); + private ready: Promise; + + constructor(webSocketDebuggerUrl: string) { + this.ws = new WebSocket(webSocketDebuggerUrl); + this.ready = new Promise((resolve, reject) => { + this.ws.addEventListener("open", () => resolve()); + this.ws.addEventListener("error", (event) => reject(event)); + }); + this.ws.addEventListener("message", (event) => { + const message = JSON.parse(event.data.toString()); + if (message.id && this.pending.has(message.id)) { + const { resolve, reject } = this.pending.get(message.id)!; + this.pending.delete(message.id); + if (message.error) + reject(new Error(JSON.stringify(message.error))); + else resolve(message.result); + } + }); + } + + private send( + method: string, + params: Record = {}, + ): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify({ id, method, params })); + }); + } + + /** Evaluates `expression` in the page's main world. If it returns a Promise, awaits it. */ + async evaluate(expression: string): Promise { + await this.ready; + const result = await this.send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result.exceptionDetails) { + throw new Error( + `Runtime.evaluate threw: ${JSON.stringify(result.exceptionDetails)}\nExpression: ${expression}`, + ); + } + return result.result?.value as T; + } + + /** Sets a React-controlled ``'s value via the native value setter (bypassing React's + * value-setter override) and dispatches an `input` event so React's onChange fires — the + * standard trick for scripting controlled inputs without a full synthetic-event stack. */ + async fillInput(selector: string, value: string): Promise { + const escapedSelector = JSON.stringify(selector); + const escapedValue = JSON.stringify(value); + await this.evaluate(` + (() => { + const el = document.querySelector(${escapedSelector}); + if (!el) throw new Error('fillInput: no element matching ${escapedSelector}'); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(el, ${escapedValue}); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + })() + `); + } + + /** Clicks the element matching `selector` (throws if not found). */ + async click(selector: string): Promise { + const escapedSelector = JSON.stringify(selector); + await this.evaluate(` + (() => { + const el = document.querySelector(${escapedSelector}); + if (!el) throw new Error('click: no element matching ${escapedSelector}'); + el.click(); + })() + `); + } + + /** Returns the (trimmed) textContent of the first element matching `selector`, or null. */ + async textContent(selector: string): Promise { + const escapedSelector = JSON.stringify(selector); + return this.evaluate(` + (() => { + const el = document.querySelector(${escapedSelector}); + return el ? el.textContent.trim() : null; + })() + `); + } + + /** Polls until `selector` exists in the DOM (throws on timeout). */ + async waitForSelector(selector: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const exists = await this.evaluate( + `!!document.querySelector(${JSON.stringify(selector)})`, + ); + if (exists) return; + await new Promise((resolve) => setTimeout(resolve, 300)); + } + throw new Error( + `waitForSelector: '${selector}' did not appear within ${timeoutMs}ms.`, + ); + } + + close(): void { + this.ws.close(); + } +} + +/** Waits for a CDP target matching `titleOrUrlSubstring`, connects to it directly, and returns + * a ready-to-use RawCdpPage. */ +export const attachToCdpTarget = async ( + cdpPort: number, + titleOrUrlSubstring: string, + timeoutMs = 15_000, +): Promise => { + const target = await waitForCdpTarget( + cdpPort, + titleOrUrlSubstring, + timeoutMs, + ); + const cdpPage = new RawCdpPage(target.webSocketDebuggerUrl); + // Force a microtask tick so the constructor's `ready` promise has a listener attached + // before callers start issuing commands (evaluate() already awaits `ready` regardless). + void randomUUID(); + return cdpPage; +}; diff --git a/src/BloomTests/e2e/harness/reset.ts b/src/BloomTests/e2e/harness/reset.ts new file mode 100644 index 000000000000..8d068701c220 --- /dev/null +++ b/src/BloomTests/e2e/harness/reset.ts @@ -0,0 +1,197 @@ +// Resets the local dev stack to a known-empty state between scenarios (HARD-WON RULE #5 in +// src/BloomTests/e2e/README.md "Per-scenario reset"): `supabase db reset` replays +// migrations + seed, the MinIO bucket prefix is cleared via a throwaway `mc` container on the +// same Podman/Docker network as `bloom-minio` (NOT via host.containers.internal — see +// server/dev/README.md's gvproxy-hang gotcha), and local scratch collection folders are wiped. +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { repoRoot } from "./paths"; + +const execFileAsync = promisify(execFile); + +// Root for every scratch collection folder this harness creates. Kept outside the repo so a +// stray leftover can never get committed, and named distinctively so it's obviously +// harness-owned if a human finds it. +export const E2E_SCRATCH_ROOT = "C:\\BloomE2E"; + +// `collections/pullDown` (the "join a cloud collection" API) has no configurable destination: +// CloudJoinFlow.DetermineLocalCollectionFolder always resolves to +// `NewCollectionWizard.DefaultParentDirectoryForCollections` (= `%MyDocuments%\Bloom`) + the +// collection's display name — the SAME real folder a human's Bloom would use. There is no env +// var or API param to redirect it. To keep the harness from ever touching a developer's real +// collections, every collection this harness creates (and therefore ever pulls down) MUST use +// a display name starting with this prefix, and `resetJoinedCollections` only ever deletes +// folders matching it. +export const JOINED_COLLECTION_NAME_PREFIX = "BloomE2E-"; + +// Resolved via the same Windows shell-folder API .NET's Environment.SpecialFolder.MyDocuments +// uses (NOT `%USERPROFILE%\Documents` — Documents is commonly redirected to OneDrive, as it is +// on the machine this harness was built on: `[Environment]::GetFolderPath('MyDocuments')` is +// the only reliable way to match what Bloom itself will resolve). +let cachedDocumentsFolder: string | undefined; +/** The real `%MyDocuments%\Bloom` folder (see the doc comment above `JOINED_COLLECTION_NAME_PREFIX` + * for why this must be resolved via PowerShell rather than `%USERPROFILE%\Documents`). Exported + * for collectionFixture.ts's `pulledDownCollectionFilePath`, which needs the same resolution. */ +export const documentsBloomFolder = async (): Promise => { + if (!cachedDocumentsFolder) { + const { stdout } = await execFileAsync( + "powershell", + [ + "-NoProfile", + "-Command", + "[Environment]::GetFolderPath('MyDocuments')", + ], + { timeout: 10_000, windowsHide: true }, + ); + cachedDocumentsFolder = stdout.trim(); + } + return path.join(cachedDocumentsFolder, "Bloom"); +}; + +/** Deletes any `%MyDocuments%\Bloom\` folder this harness could have created via + * `collections/pullDown` — restricted to names starting with `JOINED_COLLECTION_NAME_PREFIX` + * so this can never touch a developer's real collections. */ +export const resetJoinedCollections = async (): Promise => { + const bloomDocsFolder = await documentsBloomFolder(); + let entries: string[]; + try { + entries = await fs.readdir(bloomDocsFolder); + } catch { + return; // folder doesn't exist yet — nothing to clean + } + await Promise.all( + entries + .filter((name) => name.startsWith(JOINED_COLLECTION_NAME_PREFIX)) + .map((name) => + fs.rm(path.join(bloomDocsFolder, name), { + recursive: true, + force: true, + }), + ), + ); +}; + +/** Runs `supabase db reset`, which drops+recreates the DB, replays all migrations, and reruns + * server/dev/seed.sql (wired via supabase/config.toml's `seed_sql_path`). Wipes all tc.* rows. */ +export const resetDatabase = async (): Promise => { + // `supabase` resolves to a Volta .cmd shim on Windows; Node can only spawn .cmd/.bat files + // with `shell: true`. Safe here since the argument list is fixed (no interpolated/quoted + // user data that shell-concatenation could mangle) — see harness/db.ts for why the SQL + // verification path avoids this CLI entirely. + await execFileAsync("supabase", ["db", "reset"], { + cwd: repoRoot, + timeout: 120_000, + windowsHide: true, + shell: true, + }); +}; + +/** Clears every object (and all noncurrent versions) under the dev bucket by running `mc rm` + * in a throwaway container on the same network as `bloom-minio` — container-to-container + * traffic on the shared bridge network is instant; routing through the host gateway + * (host.containers.internal) is known to hang indefinitely on this Podman setup (see + * server/dev/README.md "Known gotchas" #1). Uses `podman`; falls back to `docker` if present. */ +export const resetMinioBucket = async ( + bucket = "bloom-teams-local", +): Promise => { + const engine = await detectContainerEngine(); + const script = `mc alias set local http://bloom-minio:9000 minioadmin minioadmin >/dev/null && mc rm --recursive --force --versions local/${bucket} || true`; + await execFileAsync( + engine, + [ + "run", + "--rm", + "--network", + "dev_default", + "--entrypoint", + "/bin/sh", + "quay.io/minio/mc:latest", + "-c", + script, + ], + { + timeout: 60_000, + windowsHide: true, + env: { ...process.env, MSYS_NO_PATHCONV: "1" }, + }, + ); +}; + +let cachedEngine: string | undefined; +const detectContainerEngine = async (): Promise => { + if (cachedEngine) return cachedEngine; + for (const candidate of ["podman", "docker"]) { + try { + await execFileAsync( + candidate, + ["version", "--format", "{{.Server.Os}}"], + { + timeout: 10_000, + windowsHide: true, + }, + ); + cachedEngine = candidate; + return candidate; + } catch { + // try next + } + } + throw new Error( + "Neither podman nor docker is available to reset the MinIO bucket. See server/dev/README.md prerequisites.", + ); +}; + +/** Deletes every scratch collection folder created by a previous run, and recreates the empty + * root. Safe to call even if the root doesn't exist yet. */ +export const resetScratchCollections = async (): Promise => { + await fs.rm(E2E_SCRATCH_ROOT, { recursive: true, force: true }); + await fs.mkdir(E2E_SCRATCH_ROOT, { recursive: true }); +}; + +// DISCOVERED (see progress log): WebView2Browser.cs never deletes its per-environment +// `%TEMP%\Bloom WV2-` profile folder on exit ("Enhance: it might be a good +// thing to try to delete this folder if we find it already exists" — an acknowledged gap in +// Bloom itself). Across a long harness session (many Bloom launches, 2+ WebView2 environments +// each) these accumulate into the hundreds, and environment creation does a linear +// `Directory.Exists` scan to find a free name — combined with antivirus real-time scanning of +// each newly-created profile's files, this was observed to make later launches in the same +// session dramatically slower (a fresh single-instance launch sitting at `about:blank` for 2+ +// minutes with 117 leaked folders present; disk usage of the leaked folders was large enough +// that even `du` over them didn't finish in 60s). Not fixable from outside Bloom's process +// while it runs, so the harness proactively cleans these up itself. +const webView2TempFolderGlobPrefix = "Bloom WV2-"; +export const resetLeakedWebView2Profiles = async (): Promise => { + const tempDir = process.env.TEMP ?? process.env.TMP; + if (!tempDir) return; + let entries: string[]; + try { + entries = await fs.readdir(tempDir); + } catch { + return; + } + await Promise.all( + entries + .filter((name) => name.startsWith(webView2TempFolderGlobPrefix)) + .map((name) => + fs + .rm(path.join(tempDir, name), { + recursive: true, + force: true, + }) + .catch(() => {}), + ), + ); +}; + +/** Full per-scenario reset: DB + bucket + local scratch folders. Call this in `test.beforeEach` + * (or once in `beforeAll` for scenarios that intentionally chain steps within one test). */ +export const resetStack = async (): Promise => { + await Promise.all([ + resetDatabase(), + resetMinioBucket(), + resetScratchCollections(), + resetJoinedCollections(), + ]); +}; diff --git a/src/BloomTests/e2e/harness/s3.ts b/src/BloomTests/e2e/harness/s3.ts new file mode 100644 index 000000000000..eaf1ca872181 --- /dev/null +++ b/src/BloomTests/e2e/harness/s3.ts @@ -0,0 +1,69 @@ +// S3 (MinIO) verification helpers. Uses the same throwaway-`mc`-container-on-the-shared-network +// approach as harness/reset.ts's bucket clear (see that file's comment for why +// `host.containers.internal` is never used here). +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +let cachedEngine: string | undefined; +const detectContainerEngine = async (): Promise => { + if (cachedEngine) return cachedEngine; + for (const candidate of ["podman", "docker"]) { + try { + await execFileAsync( + candidate, + ["version", "--format", "{{.Server.Os}}"], + { + timeout: 10_000, + windowsHide: true, + }, + ); + cachedEngine = candidate; + return candidate; + } catch { + // try next + } + } + throw new Error("Neither podman nor docker is available to query MinIO."); +}; + +/** Lists every object key under `prefix` (recursive) in the dev bucket. Returns bare keys + * (relative to the bucket root), e.g. `tc//books//meta.json`. */ +export const listS3Objects = async ( + prefix: string, + bucket = "bloom-teams-local", +): Promise => { + const engine = await detectContainerEngine(); + const script = `mc alias set local http://bloom-minio:9000 minioadmin minioadmin >/dev/null && mc ls --recursive local/${bucket}/${prefix} 2>/dev/null || true`; + const { stdout } = await execFileAsync( + engine, + [ + "run", + "--rm", + "--network", + "dev_default", + "--entrypoint", + "/bin/sh", + "quay.io/minio/mc:latest", + "-c", + script, + ], + { + timeout: 30_000, + windowsHide: true, + env: { ...process.env, MSYS_NO_PATHCONV: "1" }, + }, + ); + // Each line looks like: "[2026-07-08 05:39:57 UTC] 56KiB STANDARD books//A5 Portrait.htm" + // The key is everything after the 4th whitespace-separated field (keys may contain spaces). + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const match = line.match(/^\[.+?\]\s+\S+\s+\S+\s+(.+)$/); + return match ? `${prefix.replace(/\/$/, "")}/${match[1]}` : null; + }) + .filter((key): key is string => key !== null); +}; diff --git a/src/BloomTests/e2e/harness/selectBook.ts b/src/BloomTests/e2e/harness/selectBook.ts new file mode 100644 index 000000000000..c2bacc4315be --- /dev/null +++ b/src/BloomTests/e2e/harness/selectBook.ts @@ -0,0 +1,81 @@ +// Selects a book via the direct `external/select-book` API instead of a CDP click. +// +// DISCOVERED (E2E-3): a fresh `connectOverCdp` against an instance that joined a cloud +// collection via pullDown + kill + relaunch (Bob's pattern in twoInstanceSetup.ts) reliably finds +// only a stuck `about:blank` CDP target -- confirmed via the raw `/json/list` endpoint showing +// exactly one target, that URL, unchanging for the full 120s retry window, even though the same +// instance's HTTP API (`teamCollection/capabilities`, etc.) was already responding correctly +// (i.e. the instance itself is fully initialized; this is specifically a CDP-attach problem, not +// a slow-startup one). This is the same class of problem as README.md's "ReactDialog-hosted +// WebView2 controls are not CDP-reachable" finding (only one WebView2 control's content can ever +// be visible on the single shared `--remote-debugging-port` at a time) -- Alice's page stays +// reliably attached only because it connects ONCE, before any dialog/reload churn, and is never +// reconnected afterward. Bob's instance never gets that same early, held-open connection in these +// scenarios, so a *fresh* connect after his relaunch hits the same problem createCloudTeamCollection's +// reopen causes for a fresh post-hoc connect. +// +// Workaround: `external/select-book` (src/BloomExe/web/controllers/ExternalApi.cs) does exactly +// what a real book-tile click does (`_collectionModel.SelectBook`) without needing any WebView2 at +// all, given the book's id (`BookInfo.Id`, JSON property `bookInstanceId` in the book's meta.json). +// This sidesteps the CDP-attach problem entirely for any instance that doesn't already have a page +// held open from before its own launch/reopen churn. +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { expect } from "@playwright/test"; +import { postApi } from "./bloomApi"; + +/** Waits until `filePath` exists (progressive join, batch item 7: a freshly joined + * collection opens IMMEDIATELY with placeholder tiles while each book's folder downloads in + * the background — so specs must not assume a book's files are on disk the moment pullDown + * or a join-time relaunch returns). Rejects with a pointed message after `timeoutMs`. */ +export const waitForBookFile = async ( + filePath: string, + timeoutMs = 90_000, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await fs.access(filePath); + return; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + throw new Error( + `${filePath} did not appear within ${timeoutMs}ms — background book download ` + + `(progressive join) never delivered it.`, + ); +}; + +/** Reads `bookInstanceId` out of `//meta.json`, first waiting for + * the file to exist (see waitForBookFile — joined books now download in the background). */ +export const readBookInstanceId = async ( + collectionFolder: string, + bookName: string, +): Promise => { + const metaPath = path.join(collectionFolder, bookName, "meta.json"); + await waitForBookFile(metaPath); + const meta = JSON.parse(await fs.readFile(metaPath, "utf8")); + if (!meta.bookInstanceId) { + throw new Error(`${metaPath} has no bookInstanceId.`); + } + return meta.bookInstanceId as string; +}; + +/** Makes `bookName` (identified by its `bookInstanceId`, read from `collectionFolder`'s copy of + * its meta.json -- any instance's copy works, since cloud-collection books share one server-side + * id) the current selection on the instance at `httpPort`, via `external/select-book` rather than + * a CDP click. Requires the Collection tab to be the active tab (true by default on launch). */ +export const selectBookByName = async ( + httpPort: number, + collectionFolder: string, + bookName: string, +): Promise => { + const id = await readBookInstanceId(collectionFolder, bookName); + const response = await postApi( + httpPort, + "external/select-book", + JSON.stringify({ id }), + ); + expect(response.status).toBe(200); +}; diff --git a/src/BloomTests/e2e/harness/twoInstanceSetup.ts b/src/BloomTests/e2e/harness/twoInstanceSetup.ts new file mode 100644 index 000000000000..389aa2e42552 --- /dev/null +++ b/src/BloomTests/e2e/harness/twoInstanceSetup.ts @@ -0,0 +1,100 @@ +// Shared "Alice shares a collection, approves Bob, Bob joins" setup, factored out of E2E-2 once +// E2E-3 needed the identical sequence. Every scenario below E2E-2 that needs two instances on the +// same cloud collection should use this instead of re-deriving the create/approve/pullDown/ +// relaunch dance (see E2E-2's header comment for why each individual step is shaped the way it +// is -- connect-before-trigger, direct-API create, relaunch-after-pullDown). +import { expect } from "@playwright/test"; +import { Page } from "@playwright/test"; +import { + createScratchCollection, + pulledDownCollectionFilePath, + ScratchCollection, +} from "./collectionFixture"; +import { launchBloom, LaunchedBloom } from "./launch"; +import { ALICE, BOB, DevUser } from "./devStack"; +import { + postApi, + postCreateCloudTeamCollection, + waitForSharingReady, +} from "./bloomApi"; + +export interface SharedCloudCollection { + alice: LaunchedBloom; + alicePage: Page; + aliceScratch: ScratchCollection; + bob: LaunchedBloom; + bobCollectionFilePath: string; +} + +/** Alice creates+shares `scenarioName`'s scratch collection, approves Bob as a member, and Bob + * pulls it down and relaunches pointed at his own local copy. Returns both live instances plus + * Alice's already-attached Page (kept open across the create-cloud-collection reopen per the + * connect-before-trigger finding) and the path to Bob's pulled-down .bloomCollection file. Callers + * are responsible for killing both instances (e.g. in `afterEach`). */ +export const setUpAliceAndBobOnSharedCollection = async ( + scenarioName: string, + logDir: string, + bobUser: DevUser = BOB, +): Promise => { + const aliceScratch = await createScratchCollection(scenarioName, "alice"); + const alice = await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: `${scenarioName}-alice`, + logDir, + }); + + // Connect BEFORE triggering createCloudTeamCollection and keep reusing this same Page -- + // see E2E-2's header comment for why a fresh connectOverCDP after the reopen is unreliable. + const { page: alicePage } = await alice.connect(); + + const createResponse = await postCreateCloudTeamCollection(alice); + expect(createResponse.status).toBe(200); + await waitForSharingReady(alice.httpPort); + + const approveResponse = await postApi( + alice.httpPort, + "sharing/addApproval", + JSON.stringify({ + collectionId: aliceScratch.collectionId, + email: bobUser.email, + role: "member", + }), + ); + expect(approveResponse.status).toBe(200); + + const bobPlaceholder = await createScratchCollection( + scenarioName, + "bob", + "BobPlaceholder", + ); + let bob = await launchBloom({ + collectionFilePath: bobPlaceholder.collectionFilePath, + user: bobUser, + label: `${scenarioName}-bob`, + logDir, + }); + + const pullDownResponse = await postApi( + bob.httpPort, + "collections/pullDown", + JSON.stringify({ collectionId: aliceScratch.collectionId }), + ); + expect(pullDownResponse.status).toBe(200); + + // pullDown only downloads files -- it doesn't switch what the currently-running instance has + // open, so relaunch pointed at the freshly pulled-down collection. + await bob.kill(); + const bobCollectionFilePath = await pulledDownCollectionFilePath( + aliceScratch.collectionName, + ); + bob = await launchBloom({ + collectionFilePath: bobCollectionFilePath, + user: bobUser, + label: `${scenarioName}-bob-joined`, + logDir, + }); + await waitForSharingReady(bob.httpPort); + + return { alice, alicePage, aliceScratch, bob, bobCollectionFilePath }; +}; diff --git a/src/BloomTests/e2e/harness/watchWindowScreen.ps1 b/src/BloomTests/e2e/harness/watchWindowScreen.ps1 new file mode 100644 index 000000000000..c7c9261d1c50 --- /dev/null +++ b/src/BloomTests/e2e/harness/watchWindowScreen.ps1 @@ -0,0 +1,123 @@ +# Keeps every top-level window of one Bloom.exe process on a specific monitor, so E2E runs +# can be confined to a rarely-used screen instead of stealing the developer's workspace. +# Spawned (detached, one per launched instance) by harness/windowPlacement.ts when the +# BLOOM_E2E_SCREEN environment variable is set; exits on its own when the Bloom PID dies. +# +# Why a poll rather than a one-time move: Bloom recreates its main window during +# createCloudTeamCollection's reopen-collection callback (Program.SwitchToCollection closes +# the Shell and opens a new one), shows a splash screen first, and opens WinForms dialogs -- +# each a NEW top-level window that would appear on the default monitor. The poll is fast +# (500ms) for the first minute -- when the splash/chooser/dialog churn happens -- then 2s. +# +# DPI awareness is MANDATORY, not a nicety: without it, PowerShell runs DPI-unaware, so on a +# mixed-scaling multi-monitor setup both the screen bounds it reads AND the MoveWindow +# coordinates it passes are virtualized, and windows land on the WRONG monitor (found live, +# 10 Jul 2026: with the primary at >100% scaling, windows targeted at the leftmost screen +# consistently landed one monitor to its right). +param( + [Parameter(Mandatory = $true)][int]$TargetPid, + # 1-based, counting screens left-to-right by their X coordinate (see windowPlacement.ts + # and the README for how to list screens). + [Parameter(Mandatory = $true)][int]$ScreenIndex, + # Optional log file; decisions and moves are appended so misplacement is diagnosable. + [string]$LogPath +) + +$ErrorActionPreference = "SilentlyContinue" + +function Write-PlacementLog([string]$message) { + if ($LogPath) { + try { + Add-Content -Path $LogPath -Value "$(Get-Date -Format 'HH:mm:ss.fff') $message" + } catch {} + } +} + +Add-Type @" +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +public class BloomWindowMover { + [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + [DllImport("user32.dll")] static extern bool MoveWindow(IntPtr hWnd, int x, int y, int w, int h, bool repaint); + [DllImport("user32.dll")] static extern bool IsZoomed(IntPtr hWnd); + [DllImport("user32.dll")] static extern bool ShowWindow(IntPtr hWnd, int cmd); + [DllImport("user32.dll")] static extern IntPtr SetProcessDpiAwarenessContext(IntPtr value); + [DllImport("user32.dll")] static extern bool SetProcessDPIAware(); + const int SW_RESTORE = 9; + const int SW_MAXIMIZE = 3; + public struct RECT { public int Left, Top, Right, Bottom; } + delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + // Must run before ANY window/screen API use. Per-monitor-v2 gives physical (unscaled) + // coordinates for both Screen bounds and MoveWindow on mixed-DPI setups; the fallback + // (system aware) still beats DPI-unaware virtualization on the primary monitor. + public static void MakeDpiAware() { + // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 == (DPI_AWARENESS_CONTEXT)-4 + if (SetProcessDpiAwarenessContext(new IntPtr(-4)) == IntPtr.Zero) + SetProcessDPIAware(); + } + + public static List VisibleTopLevelWindows(uint targetPid) { + var result = new List(); + EnumWindows((h, l) => { + uint pid; GetWindowThreadProcessId(h, out pid); + if (pid == targetPid && IsWindowVisible(h)) result.Add(h); + return true; + }, IntPtr.Zero); + return result; + } + + // Moves the window onto the target screen (given by its working-area rectangle) if its + // center isn't already there. Preserves size (clamped to the screen) and maximized state. + // Returns true when a move actually happened (for logging). + public static bool EnsureOnScreen(IntPtr hWnd, int sx, int sy, int sw, int sh) { + RECT r; + if (!GetWindowRect(hWnd, out r)) return false; + int cx = (r.Left + r.Right) / 2, cy = (r.Top + r.Bottom) / 2; + bool onTarget = cx >= sx && cx < sx + sw && cy >= sy && cy < sy + sh; + if (onTarget) return false; + bool wasZoomed = IsZoomed(hWnd); + if (wasZoomed) ShowWindow(hWnd, SW_RESTORE); + int w = Math.Min(r.Right - r.Left, sw), h = Math.Min(r.Bottom - r.Top, sh); + MoveWindow(hWnd, sx, sy, w, h, true); + if (wasZoomed) ShowWindow(hWnd, SW_MAXIMIZE); // re-maximizes onto the NEW monitor + return true; + } +} +"@ + +# DPI awareness FIRST -- System.Windows.Forms reads screen bounds at first use, so awareness +# must be set before the assembly queries monitors. +[BloomWindowMover]::MakeDpiAware() +Add-Type -AssemblyName System.Windows.Forms + +$screens = [System.Windows.Forms.Screen]::AllScreens | Sort-Object { $_.Bounds.X }, { $_.Bounds.Y } +$screenList = ($screens | ForEach-Object { "$($_.DeviceName)@$($_.Bounds.X),$($_.Bounds.Y) $($_.Bounds.Width)x$($_.Bounds.Height)" }) -join "; " +if ($ScreenIndex -lt 1 -or $ScreenIndex -gt $screens.Count) { + Write-PlacementLog "ERROR: screen index $ScreenIndex out of range (1..$($screens.Count)); screens: $screenList" + Write-Output "watchWindowScreen: screen index $ScreenIndex out of range (1..$($screens.Count)); exiting." + exit 1 +} +$target = $screens[$ScreenIndex - 1].WorkingArea +Write-PlacementLog "watching PID $TargetPid; target screen #$ScreenIndex = $($screens[$ScreenIndex - 1].DeviceName) workingArea=$($target.X),$($target.Y) $($target.Width)x$($target.Height); all screens: $screenList" + +$started = Get-Date +while ($true) { + $bloom = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue + if (-not $bloom) { Write-PlacementLog "PID $TargetPid gone; exiting."; exit 0 } + foreach ($hwnd in [BloomWindowMover]::VisibleTopLevelWindows($TargetPid)) { + if ([BloomWindowMover]::EnsureOnScreen($hwnd, $target.X, $target.Y, $target.Width, $target.Height)) { + Write-PlacementLog "moved hwnd $hwnd to screen #$ScreenIndex" + } + } + # Fast polling during the first minute (splash/chooser/dialog churn), gentler afterward. + if (((Get-Date) - $started).TotalSeconds -lt 60) { + Start-Sleep -Milliseconds 500 + } else { + Start-Sleep -Seconds 2 + } +} diff --git a/src/BloomTests/e2e/harness/windowPlacement.ts b/src/BloomTests/e2e/harness/windowPlacement.ts new file mode 100644 index 000000000000..c07ff72b8808 --- /dev/null +++ b/src/BloomTests/e2e/harness/windowPlacement.ts @@ -0,0 +1,109 @@ +// Optional: confine every launched Bloom window to one monitor, so E2E runs don't take over +// the developer's working screens. Opt-in via the BLOOM_E2E_SCREEN environment variable — +// a 1-based screen number counting monitors left-to-right by X coordinate. Unset = current +// behavior (windows appear wherever Windows puts them). +// +// List your screens (same left-to-right order this uses): +// powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::AllScreens | Sort-Object {$_.Bounds.X} | ForEach-Object {$i=1} { \"$i. $($_.DeviceName) $($_.Bounds) primary=$($_.Primary)\"; $i++ }" +// +// Implementation: one detached PowerShell watcher per Bloom instance (watchWindowScreen.ps1) +// polls the process's top-level windows and moves any stray onto the target screen. A poll, +// not a one-shot, because Bloom creates NEW top-level windows after launch (splash, the +// post-reopen Shell that createCloudTeamCollection causes, WinForms dialogs). The watcher +// exits by itself when the Bloom PID dies, so leaks are impossible even if stop() is missed. +// +// Caveat worth knowing: Bloom saves its window position to the shared per-machine user.config +// on exit, so after an E2E run the developer's OWN next Bloom launch may open on the E2E +// screen once. Harmless, but surprising the first time. +import { spawn, execFileSync } from "node:child_process"; +import * as path from "node:path"; + +const watcherScript = path.join(__dirname, "watchWindowScreen.ps1"); + +// Fallback for a real footgun: setting BLOOM_E2E_SCREEN as a Windows user/machine +// environment variable does NOT reach processes whose parent shell started before the +// variable was set (long-lived agent shells, IDE terminals, service-launched runners). When +// the inherited environment lacks the variable, ask the registry-backed stores directly so +// "I set the variable, why is it ignored?" can't happen. Read once per test process. +let registryLookupDone = false; +let registryValue: string | undefined; +const envOrRegistry = (): string | undefined => { + if (process.env.BLOOM_E2E_SCREEN) return process.env.BLOOM_E2E_SCREEN; + if (!registryLookupDone) { + registryLookupDone = true; + try { + const value = execFileSync( + "powershell", + [ + "-NoProfile", + "-Command", + "[Environment]::GetEnvironmentVariable('BLOOM_E2E_SCREEN','User'), [Environment]::GetEnvironmentVariable('BLOOM_E2E_SCREEN','Machine') | Where-Object { $_ } | Select-Object -First 1", + ], + { timeout: 15_000, encoding: "utf8" }, + ).trim(); + registryValue = value || undefined; + } catch { + registryValue = undefined; + } + } + return registryValue; +}; + +/** The 1-based screen index from BLOOM_E2E_SCREEN (inherited environment, falling back to + * the user/machine registry value), or undefined when the feature is off. */ +export const configuredScreenIndex = (): number | undefined => { + const raw = envOrRegistry(); + if (!raw) return undefined; + const index = Number(raw); + if (!Number.isInteger(index) || index < 1) { + throw new Error( + `BLOOM_E2E_SCREEN must be a positive screen number (got '${raw}'). ` + + `See harness/windowPlacement.ts for how to list screens.`, + ); + } + return index; +}; + +/** Starts the window-placement watcher for one Bloom instance if BLOOM_E2E_SCREEN is set. + * Returns a stop function (safe to call more than once; also safe to never call, since the + * watcher exits when the Bloom PID dies). */ +export const startWindowPlacementWatcher = ( + bloomProcessId: number, + logPath?: string, +): (() => void) => { + const screenIndex = configuredScreenIndex(); + if (!screenIndex) return () => {}; + const args = [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + watcherScript, + "-TargetPid", + String(bloomProcessId), + "-ScreenIndex", + String(screenIndex), + ]; + // The watcher appends its decisions (target screen resolution, every move) here, so + // misplacement is diagnosable from artifacts instead of live observation. + if (logPath) args.push("-LogPath", logPath); + // detached MUST be false: spawning powershell with detached+windowsHide makes it exit + // code 0 within ~1s having executed NOTHING (empirically bisected 10 Jul 2026 — the + // DETACHED_PROCESS and CREATE_NO_WINDOW console flags conflict for a console app). This + // silent failure meant the watcher NEVER ran from the day this feature was added; every + // "windows on the wrong monitor" report traces back to it. Non-detached loses nothing: + // the watcher still self-exits when the Bloom PID dies, and stop() still kills it. + const watcher = spawn("powershell", args, { + detached: false, + stdio: "ignore", + windowsHide: true, + }); + watcher.unref(); // never keep the test process alive on its account + return () => { + try { + watcher.kill(); + } catch { + // already gone — fine, it self-exits when the Bloom PID dies + } + }; +}; diff --git a/src/BloomTests/e2e/package.json b/src/BloomTests/e2e/package.json new file mode 100644 index 000000000000..7a04e07486a4 --- /dev/null +++ b/src/BloomTests/e2e/package.json @@ -0,0 +1,27 @@ +{ + "name": "bloom-cloudtc-e2e", + "private": true, + "version": "0.0.0", + "description": "Playwright-over-CDP E2E harness for Cloud Team Collections. See README.md for invocation, environment prerequisites, and the hard-won environment rules this harness encodes.", + "engines": { + "node": ">=22.11.0" + }, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed --workers=1" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^22.10.1", + "@types/pg": "^8.20.0", + "tsx": "^4.23.0", + "typescript": "^5.6.3" + }, + "volta": { + "node": "22.21.1", + "yarn": "1.22.22" + }, + "dependencies": { + "pg": "^8.22.0" + } +} diff --git a/src/BloomTests/e2e/playwright.config.ts b/src/BloomTests/e2e/playwright.config.ts new file mode 100644 index 000000000000..91266ae17387 --- /dev/null +++ b/src/BloomTests/e2e/playwright.config.ts @@ -0,0 +1,23 @@ +import type { PlaywrightTestConfig } from "@playwright/test"; + +// Real Bloom.exe instances + a live local Supabase/MinIO stack are slow to bring up (multi- +// second RPC round trips, S3 uploads, `supabase db reset`). No watch modes, no retries that +// could mask a flaky reset, and workers=1: scenarios launch/kill real OS processes and reset +// shared local infra (DB + MinIO bucket), so parallel workers would stomp on each other. +const config: PlaywrightTestConfig = { + testDir: "tests", + globalSetup: require.resolve("./harness/globalSetup.ts"), + timeout: 480_000, + workers: 1, + retries: 0, + fullyParallel: false, + reporter: [["list"]], + expect: { + timeout: 15_000, + }, + use: { + trace: "retain-on-failure", + }, +}; + +export default config; diff --git a/src/BloomTests/e2e/tests/e2e-1-create-share.spec.ts b/src/BloomTests/e2e/tests/e2e-1-create-share.spec.ts new file mode 100644 index 000000000000..5cbc39066e35 --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-1-create-share.spec.ts @@ -0,0 +1,122 @@ +// E2E-1: create/share an existing collection (verify rows + objects). +// +// The UI path for this (Settings dialog -> Team Collection tab -> "Share this collection on +// the Bloom sharing server" -> CreateCloudTeamCollectionDialog) is NOT automatable over CDP: +// every one of those is a WinForms Form hosting its OWN WebView2 control (a genuinely separate +// browser process/environment, confirmed via Bloom's own log: each shows a distinct +// `UserDataFolder=...Bloom WV2-`), and ALL of them request the SAME fixed +// `--remote-debugging-port` (WebView2Browser.cs: `RemoteDebuggingPort => portForHttp + 2`, +// applied uniformly to every control). Only one such browser process can ever actually bind +// that port, so secondary dialogs are invisible to Playwright's `connectOverCDP` AND to the +// raw CDP `/json/list` endpoint (confirmed empirically: polled both for 15s with the dialog +// independently known to be open). See README.md and the progress log for the full +// investigation. +// +// Since the create-cloud-collection dialog's checkboxes are a pure client-side +// acknowledgement gate (never sent to the server -- see HandleCreateCloudTeamCollection in +// TeamCollectionApi.cs, which takes no request body), this test drives the exact same backend +// action the dialog's "Share Collection" button would (`POST +// teamCollection/createCloudTeamCollection`), then verifies the real, observable results: the +// TeamCollection capabilities flip from folder to cloud, a `tc.collections` row appears, and +// every collection file + book file lands in S3 under `tc//`. +import { test, expect } from "@playwright/test"; +import * as path from "node:path"; +import { resetStack } from "../harness/reset"; +import { createScratchCollection } from "../harness/collectionFixture"; +import { launchBloom, LaunchedBloom } from "../harness/launch"; +import { ALICE } from "../harness/devStack"; +import { + postApi, + getApi, + postCreateCloudTeamCollection, + waitForSharingReady, +} from "../harness/bloomApi"; +import { queryDb } from "../harness/db"; +import { listS3Objects } from "../harness/s3"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-1"; + +test.describe("E2E-1 create/share an existing collection", () => { + let instance: LaunchedBloom | undefined; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + if (instance) { + await instance.kill(); + instance = undefined; + } + }); + + test("sharing a folder collection to the cloud creates a DB row and uploads every file", async () => { + const scratch = await createScratchCollection("e2e-1", "alice"); + + instance = await launchBloom({ + collectionFilePath: scratch.collectionFilePath, + user: ALICE, + label: "e2e1-alice", + logDir: LOG_DIR, + }); + + // Wait for the workspace WebView2 to finish initializing before triggering + // createCloudTeamCollection (the same connect-before-trigger pattern E2E-2 uses, but + // for a different reason): the endpoint's handler runs ON the UI thread and opens a + // modal BrowserProgressDialog. If the request arrives while the workspace browser is + // still inside EnsureBrowserReadyToNavigate's nested message pump, the dialog's OWN + // WebView2 initialization deadlocks against it, the dialog's React page never POSTs + // progress/ready, DoWorkWithProgressDialog's worker spins forever on + // _readyForProgressReports, and the HTTP request never returns (diagnosed 8 Jul 2026 + // from a dotnet-stack dump of the hung process; reproducible whenever the desktop + // session is locked, which slows WebView2 startup enough to lose the race every time). + // A CDP-attachable, dom-loaded workspace page proves initialization is complete. + await instance.connect(); + + const capsBefore = (await ( + await getApi(instance.httpPort, "teamCollection/capabilities") + ).json()) as { supportsSharingUi: boolean }; + expect(capsBefore.supportsSharingUi).toBe(false); + + const createResponse = await postCreateCloudTeamCollection(instance); + expect(createResponse.status).toBe(200); + + // ConnectToCloudCollection + the reopen callback + the initial upload are not + // synchronous with the HTTP reply; poll for the capability flip rather than assuming + // a fixed delay is enough. + await waitForSharingReady(instance.httpPort); + + const collectionRows = await queryDb<{ id: string; name: string }>( + "select id, name from tc.collections where id = $1", + [scratch.collectionId], + ); + expect(collectionRows).toHaveLength(1); + expect(collectionRows[0].name).toBe(scratch.collectionName); + + // Poll S3 too: the initial upload happens after the DB row is created. + await expect + .poll( + async () => + (await listS3Objects(`tc/${scratch.collectionId}/`)).length, + { + timeout: 20_000, + message: + "no objects ever appeared in S3 for this collection", + }, + ) + .toBeGreaterThan(0); + + const keys = await listS3Objects(`tc/${scratch.collectionId}/`); + // One collection file group (customCollectionStyles.css, the .bloomCollection file, + // ReaderTools*.json) plus the one template book's files, each under books//. + expect( + keys.some((key) => + key.endsWith(`${scratch.collectionName}.bloomCollection`), + ), + ).toBe(true); + expect( + keys.some((key) => key.endsWith(`${scratch.bookName}.htm`)), + ).toBe(true); + expect(keys.some((key) => key.endsWith("meta.json"))).toBe(true); + }); +}); diff --git a/src/BloomTests/e2e/tests/e2e-10-account-switch.spec.ts b/src/BloomTests/e2e/tests/e2e-10-account-switch.spec.ts new file mode 100644 index 000000000000..d1468fe9fd47 --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-10-account-switch.spec.ts @@ -0,0 +1,233 @@ +// E2E-10: account-switch behavior (dogfood batch 1, item 9). +// +// STATUS: authored but NOT RUN. This task's hard rules forbid launching Bloom or running +// anything under src/BloomTests/e2e (the desktop/E2E lane belongs to the orchestrator). This is +// a non-run artifact for the orchestrator's next E2E pass, written against the harness patterns +// established by e2e-5 (approved accounts) and e2e-2 (two-instance collaboration loop). +// +// This REPLACES the task-09 scenario of the same number: that older scenario ("in-session +// account switch/sign-out while a book is checked out, blocked with preserve-&-release +// choices") was recorded as BLOCKED in tasks/09-e2e.md because no such feature existed. John's 9 +// Jul decision (Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md item 9) replaces +// that shape entirely with an OPEN-TIME scenario: a local collection folder was used under one +// account; Bloom is re-launched against that SAME LOCAL FOLDER signed in as a DIFFERENT account +// (simulating a shared computer). No "no such feature exists" gap remains -- there is now a +// real accept/refuse decision to drive an E2E against. No spec file previously existed for +// "e2e-10"; this one is new. +// +// The harness represents "the same local collection reopened under a different account" as +// launchBloom() called twice, SEQUENTIALLY (never concurrently -- only one process may hold the +// collection folder), against the SAME collectionFilePath, with a different `user` each time -- +// exactly like e2e-5's already-established pattern of relaunching a pulled-down collection under +// a different DevUser. "Same machine" falls out for free: every launch in this harness runs on +// the same physical/dev-stack machine, so TeamCollectionManager.CurrentMachine is identical +// across the two launches without any special handling. +// +// Two scenarios, matching DOGFOOD-BATCH-1.md item 9 exactly: +// 1. Bloom relaunched signed in as a NON-member (never approved) of the collection -> +// REFUSE to open. Verified via the instance's own log file (native WinForms MessageBox +// content is not CDP-reachable at all -- see RISK note below -- so the log line +// Program.HandleErrorOpeningProjectWindow now writes for this exact case +// ("*** Refused to open collection ...") is the robust, already-available signal). +// 2. Bloom relaunched signed in as an APPROVED member (but not the account that checked out +// the book) -> CONNECTED. The book Alice checked out here still shows `who: alice`, but Bob +// can check it in directly (no explicit re-checkout first) and the check-in succeeds and is +// attributed to Bob both in the server event log and in the book's post-checkin lock state. +// +// RISKS / things the orchestrator should double-check on the first real run: +// - The refusal MessageBox is a native Win32 dialog (SIL.Reporting.ErrorReport.NotifyUserOfProblem), +// not a WebView2/React one -- CDP cannot see it at all (worse than finding #2's React-dialog +// case). The log-file assertion below is the recommended verification; if a future task wants +// to also assert the dialog was VISIBLE on screen, that needs a native Win32 approach (e.g. +// enumerating top-level windows by title), not CDP. +// - After the refusal, Program.cs falls through to ChooseACollection() (the collection chooser), +// so the process still reports BLOOM_AUTOMATION_READY (WriteAutomationStartupInfo fires +// whenever ANY BloomServer starts listening, including the chooser's) -- launchBloom() should +// NOT hang, but `connect()` in that case attaches to the CHOOSER's page, not a project. This +// spec does not call connect() for the refusal instance at all, only checks the log + that no +// project-specific state exists. +// - The takeover's "on B's first EDIT" requirement has no literal keystroke-level hook in the +// product (see the item 9 implementation report); the atomic lock handover happens at +// check-in time instead (the earliest point it has any observable effect). This spec checks +// in without an intervening edit, which is explicitly one of the two cases John's spec +// names ("If B checks the book in (even without editing first), history records the checkin +// by B") -- it does not separately exercise an edit-then-checkin path. +import { test, expect } from "@playwright/test"; +import { resetStack } from "../harness/reset"; +import { createScratchCollection } from "../harness/collectionFixture"; +import { launchBloom, LaunchedBloom } from "../harness/launch"; +import { ALICE, BOB, ADMIN } from "../harness/devStack"; +import { + postApi, + postCreateCloudTeamCollection, + waitForSharingReady, +} from "../harness/bloomApi"; +import { bookStatus } from "../harness/bookStatus"; +import { selectBookByName } from "../harness/selectBook"; +import { queryDb } from "../harness/db"; +import * as fs from "node:fs/promises"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-10"; + +test.describe("E2E-10 account-switch behavior", () => { + const instances: LaunchedBloom[] = []; + + const track = (instance: LaunchedBloom): LaunchedBloom => { + instances.push(instance); + return instance; + }; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + await Promise.all( + instances.map((i) => i.kill().catch(() => undefined)), + ); + instances.length = 0; + }); + + test("non-member reopening the collection is refused; a member takes over an on-this-machine checkout", async () => { + // --- Setup: Alice creates/shares, approves Bob, checks a book out --- + const aliceScratch = await createScratchCollection("e2e-10", "alice"); + const alice = track( + await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: "e2e-10-alice", + logDir: LOG_DIR, + }), + ); + await alice.connect(); // connect-before-trigger (harness finding #4) + const createResponse = await postCreateCloudTeamCollection(alice); + expect(createResponse.status).toBe(200); + await waitForSharingReady(alice.httpPort); + + // Approve Bob as a member (but NOT Admin -- Admin stays unapproved for scenario 1). + const approveResponse = await postApi( + alice.httpPort, + "sharing/addApproval", + JSON.stringify({ + collectionId: aliceScratch.collectionId, + email: BOB.email, + role: "member", + }), + ); + expect(approveResponse.status).toBe(200); + + const bookName = aliceScratch.bookName; + await selectBookByName( + alice.httpPort, + aliceScratch.collectionFolder, + bookName, + ); + const checkoutResult = await ( + await postApi( + alice.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ) + ).json(); + expect(checkoutResult, "Alice's checkout must succeed").toBe(true); + + const aliceStatusBefore = await bookStatus(alice.httpPort, bookName); + expect(aliceStatusBefore.who).toBe(ALICE.email); + const sharedMachine = aliceStatusBefore.where; // "this machine", per this harness's own identity. + + // Alice's "computer" is now switched off, WITHOUT un-linking or wiping her local + // collection folder -- the next launches reopen this exact folder. + await alice.kill(); + + // --- Scenario 1: reopen the SAME folder signed in as a NON-member (Admin) --- + const adminReopen = track( + await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ADMIN, + label: "e2e-10-admin-refused", + logDir: LOG_DIR, + }), + ); + // Do NOT call adminReopen.connect() -- per the RISK note above, a refused open falls + // through to the collection chooser, not the project; there is nothing project-specific + // to attach to. Give Bloom a moment to reach and log the refusal, then inspect the log. + await expect + .poll( + async () => { + const log = await fs + .readFile(adminReopen.logPath, "utf8") + .catch(() => ""); + // Printed to stdout (this harness log) by Program.cs's refusal branch in + // automation mode; the SIL Logger file the refusal ALSO goes to lives in a + // per-session temp folder a test can't reliably find. (First live run of + // this spec found the original assertion watching the wrong log.) + return log.includes("BLOOM_AUTOMATION_REFUSED_COLLECTION"); + }, + { timeout: 30_000 }, + ) + .toBe(true); + const adminLog = await fs.readFile(adminReopen.logPath, "utf8"); + expect(adminLog).toContain(ADMIN.email); // names the current (refused) logon + expect(adminLog.toLowerCase()).toContain("not a member"); + await adminReopen.kill(); + + // The refusal must not have mutated server state: the book is still locked to Alice. + const stillAlice = await queryDb<{ locked_by: string | null }>( + "select m.email as locked_by from tc.books b join tc.members m on m.collection_id = b.collection_id and m.user_id = b.locked_by where b.collection_id = $1 and b.name = $2", + [aliceScratch.collectionId, bookName], + ); + expect(stillAlice).toHaveLength(1); + expect(stillAlice[0].locked_by).toBe(ALICE.email); + + // --- Scenario 2: reopen the SAME folder signed in as an APPROVED member (Bob) --- + const bobReopen = track( + await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: BOB, + label: "e2e-10-bob-takeover", + logDir: LOG_DIR, + }), + ); + await bobReopen.connect(); + await waitForSharingReady(bobReopen.httpPort); + + // CONNECTED, not disconnected: the book still shows checked out to Alice, on this + // machine, but Bob is the current signed-in user. + const bobStatus = await bookStatus(bobReopen.httpPort, bookName); + expect(bobStatus.who).toBe(ALICE.email); + expect(bobStatus.where).toBe(sharedMachine); + expect(bobStatus.currentUser).toBe(BOB.email); + + // Bob checks the book in WITHOUT an explicit re-checkout first (John's spec: "If B + // checks the book in (even without editing first), history records the checkin by B"). + await selectBookByName( + bobReopen.httpPort, + aliceScratch.collectionFolder, + bookName, + ); + const checkinResponse = await postApi( + bobReopen.httpPort, + "teamCollection/checkInCurrentBook", + "{}", + ); + expect( + checkinResponse.status, + "Bob's check-in must succeed even though Alice's account originally held the lock", + ).toBe(200); + + // Server-side: the lock released, and the checkin event is attributed to BOB, not Alice. + const afterCheckin = await queryDb<{ locked_by: string | null }>( + "select locked_by from tc.books where collection_id = $1 and name = $2", + [aliceScratch.collectionId, bookName], + ); + expect(afterCheckin).toHaveLength(1); + expect(afterCheckin[0].locked_by).toBeNull(); + + const lastCheckinEvent = await queryDb<{ by_email: string }>( + "select by_email from tc.events where collection_id = $1 and book_id = (select id from tc.books where collection_id = $1 and name = $2) order by id desc limit 1", + [aliceScratch.collectionId, bookName], + ); + expect(lastCheckinEvent).toHaveLength(1); + expect(lastCheckinEvent[0].by_email).toBe(BOB.email); + }); +}); diff --git a/src/BloomTests/e2e/tests/e2e-2-collaboration-loop.spec.ts b/src/BloomTests/e2e/tests/e2e-2-collaboration-loop.spec.ts new file mode 100644 index 000000000000..bdc9857cbc10 --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-2-collaboration-loop.spec.ts @@ -0,0 +1,216 @@ +// E2E-2: two-instance collaboration loop (checkout visible; Send/Receive; byte-equal). +// Automates the Wave-3 manual two-instance smoke test end to end: Alice shares a collection, +// approves Bob, Bob joins ("pulls down") the same cloud collection on his own local folder, +// Alice checks out the one book (Bob must see it locked), Alice checks it back in (Send), and +// Bob receives the update (must see the lock released and end up byte-identical to Alice's copy). +// +// Setup steps (create/approve/join) go through the same direct-API path as E2E-1 for the same +// reason documented there (ReactDialog-hosted WebView2 dialogs aren't CDP-reachable). Book +// SELECTION is a real CDP click on the main Collection-tab page (the one WebView2 control +// that's reliably CDP-reachable), but checkout/check-in themselves also go through the direct +// API (see the NOTE below -- clicking the visible buttons had no effect, root cause +// undiagnosed). Bob's view of Alice's lock is forced to refresh immediately via +// `pollNowViaReceiveUpdates` rather than waiting out CloudCollectionMonitor's 60s timer. +import { test, expect } from "@playwright/test"; +import * as fs from "node:fs/promises"; +import { resetStack } from "../harness/reset"; +import { + createScratchCollection, + pulledDownCollectionFilePath, +} from "../harness/collectionFixture"; +import { launchBloom, LaunchedBloom } from "../harness/launch"; +import { ALICE, BOB } from "../harness/devStack"; +import { + postApi, + postCreateCloudTeamCollection, + waitForSharingReady, +} from "../harness/bloomApi"; +import { bookStatus, pollNowViaReceiveUpdates } from "../harness/bookStatus"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-2"; + +test.describe("E2E-2 two-instance collaboration loop", () => { + let alice: LaunchedBloom | undefined; + let bob: LaunchedBloom | undefined; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + await Promise.all( + [alice, bob] + .filter((i): i is LaunchedBloom => !!i) + .map((i) => i.kill()), + ); + alice = undefined; + bob = undefined; + }); + + test("checkout is visible cross-instance, and Send/Receive ends byte-identical", async () => { + // --- Alice creates and shares the collection --- + const aliceScratch = await createScratchCollection("e2e-2", "alice"); + alice = await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: "e2e2-alice", + logDir: LOG_DIR, + }); + + // Connect BEFORE triggering createCloudTeamCollection and keep reusing this same Page. + // DISCOVERED: createCloudTeamCollection's reopen-collection callback reloads the + // workspace's WebView2 control IN PLACE (same CDP target, growing body content as it + // reloads) if something is already attached and watching -- but a FRESH + // `connectOverCDP` call made *after* the reopen has already happened intermittently + // fails to find any `/bloom/` page at all (confirmed via raw `/json/list` showing only + // an `about:blank` target in that failure mode). Attaching early and holding the + // connection across the reopen is the reliable path; reconnecting afterward is not. + const { page: alicePage } = await alice.connect(); + + const createResponse = await postCreateCloudTeamCollection(alice); + expect(createResponse.status).toBe(200); + await waitForSharingReady(alice.httpPort); + + const approveResponse = await postApi( + alice.httpPort, + "sharing/addApproval", + JSON.stringify({ + collectionId: aliceScratch.collectionId, + email: BOB.email, + role: "member", + }), + ); + expect(approveResponse.status).toBe(200); + + // --- Bob joins from his own machine/profile (a separate local placeholder collection) --- + const bobPlaceholder = await createScratchCollection( + "e2e-2", + "bob", + "BobPlaceholder", + ); + bob = await launchBloom({ + collectionFilePath: bobPlaceholder.collectionFilePath, + user: BOB, + label: "e2e2-bob", + logDir: LOG_DIR, + }); + + const pullDownResponse = await postApi( + bob.httpPort, + "collections/pullDown", + JSON.stringify({ collectionId: aliceScratch.collectionId }), + ); + expect(pullDownResponse.status).toBe(200); + + // Bob's instance is still showing his placeholder collection; relaunch pointed at the + // freshly pulled-down one (pullDown only downloads files, it doesn't switch what the + // currently-running instance has open). + await bob.kill(); + const bobCollectionFilePath = await pulledDownCollectionFilePath( + aliceScratch.collectionName, + ); + bob = await launchBloom({ + collectionFilePath: bobCollectionFilePath, + user: BOB, + label: "e2e2-bob-joined", + logDir: LOG_DIR, + }); + await waitForSharingReady(bob.httpPort); + + // Before checkout, nobody has the book locked. + const statusBeforeCheckout = await bookStatus( + bob.httpPort, + aliceScratch.bookName, + ); + expect(statusBeforeCheckout.who).toBeFalsy(); + + // --- Alice selects the book (real CDP click; sets the server-facing "current book" + // that the checkout/check-in endpoints below operate on) then checks it out --- + // NOTE: clicking the visible "CHECK OUT BOOK"/"CHECK IN BOOK" buttons via CDP was tried + // first and reliably had NO effect (confirmed via before/after screenshots showing an + // unchanged button and `bookStatus` still reporting `who: null`) despite Playwright + // reporting the click as successful and the button being visibly present -- while + // calling the exact same backend endpoint those buttons post to + // (`teamCollection/attemptLockOfCurrentBook`) directly succeeded immediately. Root cause + // not fully diagnosed (possibly an MUI ripple/overlay intercepting the synthetic click's + // hit-test); using the direct API call here, same rationale as the ReactDialog + // workaround in E2E-1's header comment. + await alicePage + .getByText(aliceScratch.bookName, { exact: true }) + .first() + .click(); + const lockResponse = await postApi( + alice.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ); + expect(lockResponse.status).toBe(200); + + await pollNowViaReceiveUpdates(bob.httpPort); + await expect + .poll( + async () => + (await bookStatus(bob!.httpPort, aliceScratch.bookName)) + .who, + { + timeout: 90_000, // past the organic 60s CloudCollectionMonitor poll, in case the forced poll raced the commit + message: "Bob never saw Alice's checkout", + }, + ) + .toBeTruthy(); + + // --- Alice checks the book back in (Send) --- + const checkinResponse = await postApi( + alice.httpPort, + "teamCollection/checkInCurrentBook", + "{}", + ); + expect(checkinResponse.status).toBe(200); + + await pollNowViaReceiveUpdates(bob.httpPort); + await expect + .poll( + async () => + (await bookStatus(bob!.httpPort, aliceScratch.bookName)) + .who, + { + timeout: 90_000, // see above + message: + "Bob still sees the book checked out after Alice's check-in", + }, + ) + .toBeFalsy(); + + // --- Bob receives the update and ends up byte-identical to Alice's copy --- + const receiveResponse = await postApi( + bob.httpPort, + "teamCollection/receiveUpdates", + "{}", + ); + expect(receiveResponse.status).toBe(200); + + await expect + .poll( + async () => { + const status = await bookStatus( + bob!.httpPort, + aliceScratch.bookName, + ); + return status.isChangedRemotely; + }, + { + timeout: 20_000, + message: "Bob's copy never caught up after receiveUpdates", + }, + ) + .toBeFalsy(); + + const aliceBookHtmlPath = `${aliceScratch.collectionFolder}\\${aliceScratch.bookName}\\${aliceScratch.bookName}.htm`; + const bobBookHtmlPath = `${bobCollectionFilePath.replace(/[^\\]+\.bloomCollection$/, "")}${aliceScratch.bookName}\\${aliceScratch.bookName}.htm`; + const [aliceBytes, bobBytes] = await Promise.all([ + fs.readFile(aliceBookHtmlPath), + fs.readFile(bobBookHtmlPath), + ]); + expect(bobBytes.equals(aliceBytes)).toBe(true); + }); +}); diff --git a/src/BloomTests/e2e/tests/e2e-3-checkout-contention.spec.ts b/src/BloomTests/e2e/tests/e2e-3-checkout-contention.spec.ts new file mode 100644 index 000000000000..3924f4dd534c --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-3-checkout-contention.spec.ts @@ -0,0 +1,162 @@ +// E2E-3: checkout contention (exactly one winner; loser sees holder). +// +// CloudTeamCollection.TryLockInRepo (src/BloomExe/TeamCollection/Cloud/CloudTeamCollection.cs) +// dispatches to a single conditional-UPDATE RPC (`checkout_book`, CONTRACTS.md: "race-free") -- +// the server, not either client, decides who wins a simultaneous attempt. This test proves that +// server-side race-freedom for real: Alice and Bob both select the same book, then both POST +// `teamCollection/attemptLockOfCurrentBook` via `Promise.all` (no synchronization between them +// beyond firing both requests at once), rather than one after the other. +// +// Book selection uses `selectBookByName` (direct `external/select-book` API), not a CDP click, for +// BOB specifically: a fresh `connectOverCdp` against his instance (joined via pullDown + kill + +// relaunch, see twoInstanceSetup.ts) reliably found only a stuck `about:blank` CDP target even +// though his HTTP API was already responding -- see harness/selectBook.ts's header comment for +// the full investigation, a new finding on top of README.md's existing CDP-reachability notes. +// Alice still uses her real, already-open, held-since-launch CDP page for the click (proven +// reliable by E2E-2), so this test also cross-checks that both selection paths agree. +// Checkout/lock itself goes through the direct API for the same reason E2E-2 does (CDP clicks on +// the checkout button have no observed effect). +import { test, expect } from "@playwright/test"; +import { resetStack } from "../harness/reset"; +import { setUpAliceAndBobOnSharedCollection } from "../harness/twoInstanceSetup"; +import { LaunchedBloom } from "../harness/launch"; +import { postApi } from "../harness/bloomApi"; +import { bookStatus, pollNowViaReceiveUpdates } from "../harness/bookStatus"; +import { selectBookByName } from "../harness/selectBook"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-3"; + +test.describe("E2E-3 checkout contention", () => { + let alice: LaunchedBloom | undefined; + let bob: LaunchedBloom | undefined; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + await Promise.all( + [alice, bob] + .filter((i): i is LaunchedBloom => !!i) + .map((i) => i.kill()), + ); + alice = undefined; + bob = undefined; + }); + + test("simultaneous checkout attempts have exactly one winner, and the loser sees the winner as holder", async () => { + const shared = await setUpAliceAndBobOnSharedCollection( + "e2e-3", + LOG_DIR, + ); + alice = shared.alice; + bob = shared.bob; + const { aliceScratch, alicePage } = shared; + + // Both instances select the same book -- Alice via her already-attached CDP page (real + // click, proven reliable in E2E-2), Bob via the direct external/select-book API (see the + // header comment above for why a fresh CDP connect to his instance isn't reliable here). + await alicePage + .getByText(aliceScratch.bookName, { exact: true }) + .first() + .click(); + await selectBookByName( + bob.httpPort, + shared.bobCollectionFilePath.replace( + /[^\\]+\.bloomCollection$/, + "", + ), + aliceScratch.bookName, + ); + + // The race: fire both lock attempts at once, with no ordering between them beyond + // Promise.all's simultaneous dispatch. TryLockInRepo's conditional UPDATE on the server + // is what actually decides the winner -- if the server logic were not race-free, this + // could non-deterministically report BOTH as successful. + const [aliceResult, bobResult] = await Promise.all([ + postApi( + alice.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ), + postApi( + bob.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ), + ]); + expect(aliceResult.status).toBe(200); + expect(bobResult.status).toBe(200); + const [aliceWon, bobWon] = await Promise.all([ + aliceResult.json(), + bobResult.json(), + ]); + + expect( + aliceWon !== bobWon, + `expected exactly one winner, got alice=${aliceWon} bob=${bobWon}`, + ).toBe(true); + + const winner = aliceWon ? alice : bob; + const winnerLabel = aliceWon ? "alice" : "bob"; + const loser = aliceWon ? bob : alice; + + // The loser must see the winner holding the book once it refreshes (forced immediately + // via pollNowViaReceiveUpdates rather than waiting out the 60s poll timer). + await pollNowViaReceiveUpdates(loser.httpPort); + await expect + .poll( + async () => + (await bookStatus(loser.httpPort, aliceScratch.bookName)) + .who, + { + timeout: 90_000, // past the organic 60s poll, in case the forced poll raced the commit + message: "loser never saw the winner as the lock holder", + }, + ) + .toBeTruthy(); + + const winnerStatus = await bookStatus( + winner.httpPort, + aliceScratch.bookName, + ); + expect( + winnerStatus.who, + `winner (${winnerLabel}) does not see itself as the lock holder`, + ).toBeTruthy(); + + // The loser's very first `who` reading above can legitimately be the raw auth user id + // rather than the resolved email: `attemptLockOfCurrentBook`'s own failed-checkout RPC + // response write-throughs `locked_by` (raw id) synchronously (CloudRepoCache. + // RecordCheckoutResult), while the friendlier `locked_by_email` only arrives via the + // NEXT get_changes poll (HandleReceiveUpdates replies to the HTTP request BEFORE calling + // PollNow() -- see its own comment -- so `pollNowViaReceiveUpdates` resolving does not + // guarantee that poll's delta has actually been applied to the cache yet). Poll again + // (re-issuing receiveUpdates each iteration) until the two sides' identity strings agree. + await expect + .poll( + async () => { + await pollNowViaReceiveUpdates(loser.httpPort); + return ( + await bookStatus(loser.httpPort, aliceScratch.bookName) + ).who; + }, + { + timeout: 20_000, + message: `loser's view of the holder's identity (raw id vs resolved email) never converged with the winner's own ('${winnerStatus.who}')`, + }, + ) + .toBe(winnerStatus.who); + + // The loser's own attempt must not have actually taken the lock -- re-confirm by trying + // to lock again from the loser: this call should still fail while the winner holds it. + const loserRetry = await ( + await postApi( + loser.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ) + ).json(); + expect(loserRetry).toBe(false); + }); +}); diff --git a/src/BloomTests/e2e/tests/e2e-4-forced-checkin-recovery.spec.ts b/src/BloomTests/e2e/tests/e2e-4-forced-checkin-recovery.spec.ts new file mode 100644 index 000000000000..440097d7cdf6 --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-4-forced-checkin-recovery.spec.ts @@ -0,0 +1,168 @@ +// E2E-4: forced check-in / recovery. +// +// WHAT THIS TEST COVERS (reachable, green): the admin force-unlock + steal-checkout flow and +// the victim's coherent aftermath. Alice checks a book out; an admin (Bob, promoted mid-test) +// force-unlocks her and takes the checkout; Alice's instance converges on "checked out by Bob", +// her own attempt to re-take the lock is refused by the race-free server, and Bob's lock is +// intact throughout -- no crash, no corruption, no double-lock. +// +// WHAT IS DOCUMENTED AS BLOCKED (see the task progress log for the full root-cause): the design +// doc's "unified recovery" -- preserving the victim's un-checked-in local edits as a +// `.bloomSource` in "Lost and Found" plus a WorkPreservedLocally incident -- could NOT be +// driven end-to-end through the cloud backend from this harness. Both entry points into the +// shared recovery code are gated by cloud-specific state the harness can't establish: +// * SyncAtStartup's conflict loop is gated on `IsCheckedOutHereBy(localStatus)` reading the +// on-disk TeamCollection.status file -- but cloud `AttemptLock` never writes `lockedBy` +// into that file (it updates only the server row + in-memory cache + UI events), so after a +// restart the loop treats the book as not-checked-out-here and skips recovery entirely +// (confirmed live: the checkout-time local status showed `lockedBy: null`). +// * The interactive `checkInCurrentBook` recovery branch (`!OkToCheckIn`) is unreachable +// because `HandleCheckInCurrentBook` calls `Save()` first (throws once the cache knows Bob +// holds the lock -> 503 before the branch), and if the cache has NOT yet learned of the +// steal, `OkToCheckIn` returns true and the server rejects at checkin-start with +// LockHeldByOther instead. +// The two real bugs this scenario nonetheless pinned WERE fixed: the recovery-path NRE in +// `TeamCollectionApi.UpdateUiForBook` (fixed under E2E-9, finding #11 -- CheckInOneBook calls it +// on both the happy AND recovery branches), and `CloudCollectionClient.LogEvent` posting +// `p_comment` instead of the RPC's real `p_message` parameter (which would have silently dropped +// the WorkPreservedLocally incident even if the path were reached) -- fixed here with unit +// coverage in CloudCollectionClientTests. +import { test, expect } from "@playwright/test"; +import * as path from "node:path"; +import { resetStack } from "../harness/reset"; +import { setUpAliceAndBobOnSharedCollection } from "../harness/twoInstanceSetup"; +import { LaunchedBloom } from "../harness/launch"; +import { BOB } from "../harness/devStack"; +import { postApi } from "../harness/bloomApi"; +import { bookStatus, pollNowViaReceiveUpdates } from "../harness/bookStatus"; +import { selectBookByName } from "../harness/selectBook"; +import { queryDb } from "../harness/db"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-4"; +const BOB_USER_ID = "00000000-0000-0000-0000-000000000003"; + +test.describe("E2E-4 forced check-in recovery", () => { + let alice: LaunchedBloom | undefined; + let bob: LaunchedBloom | undefined; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + await Promise.all( + [alice, bob] + .filter((i): i is LaunchedBloom => !!i) + .map((i) => i.kill().catch(() => undefined)), + ); + alice = undefined; + bob = undefined; + }); + + test("admin force-unlock steals a checkout race-free: victim converges on the new holder, cannot re-take, and the thief's lock stays intact", async () => { + const shared = await setUpAliceAndBobOnSharedCollection( + "e2e-4", + LOG_DIR, + ); + alice = shared.alice; + bob = shared.bob; + const { aliceScratch, bobCollectionFilePath } = shared; + const bookName = aliceScratch.bookName; + + // --- Alice checks the book out --- + await selectBookByName( + alice.httpPort, + aliceScratch.collectionFolder, + bookName, + ); + const aliceLock = await ( + await postApi( + alice.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ) + ).json(); + expect(aliceLock).toBe(true); + + // Alice (the creator/admin) promotes Bob to admin so he can force-unlock. setRole is an + // admin-only action; Bob cannot promote himself. + const setRole = await postApi( + alice.httpPort, + "sharing/setRole", + JSON.stringify({ + collectionId: aliceScratch.collectionId, + email: BOB.email, + role: "admin", + }), + ); + expect(setRole.status).toBe(200); + + // --- Bob sees Alice's lock, force-unlocks it, and takes the checkout himself --- + await selectBookByName( + bob.httpPort, + path.dirname(bobCollectionFilePath), + bookName, + ); + await expect + .poll( + async () => { + await pollNowViaReceiveUpdates(bob!.httpPort); + return (await bookStatus(bob!.httpPort, bookName)).who; + }, + { timeout: 90_000, message: "Bob never saw Alice's checkout" }, + ) + .toBeTruthy(); + + const forceUnlock = await postApi( + bob.httpPort, + "teamCollection/forceUnlock", + "{}", + ); + expect(forceUnlock.status).toBe(200); + const bobLock = await ( + await postApi( + bob.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ) + ).json(); + expect(bobLock, "Bob's checkout after force-unlock must succeed").toBe( + true, + ); + + // --- Alice's instance converges on "checked out by Bob" --- + await expect + .poll( + async () => { + await pollNowViaReceiveUpdates(alice!.httpPort); + return (await bookStatus(alice!.httpPort, bookName)).who; + }, + { + timeout: 20_000, + message: "Alice never learned Bob now holds the lock", + }, + ) + .toBe(BOB.email); + + // --- Alice cannot re-take the lock while Bob holds it (server is race-free) --- + const aliceRetake = await ( + await postApi( + alice.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ) + ).json(); + expect( + aliceRetake, + "Alice must not be able to re-take a book the admin took from her", + ).toBe(false); + + // --- The server lock is exactly Bob's, singular and intact --- + const rows = await queryDb<{ locked_by: string | null }>( + "select locked_by from tc.books where collection_id = $1 and name = $2", + [aliceScratch.collectionId, bookName], + ); + expect(rows).toHaveLength(1); + expect(rows[0].locked_by).toBe(BOB_USER_ID); + }); +}); diff --git a/src/BloomTests/e2e/tests/e2e-5-approved-accounts.spec.ts b/src/BloomTests/e2e/tests/e2e-5-approved-accounts.spec.ts new file mode 100644 index 000000000000..9bd285030815 --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-5-approved-accounts.spec.ts @@ -0,0 +1,279 @@ +// E2E-5: approved accounts on two fresh profiles ("another computer"). +// +// The approved-accounts model (CONTRACTS.md; tc.members): access is granted per ACCOUNT EMAIL, +// not per machine or per Bloom registration. An admin approves an email; the row sits UNCLAIMED +// (user_id NULL) until that account's holder signs in anywhere and claims it; from then on that +// account can participate from ANY computer with no pre-existing local state. This spec drives +// that full arc against real instances: +// +// 1. Alice creates/shares and approves bob@dev.local -> a tc.members row exists with +// user_id NULL (approved-but-unclaimed). +// 2. An UNAPPROVED account (admin@dev.local -- a seeded dev user never added to this +// collection) on its own fresh profile does NOT see the collection in collections/mine and +// cannot pull it down (server refuses; nothing is created locally). +// 3. Bob, on a fresh profile ("another computer": a placeholder collection unrelated to the +// TC, no prior local copy), DOES see it in collections/mine while still UNCLAIMED (the +// email-match rule), pulls it down, and the join claims his membership (user_id stamped +// with his fixed seeded id, claimed_at set). +// 4. With Alice's instance killed entirely (her "computer" is off -- membership must not +// depend on the admin being online), Bob checks out, is identified by his ACCOUNT email in +// book status, and checks in a new version successfully. +import { test, expect } from "@playwright/test"; +import * as path from "node:path"; +import { resetStack } from "../harness/reset"; +import { + createScratchCollection, + pulledDownCollectionFilePath, +} from "../harness/collectionFixture"; +import { launchBloom, LaunchedBloom } from "../harness/launch"; +import { ALICE, BOB, ADMIN } from "../harness/devStack"; +import { + postApi, + getApi, + postCreateCloudTeamCollection, + waitForSharingReady, +} from "../harness/bloomApi"; +import { bookStatus } from "../harness/bookStatus"; +import { selectBookByName } from "../harness/selectBook"; +import { queryDb } from "../harness/db"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-5"; + +// Bob's fixed seeded auth user id (server/dev/seed.sql). +const BOB_USER_ID = "00000000-0000-0000-0000-000000000003"; + +test.describe("E2E-5 approved accounts on two fresh profiles", () => { + const instances: LaunchedBloom[] = []; + + const track = (instance: LaunchedBloom): LaunchedBloom => { + instances.push(instance); + return instance; + }; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + await Promise.all( + instances.map((i) => i.kill().catch(() => undefined)), + ); + instances.length = 0; + }); + + test("approval is per account: unclaimed member can join from a fresh profile, unapproved account cannot", async () => { + // --- 1. Alice creates, shares, and approves Bob's EMAIL --- + const aliceScratch = await createScratchCollection("e2e-5", "alice"); + const alice = track( + await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: "e2e-5-alice", + logDir: LOG_DIR, + }), + ); + await alice.connect(); // connect-before-trigger (finding #7) + const createResponse = await postCreateCloudTeamCollection(alice); + expect(createResponse.status).toBe(200); + await waitForSharingReady(alice.httpPort); + const approveResponse = await postApi( + alice.httpPort, + "sharing/addApproval", + JSON.stringify({ + collectionId: aliceScratch.collectionId, + email: BOB.email, + role: "member", + }), + ); + expect(approveResponse.status).toBe(200); + + // The membership row exists but is UNCLAIMED: approval was by email alone; Bob's + // account has never touched this collection. + const unclaimedRows = await queryDb<{ + email: string; + user_id: string | null; + claimed_at: string | null; + }>( + "select email, user_id, claimed_at from tc.members where collection_id = $1 and email = $2", + [aliceScratch.collectionId, BOB.email], + ); + expect(unclaimedRows).toHaveLength(1); + expect(unclaimedRows[0].user_id).toBeNull(); + expect(unclaimedRows[0].claimed_at).toBeNull(); + + // --- 2. An UNAPPROVED account cannot see or join the collection --- + const adminPlaceholder = await createScratchCollection( + "e2e-5", + "admin", + "AdminPlaceholder", + ); + const adminInstance = track( + await launchBloom({ + collectionFilePath: adminPlaceholder.collectionFilePath, + user: ADMIN, + label: "e2e-5-admin-unapproved", + logDir: LOG_DIR, + }), + ); + const adminMineResponse = await getApi( + adminInstance.httpPort, + "collections/mine", + ); + expect(adminMineResponse.status).toBe(200); + // Wire shape per SharingApi.ToCollectionSummary: {collectionId, name, role}. + const adminMine = (await adminMineResponse.json()) as { + collectionId: string; + }[]; + expect( + adminMine.map((c) => c.collectionId), + "an unapproved account must not see the collection in collections/mine", + ).not.toContain(aliceScratch.collectionId); + + const adminPullDown = await postApi( + adminInstance.httpPort, + "collections/pullDown", + JSON.stringify({ collectionId: aliceScratch.collectionId }), + ); + expect( + adminPullDown.status, + "an unapproved account's pullDown must be refused", + ).not.toBe(200); + await adminInstance.kill(); + + // --- 3. Bob, fresh profile: sees it while UNCLAIMED, joins, membership gets claimed --- + const bobPlaceholder = await createScratchCollection( + "e2e-5", + "bob", + "BobPlaceholder", + ); + const bobChooser = track( + await launchBloom({ + collectionFilePath: bobPlaceholder.collectionFilePath, + user: BOB, + label: "e2e-5-bob-chooser", + logDir: LOG_DIR, + }), + ); + // collections/mine must list the collection by EMAIL match even though Bob's user_id + // has never been stamped on the membership row (my_collections' unclaimed-rows rule). + const bobMineResponse = await getApi( + bobChooser.httpPort, + "collections/mine", + ); + expect(bobMineResponse.status).toBe(200); + const bobMine = (await bobMineResponse.json()) as { + collectionId: string; + name: string; + }[]; + expect(bobMine.map((c) => c.collectionId)).toContain( + aliceScratch.collectionId, + ); + + const bobPullDown = await postApi( + bobChooser.httpPort, + "collections/pullDown", + JSON.stringify({ collectionId: aliceScratch.collectionId }), + ); + expect(bobPullDown.status).toBe(200); + + // The join claimed the membership: user_id stamped with Bob's fixed seeded account id. + const claimedRows = await queryDb<{ + user_id: string | null; + claimed_at: string | null; + }>( + "select user_id, claimed_at from tc.members where collection_id = $1 and email = $2", + [aliceScratch.collectionId, BOB.email], + ); + expect(claimedRows).toHaveLength(1); + expect(claimedRows[0].user_id).toBe(BOB_USER_ID); + expect(claimedRows[0].claimed_at).not.toBeNull(); + + await bobChooser.kill(); + + // --- 4. Alice's computer goes off; Bob participates fully on his own --- + // But first, wait for her initial share's v1 commit: it runs asynchronously after + // createCloudTeamCollection, and under load it can still be in flight this far into + // the test (11 Jul matrix) — killing her mid-first-Send would leave no book row at + // all, ever. + const bookName = aliceScratch.bookName; + await expect + .poll( + async () => + ( + await queryDb<{ current_version_seq: number }>( + "select current_version_seq from tc.books where collection_id = $1 and name = $2 and current_version_seq >= 1", + [aliceScratch.collectionId, bookName], + ) + ).length, + { + timeout: 90_000, + message: "Alice's initial share never committed v1", + }, + ) + .toBe(1); + await alice.kill(); + + const initialSeqRows = await queryDb<{ current_version_seq: number }>( + "select current_version_seq from tc.books where collection_id = $1 and name = $2", + [aliceScratch.collectionId, bookName], + ); + expect(initialSeqRows).toHaveLength(1); + const initialSeq = Number(initialSeqRows[0].current_version_seq); + expect(initialSeq).toBeGreaterThanOrEqual(1); // sanity: initial share committed v1 + + const bobCollectionFilePath = await pulledDownCollectionFilePath( + aliceScratch.collectionName, + ); + const bob = track( + await launchBloom({ + collectionFilePath: bobCollectionFilePath, + user: BOB, + label: "e2e-5-bob-joined", + logDir: LOG_DIR, + }), + ); + await waitForSharingReady(bob.httpPort); + + await selectBookByName( + bob.httpPort, + path.dirname(bobCollectionFilePath), + bookName, + ); + const lockResult = await ( + await postApi( + bob.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ) + ).json(); + expect(lockResult, "Bob's checkout must succeed").toBe(true); + + // Identity in a cloud TC is the ACCOUNT email (not machine, not Bloom registration) -- + // this is the "registration-vs-account" identity model the Wave-3 smoke fixed 4 sites + // over. His own status must show his account as the holder. + const status = await bookStatus(bob.httpPort, bookName); + expect(status.who).toBe(BOB.email); + + const checkinResponse = await postApi( + bob.httpPort, + "teamCollection/checkInCurrentBook", + "{}", + ); + expect(checkinResponse.status).toBe(200); + + // The check-in committed a NEW version on the server (seq advanced past the initial + // upload's), and released the lock. + const finalRows = await queryDb<{ + current_version_seq: number; + locked_by: string | null; + }>( + "select current_version_seq, locked_by from tc.books where collection_id = $1 and name = $2", + [aliceScratch.collectionId, bookName], + ); + expect(finalRows).toHaveLength(1); + expect(Number(finalRows[0].current_version_seq)).toBeGreaterThan( + initialSeq, + ); + expect(finalRows[0].locked_by).toBeNull(); + }); +}); diff --git a/src/BloomTests/e2e/tests/e2e-6-kill-mid-send-resume.spec.ts b/src/BloomTests/e2e/tests/e2e-6-kill-mid-send-resume.spec.ts new file mode 100644 index 000000000000..814ad81874cc --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-6-kill-mid-send-resume.spec.ts @@ -0,0 +1,283 @@ +// E2E-6: kill mid-Send -> restart -> resume -> never a partial version (EXISTING book, v2 Send). +// +// E2E-9(b) already proved the new-book first-Send case (kill leaves no phantom; resume commits +// exactly one version). This scenario is the complementary, arguably more important one: a book +// that teammates ALREADY have at v1 must keep seeing intact v1 throughout an interrupted v2 +// Send -- never a partial or mixed version -- and after Alice restarts and resumes, v2 commits +// cleanly and teammates receive exactly v2. +// +// The invariant is enforced server-side by `tc.checkin_finish_tx` +// (supabase/migrations/20260706000004): the new version row + its version_files + the book's +// current_version pointer + the events are all written in a SINGLE atomic DB transaction. A kill +// anywhere before checkin-finish leaves the book's `current_version_*` untouched (still v1) and +// only an OPEN `tc.checkin_transactions` row -- invisible to `get_changes`/`get_collection_state`, +// so no teammate can ever observe a half-written version. Resume works because +// `checkin_start_tx` finds and reuses that same open transaction for the same (book, caller). +// +// Reproducing the mid-Send window uses the E2E-9(b) technique: fire checkInCurrentBook without +// awaiting, poll the DB (held-open pg connection) for the open checkin_transactions row, then +// `process.kill` the instance immediately -- faster than alice.kill()'s subprocess path, which +// is slower than the whole small-book Send. Injecting the v2 edit is done by editing Alice's +// on-disk htm while her instance is down (there is no CDP-drivable editor -- see selectBook.ts). +import { test, expect } from "@playwright/test"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { resetStack } from "../harness/reset"; +import { setUpAliceAndBobOnSharedCollection } from "../harness/twoInstanceSetup"; +import { launchBloom, LaunchedBloom } from "../harness/launch"; +import { ALICE } from "../harness/devStack"; +import { postApi, waitForSharingReady } from "../harness/bloomApi"; +import { pollNowViaReceiveUpdates } from "../harness/bookStatus"; +import { selectBookByName } from "../harness/selectBook"; +import { queryDb, openPersistentClient } from "../harness/db"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-6"; +const V2_MARKER = "ALICE-V2-EDIT-MARKER"; + +const checkInWithConnectionRetry = async ( + httpPort: number, +): Promise => { + const deadline = Date.now() + 15_000; + let last: Response; + do { + last = await postApi( + httpPort, + "teamCollection/checkInCurrentBook", + "{}", + ); + if (last.status === 200) return last; + await new Promise((r) => setTimeout(r, 500)); + } while (Date.now() < deadline); + return last; +}; + +test.describe("E2E-6 kill mid-Send / resume", () => { + let alice: LaunchedBloom | undefined; + let bob: LaunchedBloom | undefined; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + await Promise.all( + [alice, bob] + .filter((i): i is LaunchedBloom => !!i) + .map((i) => i.kill().catch(() => undefined)), + ); + alice = undefined; + bob = undefined; + }); + + test("interrupting a v2 Send never exposes a partial version; teammates keep v1 until the resumed Send commits v2", async () => { + const shared = await setUpAliceAndBobOnSharedCollection( + "e2e-6", + LOG_DIR, + ); + alice = shared.alice; + bob = shared.bob; + const { aliceScratch, bobCollectionFilePath } = shared; + const bookName = aliceScratch.bookName; + const aliceHtmPath = path.join( + aliceScratch.collectionFolder, + bookName, + `${bookName}.htm`, + ); + const bobHtmPath = path.join( + path.dirname(bobCollectionFilePath), + bookName, + `${bookName}.htm`, + ); + + // The book is already shared at v1. Record its server book id + confirm seq 1. + const bookRows = await queryDb<{ + id: string; + current_version_seq: number; + }>( + "select id, current_version_seq from tc.books where collection_id = $1 and name = $2", + [aliceScratch.collectionId, bookName], + ); + expect(bookRows).toHaveLength(1); + expect(Number(bookRows[0].current_version_seq)).toBe(1); + const bookId = bookRows[0].id; + + // Bob syncs down v1 so he has a concrete baseline to compare against later. + // Since the progressive-join work, a book new to this instance arrives via the + // background download queue AFTER the poll call returns, so wait for the file + // rather than reading it immediately. + await expect + .poll( + async () => { + await pollNowViaReceiveUpdates(bob!.httpPort); + return fs.stat(bobHtmPath).then( + () => true, + () => false, + ); + }, + { + timeout: 90_000, // past the organic 60s poll + message: "Bob never downloaded the v1 baseline", + }, + ) + .toBe(true); + const bobV1 = await fs.readFile(bobHtmPath); + expect( + bobV1.includes(Buffer.from(V2_MARKER, "utf8")), + "sanity: Bob's v1 must not already contain the v2 marker", + ).toBe(false); + + // --- Alice checks out, then (while down) her local content is edited to v2 --- + await selectBookByName( + alice.httpPort, + aliceScratch.collectionFolder, + bookName, + ); + const lock = await ( + await postApi( + alice.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ) + ).json(); + expect(lock).toBe(true); + + await alice.kill(); + alice = undefined; + const originalHtm = await fs.readFile(aliceHtmPath, "utf8"); + const editedHtm = originalHtm.replace( + "", + `
${V2_MARKER}
`, + ); + expect(editedHtm).not.toBe(originalHtm); + await fs.writeFile(aliceHtmPath, editedHtm, "utf8"); + + // --- Alice reopens (still holds the checkout server-side) and starts the v2 Send --- + alice = await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: "e2e-6-alice-sending", + logDir: LOG_DIR, + }); + await waitForSharingReady(alice.httpPort); + await selectBookByName( + alice.httpPort, + aliceScratch.collectionFolder, + bookName, + ); + + const dbClient = await openPersistentClient(); + try { + void postApi( + alice.httpPort, + "teamCollection/checkInCurrentBook", + "{}", + ).catch(() => undefined); + + // Catch the mid-Send window: an OPEN checkin_transactions row for this book, but + // before checkin_finish_tx has advanced the book's version. + let caught = false; + const deadline = Date.now() + 15_000; + while (!caught && Date.now() < deadline) { + const txRows = await dbClient.query( + "select status from tc.checkin_transactions where book_id = $1 and status = 'open'", + [bookId], + ); + if (txRows.rows.length > 0) { + caught = true; + break; + } + await new Promise((r) => setTimeout(r, 5)); + } + expect( + caught, + "never observed an open checkin transaction -- could not exercise the mid-Send window", + ).toBe(true); + const pid = alice.processId; + process.kill(pid); + + // Immediately: the book's committed version must STILL be v1 (atomic finish never ran). + const midRows = await dbClient.query( + "select current_version_seq from tc.books where id = $1", + [bookId], + ); + expect( + Number(midRows.rows[0].current_version_seq), + "a partial/interrupted Send must not have advanced the committed version", + ).toBe(1); + } finally { + await dbClient.end(); + } + + // Bob, receiving now, still gets intact v1 -- never a mix. + await pollNowViaReceiveUpdates(bob.httpPort); + const bobDuringInterruption = await fs.readFile(bobHtmPath); + expect( + bobDuringInterruption.equals(bobV1), + "Bob's copy changed during an interrupted Send -- he must keep byte-identical v1", + ).toBe(true); + + // --- Alice restarts and resumes the Send; v2 commits cleanly as exactly one new version --- + alice = await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: "e2e-6-alice-resumed", + logDir: LOG_DIR, + }); + await waitForSharingReady(alice.httpPort); + await selectBookByName( + alice.httpPort, + aliceScratch.collectionFolder, + bookName, + ); + const resumed = await checkInWithConnectionRetry(alice.httpPort); + expect(resumed.status).toBe(200); + + // Server: exactly v2 now (seq advanced by exactly one, no gap/duplicate), lock released. + await expect + .poll( + async () => + Number( + ( + await queryDb<{ current_version_seq: number }>( + "select current_version_seq from tc.books where id = $1", + [bookId], + ) + )[0].current_version_seq, + ), + { + timeout: 20_000, + message: "the resumed Send never committed v2", + }, + ) + .toBe(2); + const versionCount = await queryDb( + "select seq from tc.versions where book_id = $1", + [bookId], + ); + expect( + versionCount, + "there must be exactly two versions (v1 + the resumed v2), no partial duplicate", + ).toHaveLength(2); + + // Bob receives v2 and ends byte-identical to Alice's committed v2. + await expect + .poll( + async () => { + await pollNowViaReceiveUpdates(bob!.httpPort); + return (await fs.readFile(bobHtmPath)).includes( + Buffer.from(V2_MARKER, "utf8"), + ); + }, + { + timeout: 90_000, // v2 arrives via the background auto-apply queue + message: "Bob never received the resumed v2", + }, + ) + .toBe(true); + const [aliceV2, bobV2] = await Promise.all([ + fs.readFile(aliceHtmPath), + fs.readFile(bobHtmPath), + ]); + expect(bobV2.equals(aliceV2)).toBe(true); + }); +}); diff --git a/src/BloomTests/e2e/tests/e2e-7-unteam-adoption.spec.ts b/src/BloomTests/e2e/tests/e2e-7-unteam-adoption.spec.ts new file mode 100644 index 000000000000..90e3a4ffa926 --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-7-unteam-adoption.spec.ts @@ -0,0 +1,269 @@ +// E2E-7: un-team adoption (stale artifacts cleaned; books upload as v1). +// +// The adoption path (task 10): a collection that used to belong to a folder-based Team +// Collection gets "un-teamed" (the user deletes TeamCollectionLink.txt per the user docs) and +// then shared to the cloud. Two live behaviors merged from task 10 are exercised for real here: +// +// Test 1 (the happy adoption): a collection carrying stale folder-TC artifacts -- per-book +// `TeamCollection.status` (with a stale checksum AND a stale lockedBy from some departed +// teammate), collection-level `lastCollectionFileSyncData.txt` and `log.txt` -- but NO link +// file, is shared to the cloud. `TeamCollectionManager.ConnectToCloudCollection` must call +// `TeamCollection.CleanStaleTeamCollectionArtifacts` first, so: the three stale files are +// deleted locally, the stale status file is NOT uploaded into the book's S3 files, the book's +// server row commits as v1 (current_version_seq = 1) with NO lock (the stale lockedBy must +// not leak into the brand-new collection), and the book is immediately checkout-able. +// +// Test 2 (the conflict guard): the same collection still carrying a FOLDER TeamCollectionLink +// .txt (the "user skipped the un-team step" case). `ThrowIfConflictingTeamCollectionLink` +// runs BEFORE `create_collection` (verified by reading ConnectToCloudCollection), so the +// observable contract is: no tc.collections row is ever created, capabilities never flip to +// cloud, and the link file is left exactly as it was. NOTE on the error surface: the handler +// (HandleCreateCloudTeamCollection) reports the exception via ErrorReport.NotifyUserOfProblem +// -- a modal dialog no automated session can dismiss -- and only replies to the HTTP request +// after that. The test therefore treats "request timed out (blocked on the modal)" and +// "request returned" as BOTH acceptable, and asserts on the durable server/local state +// instead, which is what actually matters. +import { test, expect } from "@playwright/test"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { resetStack } from "../harness/reset"; +import { createScratchCollection } from "../harness/collectionFixture"; +import { launchBloom, LaunchedBloom } from "../harness/launch"; +import { ALICE } from "../harness/devStack"; +import { + postApi, + getApi, + postCreateCloudTeamCollection, + waitForSharingReady, +} from "../harness/bloomApi"; +import { bookStatus } from "../harness/bookStatus"; +import { selectBookByName } from "../harness/selectBook"; +import { queryDb } from "../harness/db"; +import { listS3Objects } from "../harness/s3"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-7"; + +// Matches TeamCollection.cs's artifact names (GetStatusFilePathFromBookFolderPath, +// kLastcollectionfilesynctimeTxt) and TeamCollectionManager.TeamCollectionLinkFileName. +const STATUS_FILE_NAME = "TeamCollection.status"; +const LAST_SYNC_FILE_NAME = "lastCollectionFileSyncData.txt"; +const LOG_FILE_NAME = "log.txt"; +const LINK_FILE_NAME = "TeamCollectionLink.txt"; + +/** Plants the stale files an abandoned folder TC leaves behind in a local collection folder. */ +const seedStaleFolderTcArtifacts = async ( + collectionFolder: string, + bookName: string, +): Promise => { + // A worst-case stale status: wrong checksum AND a checkout by a teammate who no longer + // exists. If this leaked into the new cloud collection's first Send, the book would appear + // locked by a ghost. + const staleStatus = JSON.stringify({ + checksum: "0123456789abcdef-stale", + lockedBy: "departed.teammate@example.com", + lockedByFirstName: "Departed", + lockedBySurname: "Teammate", + lockedWhere: "OLD-DEAD-MACHINE", + }); + await fs.writeFile( + path.join(collectionFolder, bookName, STATUS_FILE_NAME), + staleStatus, + "utf8", + ); + await fs.writeFile( + path.join(collectionFolder, LAST_SYNC_FILE_NAME), + "stale sync data from the old folder TC", + "utf8", + ); + await fs.writeFile( + path.join(collectionFolder, LOG_FILE_NAME), + "stale folder-TC message log", + "utf8", + ); +}; + +test.describe("E2E-7 un-team adoption", () => { + let instance: LaunchedBloom | undefined; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + if (instance) { + await instance.kill().catch(() => undefined); + instance = undefined; + } + }); + + test("stale folder-TC artifacts are cleaned and books upload as clean v1", async () => { + const scratch = await createScratchCollection("e2e-7", "alice"); + await seedStaleFolderTcArtifacts( + scratch.collectionFolder, + scratch.bookName, + ); + + instance = await launchBloom({ + collectionFilePath: scratch.collectionFilePath, + user: ALICE, + label: "e2e-7-adoption", + logDir: LOG_DIR, + }); + await instance.connect(); // connect-before-trigger (finding #7) + + // Sanity before acting: the stale files really are on disk and this is NOT a TC yet. + await fs.access( + path.join( + scratch.collectionFolder, + scratch.bookName, + STATUS_FILE_NAME, + ), + ); + await fs.access( + path.join(scratch.collectionFolder, LAST_SYNC_FILE_NAME), + ); + const capsBefore = (await ( + await getApi(instance.httpPort, "teamCollection/capabilities") + ).json()) as { supportsSharingUi: boolean }; + expect(capsBefore.supportsSharingUi).toBe(false); + + const createResponse = await postCreateCloudTeamCollection(instance); + expect(createResponse.status).toBe(200); + await waitForSharingReady(instance.httpPort); + + // 1. The STALE artifact content did not survive (CleanStaleTeamCollectionArtifacts ran + // before the initial Send). Note: absence of the files is NOT the invariant -- a live + // TC legitimately re-creates a FRESH per-book TeamCollection.status (WriteLocalStatus, + // during the initial upload) and may re-create lastCollectionFileSyncData.txt/log.txt + // for its own current state (confirmed live: the status file exists again right after a + // successful adoption). What must be true is that whatever is there now is NOT the old + // TC's content. + const readIfExists = async (filePath: string): Promise => + fs.readFile(filePath, "utf8").catch(() => null); + const statusNow = await readIfExists( + path.join( + scratch.collectionFolder, + scratch.bookName, + STATUS_FILE_NAME, + ), + ); + if (statusNow !== null) { + expect( + statusNow, + "the re-created status file must not carry the old TC's ghost checkout", + ).not.toContain("departed.teammate@example.com"); + expect(statusNow).not.toContain("0123456789abcdef-stale"); + } + const lastSyncNow = await readIfExists( + path.join(scratch.collectionFolder, LAST_SYNC_FILE_NAME), + ); + if (lastSyncNow !== null) { + expect(lastSyncNow).not.toContain( + "stale sync data from the old folder TC", + ); + } + const logNow = await readIfExists( + path.join(scratch.collectionFolder, LOG_FILE_NAME), + ); + if (logNow !== null) { + expect(logNow).not.toContain("stale folder-TC message log"); + } + + // 2. The book uploaded as a clean v1 with NO leaked lock. + await expect + .poll( + async () => + ( + await queryDb( + "select 1 from tc.books where collection_id = $1 and name = $2 and current_version_id is not null", + [scratch.collectionId, scratch.bookName], + ) + ).length, + { + timeout: 90_000, // first-Send upload can exceed 20s under load (11 Jul matrix) + message: "the book's first version never committed", + }, + ) + .toBe(1); + const bookRows = await queryDb<{ + current_version_seq: number; + locked_by: string | null; + }>( + "select current_version_seq, locked_by from tc.books where collection_id = $1 and name = $2", + [scratch.collectionId, scratch.bookName], + ); + expect(Number(bookRows[0].current_version_seq)).toBe(1); + expect( + bookRows[0].locked_by, + "the stale status file's ghost lockedBy must not leak into the new collection", + ).toBeNull(); + + // 3. The stale status file was not uploaded among the book's S3 files. + const keys = await listS3Objects(`tc/${scratch.collectionId}/`); + expect(keys.length).toBeGreaterThan(0); // sanity: upload really happened + expect( + keys.some((key) => key.endsWith(STATUS_FILE_NAME)), + "TeamCollection.status must not be uploaded to S3", + ).toBe(false); + + // 4. The adopted book is immediately usable: status shows unlocked, and checkout works. + const status = await bookStatus(instance.httpPort, scratch.bookName); + expect(status.who).toBeFalsy(); + await selectBookByName( + instance.httpPort, + scratch.collectionFolder, + scratch.bookName, + ); + const lockResult = await ( + await postApi( + instance.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ) + ).json(); + expect(lockResult).toBe(true); + }); + + test("a leftover folder-TC link blocks cloud creation: no server row, capabilities stay folder, link untouched", async () => { + const scratch = await createScratchCollection("e2e-7-guard", "alice"); + // The user "un-teamed" by hand but forgot the last step: the link file still points at + // the old shared folder (which no longer exists -- typical after leaving a Dropbox). + const staleLinkContent = + "C:\\BloomE2E\\e2e-7-guard\\nonexistent-dropbox\\Old Collection - TC"; + const linkPath = path.join(scratch.collectionFolder, LINK_FILE_NAME); + await fs.writeFile(linkPath, staleLinkContent, "utf8"); + + instance = await launchBloom({ + collectionFilePath: scratch.collectionFilePath, + user: ALICE, + label: "e2e-7-guard", + logDir: LOG_DIR, + }); + await instance.connect(); + + // The attempt: ThrowIfConflictingTeamCollectionLink runs BEFORE create_collection, so + // whatever the HTTP reply does (the handler shows a modal error dialog before replying, + // which nothing in an automated session can click -- see the file header), the durable + // state below is the contract. Tolerate either a reply or a timeout. + await postCreateCloudTeamCollection(instance).catch(() => undefined); + + // No server-side collection row was ever created. + const collectionRows = await queryDb( + "select 1 from tc.collections where id = $1", + [scratch.collectionId], + ); + expect( + collectionRows, + "the conflict guard must fire BEFORE create_collection", + ).toHaveLength(0); + + // Nothing was uploaded. + expect(await listS3Objects(`tc/${scratch.collectionId}/`)).toHaveLength( + 0, + ); + + // The link file is exactly as the user left it (the guard must not "fix" it silently -- + // the fix instructions tell the USER to delete it, so they understand what happened). + expect(await fs.readFile(linkPath, "utf8")).toBe(staleLinkContent); + }); +}); diff --git a/src/BloomTests/e2e/tests/e2e-8-receive-during-send.spec.ts b/src/BloomTests/e2e/tests/e2e-8-receive-during-send.spec.ts new file mode 100644 index 000000000000..673cd0c0785b --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-8-receive-during-send.spec.ts @@ -0,0 +1,304 @@ +// E2E-8: Receive-during-Send coherence (mandated) -- byte-perfect old version, never a mix. +// +// The guarantee: while one teammate's Send of a new version is in flight (checkin transaction +// OPEN, changed files being/already uploaded to S3 as new object-versions, but checkin-finish +// not yet committed), any OTHER teammate who Receives must get the current committed version +// byte-for-byte -- never a mix of old files and the sender's in-flight new ones. This holds +// because `tc.version_files` (the manifest `get_book_manifest` hands the receiver, which pins +// exact per-path S3 `s3VersionId`s) is only rewritten inside the atomic `tc.checkin_finish_tx`; +// until then it still pins v1's object-versions, so even though newer S3 object-versions for the +// changed files already exist, the receiver downloads v1's pinned ones. (Migrations +// 20260706000004 + 20260707000005_tc_get_book_manifest.) +// +// TEST FRAMING: a genuinely live race ("Receive while a Send is momentarily open") is +// TOCTOU-prone -- the Send can commit between the harness confirming "open" and the receiver's +// download completing, so the receiver legitimately observes v2 and the test can't tell a real +// coherence bug from a benign timing win. Instead this FREEZES the Send in its open state: the +// v2 edit adds a large (~48 MB) incompressible asset so the upload phase is long, and the sender +// is `process.kill`ed the instant its checkin transaction opens -- reliably BEFORE checkin-finish +// (which waits for the whole 48 MB). That leaves an orphaned-but-open transaction and the book +// still committed at v1, an immutable state in which the receiver's coherence can be asserted +// deterministically and repeatedly. Surviving the sender's mid-Send death is a strictly harder +// case than a merely in-progress Send. Alice then restarts, resumes, and Bob finally gets v2. +import { test, expect } from "@playwright/test"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { randomBytes } from "node:crypto"; +import { resetStack } from "../harness/reset"; +import { setUpAliceAndBobOnSharedCollection } from "../harness/twoInstanceSetup"; +import { launchBloom, LaunchedBloom } from "../harness/launch"; +import { ALICE } from "../harness/devStack"; +import { postApi, waitForSharingReady } from "../harness/bloomApi"; +import { pollNowViaReceiveUpdates } from "../harness/bookStatus"; +import { selectBookByName, waitForBookFile } from "../harness/selectBook"; +import { queryDb, openPersistentClient } from "../harness/db"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-8"; +const V2_MARKER = "ALICE-V2-INFLIGHT-MARKER"; +// A large NEW book-root `.txt` widens the v2 Send's upload phase enough to reliably kill the +// sender mid-upload (before checkin-finish). It must be an extension BookFileFilter INCLUDES in +// the upload manifest (an arbitrary `.bin` is silently excluded -> zero upload time) but which +// Bloom does NOT image-process (a large fake `.png` makes external/select-book hang 30s+ as +// Bloom tries to decode it). `.txt` is in BookLevelFileExtensionsLowerCase and never decoded. +const BIG_ASSET_NAME = "big-v2-asset.txt"; +// Sized so the upload phase lasts SECONDS against warm localhost MinIO, not tens of +// milliseconds: the kill must land between checkin-start's tx-open and checkin-finish, and +// its end-to-end latency (pg poll + signal delivery) is ~100ms+. 40 MB lost that race on a +// warm full-matrix run (the Send committed first); 256 MB gives an order-of-magnitude margin. +const BIG_ASSET_BYTES = 256 * 1024 * 1024; + +test.describe("E2E-8 Receive-during-Send coherence", () => { + let alice: LaunchedBloom | undefined; + let bob: LaunchedBloom | undefined; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + await Promise.all( + [alice, bob] + .filter((i): i is LaunchedBloom => !!i) + .map((i) => i.kill().catch(() => undefined)), + ); + alice = undefined; + bob = undefined; + }); + + test("a teammate Receiving while a v2 Send is in flight gets byte-perfect v1, never a mix; clean v2 only after the Send commits", async () => { + const shared = await setUpAliceAndBobOnSharedCollection( + "e2e-8", + LOG_DIR, + ); + alice = shared.alice; + bob = shared.bob; + const { aliceScratch, bobCollectionFilePath } = shared; + const bookName = aliceScratch.bookName; + const aliceHtmPath = path.join( + aliceScratch.collectionFolder, + bookName, + `${bookName}.htm`, + ); + const bobHtmPath = path.join( + path.dirname(bobCollectionFilePath), + bookName, + `${bookName}.htm`, + ); + + const bookRows = await queryDb<{ + id: string; + current_version_seq: number; + }>( + "select id, current_version_seq from tc.books where collection_id = $1 and name = $2", + [aliceScratch.collectionId, bookName], + ); + const bookId = bookRows[0].id; + expect(Number(bookRows[0].current_version_seq)).toBe(1); + + await pollNowViaReceiveUpdates(bob.httpPort); + // Progressive join (batch item 7): Bob's copy of the book downloads in the background + // after his join, so wait for the file rather than assuming it's already on disk. + await waitForBookFile(bobHtmPath); + const v1Bytes = await fs.readFile(bobHtmPath); + expect(v1Bytes.includes(Buffer.from(V2_MARKER, "utf8"))).toBe(false); + // The big asset is NEW in v2 -- confirm it's absent from Bob's v1 so its absence during + // the open Send (and presence only after commit) is a meaningful coherence signal. + const bobBigAssetPath = path.join( + path.dirname(bobHtmPath), + BIG_ASSET_NAME, + ); + expect( + await fs.access(bobBigAssetPath).then( + () => true, + () => false, + ), + "sanity: v1 must not already contain the big v2 asset", + ).toBe(false); + + // Alice checks out; while down, her local content is edited to v2 (marker in the htm PLUS + // a large new asset that widens the upload/open-transaction window). + await selectBookByName( + alice.httpPort, + aliceScratch.collectionFolder, + bookName, + ); + expect( + await ( + await postApi( + alice.httpPort, + "teamCollection/attemptLockOfCurrentBook", + "{}", + ) + ).json(), + ).toBe(true); + await alice.kill(); + alice = undefined; + const originalHtm = await fs.readFile(aliceHtmPath, "utf8"); + await fs.writeFile( + aliceHtmPath, + originalHtm.replace( + "", + `
${V2_MARKER}
`, + ), + "utf8", + ); + await fs.writeFile( + path.join(aliceScratch.collectionFolder, bookName, BIG_ASSET_NAME), + randomBytes(BIG_ASSET_BYTES), + ); + + // Alice reopens (still holds the checkout) and starts the v2 Send -- left in flight. + alice = await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: "e2e-8-alice-sending", + logDir: LOG_DIR, + }); + await waitForSharingReady(alice.httpPort); + await selectBookByName( + alice.httpPort, + aliceScratch.collectionFolder, + bookName, + ); + + void postApi( + alice.httpPort, + "teamCollection/checkInCurrentBook", + "{}", + ).catch(() => undefined); + + const dbClient = await openPersistentClient(); + try { + // Kill the sender the instant its transaction opens -- while the 48 MB asset is still + // uploading, so this lands before checkin-finish -- freezing an orphaned open Send. + let caught = false; + const deadline = Date.now() + 30_000; + while (!caught && Date.now() < deadline) { + const rows = await dbClient.query( + "select 1 from tc.checkin_transactions where book_id = $1 and status = 'open'", + [bookId], + ); + if (rows.rows.length > 0) { + caught = true; + break; + } + await new Promise((r) => setTimeout(r, 5)); + } + expect( + caught, + "never observed an open checkin transaction -- could not exercise Receive-during-Send", + ).toBe(true); + process.kill(alice.processId); + + // Confirm the state is frozen: transaction still open, book still v1. (If the Send + // committed before the kill landed, the big asset wasn't big enough -- re-run/enlarge.) + const frozen = await dbClient.query( + "select b.current_version_seq, " + + // started_at, NOT id: the id is gen_random_uuid(), which has no temporal + // order — sorting by it returned the FINISHED v1 transaction instead of + // the frozen-open v2 one on a literal coin flip, making this test's + // pass/fail random (three false failures before the post-mortem caught it). + "(select status from tc.checkin_transactions where book_id = b.id order by started_at desc limit 1) as tx_status " + + "from tc.books b where b.id = $1", + [bookId], + ); + expect( + frozen.rows[0].tx_status, + "the Send committed before the kill landed; widen BIG_ASSET_BYTES", + ).toBe("open"); + expect(Number(frozen.rows[0].current_version_seq)).toBe(1); + } finally { + await dbClient.end(); + } + + // THE MANDATED ASSERTION: with the Send frozen open (state immutable -- sender is dead), + // Bob Receives. He must get byte-perfect v1 -- no marker, no big asset, byte-identical + // htm -- never the in-flight v2 or a mix. Twice, to be sure a repeated pull can't leak it. + for (let i = 0; i < 2; i++) { + await pollNowViaReceiveUpdates(bob.httpPort); + const bobNow = await fs.readFile(bobHtmPath); + expect( + bobNow.includes(Buffer.from(V2_MARKER, "utf8")), + `Receive #${i + 1} during an open Send leaked Alice's in-flight v2 htm content`, + ).toBe(false); + expect( + bobNow.equals(v1Bytes), + `Receive #${i + 1} during an open Send did not yield byte-identical v1 htm`, + ).toBe(true); + expect( + await fs.access(bobBigAssetPath).then( + () => true, + () => false, + ), + `Receive #${i + 1} during an open Send leaked Alice's in-flight big v2 asset`, + ).toBe(false); + } + + // --- Alice restarts and resumes; only NOW does v2 become the committed version --- + alice = await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: "e2e-8-alice-resumed", + logDir: LOG_DIR, + }); + await waitForSharingReady(alice.httpPort); + await selectBookByName( + alice.httpPort, + aliceScratch.collectionFolder, + bookName, + ); + const resumeDeadline = Date.now() + 20_000; + let resumeStatus = 0; + while (resumeStatus !== 200 && Date.now() < resumeDeadline) { + resumeStatus = ( + await postApi( + alice.httpPort, + "teamCollection/checkInCurrentBook", + "{}", + ) + ).status; + if (resumeStatus !== 200) + await new Promise((r) => setTimeout(r, 500)); + } + expect(resumeStatus).toBe(200); + await expect + .poll( + async () => + Number( + ( + await queryDb<{ current_version_seq: number }>( + "select current_version_seq from tc.books where id = $1", + [bookId], + ) + )[0].current_version_seq, + ), + { timeout: 30_000, message: "Alice's Send never committed v2" }, + ) + .toBe(2); + + // Now (and only now) Bob receives byte-identical v2, big asset included. + await expect + .poll( + async () => { + await pollNowViaReceiveUpdates(bob!.httpPort); + return (await fs.readFile(bobHtmPath)).includes( + Buffer.from(V2_MARKER, "utf8"), + ); + }, + { + timeout: 30_000, + message: "Bob never received the committed v2", + }, + ) + .toBe(true); + const [aliceV2, bobV2] = await Promise.all([ + fs.readFile(aliceHtmPath), + fs.readFile(bobHtmPath), + ]); + expect(bobV2.equals(aliceV2)).toBe(true); + expect( + (await fs.stat(bobBigAssetPath)).size, + "Bob should have the big v2 asset after the Send committed", + ).toBe(BIG_ASSET_BYTES); + }); +}); diff --git a/src/BloomTests/e2e/tests/e2e-9-new-book-lifecycle.spec.ts b/src/BloomTests/e2e/tests/e2e-9-new-book-lifecycle.spec.ts new file mode 100644 index 000000000000..d1ee23b343ea --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-9-new-book-lifecycle.spec.ts @@ -0,0 +1,410 @@ +// E2E-9: new-book lifecycle. +// (a) a brand-new book is invisible to teammates until its first Send, then appears; +// (b) killing Bloom mid first-Send leaves no phantom book visible to anyone, and resuming +// the same Send afterward completes cleanly to exactly one committed version; +// (c) two members independently creating a book with the same display name both end up +// shared, under distinct server-side names (CloudTeamCollection.PutBookInRepo's +// NameConflict retry loop, driven by tc.checkin_start_tx's per-collection name +// uniqueness check). +// +// (a)/(b) rely on a real, product-level invariant confirmed by reading the server RPCs (not +// guessed): `tc.checkin_start_tx` (supabase/migrations/20260706000004_tc_checkin_txn_functions.sql) +// inserts the book's `tc.books` row (current_version_id NULL) WITHOUT inserting any `tc.events` +// row; only `tc.checkin_finish_tx` inserts the event that makes the book visible via +// `tc.get_changes`'s delta query (which joins on `tc.events`). A book stuck between start and +// finish is therefore invisible to every other member by construction, independent of any +// server-side reaping (`tc.reap_expired_checkin_transactions`, which only runs on a 48h expiry +// and is not exercised here). What (b) actually needs to prove empirically is the CLIENT side: +// that a hard-killed Bloom, on restart, can resume and finish that same transaction (via the +// SAME bookInstanceId -- checkin_start_tx's own "resume our own never-finished row" branch) and +// end up with exactly one committed version, never two rows / a duplicate / a lost book. +// +// (c) cannot use `CollectionModel.DuplicateBook` for the two "same name" books: +// `BookStorage.Duplicate` deliberately GUID-suffixes every duplicate's folder name specifically +// so two Team Collection members' independent duplicates never collide locally (see its own +// comment) -- which means it can never produce the identical *proposed* server name we need to +// race. `harness/collectionFixture.ts`'s `seedAdditionalBookIntoCollection` seeds two unrelated +// books (distinct bookInstanceId) with the identical display name directly on each side's local +// folder instead. +import { test, expect } from "@playwright/test"; +import * as path from "node:path"; +import { resetStack } from "../harness/reset"; +import { setUpAliceAndBobOnSharedCollection } from "../harness/twoInstanceSetup"; +import { seedAdditionalBookIntoCollection } from "../harness/collectionFixture"; +import { launchBloom, LaunchedBloom } from "../harness/launch"; +import { ALICE, BOB } from "../harness/devStack"; +import { postApi, getApi, waitForSharingReady } from "../harness/bloomApi"; +import { pollNowViaReceiveUpdates } from "../harness/bookStatus"; +import { readBookInstanceId, selectBookByName } from "../harness/selectBook"; +import { duplicateBook, listBookFolders } from "../harness/duplicateBook"; +import { queryDb, openPersistentClient } from "../harness/db"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-9"; + +// DISCOVERED: even after `capabilities.supportsSharingUi` is true (proving `CurrentCollection` +// IS a live CloudTeamCollection right then), `teamCollection/checkInCurrentBook` can still 503 +// with an empty body moments later. Root cause (read, not guessed): +// `TeamCollectionManager.CheckConnection()` makes a FRESH, synchronous, unretried +// `_client.MyCollections()` network round trip on EVERY call (not a cached flag), and on ANY +// failure calls `MakeDisconnected(...)`, which sets `CurrentCollection = null` -- so a single +// transient hiccup (plausible right after a relaunch, especially when racing another instance's +// simultaneous relaunch against the same local stack, as this file's tests do) can permanently +// drop the connection for the rest of that process's life, not just glitch once. Retrying the +// SAME `checkInCurrentBook` call a few times is a legitimate probe of whether that happened: if +// the underlying connection is fine and this was a one-off network blip, a later `MyCollections()` +// call succeeds and capabilities would still show connected; if `CurrentCollection` was actually +// nulled out, capabilities would flip to false and no amount of retrying `checkInCurrentBook` +// itself would recover without a fresh relaunch. +const checkInCurrentBookWithConnectionRetry = async ( + httpPort: number, +): Promise => { + const deadline = Date.now() + 15_000; + let lastResponse: Response; + do { + lastResponse = await postApi( + httpPort, + "teamCollection/checkInCurrentBook", + "{}", + ); + if (lastResponse.status === 200) return lastResponse; + await new Promise((resolve) => setTimeout(resolve, 500)); + } while (Date.now() < deadline); + return lastResponse; +}; + +test.describe("E2E-9 new-book lifecycle", () => { + let alice: LaunchedBloom | undefined; + let bob: LaunchedBloom | undefined; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + await Promise.all( + [alice, bob] + .filter((i): i is LaunchedBloom => !!i) + .map((i) => i.kill()), + ); + alice = undefined; + bob = undefined; + }); + + test("a new book is invisible to teammates until the first Send, then appears", async () => { + const shared = await setUpAliceAndBobOnSharedCollection( + "e2e-9-lifecycle", + LOG_DIR, + ); + alice = shared.alice; + bob = shared.bob; + const { aliceScratch, bobCollectionFilePath } = shared; + const bobCollectionFolder = path.dirname(bobCollectionFilePath); + + const sourceId = await readBookInstanceId( + aliceScratch.collectionFolder, + aliceScratch.bookName, + ); + const { folderName: newBookFolder } = await duplicateBook( + alice.httpPort, + aliceScratch.collectionFolder, + sourceId, + ); + + // Sanity: before any Send, this book has never been registered server-side at all, so + // Bob's local folder (unrelated to the local-only duplicate) has never heard of it. + await pollNowViaReceiveUpdates(bob.httpPort); + expect(await listBookFolders(bobCollectionFolder)).not.toContain( + newBookFolder, + ); + + // Send (checkInCurrentBook operates on the current selection, which DuplicateBook + // already switched to the new book -- CollectionModel.DuplicateBook calls SelectBook). + const checkinResponse = await postApi( + alice.httpPort, + "teamCollection/checkInCurrentBook", + "{}", + ); + expect(checkinResponse.status).toBe(200); + + // Bob must now see it once he refreshes (re-issuing receiveUpdates each iteration -- + // see E2E-3 finding #10 on why a single reading right after one receiveUpdates call + // isn't guaranteed to reflect its result yet). + await expect + .poll( + async () => { + await pollNowViaReceiveUpdates(bob!.httpPort); + return listBookFolders(bobCollectionFolder); + }, + { + timeout: 90_000, // arrival is via the background download queue + message: "Bob never received the newly-committed book", + }, + ) + .toContain(newBookFolder); + }); + + test("killing Bloom mid first-Send leaves no phantom, and resuming completes cleanly to one version", async () => { + const shared = await setUpAliceAndBobOnSharedCollection( + "e2e-9-kill-mid-send", + LOG_DIR, + ); + alice = shared.alice; + bob = shared.bob; + const { aliceScratch, bobCollectionFilePath } = shared; + const bobCollectionFolder = path.dirname(bobCollectionFilePath); + + const sourceId = await readBookInstanceId( + aliceScratch.collectionFolder, + aliceScratch.bookName, + ); + const { folderName: newBookFolder, bookInstanceId } = + await duplicateBook( + alice.httpPort, + aliceScratch.collectionFolder, + sourceId, + ); + + const dbClient = await openPersistentClient(); + try { + // Fire the Send without awaiting its HTTP response -- we want to interrupt it + // mid-flight, not after it completes. Swallow the eventual connection-reset error + // from killing the process out from under the request. + void postApi( + alice.httpPort, + "teamCollection/checkInCurrentBook", + "{}", + ).catch(() => undefined); + + // Poll the DB directly with a held-open connection (a fresh connect()/end() per + // query, as harness/db.ts's queryDb does, is tens of ms of overhead on its own -- + // enough to blow straight through the narrow window between checkin_start_tx's + // row-insert and checkin_finish_tx's version-commit on a fast local stack) for the + // checkin-start row, then kill Alice as fast as possible afterward. + let sawRow: { current_version_id: string | null } | undefined; + const deadline = Date.now() + 10_000; + while (!sawRow && Date.now() < deadline) { + const result = await dbClient.query( + "select current_version_id from tc.books where instance_id = $1", + [bookInstanceId], + ); + if (result.rows.length > 0) { + sawRow = result.rows[0]; + } else { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } + if (!sawRow) { + throw new Error( + "checkin_start_tx's tc.books row for the new book never appeared within 10s " + + "-- cannot exercise the kill-mid-Send race.", + ); + } + // A direct `process.kill()` (a synchronous OS call from right here in this process, + // which Node maps to TerminateProcess on Windows) rather than `alice.kill()`'s full + // killBloomProcess.mjs-subprocess-plus-port-verification dance: that path's own + // overhead (spawning a whole separate Node process, then polling every 500ms for the + // port to go dark) is easily slower than this entire Send (a handful of small files + // to local MinIO) takes to finish end-to-end -- confirmed empirically, this test + // reliably found a fully-committed book (tc.events already populated) by the time + // `alice.kill()`'s slower path had finished "interrupting" it. + process.kill(alice.processId); + + expect( + sawRow.current_version_id, + "the Send completed (current_version_id was already set) before the kill could " + + "land -- this run did not actually exercise a mid-Send interruption; re-run " + + "or tighten the poll interval", + ).toBeNull(); + } finally { + await dbClient.end(); + } + + // No phantom: Bob (an unrelated, already-joined member) must never see this book while + // its transaction sits interrupted, and no event should exist for it at all. + await pollNowViaReceiveUpdates(bob.httpPort); + expect(await listBookFolders(bobCollectionFolder)).not.toContain( + newBookFolder, + ); + const eventRows = await queryDb( + "select e.id from tc.events e join tc.books b on b.id = e.book_id where b.instance_id = $1", + [bookInstanceId], + ); + expect( + eventRows, + "an interrupted checkin_start_tx should never have produced a tc.events row", + ).toHaveLength(0); + + // Resume: relaunch Alice pointed at the SAME collection file. Her local book folder + // (with its content and the still-local-only lock) is untouched by the kill -- only the + // process died. Re-select the book explicitly rather than relying on whatever Bloom + // happens to auto-select on startup. + alice = await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: "e2e-9-kill-mid-send-alice-resumed", + logDir: LOG_DIR, + }); + await waitForSharingReady(alice.httpPort); + await selectBookByName( + alice.httpPort, + aliceScratch.collectionFolder, + newBookFolder, + ); + const resumedCheckin = await checkInCurrentBookWithConnectionRetry( + alice.httpPort, + ); + if (resumedCheckin.status !== 200) { + // Surface Bloom's own message log on failure -- this is exactly how a real bug was + // found and fixed here (see TeamCollectionApi.UpdateUiForBook's doc comment): the + // resumed check-in was silently succeeding SERVER-SIDE, then reporting 503 to the + // caller because of a NullReferenceException in post-checkin UI-refresh code that + // only reproduces when no window is the OS's "active form" (true for every instance + // this harness launches, since none of them ever receive real focus). + // eslint-disable-next-line no-console + console.log( + "resumedCheckin failed; teamCollection/getLog:", + await ( + await getApi(alice.httpPort, "teamCollection/getLog") + ).text(), + ); + } + expect(resumedCheckin.status).toBe(200); + + // Exactly one committed version, exactly one book row -- never two, never zero. + const finalRows = await queryDb<{ + id: string; + current_version_id: string | null; + }>( + "select id, current_version_id from tc.books where instance_id = $1", + [bookInstanceId], + ); + expect(finalRows).toHaveLength(1); + expect(finalRows[0].current_version_id).not.toBeNull(); + + // And Bob now receives exactly one copy of it too. + await expect + .poll( + async () => { + await pollNowViaReceiveUpdates(bob!.httpPort); + return (await listBookFolders(bobCollectionFolder)).filter( + (name) => name === newBookFolder, + ).length; + }, + { + timeout: 90_000, // past the organic 60s poll + message: + "Bob never received the resumed book (or received it more than once)", + }, + ) + .toBe(1); + }); + + test("two members creating a same-named book concurrently both end up shared under distinct names", async () => { + const shared = await setUpAliceAndBobOnSharedCollection( + "e2e-9-name-race", + LOG_DIR, + ); + alice = shared.alice; + bob = shared.bob; + const { aliceScratch, bobCollectionFilePath } = shared; + const bobCollectionFolder = path.dirname(bobCollectionFilePath); + const raceBookName = "RaceBook"; + + // Seed on disk (Bloom instances still running, but each seeds only its OWN local + // collection folder, which neither instance is watching for externally-added files -- + // see seedAdditionalBookIntoCollection's doc comment) then relaunch to pick it up via + // the normal collection-load scan, exactly like collections/pullDown's own + // kill-then-relaunch pattern. + const aliceBook = await seedAdditionalBookIntoCollection( + aliceScratch.collectionFolder, + raceBookName, + ); + const bobBook = await seedAdditionalBookIntoCollection( + bobCollectionFolder, + raceBookName, + ); + expect(aliceBook.bookInstanceId).not.toBe(bobBook.bookInstanceId); + + await Promise.all([alice.kill(), bob.kill()]); + [alice, bob] = await Promise.all([ + launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: "e2e-9-name-race-alice", + logDir: LOG_DIR, + }), + launchBloom({ + collectionFilePath: bobCollectionFilePath, + user: BOB, + label: "e2e-9-name-race-bob", + logDir: LOG_DIR, + }), + ]); + + await Promise.all([ + waitForSharingReady(alice.httpPort), + waitForSharingReady(bob.httpPort), + ]); + + // Select via the direct API on both sides (no CDP dependency -- see harness/selectBook.ts). + await Promise.all([ + selectBookByName( + alice.httpPort, + aliceScratch.collectionFolder, + raceBookName, + ), + selectBookByName(bob.httpPort, bobCollectionFolder, raceBookName), + ]); + + // The race: both Sends fire at once. CloudTeamCollection.PutBookInRepo's retry loop + // means the loser's OWN HTTP call still succeeds -- it just resolves to a numeric- + // suffixed name transparently before replying -- so both requests should report 200. + const [aliceCheckin, bobCheckin] = await Promise.all([ + checkInCurrentBookWithConnectionRetry(alice.httpPort), + checkInCurrentBookWithConnectionRetry(bob.httpPort), + ]); + expect( + aliceCheckin.status, + "Alice's Send should still succeed even if she lost the name race (the client " + + "retries with a numeric suffix transparently)", + ).toBe(200); + expect(bobCheckin.status).toBe(200); + + // Exactly two distinct, fully-committed book rows sharing the same base name. + const rows = await queryDb<{ + name: string; + current_version_id: string | null; + }>( + "select b.name, b.current_version_id from tc.books b " + + "where b.instance_id = any($1::uuid[])", + [[aliceBook.bookInstanceId, bobBook.bookInstanceId]], + ); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect( + row.current_version_id, + `both racing books must end up committed, got name=${row.name}`, + ).not.toBeNull(); + } + const names = rows.map((row) => row.name).sort(); + expect( + names[0], + `expected the two racing books to resolve to distinct names, got ${JSON.stringify(names)}`, + ).not.toBe(names[1]); + // Exactly one side keeps the plain name; the loser's suffix-resolved name still starts + // with it (PutBookInRepo's "name2" convention). This is what proves the race actually + // collided at the SAME proposed name rather than both sides having quietly diverged + // before Send (which is exactly what happened before seedAdditionalBookIntoCollection + // stamped the book title -- see its doc comment). + expect( + names.filter((name) => name === raceBookName), + `exactly one book should keep the plain name '${raceBookName}', got ${JSON.stringify(names)}`, + ).toHaveLength(1); + for (const name of names) { + expect( + name.startsWith(raceBookName), + `both final names should be derived from '${raceBookName}', got ${JSON.stringify(names)}`, + ).toBe(true); + } + }); +}); diff --git a/src/BloomTests/e2e/tests/join-auto-open.spec.ts b/src/BloomTests/e2e/tests/join-auto-open.spec.ts new file mode 100644 index 000000000000..86b9d0b7fdf4 --- /dev/null +++ b/src/BloomTests/e2e/tests/join-auto-open.spec.ts @@ -0,0 +1,145 @@ +// Pins the task-10 "pull-down auto-open" behavior end to end: when Bloom is told to join a +// cloud Team Collection, the user must land IN that collection without choosing it again. +// +// The join dialog itself (JoinCloudCollectionDialog, hosted in the collection chooser) is not +// CDP-reachable (see README.md finding #2), so this spec drives the exact same two calls the +// dialog makes, in order, against a REAL running instance: +// 1. POST collections/pullDown -> replies { collectionPath: <.bloomCollection file> } +// 2. POST workspace/openCollection -> Bloom switches to that collection IN PLACE +// and then asserts the instance is now inside a functioning cloud TC (capabilities flipped, +// collection name matches) WITHOUT any relaunch — the relaunch-after-pullDown dance the other +// scenarios use predates auto-open and deliberately bypasses it. +// +// The dialog's own half (that it really makes call 2 with the reply of call 1) is covered by +// JoinCloudCollectionDialog.test.tsx; this spec covers everything below it live, including the +// path-shape contract that broke once already (HandlePullDown originally returned the FOLDER, +// which Program.SwitchToCollection cannot open — caught in task-10 review, pinned here). +import { test, expect } from "@playwright/test"; +import * as fs from "node:fs/promises"; +import { resetStack } from "../harness/reset"; +import { createScratchCollection } from "../harness/collectionFixture"; +import { launchBloom, LaunchedBloom } from "../harness/launch"; +import { ALICE, BOB } from "../harness/devStack"; +import { + postApi, + getApi, + postCreateCloudTeamCollection, + waitForSharingReady, +} from "../harness/bloomApi"; + +const LOG_DIR = "C:\\BloomE2E-logs\\join-auto-open"; + +test.describe("Join auto-open", () => { + const instances: LaunchedBloom[] = []; + const track = (instance: LaunchedBloom): LaunchedBloom => { + instances.push(instance); + return instance; + }; + + test.beforeEach(async () => { + await resetStack(); + }); + + test.afterEach(async () => { + await Promise.all(instances.map((i) => i.kill())); + instances.length = 0; + }); + + test("pullDown replies with an openable .bloomCollection path, and openCollection lands in the TC without a relaunch", async () => { + // --- Alice shares a collection and approves Bob --- + const aliceScratch = await createScratchCollection( + "join-auto-open", + "alice", + ); + const alice = track( + await launchBloom({ + collectionFilePath: aliceScratch.collectionFilePath, + user: ALICE, + label: "join-auto-open-alice", + logDir: LOG_DIR, + }), + ); + await alice.connect(); // connect-before-trigger (finding #7) + expect((await postCreateCloudTeamCollection(alice)).status).toBe(200); + await waitForSharingReady(alice.httpPort); + expect( + ( + await postApi( + alice.httpPort, + "sharing/addApproval", + JSON.stringify({ + collectionId: aliceScratch.collectionId, + email: BOB.email, + role: "member", + }), + ) + ).status, + ).toBe(200); + + // --- Bob, on a placeholder collection, joins: the dialog's two calls, verbatim --- + const bobPlaceholder = await createScratchCollection( + "join-auto-open", + "bob", + "BobPlaceholder", + ); + const bob = track( + await launchBloom({ + collectionFilePath: bobPlaceholder.collectionFilePath, + user: BOB, + label: "join-auto-open-bob", + logDir: LOG_DIR, + }), + ); + // Bob is NOT in a cloud TC yet — sanity check before asserting the switch happened. + const bobCapsBefore = (await ( + await getApi(bob.httpPort, "teamCollection/capabilities") + ).json()) as { supportsSharingUi: boolean }; + expect(bobCapsBefore.supportsSharingUi).toBe(false); + + // Call 1: pullDown. The reply's collectionPath is the auto-open contract: a + // .bloomCollection FILE (what the chooser's cards pass to workspace/openCollection), + // never the folder. + const pullDownResponse = await postApi( + bob.httpPort, + "collections/pullDown", + JSON.stringify({ collectionId: aliceScratch.collectionId }), + ); + expect(pullDownResponse.status).toBe(200); + const { collectionPath } = (await pullDownResponse.json()) as { + collectionPath: string; + }; + expect( + collectionPath, + "pullDown must reply with the .bloomCollection file path the dialog auto-opens", + ).toMatch(/\.bloomCollection$/); + await fs.access(collectionPath); // the file must really exist on disk + + // Call 2: openCollection — the same in-place switch the dialog triggers. No relaunch. + expect( + ( + await postApi( + bob.httpPort, + "workspace/openCollection", + collectionPath, + ) + ).status, + ).toBe(200); + + // The switch happens on Application.Idle and reopens the workspace; poll the SAME + // instance (same port — the process survives the switch) until it reports being + // inside the cloud TC. Much longer timeout than the default: the in-place reopen + // includes the full initial sync of the pulled-down collection. + await waitForSharingReady(bob.httpPort, 120_000); + + // And it is the RIGHT collection, fully functional (book visible via status API). + const name = await ( + await getApi(bob.httpPort, "teamCollection/getCollectionName") + ).text(); + expect(name).toContain(aliceScratch.collectionName); + const statusResponse = await getApi( + bob.httpPort, + `teamCollection/bookStatus?folderName=${encodeURIComponent(aliceScratch.bookName)}`, + ); + expect(statusResponse.status).toBe(200); + }); +}); diff --git a/src/BloomTests/e2e/tsconfig.json b/src/BloomTests/e2e/tsconfig.json new file mode 100644 index 000000000000..9ed2cbb74ab6 --- /dev/null +++ b/src/BloomTests/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "dist" + }, + "include": ["harness/**/*.ts", "tests/**/*.ts", "playwright.config.ts"], + "exclude": ["node_modules", "dist", "test-results"] +} diff --git a/src/BloomTests/e2e/yarn.lock b/src/BloomTests/e2e/yarn.lock new file mode 100644 index 000000000000..0a2129fd4d19 --- /dev/null +++ b/src/BloomTests/e2e/yarn.lock @@ -0,0 +1,331 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@esbuild/aix-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" + integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== + +"@esbuild/android-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" + integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== + +"@esbuild/android-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" + integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== + +"@esbuild/android-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" + integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== + +"@esbuild/darwin-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" + integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== + +"@esbuild/darwin-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" + integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== + +"@esbuild/freebsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" + integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== + +"@esbuild/freebsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" + integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== + +"@esbuild/linux-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" + integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== + +"@esbuild/linux-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" + integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== + +"@esbuild/linux-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" + integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== + +"@esbuild/linux-loong64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" + integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== + +"@esbuild/linux-mips64el@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" + integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== + +"@esbuild/linux-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" + integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== + +"@esbuild/linux-riscv64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" + integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== + +"@esbuild/linux-s390x@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" + integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== + +"@esbuild/linux-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" + integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== + +"@esbuild/netbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" + integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== + +"@esbuild/netbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" + integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== + +"@esbuild/openbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" + integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== + +"@esbuild/openbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" + integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== + +"@esbuild/openharmony-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" + integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== + +"@esbuild/sunos-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" + integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== + +"@esbuild/win32-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" + integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== + +"@esbuild/win32-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" + integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== + +"@esbuild/win32-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" + integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== + +"@playwright/test@^1.48.0": + version "1.61.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.61.1.tgz#48568dc22af7819e55fa5e8e3bc79b7e6a3e6675" + integrity sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig== + dependencies: + playwright "1.61.1" + +"@types/node@*": + version "26.1.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.1.0.tgz#aa85f0727fc5611347091c478341c63650903439" + integrity sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw== + dependencies: + undici-types "~8.3.0" + +"@types/node@^22.10.1": + version "22.20.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.20.0.tgz#431f5007396bc1a1a47b9c7df60f3e5e0b5b7304" + integrity sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g== + dependencies: + undici-types "~6.21.0" + +"@types/pg@^8.20.0": + version "8.20.0" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.20.0.tgz#8bd03d3ac6b19143a8de7d66a9d13da32cd91526" + integrity sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow== + dependencies: + "@types/node" "*" + pg-protocol "*" + pg-types "^2.2.0" + +esbuild@~0.28.0: + version "0.28.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" + integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.1" + "@esbuild/android-arm" "0.28.1" + "@esbuild/android-arm64" "0.28.1" + "@esbuild/android-x64" "0.28.1" + "@esbuild/darwin-arm64" "0.28.1" + "@esbuild/darwin-x64" "0.28.1" + "@esbuild/freebsd-arm64" "0.28.1" + "@esbuild/freebsd-x64" "0.28.1" + "@esbuild/linux-arm" "0.28.1" + "@esbuild/linux-arm64" "0.28.1" + "@esbuild/linux-ia32" "0.28.1" + "@esbuild/linux-loong64" "0.28.1" + "@esbuild/linux-mips64el" "0.28.1" + "@esbuild/linux-ppc64" "0.28.1" + "@esbuild/linux-riscv64" "0.28.1" + "@esbuild/linux-s390x" "0.28.1" + "@esbuild/linux-x64" "0.28.1" + "@esbuild/netbsd-arm64" "0.28.1" + "@esbuild/netbsd-x64" "0.28.1" + "@esbuild/openbsd-arm64" "0.28.1" + "@esbuild/openbsd-x64" "0.28.1" + "@esbuild/openharmony-arm64" "0.28.1" + "@esbuild/sunos-x64" "0.28.1" + "@esbuild/win32-arm64" "0.28.1" + "@esbuild/win32-ia32" "0.28.1" + "@esbuild/win32-x64" "0.28.1" + +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +pg-cloudflare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497" + integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A== + +pg-connection-string@^2.14.0: + version "2.14.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9" + integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90" + integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw== + +pg-protocol@*, pg-protocol@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.15.0.tgz#758f6c0679cc0bbf4938603b7597703f333180c0" + integrity sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ== + +pg-types@2.2.0, pg-types@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@^8.22.0: + version "8.22.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.22.0.tgz#55ca3975026180c6dced6eec3a20a844c2dc9237" + integrity sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA== + dependencies: + pg-connection-string "^2.14.0" + pg-pool "^3.14.0" + pg-protocol "^1.15.0" + pg-types "2.2.0" + pgpass "1.0.5" + optionalDependencies: + pg-cloudflare "^1.4.0" + +pgpass@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" + integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== + dependencies: + split2 "^4.1.0" + +playwright-core@1.61.1: + version "1.61.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.61.1.tgz#3c99841307efbbabc9d724c41a88c914705d15fc" + integrity sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg== + +playwright@1.61.1: + version "1.61.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.61.1.tgz#d8c0c06eb93c28981afc747bace453bdbd5018bc" + integrity sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ== + dependencies: + playwright-core "1.61.1" + optionalDependencies: + fsevents "2.3.2" + +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz#c40b3da0222c500ff1e51c5d7014b60b79697c7a" + integrity sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ== + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + +split2@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + +tsx@^4.23.0: + version "4.23.0" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.23.0.tgz#5393ae8bfce5a9c34db9c00d1353e3d8c3fd3de6" + integrity sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w== + dependencies: + esbuild "~0.28.0" + optionalDependencies: + fsevents "~2.3.3" + +typescript@^5.6.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== + +undici-types@~8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" + integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== diff --git a/src/BloomTests/web/controllers/CollectionApiTests.cs b/src/BloomTests/web/controllers/CollectionApiTests.cs new file mode 100644 index 000000000000..bf3807c63284 --- /dev/null +++ b/src/BloomTests/web/controllers/CollectionApiTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Bloom.web.controllers; +using NUnit.Framework; + +namespace BloomTests.web.controllers +{ + /// + /// Tests for CollectionApi's pure not-yet-downloaded placeholder merge logic (dogfood batch 1, + /// item 7, progressive join): ComputeNotYetDownloadedBookEntries decides, given the local book + /// folder names already scanned from disk and a Cloud Team Collection's full repo book list + /// (folder name + stable instance id), which repo books get a placeholder entry in the + /// collections/books JSON. Follows CollectionChooserApiTests'/SharingApiTests' pattern of + /// testing internal static pure logic directly, no filesystem/network/repo access required. + /// + [TestFixture] + public class CollectionApiTests + { + [Test] + public void ComputeNotYetDownloadedBookEntries_NoRepoBooks_ReturnsEmpty() + { + var result = CollectionApi.ComputeNotYetDownloadedBookEntries( + new HashSet(), + new HashSet(), + new List<(string name, string instanceId)>(), + "C:/Collections/My Collection" + ); + + Assert.That(result, Is.Empty); + } + + [Test] + public void ComputeNotYetDownloadedBookEntries_RepoBookWithNoLocalFolder_GetsAPlaceholder() + { + var result = CollectionApi.ComputeNotYetDownloadedBookEntries( + new HashSet(), // nothing local at all yet -- fresh progressive join + new HashSet(), + new List<(string name, string instanceId)> { ("Remote Book", "instance-1") }, + "C:/Collections/My Collection" + ); + + Assert.That(result.Count, Is.EqualTo(1)); + var entry = result[0]; + Assert.That(entry.notYetDownloaded, Is.True); + Assert.That(entry.id, Is.EqualTo("instance-1")); + Assert.That(entry.title, Is.EqualTo("Remote Book")); + Assert.That(entry.folderName, Is.EqualTo("Remote Book")); + Assert.That(entry.collectionId, Is.EqualTo("C:/Collections/My Collection")); + Assert.That( + entry.folderPath, + Is.EqualTo(System.IO.Path.Combine("C:/Collections/My Collection", "Remote Book")) + ); + } + + [Test] + public void ComputeNotYetDownloadedBookEntries_RepoBookAlreadyLocal_NoPlaceholder() + { + var result = CollectionApi.ComputeNotYetDownloadedBookEntries( + new HashSet { "Already Here" }, + new HashSet(), + new List<(string name, string instanceId)> { ("Already Here", "instance-1") }, + "C:/Collections/My Collection" + ); + + Assert.That( + result, + Is.Empty, + "a repo book that already has a local folder must not get a placeholder" + ); + } + + [Test] + public void ComputeNotYetDownloadedBookEntries_LocalFolderMatchRespectsCaseInsensitiveSet() + { + // The pure function itself is comparer-agnostic (it just calls Contains on whatever + // set it's given); the real caller (CollectionApi.GetNotYetDownloadedBookEntries) + // builds its local-names set with StringComparer.OrdinalIgnoreCase, matching the rest + // of the TC code's case-insensitive folder-name handling. This test pins THAT contract + // by using the same kind of set a real caller would. + var result = CollectionApi.ComputeNotYetDownloadedBookEntries( + new HashSet(StringComparer.OrdinalIgnoreCase) { "already here" }, // different case + new HashSet(), + new List<(string name, string instanceId)> { ("Already Here", "instance-1") }, + "C:/Collections/My Collection" + ); + + Assert.That( + result, + Is.Empty, + "a case-insensitively-matching local folder must suppress the placeholder" + ); + } + + [Test] + public void ComputeNotYetDownloadedBookEntries_MixOfDownloadedAndNotYetDownloaded_OnlyPlaceholdersTheMissingOnes() + { + var result = CollectionApi.ComputeNotYetDownloadedBookEntries( + new HashSet { "Book A" }, + new HashSet(), + new List<(string name, string instanceId)> + { + ("Book A", "instance-a"), + ("Book B", "instance-b"), + ("Book C", "instance-c"), + }, + "C:/Collections/My Collection" + ); + + Assert.That( + result.Select(e => e.folderName), + Is.EquivalentTo(new[] { "Book B", "Book C" }) + ); + Assert.That(result.All(e => e.notYetDownloaded), Is.True); + } + + [Test] + public void ComputeNotYetDownloadedBookEntries_LocalBookWithSameInstanceIdButDifferentName_NoPlaceholder() + { + // Bug #15: a checked-out book renamed locally ahead of check-in. The repo still lists + // it under the OLD name (no local folder by that name), but a local book with the SAME + // instance id exists -- it IS this book, so no phantom placeholder. + var result = CollectionApi.ComputeNotYetDownloadedBookEntries( + new HashSet(StringComparer.OrdinalIgnoreCase) { "Tetun moon and cap" }, + new HashSet(StringComparer.OrdinalIgnoreCase) { "instance-1" }, + new List<(string name, string instanceId)> + { + ("The Moon and the Cap", "instance-1"), + }, + "C:/Collections/My Collection" + ); + + Assert.That( + result, + Is.Empty, + "a repo book whose instance id matches a local book is locally present, whatever " + + "its folder is currently called" + ); + } + + [Test] + public void ComputeNotYetDownloadedBookEntries_MissingInstanceId_FallsBackToNameAsId() + { + // A book whose instance id isn't known yet (shouldn't normally happen, but the id must + // never be null/empty -- the client uses it as a React key and a websocket-message + // correlation id). + var result = CollectionApi.ComputeNotYetDownloadedBookEntries( + new HashSet(), + new HashSet(), + new List<(string name, string instanceId)> { ("No Instance Id Book", null) }, + "C:/Collections/My Collection" + ); + + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result[0].id, Is.EqualTo("No Instance Id Book")); + } + } +} diff --git a/src/BloomTests/web/controllers/CollectionChooserApiTests.cs b/src/BloomTests/web/controllers/CollectionChooserApiTests.cs new file mode 100644 index 000000000000..de516805888c --- /dev/null +++ b/src/BloomTests/web/controllers/CollectionChooserApiTests.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using System.Linq; +using Bloom.TeamCollection.Cloud; +using Bloom.web.controllers; +using NUnit.Framework; + +namespace BloomTests.web.controllers +{ + /// + /// Tests for CollectionChooserApi's pure join-card matching logic (dogfood batch 1, item 6; + /// identity-aware since bug #11, John's ruling 13 Jul 2026): ComputeJoinCards decides, given + /// the cloud collections the signed-in user belongs to, the local cloud copies on this + /// machine (cloud id + last-known user, as gathered by GetLocalCloudCopies -- not exercised + /// here since that half needs real files on disk), and the signed-in email, which of those + /// cloud collections should get a "join card" in the collection chooser. A local copy only + /// suppresses a card when it was most recently used by the SIGNED-IN account -- another + /// account's copy (or one whose user was never recorded) must not hide this account's + /// invitation. Follows SharingApiTests' pattern of testing internal static pure logic + /// directly, no live server or filesystem required. + /// + [TestFixture] + public class CollectionChooserApiTests + { + private const string kSignedInEmail = "bob@example.com"; + + private static List<(string cloudCollectionId, string lastKnownUser)> NoLocalCopies() => + new List<(string cloudCollectionId, string lastKnownUser)>(); + + [Test] + public void ComputeJoinCards_NoCloudCollections_ReturnsEmpty() + { + var result = CollectionChooserApi.ComputeJoinCards( + new List(), + NoLocalCopies(), + kSignedInEmail + ); + + Assert.That(result, Is.Empty); + } + + [Test] + public void ComputeJoinCards_CollectionWithNoLocalCopy_GetsAJoinCard() + { + var summary = new CloudCollectionSummary { Id = "cloud-1", Name = "Sunshine Books" }; + + var result = CollectionChooserApi.ComputeJoinCards( + new List { summary }, + NoLocalCopies(), + kSignedInEmail + ); + + Assert.That(result.Count, Is.EqualTo(1)); + dynamic card = result[0]; + Assert.That(card.collectionId, Is.EqualTo("cloud-1")); + Assert.That(card.title, Is.EqualTo("Sunshine Books")); + } + + [Test] + public void ComputeJoinCards_LocalCopyLastUsedByThisAccount_NoJoinCard() + { + var summary = new CloudCollectionSummary { Id = "cloud-1", Name = "Sunshine Books" }; + + var result = CollectionChooserApi.ComputeJoinCards( + new List { summary }, + new List<(string, string)> { ("cloud-1", kSignedInEmail) }, + kSignedInEmail + ); + + Assert.That( + result, + Is.Empty, + "A local copy this same account most recently used means the collection is " + + "already joined here, so it should not also get a join card" + ); + } + + [Test] + public void ComputeJoinCards_LocalCopyLastUsedByThisAccountDifferentCase_NoJoinCard() + { + var summary = new CloudCollectionSummary { Id = "cloud-1", Name = "Sunshine Books" }; + + var result = CollectionChooserApi.ComputeJoinCards( + new List { summary }, + new List<(string, string)> { ("cloud-1", "BOB@Example.COM") }, + kSignedInEmail + ); + + Assert.That(result, Is.Empty, "email matching must be case-insensitive"); + } + + [Test] + public void ComputeJoinCards_LocalCopyBelongsToAnotherAccount_StillGetsJoinCard() + { + // Bug #11 (found live, 13 Jul 2026): Bob, an invited member, got no join card + // because ALICE's local copy of the collection was on this machine's chooser list. + // Another account's copy must not hide this account's invitation. + var summary = new CloudCollectionSummary { Id = "cloud-1", Name = "Sunshine Books" }; + + var result = CollectionChooserApi.ComputeJoinCards( + new List { summary }, + new List<(string, string)> { ("cloud-1", "alice@example.com") }, + kSignedInEmail + ); + + Assert.That(result.Count, Is.EqualTo(1)); + dynamic card = result[0]; + Assert.That(card.collectionId, Is.EqualTo("cloud-1")); + } + + [Test] + public void ComputeJoinCards_LocalCopyWithUnknownLastUser_StillGetsJoinCard() + { + // John's ruling: suppress only when the copy is KNOWN to be this account's. A copy + // with no recorded last-known user (e.g. a manually copied folder) shows the card; + // CloudJoinFlow's scenario matching handles any merge/conflict at actual join time. + var summary = new CloudCollectionSummary { Id = "cloud-1", Name = "Sunshine Books" }; + + var result = CollectionChooserApi.ComputeJoinCards( + new List { summary }, + new List<(string, string)> { ("cloud-1", null) }, + kSignedInEmail + ); + + Assert.That(result.Count, Is.EqualTo(1)); + } + + [Test] + public void ComputeJoinCards_MixOfOwnOtherAndUnjoined_OnlyOwnCopySuppresses() + { + var mine = new CloudCollectionSummary { Id = "cloud-mine", Name = "Already Have" }; + var alices = new CloudCollectionSummary { Id = "cloud-alices", Name = "Alices Copy" }; + var unjoined = new CloudCollectionSummary { Id = "cloud-new", Name = "New One" }; + + var result = CollectionChooserApi.ComputeJoinCards( + new List { mine, alices, unjoined }, + new List<(string, string)> + { + ("cloud-mine", kSignedInEmail), + ("cloud-alices", "alice@example.com"), + ("some-other-unrelated-local-tc", kSignedInEmail), + }, + kSignedInEmail + ); + + var ids = result.Select(c => (string)c.collectionId).ToList(); + Assert.That(ids, Does.Not.Contain("cloud-mine")); + Assert.That(ids, Does.Contain("cloud-alices")); + Assert.That(ids, Does.Contain("cloud-new")); + Assert.That(result.Count, Is.EqualTo(2)); + } + + [Test] + public void ComputeJoinCards_SameNameDifferentLocalFolder_StillGetsJoinCardBySameIdRule() + { + // Per the batch decision: matching is by cloud id ONLY. A local folder that happens to + // share the cloud collection's display name but is NOT itself linked to that cloud id + // (e.g. it's an unrelated plain collection, or a folder TC) does not suppress the join + // card -- CloudJoinFlow's own scenario matching (merge-or-conflict) applies only once + // the user actually tries to join. + var summary = new CloudCollectionSummary { Id = "cloud-1", Name = "Sunshine Books" }; + + var result = CollectionChooserApi.ComputeJoinCards( + new List { summary }, + new List<(string, string)> { ("some-unrelated-cloud-id", kSignedInEmail) }, + kSignedInEmail + ); + + Assert.That(result.Count, Is.EqualTo(1)); + dynamic card = result[0]; + Assert.That(card.collectionId, Is.EqualTo("cloud-1")); + } + } +} diff --git a/src/BloomTests/web/controllers/SharingApiTests.cs b/src/BloomTests/web/controllers/SharingApiTests.cs new file mode 100644 index 000000000000..729e7bf1fdbb --- /dev/null +++ b/src/BloomTests/web/controllers/SharingApiTests.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Bloom.History; +using Bloom.web.controllers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace BloomTests.web.controllers +{ + /// + /// Tests for SharingApi's pure wire-shape mapping/resolution logic (the parts that don't + /// require a live Supabase server): mapping RPC snake_case rows to the camelCase shapes + /// sharingApi.ts (task 07) already committed to, and resolving an email to a member row id + /// for the members_remove/members_set_role RPC-param fix. Endpoints that dispatch to a live + /// CloudCollectionClient are exercised instead (with a fake REST executor) by the Cloud test + /// suite's own established pattern -- SharingApi itself adds no additional Cloud-backend + /// business logic beyond this mapping, per its own file's design. + /// + [TestFixture] + public class SharingApiTests + { + [Test] + public void ToApprovedMember_ClaimedAdmin_MapsAllFields() + { + var row = new JObject + { + ["id"] = 1, + ["email"] = "sara@example.com", + ["display_name"] = "Sara S", + ["role"] = "admin", + ["user_id"] = "user-abc", + ["added_by"] = "user-xyz", + ["added_at"] = "2026-07-01T00:00:00Z", + ["claimed_at"] = "2026-07-02T00:00:00Z", + }; + + dynamic result = SharingApi.ToApprovedMember(row); + + Assert.That(result.email, Is.EqualTo("sara@example.com")); + Assert.That(result.role, Is.EqualTo("admin")); + Assert.That(result.claimed, Is.True); + Assert.That(result.name, Is.EqualTo("Sara S")); + } + + [Test] + public void ToApprovedMember_NoDisplayNameSet_NameIsNull() + { + // display_name is NULL until someone sets it (20260713000001 migration); the UI + // falls back to the email. A JSON-null token must map to a real null, not "". + var row = new JObject + { + ["id"] = 3, + ["email"] = "unnamed@example.com", + ["display_name"] = null, + ["role"] = "member", + ["user_id"] = "user-def", + }; + + dynamic result = SharingApi.ToApprovedMember(row); + + Assert.That(result.name, Is.Null); + } + + [Test] + public void ToApprovedMember_UnclaimedInvitation_ClaimedIsFalse() + { + var row = new JObject + { + ["id"] = 2, + ["email"] = "pending@example.com", + ["role"] = "member", + ["user_id"] = null, + ["added_by"] = "user-xyz", + ["added_at"] = "2026-07-01T00:00:00Z", + ["claimed_at"] = null, + }; + + dynamic result = SharingApi.ToApprovedMember(row); + + Assert.That(result.email, Is.EqualTo("pending@example.com")); + Assert.That( + result.claimed, + Is.False, + "An invitation with no user_id yet has not been claimed" + ); + } + + [Test] + public void ToCollectionSummary_MapsSnakeCaseMyRoleToCamelCaseRole() + { + // Exact shape live-verified against the local dev stack's my_collections() RPC. + var row = new JObject + { + ["id"] = "11111111-1111-1111-1111-111111111111", + ["name"] = "My Cloud Collection", + ["created_at"] = "2026-07-01T00:00:00Z", + ["created_by"] = "user-1", + ["my_role"] = "admin", + ["is_claimed"] = true, + }; + + dynamic result = SharingApi.ToCollectionSummary(row); + + Assert.That(result.collectionId, Is.EqualTo("11111111-1111-1111-1111-111111111111")); + Assert.That(result.name, Is.EqualTo("My Cloud Collection")); + Assert.That( + result.role, + Is.EqualTo("admin"), + "role must come from my_role, not a non-existent 'role' key" + ); + } + + [Test] + public void ToBookHistoryEvent_MapsToSameShapeFolderTeamCollectionsUse() + { + var when = new DateTime(2026, 7, 7, 12, 0, 0, DateTimeKind.Utc); + var e = new JObject + { + ["id"] = 42, + ["book_id"] = "book-1", + ["type"] = 5, // ForcedUnlock + ["by_user_id"] = "user-1", + ["by_user_name"] = "Sara", + ["by_email"] = "sara@example.com", + ["book_version_seq"] = 3, + ["lock_info"] = null, + ["book_name"] = "My Book", + ["group_key"] = null, + ["message"] = "Admin force-unlocked", + ["bloom_version"] = "6.2.100", + ["occurred_at"] = when.ToString("O"), + }; + + var result = SharingApi.ToBookHistoryEvent(e); + + Assert.That(result.BookId, Is.EqualTo("book-1")); + Assert.That(result.Title, Is.EqualTo("My Book")); + Assert.That(result.Message, Is.EqualTo("Admin force-unlocked")); + Assert.That(result.Type, Is.EqualTo(BookHistoryEventType.ForcedUnlock)); + Assert.That(result.UserId, Is.EqualTo("user-1")); + Assert.That(result.UserName, Is.EqualTo("Sara")); + // The (DateTime) JToken cast (matching this codebase's existing + // (DateTime?)row["locked_at"]-style casts elsewhere) converts to local kind, so compare + // in UTC rather than assuming the cast preserves Z/UTC as-is. + Assert.That(result.When.ToUniversalTime(), Is.EqualTo(when)); + Assert.That(result.BloomVersion, Is.EqualTo("6.2.100")); + } + + [Test] + public void ToBookHistoryEvent_NoDisplayName_FallsBackToEmail() + { + var e = new JObject + { + ["id"] = 43, + ["book_id"] = "book-1", + ["type"] = 0, // CheckOut + ["by_user_id"] = "user-2", + ["by_user_name"] = null, + ["by_email"] = "bob@example.com", + ["book_name"] = "My Book", + ["message"] = null, + ["bloom_version"] = "6.2.100", + ["occurred_at"] = DateTime.UtcNow.ToString("O"), + }; + + var result = SharingApi.ToBookHistoryEvent(e); + + Assert.That( + result.UserName, + Is.EqualTo("bob@example.com"), + "when by_user_name is null (no JWT name claim, common in dev-auth mode), UserName " + + "should fall back to the always-present by_email rather than showing nothing" + ); + } + + [Test] + public void ToBookHistoryEvent_DurableDisplayNamePresent_BeatsJwtNameAndEmail() + { + var e = new JObject + { + ["id"] = 44, + ["book_id"] = "book-1", + ["type"] = 1, // CheckIn + ["by_user_id"] = "user-3", + ["by_user_name"] = "JWT Name", + ["by_email"] = "carol@example.com", + ["by_display_name"] = "Carol the Editor", + ["book_name"] = "My Book", + ["message"] = null, + ["bloom_version"] = "6.2.100", + ["occurred_at"] = DateTime.UtcNow.ToString("O"), + }; + + var result = SharingApi.ToBookHistoryEvent(e); + + Assert.That( + result.UserName, + Is.EqualTo("Carol the Editor"), + "the member's current durable display name (by_display_name, 20260713000001) " + + "should beat both the at-event-time JWT name claim and the email" + ); + } + + [Test] + public void ResolveMemberIdFromList_KnownEmail_ReturnsRowId() + { + var members = new JArray( + new JObject + { + ["id"] = 17, + ["email"] = "admin@dev.local", + ["role"] = "admin", + }, + new JObject + { + ["id"] = 18, + ["email"] = "alice@dev.local", + ["role"] = "member", + } + ); + + var id = SharingApi.ResolveMemberIdFromList(members, "collection-1", "alice@dev.local"); + + Assert.That(id, Is.EqualTo(18)); + } + + [Test] + public void ResolveMemberIdFromList_EmailIsCaseInsensitive() + { + var members = new JArray( + new JObject + { + ["id"] = 18, + ["email"] = "alice@dev.local", + ["role"] = "member", + } + ); + + var id = SharingApi.ResolveMemberIdFromList(members, "collection-1", "ALICE@DEV.LOCAL"); + + Assert.That(id, Is.EqualTo(18)); + } + + [Test] + public void ResolveMemberIdFromList_UnknownEmail_Throws() + { + var members = new JArray( + new JObject + { + ["id"] = 17, + ["email"] = "admin@dev.local", + ["role"] = "admin", + } + ); + + Assert.Throws( + () => + SharingApi.ResolveMemberIdFromList( + members, + "collection-1", + "not-a-member@dev.local" + ), + "removeApproval/setRole for someone who isn't an approved member is a genuine " + + "caller error and should fail loudly, not silently no-op" + ); + } + + // ------------------------------------------------------------------ + // History incremental cache (E8): fetch only NEW events past the cursor + // and merge, instead of re-downloading the whole log every open. + // ------------------------------------------------------------------ + + private static JObject HistoryEventJson(string bookId, DateTime when, string message) => + new JObject + { + ["book_id"] = bookId, + ["type"] = 1, // CheckIn + ["by_user_id"] = "u", + ["by_user_name"] = "U", + ["by_email"] = "u@example.com", + ["book_name"] = "B", + ["message"] = message, + ["bloom_version"] = "6.2", + ["occurred_at"] = when.ToString("O"), + }; + + [Test] + public void MergeHistory_Cursor0_ReplacesWithWholeLog() + { + var cached = new SharingApi.CloudHistoryCache + { + MaxEventId = 0, + Events = new List + { + SharingApi.ToBookHistoryEvent( + HistoryEventJson( + "b1", + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + "stale" + ) + ), + }, + }; + var wholeLog = new JArray + { + HistoryEventJson( + "b2", + new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc), + "fresh" + ), + }; + + var result = SharingApi.MergeHistory(cached, wholeLog, 5); + + Assert.That(result.MaxEventId, Is.EqualTo(5)); + Assert.That( + result.Events.Count, + Is.EqualTo(1), + "a 0 cursor means the response is the whole log, so it replaces the cache" + ); + Assert.That(result.Events[0].Message, Is.EqualTo("fresh")); + } + + [Test] + public void MergeHistory_NonZeroCursor_AppendsNewEventsNewestFirst() + { + var cached = new SharingApi.CloudHistoryCache + { + MaxEventId = 3, + Events = new List + { + SharingApi.ToBookHistoryEvent( + HistoryEventJson( + "b1", + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + "old" + ) + ), + }, + }; + var delta = new JArray + { + HistoryEventJson("b2", new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc), "new"), + }; + + var result = SharingApi.MergeHistory(cached, delta, 7); + + Assert.That(result.MaxEventId, Is.EqualTo(7)); + Assert.That(result.Events.Count, Is.EqualTo(2), "new events append to the cached ones"); + Assert.That(result.Events[0].Message, Is.EqualTo("new"), "sorted newest-first"); + Assert.That(result.Events[1].Message, Is.EqualTo("old")); + } + + [Test] + public void MergeHistory_NullResponseMax_KeepsExistingCursor() + { + var cached = new SharingApi.CloudHistoryCache + { + MaxEventId = 3, + Events = new List(), + }; + + var result = SharingApi.MergeHistory(cached, new JArray(), null); + + Assert.That(result.MaxEventId, Is.EqualTo(3)); + Assert.That(result.Events, Is.Empty); + } + + [Test] + public void LoadHistoryCache_RoundTripsNewFormat() + { + var path = TempCachePath(); + try + { + var cache = new SharingApi.CloudHistoryCache + { + MaxEventId = 9, + Events = new List + { + SharingApi.ToBookHistoryEvent(HistoryEventJson("b1", DateTime.UtcNow, "m")), + }, + }; + SharingApi.SaveHistoryCache(path, cache); + + var loaded = SharingApi.LoadHistoryCache(path); + + Assert.That(loaded.MaxEventId, Is.EqualTo(9)); + Assert.That(loaded.Events.Count, Is.EqualTo(1)); + Assert.That(loaded.Events[0].BookId, Is.EqualTo("b1")); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + + [Test] + public void LoadHistoryCache_OldBareArrayFormat_KeepsEventsButCursorZero() + { + var path = TempCachePath(); + try + { + // The pre-incremental on-disk format was a bare List. + var oldEvents = new List + { + SharingApi.ToBookHistoryEvent(HistoryEventJson("b1", DateTime.UtcNow, "m")), + }; + File.WriteAllText(path, JsonConvert.SerializeObject(oldEvents)); + + var loaded = SharingApi.LoadHistoryCache(path); + + Assert.That( + loaded.MaxEventId, + Is.EqualTo(0), + "an old bare-array cache has no cursor, forcing the next fetch to be a full refetch" + ); + Assert.That( + loaded.Events.Count, + Is.EqualTo(1), + "old-format events are preserved so the offline view still works after upgrade" + ); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + + [Test] + public void LoadHistoryCache_MissingFileOrNullPath_ReturnsEmpty() + { + Assert.That(SharingApi.LoadHistoryCache(null).Events, Is.Empty); + var loaded = SharingApi.LoadHistoryCache(TempCachePath()); // never written + Assert.That(loaded.MaxEventId, Is.EqualTo(0)); + Assert.That(loaded.Events, Is.Empty); + } + + private static string TempCachePath() => + Path.Combine( + Path.GetTempPath(), + "BloomHistoryCacheTest-" + Guid.NewGuid().ToString("N") + ".json" + ); + } +} diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 000000000000..7690614d5873 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,5 @@ +# Supabase CLI local state — never commit +.branches/ +.temp/ +# snippets/ and functions/ hold committed content (.gitkeep at minimum): Podman does not +# auto-create missing bind-mount directories, so fresh clones must already have them. diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 000000000000..55608c9adcbd --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,187 @@ +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "bloom-team-collections" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get +# API endpoints. `public` and `graphql_public` schemas are always included. +schemas = ["public", "graphql_public", "tc"] +# Extra schemas to add to the search_path of every request. `public` is always included. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload +# size for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +enabled = false + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run +# `SHOW server_version;` on the remote database to check. +major_version = 15 + +# Dev seed: creates the standard dev users (admin/alice/bob@dev.local) — see server/dev/README.md +[db.seed] +enabled = true +sql_paths = ["../server/dev/seed.sql"] + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv6) +# ip_version = "IPv6" +# The minimum JWT expiry to allow for realtime subscriptions (in seconds). +# min_message_expiry_ms = 1000 +# Maximum number of channels per client. +# max_channels_per_client = 100 +# Timeout for auth checks (in milliseconds). +# experimental_broadcast_ack_ms = 300 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, +# they are monitored, and you can view the emails that would have been sent from the web +# interface on inbucket. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +smtp_port = 54325 +pop3_port = 54326 + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +[storage.image_transformation] +enabled = true + +# Uncomment to use a custom domain for storage uploads +# [storage.s3_protocol] +# enabled = true +# endpoint = "env(SUPABASE_STORAGE_S3_ENDPOINT)" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs +# used in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 +# week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows session invalidation from server-side. (default: true) +enable_signup = true +# IMPORTANT: for Cloud TC dev, auto-confirm is ON so any login is "verified" +# (mimics a backend that accepts any sign-in). Real Firebase/BloomLibrary auth plugs +# in behind CloudAuth later; tc.jwt_email_verified() on the server handles both shapes. +[auth.email] +enable_signup = true +double_confirm_changes = true +enable_confirmations = false # dev: auto-confirm = no email loop needed + +[auth.sms] +enable_signup = false + +[auth.external.apple] +enabled = false + +[auth.external.azure] +enabled = false + +[auth.external.bitbucket] +enabled = false + +[auth.external.discord] +enabled = false + +[auth.external.facebook] +enabled = false + +[auth.external.figma] +enabled = false + +[auth.external.github] +enabled = false + +[auth.external.gitlab] +enabled = false + +[auth.external.google] +enabled = false + +[auth.external.keycloak] +enabled = false + +[auth.external.linkedin_oidc] +enabled = false + +[auth.external.notion] +enabled = false + +[auth.external.twitch] +enabled = false + +[auth.external.twitter] +enabled = false + +[auth.external.slack_oidc] +enabled = false + +[auth.external.spotify] +enabled = false + +[auth.external.workos] +enabled = false + +[auth.external.zoom] +enabled = false + +[edge_runtime] +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload (recommended for development), `per_worker` for production. +# NOTE (task 02, 6 Jul 2026): `oneshot` re-transpiles/type-checks the whole module graph +# — including the heavy npm:@aws-sdk/client-s3 + client-sts imports in +# supabase/functions/_shared/s3.ts — on EVERY request. That cold-start regularly exceeds +# the edge-runtime's fixed ~10s worker-boot timeout (InvalidWorkerCreation: "worker did +# not respond in time"), which made checkin-start/etc. fail every call locally. `per_worker` +# compiles once and reuses the worker, which is also a closer match to how these functions +# actually run in production. See server/dev/README.md. +policy = "per_worker" +inspector_port = 8083 + +[analytics] +enabled = false +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" diff --git a/supabase/functions/.gitkeep b/supabase/functions/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/supabase/functions/_shared/env.ts b/supabase/functions/_shared/env.ts new file mode 100644 index 000000000000..c8f80d04ceff --- /dev/null +++ b/supabase/functions/_shared/env.ts @@ -0,0 +1,83 @@ +// Environment / configuration helpers shared by every Cloud Team Collections edge +// function. Centralised here so the dev/prod credential-provider seam (see +// server/dev/DEV-CREDENTIALS.md) lives in exactly one place. + +/** Reads a required env var; throws (fails fast) if missing — see AGENTS.md testing + * philosophy: don't silently work around a missing dependency. */ +export const requireEnv = (name: string): string => { + const value = Deno.env.get(name); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +}; + +/** Optional env var with a default. */ +export const optionalEnv = (name: string, fallback: string): string => + Deno.env.get(name) ?? fallback; + +/** True when running against the LOCAL stack (MinIO via AssumeRole, on the developer's own + * machine) rather than real AWS. Mirrors server/dev/DEV-CREDENTIALS.md's + * `BLOOM_CLOUD_LOCAL_MODE` secret. Named "local", not "dev": in the Bloom ecosystem "dev" + * conventionally means a REAL, hosted, reserved-for-testing deployment (dev.bloomlibrary.org), + * and a future cloud-collections project will likely carry "dev" in its name — a hosted "dev" + * deployment runs with this flag FALSE. */ +export const isLocalMode = (): boolean => + optionalEnv("BLOOM_CLOUD_LOCAL_MODE", "false") === "true"; + +/** Supabase project URL + anon key, auto-injected by the Supabase CLI/platform into + * every edge function's environment — used to call PostgREST RPCs with the caller's + * OWN forwarded JWT (never the service-role key; see the migration's header comment + * for why that is both sufficient and correct here). */ +export const supabaseUrl = (): string => requireEnv("SUPABASE_URL"); +export const supabaseAnonKey = (): string => requireEnv("SUPABASE_ANON_KEY"); + +/** S3 / MinIO connection details. */ +export interface S3Env { + endpoint: string; + bucket: string; + region: string; + forcePathStyle: boolean; +} + +export const s3Env = (): S3Env => ({ + endpoint: requireEnv("BLOOM_S3_ENDPOINT"), + bucket: requireEnv("BLOOM_S3_BUCKET"), + region: optionalEnv("BLOOM_S3_REGION", "us-east-1"), + // MinIO requires path-style; real AWS uses virtual-hosted style. Local mode always + // forces path-style; production can opt out via BLOOM_S3_FORCE_PATH_STYLE=false. + forcePathStyle: + isLocalMode() || + optionalEnv("BLOOM_S3_FORCE_PATH_STYLE", "true") === "true", +}); + +/** Root/admin credentials used ONLY server-side (never sent to a client) to call + * MinIO's AssumeRole STS endpoint in local mode, and for admin S3 operations + * (HeadObject / GetObjectAttributes verification, .manifest.json writes). */ +export const minioRootCredentials = () => ({ + accessKeyId: optionalEnv("BLOOM_S3_ROOT_ACCESS_KEY", "minioadmin"), + secretAccessKey: optionalEnv("BLOOM_S3_ROOT_SECRET_KEY", "minioadmin"), +}); + +/** Production broker configuration: the "assume-only" IAM user's credentials and the + * bloom-teams-broker role ARN it is allowed to assume (see server/provision-aws.ts). */ +export const prodBrokerConfig = () => ({ + roleArn: requireEnv("BLOOM_TEAMS_BROKER_ROLE_ARN"), + accessKeyId: requireEnv("AWS_ACCESS_KEY_ID"), + secretAccessKey: requireEnv("AWS_SECRET_ACCESS_KEY"), + region: optionalEnv("AWS_REGION", "us-east-1"), +}); + +/** Admin S3 credentials for server-side verification/writes (HeadObject, + * GetObjectAttributes, .manifest.json PUT). In local mode this is the MinIO root user; in + * production a dedicated admin/broker identity with full bucket access (distinct + * from the narrowly-scoped session credentials handed to clients). */ +export const adminS3Credentials = () => { + if (isLocalMode()) { + return minioRootCredentials(); + } + return { + accessKeyId: requireEnv("BLOOM_S3_ADMIN_ACCESS_KEY"), + secretAccessKey: requireEnv("BLOOM_S3_ADMIN_SECRET_KEY"), + }; +}; diff --git a/supabase/functions/_shared/errors.ts b/supabase/functions/_shared/errors.ts new file mode 100644 index 000000000000..c9176d9d9c49 --- /dev/null +++ b/supabase/functions/_shared/errors.ts @@ -0,0 +1,39 @@ +// Small JSON-response helpers shared by every edge function. Kept deliberately +// un-clever: the contract error shapes in CONTRACTS.md are just +// `{ error: "", ...extra }`, so we build exactly that. + +export const CORS_HEADERS: Record = { + // Bloom Desktop calls these from the C# process (HttpClient), not a browser page, + // so CORS is not actually load-bearing — but it's harmless and cheap to allow, in + // case any tooling (smoke tests, future browser-based admin UI) calls in directly. + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +export const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + +/** Standard error envelope: `{ error: "", ...extra }`. */ +export const errorResponse = ( + status: number, + error: string, + extra?: Record, +): Response => jsonResponse(status, { error, ...extra }); + +export class HttpError extends Error { + readonly status: number; + readonly body: Record; + constructor(status: number, body: Record) { + super(typeof body.error === "string" ? body.error : `HTTP ${status}`); + this.status = status; + this.body = body; + } + toResponse(): Response { + return jsonResponse(this.status, this.body); + } +} diff --git a/supabase/functions/_shared/handler.ts b/supabase/functions/_shared/handler.ts new file mode 100644 index 000000000000..39e489a0f630 --- /dev/null +++ b/supabase/functions/_shared/handler.ts @@ -0,0 +1,58 @@ +// Common Deno.serve wrapper for every Cloud TC edge function: CORS preflight, +// method + JSON-body handling, and turning a thrown HttpError into the right JSON +// response. Keeps each function's index.ts focused on its own request shape. +import { CORS_HEADERS, errorResponse, HttpError } from "./errors.ts"; + +export type JsonHandler = ( + req: Request, + body: Record, +) => Promise; + +/** Fails fast (throws HttpError 400) if `value` is missing/empty — used for the + * required fields in each function's request body. */ +export const requireField = ( + body: Record, + name: string, +): T => { + const value = body[name]; + if (value === undefined || value === null || value === "") { + throw new HttpError(400, { error: "invalid_request", field: name }); + } + return value as T; +}; + +export const optionalField = ( + body: Record, + name: string, +): T | null => (body[name] as T | undefined) ?? null; + +export const serveJsonPost = (handler: JsonHandler): void => { + Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: CORS_HEADERS }); + } + if (req.method !== "POST") { + return errorResponse(405, "method_not_allowed"); + } + + let body: Record; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return errorResponse(400, "invalid_json"); + } + + try { + return await handler(req, body); + } catch (err) { + if (err instanceof HttpError) { + return err.toResponse(); + } + console.error("Unhandled error:", err); + return errorResponse(500, "internal_error", { + message: String(err), + }); + } + }); +}; diff --git a/supabase/functions/_shared/invariants.test.ts b/supabase/functions/_shared/invariants.test.ts new file mode 100644 index 000000000000..45c6604b6c66 --- /dev/null +++ b/supabase/functions/_shared/invariants.test.ts @@ -0,0 +1,98 @@ +// Cross-file invariant (task 02 acceptance criterion): a check-in/collection-files +// transaction's lifetime must be strictly less than S3's noncurrent-version-expiry +// lifecycle floor. If it weren't, a slow/stalled transaction could still be trying to +// reference an object version that MinIO/S3 has already permanently deleted as a +// noncurrent version — silently corrupting an in-progress check-in. +// +// There is no single source-of-truth constant shared by the SQL (Postgres) and the S3 +// lifecycle config (docker-compose.yml locally; server/provision-aws in production), so +// this test re-parses both source files at test time rather than hardcoding both sides +// — that way it actually fails loudly if someone changes one without the other, per +// AGENTS.md's testing philosophy ("test failures indicate what went wrong"). +import { assert, assertEquals } from "jsr:@std/assert@1"; + +const repoRoot = new URL("../../../", import.meta.url); // supabase/functions/_shared/ -> repo root + +const readText = async (relativePath: string): Promise => + await Deno.readTextFile(new URL(relativePath, repoRoot)); + +/** Extracts every `INTERVAL ' hours'` used as a checkin/collection-files transaction + * expiry (in the schema's initial DEFAULT and both transaction functions' resume-path + * updates) and asserts they all agree — a mismatch would mean a resumed transaction + * silently gets a different lifetime than a fresh one. */ +Deno.test( + "invariant: transaction expiry intervals are internally consistent (48h everywhere)", + async () => { + const schema = await readText( + "supabase/migrations/20260706000001_tc_schema.sql", + ); + const txFunctions = await readText( + "supabase/migrations/20260706000004_tc_checkin_txn_functions.sql", + ); + + const schemaHours = [ + ...schema.matchAll( + /expires_at\s+timestamptz[^,]*INTERVAL '(\d+) hours'/g, + ), + ].map((m) => Number(m[1])); + const resumeHours = [ + ...txFunctions.matchAll( + /expires_at\s*=\s*now\(\)\s*\+\s*INTERVAL '(\d+) hours'/g, + ), + ].map((m) => Number(m[1])); + + assert( + schemaHours.length >= 1, + "expected to find at least one expires_at DEFAULT INTERVAL in the schema", + ); + assert( + resumeHours.length >= 2, + "expected checkin_start_tx AND collection_files_start_tx resume updates", + ); + + const allHours = [...schemaHours, ...resumeHours]; + for (const h of allHours) { + assertEquals( + h, + allHours[0], + `all transaction-expiry intervals must match; found ${allHours.join(", ")}`, + ); + } + }, +); + +Deno.test( + "invariant: transaction lifetime (48h) is strictly less than the S3 noncurrent-version-expiry floor (7d, dev MinIO)", + async () => { + const schema = await readText( + "supabase/migrations/20260706000001_tc_schema.sql", + ); + const compose = await readText("server/dev/docker-compose.yml"); + + const txHoursMatch = schema.match( + /expires_at\s+timestamptz[^,]*INTERVAL '(\d+) hours'/, + ); + assert( + txHoursMatch, + "could not find the checkin_transactions expires_at default in the schema migration", + ); + const txHours = Number(txHoursMatch[1]); + + const noncurrentDaysMatch = compose.match( + /--noncurrent-expire-days (\d+)/, + ); + assert( + noncurrentDaysMatch, + "could not find --noncurrent-expire-days in server/dev/docker-compose.yml", + ); + const noncurrentDays = Number(noncurrentDaysMatch[1]); + + assert( + txHours < noncurrentDays * 24, + `CONTRACTS.md invariant violated: transaction lifetime (${txHours}h) must be strictly ` + + `less than the noncurrent-version-expiry floor (${noncurrentDays}d = ${noncurrentDays * 24}h) — ` + + `otherwise a version an in-flight transaction still references could be permanently deleted ` + + `out from under it.`, + ); + }, +); diff --git a/supabase/functions/_shared/paths.ts b/supabase/functions/_shared/paths.ts new file mode 100644 index 000000000000..1c5db34c5b10 --- /dev/null +++ b/supabase/functions/_shared/paths.ts @@ -0,0 +1,44 @@ +// Canonical S3 key layout for Cloud Team Collections (CONTRACTS.md "S3 layout"). +// Everything a collection owns lives under tc/{collectionId}/; these helpers are the +// single source of truth for that layout so handlers never hand-template key strings. +// The trailing slashes are load-bearing: the returned values serve both as +// credential-scope prefixes (…/* in the session policy) and as concatenation bases +// for individual object keys. +import { HttpError } from "./errors.ts"; +import { selectTcRow } from "./rpc.ts"; + +/** Root prefix for everything belonging to one collection (books, collectionFiles, + * manifests). download-start scopes its read-only credentials here. */ +export const collectionPrefix = (collectionId: string): string => + `tc/${collectionId}/`; + +/** Prefix for one book's files, keyed by the book's DB-canonical instance id. */ +export const bookPrefix = (collectionId: string, instanceId: string): string => + `${collectionPrefix(collectionId)}books/${instanceId}/`; + +/** Prefix for one collection-files group ('other' | 'allowed-words' | 'sample-texts'). */ +export const collectionFilesPrefix = ( + collectionId: string, + groupKey: string, +): string => `${collectionPrefix(collectionId)}collectionFiles/${groupKey}/`; + +/** Reads back a book row (under RLS, with the caller's own JWT) to learn its + * DB-canonical instance_id, and returns that book's S3 prefix. Throws 404 if the row + * is missing or invisible to the caller. Both checkin-start and checkin-finish must + * scope S3 operations by the DB-canonical instance id, never a caller-supplied one — + * see the security note at checkin-start's call site (Greptile P1, PR #8048). */ +export const resolveBookPrefix = async ( + req: Request, + collectionId: string, + bookId: string, +): Promise => { + const book = await selectTcRow<{ instance_id: string }>( + req, + "books", + `id=eq.${bookId}&select=instance_id`, + ); + if (!book) { + throw new HttpError(404, { error: "book_not_found" }); + } + return bookPrefix(collectionId, book.instance_id); +}; diff --git a/supabase/functions/_shared/rpc.ts b/supabase/functions/_shared/rpc.ts new file mode 100644 index 000000000000..9832afc0cfd3 --- /dev/null +++ b/supabase/functions/_shared/rpc.ts @@ -0,0 +1,101 @@ +// Thin PostgREST client for the `tc` schema. Edge functions are thin orchestrators: +// the heavy per-request DB logic (locking, manifest diffing, atomic multi-table +// writes) lives in SECURITY DEFINER Postgres functions +// (supabase/migrations/20260706000004_tc_checkin_txn_functions.sql) that we call +// here via RPC, ALWAYS forwarding the caller's own Authorization header rather than +// a service-role key — see that migration's header comment for why this is correct +// (PostgREST resolves auth.jwt() from the Authorization bearer token regardless of +// which apikey is presented, and every function re-validates internally). +import { HttpError } from "./errors.ts"; +import { supabaseAnonKey, supabaseUrl } from "./env.ts"; + +/** Extracts the incoming request's bearer token unmodified. Edge functions run with + * verify_jwt = true by default (config.toml), so by the time our code runs the + * platform has already rejected missing/invalid tokens with 401 — this is just for + * forwarding, not for verification. */ +export const authHeader = (req: Request): string => { + const value = req.headers.get("Authorization"); + if (!value) { + // Should not happen given verify_jwt = true, but fail loudly rather than + // silently calling PostgREST unauthenticated if it ever does. + throw new HttpError(401, { error: "unauthenticated" }); + } + return value; +}; + +/** Calls a `tc` schema RPC (POST /rest/v1/rpc/) with the caller's own JWT. + * On a PostgREST error response, unwraps the PT### HTTP-status convention (see the + * migration header comment) and the JSON-encoded `message` field into a proper + * HttpError with the structured contract error body. */ +export const callTcRpc = async ( + req: Request, + fnName: string, + args: Record, +): Promise => { + const res = await fetch(`${supabaseUrl()}/rest/v1/rpc/${fnName}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: supabaseAnonKey(), + Authorization: authHeader(req), + "Content-Profile": "tc", + "Accept-Profile": "tc", + }, + body: JSON.stringify(args), + }); + + const text = await res.text(); + const parsed = text ? JSON.parse(text) : null; + + if (!res.ok) { + throw new HttpError(res.status, parsePostgrestErrorBody(parsed)); + } + + return parsed as T; +}; + +/** Plain PostgREST read (GET /rest/v1/?...) under RLS with the caller's own + * JWT — used where a full RPC round-trip isn't needed (e.g. checkin-finish reading + * back its own open transaction row to learn which paths to verify against S3). */ +export const selectTcRow = async >( + req: Request, + table: string, + query: string, +): Promise => { + const res = await fetch(`${supabaseUrl()}/rest/v1/${table}?${query}`, { + method: "GET", + headers: { + apikey: supabaseAnonKey(), + Authorization: authHeader(req), + "Accept-Profile": "tc", + }, + }); + const text = await res.text(); + const parsed = text ? JSON.parse(text) : []; + if (!res.ok) { + throw new HttpError(res.status, parsePostgrestErrorBody(parsed)); + } + const rows = parsed as T[]; + return rows[0] ?? null; +}; + +/** PostgREST wraps our `RAISE EXCEPTION '%', ` message in + * `{ message, code, details, hint }`. Our SQL always raises a JSON-object message + * (e.g. `{"error":"LockHeldByOther","holder":{...}}`), so unwrap it back into a + * flat contract-shaped body. Falls back gracefully for anything unexpected + * (a Postgres-native error, a constraint violation, etc.) rather than throwing. */ +const parsePostgrestErrorBody = (body: unknown): Record => { + const message = (body as { message?: unknown } | null)?.message; + if (typeof message === "string") { + try { + const inner = JSON.parse(message); + if (inner && typeof inner === "object") { + return inner as Record; + } + } catch { + // Not JSON — fall through to the generic shape below. + } + return { error: message }; + } + return { error: "internal_error", details: body }; +}; diff --git a/supabase/functions/_shared/s3.test.ts b/supabase/functions/_shared/s3.test.ts new file mode 100644 index 000000000000..d461976baabe --- /dev/null +++ b/supabase/functions/_shared/s3.test.ts @@ -0,0 +1,227 @@ +// Unit tests for _shared/s3.ts: the dev-mode MinIO AssumeRole credential seam, checksum +// verification, and the manifest backup write. S3Client/STSClient calls are mocked via +// aws-sdk-client-mock (patches the SDK client prototypes) rather than a live MinIO — +// the live-integration spike (see the task's Progress log) already exercised the real +// MinIO AssumeRole round-trip; these tests cover the wiring/logic cheaply and +// hermetically instead. +import { assertEquals, assertExists } from "jsr:@std/assert@1"; +import { mockClient } from "npm:aws-sdk-client-mock@4"; +import { AssumeRoleCommand, STSClient } from "npm:@aws-sdk/client-sts@3"; +import { + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from "npm:@aws-sdk/client-s3@3"; +import { setTestEnv } from "./test_support.ts"; + +setTestEnv(); + +// deno-lint-ignore no-import-assign +const { + getScopedCredentials, + hexToBase64, + verifyUploadedObject, + writeManifestBackup, +} = await import("./s3.ts"); + +Deno.test( + "hexToBase64 round-trips a known SHA-256 hex digest to its base64 form", + () => { + // sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + const hex = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const b64 = hexToBase64(hex); + // Sanity: decoding the base64 back to bytes and re-hex-encoding must match the input. + const decoded = atob(b64); + const rehexed = Array.from(decoded) + .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""); + assertEquals( + rehexed, + hex, + "hexToBase64 must be a faithful hex->base64 re-encoding, not a no-op", + ); + }, +); + +Deno.test( + "getScopedCredentials (dev mode) calls MinIO AssumeRole and returns the STS response shape", + async () => { + const stsMock = mockClient(STSClient); + stsMock.on(AssumeRoleCommand).resolves({ + Credentials: { + AccessKeyId: "MOCK_ACCESS_KEY", + SecretAccessKey: "MOCK_SECRET", + SessionToken: "MOCK_SESSION_TOKEN", + Expiration: new Date("2026-01-01T01:00:00Z"), + }, + }); + + const result = await getScopedCredentials("tc/col1/books/book1/", [ + "s3:PutObject", + ]); + + assertEquals(result.bucket, "bloom-teams-test"); + assertEquals(result.prefix, "tc/col1/books/book1/"); + assertEquals(result.credentials.accessKeyId, "MOCK_ACCESS_KEY"); + assertEquals(result.credentials.sessionToken, "MOCK_SESSION_TOKEN"); + assertEquals(result.credentials.expiration, "2026-01-01T01:00:00.000Z"); + + // Dev mode must NOT pass a session Policy (see s3.ts's comment: MinIO dev creds get + // the parent identity's full access; scoping is a production-only measure). + const call = stsMock.commandCalls(AssumeRoleCommand)[0]; + assertEquals(call.args[0].input.Policy, undefined); + + stsMock.restore(); + }, +); + +Deno.test( + "getScopedCredentials (dev mode) throws if MinIO AssumeRole returns no credentials", + async () => { + const stsMock = mockClient(STSClient); + stsMock.on(AssumeRoleCommand).resolves({}); // no Credentials field + + let threw = false; + try { + await getScopedCredentials("tc/col1/books/book1/", [ + "s3:PutObject", + ]); + } catch (err) { + threw = true; + assertEquals( + (err as Error).message, + "MinIO AssumeRole did not return credentials", + ); + } + assertEquals( + threw, + true, + "must fail fast rather than return a half-formed credential object", + ); + + stsMock.restore(); + }, +); + +Deno.test( + "verifyUploadedObject returns the version-id + checksum when they match", + async () => { + const s3Mock = mockClient(S3Client); + const expectedHex = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(expectedHex), + VersionId: "v1", + }); + + const client = new S3Client({ region: "us-east-1" }); + const result = await verifyUploadedObject( + client, + "bucket", + "tc/col1/books/book1/book.htm", + expectedHex, + ); + + assertExists(result); + assertEquals(result!.s3VersionId, "v1"); + + s3Mock.restore(); + }, +); + +Deno.test( + "verifyUploadedObject returns null when the stored checksum does not match", + async () => { + const s3Mock = mockClient(S3Client); + const wrongHex = + "0000000000000000000000000000000000000000000000000000000000000000"; + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(wrongHex), + VersionId: "v1", + }); + + const client = new S3Client({ region: "us-east-1" }); + const expectedHex = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const result = await verifyUploadedObject( + client, + "bucket", + "tc/col1/books/book1/book.htm", + expectedHex, + ); + + assertEquals( + result, + null, + "a checksum mismatch must be reported as unverified, not thrown", + ); + + s3Mock.restore(); + }, +); + +Deno.test( + "verifyUploadedObject returns null (not throw) when the object is missing", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).rejects(new Error("NotFound")); + + const client = new S3Client({ region: "us-east-1" }); + const result = await verifyUploadedObject( + client, + "bucket", + "tc/col1/books/book1/missing.htm", + "abc", + ); + + assertEquals( + result, + null, + "a missing object must surface as 'not verified', not an unhandled rejection", + ); + + s3Mock.restore(); + }, +); + +Deno.test( + "verifyUploadedObject returns null when VersionId is absent (unversioned bucket misconfig)", + async () => { + const s3Mock = mockClient(S3Client); + const hex = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + s3Mock + .on(HeadObjectCommand) + .resolves({ ChecksumSHA256: hexToBase64(hex) }); // no VersionId + + const client = new S3Client({ region: "us-east-1" }); + const result = await verifyUploadedObject( + client, + "bucket", + "tc/col1/books/book1/book.htm", + hex, + ); + + assertEquals(result, null); + + s3Mock.restore(); + }, +); + +Deno.test( + "writeManifestBackup never throws even when the PUT fails (best-effort backup)", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(PutObjectCommand).rejects(new Error("simulated S3 outage")); + + const client = new S3Client({ region: "us-east-1" }); + // Must resolve, not reject — checkin-finish's response to the client must not + // depend on this backup write succeeding (see s3.ts's doc comment). + await writeManifestBackup(client, "bucket", "tc/col1/books/book1/", { + some: "manifest", + }); + + s3Mock.restore(); + }, +); diff --git a/supabase/functions/_shared/s3.ts b/supabase/functions/_shared/s3.ts new file mode 100644 index 000000000000..872e248d9c3e --- /dev/null +++ b/supabase/functions/_shared/s3.ts @@ -0,0 +1,299 @@ +// S3 credential-issuance seam (dev MinIO AssumeRole vs. production AWS STS) and the +// small set of admin S3 operations edge functions need (checksum verification, +// version-id capture, .manifest.json writes). Only the functions under +// supabase/functions/** ever construct clients with these credentials — see +// server/dev/DEV-CREDENTIALS.md for the full spec this implements. +import { STSClient, AssumeRoleCommand } from "npm:@aws-sdk/client-sts@3"; +import { + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from "npm:@aws-sdk/client-s3@3"; +import { + adminS3Credentials, + isLocalMode, + minioRootCredentials, + prodBrokerConfig, + s3Env, +} from "./env.ts"; + +export interface ScopedCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + expiration: string; // ISO 8601 +} + +export interface S3Descriptor { + bucket: string; + region: string; + prefix: string; + credentials: ScopedCredentials; +} + +const DEFAULT_DURATION_SECONDS = 3600; + +/** The actions granted to a client for the duration of an upload transaction + * (check-in or collection-files): PUT new/changed content, plus read-back of what's + * already there (e.g. to resume after an interrupted upload). Only enforced in + * production — see getScopedCredentials. */ +export const S3_WRITE_ACTIONS = [ + "s3:PutObject", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", +]; + +/** Builds an IAM-style session policy scoped to one prefix. MinIO's AssumeRole + * accepts a Policy parameter but — per DEV-CREDENTIALS.md — local mode deliberately + * does NOT pass one (prefix scoping is a production security measure; MinIO local + * creds get the parent/root identity's full access). Only used in prod mode. */ +const buildSessionPolicy = ( + bucket: string, + prefix: string, + actions: string[], +): string => + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: actions, + Resource: [`arn:aws:s3:::${bucket}/${prefix}*`], + }, + ], + }); + +/** Issues short-lived, per-request S3 credentials scoped to `prefix`, in the + * IDENTICAL shape whether backed by MinIO (local) or real AWS STS (production) — see + * DEV-CREDENTIALS.md. `actions` is only enforced in production (a real IAM session + * policy); local mode gets full-bucket root-derived temp credentials. */ +export const getScopedCredentials = async ( + prefix: string, + actions: string[], + durationSeconds: number = DEFAULT_DURATION_SECONDS, +): Promise => { + const env = s3Env(); + + if (isLocalMode()) { + const root = minioRootCredentials(); + const sts = new STSClient({ + endpoint: env.endpoint, + region: env.region, + credentials: { + accessKeyId: root.accessKeyId, + secretAccessKey: root.secretAccessKey, + }, + }); + // MinIO's AssumeRole ignores RoleArn/RoleSessionName content but the AWS SDK's + // TS types require them — see DEV-CREDENTIALS.md's "empirical correction": + // local mode MUST mint real MinIO temp creds (fabricated tokens are rejected). + const result = await sts.send( + new AssumeRoleCommand({ + RoleArn: + "arn:aws:iam::000000000000:role/bloom-teams-dev-placeholder", + RoleSessionName: `bloom-dev-${crypto.randomUUID()}`, + DurationSeconds: durationSeconds, + }), + ); + const creds = result.Credentials; + if ( + !creds?.AccessKeyId || + !creds.SecretAccessKey || + !creds.SessionToken + ) { + throw new Error("MinIO AssumeRole did not return credentials"); + } + return { + bucket: env.bucket, + region: env.region, + prefix, + credentials: { + accessKeyId: creds.AccessKeyId, + secretAccessKey: creds.SecretAccessKey, + sessionToken: creds.SessionToken, + expiration: ( + creds.Expiration ?? + new Date(Date.now() + durationSeconds * 1000) + ).toISOString(), + }, + }; + } + + const broker = prodBrokerConfig(); + const sts = new STSClient({ + region: broker.region, + credentials: { + accessKeyId: broker.accessKeyId, + secretAccessKey: broker.secretAccessKey, + }, + }); + const result = await sts.send( + new AssumeRoleCommand({ + RoleArn: broker.roleArn, + RoleSessionName: `bloom-teams-${crypto.randomUUID()}`, + DurationSeconds: durationSeconds, + Policy: buildSessionPolicy(env.bucket, prefix, actions), + }), + ); + const creds = result.Credentials; + if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) { + throw new Error("AWS STS AssumeRole did not return credentials"); + } + return { + bucket: env.bucket, + region: env.region, + prefix, + credentials: { + accessKeyId: creds.AccessKeyId, + secretAccessKey: creds.SecretAccessKey, + sessionToken: creds.SessionToken, + expiration: ( + creds.Expiration ?? + new Date(Date.now() + durationSeconds * 1000) + ).toISOString(), + }, + }; +}; + +/** Admin S3 client for server-side-only operations: HeadObject checksum/version-id + * verification and .manifest.json backup writes. Never handed to a client. */ +export const adminS3Client = (): S3Client => { + const env = s3Env(); + const creds = adminS3Credentials(); + return new S3Client({ + endpoint: env.endpoint, + region: env.region, + forcePathStyle: env.forcePathStyle, + credentials: { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + }, + }); +}; + +export interface VerifiedUpload { + s3VersionId: string; + sha256Base64: string; +} + +/** Base64 <-> hex helpers. S3's x-amz-checksum-sha256 attribute is base64; the + * manifest's `sha256` field (matching the C# client, which uses + * Convert.ToHexString/SHA256) is lowercase hex. */ +export const hexToBase64 = (hex: string): string => { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return btoa(String.fromCharCode(...bytes)); +}; + +/** HeadObject with ChecksumMode ENABLED to read back the stored SHA-256 attribute + * and capture the S3 version-id created by the just-completed PUT. Returns null if + * the object is missing or its checksum doesn't match `expectedSha256Hex`. */ +export const verifyUploadedObject = async ( + client: S3Client, + bucket: string, + key: string, + expectedSha256Hex: string, +): Promise => { + try { + const head = await client.send( + new HeadObjectCommand({ + Bucket: bucket, + Key: key, + ChecksumMode: "ENABLED", + }), + ); + const actual = head.ChecksumSHA256; + const expected = hexToBase64(expectedSha256Hex); + if (!actual || actual !== expected || !head.VersionId) { + return null; + } + return { s3VersionId: head.VersionId, sha256Base64: actual }; + } catch { + // NotFound (or any other S3 error) — treat as "not verified", let the caller + // report it as a missing/bad upload rather than propagating a 5xx. + return null; + } +}; + +export interface CapturedUpload { + path: string; + s3VersionId: string; +} + +/** How many verifyUploadedObject (HeadObject) calls captureVerifiedUploads runs at + * once — enough to hide S3 round-trip latency on a many-file check-in without + * hammering the endpoint with an unbounded burst. */ +const VERIFY_CONCURRENCY = 8; + +/** Verifies each changed path's uploaded object against its proposed sha256 (via + * verifyUploadedObject) and returns the version-id captures for the ones that + * verified, in changedPaths order. A path with no matching proposed file, or whose + * object is missing/mismatched, is simply omitted — the DB-side finish RPC + * independently detects the gap and reports it as 409 MissingOrBadUploads, so no + * error handling is duplicated here. Verification runs with bounded concurrency + * (VERIFY_CONCURRENCY workers) rather than one-at-a-time. */ +export const captureVerifiedUploads = async ( + client: S3Client, + bucket: string, + prefix: string, + changedPaths: string[], + proposedFiles: { path: string; sha256: string }[], +): Promise => { + const proposedByPath = new Map(proposedFiles.map((f) => [f.path, f])); + // Filled by index so the output order matches changedPaths regardless of which + // verification finishes first. + const results: (CapturedUpload | null)[] = new Array( + changedPaths.length, + ).fill(null); + let next = 0; + const worker = async (): Promise => { + while (next < changedPaths.length) { + const i = next++; // safe: no await between the check and the increment + const path = changedPaths[i]; + const proposed = proposedByPath.get(path); + if (!proposed) continue; // defensive; DB-side check still catches this as missing + const verified = await verifyUploadedObject( + client, + bucket, + `${prefix}${path}`, + proposed.sha256, + ); + if (verified) { + results[i] = { path, s3VersionId: verified.s3VersionId }; + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(VERIFY_CONCURRENCY, changedPaths.length) }, + worker, + ), + ); + return results.filter((r): r is CapturedUpload => r !== null); +}; + +/** Best-effort `.manifest.json` backup write (CONTRACTS.md S3 layout). Never + * throws — this is a convenience backup, not the source of truth (that's the DB). */ +export const writeManifestBackup = async ( + client: S3Client, + bucket: string, + prefix: string, + manifest: unknown, +): Promise => { + try { + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: `${prefix}.manifest.json`, + Body: JSON.stringify(manifest, null, 2), + ContentType: "application/json", + }), + ); + } catch (err) { + console.error("writeManifestBackup failed (non-fatal):", err); + } +}; diff --git a/supabase/functions/_shared/test_support.ts b/supabase/functions/_shared/test_support.ts new file mode 100644 index 000000000000..20a6ddf0fdd9 --- /dev/null +++ b/supabase/functions/_shared/test_support.ts @@ -0,0 +1,134 @@ +// Shared helpers for the Deno unit tests under supabase/functions/**. Every edge +// function handler talks to two external systems: PostgREST (via plain `fetch` in +// rpc.ts) and S3/STS (via the AWS SDK clients in s3.ts). This module gives tests a +// cheap way to fake both without a live stack: +// - `withMockFetch` temporarily replaces `globalThis.fetch` for the duration of one +// test, then restores the original — used to fake PostgREST RPC/table responses. +// - `setTestEnv` populates the env vars `_shared/env.ts` requires, so importing a +// handler module never throws "missing required environment variable" at test time. +// - `mockRequest` builds a same-shaped `Request` the handler expects (JSON body + +// bearer token), matching what `serveJsonPost` hands the handler after parsing. +// S3/STS mocking uses `aws-sdk-client-mock` (it patches the SDK client prototypes): +// - `stubAssumeRole` here covers the common case (every *-start handler mints scoped +// credentials via STS AssumeRole); +// - S3 HeadObject/PutObject behavior varies per test, so the *-finish test files +// still call `mockClient(S3Client)` directly. +import { mockClient } from "npm:aws-sdk-client-mock@4"; +import { AssumeRoleCommand, STSClient } from "npm:@aws-sdk/client-sts@3"; + +/** Stubs the AWS SDK's STSClient so any AssumeRole call (MinIO-local or prod-shaped — + * see _shared/s3.ts's getScopedCredentials) returns fixed fake credentials. Callers + * must call `.restore()` on the returned mock when the test is done. */ +export const stubAssumeRole = () => { + const stsMock = mockClient(STSClient); + stsMock.on(AssumeRoleCommand).resolves({ + Credentials: { + AccessKeyId: "K", + SecretAccessKey: "S", + SessionToken: "T", + Expiration: new Date("2026-01-01T01:00:00Z"), + }, + }); + return stsMock; +}; + +/** Sets every env var `_shared/env.ts` reads, with dev-mode-friendly defaults. Call + * this at the top of every test file (module scope) — handlers call `s3Env()` / + * `supabaseUrl()` etc. eagerly inside the request path, not at import time, but it's + * simplest to just always have them present. */ +export const setTestEnv = (): void => { + Deno.env.set("SUPABASE_URL", "http://127.0.0.1:54321"); + Deno.env.set("SUPABASE_ANON_KEY", "test-anon-key"); + Deno.env.set("BLOOM_CLOUD_LOCAL_MODE", "true"); + Deno.env.set("BLOOM_S3_ENDPOINT", "http://minio.invalid:9000"); + Deno.env.set("BLOOM_S3_BUCKET", "bloom-teams-test"); + Deno.env.set("BLOOM_S3_REGION", "us-east-1"); + Deno.env.set("BLOOM_S3_ROOT_ACCESS_KEY", "test-root-key"); + Deno.env.set("BLOOM_S3_ROOT_SECRET_KEY", "test-root-secret"); +}; + +/** Builds a `Request` shaped like what `serveJsonPost` passes to a handler: JSON body, + * bearer auth header. Handlers only read `req.headers`, never re-read the body (that + * was already consumed by `serveJsonPost`), so this is safe to pass directly. */ +export const mockRequest = (body: unknown, token = "test-jwt"): Request => + new Request("http://localhost/test", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + +/** Calls an exported handler the same way `serveJsonPost` (handler.ts) would: a thrown + * `HttpError` becomes its JSON error `Response` instead of an unhandled rejection. + * Handlers are written to throw (see AGENTS.md's fail-fast testing philosophy) and rely + * on `serveJsonPost`'s try/catch to translate that — tests calling the handler directly + * (bypassing serveJsonPost) need the same translation, or every error-path test would + * have to catch HttpError itself. */ +export const callHandler = async ( + handler: (req: Request, body: Record) => Promise, + req: Request, + body: Record, +): Promise => { + // Imported lazily to avoid every test file needing its own import of HttpError. + const { HttpError } = await import("./errors.ts"); + try { + return await handler(req, body); + } catch (err) { + if (err instanceof HttpError) { + return err.toResponse(); + } + throw err; + } +}; + +export type FetchStub = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +/** Replaces `globalThis.fetch` with `stub` for the duration of `fn`, always restoring + * the original afterward (even if `fn` throws) — used to fake PostgREST responses from + * `_shared/rpc.ts`'s `callTcRpc`/`selectTcRow`, which call the real `fetch`. */ +export const withMockFetch = async ( + stub: FetchStub, + fn: () => Promise, +): Promise => { + const original = globalThis.fetch; + // deno-lint-ignore no-explicit-any + globalThis.fetch = stub as any; + try { + return await fn(); + } finally { + globalThis.fetch = original; + } +}; + +/** A `fetch` stub that dispatches by matching a substring against the request URL, in + * order — the first match wins. Each route returns `{ status, body }`; `body` is + * JSON-stringified (or `""` for `null`, matching how PostgREST responds to e.g. a + * successful RPC with no return value). */ +export const routedFetchStub = ( + routes: { when: string; status: number; body: unknown }[], +): FetchStub => { + return (input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const route = routes.find((r) => url.includes(r.when)); + if (!route) { + throw new Error(`routedFetchStub: no route matched for ${url}`); + } + const text = route.body === null ? "" : JSON.stringify(route.body); + return Promise.resolve( + new Response(text, { + status: route.status, + headers: { "Content-Type": "application/json" }, + }), + ); + }; +}; diff --git a/supabase/functions/checkin-abort/index.test.ts b/supabase/functions/checkin-abort/index.test.ts new file mode 100644 index 000000000000..6a02b8c1adc6 --- /dev/null +++ b/supabase/functions/checkin-abort/index.test.ts @@ -0,0 +1,97 @@ +// Unit tests for checkin-abort's handler — the thinnest of the six, so mostly pinning +// down request validation and RPC error/argument passthrough. +import { assertEquals } from "jsr:@std/assert@1"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); + +Deno.test( + "checkin-abort: happy path calls checkin_abort_tx with the transactionId and returns 200 {}", + async () => { + let sentArgs: unknown; + const fetchStub: typeof fetch = (input, init) => { + sentArgs = JSON.parse(String(init?.body)); + return Promise.resolve(new Response("", { status: 200 })); + }; + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 200); + assertEquals(await res.json(), {}); + assertEquals(sentArgs, { p_transaction_id: "tx-1" }); + }, +); + +Deno.test( + "checkin-abort: missing transactionId -> 400 before any RPC call", + async () => { + const fetchStub = routedFetchStub([]); // must not be called + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({}), {}), + ); + + assertEquals(res.status, 400); + const json = await res.json(); + assertEquals(json.error, "invalid_request"); + assertEquals(json.field, "transactionId"); + }, +); + +Deno.test( + "checkin-abort: RPC 409 already_finished passes through", + async () => { + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_abort_tx", + status: 409, + body: { + message: JSON.stringify({ error: "already_finished" }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 409); + assertEquals((await res.json()).error, "already_finished"); + }, +); + +Deno.test( + "checkin-abort: RPC 404 transaction_not_found passes through", + async () => { + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_abort_tx", + status: 404, + body: { + message: JSON.stringify({ error: "transaction_not_found" }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "nope" }), { + transactionId: "nope", + }), + ); + + assertEquals(res.status, 404); + assertEquals((await res.json()).error, "transaction_not_found"); + }, +); diff --git a/supabase/functions/checkin-abort/index.ts b/supabase/functions/checkin-abort/index.ts new file mode 100644 index 000000000000..af42a02a2163 --- /dev/null +++ b/supabase/functions/checkin-abort/index.ts @@ -0,0 +1,24 @@ +// POST /functions/v1/checkin-abort — CONTRACTS.md §checkin-abort +// Req: { transactionId } -> 200. Idempotent; rolls back a never-finished new book. +import { requireField, serveJsonPost } from "../_shared/handler.ts"; +import { jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc } from "../_shared/rpc.ts"; + +// Exported so Deno tests can import and call it directly — see checkin-start/index.ts's +// comment on the `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const transactionId = requireField(body, "transactionId"); + + await callTcRpc(req, "checkin_abort_tx", { + p_transaction_id: transactionId, + }); + + return jsonResponse(200, {}); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/functions/checkin-finish/index.test.ts b/supabase/functions/checkin-finish/index.test.ts new file mode 100644 index 000000000000..915e13c5fb75 --- /dev/null +++ b/supabase/functions/checkin-finish/index.test.ts @@ -0,0 +1,226 @@ +// Unit tests for checkin-finish's handler. PostgREST calls (both the selectTcRow reads +// of checkin_transactions/books and the checkin_finish_tx RPC) are faked via a fetch +// stub; the S3 HeadObject checksum verification is faked via aws-sdk-client-mock. The +// live-integration spike already exercises the real MinIO checksum round-trip; these +// tests pin down the handler's own wiring: which paths get verified, what gets sent to +// the RPC, and error passthrough. +import { assertEquals } from "jsr:@std/assert@1"; +import { mockClient } from "npm:aws-sdk-client-mock@4"; +import { + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from "npm:@aws-sdk/client-s3@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); +const { hexToBase64 } = await import("../_shared/s3.ts"); + +const TX_ROW = { + id: "tx-1", + collection_id: "col-1", + book_id: "book-1", + changed_paths: ["book.htm"], + proposed_files: [ + { + path: "book.htm", + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + size: 0, + }, + ], + status: "open", +}; +const BOOK_ROW = { instance_id: "instance-1" }; + +const routesFor = ( + txRow: unknown, + bookRow: unknown, + finishStatus: number, + finishBody: unknown, +) => + routedFetchStub([ + { + when: "checkin_transactions", + status: txRow ? 200 : 200, + body: txRow ? [txRow] : [], + }, + { + when: "/books?", + status: bookRow ? 200 : 200, + body: bookRow ? [bookRow] : [], + }, + { + when: "rpc/checkin_finish_tx", + status: finishStatus, + body: finishBody, + }, + ]); + +Deno.test( + "checkin-finish: happy path verifies checksum, captures version-id, returns versionId+seq", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(TX_ROW.proposed_files[0].sha256), + VersionId: "v-42", + }); + + const fetchStub = routesFor(TX_ROW, BOOK_ROW, 200, { + versionId: "ver-1", + seq: 3, + }); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals(json.versionId, "ver-1"); + assertEquals(json.seq, 3); + + // The HeadObject must have been issued against the right key (prefix + path). + const headCalls = s3Mock.commandCalls(HeadObjectCommand); + assertEquals(headCalls.length, 1); + assertEquals( + headCalls[0].args[0].input.Key, + "tc/col-1/books/instance-1/book.htm", + ); + + s3Mock.restore(); + }, +); + +Deno.test( + "checkin-finish: unverifiable upload is omitted from `captured` (DB RPC reports MissingOrBadUploads)", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).rejects(new Error("NotFound")); // never uploaded + + let capturedSentToRpc: unknown; + const fetchStub: typeof fetch = (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (url.includes("checkin_transactions")) { + return Promise.resolve( + new Response(JSON.stringify([TX_ROW]), { status: 200 }), + ); + } + if (url.includes("/books?")) { + return Promise.resolve( + new Response(JSON.stringify([BOOK_ROW]), { status: 200 }), + ); + } + if (url.includes("rpc/checkin_finish_tx")) { + capturedSentToRpc = JSON.parse(String(init?.body)).p_captured; + return Promise.resolve( + new Response( + JSON.stringify({ + message: JSON.stringify({ + error: "MissingOrBadUploads", + paths: ["book.htm"], + }), + }), + { status: 409 }, + ), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }; + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 409); + const json = await res.json(); + assertEquals(json.error, "MissingOrBadUploads"); + assertEquals(json.paths, ["book.htm"]); + // The edge function must not have fabricated a captured entry for the failed path — + // it lets the DB-side check (which independently re-verifies) report the gap. + assertEquals( + capturedSentToRpc, + [], + "unverified path must not appear in p_captured", + ); + + s3Mock.restore(); + }, +); + +Deno.test( + "checkin-finish: unknown transactionId -> 404 before any S3 call", + async () => { + const s3Mock = mockClient(S3Client); + const fetchStub = routedFetchStub([ + { when: "checkin_transactions", status: 200, body: [] }, // selectTcRow finds nothing + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "nope" }), { + transactionId: "nope", + }), + ); + + assertEquals(res.status, 404); + const json = await res.json(); + assertEquals(json.error, "transaction_not_found"); + assertEquals(s3Mock.commandCalls(HeadObjectCommand).length, 0); + + s3Mock.restore(); + }, +); + +Deno.test( + "checkin-finish: writes a .manifest.json backup when the RPC returns one, but it never affects the response", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(TX_ROW.proposed_files[0].sha256), + VersionId: "v-1", + }); + s3Mock + .on(PutObjectCommand) + .rejects(new Error("simulated backup-write outage")); + + const fetchStub = routesFor(TX_ROW, BOOK_ROW, 200, { + versionId: "ver-9", + seq: 9, + manifest: [{ path: "book.htm" }], + }); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + // Even though the manifest backup PUT fails, the client-facing response must be + // unaffected (writeManifestBackup is documented best-effort/never-throws). + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals(json.versionId, "ver-9"); + assertEquals( + "manifest" in json, + false, + "the internal `manifest` field must never leak to the client", + ); + + s3Mock.restore(); + }, +); diff --git a/supabase/functions/checkin-finish/index.ts b/supabase/functions/checkin-finish/index.ts new file mode 100644 index 000000000000..7604f3216e8f --- /dev/null +++ b/supabase/functions/checkin-finish/index.ts @@ -0,0 +1,95 @@ +// POST /functions/v1/checkin-finish — CONTRACTS.md §checkin-finish +// +// Req: { transactionId, comment?, keepCheckedOut? } +// Verifies each changed object's sha256 attribute server-side, captures S3 +// version-ids, then commits the single atomic DB transaction (tc.checkin_finish_tx). +// 200: { versionId, seq } · 409 MissingOrBadUploads { paths[] } · 410 expired. +import { + optionalField, + requireField, + serveJsonPost, +} from "../_shared/handler.ts"; +import { HttpError, jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc, selectTcRow } from "../_shared/rpc.ts"; +import { + adminS3Client, + captureVerifiedUploads, + writeManifestBackup, +} from "../_shared/s3.ts"; +import { resolveBookPrefix } from "../_shared/paths.ts"; +import { s3Env } from "../_shared/env.ts"; + +interface CheckinTransactionRow { + id: string; + collection_id: string; + book_id: string; + changed_paths: string[]; + proposed_files: { path: string; sha256: string; size: number }[]; + status: string; +} + +interface CheckinFinishResult { + versionId: string; + seq: number; + manifest?: unknown; +} + +// Exported so Deno tests can import and call it directly with a mocked Request, +// without triggering Deno.serve — see the `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const transactionId = requireField(body, "transactionId"); + const comment = optionalField(body, "comment"); + const keepCheckedOut = Boolean(body["keepCheckedOut"]); + + // Read back our own open transaction (RLS restricts this to rows we started) so + // we know which S3 objects to verify — checkin-finish's request body carries no + // file list per CONTRACTS.md. + const tx = await selectTcRow( + req, + "checkin_transactions", + `id=eq.${transactionId}&select=id,collection_id,book_id,changed_paths,proposed_files,status`, + ); + if (!tx) { + throw new HttpError(404, { error: "transaction_not_found" }); + } + + const prefix = await resolveBookPrefix(req, tx.collection_id, tx.book_id); + const { bucket } = s3Env(); + const client = adminS3Client(); + + // Verify every changed path against S3; anything that fails is simply omitted + // from `captured` — tc.checkin_finish_tx independently detects and reports the + // gap as 409 MissingOrBadUploads, so there is no duplicated logic here. + const captured = await captureVerifiedUploads( + client, + bucket, + prefix, + tx.changed_paths, + tx.proposed_files, + ); + + const result = await callTcRpc( + req, + "checkin_finish_tx", + { + p_transaction_id: transactionId, + p_comment: comment, + p_keep_checked_out: keepCheckedOut, + p_captured: captured, + }, + ); + + if (result.manifest) { + // Best-effort backup; never blocks the response (see writeManifestBackup). + await writeManifestBackup(client, bucket, prefix, result.manifest); + } + + return jsonResponse(200, { versionId: result.versionId, seq: result.seq }); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/functions/checkin-start/index.test.ts b/supabase/functions/checkin-start/index.test.ts new file mode 100644 index 000000000000..fde4f4bb110c --- /dev/null +++ b/supabase/functions/checkin-start/index.test.ts @@ -0,0 +1,233 @@ +// Unit tests for checkin-start's handler: PostgREST RPC calls are faked via a fetch +// stub (see _shared/test_support.ts); the MinIO/STS AssumeRole call is faked via +// aws-sdk-client-mock. The live-integration spike (task's Progress log) already +// exercises the real stack end-to-end; these tests pin down the handler's own request +// validation, RPC-argument wiring, and error passthrough cheaply and hermetically. +import { assertEquals } from "jsr:@std/assert@1"; +import { AssumeRoleCommand } from "npm:@aws-sdk/client-sts@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + stubAssumeRole, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); + +const VALID_BODY = { + collectionId: "11111111-1111-1111-1111-111111111111", + bookId: null, + bookInstanceId: "22222222-2222-2222-2222-222222222222", + proposedName: "My Book", + checksum: "abc123", + clientVersion: "1.0.0", + files: [{ path: "book.htm", sha256: "deadbeef", size: 42 }], +}; + +Deno.test( + "checkin-start: happy path returns transactionId, changedPaths and scoped s3 creds", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_start_tx", + status: 200, + body: { + transactionId: "tx-1", + bookId: "book-1", + changedPaths: ["book.htm"], + }, + }, + { + when: "rest/v1/books", + status: 200, + body: [{ instance_id: "22222222-2222-2222-2222-222222222222" }], + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(VALID_BODY), VALID_BODY), + ); + + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals(json.transactionId, "tx-1"); + assertEquals(json.changedPaths, ["book.htm"]); + assertEquals(json.s3.bucket, "bloom-teams-test"); + // CONTRACTS.md: creds scoped to tc/{cid}/books/{instance_id}/* — the instance id read + // back from the books row, not the request body (see the mismatch test below). + assertEquals( + json.s3.prefix, + "tc/11111111-1111-1111-1111-111111111111/books/22222222-2222-2222-2222-222222222222/", + ); + assertEquals(json.s3.credentials.sessionToken, "T"); + // bookId is internal-only — CONTRACTS.md's 200 response never exposes it (that's + // what makes an uncommitted new book invisible until the client re-learns its id + // via get_collection_state/checkout_book). + assertEquals("bookId" in json, false); + + stsMock.restore(); + }, +); + +Deno.test( + "checkin-start: s3 prefix comes from the DB-canonical instance_id, not the caller-supplied bookInstanceId", + async () => { + const stsMock = stubAssumeRole(); + // The caller claims an instance id belonging to some OTHER book; the books row for the + // book actually being checked in has a different (canonical) instance id. The issued + // credentials must be scoped to the canonical one. + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_start_tx", + status: 200, + body: { + transactionId: "tx-1", + bookId: "book-1", + changedPaths: ["book.htm"], + }, + }, + { + when: "rest/v1/books", + status: 200, + body: [{ instance_id: "99999999-9999-9999-9999-999999999999" }], + }, + ]); + const bodyWithForeignInstanceId = { + ...VALID_BODY, + bookId: "book-1", + bookInstanceId: "22222222-2222-2222-2222-222222222222", + }; + + const res = await withMockFetch(fetchStub, () => + callHandler( + handler, + mockRequest(bodyWithForeignInstanceId), + bodyWithForeignInstanceId, + ), + ); + + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals( + json.s3.prefix, + "tc/11111111-1111-1111-1111-111111111111/books/99999999-9999-9999-9999-999999999999/", + ); + + stsMock.restore(); + }, +); + +Deno.test( + "checkin-start: missing required field -> 400 before any RPC/S3 call", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([]); // must not be called + + const { checksum: _omit, ...bodyMissingChecksum } = VALID_BODY; + const res = await withMockFetch(fetchStub, () => + callHandler( + handler, + mockRequest(bodyMissingChecksum), + bodyMissingChecksum, + ), + ); + + assertEquals(res.status, 400); + const json = await res.json(); + assertEquals(json.error, "invalid_request"); + assertEquals(json.field, "checksum"); + assertEquals( + stsMock.commandCalls(AssumeRoleCommand).length, + 0, + "must fail validation before touching S3", + ); + + stsMock.restore(); + }, +); + +Deno.test( + "checkin-start: RPC 409 LockHeldByOther passes through with the holder payload intact", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_start_tx", + status: 409, + // PostgREST wraps our RAISE EXCEPTION message like this — see rpc.ts's + // parsePostgrestErrorBody, which unwraps it back to the flat contract shape. + body: { + message: JSON.stringify({ + error: "LockHeldByOther", + holder: { userId: "u2" }, + }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(VALID_BODY), VALID_BODY), + ); + + assertEquals(res.status, 409); + const json = await res.json(); + assertEquals(json.error, "LockHeldByOther"); + assertEquals(json.holder.userId, "u2"); + assertEquals( + stsMock.commandCalls(AssumeRoleCommand).length, + 0, + "must not issue S3 creds when the RPC itself failed", + ); + + stsMock.restore(); + }, +); + +Deno.test("checkin-start: RPC 426 ClientOutOfDate passes through", async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_start_tx", + status: 426, + body: { + message: JSON.stringify({ + error: "ClientOutOfDate", + minVersion: "2.0.0", + }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(VALID_BODY), VALID_BODY), + ); + + assertEquals(res.status, 426); + const json = await res.json(); + assertEquals(json.error, "ClientOutOfDate"); + assertEquals(json.minVersion, "2.0.0"); + + stsMock.restore(); +}); + +Deno.test( + "checkin-start: missing Authorization header -> 401 (defensive; platform normally rejects first)", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([]); + + const reqNoAuth = new Request("http://localhost/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(VALID_BODY), + }); + const res = await callHandler(handler, reqNoAuth, VALID_BODY); + + assertEquals(res.status, 401); + stsMock.restore(); + }, +); diff --git a/supabase/functions/checkin-start/index.ts b/supabase/functions/checkin-start/index.ts new file mode 100644 index 000000000000..576ef0fe980d --- /dev/null +++ b/supabase/functions/checkin-start/index.ts @@ -0,0 +1,73 @@ +// POST /functions/v1/checkin-start — CONTRACTS.md §checkin-start +// +// Req: { collectionId, bookId?, bookInstanceId, proposedName, baseVersionId?, +// checksum, clientVersion, files: [{path, sha256, size}] } +// 200: { transactionId, changedPaths[], s3: { bucket, region, prefix, credentials } } +// Errors: 401/403 · 409 LockHeldByOther/BaseVersionSuperseded/NameConflict · 426 ClientOutOfDate. +import { + optionalField, + requireField, + serveJsonPost, +} from "../_shared/handler.ts"; +import { jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc } from "../_shared/rpc.ts"; +import { getScopedCredentials, S3_WRITE_ACTIONS } from "../_shared/s3.ts"; +import { resolveBookPrefix } from "../_shared/paths.ts"; + +interface CheckinStartResult { + transactionId: string; + bookId: string; + changedPaths: string[]; +} + +// Exported (rather than only passed inline to serveJsonPost) so Deno tests can import +// and call it directly with a mocked Request, without triggering Deno.serve — see the +// `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const collectionId = requireField(body, "collectionId"); + const bookInstanceId = requireField(body, "bookInstanceId"); + const proposedName = requireField(body, "proposedName"); + const checksum = requireField(body, "checksum"); + const clientVersion = requireField(body, "clientVersion"); + const files = requireField(body, "files"); + const bookId = optionalField(body, "bookId"); + const baseVersionId = optionalField(body, "baseVersionId"); + + const result = await callTcRpc( + req, + "checkin_start_tx", + { + p_collection_id: collectionId, + p_book_id: bookId, + p_book_instance_id: bookInstanceId, + p_proposed_name: proposedName, + p_base_version_id: baseVersionId, + p_checksum: checksum, + p_client_version: clientVersion, + p_files: files, + }, + ); + + // Scope the S3 credentials to the DB-canonical instance_id, never the caller-supplied + // bookInstanceId: for an existing book checkin_start_tx validates/locks by bookId and + // ignores the client's instance id, so using the client value here would let a member + // request write credentials for an arbitrary book's prefix (Greptile P1, PR #8048). + // resolveBookPrefix reads the canonical value back from the books row (same pattern + // as checkin-finish); for the new-book path the row was just created from + // bookInstanceId, so the canonical value is identical. + const prefix = await resolveBookPrefix(req, collectionId, result.bookId); + const s3 = await getScopedCredentials(prefix, S3_WRITE_ACTIONS); + + return jsonResponse(200, { + transactionId: result.transactionId, + changedPaths: result.changedPaths, + s3, + }); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/functions/collection-files-finish/index.test.ts b/supabase/functions/collection-files-finish/index.test.ts new file mode 100644 index 000000000000..7d8c1f0b9d38 --- /dev/null +++ b/supabase/functions/collection-files-finish/index.test.ts @@ -0,0 +1,136 @@ +// Unit tests for collection-files-finish's handler — same shape as checkin-finish but +// scoped to a collection_file_transactions row instead of a book. +import { assertEquals } from "jsr:@std/assert@1"; +import { mockClient } from "npm:aws-sdk-client-mock@4"; +import { HeadObjectCommand, S3Client } from "npm:@aws-sdk/client-s3@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); +const { hexToBase64 } = await import("../_shared/s3.ts"); + +const TX_ROW = { + id: "tx-1", + collection_id: "col-1", + group_key: "allowed-words", + changed_paths: ["allowed.txt"], + proposed_files: [ + { + path: "allowed.txt", + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + size: 0, + }, + ], +}; + +Deno.test( + "collection-files-finish: happy path verifies checksum under collectionFiles/{groupKey}/ and returns version", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(TX_ROW.proposed_files[0].sha256), + VersionId: "v-1", + }); + + const fetchStub = routedFetchStub([ + { + when: "collection_file_transactions", + status: 200, + body: [TX_ROW], + }, + { + when: "rpc/collection_files_finish_tx", + status: 200, + body: { version: 4 }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 200); + assertEquals((await res.json()).version, 4); + + const headCalls = s3Mock.commandCalls(HeadObjectCommand); + assertEquals(headCalls.length, 1); + assertEquals( + headCalls[0].args[0].input.Key, + "tc/col-1/collectionFiles/allowed-words/allowed.txt", + ); + + s3Mock.restore(); + }, +); + +Deno.test( + "collection-files-finish: RPC 409 VersionConflict at finish time (repo-wins) passes through", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(TX_ROW.proposed_files[0].sha256), + VersionId: "v-1", + }); + + const fetchStub = routedFetchStub([ + { + when: "collection_file_transactions", + status: 200, + body: [TX_ROW], + }, + { + when: "rpc/collection_files_finish_tx", + status: 409, + body: { + message: JSON.stringify({ + error: "VersionConflict", + currentVersion: 7, + }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 409); + const json = await res.json(); + assertEquals(json.error, "VersionConflict"); + assertEquals(json.currentVersion, 7); + + s3Mock.restore(); + }, +); + +Deno.test( + "collection-files-finish: unknown transactionId -> 404 before any S3 call", + async () => { + const s3Mock = mockClient(S3Client); + const fetchStub = routedFetchStub([ + { when: "collection_file_transactions", status: 200, body: [] }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "nope" }), { + transactionId: "nope", + }), + ); + + assertEquals(res.status, 404); + assertEquals((await res.json()).error, "transaction_not_found"); + assertEquals(s3Mock.commandCalls(HeadObjectCommand).length, 0); + + s3Mock.restore(); + }, +); diff --git a/supabase/functions/collection-files-finish/index.ts b/supabase/functions/collection-files-finish/index.ts new file mode 100644 index 000000000000..aca9900933a7 --- /dev/null +++ b/supabase/functions/collection-files-finish/index.ts @@ -0,0 +1,76 @@ +// POST /functions/v1/collection-files-finish — CONTRACTS.md §collection-files-start/finish +// Req: { transactionId } -> bumps the group version atomically. +// 409 VersionConflict ⇒ client pulls first (repo-wins rule); 409 MissingOrBadUploads. +import { requireField, serveJsonPost } from "../_shared/handler.ts"; +import { HttpError, jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc, selectTcRow } from "../_shared/rpc.ts"; +import { + adminS3Client, + captureVerifiedUploads, + writeManifestBackup, +} from "../_shared/s3.ts"; +import { collectionFilesPrefix } from "../_shared/paths.ts"; +import { s3Env } from "../_shared/env.ts"; + +interface CollectionFileTransactionRow { + id: string; + collection_id: string; + group_key: string; + changed_paths: string[]; + proposed_files: { path: string; sha256: string; size: number }[]; +} + +interface CollectionFilesFinishResult { + version: number; + manifest?: unknown; +} + +// Exported so Deno tests can import and call it directly — see checkin-start/index.ts's +// comment on the `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const transactionId = requireField(body, "transactionId"); + + const tx = await selectTcRow( + req, + "collection_file_transactions", + `id=eq.${transactionId}&select=id,collection_id,group_key,changed_paths,proposed_files`, + ); + if (!tx) { + throw new HttpError(404, { error: "transaction_not_found" }); + } + + const prefix = collectionFilesPrefix(tx.collection_id, tx.group_key); + const { bucket } = s3Env(); + const client = adminS3Client(); + + // Same skip-unverified semantics as checkin-finish — see captureVerifiedUploads. + const captured = await captureVerifiedUploads( + client, + bucket, + prefix, + tx.changed_paths, + tx.proposed_files, + ); + + const result = await callTcRpc( + req, + "collection_files_finish_tx", + { + p_transaction_id: transactionId, + p_captured: captured, + }, + ); + + if (result.manifest) { + await writeManifestBackup(client, bucket, prefix, result.manifest); + } + + return jsonResponse(200, { version: result.version }); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/functions/collection-files-start/index.test.ts b/supabase/functions/collection-files-start/index.test.ts new file mode 100644 index 000000000000..b163473d62f7 --- /dev/null +++ b/supabase/functions/collection-files-start/index.test.ts @@ -0,0 +1,101 @@ +// Unit tests for collection-files-start's handler: groupKey validation, the +// optimistic-version RPC call, and scoped S3 credential issuance. +import { assertEquals } from "jsr:@std/assert@1"; +import { AssumeRoleCommand } from "npm:@aws-sdk/client-sts@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + stubAssumeRole, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); + +const VALID_BODY = { + collectionId: "col-1", + groupKey: "allowed-words", + expectedVersion: 0, + files: [{ path: "allowed.txt", sha256: "abc", size: 3 }], +}; + +Deno.test( + "collection-files-start: happy path scopes creds under collectionFiles/{groupKey}/", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/collection_files_start_tx", + status: 200, + body: { transactionId: "tx-1", changedPaths: ["allowed.txt"] }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(VALID_BODY), VALID_BODY), + ); + + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals(json.transactionId, "tx-1"); + assertEquals(json.s3.prefix, "tc/col-1/collectionFiles/allowed-words/"); + + stsMock.restore(); + }, +); + +Deno.test( + "collection-files-start: invalid groupKey -> 400 before any RPC/S3 call", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([]); + const badBody = { ...VALID_BODY, groupKey: "not-a-real-group" }; + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(badBody), badBody), + ); + + assertEquals(res.status, 400); + assertEquals((await res.json()).field, "groupKey"); + assertEquals(stsMock.commandCalls(AssumeRoleCommand).length, 0); + + stsMock.restore(); + }, +); + +Deno.test( + "collection-files-start: RPC 409 VersionConflict passes through with currentVersion", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/collection_files_start_tx", + status: 409, + body: { + message: JSON.stringify({ + error: "VersionConflict", + currentVersion: 5, + }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(VALID_BODY), VALID_BODY), + ); + + assertEquals(res.status, 409); + const json = await res.json(); + assertEquals(json.error, "VersionConflict"); + assertEquals(json.currentVersion, 5); + assertEquals( + stsMock.commandCalls(AssumeRoleCommand).length, + 0, + "must not issue creds on a version conflict", + ); + + stsMock.restore(); + }, +); diff --git a/supabase/functions/collection-files-start/index.ts b/supabase/functions/collection-files-start/index.ts new file mode 100644 index 000000000000..d91a96c6cbc0 --- /dev/null +++ b/supabase/functions/collection-files-start/index.ts @@ -0,0 +1,59 @@ +// POST /functions/v1/collection-files-start — CONTRACTS.md §collection-files-start/finish +// Req: { collectionId, groupKey: 'other'|'allowed-words'|'sample-texts', expectedVersion, +// files[] } -> two-phase like check-in. +// 409 VersionConflict ⇒ client pulls first (repo-wins rule). +import { requireField, serveJsonPost } from "../_shared/handler.ts"; +import { HttpError, jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc } from "../_shared/rpc.ts"; +import { getScopedCredentials, S3_WRITE_ACTIONS } from "../_shared/s3.ts"; +import { collectionFilesPrefix } from "../_shared/paths.ts"; + +const VALID_GROUP_KEYS = new Set(["other", "allowed-words", "sample-texts"]); + +interface CollectionFilesStartResult { + transactionId: string; + changedPaths: string[]; +} + +// Exported so Deno tests can import and call it directly — see checkin-start/index.ts's +// comment on the `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const collectionId = requireField(body, "collectionId"); + const groupKey = requireField(body, "groupKey"); + const expectedVersion = requireField(body, "expectedVersion"); + const files = requireField(body, "files"); + + if (!VALID_GROUP_KEYS.has(groupKey)) { + throw new HttpError(400, { + error: "invalid_request", + field: "groupKey", + }); + } + + const result = await callTcRpc( + req, + "collection_files_start_tx", + { + p_collection_id: collectionId, + p_group_key: groupKey, + p_expected_version: expectedVersion, + p_files: files, + }, + ); + + const prefix = collectionFilesPrefix(collectionId, groupKey); + const s3 = await getScopedCredentials(prefix, S3_WRITE_ACTIONS); + + return jsonResponse(200, { + transactionId: result.transactionId, + changedPaths: result.changedPaths, + s3, + }); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/functions/download-start/index.test.ts b/supabase/functions/download-start/index.test.ts new file mode 100644 index 000000000000..2c957bddcf12 --- /dev/null +++ b/supabase/functions/download-start/index.test.ts @@ -0,0 +1,82 @@ +// Unit tests for download-start's handler: membership check via RPC, then read-only +// scoped S3 credentials (GetObject + GetObjectVersion only — see CONTRACTS.md). +import { assertEquals } from "jsr:@std/assert@1"; +import { AssumeRoleCommand } from "npm:@aws-sdk/client-sts@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + stubAssumeRole, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); + +Deno.test( + "download-start: happy path returns collection-scoped read-only creds", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { when: "rpc/download_start_check", status: 200, body: null }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ collectionId: "col-1" }), { + collectionId: "col-1", + }), + ); + + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals(json.s3.prefix, "tc/col-1/"); + assertEquals(json.s3.credentials.sessionToken, "T"); + + stsMock.restore(); + }, +); + +Deno.test( + "download-start: not a member -> 403, no S3 credentials issued", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/download_start_check", + status: 403, + body: { message: JSON.stringify({ error: "not_a_member" }) }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ collectionId: "col-1" }), { + collectionId: "col-1", + }), + ); + + assertEquals(res.status, 403); + assertEquals((await res.json()).error, "not_a_member"); + assertEquals( + stsMock.commandCalls(AssumeRoleCommand).length, + 0, + "must not issue creds when membership check fails", + ); + + stsMock.restore(); + }, +); + +Deno.test("download-start: missing collectionId -> 400", async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({}), {}), + ); + + assertEquals(res.status, 400); + assertEquals((await res.json()).field, "collectionId"); + + stsMock.restore(); +}); diff --git a/supabase/functions/download-start/index.ts b/supabase/functions/download-start/index.ts new file mode 100644 index 000000000000..14572e5e97c6 --- /dev/null +++ b/supabase/functions/download-start/index.ts @@ -0,0 +1,32 @@ +// POST /functions/v1/download-start — CONTRACTS.md §download-start +// Req: { collectionId } -> 200 { s3: {...} } read-only creds +// (GetObject + GetObjectVersion) scoped tc/{cid}/*, 1h. +import { requireField, serveJsonPost } from "../_shared/handler.ts"; +import { jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc } from "../_shared/rpc.ts"; +import { getScopedCredentials } from "../_shared/s3.ts"; +import { collectionPrefix } from "../_shared/paths.ts"; + +const DOWNLOAD_ACTIONS = ["s3:GetObject", "s3:GetObjectVersion"]; + +// Exported so Deno tests can import and call it directly — see checkin-start/index.ts's +// comment on the `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const collectionId = requireField(body, "collectionId"); + + await callTcRpc(req, "download_start_check", { + p_collection_id: collectionId, + }); + + const prefix = collectionPrefix(collectionId); + const s3 = await getScopedCredentials(prefix, DOWNLOAD_ACTIONS); + + return jsonResponse(200, { s3 }); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/migrations/20260706000001_tc_schema.sql b/supabase/migrations/20260706000001_tc_schema.sql new file mode 100644 index 000000000000..bc0935f07d31 --- /dev/null +++ b/supabase/migrations/20260706000001_tc_schema.sql @@ -0,0 +1,557 @@ +-- ============================================================================= +-- Migration: tc schema + core tables +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- Creates the `tc` schema and all persistent tables: +-- collections, members, books, versions, version_files, +-- collection_file_groups, collection_group_files, +-- color_palette_entries, events, checkin_transactions +-- +-- Design references: +-- Design/CloudTeamCollections.md +-- Design/CloudTeamCollections/CONTRACTS.md +-- Design/CloudTeamCollections/tasks/01-server-schema.md +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- Schema +-- --------------------------------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS tc; + +-- Expose tc in the search path for PostgREST (config.toml also sets extra_search_path). +-- Functions are defined in the tc schema explicitly; no implicit search-path dependency. + +-- --------------------------------------------------------------------------- +-- Helper: JWT email-verified check +-- --------------------------------------------------------------------------- +-- THIS IS THE ONE PLACE that reads email-verification off the JWT. +-- Two auth shapes are supported: +-- 1. Firebase ID token (real production): carries `email_verified` boolean in the JWT. +-- 2. Local GoTrue (dev stack, task 11): auto-confirm is ON so every login is confirmed; +-- GoTrue does NOT put `email_verified` in the JWT, but sets `aud = 'authenticated'` +-- and the user record is confirmed. We treat any local-GoTrue user as verified. +-- +-- Callers (claim_memberships, etc.) MUST call this function instead of reading the claim +-- directly so that a future auth change only needs to touch this one function. +CREATE OR REPLACE FUNCTION tc.jwt_email_verified() +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT + CASE + -- Firebase-style: explicit boolean claim (may arrive as 'true'::text or true::bool) + WHEN (auth.jwt() ->> 'email_verified') IS NOT NULL THEN + (auth.jwt() ->> 'email_verified')::boolean + -- Local GoTrue (dev): no email_verified claim; role = 'authenticated' implies confirmed + WHEN (auth.jwt() ->> 'role') = 'authenticated' THEN + TRUE + ELSE + FALSE + END +$$; + +COMMENT ON FUNCTION tc.jwt_email_verified() IS + 'The ONLY place that decides whether the caller''s email is verified. ' + 'Handles both a Firebase-style email_verified JWT claim and local-GoTrue auto-confirmed ' + 'users (dev stack). All callers must use this function, never the claim directly.'; + +-- --------------------------------------------------------------------------- +-- Helper: current user id from JWT sub claim (TEXT, not uuid) +-- --------------------------------------------------------------------------- +-- User ids are TEXT: Firebase UIDs are ~28 base64 chars; local-GoTrue issues uuid strings. +-- Both fit in TEXT without casting. +CREATE OR REPLACE FUNCTION tc.current_user_id() +RETURNS text +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT auth.jwt() ->> 'sub' +$$; + +COMMENT ON FUNCTION tc.current_user_id() IS + 'Returns the caller''s user id from the JWT sub claim as TEXT. ' + 'Firebase UIDs (~28 chars) and local-GoTrue UUIDs both fit in TEXT.'; + +-- --------------------------------------------------------------------------- +-- Helper: current user email from JWT +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.current_user_email() +RETURNS text +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT lower(auth.jwt() ->> 'email') +$$; + +COMMENT ON FUNCTION tc.current_user_email() IS + 'Returns the caller''s email from the JWT, lowercased for case-insensitive comparison.'; + +-- --------------------------------------------------------------------------- +-- collections +-- --------------------------------------------------------------------------- +-- One row per cloud Team Collection. The id is the Bloom CollectionId GUID +-- (same value in TeamCollectionLink.txt: cloud://sil.bloom/collection/). +CREATE TABLE tc.collections ( + id uuid PRIMARY KEY, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + created_by text NOT NULL -- user_id (TEXT; see note above) +); + +COMMENT ON TABLE tc.collections IS + 'One row per cloud Team Collection. id = Bloom CollectionId GUID.'; +COMMENT ON COLUMN tc.collections.id IS + 'The collection UUID — same value as in TeamCollectionLink.txt ' + '(cloud://sil.bloom/collection/).'; +COMMENT ON COLUMN tc.collections.created_by IS + 'TEXT user id (Firebase UID or GoTrue UUID) of the creator.'; + +-- --------------------------------------------------------------------------- +-- members (approved-accounts table) +-- --------------------------------------------------------------------------- +-- Each row is one approved email ↔ collection binding. +-- user_id is NULL until the account holder claims the seat (see claim_memberships RPC). +-- Constraints: +-- • A claimed user may appear at most once per collection (UNIQUE WHERE user_id IS NOT NULL). +-- • At least one admin must remain (enforced by last-admin guard trigger). +CREATE TYPE tc.member_role AS ENUM ('admin', 'member'); + +CREATE TABLE tc.members ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + email text NOT NULL, -- the approved email (lowercase, NFC-normalized on insert) + role tc.member_role NOT NULL DEFAULT 'member', + user_id text, -- NULL until claimed; TEXT (Firebase UID / GoTrue UUID) + added_by text NOT NULL, -- user_id of the admin who added this row + added_at timestamptz NOT NULL DEFAULT now(), + claimed_at timestamptz, + + -- Unique approved email per collection (case-insensitive; enforced via lower() on insert) + CONSTRAINT members_collection_email_uq UNIQUE (collection_id, email), + + -- A claimed user appears at most once per collection + CONSTRAINT members_claimed_user_uq UNIQUE NULLS NOT DISTINCT (collection_id, user_id) +); + +COMMENT ON TABLE tc.members IS + 'Approved-accounts table. Unclaimed rows (user_id IS NULL) are pending until the ' + 'account holder signs in and calls claim_memberships(). ' + 'email is stored lowercase + NFC-normalised.'; +COMMENT ON COLUMN tc.members.user_id IS + 'NULL until the account holder claims the seat. TEXT covers both Firebase UIDs and ' + 'local-GoTrue UUIDs.'; + +CREATE INDEX members_collection_id_idx ON tc.members(collection_id); +CREATE INDEX members_user_id_idx ON tc.members(user_id) WHERE user_id IS NOT NULL; +CREATE INDEX members_email_idx ON tc.members(lower(email)); + +-- --------------------------------------------------------------------------- +-- Trigger: last-admin guard on members +-- --------------------------------------------------------------------------- +-- Prevents the last admin of a collection from being removed or demoted to member. +CREATE OR REPLACE FUNCTION tc.members_last_admin_guard() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + admin_count integer; +BEGIN + -- Fires on DELETE or UPDATE (role change to 'member' / user_id removal) + -- Determine the collection_id being affected + IF TG_OP = 'DELETE' THEN + -- Count remaining admins after this hypothetical delete + SELECT count(*) INTO admin_count + FROM tc.members + WHERE collection_id = OLD.collection_id + AND role = 'admin' + AND id != OLD.id; + + IF admin_count = 0 AND OLD.role = 'admin' THEN + RAISE EXCEPTION 'last_admin_guard: cannot remove the last admin of collection %', + OLD.collection_id + USING ERRCODE = 'P0001'; + END IF; + ELSIF TG_OP = 'UPDATE' THEN + -- Fires when role changes from admin → member + IF OLD.role = 'admin' AND NEW.role = 'member' THEN + SELECT count(*) INTO admin_count + FROM tc.members + WHERE collection_id = OLD.collection_id + AND role = 'admin' + AND id != OLD.id; + + IF admin_count = 0 THEN + RAISE EXCEPTION 'last_admin_guard: cannot demote the last admin of collection %', + OLD.collection_id + USING ERRCODE = 'P0001'; + END IF; + END IF; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$; + +COMMENT ON FUNCTION tc.members_last_admin_guard() IS + 'Trigger function: prevents deleting or demoting the last admin of a collection.'; + +CREATE TRIGGER members_last_admin_guard_tg + BEFORE DELETE OR UPDATE ON tc.members + FOR EACH ROW EXECUTE FUNCTION tc.members_last_admin_guard(); + +-- --------------------------------------------------------------------------- +-- books +-- --------------------------------------------------------------------------- +-- Authoritative server-side state for each book in a collection. +-- Lock columns: locked_by (TEXT user_id), locked_by_machine, locked_at. +-- deleted_at: soft tombstone (set by delete_book, cleared by undelete_book). +-- instance_id: client-generated UUID (stable across renames; keyed in S3). +-- +-- Uniqueness constraints: +-- 1. (collection_id, instance_id) — one S3 prefix per book. +-- 2. Live lower(NFC-normalized name) per collection (tombstoned names reusable). +CREATE TABLE tc.books ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + instance_id uuid NOT NULL, + name text NOT NULL, -- NFC-normalized on insert/update via trigger + current_version_id uuid, -- FK to tc.versions, set after first checkin-finish + current_version_seq bigint, -- denormalised seq for quick status reads + current_checksum text, -- SHA-256 of the current version manifest + locked_by text, -- TEXT user_id; NULL = not checked out + locked_by_machine text, -- machine label provided by the client + locked_at timestamptz, + deleted_at timestamptz, -- tombstone; NULL = live + created_at timestamptz NOT NULL DEFAULT now(), + created_by text NOT NULL, + + -- Physical uniqueness: one S3 prefix per book in a collection + CONSTRAINT books_collection_instance_uq UNIQUE (collection_id, instance_id) +); + +COMMENT ON TABLE tc.books IS + 'Authoritative book state per collection. Lock columns, soft tombstone, ' + 'current-version denormalization. All state transitions go through RPCs/edge functions; ' + 'no direct writes via PostgREST.'; +COMMENT ON COLUMN tc.books.instance_id IS + 'Client-generated UUID stable across renames. Used as the S3 prefix key ' + '(tc/{cid}/books/{instance_id}/).'; +COMMENT ON COLUMN tc.books.name IS + 'NFC-normalized on write by the nfc_normalize_book_name trigger. ' + 'Live-name uniqueness (lower(name)) is enforced by a partial unique index.'; +COMMENT ON COLUMN tc.books.deleted_at IS + 'Soft tombstone: non-NULL = deleted. Tombstoned names are reusable (excluded from the ' + 'live-name uniqueness index).'; + +CREATE INDEX books_collection_id_idx ON tc.books(collection_id); +CREATE INDEX books_locked_by_idx ON tc.books(locked_by) WHERE locked_by IS NOT NULL; +CREATE INDEX books_instance_id_idx ON tc.books(instance_id); + +-- Partial unique index: enforce lower(NFC-normalized name) uniqueness among live books. +-- Tombstoned (deleted_at IS NOT NULL) names are excluded → reusable after deletion. +CREATE UNIQUE INDEX books_live_name_uq + ON tc.books (collection_id, lower(normalize(name, NFC))) + WHERE deleted_at IS NULL; + +-- --------------------------------------------------------------------------- +-- Trigger: NFC-normalize book names on insert/update +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.nfc_normalize_book_name() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.name := normalize(NEW.name, NFC); + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION tc.nfc_normalize_book_name() IS + 'Trigger: NFC-normalize the book name before every insert or update.'; + +CREATE TRIGGER books_nfc_normalize_name_tg + BEFORE INSERT OR UPDATE OF name ON tc.books + FOR EACH ROW EXECUTE FUNCTION tc.nfc_normalize_book_name(); + +-- --------------------------------------------------------------------------- +-- versions (metadata per check-in) +-- --------------------------------------------------------------------------- +CREATE TABLE tc.versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + book_id uuid NOT NULL REFERENCES tc.books(id) ON DELETE CASCADE, + collection_id uuid NOT NULL, -- denormalised for fast queries + seq bigint NOT NULL, -- monotonically increasing per book + checksum text NOT NULL, -- SHA-256 of the manifest + comment text, + created_by text NOT NULL, -- user_id + created_at timestamptz NOT NULL DEFAULT now(), + client_version text, -- Bloom version string + + CONSTRAINT versions_book_seq_uq UNIQUE (book_id, seq) +); + +COMMENT ON TABLE tc.versions IS + 'One metadata row per successful check-in (checkin-finish edge function). ' + 'seq is monotonically increasing per book.'; + +CREATE INDEX versions_book_id_idx ON tc.versions(book_id); +CREATE INDEX versions_collection_id_idx ON tc.versions(collection_id); + +-- --------------------------------------------------------------------------- +-- version_files (CURRENT manifest: path → sha256 + s3_version_id) +-- --------------------------------------------------------------------------- +-- Rows here are the live manifest for the current version of each book. +-- Superseded rows are pruned by checkin-finish when a new version is committed. +-- s3_version_id is the S3 object version captured at upload time; reads always use +-- (path, s3_version_id) — never "latest" — for transactional safety. +CREATE TABLE tc.version_files ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + book_id uuid NOT NULL REFERENCES tc.books(id) ON DELETE CASCADE, + version_id uuid NOT NULL REFERENCES tc.versions(id) ON DELETE CASCADE, + path text NOT NULL, -- relative path within the book; NFC-normalized + sha256 text NOT NULL, + size_bytes bigint NOT NULL, + s3_version_id text NOT NULL, -- S3 object version id captured at PUT time + + CONSTRAINT version_files_book_path_uq UNIQUE (book_id, path) +); + +COMMENT ON TABLE tc.version_files IS + 'Current manifest for each book: path → sha256, size, s3_version_id. ' + 'Superseded rows are pruned at checkin-finish. Reads always use (path, s3_version_id).'; + +CREATE INDEX version_files_book_id_idx ON tc.version_files(book_id); +CREATE INDEX version_files_version_id_idx ON tc.version_files(version_id); + +-- Trigger: NFC-normalize version_files.path on insert +CREATE OR REPLACE FUNCTION tc.nfc_normalize_path() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.path := normalize(NEW.path, NFC); + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION tc.nfc_normalize_path() IS + 'Trigger: NFC-normalize the file path before every insert or update.'; + +CREATE TRIGGER version_files_nfc_normalize_path_tg + BEFORE INSERT OR UPDATE OF path ON tc.version_files + FOR EACH ROW EXECUTE FUNCTION tc.nfc_normalize_path(); + +-- --------------------------------------------------------------------------- +-- collection_file_groups (versioned blobs: allowed-words, sample-texts, other) +-- --------------------------------------------------------------------------- +CREATE TABLE tc.collection_file_groups ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + group_key text NOT NULL + CHECK (group_key IN ('other', 'allowed-words', 'sample-texts')), + version bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by text NOT NULL, + + CONSTRAINT collection_file_groups_uq UNIQUE (collection_id, group_key) +); + +COMMENT ON TABLE tc.collection_file_groups IS + 'Versioned collection-level file groups. version is bumped atomically by ' + 'collection-files-finish edge function; 409 VersionConflict if expectedVersion != version.'; + +CREATE INDEX collection_file_groups_collection_id_idx ON tc.collection_file_groups(collection_id); + +-- --------------------------------------------------------------------------- +-- collection_group_files (manifest per group) +-- --------------------------------------------------------------------------- +CREATE TABLE tc.collection_group_files ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + group_id bigint NOT NULL REFERENCES tc.collection_file_groups(id) ON DELETE CASCADE, + path text NOT NULL, -- NFC-normalized + sha256 text NOT NULL, + size_bytes bigint NOT NULL, + s3_version_id text NOT NULL, + + CONSTRAINT collection_group_files_group_path_uq UNIQUE (group_id, path) +); + +COMMENT ON TABLE tc.collection_group_files IS + 'Current file manifest for each collection_file_group. ' + 'Rows for the old version are replaced atomically by collection-files-finish.'; + +CREATE INDEX collection_group_files_group_id_idx ON tc.collection_group_files(group_id); + +CREATE TRIGGER collection_group_files_nfc_normalize_path_tg + BEFORE INSERT OR UPDATE OF path ON tc.collection_group_files + FOR EACH ROW EXECUTE FUNCTION tc.nfc_normalize_path(); + +-- --------------------------------------------------------------------------- +-- color_palette_entries (union-merge; no deletes) +-- --------------------------------------------------------------------------- +CREATE TABLE tc.color_palette_entries ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + palette text NOT NULL, -- named palette (e.g. 'cover-colors') + color text NOT NULL, -- hex or CSS color string + added_at timestamptz NOT NULL DEFAULT now(), + added_by text NOT NULL, + + CONSTRAINT color_palette_entries_uq UNIQUE (collection_id, palette, color) +); + +COMMENT ON TABLE tc.color_palette_entries IS + 'Color palette entries per collection. Merge is union-only: ' + 'insert ... on conflict do nothing. No rows are ever deleted.'; + +CREATE INDEX color_palette_entries_collection_id_idx ON tc.color_palette_entries(collection_id); + +-- --------------------------------------------------------------------------- +-- events (history log + realtime source + polling cursor) +-- --------------------------------------------------------------------------- +-- Numeric type values MUST match the C# BookHistoryEventType enum +-- (src/BloomExe/History/HistoryEvent.cs) which stores the integer in SQLite. +-- The enum is: +-- CheckOut = 0 +-- CheckIn = 1 +-- Created = 2 +-- Renamed = 3 +-- Uploaded = 4 (legacy; not used in cloud TC) +-- ForcedUnlock = 5 +-- ImportSpreadsheet = 6 (client-side only; may appear in log_event) +-- SyncProblem = 7 (legacy; not used in cloud TC) +-- Deleted = 8 +-- Moved = 9 +-- +-- Cloud-TC-specific incident types start at 100 to avoid collision with any future +-- additions to BookHistoryEventType (which must be added at the end of that enum): +-- WorkPreservedLocally = 100 (Lost & Found: local work saved as .bloomSource) +-- (future incidents: 101, 102, ...) +-- +-- The check constraint below lists all valid values; update it when extending. +CREATE TABLE tc.events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + book_id uuid REFERENCES tc.books(id) ON DELETE SET NULL, + type integer NOT NULL + CHECK (type IN ( + -- C# BookHistoryEventType (must stay in sync with HistoryEvent.cs) + 0, -- CheckOut + 1, -- CheckIn + 2, -- Created + 3, -- Renamed + 4, -- Uploaded (legacy) + 5, -- ForcedUnlock + 6, -- ImportSpreadsheet + 7, -- SyncProblem (legacy) + 8, -- Deleted + 9, -- Moved + -- Cloud-TC incident extensions (start at 100, never collide with C# enum) + 100 -- WorkPreservedLocally + )), + by_user_id text NOT NULL, + by_user_name text, + by_email text, + book_version_seq bigint, + lock_info jsonb, -- JSON {locked_by, machine} snapshot for ForcedUnlock events + book_name text, -- denormalized for display (name at event time) + group_key text, -- for collection-files events + message text, -- check-in comment / incident detail + bloom_version text, + occurred_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE tc.events IS + 'History log, realtime broadcast source, and polling cursor. ' + 'type values mirror C# BookHistoryEventType (HistoryEvent.cs): 0=CheckOut, 1=CheckIn, ' + '2=Created, 3=Renamed, 4=Uploaded(legacy), 5=ForcedUnlock, 6=ImportSpreadsheet, ' + '7=SyncProblem(legacy), 8=Deleted, 9=Moved. ' + 'Cloud-TC incident extensions start at 100 to avoid colliding with future C# additions: ' + '100=WorkPreservedLocally.'; + +CREATE INDEX events_collection_id_idx ON tc.events(collection_id); +CREATE INDEX events_book_id_idx ON tc.events(book_id) WHERE book_id IS NOT NULL; +-- Cursor index: get_changes(since_event_id) uses id > since_event_id +CREATE INDEX events_collection_cursor_idx ON tc.events(collection_id, id); + +-- --------------------------------------------------------------------------- +-- Realtime broadcast trigger on events +-- --------------------------------------------------------------------------- +-- Broadcasts a lightweight message on the private channel collection:{uuid} whenever a +-- new event row is inserted. Clients receive the metadata; large payloads (book content) +-- are never pushed — clients call get_changes(since) to fetch details. +-- +-- TODO(realtime, wave 4): pg_notify does NOT reach Supabase Realtime websockets. When the +-- realtime optimization lands (polling ships first — CloudCollectionMonitor), replace this +-- with realtime.send(payload, event, topic, private), topic 'collection:' || collection_id +-- per CONTRACTS.md §Realtime, wrapped in an EXCEPTION guard so an events INSERT never fails +-- in environments without the realtime schema (e.g. bare-Postgres pgTAP CI). +CREATE OR REPLACE FUNCTION tc.events_realtime_broadcast() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM pg_notify( + 'realtime:' || NEW.collection_id::text, + json_build_object( + 'eventId', NEW.id, + 'type', NEW.type, + 'bookId', NEW.book_id, + 'versionSeq', NEW.book_version_seq, + 'byUserName', NEW.by_user_name, + 'byEmail', NEW.by_email, + 'lock', NEW.lock_info, + 'name', NEW.book_name, + 'groupKey', NEW.group_key + )::text + ); + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION tc.events_realtime_broadcast() IS + 'Broadcasts a realtime notification on channel realtime:{collection_id} for every new ' + 'event row. The message shape matches CONTRACTS.md §Realtime.'; + +CREATE TRIGGER events_realtime_broadcast_tg + AFTER INSERT ON tc.events + FOR EACH ROW EXECUTE FUNCTION tc.events_realtime_broadcast(); + +-- --------------------------------------------------------------------------- +-- checkin_transactions (open send transactions; reaped on expiry) +-- --------------------------------------------------------------------------- +-- Tracks in-flight checkin-start → checkin-finish two-phase commits. +-- An open transaction means a new book row may exist with no current_version_id (invisible). +-- Expiry is 48 hours (per design); expired rows should be reaped by a scheduled function +-- (edge function task 02 / pg_cron). +CREATE TABLE tc.checkin_transactions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + book_id uuid NOT NULL REFERENCES tc.books(id) ON DELETE CASCADE, + started_by text NOT NULL, -- user_id + proposed_name text NOT NULL, + base_version_id uuid REFERENCES tc.versions(id), + changed_paths text[] NOT NULL DEFAULT '{}', + client_version text, + started_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL DEFAULT (now() + INTERVAL '48 hours'), + finished_at timestamptz, -- set on checkin-finish (success or abort) + aborted_at timestamptz, + status text NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'finished', 'aborted', 'expired')) +); + +COMMENT ON TABLE tc.checkin_transactions IS + 'Open check-in transactions (checkin-start → checkin-finish). ' + 'expires_at = 48h; expired rows are reaped by a scheduled edge function. ' + 'An open transaction for a new book means the book row has no current_version_id ' + 'and is invisible to teammates until checkin-finish commits it.'; + +CREATE INDEX checkin_transactions_book_id_idx ON tc.checkin_transactions(book_id); +CREATE INDEX checkin_transactions_started_by_idx ON tc.checkin_transactions(started_by); +CREATE INDEX checkin_transactions_expires_at_idx ON tc.checkin_transactions(expires_at) + WHERE status = 'open'; diff --git a/supabase/migrations/20260706000002_tc_rls.sql b/supabase/migrations/20260706000002_tc_rls.sql new file mode 100644 index 000000000000..b2014fa74770 --- /dev/null +++ b/supabase/migrations/20260706000002_tc_rls.sql @@ -0,0 +1,229 @@ +-- ============================================================================= +-- Migration: Row-Level Security policies for tc schema +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- RLS principles: +-- • Members can read their own collections and related data. +-- • Admins can manage membership, force-unlock, and approve accounts. +-- • NO direct INSERT/UPDATE/DELETE on books or versions via PostgREST — +-- all state transitions go through the RPCs defined in 20260706000003_tc_rpcs.sql. +-- • The realtime channel is private: only members of a collection subscribe. +-- • Service-role (used by edge functions) bypasses RLS; all RPCs run as SECURITY DEFINER +-- to get elevated access where needed. +-- ============================================================================= + +-- Enable RLS on every table in the tc schema. +ALTER TABLE tc.collections ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.members ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.books ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.versions ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.version_files ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.collection_file_groups ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.collection_group_files ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.color_palette_entries ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.events ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.checkin_transactions ENABLE ROW LEVEL SECURITY; + +-- --------------------------------------------------------------------------- +-- Helper: is the caller a member of a given collection? +-- --------------------------------------------------------------------------- +-- Used by multiple policies; defined as a stable SQL function so Postgres can +-- inline it into RLS policy checks without repeated subquery overhead. +CREATE OR REPLACE FUNCTION tc.is_member(p_collection_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND m.user_id = tc.current_user_id() + ) +$$; + +COMMENT ON FUNCTION tc.is_member(uuid) IS + 'Returns TRUE when the caller (JWT sub) is a claimed member of the given collection.'; + +-- --------------------------------------------------------------------------- +-- Helper: is the caller an admin of a given collection? +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.is_admin(p_collection_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND m.user_id = tc.current_user_id() + AND m.role = 'admin' + ) +$$; + +COMMENT ON FUNCTION tc.is_admin(uuid) IS + 'Returns TRUE when the caller (JWT sub) is a claimed admin of the given collection.'; + +-- ============================================================================= +-- COLLECTIONS +-- ============================================================================= + +-- Members can read their own collections. +CREATE POLICY collections_select ON tc.collections + FOR SELECT + USING (tc.is_member(id)); + +-- No direct INSERT/UPDATE/DELETE via PostgREST: create_collection RPC handles creation. +-- (No INSERT/UPDATE/DELETE policies → those operations are blocked for non-service-role.) + +-- ============================================================================= +-- MEMBERS +-- ============================================================================= + +-- Members can read the member list for their collections (so they know who else is in it). +CREATE POLICY members_select ON tc.members + FOR SELECT + USING (tc.is_member(collection_id)); + +-- Admins can insert new member rows (add_member RPC runs SECURITY DEFINER, but this +-- policy is defence-in-depth in case someone calls the table directly). +CREATE POLICY members_insert ON tc.members + FOR INSERT + WITH CHECK (tc.is_admin(collection_id)); + +-- Admins can update roles; the claim_memberships RPC writes user_id (SECURITY DEFINER). +CREATE POLICY members_update ON tc.members + FOR UPDATE + USING (tc.is_admin(collection_id)) + WITH CHECK (tc.is_admin(collection_id)); + +-- Admins can remove member rows. +CREATE POLICY members_delete ON tc.members + FOR DELETE + USING (tc.is_admin(collection_id)); + +-- ============================================================================= +-- BOOKS +-- ============================================================================= +-- NO direct writes (INSERT/UPDATE/DELETE) allowed via PostgREST. +-- All transitions go through SECURITY DEFINER RPCs. + +-- Members can read books in their collections (including tombstoned ones — needed +-- for get_collection_state delta and history display). +CREATE POLICY books_select ON tc.books + FOR SELECT + USING (tc.is_member(collection_id)); + +-- ============================================================================= +-- VERSIONS +-- ============================================================================= +-- Read-only for members; written only by checkin-finish edge function (service-role). + +CREATE POLICY versions_select ON tc.versions + FOR SELECT + USING (tc.is_member(collection_id)); + +-- ============================================================================= +-- VERSION_FILES +-- ============================================================================= +-- Read-only for members (for manifest inspection); written only by checkin-finish. + +CREATE POLICY version_files_select ON tc.version_files + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM tc.books b + WHERE b.id = version_files.book_id + AND tc.is_member(b.collection_id) + ) + ); + +-- ============================================================================= +-- COLLECTION_FILE_GROUPS +-- ============================================================================= + +CREATE POLICY collection_file_groups_select ON tc.collection_file_groups + FOR SELECT + USING (tc.is_member(collection_id)); + +-- ============================================================================= +-- COLLECTION_GROUP_FILES +-- ============================================================================= + +CREATE POLICY collection_group_files_select ON tc.collection_group_files + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM tc.collection_file_groups fg + WHERE fg.id = collection_group_files.group_id + AND tc.is_member(fg.collection_id) + ) + ); + +-- ============================================================================= +-- COLOR_PALETTE_ENTRIES +-- ============================================================================= + +-- Members can read palette entries. +CREATE POLICY color_palette_entries_select ON tc.color_palette_entries + FOR SELECT + USING (tc.is_member(collection_id)); + +-- add_palette_colors RPC inserts via SECURITY DEFINER; direct insert requires membership. +-- (The RPC is the preferred path; this is defence-in-depth.) +CREATE POLICY color_palette_entries_insert ON tc.color_palette_entries + FOR INSERT + WITH CHECK (tc.is_member(collection_id)); + +-- ============================================================================= +-- EVENTS +-- ============================================================================= + +-- Members can read events for their collections. +CREATE POLICY events_select ON tc.events + FOR SELECT + USING (tc.is_member(collection_id)); + +-- Members can insert their own events via log_event RPC (SECURITY DEFINER). +-- Defence-in-depth: direct INSERT requires membership. +CREATE POLICY events_insert ON tc.events + FOR INSERT + WITH CHECK ( + tc.is_member(collection_id) + AND by_user_id = tc.current_user_id() + ); + +-- No UPDATE or DELETE on events (immutable audit log). + +-- ============================================================================= +-- CHECKIN_TRANSACTIONS +-- ============================================================================= + +-- Users can read their own open transactions. +CREATE POLICY checkin_transactions_select ON tc.checkin_transactions + FOR SELECT + USING (started_by = tc.current_user_id()); + +-- Insertions are done by the checkin-start edge function (service-role / SECURITY DEFINER). +-- No direct write access via PostgREST. + +-- ============================================================================= +-- Grant usage on schema and SELECT on all tables to authenticated role +-- ============================================================================= +-- PostgREST uses the `authenticated` role for JWT-carrying requests. +GRANT USAGE ON SCHEMA tc TO authenticated; +GRANT SELECT ON ALL TABLES IN SCHEMA tc TO authenticated; +GRANT INSERT ON tc.members TO authenticated; +GRANT UPDATE ON tc.members TO authenticated; +GRANT DELETE ON tc.members TO authenticated; +GRANT INSERT ON tc.color_palette_entries TO authenticated; +GRANT INSERT ON tc.events TO authenticated; + +-- Sequences needed for GENERATED ALWAYS AS IDENTITY columns. +GRANT USAGE ON ALL SEQUENCES IN SCHEMA tc TO authenticated; + +-- anon role gets no access (all endpoints require a JWT). +REVOKE ALL ON ALL TABLES IN SCHEMA tc FROM anon; diff --git a/supabase/migrations/20260706000003_tc_rpcs.sql b/supabase/migrations/20260706000003_tc_rpcs.sql new file mode 100644 index 000000000000..095a5f48531d --- /dev/null +++ b/supabase/migrations/20260706000003_tc_rpcs.sql @@ -0,0 +1,963 @@ +-- ============================================================================= +-- Migration: Postgres RPCs (PostgREST /rest/v1/rpc/...) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- All RPCs defined here are SECURITY DEFINER so they can bypass RLS where needed +-- (e.g. writing books/versions, reading members to check admin status). +-- They re-check membership / admin status internally before any mutation. +-- +-- RPC signatures must match CONTRACTS.md exactly. +-- All timestamps server-side via now(). +-- User ids are TEXT (Firebase UIDs ~28 chars; local-GoTrue UUIDs; both fit in TEXT). +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- create_collection(id uuid, name text) +-- --------------------------------------------------------------------------- +-- Creates a collection + the caller as the sole claimed admin. +-- Requires the caller to be authenticated (email_verified is NOT required for +-- collection creation — just a valid JWT). +CREATE OR REPLACE FUNCTION tc.create_collection( + p_id uuid, + p_name text +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_email text; +BEGIN + v_user_id := tc.current_user_id(); + v_email := tc.current_user_email(); + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + + -- Insert collection + INSERT INTO tc.collections (id, name, created_by) + VALUES (p_id, normalize(p_name, NFC), v_user_id); + + -- Insert caller as sole claimed admin + INSERT INTO tc.members (collection_id, email, role, user_id, added_by, claimed_at) + VALUES (p_id, lower(v_email), 'admin', v_user_id, v_user_id, now()); +END; +$$; + +COMMENT ON FUNCTION tc.create_collection(uuid, text) IS + 'CONTRACTS.md: create_collection — creates collection + caller as sole claimed admin.'; + +-- --------------------------------------------------------------------------- +-- my_collections() +-- --------------------------------------------------------------------------- +-- Returns collections where the caller's email is in the approved list +-- (claimed or unclaimed — email match; used in the "Get my Team Collections" flow). +CREATE OR REPLACE FUNCTION tc.my_collections() +RETURNS TABLE ( + id uuid, + name text, + created_at timestamptz, + created_by text, + my_role tc.member_role, + is_claimed boolean +) +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT + c.id, + c.name, + c.created_at, + c.created_by, + m.role AS my_role, + (m.user_id IS NOT NULL) AS is_claimed + FROM tc.collections c + JOIN tc.members m + ON m.collection_id = c.id + AND lower(m.email) = tc.current_user_email() + ORDER BY c.name +$$; + +COMMENT ON FUNCTION tc.my_collections() IS + 'CONTRACTS.md: my_collections — returns collections where the caller''s email is approved ' + '(claimed or not).'; + +-- --------------------------------------------------------------------------- +-- claim_memberships() +-- --------------------------------------------------------------------------- +-- Fills user_id on rows matching the caller's verified email. +-- REQUIRES email_verified = true (tc.jwt_email_verified()). +CREATE OR REPLACE FUNCTION tc.claim_memberships() +RETURNS TABLE ( + collection_id uuid, + role tc.member_role +) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_email text; +BEGIN + IF NOT tc.jwt_email_verified() THEN + RAISE EXCEPTION 'email_not_verified: claiming memberships requires a verified email' + USING ERRCODE = '28000'; + END IF; + + v_user_id := tc.current_user_id(); + v_email := tc.current_user_email(); + + -- Fill user_id on unclaimed matching rows + UPDATE tc.members m + SET user_id = v_user_id, + claimed_at = now() + WHERE lower(m.email) = v_email + AND m.user_id IS NULL; + + -- Return the now-claimed memberships + RETURN QUERY + SELECT m.collection_id, m.role + FROM tc.members m + WHERE m.user_id = v_user_id; +END; +$$; + +COMMENT ON FUNCTION tc.claim_memberships() IS + 'CONTRACTS.md: claim_memberships — fills user_id on rows matching the caller''s verified ' + 'email. Requires tc.jwt_email_verified().'; + +-- --------------------------------------------------------------------------- +-- get_collection_state(collection_id uuid, since_event_id bigint) +-- --------------------------------------------------------------------------- +-- Full or delta snapshot: book rows (locks, current version seq + checksum), +-- collection-file group versions, max_event_id. +-- If since_event_id IS NULL → full snapshot. +-- If since_event_id is provided → delta (only books touched since that event). +CREATE OR REPLACE FUNCTION tc.get_collection_state( + p_collection_id uuid, + p_since_event_id bigint DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_max_event_id bigint; + v_books jsonb; + v_groups jsonb; +BEGIN + -- Verify membership + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Max event id for the cursor + SELECT max(id) INTO v_max_event_id + FROM tc.events + WHERE collection_id = p_collection_id; + + -- Books: full or delta + IF p_since_event_id IS NULL THEN + -- Full snapshot: all live books. + -- (task 02 fix, live-integration spike 6 Jul 2026): a book with + -- current_version_id IS NULL has never had a first checkin-finish — per + -- CONTRACTS.md's checkin-start spec it is "invisible to teammates until + -- first commit". The delta branch below gets this for free (a never- + -- committed book has no events yet to join against), but this full-snapshot + -- branch queried tc.books directly and leaked it to every member. Exclude + -- it unless the caller is the one who has it locked (i.e. is themselves + -- mid-Send) — they should still see their own in-flight new book. + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by + FROM tc.books b + WHERE b.collection_id = p_collection_id + AND (b.current_version_id IS NOT NULL OR b.locked_by = tc.current_user_id()) + ORDER BY lower(b.name) + ) b; + ELSE + -- Delta: only books that have an event since since_event_id + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + WHERE b.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + END IF; + + -- Collection file group versions + SELECT jsonb_agg(row_to_json(g)::jsonb) + INTO v_groups + FROM ( + SELECT group_key, version, updated_at + FROM tc.collection_file_groups + WHERE collection_id = p_collection_id + ORDER BY group_key + ) g; + + RETURN jsonb_build_object( + 'books', COALESCE(v_books, '[]'::jsonb), + 'groups', COALESCE(v_groups, '[]'::jsonb), + 'max_event_id', v_max_event_id + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_collection_state(uuid, bigint) IS + 'CONTRACTS.md: get_collection_state — full/delta snapshot of book rows + group versions + ' + 'max_event_id. since_event_id = NULL → full; otherwise delta.'; + +-- --------------------------------------------------------------------------- +-- get_changes(collection_id uuid, since_event_id bigint) +-- --------------------------------------------------------------------------- +-- Returns events + touched book rows since the cursor (for polling / reconnect catch-up). +CREATE OR REPLACE FUNCTION tc.get_changes( + p_collection_id uuid, + p_since_event_id bigint +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_events jsonb; + v_books jsonb; +BEGIN + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Events since cursor + SELECT jsonb_agg(row_to_json(e)::jsonb ORDER BY e.id) + INTO v_events + FROM ( + SELECT + e.id, + e.book_id, + e.type, + e.by_user_id, + e.by_user_name, + e.by_email, + e.book_version_seq, + e.lock_info, + e.book_name, + e.group_key, + e.message, + e.bloom_version, + e.occurred_at + FROM tc.events e + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY e.id + ) e; + + -- Touched book rows (distinct books referenced in those events) + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + + RETURN jsonb_build_object( + 'events', COALESCE(v_events, '[]'::jsonb), + 'books', COALESCE(v_books, '[]'::jsonb), + 'max_event_id', ( + SELECT max(id) FROM tc.events + WHERE collection_id = p_collection_id + AND id > p_since_event_id + ) + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_changes(uuid, bigint) IS + 'CONTRACTS.md: get_changes — events + touched book rows since the cursor. ' + 'Used for polling (60s fallback) and realtime reconnect catch-up.'; + +-- --------------------------------------------------------------------------- +-- checkout_book(book_id uuid, machine text) +-- --------------------------------------------------------------------------- +-- Conditional lock: race-free UPDATE … WHERE locked_by IS NULL OR locked_by = me. +-- Returns the resulting lock status: {success, locked_by, locked_by_machine, locked_at}. +CREATE OR REPLACE FUNCTION tc.checkout_book( + p_book_id uuid, + p_machine text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_updated integer; -- row count from the conditional UPDATE (0 or 1) + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + -- Get book + membership check + SELECT b.collection_id INTO v_collection + FROM tc.books b + WHERE b.id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Race-free conditional UPDATE + UPDATE tc.books + SET locked_by = v_user_id, + locked_by_machine = p_machine, + locked_at = now() + WHERE id = p_book_id + AND deleted_at IS NULL + AND (locked_by IS NULL OR locked_by = v_user_id); + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Fetch resulting row + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF v_updated > 0 THEN + -- Emit CheckOut event (type = 0) + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + SELECT + v_row.collection_id, p_book_id, 0, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name; + + RETURN jsonb_build_object( + 'success', true, + 'locked_by', v_user_id, + 'locked_by_machine', p_machine, + 'locked_at', now() + ); + ELSE + -- Lock held by someone else + RETURN jsonb_build_object( + 'success', false, + 'locked_by', v_row.locked_by, + 'locked_by_machine', v_row.locked_by_machine, + 'locked_at', v_row.locked_at + ); + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.checkout_book(uuid, text) IS + 'CONTRACTS.md: checkout_book — conditional lock (race-free UPDATE WHERE locked_by IS NULL ' + 'OR locked_by = me). Returns {success, locked_by, locked_by_machine, locked_at}. ' + 'Emits CheckOut event (type=0) on success.'; + +-- --------------------------------------------------------------------------- +-- unlock_book(book_id uuid) +-- --------------------------------------------------------------------------- +-- Release the caller's own lock (undo checkout, no content change, no event needed — +-- caller never committed content so no history entry required). +-- Only the lock holder can unlock; no admin override here (use force_unlock). +CREATE OR REPLACE FUNCTION tc.unlock_book( + p_book_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_locked_by text; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT b.collection_id, b.locked_by INTO v_collection, v_locked_by + FROM tc.books b + WHERE b.id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + IF v_locked_by IS DISTINCT FROM v_user_id THEN + RAISE EXCEPTION 'lock_not_held: book is not locked by you' USING ERRCODE = 'P0001'; + END IF; + + UPDATE tc.books + SET locked_by = NULL, + locked_by_machine = NULL, + locked_at = NULL + WHERE id = p_book_id; +END; +$$; + +COMMENT ON FUNCTION tc.unlock_book(uuid) IS + 'CONTRACTS.md: unlock_book — release own lock (undo checkout, no content change). ' + 'Only the lock holder may call this; use force_unlock for admin override.'; + +-- --------------------------------------------------------------------------- +-- force_unlock(book_id uuid) +-- --------------------------------------------------------------------------- +-- Admin-only; releases any lock; emits ForcedUnlock event (type=5). +CREATE OR REPLACE FUNCTION tc.force_unlock( + p_book_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_admin(v_row.collection_id) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + -- Snapshot the lock state for the audit event before clearing it + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, + lock_info, book_name + ) + VALUES ( + v_row.collection_id, p_book_id, 5, -- ForcedUnlock + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + jsonb_build_object( + 'locked_by', v_row.locked_by, + 'machine', v_row.locked_by_machine, + 'locked_at', v_row.locked_at + ), + v_row.name + ); + + UPDATE tc.books + SET locked_by = NULL, + locked_by_machine = NULL, + locked_at = NULL + WHERE id = p_book_id; +END; +$$; + +COMMENT ON FUNCTION tc.force_unlock(uuid) IS + 'CONTRACTS.md: force_unlock — admin-only; releases any lock; emits ForcedUnlock (type=5).'; + +-- --------------------------------------------------------------------------- +-- delete_book(book_id uuid) +-- --------------------------------------------------------------------------- +-- Requires the caller holds the lock (editorial workflow: check out, then delete). +-- Sets deleted_at tombstone; emits Deleted event (type=8). +CREATE OR REPLACE FUNCTION tc.delete_book( + p_book_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_row.collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + IF v_row.locked_by IS DISTINCT FROM v_user_id THEN + RAISE EXCEPTION 'lock_required: caller must hold the lock to delete a book' + USING ERRCODE = 'P0001'; + END IF; + + IF v_row.deleted_at IS NOT NULL THEN + RAISE EXCEPTION 'already_deleted' USING ERRCODE = 'P0001'; + END IF; + + UPDATE tc.books + SET deleted_at = now(), + locked_by = NULL, + locked_by_machine = NULL, + locked_at = NULL + WHERE id = p_book_id; + + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + VALUES ( + v_row.collection_id, p_book_id, 8, -- Deleted + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name + ); +END; +$$; + +COMMENT ON FUNCTION tc.delete_book(uuid) IS + 'CONTRACTS.md: delete_book — requires caller holds the lock; sets deleted_at tombstone; ' + 'emits Deleted (type=8). Lock is released on deletion.'; + +-- --------------------------------------------------------------------------- +-- undelete_book(book_id uuid) +-- --------------------------------------------------------------------------- +-- Admin-only; clears tombstone. Name-uniqueness is re-enforced: if another live book +-- already uses the same lower(NFC(name)), the call fails with a name_conflict error. +CREATE OR REPLACE FUNCTION tc.undelete_book( + p_book_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_row tc.books%ROWTYPE; + v_conflict_count integer; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_admin(v_row.collection_id) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + IF v_row.deleted_at IS NULL THEN + RAISE EXCEPTION 'not_deleted: book is not tombstoned' USING ERRCODE = 'P0001'; + END IF; + + -- Check live-name uniqueness before restoring + SELECT count(*) INTO v_conflict_count + FROM tc.books + WHERE collection_id = v_row.collection_id + AND id != p_book_id + AND deleted_at IS NULL + AND lower(normalize(name, NFC)) = lower(normalize(v_row.name, NFC)); + + IF v_conflict_count > 0 THEN + RAISE EXCEPTION 'name_conflict: a live book already uses this name' + USING ERRCODE = 'P0001'; + END IF; + + UPDATE tc.books + SET deleted_at = NULL + WHERE id = p_book_id; + + -- Log the undelete as a Created event to make it visible in history + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name, message + ) + VALUES ( + v_row.collection_id, p_book_id, 2, -- Created (reuse; undelete restores the book) + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name, 'undeleted' + ); +END; +$$; + +COMMENT ON FUNCTION tc.undelete_book(uuid) IS + 'CONTRACTS.md: undelete_book — admin-only; clears tombstone; enforces live-name ' + 'uniqueness (raises name_conflict if another live book uses the same name).'; + +-- --------------------------------------------------------------------------- +-- rename_check(book_id uuid, new_name text) +-- --------------------------------------------------------------------------- +-- Advisory uniqueness pre-check (returns whether the name is available). +-- Does NOT perform the rename — that happens via checkin-finish. +CREATE OR REPLACE FUNCTION tc.rename_check( + p_book_id uuid, + p_new_name text +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_collection uuid; + v_conflict boolean; +BEGIN + SELECT b.collection_id INTO v_collection + FROM tc.books b WHERE b.id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM tc.books + WHERE collection_id = v_collection + AND id != p_book_id + AND deleted_at IS NULL + AND lower(normalize(name, NFC)) = lower(normalize(p_new_name, NFC)) + ) INTO v_conflict; + + RETURN jsonb_build_object( + 'available', NOT v_conflict, + 'conflict', v_conflict + ); +END; +$$; + +COMMENT ON FUNCTION tc.rename_check(uuid, text) IS + 'CONTRACTS.md: rename_check — advisory live-name uniqueness pre-check. ' + 'Returns {available, conflict}. Does NOT perform the rename.'; + +-- --------------------------------------------------------------------------- +-- members_list(collection_id uuid) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.members_list( + p_collection_id uuid +) +RETURNS TABLE ( + id bigint, + email text, + role tc.member_role, + user_id text, + added_by text, + added_at timestamptz, + claimed_at timestamptz +) +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT m.id, m.email, m.role, m.user_id, m.added_by, m.added_at, m.claimed_at + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND tc.is_member(p_collection_id) -- membership gate + ORDER BY m.email +$$; + +COMMENT ON FUNCTION tc.members_list(uuid) IS + 'CONTRACTS.md: members list — returns approved-accounts for the collection. ' + 'Any member may call this.'; + +-- --------------------------------------------------------------------------- +-- members_add(collection_id uuid, email text, role tc.member_role) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.members_add( + p_collection_id uuid, + p_email text, + p_role tc.member_role DEFAULT 'member' +) +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_new_id bigint; +BEGIN + v_user_id := tc.current_user_id(); + + IF NOT tc.is_admin(p_collection_id) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + INSERT INTO tc.members (collection_id, email, role, added_by) + VALUES (p_collection_id, lower(normalize(p_email, NFC)), p_role, v_user_id) + ON CONFLICT (collection_id, email) DO NOTHING + RETURNING id INTO v_new_id; + + RETURN v_new_id; +END; +$$; + +COMMENT ON FUNCTION tc.members_add(uuid, text, tc.member_role) IS + 'CONTRACTS.md: members add — admin-only; adds an approved-account email. ' + 'Idempotent (on conflict do nothing).'; + +-- --------------------------------------------------------------------------- +-- members_remove(collection_id uuid, member_id bigint) +-- --------------------------------------------------------------------------- +-- Admin-only; removing a member force-unlocks any books they hold and emits events. +CREATE OR REPLACE FUNCTION tc.members_remove( + p_collection_id uuid, + p_member_id bigint +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_caller_id text; + v_target_user_id text; + v_book record; +BEGIN + v_caller_id := tc.current_user_id(); + + IF NOT tc.is_admin(p_collection_id) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + SELECT user_id INTO v_target_user_id + FROM tc.members + WHERE id = p_member_id AND collection_id = p_collection_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'member_not_found' USING ERRCODE = 'P0002'; + END IF; + + -- Force-unlock all books held by this user and emit ForcedUnlock events + FOR v_book IN + SELECT b.id, b.name, b.locked_by_machine, b.locked_at + FROM tc.books b + WHERE b.collection_id = p_collection_id + AND b.locked_by = v_target_user_id + LOOP + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, + lock_info, book_name, message + ) + VALUES ( + p_collection_id, v_book.id, 5, -- ForcedUnlock + v_caller_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + jsonb_build_object( + 'locked_by', v_target_user_id, + 'machine', v_book.locked_by_machine, + 'locked_at', v_book.locked_at + ), + v_book.name, + 'lock released due to member removal' + ); + + UPDATE tc.books + SET locked_by = NULL, + locked_by_machine = NULL, + locked_at = NULL + WHERE id = v_book.id; + END LOOP; + + -- Delete the member row (last-admin guard trigger will fire here if applicable) + DELETE FROM tc.members WHERE id = p_member_id; +END; +$$; + +COMMENT ON FUNCTION tc.members_remove(uuid, bigint) IS + 'CONTRACTS.md: members remove — admin-only; force-unlocks any books held by the removed ' + 'member (emits ForcedUnlock events). Last-admin guard trigger fires on DELETE.'; + +-- --------------------------------------------------------------------------- +-- members_set_role(collection_id uuid, member_id bigint, new_role tc.member_role) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.members_set_role( + p_collection_id uuid, + p_member_id bigint, + p_new_role tc.member_role +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + IF NOT tc.is_admin(p_collection_id) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + -- The last-admin guard trigger will raise if this demotes the last admin. + UPDATE tc.members + SET role = p_new_role + WHERE id = p_member_id + AND collection_id = p_collection_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'member_not_found' USING ERRCODE = 'P0002'; + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.members_set_role(uuid, bigint, tc.member_role) IS + 'CONTRACTS.md: members set_role — admin-only; last-admin guard trigger fires on demotion.'; + +-- --------------------------------------------------------------------------- +-- add_palette_colors(collection_id uuid, palette text, colors text[]) +-- --------------------------------------------------------------------------- +-- Union merge: insert-on-conflict-do-nothing. +CREATE OR REPLACE FUNCTION tc.add_palette_colors( + p_collection_id uuid, + p_palette text, + p_colors text[] +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_color text; +BEGIN + v_user_id := tc.current_user_id(); + + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + FOREACH v_color IN ARRAY p_colors LOOP + INSERT INTO tc.color_palette_entries (collection_id, palette, color, added_by) + VALUES (p_collection_id, p_palette, v_color, v_user_id) + ON CONFLICT (collection_id, palette, color) DO NOTHING; + END LOOP; +END; +$$; + +COMMENT ON FUNCTION tc.add_palette_colors(uuid, text, text[]) IS + 'CONTRACTS.md: add_palette_colors — union merge; insert-on-conflict-do-nothing. ' + 'Any member may call.'; + +-- --------------------------------------------------------------------------- +-- log_event(collection_id uuid, book_id uuid, type integer, message text, +-- book_name text, bloom_version text) +-- --------------------------------------------------------------------------- +-- Client-originated history entries (e.g. WorkPreservedLocally incidents). +CREATE OR REPLACE FUNCTION tc.log_event( + p_collection_id uuid, + p_book_id uuid DEFAULT NULL, + p_type integer DEFAULT NULL, + p_message text DEFAULT NULL, + p_book_name text DEFAULT NULL, + p_bloom_version text DEFAULT NULL +) +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_event_id bigint; +BEGIN + v_user_id := tc.current_user_id(); + + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- type must be a valid event type value (the check constraint on tc.events will catch + -- invalid values, but we give a friendlier error here). + IF p_type IS NULL THEN + RAISE EXCEPTION 'event_type_required' USING ERRCODE = '22023'; + END IF; + + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, + book_name, message, bloom_version + ) + VALUES ( + p_collection_id, p_book_id, p_type, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + p_book_name, p_message, p_bloom_version + ) + RETURNING id INTO v_event_id; + + RETURN v_event_id; +END; +$$; + +COMMENT ON FUNCTION tc.log_event(uuid, uuid, integer, text, text, text) IS + 'CONTRACTS.md: log_event — client-originated history entries (e.g. WorkPreservedLocally ' + 'incident events). Returns the new event id.'; + +-- --------------------------------------------------------------------------- +-- Grant EXECUTE on all RPCs to authenticated role +-- --------------------------------------------------------------------------- +GRANT EXECUTE ON FUNCTION tc.create_collection(uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.my_collections() TO authenticated; +GRANT EXECUTE ON FUNCTION tc.claim_memberships() TO authenticated; +GRANT EXECUTE ON FUNCTION tc.get_collection_state(uuid, bigint) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.get_changes(uuid, bigint) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.checkout_book(uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.unlock_book(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.force_unlock(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.delete_book(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.undelete_book(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.rename_check(uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.members_list(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.members_add(uuid, text, tc.member_role) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.members_remove(uuid, bigint) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.members_set_role(uuid, bigint, tc.member_role) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.add_palette_colors(uuid, text, text[]) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.log_event(uuid, uuid, integer, text, text, text) TO authenticated; diff --git a/supabase/migrations/20260706000004_tc_checkin_txn_functions.sql b/supabase/migrations/20260706000004_tc_checkin_txn_functions.sql new file mode 100644 index 000000000000..9dcb62100774 --- /dev/null +++ b/supabase/migrations/20260706000004_tc_checkin_txn_functions.sql @@ -0,0 +1,842 @@ +-- ============================================================================= +-- Migration: check-in / collection-files transaction functions (task 02) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- These functions implement the atomic-DB-transaction half of the two-phase +-- check-in / collection-files protocols described in CONTRACTS.md. They are +-- called ONLY by the edge functions in supabase/functions/** (checkin-start, +-- checkin-finish, checkin-abort, collection-files-start, collection-files-finish), +-- forwarding the CALLING USER'S OWN JWT (not a service-role key) so that +-- tc.current_user_id()/tc.current_user_email() resolve correctly. They are +-- SECURITY DEFINER (like every other RPC in this schema) so they can write to +-- tables that have no direct-write RLS policy for `authenticated`. +-- +-- They are NOT part of the public wire contract in CONTRACTS.md — that document +-- governs the HTTP shape of the edge functions, not these internal helpers. A +-- client should never call these directly; nothing stops it (same trust model as +-- every other RPC here: re-validate everything internally), but there is no +-- reason to. +-- +-- HTTP-status passthrough convention: PostgREST maps an ERRCODE of the form +-- 'PT###' directly to HTTP status ###. We RAISE EXCEPTION '%', +-- USING ERRCODE = 'PT409' (etc.) so the edge function can forward the same +-- status code and JSON.parse() the exception message for structured fields +-- (e.g. the lock holder's identity). See _shared/rpc.ts on the edge-function side. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- Extra checkin_transactions columns needed to make checkin-finish stateless +-- (it receives only { transactionId, comment?, keepCheckedOut? } per CONTRACTS.md — +-- no files list — so the full proposed manifest and its checksum must be +-- persisted at checkin-start time). +-- --------------------------------------------------------------------------- +ALTER TABLE tc.checkin_transactions + ADD COLUMN IF NOT EXISTS proposed_files jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS checksum text, + ADD COLUMN IF NOT EXISTS result_version_id uuid REFERENCES tc.versions(id), + ADD COLUMN IF NOT EXISTS result_seq bigint; + +COMMENT ON COLUMN tc.checkin_transactions.proposed_files IS + 'Full proposed manifest [{path,sha256,size}] captured at checkin-start; ' + 'checkin-finish reconstructs the committed manifest from this + changed_paths + ' + 'the S3 version-ids captured after upload verification.'; +COMMENT ON COLUMN tc.checkin_transactions.checksum IS + 'SHA-256 checksum of the full proposed manifest, supplied at checkin-start and ' + 'persisted as tc.versions.checksum / tc.books.current_checksum on finish.'; +COMMENT ON COLUMN tc.checkin_transactions.result_version_id IS + 'Set on successful checkin-finish; makes a repeated checkin-finish call for an ' + 'already-finished transaction idempotent (returns the same result).'; + +-- --------------------------------------------------------------------------- +-- collection_file_transactions (two-phase commit for collection-files-start/finish) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS tc.collection_file_transactions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + group_key text NOT NULL + CHECK (group_key IN ('other', 'allowed-words', 'sample-texts')), + started_by text NOT NULL, + expected_version bigint NOT NULL, + proposed_files jsonb NOT NULL DEFAULT '[]'::jsonb, + changed_paths text[] NOT NULL DEFAULT '{}', + started_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL DEFAULT (now() + INTERVAL '48 hours'), + finished_at timestamptz, + aborted_at timestamptz, + status text NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'finished', 'aborted', 'expired')), + result_version bigint +); + +COMMENT ON TABLE tc.collection_file_transactions IS + 'Open collection-files-start -> collection-files-finish two-phase commits. ' + 'Mirrors tc.checkin_transactions but scoped to (collection_id, group_key) instead ' + 'of a book.'; + +CREATE INDEX IF NOT EXISTS collection_file_transactions_scope_idx + ON tc.collection_file_transactions(collection_id, group_key, started_by) + WHERE status = 'open'; +CREATE INDEX IF NOT EXISTS collection_file_transactions_expires_at_idx + ON tc.collection_file_transactions(expires_at) WHERE status = 'open'; + +ALTER TABLE tc.collection_file_transactions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY collection_file_transactions_select ON tc.collection_file_transactions + FOR SELECT + USING (started_by = tc.current_user_id()); + +-- --------------------------------------------------------------------------- +-- Minimum supported client version (ClientOutOfDate / 426 handling) +-- --------------------------------------------------------------------------- +-- A tiny "constant function" so operators can bump the floor by CREATE OR REPLACE +-- without a migration. Version strings compare as dotted integer tuples +-- ('1.2.3' < '1.10.0'); a NULL/unparsable client_version is treated as out of date +-- only if the floor is above '0.0.0' (keeps existing behaviour permissive by default). +CREATE OR REPLACE FUNCTION tc.min_supported_client_version() +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT '0.0.0'::text +$$; + +COMMENT ON FUNCTION tc.min_supported_client_version() IS + 'Floor Bloom client version for cloud check-in operations. Bump via ' + 'CREATE OR REPLACE FUNCTION when a breaking client-side protocol change ships. ' + 'ClientOutOfDate (426) is raised when the caller''s clientVersion sorts below this.'; + +CREATE OR REPLACE FUNCTION tc.is_client_version_supported(p_client_version text) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_min int[]; + v_cur int[]; + v_floor text := tc.min_supported_client_version(); +BEGIN + IF v_floor IS NULL OR v_floor = '0.0.0' THEN + RETURN true; -- floor disabled + END IF; + IF p_client_version IS NULL OR p_client_version !~ '^[0-9]+(\.[0-9]+)*$' THEN + RETURN false; -- unparsable version, floor is enabled ⇒ reject + END IF; + + SELECT array_agg(x::int) INTO v_min FROM unnest(string_to_array(v_floor, '.')) x; + SELECT array_agg(x::int) INTO v_cur FROM unnest(string_to_array(p_client_version, '.')) x; + + FOR i IN 1 .. greatest(array_length(v_min, 1), array_length(v_cur, 1)) LOOP + IF COALESCE(v_cur[i], 0) > COALESCE(v_min[i], 0) THEN + RETURN true; + ELSIF COALESCE(v_cur[i], 0) < COALESCE(v_min[i], 0) THEN + RETURN false; + END IF; + END LOOP; + RETURN true; -- equal +END; +$$; + +COMMENT ON FUNCTION tc.is_client_version_supported(text) IS + 'Dotted-integer version compare against tc.min_supported_client_version(). ' + 'Used to raise ClientOutOfDate (426) in checkin_start_tx.'; + +-- --------------------------------------------------------------------------- +-- download_start_check(collection_id uuid) +-- --------------------------------------------------------------------------- +-- All the DB-side work download-start needs: confirm membership. Raises PT403 +-- if not a member; returns void on success. +CREATE OR REPLACE FUNCTION tc.download_start_check( + p_collection_id uuid +) +RETURNS void +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +BEGIN + IF tc.current_user_id() IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION '%', '{"error":"not_a_member"}' USING ERRCODE = 'PT403'; + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.download_start_check(uuid) IS + 'Internal to the download-start edge function: membership gate only. ' + 'PT403 not_a_member if the caller is not a member of the collection.'; + +-- --------------------------------------------------------------------------- +-- _checkin_reap_book(book_id uuid) — internal helper +-- --------------------------------------------------------------------------- +-- Reaps expired OPEN transactions tied to a single book. A brand-new book that +-- was never finished (current_version_id IS NULL) is deleted outright (fully +-- invisible, as if the Send had never started); an existing book's stale +-- transaction is just marked 'expired' — its lock is left alone (expiry of a +-- check-in attempt should not silently release a checkout the user still holds). +CREATE OR REPLACE FUNCTION tc._checkin_reap_book(p_book_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_new_book boolean; +BEGIN + SELECT (current_version_id IS NULL) INTO v_new_book + FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RETURN; + END IF; + + IF v_new_book THEN + -- Deleting the book cascades its (expired, still-open) transactions. + DELETE FROM tc.books + WHERE id = p_book_id + AND current_version_id IS NULL + AND EXISTS ( + SELECT 1 FROM tc.checkin_transactions t + WHERE t.book_id = p_book_id AND t.status = 'open' AND t.expires_at < now() + ) + -- never reap while ANOTHER still-live open transaction exists + AND NOT EXISTS ( + SELECT 1 FROM tc.checkin_transactions t + WHERE t.book_id = p_book_id AND t.status = 'open' AND t.expires_at >= now() + ); + ELSE + UPDATE tc.checkin_transactions + SET status = 'expired' + WHERE book_id = p_book_id AND status = 'open' AND expires_at < now(); + END IF; +END; +$$; + +COMMENT ON FUNCTION tc._checkin_reap_book(uuid) IS + 'Internal: reap expired open checkin_transactions for one book. New, ' + 'never-finished books are deleted outright; existing books just have the ' + 'stale transaction marked expired (lock is left untouched).'; + +-- --------------------------------------------------------------------------- +-- reap_expired_checkin_transactions() — global sweep +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.reap_expired_checkin_transactions() +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_book_id uuid; + v_count integer := 0; +BEGIN + FOR v_book_id IN + SELECT DISTINCT book_id FROM tc.checkin_transactions + WHERE status = 'open' AND expires_at < now() + LOOP + PERFORM tc._checkin_reap_book(v_book_id); + v_count := v_count + 1; + END LOOP; + + UPDATE tc.collection_file_transactions + SET status = 'expired' + WHERE status = 'open' AND expires_at < now(); + GET DIAGNOSTICS v_count = ROW_COUNT; + + RETURN v_count; +END; +$$; + +COMMENT ON FUNCTION tc.reap_expired_checkin_transactions() IS + 'Global expiry sweep for both checkin_transactions (via _checkin_reap_book) and ' + 'collection_file_transactions. Called opportunistically at the top of ' + 'checkin_start_tx/checkin_abort_tx/collection_files_start_tx; also safe to run ' + 'from a scheduled job if one is ever wired up (no pg_cron dependency here).'; + +-- --------------------------------------------------------------------------- +-- checkin_start_tx(...) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.checkin_start_tx( + p_collection_id uuid, + p_book_id uuid, -- NULL ⇒ new book + p_book_instance_id uuid, + p_proposed_name text, + p_base_version_id uuid, + p_checksum text, + p_client_version text, + p_files jsonb -- [{path, sha256, size}], full proposed manifest +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text := tc.current_user_id(); + v_book tc.books%ROWTYPE; + v_changed text[]; + v_tx_id uuid; + v_existing_tx tc.checkin_transactions%ROWTYPE; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + + IF NOT tc.is_client_version_supported(p_client_version) THEN + RAISE EXCEPTION '%', json_build_object( + 'error', 'ClientOutOfDate', + 'minVersion', tc.min_supported_client_version() + )::text USING ERRCODE = 'PT426'; + END IF; + + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION '%', '{"error":"not_a_member"}' USING ERRCODE = 'PT403'; + END IF; + + PERFORM tc.reap_expired_checkin_transactions(); + + IF p_book_id IS NULL THEN + -- ---- New-book path (or resume of our own not-yet-committed new-book Send) -- + -- CONTRACTS.md's checkin-start response never exposes `bookId` (that's the + -- whole point of "invisible until commit") so a client resuming an + -- interrupted new-book Send has no id to pass back — it re-calls with + -- bookId=null and the SAME bookInstanceId, which is the only identity it has. + -- We must therefore recognize "a row already exists with this instance_id, + -- but it's OUR OWN never-committed, still-locked-to-us row" as a resume, not + -- a conflict — otherwise resume is unreachable (every retry would trip the + -- instance-id uniqueness check below against the row created by try #1). + SELECT * INTO v_book FROM tc.books + WHERE collection_id = p_collection_id AND instance_id = p_book_instance_id; + + IF FOUND THEN + IF v_book.current_version_id IS NOT NULL OR v_book.locked_by IS DISTINCT FROM v_user_id THEN + -- Committed already, or in-flight under someone else's Send: genuine conflict. + RAISE EXCEPTION '%', json_build_object('error', 'NameConflict', + 'detail', 'instance_id already in use')::text + USING ERRCODE = 'PT409'; + END IF; + -- else: fall through with v_book already set to our own resumable row. + ELSE + IF EXISTS ( + SELECT 1 FROM tc.books + WHERE collection_id = p_collection_id + AND deleted_at IS NULL + AND lower(normalize(name, NFC)) = lower(normalize(p_proposed_name, NFC)) + ) THEN + RAISE EXCEPTION '%', json_build_object('error', 'NameConflict')::text + USING ERRCODE = 'PT409'; + END IF; + + INSERT INTO tc.books ( + collection_id, instance_id, name, locked_by, locked_at, created_by + ) + VALUES ( + p_collection_id, p_book_instance_id, p_proposed_name, v_user_id, now(), v_user_id + ) + RETURNING * INTO v_book; + END IF; + ELSE + -- ---- Existing-book path --------------------------------------------- + SELECT * INTO v_book FROM tc.books + WHERE id = p_book_id AND collection_id = p_collection_id; + + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"book_not_found"}' USING ERRCODE = 'PT404'; + END IF; + + IF v_book.locked_by IS NOT NULL AND v_book.locked_by <> v_user_id THEN + RAISE EXCEPTION '%', json_build_object( + 'error', 'LockHeldByOther', + 'holder', json_build_object( + 'userId', v_book.locked_by, + 'machine', v_book.locked_by_machine, + 'lockedAt', v_book.locked_at + ) + )::text USING ERRCODE = 'PT409'; + END IF; + + IF p_base_version_id IS NOT NULL + AND v_book.current_version_id IS DISTINCT FROM p_base_version_id THEN + RAISE EXCEPTION '%', json_build_object( + 'error', 'BaseVersionSuperseded', + 'currentVersionId', v_book.current_version_id, + 'currentVersionSeq', v_book.current_version_seq + )::text USING ERRCODE = 'PT409'; + END IF; + + -- Take the lock if free; no-op if already ours (lock ACQUISITION here, not just + -- verification, is a deliberate reading of "membership + lock checks" — see + -- orchestration report for the alternative interpretation considered). + UPDATE tc.books + SET locked_by = v_user_id, locked_at = now() + WHERE id = p_book_id AND (locked_by IS NULL OR locked_by = v_user_id) + RETURNING * INTO v_book; + END IF; + + -- ---- Diff proposed manifest vs current -------------------------------- + SELECT COALESCE(array_agg(f.path), '{}') INTO v_changed + FROM jsonb_to_recordset(p_files) AS f(path text, sha256 text, size bigint) + WHERE NOT EXISTS ( + SELECT 1 FROM tc.version_files vf + WHERE vf.book_id = v_book.id + AND vf.path = f.path + AND vf.sha256 = f.sha256 + AND vf.size_bytes = f.size + ); + + -- ---- Resume an already-open transaction for this (book, caller) ------- + SELECT * INTO v_existing_tx + FROM tc.checkin_transactions + WHERE book_id = v_book.id AND started_by = v_user_id AND status = 'open'; + + IF FOUND THEN + UPDATE tc.checkin_transactions + SET proposed_name = p_proposed_name, + base_version_id = p_base_version_id, + checksum = p_checksum, + client_version = p_client_version, + proposed_files = p_files, + changed_paths = v_changed, + expires_at = now() + INTERVAL '48 hours' + WHERE id = v_existing_tx.id + RETURNING id INTO v_tx_id; + ELSE + INSERT INTO tc.checkin_transactions ( + collection_id, book_id, started_by, proposed_name, base_version_id, + changed_paths, client_version, proposed_files, checksum + ) + VALUES ( + p_collection_id, v_book.id, v_user_id, p_proposed_name, p_base_version_id, + v_changed, p_client_version, p_files, p_checksum + ) + RETURNING id INTO v_tx_id; + END IF; + + RETURN jsonb_build_object( + 'transactionId', v_tx_id, + 'bookId', v_book.id, + 'changedPaths', to_jsonb(v_changed) + ); +END; +$$; + +COMMENT ON FUNCTION tc.checkin_start_tx(uuid, uuid, uuid, text, uuid, text, text, jsonb) IS + 'Internal to the checkin-start edge function. Handles membership/lock/base-version ' + 'checks, the new-book path, manifest diffing, and open-transaction resume. ' + 'Raises PT401/PT403/PT404/PT409/PT426 per CONTRACTS.md checkin-start error list.'; + +-- --------------------------------------------------------------------------- +-- checkin_finish_tx(...) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.checkin_finish_tx( + p_transaction_id uuid, + p_comment text, + p_keep_checked_out boolean, + p_captured jsonb -- [{path, s3VersionId}] for every path in changed_paths +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text := tc.current_user_id(); + v_tx tc.checkin_transactions%ROWTYPE; + v_missing text[]; + v_final jsonb; + v_was_new boolean; + v_new_seq bigint; + v_version_id uuid; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + + SELECT * INTO v_tx FROM tc.checkin_transactions WHERE id = p_transaction_id; + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"transaction_not_found"}' USING ERRCODE = 'PT404'; + END IF; + IF v_tx.started_by <> v_user_id THEN + RAISE EXCEPTION '%', '{"error":"forbidden"}' USING ERRCODE = 'PT403'; + END IF; + + IF v_tx.status = 'finished' THEN + -- Idempotent retry: return the previously-committed result unchanged. + RETURN jsonb_build_object('versionId', v_tx.result_version_id, 'seq', v_tx.result_seq); + END IF; + + IF v_tx.status = 'aborted' THEN + RAISE EXCEPTION '%', '{"error":"transaction_aborted"}' USING ERRCODE = 'PT409'; + END IF; + + IF v_tx.status = 'expired' OR v_tx.expires_at < now() THEN + UPDATE tc.checkin_transactions SET status = 'expired' + WHERE id = p_transaction_id AND status = 'open'; + RAISE EXCEPTION '%', '{"error":"TransactionExpired"}' USING ERRCODE = 'PT410'; + END IF; + + -- ---- Verify every changed path was captured (uploaded + checksum-verified + -- by the edge function before calling us) ------------------------- + SELECT COALESCE(array_agg(cp), '{}') INTO v_missing + FROM unnest(v_tx.changed_paths) cp + WHERE NOT EXISTS ( + SELECT 1 FROM jsonb_to_recordset(p_captured) AS c(path text, "s3VersionId" text) + WHERE c.path = cp AND c."s3VersionId" IS NOT NULL + ); + + IF array_length(v_missing, 1) > 0 THEN + -- Transaction stays OPEN so the client can re-upload and retry. + RAISE EXCEPTION '%', json_build_object( + 'error', 'MissingOrBadUploads', 'paths', to_jsonb(v_missing) + )::text USING ERRCODE = 'PT409'; + END IF; + + -- ---- Build the final manifest: proposed_files, with s3_version_id from + -- p_captured for changed paths and from the CURRENT manifest for + -- everything else. ------------------------------------------------- + SELECT jsonb_agg(jsonb_build_object( + 'path', f.path, + 'sha256', f.sha256, + 'size', f.size, + 's3VersionId', COALESCE( + (SELECT c."s3VersionId" FROM jsonb_to_recordset(p_captured) AS c(path text, "s3VersionId" text) + WHERE c.path = f.path), + (SELECT vf.s3_version_id FROM tc.version_files vf + WHERE vf.book_id = v_tx.book_id AND vf.path = f.path) + ) + )) + INTO v_final + FROM jsonb_to_recordset(v_tx.proposed_files) AS f(path text, sha256 text, size bigint); + + IF EXISTS ( + SELECT 1 FROM jsonb_array_elements(COALESCE(v_final, '[]'::jsonb)) e + WHERE e->>'s3VersionId' IS NULL + ) THEN + -- Defensive: a path was neither captured now nor present in the prior manifest. + RAISE EXCEPTION '%', json_build_object('error', 'MissingOrBadUploads', + 'paths', (SELECT jsonb_agg(e->>'path') FROM jsonb_array_elements(v_final) e + WHERE e->>'s3VersionId' IS NULL))::text + USING ERRCODE = 'PT409'; + END IF; + + v_was_new := (SELECT current_version_id FROM tc.books WHERE id = v_tx.book_id) IS NULL; + v_new_seq := COALESCE((SELECT max(seq) FROM tc.versions WHERE book_id = v_tx.book_id), 0) + 1; + + INSERT INTO tc.versions (book_id, collection_id, seq, checksum, comment, created_by, client_version) + VALUES (v_tx.book_id, v_tx.collection_id, v_new_seq, v_tx.checksum, p_comment, v_user_id, v_tx.client_version) + RETURNING id INTO v_version_id; + + DELETE FROM tc.version_files WHERE book_id = v_tx.book_id; + + INSERT INTO tc.version_files (book_id, version_id, path, sha256, size_bytes, s3_version_id) + SELECT v_tx.book_id, v_version_id, e->>'path', e->>'sha256', (e->>'size')::bigint, e->>'s3VersionId' + FROM jsonb_array_elements(v_final) e; + + UPDATE tc.books + SET current_version_id = v_version_id, + current_version_seq = v_new_seq, + current_checksum = v_tx.checksum, + name = v_tx.proposed_name, + locked_by = CASE WHEN p_keep_checked_out THEN locked_by ELSE NULL END, + locked_by_machine = CASE WHEN p_keep_checked_out THEN locked_by_machine ELSE NULL END, + locked_at = CASE WHEN p_keep_checked_out THEN locked_at ELSE NULL END + WHERE id = v_tx.book_id; + + IF v_was_new THEN + INSERT INTO tc.events (collection_id, book_id, type, by_user_id, by_user_name, by_email, book_name, bloom_version) + VALUES (v_tx.collection_id, v_tx.book_id, 2, v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), v_tx.proposed_name, v_tx.client_version); + END IF; + + INSERT INTO tc.events ( + collection_id, book_id, type, by_user_id, by_user_name, by_email, + book_version_seq, book_name, message, bloom_version + ) + VALUES ( + v_tx.collection_id, v_tx.book_id, 1, v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_new_seq, v_tx.proposed_name, p_comment, v_tx.client_version + ); + + UPDATE tc.checkin_transactions + SET status = 'finished', finished_at = now(), result_version_id = v_version_id, result_seq = v_new_seq + WHERE id = p_transaction_id; + + -- 'manifest' is NOT part of the CONTRACTS.md response ({versionId, seq} only) — it + -- is extra data for the edge function's own use (writing .manifest.json to S3); + -- the edge function must not forward it to the client. + RETURN jsonb_build_object('versionId', v_version_id, 'seq', v_new_seq, 'manifest', v_final); +END; +$$; + +COMMENT ON FUNCTION tc.checkin_finish_tx(uuid, text, boolean, jsonb) IS + 'Internal to the checkin-finish edge function. Single atomic DB transaction: ' + 'version row, current-manifest replacement, book update, lock release, events, ' + 'transaction close. Idempotent when re-called on an already-finished transaction. ' + 'Raises PT401/PT403/PT404/PT409(MissingOrBadUploads)/PT410(expired).'; + +-- --------------------------------------------------------------------------- +-- checkin_abort_tx(transaction_id uuid) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.checkin_abort_tx( + p_transaction_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text := tc.current_user_id(); + v_tx tc.checkin_transactions%ROWTYPE; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + + SELECT * INTO v_tx FROM tc.checkin_transactions WHERE id = p_transaction_id; + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"transaction_not_found"}' USING ERRCODE = 'PT404'; + END IF; + IF v_tx.started_by <> v_user_id THEN + RAISE EXCEPTION '%', '{"error":"forbidden"}' USING ERRCODE = 'PT403'; + END IF; + + IF v_tx.status = 'aborted' THEN + RETURN; -- idempotent + END IF; + IF v_tx.status = 'finished' THEN + RAISE EXCEPTION '%', '{"error":"already_finished"}' USING ERRCODE = 'PT409'; + END IF; + + UPDATE tc.checkin_transactions SET status = 'aborted', aborted_at = now() + WHERE id = p_transaction_id; + + -- Roll back a never-finished new book entirely (fully invisible, as designed). + -- Existing books keep whatever lock they had — aborting a Send is not the same + -- as releasing a Checkout. + PERFORM 1 FROM tc.books WHERE id = v_tx.book_id AND current_version_id IS NULL; + IF FOUND AND NOT EXISTS ( + SELECT 1 FROM tc.checkin_transactions + WHERE book_id = v_tx.book_id AND status = 'open' + ) THEN + DELETE FROM tc.books WHERE id = v_tx.book_id AND current_version_id IS NULL; + END IF; + + PERFORM tc.reap_expired_checkin_transactions(); +END; +$$; + +COMMENT ON FUNCTION tc.checkin_abort_tx(uuid) IS + 'Internal to the checkin-abort edge function. Idempotent. Rolls back a never-' + 'finished new book entirely; leaves an existing book''s lock untouched.'; + +-- --------------------------------------------------------------------------- +-- collection_files_start_tx(...) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.collection_files_start_tx( + p_collection_id uuid, + p_group_key text, + p_expected_version bigint, + p_files jsonb +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text := tc.current_user_id(); + v_group tc.collection_file_groups%ROWTYPE; + v_changed text[]; + v_tx_id uuid; + v_existing tc.collection_file_transactions%ROWTYPE; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION '%', '{"error":"not_a_member"}' USING ERRCODE = 'PT403'; + END IF; + + PERFORM tc.reap_expired_checkin_transactions(); + + INSERT INTO tc.collection_file_groups (collection_id, group_key, version, updated_by) + VALUES (p_collection_id, p_group_key, 0, v_user_id) + ON CONFLICT (collection_id, group_key) DO NOTHING; + + SELECT * INTO v_group FROM tc.collection_file_groups + WHERE collection_id = p_collection_id AND group_key = p_group_key; + + IF v_group.version <> p_expected_version THEN + RAISE EXCEPTION '%', json_build_object( + 'error', 'VersionConflict', 'currentVersion', v_group.version + )::text USING ERRCODE = 'PT409'; + END IF; + + SELECT COALESCE(array_agg(f.path), '{}') INTO v_changed + FROM jsonb_to_recordset(p_files) AS f(path text, sha256 text, size bigint) + WHERE NOT EXISTS ( + SELECT 1 FROM tc.collection_group_files gf + WHERE gf.group_id = v_group.id + AND gf.path = f.path + AND gf.sha256 = f.sha256 + AND gf.size_bytes = f.size + ); + + SELECT * INTO v_existing FROM tc.collection_file_transactions + WHERE collection_id = p_collection_id AND group_key = p_group_key + AND started_by = v_user_id AND status = 'open'; + + IF FOUND THEN + UPDATE tc.collection_file_transactions + SET expected_version = p_expected_version, + proposed_files = p_files, + changed_paths = v_changed, + expires_at = now() + INTERVAL '48 hours' + WHERE id = v_existing.id + RETURNING id INTO v_tx_id; + ELSE + INSERT INTO tc.collection_file_transactions ( + collection_id, group_key, started_by, expected_version, proposed_files, changed_paths + ) + VALUES ( + p_collection_id, p_group_key, v_user_id, p_expected_version, p_files, v_changed + ) + RETURNING id INTO v_tx_id; + END IF; + + RETURN jsonb_build_object('transactionId', v_tx_id, 'changedPaths', to_jsonb(v_changed)); +END; +$$; + +COMMENT ON FUNCTION tc.collection_files_start_tx(uuid, text, bigint, jsonb) IS + 'Internal to the collection-files-start edge function. Optimistic-version gate ' + '(PT409 VersionConflict) + manifest diff + transaction open/resume.'; + +-- --------------------------------------------------------------------------- +-- collection_files_finish_tx(...) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.collection_files_finish_tx( + p_transaction_id uuid, + p_captured jsonb +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text := tc.current_user_id(); + v_tx tc.collection_file_transactions%ROWTYPE; + v_group tc.collection_file_groups%ROWTYPE; + v_missing text[]; + v_final jsonb; + v_new_ver bigint; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + + SELECT * INTO v_tx FROM tc.collection_file_transactions WHERE id = p_transaction_id; + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"transaction_not_found"}' USING ERRCODE = 'PT404'; + END IF; + IF v_tx.started_by <> v_user_id THEN + RAISE EXCEPTION '%', '{"error":"forbidden"}' USING ERRCODE = 'PT403'; + END IF; + + IF v_tx.status = 'finished' THEN + RETURN jsonb_build_object('version', v_tx.result_version); + END IF; + IF v_tx.status = 'aborted' THEN + RAISE EXCEPTION '%', '{"error":"transaction_aborted"}' USING ERRCODE = 'PT409'; + END IF; + IF v_tx.status = 'expired' OR v_tx.expires_at < now() THEN + UPDATE tc.collection_file_transactions SET status = 'expired' + WHERE id = p_transaction_id AND status = 'open'; + RAISE EXCEPTION '%', '{"error":"TransactionExpired"}' USING ERRCODE = 'PT410'; + END IF; + + SELECT * INTO v_group FROM tc.collection_file_groups + WHERE collection_id = v_tx.collection_id AND group_key = v_tx.group_key; + + IF v_group.version <> v_tx.expected_version THEN + UPDATE tc.collection_file_transactions SET status = 'aborted', aborted_at = now() + WHERE id = p_transaction_id; + RAISE EXCEPTION '%', json_build_object( + 'error', 'VersionConflict', 'currentVersion', v_group.version + )::text USING ERRCODE = 'PT409'; + END IF; + + SELECT COALESCE(array_agg(cp), '{}') INTO v_missing + FROM unnest(v_tx.changed_paths) cp + WHERE NOT EXISTS ( + SELECT 1 FROM jsonb_to_recordset(p_captured) AS c(path text, "s3VersionId" text) + WHERE c.path = cp AND c."s3VersionId" IS NOT NULL + ); + + IF array_length(v_missing, 1) > 0 THEN + RAISE EXCEPTION '%', json_build_object( + 'error', 'MissingOrBadUploads', 'paths', to_jsonb(v_missing) + )::text USING ERRCODE = 'PT409'; + END IF; + + SELECT jsonb_agg(jsonb_build_object( + 'path', f.path, + 'sha256', f.sha256, + 'size', f.size, + 's3VersionId', COALESCE( + (SELECT c."s3VersionId" FROM jsonb_to_recordset(p_captured) AS c(path text, "s3VersionId" text) + WHERE c.path = f.path), + (SELECT gf.s3_version_id FROM tc.collection_group_files gf + WHERE gf.group_id = v_group.id AND gf.path = f.path) + ) + )) + INTO v_final + FROM jsonb_to_recordset(v_tx.proposed_files) AS f(path text, sha256 text, size bigint); + + v_new_ver := v_tx.expected_version + 1; + + UPDATE tc.collection_file_groups + SET version = v_new_ver, updated_at = now(), updated_by = v_user_id + WHERE id = v_group.id AND version = v_tx.expected_version; + + IF NOT FOUND THEN + UPDATE tc.collection_file_transactions SET status = 'aborted', aborted_at = now() + WHERE id = p_transaction_id; + RAISE EXCEPTION '%', json_build_object( + 'error', 'VersionConflict', 'currentVersion', v_group.version + )::text USING ERRCODE = 'PT409'; + END IF; + + DELETE FROM tc.collection_group_files WHERE group_id = v_group.id; + + INSERT INTO tc.collection_group_files (group_id, path, sha256, size_bytes, s3_version_id) + SELECT v_group.id, e->>'path', e->>'sha256', (e->>'size')::bigint, e->>'s3VersionId' + FROM jsonb_array_elements(v_final) e; + + INSERT INTO tc.events (collection_id, type, by_user_id, by_user_name, by_email, group_key) + VALUES (v_tx.collection_id, 1, v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), v_tx.group_key); + + UPDATE tc.collection_file_transactions + SET status = 'finished', finished_at = now(), result_version = v_new_ver + WHERE id = p_transaction_id; + + -- 'manifest' is extra data for the edge function only (not part of the + -- CONTRACTS.md {version} response) — used to write the .manifest.json backup. + RETURN jsonb_build_object('version', v_new_ver, 'manifest', v_final); +END; +$$; + +COMMENT ON FUNCTION tc.collection_files_finish_tx(uuid, jsonb) IS + 'Internal to the collection-files-finish edge function. Re-checks the optimistic ' + 'version at finish time too (repo-wins rule); PT409 VersionConflict aborts the ' + 'transaction so a stale retry cannot succeed later.'; + +-- --------------------------------------------------------------------------- +-- Grants — same pattern as 20260706000003_tc_rpcs.sql (SECURITY DEFINER + grant +-- to `authenticated`; internal re-validation makes this safe to call directly). +-- --------------------------------------------------------------------------- +GRANT EXECUTE ON FUNCTION tc.download_start_check(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.checkin_start_tx(uuid, uuid, uuid, text, uuid, text, text, jsonb) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.checkin_finish_tx(uuid, text, boolean, jsonb) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.checkin_abort_tx(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.collection_files_start_tx(uuid, text, bigint, jsonb) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.collection_files_finish_tx(uuid, jsonb) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.reap_expired_checkin_transactions() TO authenticated; + +GRANT SELECT ON tc.collection_file_transactions TO authenticated; +GRANT USAGE ON ALL SEQUENCES IN SCHEMA tc TO authenticated; diff --git a/supabase/migrations/20260707000005_tc_get_book_manifest.sql b/supabase/migrations/20260707000005_tc_get_book_manifest.sql new file mode 100644 index 000000000000..303ccf997269 --- /dev/null +++ b/supabase/migrations/20260707000005_tc_get_book_manifest.sql @@ -0,0 +1,77 @@ +-- ============================================================================= +-- Migration: get_book_manifest RPC (CONTRACTS.md v1.2, additive) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- Task 04's client review found that no RPC returns a book's per-file manifest +-- (path → sha256, size, s3_version_id), which the Receive path needs in order to +-- download by pinned (path, s3VersionId). get_collection_state/get_changes carry +-- only the aggregate current_version_seq/current_checksum by design (cheap +-- polling); this RPC is the explicit per-book fetch used when actual bytes are +-- about to move. (The S3 .manifest.json object is a backup of the same data, +-- not the authority.) + +-- Returns: { bookId, versionId, seq, checksum, files: [{path, sha256, size, s3VersionId}] } +-- Errors: book_not_found (P0002) — including for a never-committed (versionless) +-- book unless the caller is the one mid-Send holding its lock, mirroring +-- get_collection_state's invisibility rule; not_a_member (42501). +CREATE OR REPLACE FUNCTION tc.get_book_manifest( + p_book_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_row tc.books%ROWTYPE; + v_files jsonb; +BEGIN + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_row.collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Never-committed books are invisible to everyone except their mid-Send owner + -- (same rule as get_collection_state's full snapshot). + IF v_row.current_version_id IS NULL + AND v_row.locked_by IS DISTINCT FROM tc.current_user_id() THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'path', vf.path, + 'sha256', vf.sha256, + 'size', vf.size_bytes, + 's3VersionId', vf.s3_version_id + ) + ORDER BY vf.path + ), + '[]'::jsonb + ) + INTO v_files + FROM tc.version_files vf + WHERE vf.book_id = p_book_id; + + RETURN jsonb_build_object( + 'bookId', v_row.id, + 'versionId', v_row.current_version_id, + 'seq', v_row.current_version_seq, + 'checksum', v_row.current_checksum, + 'files', v_files + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_book_manifest(uuid) IS + 'CONTRACTS.md v1.2: get_book_manifest — per-file current manifest for one book ' + '(path, sha256, size, s3VersionId), used by Receive to download pinned versions. ' + 'Enforces the never-committed-book invisibility rule.'; + +GRANT EXECUTE ON FUNCTION tc.get_book_manifest(uuid) TO authenticated; diff --git a/supabase/migrations/20260707000006_tc_locked_by_display.sql b/supabase/migrations/20260707000006_tc_locked_by_display.sql new file mode 100644 index 000000000000..b82d73fd138b --- /dev/null +++ b/supabase/migrations/20260707000006_tc_locked_by_display.sql @@ -0,0 +1,344 @@ +-- ============================================================================= +-- Migration: lockedByEmail / lockedByName display fields (CONTRACTS.md v1.2, additive) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- Task 05's live-testing discovery: tc.books.locked_by stores the raw auth user_id +-- (JWT `sub`), not an email or display name -- useless for a "checked out to Sara" +-- UI label. The client-side workaround (CloudTeamCollection.ResolveLockedByForDisplay) +-- can only resolve the CALLER's OWN id back to their OWN email; a teammate's lock +-- still shows as a bare id. This migration adds a server-side resolution so every +-- member sees every OTHER member's lock with a friendly identity too. +-- +-- Email comes from tc.members (the approved-accounts table already keyed by +-- (collection_id, user_id) once claimed) -- authoritative and always present for +-- anyone who could possibly hold a lock (checkout_book requires membership). +-- A display *name* has no dedicated column anywhere server-side (tc.members has +-- none; the dev auth provider never sets one either) -- the best available source +-- is tc.events.by_user_name, which captures the JWT `name` claim at the moment of +-- each of that user's actions in this collection (see 20260706000003_tc_rpcs.sql's +-- `(auth.jwt() ->> 'name')` calls). We take the most recent non-null one. In dev-auth +-- mode this will normally be NULL (GoTrue sign-up carries no name claim), which is a +-- known, acceptable gap until real auth (Option A/B/C) supplies one -- exactly the +-- same "may be null, treat as no worse than today" contract as the client's own +-- lockedByFirstName/lockedBySurname fields. +-- +-- tc.get_book_manifest also gains lockedBy/lockedByEmail/lockedByName (previously it +-- returned no lock information at all) so the Receive path can show "still checked +-- out to X" without a second round trip. + +-- --------------------------------------------------------------------------- +-- Helper: resolve a user_id's best-known email/display name within one collection. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.resolve_member_display( + p_collection_id uuid, + p_user_id text, + OUT email text, + OUT display_name text +) +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT + m.email, + ( + SELECT e.by_user_name + FROM tc.events e + WHERE e.collection_id = p_collection_id + AND e.by_user_id = p_user_id + AND e.by_user_name IS NOT NULL + ORDER BY e.id DESC + LIMIT 1 + ) + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND m.user_id = p_user_id + LIMIT 1; +$$; + +COMMENT ON FUNCTION tc.resolve_member_display(uuid, text) IS + 'Best-effort resolution of a locked_by/created_by user_id to a display email ' + '(from tc.members, authoritative) and display name (from the most recent ' + 'tc.events.by_user_name for that user in this collection, often NULL in dev-auth ' + 'mode). Returns an all-NULL row (never an error) when p_user_id is NULL or unknown.'; + +GRANT EXECUTE ON FUNCTION tc.resolve_member_display(uuid, text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- get_collection_state: add locked_by_email / locked_by_name to each book row. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.get_collection_state( + p_collection_id uuid, + p_since_event_id bigint DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_max_event_id bigint; + v_books jsonb; + v_groups jsonb; +BEGIN + -- Verify membership + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Max event id for the cursor + SELECT max(id) INTO v_max_event_id + FROM tc.events + WHERE collection_id = p_collection_id; + + -- Books: full or delta + IF p_since_event_id IS NULL THEN + -- Full snapshot: all live books. + -- (task 02 fix, live-integration spike 6 Jul 2026): a book with + -- current_version_id IS NULL has never had a first checkin-finish — per + -- CONTRACTS.md's checkin-start spec it is "invisible to teammates until + -- first commit". The delta branch below gets this for free (a never- + -- committed book has no events yet to join against), but this full-snapshot + -- branch queried tc.books directly and leaked it to every member. Exclude + -- it unless the caller is the one who has it locked (i.e. is themselves + -- mid-Send) — they should still see their own in-flight new book. + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE b.collection_id = p_collection_id + AND (b.current_version_id IS NOT NULL OR b.locked_by = tc.current_user_id()) + ORDER BY lower(b.name) + ) b; + ELSE + -- Delta: only books that have an event since since_event_id + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE b.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + END IF; + + -- Collection file group versions + SELECT jsonb_agg(row_to_json(g)::jsonb) + INTO v_groups + FROM ( + SELECT group_key, version, updated_at + FROM tc.collection_file_groups + WHERE collection_id = p_collection_id + ORDER BY group_key + ) g; + + RETURN jsonb_build_object( + 'books', COALESCE(v_books, '[]'::jsonb), + 'groups', COALESCE(v_groups, '[]'::jsonb), + 'max_event_id', v_max_event_id + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_collection_state(uuid, bigint) IS + 'CONTRACTS.md: get_collection_state — full/delta snapshot of book rows + group versions + ' + 'max_event_id. since_event_id = NULL → full; otherwise delta. v1.2 (20260707000006): book ' + 'rows also carry locked_by_email/locked_by_name for display.'; + +-- --------------------------------------------------------------------------- +-- get_changes: add locked_by_email / locked_by_name to each touched book row. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.get_changes( + p_collection_id uuid, + p_since_event_id bigint +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_events jsonb; + v_books jsonb; +BEGIN + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Events since cursor + SELECT jsonb_agg(row_to_json(e)::jsonb ORDER BY e.id) + INTO v_events + FROM ( + SELECT + e.id, + e.book_id, + e.type, + e.by_user_id, + e.by_user_name, + e.by_email, + e.book_version_seq, + e.lock_info, + e.book_name, + e.group_key, + e.message, + e.bloom_version, + e.occurred_at + FROM tc.events e + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY e.id + ) e; + + -- Touched book rows (distinct books referenced in those events) + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + + RETURN jsonb_build_object( + 'events', COALESCE(v_events, '[]'::jsonb), + 'books', COALESCE(v_books, '[]'::jsonb), + 'max_event_id', ( + SELECT max(id) FROM tc.events + WHERE collection_id = p_collection_id + AND id > p_since_event_id + ) + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_changes(uuid, bigint) IS + 'CONTRACTS.md: get_changes — events + touched book rows since the cursor. ' + 'Used for polling (60s fallback) and realtime reconnect catch-up. v1.2 (20260707000006): ' + 'touched book rows also carry locked_by_email/locked_by_name for display.'; + +-- --------------------------------------------------------------------------- +-- get_book_manifest: add lockedBy / lockedByEmail / lockedByName. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.get_book_manifest( + p_book_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_row tc.books%ROWTYPE; + v_files jsonb; + v_email text; + v_name text; +BEGIN + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_row.collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Never-committed books are invisible to everyone except their mid-Send owner + -- (same rule as get_collection_state's full snapshot). + IF v_row.current_version_id IS NULL + AND v_row.locked_by IS DISTINCT FROM tc.current_user_id() THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'path', vf.path, + 'sha256', vf.sha256, + 'size', vf.size_bytes, + 's3VersionId', vf.s3_version_id + ) + ORDER BY vf.path + ), + '[]'::jsonb + ) + INTO v_files + FROM tc.version_files vf + WHERE vf.book_id = p_book_id; + + SELECT rd.email, rd.display_name + INTO v_email, v_name + FROM tc.resolve_member_display(v_row.collection_id, v_row.locked_by) rd; + + RETURN jsonb_build_object( + 'bookId', v_row.id, + 'versionId', v_row.current_version_id, + 'seq', v_row.current_version_seq, + 'checksum', v_row.current_checksum, + 'files', v_files, + 'lockedBy', v_row.locked_by, + 'lockedByEmail', v_email, + 'lockedByName', v_name + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_book_manifest(uuid) IS + 'CONTRACTS.md v1.2: get_book_manifest — per-file current manifest for one book ' + '(path, sha256, size, s3VersionId), used by Receive to download pinned versions. ' + 'Enforces the never-committed-book invisibility rule. v1.2 (20260707000006): also ' + 'reports lockedBy/lockedByEmail/lockedByName so Receive can show "still checked out ' + 'to X" without a second round trip.'; + +GRANT EXECUTE ON FUNCTION tc.get_book_manifest(uuid) TO authenticated; diff --git a/supabase/migrations/20260709000007_tc_checkout_takeover.sql b/supabase/migrations/20260709000007_tc_checkout_takeover.sql new file mode 100644 index 000000000000..bd1161b288f8 --- /dev/null +++ b/supabase/migrations/20260709000007_tc_checkout_takeover.sql @@ -0,0 +1,127 @@ +-- ============================================================================= +-- Account-switch checkout takeover (dogfood batch 1, item 9) +-- ============================================================================= +-- Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md item 9 (John's decision): +-- a local collection joined under account A is reopened signed in as a DIFFERENT member +-- account B. B may edit books A left checked out on THIS machine, and the first time B's +-- Bloom needs to push a change to the server (check-in), the checkout must atomically move +-- to B -- but ONLY because A's lock was for this same machine; a lock A holds on a different +-- machine remains a genuine conflict, unchanged. +-- +-- This is purely additive: tc.checkin_start_tx (20260706000004) is NOT modified. That +-- function already rejects a check-in when `locked_by <> caller` (see its "LockHeldByOther" +-- raise). Rather than loosen that gate (a real behavior/contract change to an existing, +-- already-shipped RPC, which per this task's rules is an orchestrator decision, not something +-- to do unilaterally), the C# client calls this NEW RPC first, so that by the time +-- checkin_start_tx runs, `locked_by` already equals the caller and its existing check passes +-- cleanly with zero change to its behavior. +-- +-- CONTRACTS.md ADDITION FLAGGED (not applied here -- this task's rules say contract changes/ +-- additions should be flagged for the orchestrator, not self-applied to CONTRACTS.md): add a +-- `checkout_book_takeover(book_id uuid, machine text) -> {success, locked_by, +-- locked_by_machine, locked_at}` row to the RPCs table, alongside checkout_book/unlock_book/ +-- force_unlock. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- checkout_book_takeover(book_id uuid, machine text) +-- --------------------------------------------------------------------------- +-- Same shape and conventions as tc.checkout_book (20260706000003): race-free conditional +-- UPDATE, returns {success, locked_by, locked_by_machine, locked_at}, emits a CheckOut event +-- (type=0) on a genuine handover. Differs from checkout_book only in its WHERE clause: instead +-- of requiring the lock be free (or already the caller's), it requires the lock be held by +-- SOMEONE ELSE on the SAME machine the caller is on now. It is safe to call speculatively +-- (e.g. before every check-in) -- if the caller already holds the lock, or the book is +-- unlocked, or it's locked on a different machine, no row matches and `success` is false with +-- no event emitted; callers that only wanted "make sure a same-machine takeover happens if +-- eligible" can treat "false" here as "nothing to take over" and proceed with their own next +-- step's ordinary conflict handling (e.g. checkin_start_tx's unmodified LockHeldByOther gate +-- for a genuinely-different-machine conflict). +CREATE OR REPLACE FUNCTION tc.checkout_book_takeover( + p_book_id uuid, + p_machine text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_before tc.books%ROWTYPE; + v_updated integer; -- row count from the conditional UPDATE (0 or 1) + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_before FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + v_collection := v_before.collection_id; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Race-free conditional UPDATE: only takes the lock from a DIFFERENT account, and only + -- when that account's lock is recorded against the SAME machine the caller is on now. + -- Never takes over a lock held on a different machine -- that stays a genuine conflict. + UPDATE tc.books + SET locked_by = v_user_id, + locked_by_machine = p_machine, + locked_at = now() + WHERE id = p_book_id + AND deleted_at IS NULL + AND locked_by IS NOT NULL + AND locked_by <> v_user_id + AND locked_by_machine = p_machine; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Fetch resulting row + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF v_updated > 0 THEN + -- Emit CheckOut event (type = 0) -- same event type an ordinary checkout_book success + -- emits, since from the audit trail's point of view this genuinely is B checking the + -- book out; the preceding history already shows A's own checkout, so the handoff reads + -- naturally without needing a new event-type constant shared across client/server. + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + SELECT + v_row.collection_id, p_book_id, 0, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name; + + RETURN jsonb_build_object( + 'success', true, + 'locked_by', v_user_id, + 'locked_by_machine', p_machine, + 'locked_at', v_row.locked_at + ); + ELSE + -- Nothing to take over (already ours, unlocked, or locked on a different machine). + RETURN jsonb_build_object( + 'success', false, + 'locked_by', v_row.locked_by, + 'locked_by_machine', v_row.locked_by_machine, + 'locked_at', v_row.locked_at + ); + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.checkout_book_takeover(uuid, text) IS + 'CONTRACTS.md addition (flagged, not yet applied to CONTRACTS.md -- see Design/CloudTeamCollections ' + 'orchestration item 9 report): checkout_book_takeover -- atomically reassigns a book''s lock from a ' + 'DIFFERENT account to the caller, but ONLY when the existing lock is recorded for the SAME machine ' + '(account-switch behavior, batch item 9). Returns {success, locked_by, locked_by_machine, locked_at}, ' + 'same shape as checkout_book. Emits a CheckOut event (type=0) only when the lock actually changed ' + 'hands. Never modifies checkin_start_tx/checkin_finish_tx -- purely additive.'; + +GRANT EXECUTE ON FUNCTION tc.checkout_book_takeover(uuid, text) TO authenticated; diff --git a/supabase/migrations/20260711000001_tc_reap_counter_fix.sql b/supabase/migrations/20260711000001_tc_reap_counter_fix.sql new file mode 100644 index 000000000000..bb60ee0d9be9 --- /dev/null +++ b/supabase/migrations/20260711000001_tc_reap_counter_fix.sql @@ -0,0 +1,49 @@ +-- ============================================================================= +-- Fix: reap_expired_checkin_transactions returned the wrong count +-- ============================================================================= +-- Found by Greptile review on PR #8048: the original function (20260706000004) +-- accumulated a per-book count in v_count across the loop, then immediately +-- clobbered it with `GET DIAGNOSTICS v_count = ROW_COUNT` from the +-- collection_file_transactions sweep, so it always returned only the +-- collection-file count. Diagnostic-only today (every call site PERFORMs and +-- discards the result), but the return value now matches the documented intent: +-- total items reaped across both sweeps. +-- +-- Convention: merged migrations are never edited in place -- fixes ship as new +-- migration files (see Design/CloudTeamCollections/IMPLEMENTATION.md). +-- ============================================================================= + +CREATE OR REPLACE FUNCTION tc.reap_expired_checkin_transactions() +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_book_id uuid; + v_count integer := 0; + v_updated integer; +BEGIN + FOR v_book_id IN + SELECT DISTINCT book_id FROM tc.checkin_transactions + WHERE status = 'open' AND expires_at < now() + LOOP + PERFORM tc._checkin_reap_book(v_book_id); + v_count := v_count + 1; + END LOOP; + + UPDATE tc.collection_file_transactions + SET status = 'expired' + WHERE status = 'open' AND expires_at < now(); + GET DIAGNOSTICS v_updated = ROW_COUNT; + v_count := v_count + v_updated; + + RETURN v_count; +END; +$$; + +COMMENT ON FUNCTION tc.reap_expired_checkin_transactions() IS + 'Global expiry sweep for both checkin_transactions (via _checkin_reap_book) and ' + 'collection_file_transactions. Returns the total number of items reaped across both ' + 'sweeps. Called opportunistically at the top of checkin_start_tx/checkin_abort_tx/' + 'collection_files_start_tx; also safe to run from a scheduled job if one is ever ' + 'wired up (no pg_cron dependency here).'; diff --git a/supabase/migrations/20260711000002_tc_takeover_error_codes.sql b/supabase/migrations/20260711000002_tc_takeover_error_codes.sql new file mode 100644 index 000000000000..92fe944c574d --- /dev/null +++ b/supabase/migrations/20260711000002_tc_takeover_error_codes.sql @@ -0,0 +1,95 @@ +-- ============================================================================= +-- Fix: checkout_book_takeover error codes aligned with the schema-wide convention +-- ============================================================================= +-- Found by Greptile review on PR #8048: the original function (20260709000007) +-- raised `book_not_found` with ERRCODE P0002 and `not_a_member` with ERRCODE +-- 42501 -- bare-string messages with codes PostgREST does not map to HTTP +-- statuses. Every other RPC in this schema raises a JSON-object message with a +-- PT### passthrough code (PT404/PT403), which PostgREST turns into the matching +-- HTTP status and CloudCollectionClientException then maps to a typed +-- CloudErrorCode instead of Unknown. This migration re-creates the function +-- with only those two RAISE statements changed; the takeover logic itself is +-- untouched (see 20260709000007 for the full design commentary). +-- +-- Convention: merged migrations are never edited in place -- fixes ship as new +-- migration files (see Design/CloudTeamCollections/IMPLEMENTATION.md). +-- ============================================================================= + +CREATE OR REPLACE FUNCTION tc.checkout_book_takeover( + p_book_id uuid, + p_machine text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_before tc.books%ROWTYPE; + v_updated integer; -- row count from the conditional UPDATE (0 or 1) + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_before FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"book_not_found"}' USING ERRCODE = 'PT404'; + END IF; + + v_collection := v_before.collection_id; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION '%', '{"error":"not_a_member"}' USING ERRCODE = 'PT403'; + END IF; + + -- Race-free conditional UPDATE: only takes the lock from a DIFFERENT account, and only + -- when that account's lock is recorded against the SAME machine the caller is on now. + -- Never takes over a lock held on a different machine -- that stays a genuine conflict. + UPDATE tc.books + SET locked_by = v_user_id, + locked_by_machine = p_machine, + locked_at = now() + WHERE id = p_book_id + AND deleted_at IS NULL + AND locked_by IS NOT NULL + AND locked_by <> v_user_id + AND locked_by_machine = p_machine; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Fetch resulting row + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF v_updated > 0 THEN + -- Emit CheckOut event (type = 0) -- same event type an ordinary checkout_book success + -- emits, since from the audit trail's point of view this genuinely is B checking the + -- book out; the preceding history already shows A's own checkout, so the handoff reads + -- naturally without needing a new event-type constant shared across client/server. + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + SELECT + v_row.collection_id, p_book_id, 0, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name; + + RETURN jsonb_build_object( + 'success', true, + 'locked_by', v_user_id, + 'locked_by_machine', p_machine, + 'locked_at', v_row.locked_at + ); + ELSE + -- Nothing to take over (already ours, unlocked, or locked on a different machine). + RETURN jsonb_build_object( + 'success', false, + 'locked_by', v_row.locked_by, + 'locked_by_machine', v_row.locked_by_machine, + 'locked_at', v_row.locked_at + ); + END IF; +END; +$$; diff --git a/supabase/migrations/20260711000003_tc_locked_seat.sql b/supabase/migrations/20260711000003_tc_locked_seat.sql new file mode 100644 index 000000000000..eb65195e3d2e --- /dev/null +++ b/supabase/migrations/20260711000003_tc_locked_seat.sql @@ -0,0 +1,454 @@ +-- ============================================================================= +-- Per-collection-copy "seat" on checkouts (dogfood batch 1, bug #0 — John's decision) +-- ============================================================================= +-- Item 9's same-machine takeover (20260709000007) gated only on the machine name, so +-- ANY same-machine account could take over ANY same-machine lock — including across two +-- separate local copies of the collection on one computer (two "seats"), which is what +-- e2e-4 simulates and what a shared lab machine would really be. John's ruling +-- (11 Jul 2026, recorded in orchestration/DOGFOOD-BATCH-1.md): editing/takeover of a +-- checkout is only legitimate where the book is checked out — in THAT local copy of the +-- collection. So the lock record now carries a seat id (client-computed stable hash of +-- the local collection folder path — never the raw path, for privacy), and takeover +-- requires machine AND seat to match. A lock with no recorded seat (legacy row, or a +-- lock acquired via checkin_start_tx's take-if-free path, which has no seat parameter) +-- REFUSES takeover — fail-safe. +-- +-- Seat lifecycle: set by checkout_book/checkout_book_takeover; cleared automatically by +-- a BEFORE UPDATE trigger whenever locked_by is cleared (covers unlock_book, +-- force_unlock, checkin_finish_tx's release, and any future unlock path without +-- recreating them here). +-- +-- CONTRACTS.md v1.5: checkout_book/checkout_book_takeover gain an optional `seat` +-- parameter and return `locked_seat`; get_collection_state/get_changes book rows carry +-- `locked_seat`. All additive. +-- ============================================================================= + +ALTER TABLE tc.books ADD COLUMN locked_seat text; + +COMMENT ON COLUMN tc.books.locked_seat IS + 'Which local copy of the collection ("seat") holds the lock: a client-computed ' + 'stable hash of the local collection folder path (never the raw path). NULL = ' + 'unknown (legacy lock, or one acquired by checkin_start_tx''s take-if-free path); ' + 'a NULL seat can never be taken over (fail-safe).'; + +-- --------------------------------------------------------------------------- +-- Trigger: locked_seat can never outlive locked_by. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc._clear_seat_on_unlock() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.locked_by IS NULL THEN + NEW.locked_seat := NULL; + END IF; + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION tc._clear_seat_on_unlock() IS + 'Internal: clears tc.books.locked_seat whenever locked_by is cleared, so every ' + 'unlock path (unlock_book, force_unlock, checkin_finish_tx, future ones) stays ' + 'seat-consistent without each having to remember the column.'; + +CREATE TRIGGER books_clear_seat_on_unlock + BEFORE UPDATE ON tc.books + FOR EACH ROW + EXECUTE FUNCTION tc._clear_seat_on_unlock(); + +-- --------------------------------------------------------------------------- +-- checkout_book(book_id, machine, seat) — records the caller's seat with the lock. +-- --------------------------------------------------------------------------- +-- DROP + CREATE (not OR REPLACE) because the signature gains a parameter. The new +-- parameter has a DEFAULT so pre-seat callers (and pgTAP's 2-arg calls) keep working; +-- they simply record an unknown (NULL) seat. +DROP FUNCTION tc.checkout_book(uuid, text); + +CREATE FUNCTION tc.checkout_book( + p_book_id uuid, + p_machine text, + p_seat text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_updated integer; -- row count from the conditional UPDATE (0 or 1) + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + -- Get book + membership check + SELECT b.collection_id INTO v_collection + FROM tc.books b + WHERE b.id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Race-free conditional UPDATE + UPDATE tc.books + SET locked_by = v_user_id, + locked_by_machine = p_machine, + locked_seat = p_seat, + locked_at = now() + WHERE id = p_book_id + AND deleted_at IS NULL + AND (locked_by IS NULL OR locked_by = v_user_id); + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Fetch resulting row + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF v_updated > 0 THEN + -- Emit CheckOut event (type = 0) + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + SELECT + v_row.collection_id, p_book_id, 0, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name; + + RETURN jsonb_build_object( + 'success', true, + 'locked_by', v_user_id, + 'locked_by_machine', p_machine, + 'locked_seat', p_seat, + 'locked_at', now() + ); + ELSE + -- Lock held by someone else + RETURN jsonb_build_object( + 'success', false, + 'locked_by', v_row.locked_by, + 'locked_by_machine', v_row.locked_by_machine, + 'locked_seat', v_row.locked_seat, + 'locked_at', v_row.locked_at + ); + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.checkout_book(uuid, text, text) IS + 'CONTRACTS.md: checkout_book — conditional lock (race-free UPDATE WHERE locked_by IS NULL ' + 'OR locked_by = me). v1.5: also records the caller''s seat (local-copy id) with the lock. ' + 'Returns {success, locked_by, locked_by_machine, locked_seat, locked_at}. ' + 'Emits CheckOut event (type=0) on success.'; + +GRANT EXECUTE ON FUNCTION tc.checkout_book(uuid, text, text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- checkout_book_takeover(book_id, machine, seat) — takeover now requires the seat too. +-- --------------------------------------------------------------------------- +DROP FUNCTION tc.checkout_book_takeover(uuid, text); + +CREATE FUNCTION tc.checkout_book_takeover( + p_book_id uuid, + p_machine text, + p_seat text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_before tc.books%ROWTYPE; + v_updated integer; -- row count from the conditional UPDATE (0 or 1) + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_before FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"book_not_found"}' USING ERRCODE = 'PT404'; + END IF; + + v_collection := v_before.collection_id; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION '%', '{"error":"not_a_member"}' USING ERRCODE = 'PT403'; + END IF; + + -- Race-free conditional UPDATE: only takes the lock from a DIFFERENT account, and only + -- when that account's lock is recorded against the SAME machine AND the SAME seat + -- (local collection copy) the caller is on now (bug #0, John's ruling: takeover is the + -- shared-computer, same-local-folder scenario — two folders on one machine are two + -- seats and remain a genuine conflict). A NULL stored seat (legacy lock, or one taken + -- by checkin_start_tx's take-if-free path) never matches — fail-safe. A NULL p_seat + -- (pre-seat caller) likewise can never take over. + UPDATE tc.books + SET locked_by = v_user_id, + locked_by_machine = p_machine, + locked_seat = p_seat, + locked_at = now() + WHERE id = p_book_id + AND deleted_at IS NULL + AND locked_by IS NOT NULL + AND locked_by <> v_user_id + AND locked_by_machine = p_machine + AND locked_seat IS NOT NULL + AND locked_seat = p_seat; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Fetch resulting row + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF v_updated > 0 THEN + -- Emit CheckOut event (type = 0) -- same event type an ordinary checkout_book success + -- emits, since from the audit trail's point of view this genuinely is B checking the + -- book out; the preceding history already shows A's own checkout, so the handoff reads + -- naturally without needing a new event-type constant shared across client/server. + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + SELECT + v_row.collection_id, p_book_id, 0, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name; + + RETURN jsonb_build_object( + 'success', true, + 'locked_by', v_user_id, + 'locked_by_machine', p_machine, + 'locked_seat', p_seat, + 'locked_at', v_row.locked_at + ); + ELSE + -- Nothing to take over (already ours, unlocked, locked on a different machine, or + -- locked in a different/unknown seat). + RETURN jsonb_build_object( + 'success', false, + 'locked_by', v_row.locked_by, + 'locked_by_machine', v_row.locked_by_machine, + 'locked_seat', v_row.locked_seat, + 'locked_at', v_row.locked_at + ); + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.checkout_book_takeover(uuid, text, text) IS + 'CONTRACTS.md v1.5: checkout_book_takeover — atomically reassigns a book''s lock from a ' + 'DIFFERENT account to the caller, but ONLY when the existing lock is recorded for the SAME ' + 'machine AND the SAME seat (local collection copy) — bug #0: two local copies on one ' + 'computer are two seats; a NULL stored seat never matches (fail-safe). Returns ' + '{success, locked_by, locked_by_machine, locked_seat, locked_at}. Emits a CheckOut event ' + '(type=0) only when the lock actually changed hands.'; + +GRANT EXECUTE ON FUNCTION tc.checkout_book_takeover(uuid, text, text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- get_collection_state / get_changes: expose locked_seat on book rows (additive). +-- --------------------------------------------------------------------------- +-- Full recreations of the 20260707000006 versions with locked_seat added to each +-- book-row SELECT; no other change. +CREATE OR REPLACE FUNCTION tc.get_collection_state( + p_collection_id uuid, + p_since_event_id bigint DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_max_event_id bigint; + v_books jsonb; + v_groups jsonb; +BEGIN + -- Verify membership + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Max event id for the cursor + SELECT max(id) INTO v_max_event_id + FROM tc.events + WHERE collection_id = p_collection_id; + + -- Books: full or delta + IF p_since_event_id IS NULL THEN + -- Full snapshot: all live books, minus never-committed books invisible to + -- everyone but their own mid-Send lock holder (see 20260707000006 for history). + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_seat, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE b.collection_id = p_collection_id + AND (b.current_version_id IS NOT NULL OR b.locked_by = tc.current_user_id()) + ORDER BY lower(b.name) + ) b; + ELSE + -- Delta: only books that have an event since since_event_id + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_seat, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE b.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + END IF; + + -- Collection file group versions + SELECT jsonb_agg(row_to_json(g)::jsonb) + INTO v_groups + FROM ( + SELECT group_key, version, updated_at + FROM tc.collection_file_groups + WHERE collection_id = p_collection_id + ORDER BY group_key + ) g; + + RETURN jsonb_build_object( + 'books', COALESCE(v_books, '[]'::jsonb), + 'groups', COALESCE(v_groups, '[]'::jsonb), + 'max_event_id', v_max_event_id + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_collection_state(uuid, bigint) IS + 'CONTRACTS.md: get_collection_state — full/delta snapshot of book rows + group versions + ' + 'max_event_id. since_event_id = NULL → full; otherwise delta. v1.2 (20260707000006): book ' + 'rows also carry locked_by_email/locked_by_name for display. v1.5 (20260711000003): book ' + 'rows also carry locked_seat.'; + +CREATE OR REPLACE FUNCTION tc.get_changes( + p_collection_id uuid, + p_since_event_id bigint +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_events jsonb; + v_books jsonb; +BEGIN + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Events since cursor + SELECT jsonb_agg(row_to_json(e)::jsonb ORDER BY e.id) + INTO v_events + FROM ( + SELECT + e.id, + e.book_id, + e.type, + e.by_user_id, + e.by_user_name, + e.by_email, + e.book_version_seq, + e.lock_info, + e.book_name, + e.group_key, + e.message, + e.bloom_version, + e.occurred_at + FROM tc.events e + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY e.id + ) e; + + -- Touched book rows (distinct books referenced in those events) + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_seat, + b.locked_at, + b.deleted_at, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + + RETURN jsonb_build_object( + 'events', COALESCE(v_events, '[]'::jsonb), + 'books', COALESCE(v_books, '[]'::jsonb), + 'max_event_id', ( + SELECT max(id) FROM tc.events + WHERE collection_id = p_collection_id + AND id > p_since_event_id + ) + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_changes(uuid, bigint) IS + 'CONTRACTS.md: get_changes — events + touched book rows since the cursor. ' + 'Used for polling (60s fallback) and realtime reconnect catch-up. v1.2 (20260707000006): ' + 'touched book rows also carry locked_by_email/locked_by_name for display. v1.5 ' + '(20260711000003): touched book rows also carry locked_seat.'; diff --git a/supabase/migrations/20260713000001_tc_member_display_name.sql b/supabase/migrations/20260713000001_tc_member_display_name.sql new file mode 100644 index 000000000000..933a55695a13 --- /dev/null +++ b/supabase/migrations/20260713000001_tc_member_display_name.sql @@ -0,0 +1,261 @@ +-- ============================================================================= +-- Migration: durable member display names (CONTRACTS.md v1.6, additive) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- Dogfood batch 1, John's 13 Jul 2026 request: "checked out to X" (and similar) should +-- show a human-readable name with the email as a fallback, and admins should be able to +-- set that name for each team member from the Sharing panel. +-- +-- Until now the only display-name source was tc.events.by_user_name — the JWT `name` +-- claim captured per action (see 20260707000006), which is normally NULL in dev-auth +-- mode and never editable. This migration adds the durable, editable source: +-- +-- • tc.members.display_name (nullable text) — the canonical per-collection name. +-- • tc.resolve_member_display now prefers it, keeping the JWT-claim capture as a +-- fallback — so get_collection_state / get_changes / get_book_manifest (all of +-- which already report locked_by_name via this helper, unchanged signatures) +-- pick the new source up with no further changes. +-- • tc.members_list now returns display_name so the Sharing panel can show/edit it. +-- • tc.members_set_display_name — admin may set anyone's name; a claimed member may +-- set their own. Blank/whitespace clears back to NULL (= fall back to email). + +-- --------------------------------------------------------------------------- +-- Column +-- --------------------------------------------------------------------------- +ALTER TABLE tc.members ADD COLUMN display_name text; + +COMMENT ON COLUMN tc.members.display_name IS + 'Human-readable name shown in place of the email wherever the member is displayed ' + '(checkout status, history, sharing panel). NULL = none set; display falls back to ' + 'email. Set via tc.members_set_display_name (admin, or the claimed member themselves).'; + +-- --------------------------------------------------------------------------- +-- resolve_member_display: prefer the durable column, keep the JWT-claim fallback. +-- Same signature as 20260707000006, so the current get_collection_state / get_changes / +-- get_book_manifest bodies (latest: 20260711000003) need no re-creation. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.resolve_member_display( + p_collection_id uuid, + p_user_id text, + OUT email text, + OUT display_name text +) +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT + m.email, + COALESCE( + m.display_name, + ( + SELECT e.by_user_name + FROM tc.events e + WHERE e.collection_id = p_collection_id + AND e.by_user_id = p_user_id + AND e.by_user_name IS NOT NULL + ORDER BY e.id DESC + LIMIT 1 + ) + ) + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND m.user_id = p_user_id + LIMIT 1; +$$; + +COMMENT ON FUNCTION tc.resolve_member_display(uuid, text) IS + 'Best-effort resolution of a locked_by/created_by user_id to a display email ' + '(from tc.members, authoritative) and display name. v1.6 (20260713000001): prefers ' + 'the durable tc.members.display_name; falls back to the most recent ' + 'tc.events.by_user_name JWT-claim capture (often NULL in dev-auth mode). Returns an ' + 'all-NULL row (never an error) when p_user_id is NULL or unknown.'; + +-- --------------------------------------------------------------------------- +-- members_list: add display_name. Return type changes, so DROP + CREATE (the +-- 20260711000003 checkout_book precedent) and re-GRANT. +-- --------------------------------------------------------------------------- +DROP FUNCTION tc.members_list(uuid); + +CREATE FUNCTION tc.members_list( + p_collection_id uuid +) +RETURNS TABLE ( + id bigint, + email text, + display_name text, + role tc.member_role, + user_id text, + added_by text, + added_at timestamptz, + claimed_at timestamptz +) +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT m.id, m.email, m.display_name, m.role, m.user_id, m.added_by, m.added_at, + m.claimed_at + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND tc.is_member(p_collection_id) -- membership gate + ORDER BY m.email +$$; + +COMMENT ON FUNCTION tc.members_list(uuid) IS + 'CONTRACTS.md: members list — returns approved-accounts for the collection. ' + 'Any member may call this. v1.6 (20260713000001): rows also carry display_name.'; + +GRANT EXECUTE ON FUNCTION tc.members_list(uuid) TO authenticated; + +-- --------------------------------------------------------------------------- +-- members_set_display_name(collection_id uuid, member_id bigint, display_name text) +-- --------------------------------------------------------------------------- +-- Admin may set any member's name; a claimed member may set their own (the Sharing +-- panel is admin-edit-only for now, but the permission model anticipates a +-- set-my-own-name UI). Blank/whitespace input clears the name back to NULL. +CREATE FUNCTION tc.members_set_display_name( + p_collection_id uuid, + p_member_id bigint, + p_display_name text +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_target_user_id text; + v_name text; +BEGIN + -- Membership gate first, so non-members learn nothing about member row ids. + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + SELECT user_id INTO v_target_user_id + FROM tc.members + WHERE id = p_member_id AND collection_id = p_collection_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'member_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_admin(p_collection_id) + AND (v_target_user_id IS NULL OR v_target_user_id <> tc.current_user_id()) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + v_name := NULLIF(btrim(p_display_name), ''); + IF char_length(v_name) > 100 THEN + RAISE EXCEPTION 'display_name_too_long' USING ERRCODE = '22001'; + END IF; + + UPDATE tc.members + SET display_name = v_name + WHERE id = p_member_id + AND collection_id = p_collection_id; +END; +$$; + +COMMENT ON FUNCTION tc.members_set_display_name(uuid, bigint, text) IS + 'CONTRACTS.md v1.6: members set_display_name — admin may set any member''s display ' + 'name; a claimed member may set their own. Trims; blank clears to NULL (display ' + 'falls back to email). Max 100 chars.'; + +GRANT EXECUTE ON FUNCTION tc.members_set_display_name(uuid, bigint, text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- get_changes: event rows gain by_display_name (additive) so history shows the +-- CURRENT durable display name, not just the JWT-claim capture frozen into +-- by_user_name at event time. Full recreation of the 20260711000003 version with +-- only that addition (same signature, so CREATE OR REPLACE). +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.get_changes( + p_collection_id uuid, + p_since_event_id bigint +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_events jsonb; + v_books jsonb; +BEGIN + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Events since cursor + SELECT jsonb_agg(row_to_json(e)::jsonb ORDER BY e.id) + INTO v_events + FROM ( + SELECT + e.id, + e.book_id, + e.type, + e.by_user_id, + e.by_user_name, + e.by_email, + erd.display_name AS by_display_name, + e.book_version_seq, + e.lock_info, + e.book_name, + e.group_key, + e.message, + e.bloom_version, + e.occurred_at + FROM tc.events e + LEFT JOIN LATERAL tc.resolve_member_display(e.collection_id, e.by_user_id) erd + ON true + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY e.id + ) e; + + -- Touched book rows (distinct books referenced in those events) + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_seat, + b.locked_at, + b.deleted_at, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + + RETURN jsonb_build_object( + 'events', COALESCE(v_events, '[]'::jsonb), + 'books', COALESCE(v_books, '[]'::jsonb), + 'max_event_id', ( + SELECT max(id) FROM tc.events + WHERE collection_id = p_collection_id + AND id > p_since_event_id + ) + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_changes(uuid, bigint) IS + 'CONTRACTS.md: get_changes — events + touched book rows since the cursor. ' + 'Used for polling (60s fallback) and realtime reconnect catch-up. v1.2 (20260707000006): ' + 'touched book rows also carry locked_by_email/locked_by_name for display. v1.5 ' + '(20260711000003): touched book rows also carry locked_seat. v1.6 (20260713000001): ' + 'event rows also carry by_display_name (the current durable display name of by_user_id).'; diff --git a/supabase/migrations/20260713000002_tc_members_pending_uq_fix.sql b/supabase/migrations/20260713000002_tc_members_pending_uq_fix.sql new file mode 100644 index 000000000000..30a93efbd1b5 --- /dev/null +++ b/supabase/migrations/20260713000002_tc_members_pending_uq_fix.sql @@ -0,0 +1,27 @@ +-- ============================================================================= +-- Migration: allow multiple PENDING invitations per collection (constraint fix) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- Found by 03_tc_member_display_name_test.sql's fixture (13 Jul 2026): the original +-- schema declared +-- +-- CONSTRAINT members_claimed_user_uq UNIQUE NULLS NOT DISTINCT (collection_id, user_id) +-- +-- NULLS NOT DISTINCT makes two unclaimed rows (user_id IS NULL) collide, so a second +-- invitation raises 23505 from members_add before the first invitee claims — i.e. a +-- collection could only ever have ONE pending invitation at a time. That contradicts +-- the schema's own documented intent ("A claimed user may appear at most once per +-- collection (UNIQUE WHERE user_id IS NOT NULL)"); dogfooding never tripped it only +-- because invites happened to alternate with claims. members_add's ON CONFLICT clause +-- targets the (collection_id, email) constraint, so this error escaped it unhandled. +-- +-- Fix: same constraint with default NULLS DISTINCT semantics — claimed users stay +-- unique per collection, unclaimed rows don't collide. +ALTER TABLE tc.members DROP CONSTRAINT members_claimed_user_uq; +ALTER TABLE tc.members ADD CONSTRAINT members_claimed_user_uq + UNIQUE (collection_id, user_id); + +COMMENT ON CONSTRAINT members_claimed_user_uq ON tc.members IS + 'A claimed user appears at most once per collection. NULL user_id rows (pending ' + 'invitations) do not collide (default NULLS DISTINCT; fixed 20260713000002 — the ' + 'original NULLS NOT DISTINCT allowed only one pending invitation per collection).'; diff --git a/supabase/migrations/20260716000001_tc_get_collection_file_manifest.sql b/supabase/migrations/20260716000001_tc_get_collection_file_manifest.sql new file mode 100644 index 000000000000..3d2ca9f8cc69 --- /dev/null +++ b/supabase/migrations/20260716000001_tc_get_collection_file_manifest.sql @@ -0,0 +1,81 @@ +-- ============================================================================= +-- Migration: get_collection_file_manifest RPC (CONTRACTS.md, additive) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- E9: collection-file group downloads used to LIST the S3 prefix and re-fetch +-- every object every time, with no per-file change detection and no consistent +-- snapshot (a mid-write listing could read an uncommitted mix). The committed +-- per-file manifest already exists durably in tc.collection_group_files +-- (path, sha256, size_bytes, s3_version_id — replaced atomically by +-- collection-files-finish); it just wasn't exposed for reads. This RPC exposes +-- it, mirroring get_book_manifest, so the client downloads only files whose +-- sha256 changed, pinned to the committed s3_version_id (a consistent snapshot, +-- same guarantee book content already has). +-- +-- Returns: { groupKey, version, files: [{path, sha256, size, s3VersionId}] } +-- A group that has never been written (no row in tc.collection_file_groups) +-- returns { version: 0, files: [] } rather than erroring — "nothing to +-- download" is a normal state for the allowed-words/sample-texts groups most +-- collections never populate. +-- Errors: not_a_member (42501). +CREATE OR REPLACE FUNCTION tc.get_collection_file_manifest( + p_collection_id uuid, + p_group_key text +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_group_id bigint; + v_version bigint; + v_files jsonb; +BEGIN + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + SELECT id, version INTO v_group_id, v_version + FROM tc.collection_file_groups + WHERE collection_id = p_collection_id AND group_key = p_group_key; + + IF NOT FOUND THEN + RETURN jsonb_build_object( + 'groupKey', p_group_key, + 'version', 0, + 'files', '[]'::jsonb + ); + END IF; + + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'path', gf.path, + 'sha256', gf.sha256, + 'size', gf.size_bytes, + 's3VersionId', gf.s3_version_id + ) + ORDER BY gf.path + ), + '[]'::jsonb + ) + INTO v_files + FROM tc.collection_group_files gf + WHERE gf.group_id = v_group_id; + + RETURN jsonb_build_object( + 'groupKey', p_group_key, + 'version', v_version, + 'files', v_files + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_collection_file_manifest(uuid, text) IS + 'E9: per-file current manifest for one collection-file group ' + '(path, sha256, size, s3VersionId) from tc.collection_group_files, used by the ' + 'download path to fetch only changed files pinned to their committed s3_version_id. ' + 'Mirrors get_book_manifest; a never-written group returns version 0 / empty files.'; + +GRANT EXECUTE ON FUNCTION tc.get_collection_file_manifest(uuid, text) TO authenticated; diff --git a/supabase/snippets/.gitkeep b/supabase/snippets/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/supabase/tests/01_tc_schema_test.sql b/supabase/tests/01_tc_schema_test.sql new file mode 100644 index 000000000000..e8199b23ad16 --- /dev/null +++ b/supabase/tests/01_tc_schema_test.sql @@ -0,0 +1,530 @@ +-- ============================================================================= +-- pgTAP tests: Cloud Team Collections — tc schema, RLS, RPCs +-- ============================================================================= +-- NOTE: These tests are AUTHORED but UNRUN — no Docker / Supabase CLI on this +-- machine. Run with: +-- supabase start +-- supabase test db +-- or: +-- psql -U postgres -h localhost -p 54322 -f supabase/tests/01_tc_schema_test.sql +-- +-- Requires: pgTAP extension (bundled with local Supabase), pgtap schema accessible. +-- ============================================================================= + +BEGIN; + +-- Load pgTAP +SELECT plan(48); -- update count when tests are added/removed + +-- ============================================================================= +-- 0. Sanity: schema and key tables exist +-- ============================================================================= + +SELECT has_schema('tc', 'tc schema exists'); + +SELECT has_table('tc', 'collections', 'tc.collections table exists'); +SELECT has_table('tc', 'members', 'tc.members table exists'); +SELECT has_table('tc', 'books', 'tc.books table exists'); +SELECT has_table('tc', 'versions', 'tc.versions table exists'); +SELECT has_table('tc', 'version_files', 'tc.version_files table exists'); +SELECT has_table('tc', 'collection_file_groups','tc.collection_file_groups table exists'); +SELECT has_table('tc', 'collection_group_files','tc.collection_group_files table exists'); +SELECT has_table('tc', 'color_palette_entries', 'tc.color_palette_entries table exists'); +SELECT has_table('tc', 'events', 'tc.events table exists'); +SELECT has_table('tc', 'checkin_transactions', 'tc.checkin_transactions table exists'); + +SELECT has_function('tc', 'jwt_email_verified', 'tc.jwt_email_verified() exists'); +SELECT has_function('tc', 'create_collection', 'tc.create_collection() exists'); +SELECT has_function('tc', 'claim_memberships', 'tc.claim_memberships() exists'); +SELECT has_function('tc', 'checkout_book', 'tc.checkout_book() exists'); + +-- ============================================================================= +-- Test fixture helpers +-- ============================================================================= +-- Create two test users and a collection for use in subsequent tests. +-- We impersonate JWT callers via set_config so SECURITY DEFINER functions +-- can read auth.jwt(). In real Supabase these come from the auth layer. + +CREATE SCHEMA IF NOT EXISTS tests; + +-- Helper: set a fake JWT so auth.jwt() returns a known sub/email +CREATE OR REPLACE FUNCTION tests.set_jwt( + p_sub text, + p_email text, + p_email_verified boolean DEFAULT true +) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + v_token text; +BEGIN + -- Build a minimal JWT payload (no real signing needed for pgTAP in local DB; + -- Supabase local instance accepts JWTs signed with the project's anon key, + -- but for pgTAP we use set_config to inject the claims directly into the + -- session so auth.jwt() returns them). + PERFORM set_config( + 'request.jwt.claims', + json_build_object( + 'sub', p_sub, + 'email', p_email, + 'email_verified', p_email_verified, + 'role', 'authenticated', + 'aud', 'authenticated' + )::text, + true -- local to transaction + ); +END; +$$; + +-- ============================================================================= +-- 1. jwt_email_verified() +-- ============================================================================= + +-- 1a. Firebase-style: email_verified = true +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"firebase-uid-abc","email":"alice@example.com","email_verified":true,"role":"authenticated"}', + true); +END; +$$; +SELECT ok(tc.jwt_email_verified(), '1a: jwt_email_verified() true for Firebase email_verified=true'); + +-- 1b. Firebase-style: email_verified = false +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"firebase-uid-abc","email":"alice@example.com","email_verified":false,"role":"authenticated"}', + true); +END; +$$; +SELECT ok(NOT tc.jwt_email_verified(), '1b: jwt_email_verified() false for Firebase email_verified=false'); + +-- 1c. Local GoTrue: no email_verified claim, role = 'authenticated' +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","email":"dev@localhost","role":"authenticated"}', + true); +END; +$$; +SELECT ok(tc.jwt_email_verified(), '1c: jwt_email_verified() true for local GoTrue (no claim, role=authenticated)'); + +-- ============================================================================= +-- 2. create_collection + RLS: member can read their collection +-- ============================================================================= + +-- Set up as user Alice +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +-- Alice creates a collection +SELECT lives_ok( + $$SELECT tc.create_collection('a0000000-0000-0000-0000-000000000001'::uuid, 'Alice Test Collection')$$, + '2a: create_collection succeeds for authenticated user' +); + +-- Alice can see her collection via RLS. Must run as the authenticated role — the suite's +-- postgres superuser bypasses RLS, which would make this assertion pass vacuously. +SET LOCAL ROLE authenticated; + +SELECT ok( + (SELECT count(*) = 1 FROM tc.collections WHERE id = 'a0000000-0000-0000-0000-000000000001'), + '2b: Alice can SELECT her collection (RLS: is_member)' +); + +-- Alice is an admin member +SELECT ok( + (SELECT role = 'admin' FROM tc.members + WHERE collection_id = 'a0000000-0000-0000-0000-000000000001' + AND user_id = 'user-alice-001'), + '2c: Alice is recorded as admin of her collection' +); + +RESET ROLE; + +-- ============================================================================= +-- 3. RLS matrix: non-member cannot read +-- ============================================================================= + +-- Set up as user Bob (not a member) +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-bob-002","email":"bob@example.com","email_verified":true,"role":"authenticated","name":"Bob"}', + true); +END; +$$; + +-- RLS only applies to non-superuser roles: the suite runs as postgres, which BYPASSES +-- row security, so these direct-table assertions must run as the authenticated role +-- (the role PostgREST uses for JWT-carrying requests). RESET ROLE afterwards so later +-- fixture writes run as postgres again. +SET LOCAL ROLE authenticated; + +SELECT ok( + (SELECT count(*) = 0 FROM tc.collections WHERE id = 'a0000000-0000-0000-0000-000000000001'), + '3a: Non-member Bob cannot SELECT Alice''s collection (RLS)' +); + +SELECT ok( + (SELECT count(*) = 0 FROM tc.books + WHERE collection_id = 'a0000000-0000-0000-0000-000000000001'), + '3b: Non-member Bob cannot SELECT books in Alice''s collection (RLS)' +); + +SELECT ok( + (SELECT count(*) = 0 FROM tc.events + WHERE collection_id = 'a0000000-0000-0000-0000-000000000001'), + '3c: Non-member Bob cannot SELECT events in Alice''s collection (RLS)' +); + +RESET ROLE; + +-- ============================================================================= +-- 4. claim_memberships requires verified email +-- ============================================================================= + +-- Add Bob as an approved member (Alice adds him) +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +SELECT lives_ok( + $$SELECT tc.members_add('a0000000-0000-0000-0000-000000000001', 'bob@example.com', 'member')$$, + '4a: Admin Alice can add Bob as approved member' +); + +-- Bob with unverified email cannot claim +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-bob-002","email":"bob@example.com","email_verified":false,"role":"authenticated"}', + true); +END; +$$; + +SELECT throws_ok( + $$SELECT tc.claim_memberships()$$, + '28000', -- invalid_authorization_specification, raised by claim_memberships + NULL, + '4b: claim_memberships raises when email_verified=false' +); + +-- Bob with verified email can claim +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-bob-002","email":"bob@example.com","email_verified":true,"role":"authenticated"}', + true); +END; +$$; + +SELECT lives_ok( + $$SELECT tc.claim_memberships()$$, + '4c: claim_memberships succeeds for verified Bob' +); + +SELECT ok( + (SELECT user_id = 'user-bob-002' FROM tc.members + WHERE collection_id = 'a0000000-0000-0000-0000-000000000001' + AND email = 'bob@example.com'), + '4d: Bob''s user_id is filled after claiming' +); + +-- ============================================================================= +-- 5. checkout_book concurrency: exactly one winner +-- ============================================================================= +-- Insert a test book directly (SECURITY DEFINER helper — RLS bypassed for setup). +INSERT INTO tc.books (id, collection_id, instance_id, name, created_by) +VALUES ( + 'b0000000-0000-0000-0000-000000000001'::uuid, + 'a0000000-0000-0000-0000-000000000001'::uuid, + 'b0000000-0000-0000-0000-000000000002'::uuid, + 'Test Book', + 'user-alice-001' +); + +-- Simulate two concurrent checkout attempts using two separate DO blocks. +-- We use advisory locks to test the conditional UPDATE serialization. +-- In practice the conditional UPDATE is atomic at READ COMMITTED; this test +-- verifies that calling checkout_book twice yields exactly one success. + +-- Alice checks out +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +SELECT ok( + (SELECT (tc.checkout_book('b0000000-0000-0000-0000-000000000001', 'AliceMachine')) ->> 'success' = 'true'), + '5a: Alice wins the checkout race (first call)' +); + +-- Bob tries to check out the same book — should fail +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-bob-002","email":"bob@example.com","email_verified":true,"role":"authenticated","name":"Bob"}', + true); +END; +$$; + +SELECT ok( + (SELECT (tc.checkout_book('b0000000-0000-0000-0000-000000000001', 'BobMachine')) ->> 'success' = 'false'), + '5b: Bob loses the checkout race (lock already held)' +); + +-- Exactly one CheckOut event (type=0) emitted +SELECT ok( + (SELECT count(*) = 1 FROM tc.events + WHERE book_id = 'b0000000-0000-0000-0000-000000000001' + AND type = 0), + '5c: exactly one CheckOut event (type=0) emitted' +); + +-- ============================================================================= +-- 6. last-admin guard +-- ============================================================================= + +-- Attempt to remove Alice (the only admin) should fail +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +SELECT throws_ok( + $$SELECT tc.members_remove( + 'a0000000-0000-0000-0000-000000000001', + (SELECT id FROM tc.members WHERE collection_id = 'a0000000-0000-0000-0000-000000000001' + AND user_id = 'user-alice-001') + )$$, + 'P0001', + NULL, + '6a: Removing the last admin raises last_admin_guard' +); + +-- Demoting Alice to member should also fail +SELECT throws_ok( + $$UPDATE tc.members + SET role = 'member' + WHERE collection_id = 'a0000000-0000-0000-0000-000000000001' + AND user_id = 'user-alice-001'$$, + 'P0001', + NULL, + '6b: Demoting the last admin raises last_admin_guard' +); + +-- ============================================================================= +-- 7. get_changes cursor +-- ============================================================================= + +-- Log an event as Alice +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +SELECT lives_ok( + $$SELECT tc.log_event( + 'a0000000-0000-0000-0000-000000000001', + 'b0000000-0000-0000-0000-000000000001', + 100, -- WorkPreservedLocally + 'test incident', + 'Test Book', + '6.5.0' + )$$, + '7a: log_event succeeds for a member' +); + +-- get_changes with cursor = 0 returns events +SELECT ok( + (SELECT jsonb_array_length( + (tc.get_changes('a0000000-0000-0000-0000-000000000001', 0)) -> 'events' + ) > 0), + '7b: get_changes(since=0) returns at least one event' +); + +-- get_changes with cursor = max returns empty +SELECT ok( + (SELECT jsonb_array_length( + (tc.get_changes( + 'a0000000-0000-0000-0000-000000000001', + (SELECT max(id) FROM tc.events WHERE collection_id = 'a0000000-0000-0000-0000-000000000001') + )) -> 'events' + ) = 0), + '7c: get_changes(since=max_id) returns empty events' +); + +-- ============================================================================= +-- 8. Tombstone / undelete +-- ============================================================================= + +-- Alice must hold the lock to delete (she already holds it from checkout in test 5a) +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +SELECT lives_ok( + $$SELECT tc.delete_book('b0000000-0000-0000-0000-000000000001')$$, + '8a: delete_book succeeds when caller holds the lock' +); + +SELECT ok( + (SELECT deleted_at IS NOT NULL FROM tc.books + WHERE id = 'b0000000-0000-0000-0000-000000000001'), + '8b: deleted_at is set after delete_book' +); + +-- Admin (Alice) can undelete +SELECT lives_ok( + $$SELECT tc.undelete_book('b0000000-0000-0000-0000-000000000001')$$, + '8c: admin can undelete a tombstoned book' +); + +SELECT ok( + (SELECT deleted_at IS NULL FROM tc.books + WHERE id = 'b0000000-0000-0000-0000-000000000001'), + '8d: deleted_at is NULL after undelete_book' +); + +-- ============================================================================= +-- 9. Live-name uniqueness: tombstoned names are reusable +-- ============================================================================= + +-- Delete the existing book to tombstone the name 'Test Book' +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +-- Re-checkout so we can delete again +SELECT tc.checkout_book('b0000000-0000-0000-0000-000000000001', 'AliceMachine'); +SELECT tc.delete_book('b0000000-0000-0000-0000-000000000001'); + +-- Inserting a new book with the same name should succeed (tombstone excluded from index) +SELECT lives_ok( + $$INSERT INTO tc.books (id, collection_id, instance_id, name, created_by) + VALUES ( + 'b0000000-0000-0000-0000-000000000099'::uuid, + 'a0000000-0000-0000-0000-000000000001'::uuid, + 'b0000000-0000-0000-0000-000000000098'::uuid, + 'Test Book', + 'user-alice-001' + )$$, + '9a: inserting a live book with tombstoned name succeeds (name reuse)' +); + +-- But a second live book with the same name should fail +SELECT throws_ok( + $$INSERT INTO tc.books (id, collection_id, instance_id, name, created_by) + VALUES ( + 'b0000000-0000-0000-0000-000000000097'::uuid, + 'a0000000-0000-0000-0000-000000000001'::uuid, + 'b0000000-0000-0000-0000-000000000096'::uuid, + 'Test Book', + 'user-alice-001' + )$$, + '23505', -- unique_violation + NULL, + '9b: inserting a second live book with same name raises unique_violation' +); + +-- ============================================================================= +-- 10. get_collection_file_manifest (E9) +-- ============================================================================= +-- Seed a collection-file group + its committed files directly (pgTAP can't drive +-- the two-phase S3 upload). Alice is an admin/member of the collection above. +DO $$ +DECLARE + v_group_id bigint; +BEGIN + PERFORM tests.set_jwt('user-alice-001', 'alice@example.com'); + INSERT INTO tc.collection_file_groups (collection_id, group_key, version, updated_by) + VALUES ('a0000000-0000-0000-0000-000000000001', 'other', 3, 'user-alice-001') + RETURNING id INTO v_group_id; + INSERT INTO tc.collection_group_files (group_id, path, sha256, size_bytes, s3_version_id) + VALUES + (v_group_id, 'bloomCollection', 'sha-a', 10, 'sv-1'), + (v_group_id, 'customCollectionStyles.css', 'sha-b', 20, 'sv-2'); +END +$$; + +SELECT is( + (tc.get_collection_file_manifest( + 'a0000000-0000-0000-0000-000000000001', 'other') ->> 'version'), + '3', + '10a: get_collection_file_manifest returns the group version' +); +SELECT is( + jsonb_array_length(tc.get_collection_file_manifest( + 'a0000000-0000-0000-0000-000000000001', 'other') -> 'files'), + 2, + '10b: manifest returns every committed file in the group' +); +SELECT is( + (tc.get_collection_file_manifest( + 'a0000000-0000-0000-0000-000000000001', 'other') + -> 'files' -> 0 ->> 's3VersionId'), + 'sv-1', + '10c: manifest files carry the pinned s3VersionId (ordered by path)' +); +SELECT is( + (tc.get_collection_file_manifest( + 'a0000000-0000-0000-0000-000000000001', 'sample-texts') ->> 'version'), + '0', + '10d: a never-written group returns version 0 (not an error)' +); +SELECT is( + jsonb_array_length(tc.get_collection_file_manifest( + 'a0000000-0000-0000-0000-000000000001', 'sample-texts') -> 'files'), + 0, + '10e: a never-written group returns empty files' +); + +-- A non-member is refused. +DO $$ +BEGIN + PERFORM tests.set_jwt('user-carol-999', 'carol@example.com'); +END +$$; +SELECT throws_ok( + $$SELECT tc.get_collection_file_manifest( + 'a0000000-0000-0000-0000-000000000001', 'other')$$, + '42501', + NULL, + '10f: get_collection_file_manifest refuses a non-member' +); + +-- ============================================================================= +-- Finish +-- ============================================================================= + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/02_tc_checkout_takeover_test.sql b/supabase/tests/02_tc_checkout_takeover_test.sql new file mode 100644 index 000000000000..e02af48fb9ca --- /dev/null +++ b/supabase/tests/02_tc_checkout_takeover_test.sql @@ -0,0 +1,230 @@ +-- ============================================================================= +-- pgTAP tests: tc.checkout_book_takeover (dogfood batch 1, item 9 -- account-switch +-- checkout takeover; extended by 20260711000003's per-collection-copy "seat", bug #0) +-- ============================================================================= +-- Run against a local Supabase stack: +-- supabase start +-- supabase test db +-- ============================================================================= + +BEGIN; + +SELECT plan(23); + +SELECT has_function('tc', 'checkout_book_takeover', 'tc.checkout_book_takeover() exists'); + +-- Helper: set a fake JWT so auth.jwt() returns a known sub/email (same helper as +-- 01_tc_schema_test.sql; re-declared here since each test file runs standalone). +CREATE SCHEMA IF NOT EXISTS tests; + +CREATE OR REPLACE FUNCTION tests.set_jwt( + p_sub text, + p_email text, + p_email_verified boolean DEFAULT true +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM set_config( + 'request.jwt.claims', + json_build_object( + 'sub', p_sub, + 'email', p_email, + 'email_verified', p_email_verified, + 'role', 'authenticated', + 'aud', 'authenticated' + )::text, + true + ); +END; +$$; + +-- ============================================================================= +-- Fixture: a collection with Alice (admin) and Bob (member, claimed), a book Alice has +-- checked out on "SharedMachine" in her own local copy ("seat-alice-copy"). Uses the +-- public RPCs (create_collection/members_add/claim_memberships), matching +-- 01_tc_schema_test.sql's own fixture convention. +-- ============================================================================= + +SELECT tests.set_jwt('user-alice-tko', 'alice-tko@example.com', true); + +SELECT lives_ok( + $$SELECT tc.create_collection('c0000000-0000-0000-0000-00000000a001'::uuid, 'Takeover Test Collection')$$, + '0a: create_collection succeeds for Alice' +); + +SELECT lives_ok( + $$SELECT tc.members_add('c0000000-0000-0000-0000-00000000a001', 'bob-tko@example.com', 'member')$$, + '0b: Alice adds Bob as an approved member' +); + +SELECT tests.set_jwt('user-bob-tko', 'bob-tko@example.com', true); + +SELECT lives_ok( + $$SELECT tc.claim_memberships()$$, + '0c: Bob claims his membership' +); + +SELECT tests.set_jwt('user-alice-tko', 'alice-tko@example.com', true); + +-- Insert a test book directly (SECURITY DEFINER helper — RLS bypassed for setup), matching +-- 01_tc_schema_test.sql section 5's own convention. +INSERT INTO tc.books (id, collection_id, instance_id, name, created_by) +VALUES ( + 'b0000000-0000-0000-0000-00000000a001'::uuid, + 'c0000000-0000-0000-0000-00000000a001'::uuid, + 'b0000000-0000-0000-0000-00000000a002'::uuid, + 'Takeover Test Book', + 'user-alice-tko' +); + +-- Alice checks the book out on SharedMachine, seat "seat-alice-copy" (ordinary +-- checkout_book, already tested elsewhere -- used here purely as fixture setup). +SELECT tc.checkout_book('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-alice-copy'); + +SELECT ok( + (SELECT locked_seat FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'seat-alice-copy', + '0d: checkout_book records the caller''s seat with the lock' +); + +-- ============================================================================= +-- 1. Bob (different account) CANNOT take over across machines or across seats +-- ============================================================================= + +SELECT tests.set_jwt('user-bob-tko', 'bob-tko@example.com', true); + +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'BobsOwnMachine', 'seat-alice-copy')) ->> 'success' = 'false'), + '1a: Bob cannot take over Alice''s lock from a DIFFERENT machine' +); + +SELECT ok( + (SELECT locked_by FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'user-alice-tko', + '1b: the lock still belongs to Alice after the cross-machine attempt' +); + +-- bug #0 (e2e-4's scenario): same machine but a DIFFERENT local copy of the collection. +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-bob-copy')) ->> 'success' = 'false'), + '1c: Bob cannot take over from the SAME machine but a DIFFERENT seat (separate local copy)' +); + +SELECT ok( + (SELECT locked_by FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'user-alice-tko', + '1d: the lock still belongs to Alice after the wrong-seat attempt' +); + +-- A pre-seat caller (p_seat defaults to NULL) can never take over. +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine')) ->> 'success' = 'false'), + '1e: a caller supplying no seat cannot take over' +); + +-- ============================================================================= +-- 2. Bob CAN take over a lock held on the SAME machine in the SAME seat (the true +-- shared-computer scenario: account B opens the exact local folder account A used) +-- ============================================================================= + +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-alice-copy')) ->> 'success' = 'true'), + '2a: Bob takes over Alice''s same-machine same-seat lock' +); + +SELECT ok( + (SELECT locked_by FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'user-bob-tko', + '2b: the lock now belongs to Bob' +); + +SELECT ok( + (SELECT locked_by_machine FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'SharedMachine', + '2c: the machine is unchanged (still SharedMachine)' +); + +SELECT ok( + (SELECT locked_seat FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'seat-alice-copy', + '2d: the seat is unchanged (still the shared local copy)' +); + +SELECT ok( + (SELECT count(*) = 1 FROM tc.events + WHERE book_id = 'b0000000-0000-0000-0000-00000000a001' + AND type = 0 + AND by_user_id = 'user-bob-tko'), + '2e: exactly one CheckOut event (type=0) recorded for Bob''s takeover' +); + +-- ============================================================================= +-- 3. Calling it again for the CURRENT holder is a harmless no-op (not a new "takeover") +-- ============================================================================= + +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-alice-copy')) ->> 'success' = 'false'), + '3a: re-calling takeover when the caller already holds the lock reports no change' +); + +SELECT ok( + (SELECT count(*) = 1 FROM tc.events + WHERE book_id = 'b0000000-0000-0000-0000-00000000a001' + AND type = 0 + AND by_user_id = 'user-bob-tko'), + '3b: no duplicate CheckOut event was emitted for the no-op re-call' +); + +-- ============================================================================= +-- 4. A non-member cannot take over any lock +-- ============================================================================= + +SELECT tests.set_jwt('user-carol-tko', 'carol-tko@example.com', true); + +-- PT403 (not 42501): checkout_book_takeover raises the schema-wide PT### passthrough +-- codes as of 20260711000002. +SELECT throws_ok( + $$SELECT tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-alice-copy')$$, + 'PT403', + NULL, + '4a: a non-member cannot take over a lock (not_a_member)' +); + +-- ============================================================================= +-- 5. Unlock clears the seat (books_clear_seat_on_unlock trigger) +-- ============================================================================= + +SELECT tests.set_jwt('user-bob-tko', 'bob-tko@example.com', true); + +SELECT lives_ok( + $$SELECT tc.unlock_book('b0000000-0000-0000-0000-00000000a001')$$, + '5a: the current holder can unlock' +); + +SELECT ok( + (SELECT locked_seat IS NULL FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001'), + '5b: locked_seat is cleared with the lock (trigger)' +); + +-- ============================================================================= +-- 6. A lock with NO recorded seat (legacy/pre-seat, or checkin_start_tx's take-if-free +-- path) can never be taken over — fail-safe. +-- ============================================================================= + +SELECT tests.set_jwt('user-alice-tko', 'alice-tko@example.com', true); + +SELECT ok( + (SELECT (tc.checkout_book('b0000000-0000-0000-0000-00000000a001', 'SharedMachine')) ->> 'success' = 'true'), + '6a: a pre-seat checkout (no seat argument) still succeeds' +); + +SELECT tests.set_jwt('user-bob-tko', 'bob-tko@example.com', true); + +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-bob-copy')) ->> 'success' = 'false'), + '6b: a NULL stored seat never matches — takeover refused (fail-safe)' +); + +SELECT ok( + (SELECT locked_by FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'user-alice-tko', + '6c: the null-seat lock still belongs to Alice' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/03_tc_member_display_name_test.sql b/supabase/tests/03_tc_member_display_name_test.sql new file mode 100644 index 000000000000..6ff279f47d89 --- /dev/null +++ b/supabase/tests/03_tc_member_display_name_test.sql @@ -0,0 +1,294 @@ +-- ============================================================================= +-- pgTAP tests: tc.members.display_name + tc.members_set_display_name +-- (dogfood batch 1, John's 13 Jul 2026 member-display-name request; +-- migration 20260713000001) +-- ============================================================================= +-- Run against a local Supabase stack: +-- supabase start +-- supabase test db +-- ============================================================================= + +BEGIN; + +SELECT plan(24); + +SELECT has_column('tc', 'members', 'display_name', 'tc.members.display_name exists'); +SELECT has_function( + 'tc', 'members_set_display_name', + 'tc.members_set_display_name() exists' +); + +-- Helper: set a fake JWT so auth.jwt() returns a known sub/email (same helper as +-- 01_tc_schema_test.sql; re-declared here since each test file runs standalone). +CREATE SCHEMA IF NOT EXISTS tests; + +CREATE OR REPLACE FUNCTION tests.set_jwt( + p_sub text, + p_email text, + p_email_verified boolean DEFAULT true +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM set_config( + 'request.jwt.claims', + json_build_object( + 'sub', p_sub, + 'email', p_email, + 'email_verified', p_email_verified, + 'role', 'authenticated', + 'aud', 'authenticated' + )::text, + true + ); +END; +$$; + +-- ============================================================================= +-- Fixture: Alice (admin), Bob (member, claimed), Carol (approved, never claims). +-- Uses the public RPCs, matching the other test files' fixture convention. +-- ============================================================================= + +SELECT tests.set_jwt('user-alice-dn', 'alice-dn@example.com', true); + +SELECT lives_ok( + $$SELECT tc.create_collection('c0000000-0000-0000-0000-00000000d001'::uuid, 'Display Name Test Collection')$$, + '0a: create_collection succeeds for Alice' +); + +SELECT lives_ok( + $$SELECT tc.members_add('c0000000-0000-0000-0000-00000000d001', 'bob-dn@example.com', 'member')$$, + '0b: Alice adds Bob as an approved member' +); + +SELECT lives_ok( + $$SELECT tc.members_add('c0000000-0000-0000-0000-00000000d001', 'carol-dn@example.com', 'member')$$, + '0c: Alice adds Carol (who will stay unclaimed/pending)' +); + +SELECT tests.set_jwt('user-bob-dn', 'bob-dn@example.com', true); + +SELECT lives_ok( + $$SELECT tc.claim_memberships()$$, + '0d: Bob claims his membership' +); + +-- ============================================================================= +-- 1. Admin can set anyone's display name (claimed or pending) +-- ============================================================================= + +SELECT tests.set_jwt('user-alice-dn', 'alice-dn@example.com', true); + +SELECT lives_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'bob-dn@example.com'), + ' Bob the Builder ')$$, + '1a: admin sets a claimed member''s display name' +); + +SELECT is( + (SELECT ml.display_name + FROM tc.members_list('c0000000-0000-0000-0000-00000000d001') ml + WHERE ml.email = 'bob-dn@example.com'), + 'Bob the Builder', + '1b: members_list returns the (trimmed) display name' +); + +SELECT lives_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'carol-dn@example.com'), + 'Carol C')$$, + '1c: admin sets a PENDING (unclaimed) member''s display name' +); + +-- ============================================================================= +-- 2. Non-admin member: own name yes, anyone else's no +-- ============================================================================= + +SELECT tests.set_jwt('user-bob-dn', 'bob-dn@example.com', true); + +SELECT lives_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'bob-dn@example.com'), + 'Just Bob')$$, + '2a: a claimed non-admin member sets their OWN display name' +); + +SELECT is( + (SELECT m.display_name FROM tc.members m WHERE m.email = 'bob-dn@example.com'), + 'Just Bob', + '2b: the self-set name stuck' +); + +SELECT throws_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'alice-dn@example.com'), + 'Not Your Name')$$, + '42501', + 'admin_required', + '2c: a non-admin cannot set another member''s display name' +); + +SELECT throws_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'carol-dn@example.com'), + 'Not Your Name Either')$$, + '42501', + 'admin_required', + '2d: a non-admin cannot set an unclaimed member''s display name' +); + +-- Sanity: Alice's name was NOT changed by the refused 2c call. +SELECT is( + (SELECT m.display_name FROM tc.members m WHERE m.email = 'alice-dn@example.com'), + NULL, + '2e: the refused call did not write anything' +); + +-- ============================================================================= +-- 3. Non-member is refused before learning anything +-- ============================================================================= + +SELECT tests.set_jwt('user-mallory-dn', 'mallory-dn@example.com', true); + +SELECT throws_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + 1, + 'Intruder')$$, + '42501', + 'not_a_member', + '3: a non-member is refused (not_a_member, not member_not_found)' +); + +-- ============================================================================= +-- 4. Blank clears to NULL; unknown member id; over-long name +-- ============================================================================= + +SELECT tests.set_jwt('user-alice-dn', 'alice-dn@example.com', true); + +SELECT lives_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'bob-dn@example.com'), + ' ')$$, + '4a: blank/whitespace input is accepted' +); + +SELECT is( + (SELECT m.display_name FROM tc.members m WHERE m.email = 'bob-dn@example.com'), + NULL, + '4b: ...and clears the display name back to NULL' +); + +SELECT throws_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + 999999999, + 'Nobody')$$, + 'P0002', + 'member_not_found', + '4c: unknown member id raises member_not_found' +); + +SELECT throws_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'bob-dn@example.com'), + repeat('x', 101))$$, + '22001', + 'display_name_too_long', + '4d: a 101-char name raises display_name_too_long' +); + +-- ============================================================================= +-- 5. resolve_member_display precedence: durable column beats the JWT-claim +-- event capture; the event capture remains the fallback. +-- ============================================================================= + +-- A historical event carrying the JWT name claim for Bob (direct insert for setup, +-- matching the other files' fixture convention). +INSERT INTO tc.events (collection_id, type, by_user_id, by_user_name, by_email) +VALUES ( + 'c0000000-0000-0000-0000-00000000d001'::uuid, + 1, -- CheckIn + 'user-bob-dn', + 'JWT Bob', + 'bob-dn@example.com' +); + +-- Bob's display_name is NULL right now (cleared in 4a/4b) → fallback applies. +SELECT is( + (SELECT rd.display_name + FROM tc.resolve_member_display( + 'c0000000-0000-0000-0000-00000000d001'::uuid, 'user-bob-dn') rd), + 'JWT Bob', + '5a: with no durable name, the latest event by_user_name is the fallback' +); + +SELECT lives_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'bob-dn@example.com'), + 'Durable Bob')$$, + '5b: admin sets the durable name again' +); + +SELECT is( + (SELECT rd.display_name + FROM tc.resolve_member_display( + 'c0000000-0000-0000-0000-00000000d001'::uuid, 'user-bob-dn') rd), + 'Durable Bob', + '5c: the durable column now beats the event fallback' +); + +-- ...and the whole pipeline: a book Bob has checked out reports his durable name as +-- locked_by_name in get_collection_state (queried as Bob himself, since a +-- never-committed book is only visible to its locker). +INSERT INTO tc.books (id, collection_id, instance_id, name, created_by, locked_by, + locked_by_machine, locked_at) +VALUES ( + 'b0000000-0000-0000-0000-00000000d001'::uuid, + 'c0000000-0000-0000-0000-00000000d001'::uuid, + 'b0000000-0000-0000-0000-00000000d002'::uuid, + 'Display Name Test Book', + 'user-bob-dn', + 'user-bob-dn', + 'BobsMachine', + now() +); + +SELECT tests.set_jwt('user-bob-dn', 'bob-dn@example.com', true); + +SELECT is( + (SELECT b ->> 'locked_by_name' + FROM jsonb_array_elements( + tc.get_collection_state('c0000000-0000-0000-0000-00000000d001'::uuid) -> 'books' + ) b + WHERE b ->> 'name' = 'Display Name Test Book'), + 'Durable Bob', + '5d: get_collection_state book rows carry the durable name as locked_by_name' +); + +-- ...and history: get_changes event rows report the CURRENT durable name as +-- by_display_name, alongside the frozen at-event-time by_user_name ('JWT Bob'). +SELECT is( + (SELECT e ->> 'by_display_name' + FROM jsonb_array_elements( + tc.get_changes('c0000000-0000-0000-0000-00000000d001'::uuid, 0) -> 'events' + ) e + WHERE e ->> 'by_user_id' = 'user-bob-dn' + LIMIT 1), + 'Durable Bob', + '5e: get_changes event rows carry the current durable name as by_display_name' +); + +SELECT * FROM finish(); + +ROLLBACK;