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/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..058861ec63a2 --- /dev/null +++ b/Design/CloudTeamCollections/CONTRACTS.md @@ -0,0 +1,152 @@ +# Cloud Team Collections — frozen API contracts (v1) + +Changes to this file require an orchestrator commit and a version-note bump here. +**Contract version: 1.5** (11 Jul 2026 — 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 | +| `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 | +| `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/02-edge-functions.prompt.md b/Design/CloudTeamCollections/orchestration/02-edge-functions.prompt.md new file mode 100644 index 000000000000..a686086ad10d --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/02-edge-functions.prompt.md @@ -0,0 +1,46 @@ +# Agent prompt — task 02: edge functions (resume-aware) + +You are implementing task 02 of the Cloud Team Collections plan in an isolated git +worktree of c:\github\BloomDesktop. + +**Resume check (do this FIRST):** if branch `task/02-edge-functions` exists, check it out +(`git checkout task/02-edge-functions`), read the `## Progress log` at the bottom of +`Design/CloudTeamCollections/tasks/02-edge-functions.md`, and continue from the recorded +next action. Otherwise `git checkout -b task/02-edge-functions` (you start from the +`cloud-collections` state). + +**Durability protocol (mandatory, from orchestration/RESUME.md):** commit after EVERY +completed step — small coherent commits, descriptive messages ending +"Co-Authored-By: Claude Fable 5 ". In the same commit: tick that +step's checkbox in the task file AND update its `## Progress log` (create the section if +missing) with `date · done · exact next action`. Never leave >1 step uncommitted. +NOTE: the pre-commit hook may fail in a worktree ("husky-run not found"); if it does, +commit with `--no-verify` (your files are SQL/TS/MD only — the hook is a JS/C# formatter; +the orchestrator re-verifies at merge). + +**Read first:** `Design/CloudTeamCollections/tasks/02-edge-functions.md` (authoritative +steps), `Design/CloudTeamCollections/CONTRACTS.md` (v1.1 — frozen shapes), +`server/dev/DEV-CREDENTIALS.md` (CRITICAL correction: MinIO VALIDATES session tokens — +dev credential mode must mint real temporary creds via MinIO's AssumeRole STS API, never +fabricate a token), `server/dev/README.md`, and the schema/RPCs in `supabase/migrations/`. + +**Environment (all verified working on this machine):** local Supabase stack is running +(`supabase start`: API http://127.0.0.1:54321, DB 54322; anon/service keys via +`supabase status`). MinIO runs at http://localhost:9000 (bucket `bloom-teams-local`, +versioning ON, creds minioadmin/minioadmin). Deno 2.9 is installed. Serve functions with +`supabase functions serve`. Container networking wrinkle: from INSIDE the edge-runtime +container, MinIO is not `localhost` — try `host.containers.internal` (Podman) or +`host.docker.internal`; make the endpoint an env/secret (`BLOOM_S3_ENDPOINT`) and document +what actually worked in server/dev/README.md. + +**Scope:** the five functions per CONTRACTS.md (checkin-start/finish/abort, +download-start, collection-files-start/finish) under `supabase/functions/`, plus the +`server/provision-aws` script (authored + reviewed only — no AWS account available; do +not attempt to run it). Deno tests per function: unit tests with mocked S3/DB are fine +(aws-sdk-client-mock is available on npm if useful for the JS AWS SDK), but where cheap, +run integration checks against the LIVE local stack — it is up. Only these functions ever +hold S3 admin credentials. + +**Final report (raw data):** branch + shas; per-function status (authored/tested, which +tests ran vs mocked); the MinIO-endpoint networking answer; any contract ambiguities; the +exact next action if you did not finish. diff --git a/Design/CloudTeamCollections/orchestration/03-auth.prompt.md b/Design/CloudTeamCollections/orchestration/03-auth.prompt.md new file mode 100644 index 000000000000..31cfee01aa59 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/03-auth.prompt.md @@ -0,0 +1,43 @@ +# Agent prompt — task 03: auth + client skeleton (resume-aware) + +You are implementing task 03 of the Cloud Team Collections plan. Work in the MAIN working +tree at c:\github\BloomDesktop (NOT a worktree — you need the initialized C# build deps). + +**Resume check (do this FIRST):** `git status` must be clean (stop and report if not). If +branch `task/03-auth` exists, check it out and continue from the `## Progress log` at the +bottom of `Design/CloudTeamCollections/tasks/03-auth.md`. Otherwise +`git checkout -b task/03-auth cloud-collections`. + +**Durability protocol (mandatory, from orchestration/RESUME.md):** commit after EVERY +completed step — small coherent commits, descriptive messages ending +"Co-Authored-By: Claude Fable 5 ". In the same commit: tick that +step's checkbox in the task file AND update its `## Progress log` (create if missing) +with `date · done · exact next action`. Never leave >1 step uncommitted. + +**Read first:** `Design/CloudTeamCollections/tasks/03-auth.md` (authoritative steps — +note the dev-provider-first design), `Design/CloudTeamCollections/CONTRACTS.md` v1.1 +(wire format: RPC JSON keys use the `p_` prefix; `tc`-schema calls need +`Content-Profile: tc` — both verified live), `server/dev/README.md` §Environment +variables (the `BLOOM_CLOUDTC_*` contract `CloudEnvironment` must implement). + +**Environment:** local stack is running (Supabase API http://127.0.0.1:54321; anon key +via `supabase status`). Seeded dev users: admin@dev.local / alice@dev.local / +bob@dev.local, password BloomDev123! — sign-in verified working via +POST /auth/v1/token?grant_type=password. Use these for any live verification of the dev +auth provider. + +**Build caution:** Bloom.exe may be RUNNING on this machine. Building then fails copying +the Bloom.exe apphost (MSB3027) — compilation still completes. Run tests with +`dotnet test src/BloomTests/BloomTests.csproj --filter "FullyQualifiedName~Cloud"` (and +the TeamCollection filter for regression); if the build errors ONLY on the apphost copy, +verify output\Tests\Debug\AnyCPU\Bloom.dll is newer than your source changes and proceed +with `dotnet test` on the built DLL, reporting exactly what you did. Never use stale +binaries silently. + +**Scope (owns new files only):** `src/BloomExe/TeamCollection/Cloud/CloudEnvironment.cs`, +`CloudAuth.cs`, `CloudCollectionClient.cs`, plus `src/BloomTests/TeamCollection/Cloud/` +tests. If BloomExe.csproj needs explicit file includes, add ONLY your new files. All +public methods commented. Editing a checked-out book must never block on auth. + +**Final report (raw data):** branch + shas; test commands + verbatim result counts; what +was verified live vs unit-tested; the exact next action if you did not finish. diff --git a/Design/CloudTeamCollections/orchestration/04-client-core.prompt.md b/Design/CloudTeamCollections/orchestration/04-client-core.prompt.md new file mode 100644 index 000000000000..71ffff0558d5 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/04-client-core.prompt.md @@ -0,0 +1,49 @@ +# Agent prompt — task 04: client core (cache, manifest, transfer) — resume-aware + +You are implementing task 04 of the Cloud Team Collections plan. Work in the MAIN working +tree at c:\github\BloomDesktop (NOT a worktree — you need the initialized C# build deps). + +**Resume check (do this FIRST):** `git status` must be clean (stop and report if not). If +branch `task/04-client-core` exists, check it out and continue from the `## Progress log` +at the bottom of `Design/CloudTeamCollections/tasks/04-client-core.md`. Otherwise +`git checkout -b task/04-client-core cloud-collections`. + +**Durability protocol (mandatory, from orchestration/RESUME.md):** commit after EVERY +completed step — small coherent commits, messages ending +"Co-Authored-By: Claude Fable 5 ". Same commit: tick that step's +checkbox in the task file AND update its `## Progress log` (create if missing) with +`date · done · exact next action`. Never leave >1 step uncommitted — interruptions are +EXPECTED and only commits survive. + +**Anti-hang rules:** no watch modes, no foreground servers; `--max-time` on curl; timeouts +on anything that might block. + +**Read first:** `Design/CloudTeamCollections/tasks/04-client-core.md` (authoritative +steps), `Design/CloudTeamCollections/CONTRACTS.md` v1.1 (S3 layout; manifest shape; +book-status), the design doc's Architecture section, and the merged task-03 code in +`src/BloomExe/TeamCollection/Cloud/` (build on `CloudCollectionClient.CallRpc` / +`CallEdgeFunction`; config via `CloudEnvironment`). + +**Scope (owns new files only):** `src/BloomExe/TeamCollection/Cloud/CloudRepoCache.cs`, +`BookVersionManifest.cs`, `CloudBookTransfer.cs` + tests in +`src/BloomTests/TeamCollection/Cloud/`. Reuse `BloomS3Client` session-credential + +TransferUtility mechanics (extract a shared helper if needed — do NOT disturb the publish +path). Hard invariants: downloads ONLY by pinned (path, s3VersionId) — a test must assert +no code path issues an unversioned GET; NFC path normalization; staged-temp-then-atomic- +swap; junk-file exclusion reusing the publish path's filters. All public methods commented. + +**Environment:** the full local stack is UP (Supabase 127.0.0.1:54321; MinIO +localhost:9000, bucket `bloom-teams-local`, minioadmin/minioadmin, versioning ON). Unit +tests use mock S3 per the task file; optionally add [Explicit] live tests against MinIO +(BasicAWSCredentials, ForcePathStyle, ServiceURL override). + +**Build caution:** Bloom.exe may be RUNNING. A build may fail ONLY on copying the +Bloom.exe apphost (MSB3027) — compilation still completes. Run +`dotnet test src/BloomTests/BloomTests.csproj --filter "FullyQualifiedName~Cloud"` plus the +TeamCollection regression filter; if the apphost copy fails, verify +output\Tests\Debug\AnyCPU\Bloom.dll is newer than your sources and report exactly what ran. +Never silently use stale binaries. + +**Final report (raw data):** branch + shas; test commands + verbatim counts; the +no-unversioned-GET assertion location; any base-class or contract issues found (report, +don't fix); exact next action if unfinished. diff --git a/Design/CloudTeamCollections/orchestration/05-cloud-backend.prompt.md b/Design/CloudTeamCollections/orchestration/05-cloud-backend.prompt.md new file mode 100644 index 000000000000..e0743968f261 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/05-cloud-backend.prompt.md @@ -0,0 +1,76 @@ +# Agent prompt — task 05: CloudTeamCollection + monitor + join flow (resume-aware) + +You are implementing task 05 — the heart of the feature — in the MAIN working tree at +c:\github\BloomDesktop (NOT a worktree; you need the initialized C# build deps). + +**Resume check (do this FIRST):** `git status` must be clean (stop and report if not). If +branch `task/05-cloud-backend` exists, check it out and continue from the `## Progress +log` at the bottom of `Design/CloudTeamCollections/tasks/05-cloud-backend.md`. Otherwise +`git checkout -b task/05-cloud-backend cloud-collections`. + +**Durability protocol (mandatory):** commit after EVERY completed step — this is the +longest task in the plan and interruptions are CERTAIN. Small coherent commits, messages +ending "Co-Authored-By: Claude Fable 5 "; same commit ticks the +step's checkbox and updates the `## Progress log` with `date · done · exact next action`. + +**Anti-hang rules:** no watch modes or foreground servers; `--max-time` on curl; timeouts +on anything that might block. + +**Read first (in this order):** the task file +`Design/CloudTeamCollections/tasks/05-cloud-backend.md`; +`Design/CloudTeamCollections/CONTRACTS.md` (v1.2 — note the NEW `get_book_manifest` RPC, +added for exactly this task's Receive path); +`Design/CloudTeamCollections.md` §Architecture + §Client integration; +`Design/CloudTeamCollections/notes/write-book-status-audit.md` (task 00's caller audit — +drives your WriteBookStatusJsonToRepo diff-dispatch); +the merged Wave-1/2 code you build ON: `src/BloomExe/TeamCollection/Cloud/*.cs` +(CloudEnvironment/CloudAuth/CloudCollectionClient from 03; CloudRepoCache/ +BookVersionManifest/CloudBookTransfer from 04) and the abstract members of +`src/BloomExe/TeamCollection/TeamCollection.cs`. + +**File discipline (strict):** +- You own NEW files: `Cloud/CloudTeamCollection.cs`, `Cloud/CloudCollectionMonitor.cs`, + `Cloud/CloudJoinFlow.cs` + tests under `src/BloomTests/TeamCollection/Cloud/`. +- Base classes (`TeamCollection.cs`, `FolderTeamCollection.cs`, etc.) are READ-ONLY. If an + implementation genuinely needs a base change, STOP that step, record the need in your + progress log and final report, and work around it if possible — the orchestrator makes + base changes. +- ONE authorized exception in `TeamCollectionManager.cs`: replace the two + NotImplementedException placeholders from task 00 (`CreateTeamCollectionFromLink`'s + cloud branch; `ConnectToCloudCollection`) with real CloudTeamCollection construction. + Touch NOTHING else in that file. + +**Key implementation constraints (from merged-work review notes):** +- Receive: `get_book_manifest` RPC → CloudBookTransfer.DownloadFiles into a TEMP book + folder → atomic whole-directory swap done BY YOU (the transfer class's per-file move + loop is not itself a single atomic dir swap — merge log 7 Jul). +- Locks: override TryLockInRepo/UnlockInRepo with checkout_book/unlock_book/force_unlock + RPCs (server stamps identity; client sends machine name only). +- WriteBookStatusJsonToRepo: diff-dispatch to the NARROWEST RPC per the audit; pure + bookkeeping writes must never clear a lock; the SyncAtStartup callers flagged in the + audit are local-only no-ops for cloud. +- RPC wire format: `p_`-prefixed JSON keys + Content-Profile/Accept-Profile: tc (already + handled inside CloudCollectionClient.CallRpc). +- Monitor: polling only (get_changes, 60s + on-activation); event-id self-echo + suppression via last-seen cursor; realtime is a later wave. +- Unified recovery (lock-lost/base-superseded): save `.bloomSource` to Lost & Found, + Receive current, log_event incident (type 100 WorkPreservedLocally), distinct messages + per sub-case. +- All new user-visible strings: follow `.github/skills/xlf-strings/SKILL.md`, en-only. + +**Environment:** full local stack UP (Supabase 127.0.0.1:54321 — anon key via `supabase +status`; MinIO via edge functions; dev users admin/alice/bob@dev.local pw BloomDev123!). +For live integration tests: edge functions must be served — run +`supabase functions serve --env-file server/dev/functions.env` via Start-Process/`&` with +output redirected to a file, NEVER foreground (that stalled a prior agent). [Explicit] +NUnit live tests against the stack are strongly encouraged for the Send/Receive round +trip (see CloudAuthTests' LiveDevProvider test for the pattern). + +**Build caution:** Bloom.exe may be RUNNING; an apphost-copy MSB3027 during build is +benign — verify output\Tests\Debug\AnyCPU\Bloom.dll is newer than your sources, run +`dotnet test src/BloomTests/BloomTests.csproj --filter "FullyQualifiedName~Cloud"` and the +`~TeamCollection` regression filter, and report exactly what ran. Never --no-build. + +**Final report (raw data):** branch + shas; test commands + verbatim counts (cloud filter, +TC regression, any [Explicit] live runs); base-class changes you needed but could not +make; contract ambiguities; exact next action if unfinished. diff --git a/Design/CloudTeamCollections/orchestration/06-api-endpoints.prompt.md b/Design/CloudTeamCollections/orchestration/06-api-endpoints.prompt.md new file mode 100644 index 000000000000..5e263ee2cb6f --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/06-api-endpoints.prompt.md @@ -0,0 +1,57 @@ +# Agent prompt — task 06: API endpoints (resume-aware) + +You are implementing task 06 in the MAIN working tree at c:\github\BloomDesktop (NOT a +worktree). You are the exclusive owner of the shared file `TeamCollectionApi.cs` during +this task. + +**Resume check (do this FIRST):** `git status` must be clean (stop and report if not). If +branch `task/06-api-endpoints` exists, check it out and continue from the `## Progress +log` at the bottom of `Design/CloudTeamCollections/tasks/06-api-endpoints.md`. Otherwise +`git checkout -b task/06-api-endpoints cloud-collections`. + +**Durability protocol (mandatory):** commit after EVERY completed step; small coherent +commits ending "Co-Authored-By: Claude Fable 5 "; tick the step's +checkbox + update the `## Progress log` (`date · done · exact next action`) in the same +commit. Interruptions are expected; only commits survive. + +**Anti-hang rules:** no watch modes/foreground servers (background + redirect only); +`--max-time` on curl; timeouts everywhere. + +**Read first:** `Design/CloudTeamCollections/tasks/06-api-endpoints.md` (authoritative +steps); CONTRACTS.md v1.2 §Book-status JSON; the merged Cloud classes you expose +(`CloudTeamCollection`, `CloudJoinFlow`, `CloudAuth.GetLoginState`, +`CloudCollectionClient`); the END of +`Design/CloudTeamCollections/tasks/08-ui-collection-tab.md`'s Progress log — it lists the +~9 mocked endpoint names the Wave-2 UI shells already call (capabilities, tcStatusMetadata, +sendAllBooks, receiveUpdates, sharing/forceUnlock, history events, etc.); your endpoints +must match those names/shapes exactly, plus task 07's (sharing/loginState, +sharing/showSignIn, collections/mine, collections/pullDown, +teamCollection/showCreateCloudTeamCollectionDialog). + +**Scope:** +- New `src/BloomExe/web/controllers/SharingApi.cs` (endpoints per the task file), thin + pass-throughs to CloudCollectionClient/CloudJoinFlow/CloudAuth — no business logic. +- `TeamCollectionApi.cs`: additive only; book-status JSON gains + localVersionSeq/repoVersionSeq/signedIn/capability flags; receiveUpdates; sendAll; + force-unlock routes through the audited RPC for cloud. Folder-TC responses must stay + BYTE-IDENTICAL (existing tests untouched and green). +- **One authorized supabase addition:** a NEW migration (never edit merged ones), + `supabase/migrations/20260707000006_tc_locked_by_display.sql`, extending + get_collection_state/get_changes/get_book_manifest outputs with `lockedByEmail` / + `lockedByName` (join tc.members on locked_by = user_id) — task 05 found locked_by is the + raw auth UUID, useless for "checked out to Sara" display. Apply with + `supabase migration up` and live-verify. Update the C# status JSON to carry them. +- Websocket pushes for member-list changes + status refresh reuse existing contexts. + +**Environment:** local stack UP (Supabase 127.0.0.1:54321, anon key via `supabase +status`; dev users admin/alice/bob@dev.local pw BloomDev123!; edge functions via +`supabase functions serve --env-file server/dev/functions.env` in background w/ redirect). + +**Build caution:** Bloom.exe may be RUNNING; apphost-copy MSB3027 is benign — verify +output\Tests\Debug\AnyCPU\Bloom.dll freshness, run +`dotnet test src/BloomTests/BloomTests.csproj --filter "FullyQualifiedName~Cloud"`, +`~TeamCollectionApi`, and the full `~TeamCollection` regression; report verbatim counts. + +**Final report (raw data):** branch + shas; endpoint list (name → implementation status → +matched UI caller); test commands + verbatim counts; migration live-verification result; +anything the UI wiring (next step) still needs; exact next action if unfinished. diff --git a/Design/CloudTeamCollections/orchestration/07-ui-setup.prompt.md b/Design/CloudTeamCollections/orchestration/07-ui-setup.prompt.md new file mode 100644 index 000000000000..465452f2a7f7 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/07-ui-setup.prompt.md @@ -0,0 +1,41 @@ +# Agent prompt — task 07: UI setup/sharing shells (resume-aware) + +You are implementing the Wave-1 (shells) scope of task 07 of the Cloud Team Collections +plan in an isolated git worktree of c:\github\BloomDesktop. + +**Resume check (do this FIRST):** if branch `task/07-ui-setup` exists, check it out and +continue from the `## Progress log` at the bottom of +`Design/CloudTeamCollections/tasks/07-ui-setup.md`. Otherwise +`git checkout -b task/07-ui-setup`. + +**Durability protocol (mandatory, from orchestration/RESUME.md):** commit after EVERY +completed step — small coherent commits, descriptive messages ending +"Co-Authored-By: Claude Fable 5 ". In the same commit: tick that +step's checkbox in the task file AND update its `## Progress log` (create if missing) +with `date · done · exact next action`. Never leave >1 step uncommitted. +NOTE: the pre-commit hook may fail in a worktree ("husky-run not found"); if so, run +`yarn prettier --write` on your changed files manually, then commit `--no-verify` +(orchestrator re-verifies at merge). + +**Setup:** front-end lives in src/BloomBrowserUI. First run ` cd src/BloomBrowserUI && +yarn install` (yarn 1.22, NEVER npm; note the leading space guard for the terminal's +lost-first-character quirk). NEVER run `yarn build`. Verify with `yarn lint` and vitest +(component tests). Follow src/BloomBrowserUI/AGENTS.md: arrow-function components, no +prop destructuring, @emotion/react `css` prop styling, no sx objects. + +**Read first:** `Design/CloudTeamCollections/tasks/07-ui-setup.md` (authoritative steps — +Wave-1 scope is SHELLS AGAINST MOCKED ENDPOINTS; real wiring waits for task 06), +`Design/CloudTeamCollections.md` §UI changes, CONTRACTS.md §Book-status JSON. In dev auth +mode the sign-in step is a plain email/password form driven by `sharing/loginState`'s +reported mode (mock that endpoint's both modes). + +**Localization:** every new user-visible string follows +`.github/skills/xlf-strings/SKILL.md` (READ IT before adding strings). Only ever edit +under `DistFiles/localization/en/` — never other language dirs. + +**Scope:** SharingPanel.tsx, JoinCloudCollectionDialog.tsx (new); CreateTeamCollection.tsx, +TeamCollectionSettingsPanel.tsx, CollectionChooserDialog (exclusive owner during this +task). Keep folder-TC behavior byte-identical; cloud paths behind the experimental flag. + +**Final report (raw data):** branch + shas; component list with test status; `yarn lint` +result; strings added (XLF ids); the exact next action if you did not finish. diff --git a/Design/CloudTeamCollections/orchestration/08-ui-collection-tab.prompt.md b/Design/CloudTeamCollections/orchestration/08-ui-collection-tab.prompt.md new file mode 100644 index 000000000000..2f4439b0825b --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/08-ui-collection-tab.prompt.md @@ -0,0 +1,45 @@ +# Agent prompt — task 08: UI collection tab (Wave-2 shells) — resume-aware + +You are implementing the Wave-2 (shells) scope of task 08 of the Cloud Team Collections +plan in an isolated git worktree of c:\github\BloomDesktop. + +**Resume check (do this FIRST):** if branch `task/08-ui-collection-tab` exists, check it +out and continue from the `## Progress log` at the bottom of +`Design/CloudTeamCollections/tasks/08-ui-collection-tab.md`. Otherwise +`git checkout -b task/08-ui-collection-tab`. + +**Durability protocol (mandatory, from orchestration/RESUME.md):** commit after EVERY +completed step — small coherent commits, messages ending +"Co-Authored-By: Claude Fable 5 ". Same commit: tick the step's +checkbox in the task file AND update its `## Progress log` with +`date · done · exact next action`. Interruptions are EXPECTED; only commits survive. The +pre-commit hook fails in worktrees (husky-run): run `yarn prettier --write` on your changed +files manually, then commit `--no-verify`. + +**Anti-hang rules:** vitest in single-run mode ONLY (`yarn vitest run ...`, never watch); +never `yarn build`; no dev servers; timeouts on anything that might block. + +**Setup:** front-end lives in src/BloomBrowserUI. First run ` cd src/BloomBrowserUI && +yarn install` (yarn 1.22, NEVER npm; note the leading space — the terminal drops first +characters). Follow src/BloomBrowserUI/AGENTS.md: arrow-function components, no prop +destructuring, @emotion/react `css` prop, no sx objects. + +**Read first:** `Design/CloudTeamCollections/tasks/08-ui-collection-tab.md` (authoritative +steps — Wave-2 scope is SHELLS AGAINST MOCKED ENDPOINTS; real wiring waits for task 06), +CONTRACTS.md §Book-status JSON (StatusPanelState additions must stay in sync with the C# +additive fields: localVersionSeq/repoVersionSeq/signedIn/capability flags), the design +doc §UI changes, and merged task-07 patterns in src/BloomBrowserUI/teamCollection/ +(sharingApi.ts hooks; gating style). + +**GATING IS NON-NEGOTIABLE (a task-07 review caught an ungated section):** every visible +cloud element added to EXISTING components (TeamCollectionButton, TeamCollectionDialog, +TeamCollectionBookStatusPanel, statusPanelCommon, CollectionHistoryTable) must be behind +the cloud-team-collections experimental feature / backend capability flags so folder-TC +UI stays byte-identical with the flag off. Branch on capability, never concrete type. + +**Localization:** every new user-visible string follows +`.github/skills/xlf-strings/SKILL.md` (read it); only edit `DistFiles/localization/en/`. + +**Final report (raw data):** branch + shas; component list with test status + verbatim +counts; `yarn lint` result; XLF ids added; gating approach per component; exact next +action if unfinished. diff --git a/Design/CloudTeamCollections/orchestration/09-e2e.prompt.md b/Design/CloudTeamCollections/orchestration/09-e2e.prompt.md new file mode 100644 index 000000000000..7ed77ccd73ff --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/09-e2e.prompt.md @@ -0,0 +1,60 @@ +# Agent prompt — task 09: E2E harness + scenarios (resume-aware) + +You are implementing task 09 in the MAIN working tree at c:\github\BloomDesktop (NOT a +worktree — you build C# and launch real Bloom instances). + +**Resume check (do this FIRST):** `git status` must be clean (stop and report if not). If +branch `task/09-e2e` exists, check it out and continue from the `## Progress log` at the +bottom of `Design/CloudTeamCollections/tasks/09-e2e.md`. Otherwise +`git checkout -b task/09-e2e cloud-collections`. + +**Durability protocol (mandatory):** commit after EVERY completed step — harness pieces +and each E2E scenario are separate commits, messages ending +"Co-Authored-By: Claude Fable 5 "; tick checkboxes + progress log +(`date · done · exact next action`) in the same commit. Interruptions are certain. + +**Read first:** `Design/CloudTeamCollections/tasks/09-e2e.md` (authoritative steps); +`.github/skills/bloom-automation/SKILL.md` and `.claude/skills/run-bloom/SKILL.md` (CDP +attach patterns, port discovery, kill gotchas); `server/dev/README.md` (stack, dev users, +BLOOM_CLOUDTC_* env vars); the Wave-3 merge-log entry in IMPLEMENTATION.md (the 12 smoke +bugs — several scenarios below exist to pin them). + +**HARD-WON ENVIRONMENT RULES (violating these wasted days during the smoke test):** +1. NEVER run two `dotnet watch`/go.sh instances — concurrent rebuilds into the shared + output produce stale binaries. The harness design is: `dotnet build + src/BloomExe/BloomExe.csproj` ONCE per test session, then launch + `output\Debug\AnyCPU\Bloom.exe` directly N times with per-instance environment + (BLOOM_CLOUDTC_USER/_PASSWORD/_ANON_KEY, distinct collection folders, and Bloom's own + port auto-assignment handles HTTP/CDP). Launching the built exe is CORRECT here because + the harness itself just built it. +2. Building while any Bloom.exe runs fails on the locked apphost — the harness must kill + all its instances before any rebuild, and must FAIL LOUDLY if a foreign Bloom.exe is + running at session start (tell the operator), never silently test stale code. +3. Kill via the known-good pattern: killBloomProcess.mjs, then verify the port went dark + and Stop-Process any survivor PID (see the skill's gotchas; taskkill /PID is broken in + Git Bash). +4. Anti-hang: servers/instances only in background with output redirected to files; + `--max-time` on curl; Playwright timeouts explicit; vitest/playwright single-run only. +5. Per-test reset: `supabase db reset` replays migrations + seed (wipes tc data); clear + the MinIO bucket prefix via mc (see server/dev/docker-compose.yml patterns); delete + local test collection folders. The harness owns bringing the stack to a known state; + the experimental feature flag must be set in user.config BEFORE launching instances + (see the smoke-test hack recorded in IMPLEMENTATION.md merge log) — automate that. + +**Harness location:** new directory `src/BloomTests/e2e/` (Playwright over CDP, driven by +`yarn playwright test` from src/BloomBrowserUI/react_components/component-tester or a +self-contained package.json in the new dir — your choice, document it). TypeScript, no +watch modes. + +**Scenario order (commit each; earlier ones unblock later ones):** E2E-1 create/share; +E2E-2 two-instance collaboration loop (this automates the manual smoke); E2E-3 checkout +contention; E2E-9 new-book lifecycle; E2E-5 approved accounts; E2E-7 un-team adoption; +E2E-4 forced check-in recovery (this must REPRODUCE the latent recovery-path NRE noted in +the merge log — fix it in CloudTeamCollection if your reproduction confirms it, that file +is in scope for this fix only); E2E-6 kill mid-Send/resume; E2E-8 Receive-during-Send +coherence; E2E-10 account-switch safety. If a scenario is blocked by a missing feature, +document it in the progress log and move on rather than stalling. + +**Final report (raw data):** branch + shas; per-scenario status (green/blocked/why); +harness invocation command; total wall time; bugs found (fixed vs reported); exact next +action if unfinished. diff --git a/Design/CloudTeamCollections/orchestration/10-adoption.prompt.md b/Design/CloudTeamCollections/orchestration/10-adoption.prompt.md new file mode 100644 index 000000000000..8baab3c8cb86 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/10-adoption.prompt.md @@ -0,0 +1,57 @@ +# Agent prompt — task 10: adoption path + polish (resume-aware) + +You are implementing task 10 plus the Wave-3 polish list in an isolated git worktree of +c:\github\BloomDesktop. + +**Resume check (do this FIRST):** if branch `task/10-adoption` exists, check it out and +continue from the `## Progress log` at the bottom of +`Design/CloudTeamCollections/tasks/10-adoption.md`. Otherwise +`git checkout -b task/10-adoption`. + +**Durability protocol (mandatory):** commit after EVERY completed item, messages ending +"Co-Authored-By: Claude Fable 5 "; tick checkboxes + progress log +in the same commit. Worktree hook fails: `yarn prettier --write` your tsx/ts files, then +`--no-verify`. For front-end tests: ` cd src/BloomBrowserUI && yarn install` first +(leading space; yarn only; NEVER yarn build; vitest single-run only). C# can be AUTHORED +here but not fully test-run (no build deps in worktrees) — write it carefully, note +"authored, orchestrator verifies at merge" in the progress log, and keep C# changes in +small isolated commits. + +**Read first:** `Design/CloudTeamCollections/tasks/10-adoption.md` (authoritative); +IMPLEMENTATION.md's Wave-3 merge-log entry (the polish items below come from it); +`.github/skills/xlf-strings/SKILL.md` (all strings en-only). + +**Work items, in order:** +1. **Proper experimental-feature checkbox** (owed since the smoke test's user.config + hack): add "Cloud Team Collections (experimental)" to Settings → Advanced, wired like + the existing allowTeamCollection option end to end — GetAdvancedSettingsData / + StoreAdvancedSettingsData / CollectionSettingsDialog pending-change + restart plumbing + in `CollectionSettingsApi.cs`, checkbox in `AdvancedSettingsPanel.tsx`, token + `ExperimentalFeatures.kCloudTeamCollections`. Component test for the tsx side. +2. **Pull-down auto-open**: `collections/pullDown` (SharingApi.cs) returns the local + collection folder path from CloudJoinFlow; the chooser/join dialog uses it to invoke + the same open-collection action the chooser's cards use, instead of making the user + hunt for the new collection. Component test. +3. **Un-team cleanup** (task file step 1): enabling cloud on a formerly-folder-TC + collection cleans per-book `TeamCollection.status`, `lastCollectionFileSyncData.txt`, + `log.txt`; simultaneous folder-link + cloud-link in TeamCollectionLink.txt territory = + clear error. Unit-testable C# — author tests alongside. +4. **First-Receive reconcile** (task file step 2): verify-by-reading that members' existing + local copies reconcile by checksum on first Receive (CloudJoinFlow's scenario logic); + document findings in the progress log; fix only if trivially wrong. +5. **User documentation**: author `Design/CloudTeamCollections/docs/user-walkthrough.md` — + the un-team → enable cloud → invite team walkthrough incl. "everyone check in first", + written for end users (the docs-site source of truth until it moves). +6. **Localization sweep**: audit ALL new user-visible strings across the cloud UI work + (07/08/wiring/your items) per the xlf-strings skill; fix gaps; en-only. +7. **Analytics audit**: verify create/join/send/receive/force-unlock/incident events carry + Backend="Cloud" and sensible params (read TeamCollectionApi/SharingApi Analytics.Track + calls); add missing ones; note bytes-uploaded-vs-skipped as future enhancement if not + cheaply available. + +NOT in scope: the preview-pane refresh nit (needs base-code selection plumbing — the +orchestrator will assess separately); dogfood (humans). + +**Final report (raw data):** branch + shas; per-item status; test commands + verbatim +counts (front-end); which C# items need orchestrator build-verification; XLF ids +added/fixed; exact next action if unfinished. diff --git a/Design/CloudTeamCollections/orchestration/12-real-auth.prompt.md b/Design/CloudTeamCollections/orchestration/12-real-auth.prompt.md new file mode 100644 index 000000000000..3d879815472a --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/12-real-auth.prompt.md @@ -0,0 +1,39 @@ +# Agent prompt — task 12: real auth provider, Option A seams (resume-aware) + +You are implementing task 12 in the MAIN working tree at c:\github\BloomDesktop (you build +and unit-test C#; you must NOT launch Bloom.exe or run E2E tests — the machine is in +interactive use by the developer). + +**Resume check (do this FIRST):** `git status` must be clean (stop and report if not). If +branch `task/12-real-auth` exists, check it out and continue from the `## Progress log` at +the bottom of `Design/CloudTeamCollections/tasks/12-real-auth.md`. Otherwise +`git checkout -b task/12-real-auth cloud-collections`. + +**Durability protocol (mandatory):** commit after EVERY completed step, messages ending +"Co-Authored-By: Claude Fable 5 "; tick checkboxes + progress log +(`date · done · exact next action`) in the same commit. Interruptions are likely. + +**Read first:** `Design/CloudTeamCollections/tasks/12-real-auth.md` (authoritative steps); +`Design/CloudTeamCollections/GOING-LIVE.md` Phase 3 (the boundary between your work and the +deferred live wiring); the existing dev provider + seam in +`src/BloomExe/TeamCollection/Cloud/` (CloudAuth.cs, CloudEnvironment.cs) and its tests in +`src/BloomTests/TeamCollection/Cloud/`; how Bloom currently hosts BloomLibrary sign-in +(search WebLibraryIntegration and the sharing sign-in flow) BEFORE designing the token +receipt endpoint; `supabase/migrations/` for tc.jwt_email_verified()'s current shape; +`.github/skills/xlf-strings/SKILL.md` if you add any user-visible strings (en-only; +NEVER a double hyphen inside a — it crashes every Bloom launch). + +**Test rules:** the mandatory C# filter is +`"(FullyQualifiedName~Cloud|FullyQualifiedName~TeamCollection|FullyQualifiedName~SharingApi)&FullyQualifiedName!~LiveTests"`; +never `--no-build`; never `yarn build`; vitest (if any front-end) single-run with +`--pool=threads`. pgTAP (if you add migrations): `supabase test db` with the stack up — it +already is; `supabase db reset` is allowed. All HTTP in unit tests is mocked; no live +Google/Firebase calls anywhere. + +**Contract discipline:** document the token-receipt request shape in CONTRACTS.md (new +"Auth (Option A)" section) — the BloomLibrary2 editor.ts change will be written against +your text verbatim, so be precise about route, method, body fields, and the reply. + +**Final report (raw data):** branch + shas; per-step status; test commands + verbatim +counts; the CONTRACTS.md section text you added; anything you discovered about the existing +BloomLibrary sign-in flow that affects GOING-LIVE Phase 3.2; exact next action if unfinished. diff --git a/Design/CloudTeamCollections/orchestration/BUG0-OPTION-A-SKETCH.md b/Design/CloudTeamCollections/orchestration/BUG0-OPTION-A-SKETCH.md new file mode 100644 index 000000000000..e323d5ac75e3 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/BUG0-OPTION-A-SKETCH.md @@ -0,0 +1,48 @@ +# Bug #0 option (a) implementation sketch — server-side "seat" + +Prepared while the bot gauntlet runs; NOT committed anywhere. If John picks (a), this is the plan. + +## Concept +A "seat" = one local collection folder on one machine. Lock takeover is only legitimate within +the same seat (the true shared-computer scenario: account B opens the exact folder account A +used). Two folders on one machine are two seats. + +## Server (one migration, purely additive like 20260709000007) +- `alter table tc.books add column locked_seat text;` (nullable; null = legacy/unknown seat). +- `checkout_book(p_book_id, p_machine, p_seat)`: new optional param, stored on lock acquire. + (PostgREST tolerates the extra param only if the SQL function signature adds it — bump the + function, keep old 2-arg overload delegating with p_seat=null so old clients don't break; + CONTRACTS.md addition to note.) +- `checkout_book_takeover(p_book_id, p_machine, p_seat)`: takeover requires + `locked_by_machine = p_machine AND locked_seat IS NOT DISTINCT FROM p_seat AND locked_seat IS NOT NULL` + — i.e. seat must match AND be known; a null (legacy) seat refuses takeover (fail safe). +- `unlock_book` / `force_unlock` / `checkin_finish_tx`: clear locked_seat wherever locked_by is + cleared (audit which already clear locked_by_machine; mirror that). +- pgTAP: same-seat takeover OK; same-machine-different-seat REFUSED; null-seat REFUSED; + cross-machine REFUSED (existing); checkout stores seat; unlock clears it. + +## Client +- Seat id: stable hash of the local collection folder path + machine + (e.g. first 16 hex of SHA256(lowercased full path)). Compute in TeamCollectionManager or + CloudTeamCollection (has _localCollectionFolder). NOT the raw path (privacy in server rows). +- CloudCollectionClient.CheckoutBook/CheckoutBookTakeover: add seat param. +- CloudTeamCollection.TryLockInRepo / TryTakeOverLock: pass the seat. +- CanTakeOverLockOnThisMachine: unchanged machine check client-side (server enforces seat); + optionally ALSO gate client-side if the cache carries locked_seat (cache delta shape would + need the column too — get_collection_state view addition). +- Cache: add locked_seat to CloudRepoCache book rows + snapshot/delta parsing (or skip caching + it and let the server be sole enforcer — SIMPLER: skip cache change; client attempts + takeover, server refuses, AttemptLock then correctly reports "locked by other"). + RECOMMENDED: skip the cache/client gate entirely; server-only enforcement. Client behavior + on refusal already falls back to the ordinary "locked by someone else" path (verify + TryTakeOverLock's failure handling does this — it should treat {success:false} like a lost + checkout race). + +## e2e-4 expectation +With (a): alice's attemptLock → takeover refused (different seat) → lock stays bob's → +spec's final assertions pass unchanged. e2e-10's takeover (same folder = same seat) keeps +passing (its bob-takeover opens ALICE's folder). + +## Estimate +Migration + pgTAP ~1h careful work incl. running against local stack; client param plumbing +~20 min; e2e-4 + e2e-10 rerun ~10 min. diff --git a/Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md b/Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md new file mode 100644 index 000000000000..d15f4d858c84 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md @@ -0,0 +1,948 @@ +# 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: CODE DONE + e2e-2 GREEN — remaining: John's VISUAL check of the centered dialog +during his next manual checkin +- [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). +- [ ] [HUMAN, John] Visual check that the checkin-progress dialog appears centered over the + status panel during a manual checkin. + +### 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. 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. Cosmetic (tracked): Administrators field shows registration email, not signed-in email + (see "Also queued from dogfooding"). +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). + +## Progress log +(orchestrator appends: date · what was just completed · EXACT next action) +- 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. diff --git a/Design/CloudTeamCollections/orchestration/RESUME.md b/Design/CloudTeamCollections/orchestration/RESUME.md new file mode 100644 index 000000000000..b21ee7b61c1f --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/RESUME.md @@ -0,0 +1,65 @@ +# Cloud TC — agent orchestration & resume protocol + +> **In-flight batch (9 Jul 2026):** John's dogfood bug/improvement list is being worked +> per [DOGFOOD-BATCH-1.md](DOGFOOD-BATCH-1.md) — resume THAT file's checklist first. + +This folder holds the launch prompts for in-flight implementation tasks and the protocol +that makes them resumable across work sessions (including AI-session token limits). + +## The durable-state rule + +All task state lives in **git branches**, never in a conversation: + +- One branch per task, named `task/-`, based on `cloud-collections`. The + currently in-flight set = whatever `git branch --list "task/*"` shows unmerged into + `cloud-collections`. As of 8 Jul 2026 (evening) ALL Wave-4 code is merged and pushed: + harness, scenarios E2E-1..9 (E2E-4 partial, E2E-10 blocked — both are product decisions, + see tasks/09-e2e.md findings + GOING-LIVE.md Phase 5), task 10's 7 polish items, the + go-live/test-setup docs, and 3 product fixes from the scenario work. NO agent work is in + flight. What remains for Wave 4: (a) one 12/12 acceptance run of the full E2E matrix on + an IDLE machine (`cd src/BloomTests/e2e && yarn test`, desktop unlocked, ~30 min — five + runs on the busy dev machine each passed a different 8–11/12, all failures + load-correlated, worst offender = finding 9's problem-report modal); (b) John's product + decisions; (c) dogfood. All Wave-0/1/2/3 branches are merged; see IMPLEMENTATION.md. +- Agents commit after EVERY completed checklist step — small, coherent commits; never one + big commit at the end. Tick the step's checkbox in the task file in the same commit. +- Each task file ends with a `## Progress log` section; every commit appends/updates one + line: `date · what was just completed · EXACT next action`. A resumer starts by reading + this line. +- A step that is half-done at interruption is simply redone from its last commit — or, if + the orchestrator finds uncommitted work in a leftover `.claude/worktrees/agent-*` + worktree, it secures that as a WIP commit on the branch first (proven pattern). + +## How to resume (human instructions) + +1. Wait for usage limits to reset (session window), then start a **fresh** Claude Code + session in this repo (cheaper than resuming the old conversation; everything needed is + on disk and in Claude's project memory). +2. Say: **"Resume the Cloud Team Collections tasks per + Design/CloudTeamCollections/orchestration/RESUME.md."** +3. The orchestrator will: find unmerged `task/*` branches and read their progress logs; + secure any uncommitted worktree work as WIP commits; relaunch unfinished agents with + their prompt files from this folder (prompts are resume-aware — they check for an + existing branch first); review and merge finished branches into `cloud-collections` + per IMPLEMENTATION.md rules; rebase onto origin/master at least daily. + +## Orchestrator notes + +- Launch prompts live in this folder, one per task (`-.prompt.md`). Sonnet + agents. Front-end/server-file tasks run in isolated worktrees; C#-building tasks run in + the MAIN tree (worktrees lack initialized build deps) — one C# task at a time. +- 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; edge-runtime containers must reach MinIO as + `bloom-minio:9000`, never `host.containers.internal` (hangs under Podman). diff --git a/Design/CloudTeamCollections/orchestration/SQUASH-PLAN.md b/Design/CloudTeamCollections/orchestration/SQUASH-PLAN.md new file mode 100644 index 000000000000..031cbb1a34ed --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/SQUASH-PLAN.md @@ -0,0 +1,95 @@ +# Squash plan: review-grained history for the Cloud TC feature + +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**. + +## 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/notes-item7-progressive-join.md b/Design/CloudTeamCollections/orchestration/notes-item7-progressive-join.md new file mode 100644 index 000000000000..52b4dcb5d304 --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/notes-item7-progressive-join.md @@ -0,0 +1,66 @@ +# Item 7 scouting notes (progressive join) — 9 Jul 2026, read-only scout + +Companion to DOGFOOD-BATCH-1.md item 7. Verified paths/lines as of commit e98cd809c. + +## Where the join blocks today +`CloudJoinFlow.JoinCollection` (CloudJoinFlow.cs:163-235) is fully synchronous: +ClaimMemberships → DetermineScenario/folder → write TeamCollectionLink → HydrateFromServer +(line 231: populates CloudRepoCache with book list/titles/seqs, NO content) → +CopyRepoCollectionFilesToLocal (232: the .bloomCollection settings + collection files) → +**CopyAllBooksFromRepoToLocalFolder (233: the blocking bulk download to defer)**. +SharingApi.HandlePullDown (SharingApi.cs:498-566) replies with collectionPath only after all +of that returns; TS then posts workspace/openCollection → Program.SwitchToCollection +(Program.cs:1842-1847). Settings + titles are available WITHOUT any book download. +Note: cloud pull-down does NOT set NextMergeIsFirstTimeJoinCollection (folder TC does, at +FolderTeamCollection.cs:1445) — so reopen runs SyncAtStartup with firstTimeJoin:false. + +## Resume path (already exists, verified) +SyncAtStartup (TeamCollection.cs:2226; repo-book loop 2507-2558): a repo book with no local +folder hits the "brand new book! Get it." branch → CopyBookFromRepoToLocalAndReport. Pinned +by CloudSyncAtStartupTests.SyncAtStartup_NewBookOnlyInRepo_IsFetchedToLocal (251-293). +CAVEAT: it runs synchronously inside the "Syncing Team Collection" progress dialog +(SynchronizeRepoAndLocal, TeamCollection.cs:2960-3032) — a half-joined collection would +block its next open until all missing books download, so item 7 must make this path skip +cloud missing-book fetches (hand them to the background queue instead). + +## Book list / placeholder seam +CollectionApi.HandleBooksRequest (CollectionApi.cs:559-647, endpoint collections/books) +builds the JSON from BookCollection.GetBookInfos (BookCollection.cs:297-355), which is +LOCAL-DISK-ONLY — repo books with no local folder are simply absent. Cleanest seam: merge +CloudTeamCollection.GetBookList()/repo-cache titles into HandleBooksRequest's JSON with a +`notYetDownloaded: true` flag (keeps BookCollection disk-only). TS: BooksOfCollection.tsx +renders via LazyLoad with existing BookButtonPlaceHolder (BookButton.tsx:679-698, already +listens for a bookImage/reload websocket to swap in the real thumbnail); thumbnail API +falls back to a placeholder image for missing folders (CollectionApi.cs:755-789). + +## Selection priority + "downloading" status +collections/selected-book POST (CollectionApi.cs:149-208) catches-and-logs failures for +missing folders (198-208) — natural place for the "prioritize this download" hint. +Status panel: clone the offlineDisabled pattern — server seam +TeamCollectionApi.AddCloudBookStatusFields (TeamCollectionApi.cs:912-943, populates +offlineDisabledReason when disconnected && no local seq); client union + branch + render in +TeamCollectionBookStatusPanel.tsx (union line 63, effect 104-160, render ~805). + +## Queue reuse +RemoteBookAutoApplyQueue (single-consumer FIFO dedupe; TeamCollection.cs lazily constructs +at 117-128) is the right vehicle but has NO priority mechanism — selection-priority needs a +front-of-queue Enqueue variant. CloudBookTransfer.DownloadFiles (CloudBookTransfer.cs: +340-431) hash-skips already-present files → interrupted downloads resume for free; the +whole-folder atomic swap lives in FetchBookFromRepo (CloudTeamCollection.cs:735-821), so +the placeholder-only-or-fully-downloaded invariant already holds at folder level. + +## Test landscape +No CloudJoinFlow test file exists. CloudSyncAtStartupTests uses a fake HTTP executor +(pattern to copy). No test covers HandleBooksRequest JSON shape (placeholder entries land +uncovered unless added). Queue seams: TestOnly_MakeAutoApplyQueueSynchronous / +TestOnly_ProcessAutoApplyRemoteChange (TeamCollection.cs:136-150). + +## Sketch (for the implementation brief) +1. CloudJoinFlow: drop line 233; after settings download, enqueue all repo books. +2. HandleBooksRequest: synthetic notYetDownloaded entries from the repo cache. +3. Background queue drains; each completion broadcasts bookImage/reload + status refresh. +4. selected-book handler: front-of-queue bump + "Downloading…" panel state via + AddCloudBookStatusFields; selection of a missing book must not crash (currently + catch-and-log path). +5. SyncAtStartup for cloud: missing books → queue instead of synchronous fetch (keeps the + startup progress dialog fast AND preserves resume semantics). diff --git a/Design/CloudTeamCollections/orchestration/ui-wiring.prompt.md b/Design/CloudTeamCollections/orchestration/ui-wiring.prompt.md new file mode 100644 index 000000000000..0c77128fc12c --- /dev/null +++ b/Design/CloudTeamCollections/orchestration/ui-wiring.prompt.md @@ -0,0 +1,52 @@ +# Agent prompt — Wave-3 UI wiring (resume-aware) + +You are implementing the final Wave-3 step: connecting the Wave-1/2 UI shells to the real +C# endpoints task 06 just delivered. Work in an isolated git worktree of +c:\github\BloomDesktop. + +**Resume check (do this FIRST):** if branch `task/ui-wiring` exists, check it out and +continue from the `## Progress log` at the bottom of this prompt's companion notes in the +task files below. Otherwise `git checkout -b task/ui-wiring`. + +**Durability protocol (mandatory):** commit after EVERY completed item; messages ending +"Co-Authored-By: Claude Fable 5 "; keep a running `## Progress log` +(one line per item: `date · done · exact next action`) at the bottom of +`Design/CloudTeamCollections/tasks/07-ui-setup.md` (the wiring completes that task's +deferred items). Pre-commit hook fails in worktrees: `yarn prettier --write` your files, +then `--no-verify`. + +**Anti-hang rules:** vitest single-run only; never `yarn build`; no dev servers. + +**Setup:** ` cd src/BloomBrowserUI && yarn install` (leading space; yarn only). Follow +src/BloomBrowserUI/AGENTS.md conventions and the gating rule: all cloud UI stays behind +the experimental feature / capability flags; folder-TC UI byte-identical. + +**Read first:** the "Still needed for UI wiring" section of task 06's final progress-log +entry in `Design/CloudTeamCollections/tasks/06-api-endpoints.md`; the Wave-3 deferrals in +07's and 08's progress logs; `src/BloomBrowserUI/teamCollection/sharingApi.ts` and +`teamCollectionApi.tsx` (the hooks now have REAL endpoints behind them). + +**Work items:** +1. Fix the `WireUpForWinforms` double-registration in `CreateTeamCollection.tsx` (task 06 + found the cloud dialog's registration unconditionally overwrites the folder one, so the + folder create dialog can no longer open). Both dialogs must coexist — e.g. select the + component from a URL param/window flag the C# side already passes, or split bundles. + This is the one item that BREAKS FOLDER TC today: fix it first and prove it with a test. +2. Dedicated sign-in dialog: a small email/password dialog for dev auth mode (per + `sharing/loginState`'s mode field), opened by `sharing/showSignIn` instead of the + create-dialog placeholder. Real-mode shows a "not yet available" message. Wire the C# + side's existing endpoint; component test for both modes. +3. Wire `SharingPanel` into `TeamCollectionSettingsPanel`'s isTeamCollection branch for + cloud TCs (07's deferred item) — folder TCs keep the old admin panel. +4. Wire `JoinCloudCollectionDialog`'s state matching into `CollectionChooser`'s + `onPullDown` (07's deferred item) using `collections/pullDown`'s real responses. +5. Sweep `sharingApi.ts`/`teamCollectionApi.tsx` for any lingering mock-only defaults that + would mask real endpoint failures now that the endpoints exist (fail fast per + AGENTS.md). +6. Run the full component sweep (`yarn vitest run teamCollection collectionsTab + collection react_components/TopBar`) + `yarn lint`; all green, no new warnings. + +**Localization:** new strings per `.github/skills/xlf-strings/SKILL.md`, en-only. + +**Final report (raw data):** branch + shas; per-item status; test sweep verbatim counts; +lint result; anything still blocking the two-instance manual smoke (the Wave-3 gate). 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..0c1a7dbdc9f5 --- /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, resumable per orchestration/ui-wiring.prompt.md) + +- 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 + `orchestration/ui-wiring.prompt.md` 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"; + +let renderedContainer: HTMLDivElement | undefined; + +function render(): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(, container); + }); + return container; +} + +// 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(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; + 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..96921125d8ad 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 (experimental)", + "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; } const moreButtonStyle = css` @@ -65,14 +73,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", @@ -98,6 +107,10 @@ 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(); @@ -114,11 +127,22 @@ export const CollectionCard: React.FunctionComponent = ( const additionalCardTexts: JSX.Element[] = getAdditionalCardTexts(); return ( - + { - 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); }} > @@ -170,53 +194,71 @@ export const CollectionCard: React.FunctionComponent = (
- {/* 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} + +
+
+ )} ); function getAdditionalCardTexts() { const additionalCardTexts: JSX.Element[] = []; + // A join card has no local book count/checked-out/unpublished info to show (it isn't + // joined yet); show only the join cue instead. + if (props.isJoinCard) { + additionalCardTexts.push( + , + ); + return additionalCardTexts; + } additionalCardTexts.push( ({ + mockGet: vi.fn(), + mockPostString: vi.fn(), +})); + +vi.mock("../utils/bloomApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get: mockGet, + postString: mockPostString, + }; +}); + +let renderedContainer: HTMLDivElement | undefined; + +function render(element: React.ReactElement): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(element, container); + }); + return container; +} + +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(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; + 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("h5")).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("h5") 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..17d504c3e385 --- /dev/null +++ b/src/BloomBrowserUI/collection/CollectionChooser.test.tsx @@ -0,0 +1,207 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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; + }) => ( +
+ +
+ ), +})); + +let renderedContainer: HTMLDivElement | undefined; + +function render(): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(, container); + }); + return container; +} + +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(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; + 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("h5") 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..b86b1308d310 --- /dev/null +++ b/src/BloomBrowserUI/collectionsTab/BookButton.test.tsx @@ -0,0 +1,186 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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, +}; + +let renderedContainer: HTMLDivElement | undefined; + +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(); + + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot( + , + container, + ); + }); + return container; +} + +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(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; + 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/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 ?? ""), +})); + +let mountedRoot: HTMLDivElement | undefined; + +function renderButton(status: TeamCollectionStatus): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + mountedRoot = container; + act(() => { + renderRoot(, container); + }); + return container; +} + +afterEach(() => { + if (mountedRoot) { + unmountRoot(mountedRoot); + mountedRoot.remove(); + mountedRoot = undefined; + } + document.body.innerHTML = ""; + 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/teamCollection/CollectionHistoryTable.test.tsx b/src/BloomBrowserUI/teamCollection/CollectionHistoryTable.test.tsx new file mode 100644 index 000000000000..012d176fffd9 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/CollectionHistoryTable.test.tsx @@ -0,0 +1,190 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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", + }, +]; + +let renderedContainer: HTMLDivElement | undefined; + +function renderTable() { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(, container); + }); + return container; +} + +afterEach(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; + 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..620314d1c36f 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,12 @@ const kEventTypes = [ "Moved", ]; // REVIEW maybe better to do this in c# and just send it over? +// 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 +93,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 +105,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
+ {isCloud && kIncidentEventTypes.has(e.Type) && ( + + + + )} {kEventTypes[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..5889c62763f0 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/CreateCloudTeamCollection.test.tsx @@ -0,0 +1,303 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +import { CreateCloudTeamCollectionBody } from "./CreateTeamCollection"; +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. + +let renderedContainer: HTMLDivElement | undefined; + +function render(element: React.ReactElement): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(element, container); + }); + return container; +} + +// 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 })); +} + +afterEach(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; +}); + +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. + unmountRoot(renderedContainer!); + 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/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..1dd0f45ebfa6 100644 --- a/src/BloomBrowserUI/teamCollection/CreateTeamCollection.tsx +++ b/src/BloomBrowserUI/teamCollection/CreateTeamCollection.tsx @@ -6,8 +6,9 @@ import { useEffect, useState } from "react"; import { get, post, postString, useApiStringState } from "../utils/bloomApi"; import { useSubscribeToWebSocketForEvent } from "../utils/WebSocketManager"; import BloomButton from "../react_components/bloomButton"; -import { Div, P } from "../react_components/l10nComponents"; +import { Div, P, Span } from "../react_components/l10nComponents"; import { kDialogPadding } from "../bloomMaterialUITheme"; +import LinearProgress from "@mui/material/LinearProgress"; import { BloomDialog, DialogBottomButtons, @@ -21,6 +22,7 @@ import { } from "../react_components/BloomDialog/commonDialogComponents"; import { useL10n } from "../react_components/l10nHooks"; import { Checkbox } from "../react_components/checkbox"; +import { AttentionTextField } from "../react_components/AttentionTextField"; import { TextWithEmbeddedLink } from "../react_components/link"; import { WireUpForWinforms } from "../utils/WireUpWinform"; import { @@ -29,6 +31,14 @@ import { } from "../react_components/BloomDialog/BloomDialogPlumbing"; import { ErrorBox } from "../react_components/boxes"; import { showRegistrationDialog } from "../react_components/registration/registrationDialog"; +import { isValidEmail } from "../utils/emailUtils"; +import { + ISharingLoginState, + createCloudTeamCollection, + signIn as sharingSignIn, + useSharingLoginState, +} from "./sharingApi"; +import { SignInDialog } from "./SignInDialog"; // Contents of a dialog launched from TeamCollectionSettingsPanel Create Team Collection button. @@ -221,4 +231,361 @@ export const CreateTeamCollectionDialog: React.FunctionComponent<{ ); }; -WireUpForWinforms(CreateTeamCollectionDialog); +// ----------------------------------------------------------------------------------------- +// Cloud Team Collection creation; see sharingApi.ts for the real SharingApi/TeamCollectionApi +// endpoints this drives. Unlike CreateTeamCollectionDialog above, 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. +// ----------------------------------------------------------------------------------------- + +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" ? ( + + isValidEmail(value.trim())} + submitAttempts={props.signInSubmitAttempts} + data-testid="cloud-create-signin-email" + css={css` + margin-top: 5px; + `} + /> + value.length > 0} + submitAttempts={props.signInSubmitAttempts} + data-testid="cloud-create-signin-password" + css={css` + margin-top: 5px; + `} + /> + {props.signInError && ( +
+ {props.signInError} +
+ )} + + Sign In + +
+ ) : ( + // 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 Sharing + 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 [devEmail, setDevEmail] = useState(""); + const [devPassword, setDevPassword] = useState(""); + const [signInSubmitAttempts, setSignInSubmitAttempts] = useState(0); + const [signInError, setSignInError] = useState( + undefined, + ); + 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 ( + + + + { + if ( + !isValidEmail(devEmail.trim()) || + devPassword.length === 0 + ) { + setSignInSubmitAttempts((old) => old + 1); + return; + } + setSignInError(undefined); + sharingSignIn(devEmail.trim(), devPassword).then( + undefined, + (error) => + setSignInError(String(error?.message ?? error)), + ); + }} + signInSubmitAttempts={signInSubmitAttempts} + signInError={signInError} + onCloudSignInClick={() => post("sharing/showSignIn")} + nameAcknowledged={nameAcknowledged} + onAcknowledgeNameChange={setNameAcknowledged} + sendState={sendState} + sendError={sendError} + onStartSend={startSend} + onRetrySend={startSend} + /> + + + {sendState === "done" ? ( + post("common/closeReactDialog")} + > + Close + + ) : ( + + post("common/closeReactDialog") + } + /> + )} + + + ); +}; + +// ----------------------------------------------------------------------------------------- +// This one file/bundle ("createTeamCollectionDialogBundle") hosts three distinct top-level +// dialogs -- the folder-TC create dialog above, the cloud-TC create dialog above, 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 file (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 file 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..7e284403c781 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/CreateTeamCollectionBundleDispatcher.test.tsx @@ -0,0 +1,88 @@ +import { act } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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). + +let mountedRoot: HTMLDivElement | undefined; + +function render(element: React.ReactElement): HTMLDivElement { + const root = document.createElement("div"); + document.body.appendChild(root); + mountedRoot = root; + act(() => { + renderRoot(element, root); + }); + return root; +} + +afterEach(() => { + if (mountedRoot) { + unmountRoot(mountedRoot); + mountedRoot.remove(); + mountedRoot = undefined; + } + // BloomDialog/MUI Dialog portal their content directly onto document.body. + document.body.innerHTML = ""; +}); + +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/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..a632f67af10c --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/JoinCloudCollectionDialog.test.tsx @@ -0,0 +1,303 @@ +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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, + }; +}); + +let mountedRoot: HTMLDivElement | undefined; + +function renderDialog( + overrides: Partial>, +) { + const root = document.createElement("div"); + document.body.appendChild(root); + mountedRoot = root; + act(() => { + renderRoot( + , + root, + ); + }); +} + +// 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(() => { + if (mountedRoot) { + unmountRoot(mountedRoot); + mountedRoot.remove(); + mountedRoot = undefined; + } + // BloomDialog/MUI Dialog portal their content directly onto document.body, outside our + // mounted root, so it must be cleaned up separately between tests. + document.body.innerHTML = ""; + 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..20ccf046e764 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/NewerVersionAvailableMarker.test.tsx @@ -0,0 +1,49 @@ +import { act } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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). + +let renderedContainer: HTMLDivElement | undefined; + +function render(show: boolean): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(, container); + }); + return container; +} + +afterEach(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; +}); + +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..f1e4d9b99476 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/ShareButton.test.tsx @@ -0,0 +1,175 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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, +}; + +let renderedContainer: HTMLDivElement | undefined; + +function renderShareButton() { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(, container); + }); +} + +afterEach(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; + 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..355b68922b5a --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/SharingPanel.test.tsx @@ -0,0 +1,243 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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. + +let renderedContainer: HTMLDivElement | undefined; + +function render(element: React.ReactElement): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(element, container); + }); + return container; +} + +// 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 })); +} + +afterEach(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; +}); + +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; + } = {}, +) { + const onAdd = overrides.onAdd ?? vi.fn(); + const onRemove = overrides.onRemove ?? vi.fn(); + const onSetRole = overrides.onSetRole ?? vi.fn(); + const container = render( + , + ); + return { container, onAdd, onRemove, onSetRole }; +} + +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"); + }); +}); diff --git a/src/BloomBrowserUI/teamCollection/SharingPanel.tsx b/src/BloomBrowserUI/teamCollection/SharingPanel.tsx new file mode 100644 index 000000000000..a4d821ed57c6 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/SharingPanel.tsx @@ -0,0 +1,422 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { useState } from "react"; +import Chip from "@mui/material/Chip"; +import IconButton from "@mui/material/IconButton"; +import DeleteIcon from "@mui/icons-material/Delete"; +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, + 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); + }} + /> + ); +}; + +// 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; +}> = (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) + } + /> + ))} +
+ {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; +}> = (props) => { + const [confirmingRemove, setConfirmingRemove] = useState(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, + ); + + return ( +
+ +
+ {props.member.name && {props.member.name}} + + {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 ( +
+ 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..0272816a6cef --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/SignInDialog.test.tsx @@ -0,0 +1,133 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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). + +let renderedContainer: HTMLDivElement | undefined; + +function render(element: React.ReactElement): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(element, container); + }); + return container; +} + +afterEach(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; +}); + +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..7fbf6124b897 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/SignInDialog.tsx @@ -0,0 +1,223 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { useState } 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, + DialogControlGroup, +} from "../react_components/BloomDialog/commonDialogComponents"; +import { useL10n } from "../react_components/l10nHooks"; +import { AttentionTextField } from "../react_components/AttentionTextField"; +import { ErrorBox } from "../react_components/boxes"; +import { isValidEmail } from "../utils/emailUtils"; +import { + IBloomDialogEnvironmentParams, + useSetupBloomDialog, +} from "../react_components/BloomDialog/BloomDialogPlumbing"; +import { + ISharingLoginState, + openBrowserSignIn, + signIn as sharingSignIn, + useSharingLoginState, +} from "./sharingApi"; + +// 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 ( +
+ + isValidEmail(value.trim())} + submitAttempts={props.submitAttempts} + data-testid="signin-email" + css={css` + margin-top: 5px; + `} + /> + value.length > 0} + submitAttempts={props.submitAttempts} + data-testid="signin-password" + css={css` + margin-top: 5px; + `} + /> + {props.signInError && ( +
+ {props.signInError} +
+ )} + + Sign In + +
+
+ ); +}; + +// 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 [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [submitAttempts, setSubmitAttempts] = useState(0); + const [signInError, setSignInError] = useState( + undefined, + ); + 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 ( + + + + { + 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)), + ); + }} + onOpenBrowserSignIn={() => 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..130f17e35a51 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/TeamCollectionBookStatusPanel.test.tsx @@ -0,0 +1,274 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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, +}; + +let renderedContainer: HTMLDivElement | undefined; + +function renderPanel(status: IBookTeamCollectionStatus) { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(, container); + }); + return container; +} + +afterEach(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; + 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..e02f16cf4992 --- /dev/null +++ b/src/BloomBrowserUI/teamCollection/TeamCollectionDialog.test.tsx @@ -0,0 +1,147 @@ +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderRoot, unmountRoot } from "../utils/reactRender"; +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, +}; + +let renderedContainer: HTMLDivElement | undefined; + +function renderDialog(showReloadButton: boolean) { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot( + , + container, + ); + }); +} + +function getButtonById(id: string): HTMLButtonElement | null { + return document.getElementById(id) as HTMLButtonElement | null; +} + +afterEach(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + // BloomDialog/MUI Dialog portal their content directly onto document.body, outside our + // mounted root, so it must be cleaned up separately between tests. + document.body.innerHTML = ""; + 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; + }) => ( +
+ ), +})); + +let renderedContainer: HTMLDivElement | undefined; + +function render(): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + renderedContainer = container; + act(() => { + renderRoot(, container); + }); + return container; +} + +const folderCapabilities = { + supportsVersionHistory: false, + supportsSharingUi: false, + requiresSignIn: false, +}; +const cloudCapabilities = { + supportsVersionHistory: true, + supportsSharingUi: true, + requiresSignIn: true, +}; + +afterEach(() => { + if (renderedContainer) { + unmountRoot(renderedContainer); + renderedContainer.remove(); + renderedContainer = undefined; + } + document.body.innerHTML = ""; + 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..17a076ac112c 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,33 @@ 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 + (experimental) + +

+ setReload((old) => old + 1), + ); + React.useEffect(() => { + get("sharing/loginState", (result) => { + setLoginState(result.data as ISharingLoginState); + }); + }, [reload]); + return loginState; +} + +// Posts dev-auth-mode credentials. Resolves once the server has updated the login state; +// callers should watch useSharingLoginState() (or the "sharing"/"loginState" event) for the result. +export function signIn(email: string, password: string) { + return postJson("sharing/login", { email, password }); +} + +export function signOut() { + return post("sharing/logout"); +} + +// "cloud" mode's sign-in action: there is no password form, so this just tells Bloom to open +// the BloomLibrary-hosted login page in the user's browser (SharingApi.HandleOpenBrowserSignIn +// -> 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. +export function useSharingMembers(collectionId: string): { + members: IApprovedMember[]; + reload: () => void; +} { + const [members, setMembers] = useState([]); + const [generation, setGeneration] = useState(0); + useSubscribeToWebSocketForEvent("sharing", "membersChanged", () => + setGeneration((old) => old + 1), + ); + React.useEffect(() => { + if (!collectionId) return; + get( + `sharing/members?collectionId=${encodeURIComponent(collectionId)}`, + (result) => { + setMembers((result.data as IApprovedMember[]) ?? []); + }, + ); + }, [collectionId, generation]); + return { members, reload: () => setGeneration((old) => old + 1) }; +} + +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 }); +} + +// 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. +let enabledExperimentalFeaturesPromise: Promise | undefined; + +function getEnabledExperimentalFeaturesOnce(): Promise { + if (!enabledExperimentalFeaturesPromise) { + enabledExperimentalFeaturesPromise = new Promise((resolve) => + get("app/enabledExperimentalFeatures", (result) => + resolve((result.data as string) ?? ""), + ), + ); + } + return enabledExperimentalFeaturesPromise; +} + +// 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() { + enabledExperimentalFeaturesPromise = undefined; +} + +// 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..d88998d41db7 100644 --- a/src/BloomBrowserUI/teamCollection/teamCollectionApi.tsx +++ b/src/BloomBrowserUI/teamCollection/teamCollectionApi.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useState } from "react"; import { get, getBoolean } 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 +26,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 +106,138 @@ 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. +let capabilitiesPromise: Promise | undefined; + +function getTeamCollectionCapabilitiesOnce(): Promise { + if (!capabilitiesPromise) { + capabilitiesPromise = new Promise((resolve) => + get("teamCollection/capabilities", (result) => + resolve( + (result.data as ITeamCollectionCapabilities) ?? + initialTeamCollectionCapabilities, + ), + ), + ); + } + return capabilitiesPromise; +} + +// 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() { + capabilitiesPromise = undefined; +} + +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/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 b024578608a2..144de0b57a2f 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..cf22def92db7 100644 --- a/src/BloomExe/ErrorReporter/HtmlErrorReporter.cs +++ b/src/BloomExe/ErrorReporter/HtmlErrorReporter.cs @@ -394,6 +394,20 @@ Action onExtraButtonClicked // Before we do anything that might be "risky", put the problem in the log. ProblemReportApi.LogProblem(exception, messageText, severity); + if (Program.StartupAutomation) + { + // In automation mode (--automation: E2E harnesses, CI) 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. + 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..b0e152d1418b 100644 --- a/src/BloomExe/ExperimentalFeatures.cs +++ b/src/BloomExe/ExperimentalFeatures.cs @@ -12,6 +12,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; 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..da3ed3d8b83e 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,22 @@ public static void DoWorkWithProgressDialog( // stop the spinner socketServer.SendEvent(socketContext, "finished"); - if (waitForUserToCloseDialogOrReportProblems) + if (waitForUserToCloseDialogOrReportProblems && Program.StartupAutomation) + { + // In automation mode (--automation: E2E harnesses, CI) 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. + 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 +242,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 +256,18 @@ public static async Task DoWorkWithProgressDialogAsync( // stop the spinner socketServer.SendEvent(socketContext, "finished"); - if (waitForUserToCloseDialogOrReportProblems) + if (waitForUserToCloseDialogOrReportProblems && Program.StartupAutomation) + { + // See the matching branch in DoWorkWithProgressDialog above: no human exists + // to click the buttons in automation mode, 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..abddc3a344d7 100644 --- a/src/BloomExe/NonFatalProblem.cs +++ b/src/BloomExe/NonFatalProblem.cs @@ -116,6 +116,21 @@ public static void Report( return; } + if (Program.StartupAutomation) + { + // In automation mode 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..53ed971e899b 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -1701,6 +1701,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..d563203289f1 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/BookVersionManifest.cs @@ -0,0 +1,262 @@ +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; + } + } + + /// How a path differs between two manifests (or a manifest and a local folder). + public enum ManifestDiffKind + { + /// Present in the "new" side, absent from the "base" side. + Added, + + /// Present in both, but the content (sha256 or size) differs. + Changed, + + /// Present in the "base" side, absent from the "new" side. + Removed, + + /// Present in both with identical content. + Unchanged, + } + + /// One path's classification from . + public class ManifestDiffEntry + { + public string Path { get; } + public ManifestDiffKind Kind { get; } + + public ManifestDiffEntry(string path, ManifestDiffKind kind) + { + Path = path; + Kind = kind; + } + + public override string ToString() => $"{Kind}: {Path}"; + } + + /// + /// 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); + } + } + + /// + /// Compares this manifest (typically the last-known-committed one) against a book folder's + /// current content on disk, producing one per path that + /// appears on either side. This is the local, no-network pre-check used both to decide + /// whether a Send is needed at all and to build the `files` list checkin-start needs; the + /// server still does the authoritative diff against the real current version (race + /// protection), returning `changedPaths` for what actually needs uploading. + /// + public List DiffAgainstLocalFolder(string bookFolderPath) + { + return DiffAgainst(FromLocalFolder(bookFolderPath)); + } + + /// + /// Core of , taking an already-built manifest for the + /// "new" side — exposed separately so tests (and a Receive-side diff against a freshly + /// downloaded manifest) can supply one directly without touching disk. + /// + public List DiffAgainst(BookVersionManifest other) + { + var result = new List(); + var allPaths = _entriesByPath + .Keys.Union(other._entriesByPath.Keys, StringComparer.Ordinal) + .OrderBy(p => p, StringComparer.Ordinal); + foreach (var path in allPaths) + { + var inThis = _entriesByPath.TryGetValue(path, out var thisEntry); + var inOther = other._entriesByPath.TryGetValue(path, out var otherEntry); + if (inThis && !inOther) + result.Add(new ManifestDiffEntry(path, ManifestDiffKind.Removed)); + else if (!inThis && inOther) + result.Add(new ManifestDiffEntry(path, ManifestDiffKind.Added)); + else if (thisEntry.Sha256 != otherEntry.Sha256 || thisEntry.Size != otherEntry.Size) + result.Add(new ManifestDiffEntry(path, ManifestDiffKind.Changed)); + else + result.Add(new ManifestDiffEntry(path, ManifestDiffKind.Unchanged)); + } + return result; + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudAuth.cs b/src/BloomExe/TeamCollection/Cloud/CloudAuth.cs new file mode 100644 index 000000000000..42d7bb7ca0b9 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudAuth.cs @@ -0,0 +1,715 @@ +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; } + } + + /// Raised by when a new sign-in changes the identity. + public class CloudAccountSwitchedEventArgs : EventArgs + { + public string PreviousEmail { get; } + public string NewEmail { get; } + + public CloudAccountSwitchedEventArgs(string previousEmail, string newEmail) + { + PreviousEmail = previousEmail; + NewEmail = newEmail; + } + } + + /// 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; + + /// Fired when a sign-in replaces a previously-active, different account. + public event EventHandler AccountSwitched; + + /// Fired whenever the session is cleared, whether by explicit sign-out or a failed refresh. + public event EventHandler SignedOut; + + public CloudAuth(ICloudAuthProvider provider, ICloudTokenStore tokenStore = null) + { + _provider = provider; + _tokenStore = tokenStore ?? new InMemoryCloudTokenStore(); + } + + /// 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(raiseEvent: true); + return false; + } + } + + /// Clears the session (locally and in the token store) and cancels the refresh timer. + public void SignOut() => SignOutCore(raiseEvent: true); + + /// + /// 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); + + if ( + previousEmail != null + && !string.Equals( + previousEmail, + newSession.Email, + StringComparison.OrdinalIgnoreCase + ) + ) + { + AccountSwitched?.Invoke( + this, + new CloudAccountSwitchedEventArgs(previousEmail, newSession.Email) + ); + } + } + + /// + /// 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(raiseEvent: true); + } + } + + private void SignOutCore(bool raiseEvent) + { + lock (_lock) + _session = null; + _refreshTimer?.Dispose(); + _refreshTimer = null; + _tokenStore.Clear(); + if (raiseEvent) + SignedOut?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() => _refreshTimer?.Dispose(); + } + + /// + /// 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, + }; + } + } + + /// + /// 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/Cloud/CloudBookTransfer.cs b/src/BloomExe/TeamCollection/Cloud/CloudBookTransfer.cs new file mode 100644 index 000000000000..35b69283508e --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudBookTransfer.cs @@ -0,0 +1,559 @@ +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; + } + + /// 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; + } + + private 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) or that + /// are already recorded in (resume support: + /// a caller retrying an interrupted Send passes back what a prior attempt already finished, + /// updated in place as this call succeeds, so a second retry needs even less work). Runs PUTs + /// in parallel up to , each carrying an explicit + /// `x-amz-checksum-sha256` header (CONTRACTS.md: "Uploads carry x-amz-checksum-sha256"). + /// + public CloudUploadResult UploadChangedFiles( + CloudS3Location location, + string bookFolderPath, + IEnumerable changedRelativePaths, + BookVersionManifest previousCommittedManifest, + ISet alreadyUploadedThisTransaction, + 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 => + { + if ( + alreadyUploadedThisTransaction != null + && alreadyUploadedThisTransaction.Contains(relativePath) + ) + { + lock (resultGate) + result.SkippedPaths.Add(relativePath); + return; + } + + var localFilePath = ToLocalPath(bookFolderPath, relativePath); + var (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); + alreadyUploadedThisTransaction?.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..ed0f18eddcc0 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudCollectionClient.cs @@ -0,0 +1,613 @@ +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 }); + + /// 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 UnlockBookRpc(string bookId) => + (JObject)CallRpc("unlock_book", new { p_book_id = bookId }); + + /// Admin-only forced unlock; audited server-side, emits a ForcedUnlock event. + public JObject ForceUnlockRpc(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 DeleteBookRpc(string bookId) => + (JObject)CallRpc("delete_book", new { p_book_id = bookId }); + + /// Admin-only; clears a tombstone (name-uniqueness re-enforced). + public JObject UndeleteBookRpc(string bookId) => + (JObject)CallRpc("undelete_book", new { p_book_id = bookId }); + + /// Advisory uniqueness pre-check for a proposed rename. + public JObject RenameCheck(string bookId, string newName) => + (JObject)CallRpc("rename_check", new { p_book_id = bookId, p_new_name = newName }); + + /// + /// 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 } + ); + + /// + /// 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..fb69fdc51e4d --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudCollectionMonitor.cs @@ -0,0 +1,146 @@ +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); + } + } + + public void Stop() + { + lock (_gate) + { + _timer?.Dispose(); + _timer = null; + } + } + + /// + /// 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; + } + } + + 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..0587d0f69ef4 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudEnvironment.cs @@ -0,0 +1,160 @@ +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 string DefaultS3Bucket = "bloom-teams-local"; + private const CloudAuthMode DefaultAuthMode = CloudAuthMode.Dev; + + // Firebase Web API key + project id (Option A): compiled defaults are empty + // placeholders (never call the securetoken API before an override is set); production + // values are set via GOING-LIVE.md Phase 3.5's client-defaults change, sandbox/dev via + // the env-var overrides below. + private const string DefaultFirebaseApiKey = ""; + private const string DefaultFirebaseProjectId = ""; + + /// 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. + public string S3Endpoint { get; } + + /// The bucket that holds this deployment's Cloud Team Collection objects. + public string S3Bucket { 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; } + + /// The Firebase project id (`BLOOM_CLOUDTC_FIREBASE_PROJECT_ID`), used only for + /// sanity-checking a decoded ID token's `aud`/`iss` claims match the project we expect. + public string FirebaseProjectId { 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); + S3Bucket = Get("BLOOM_CLOUDTC_S3_BUCKET", DefaultS3Bucket); + + 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); + FirebaseProjectId = Get("BLOOM_CLOUDTC_FIREBASE_PROJECT_ID", DefaultFirebaseProjectId); + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudJoinFlow.cs b/src/BloomExe/TeamCollection/Cloud/CloudJoinFlow.cs new file mode 100644 index 000000000000..d9c4fd6e90b7 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudJoinFlow.cs @@ -0,0 +1,269 @@ +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; + } + + /// Lists the collections the signed-in user is approved for (CONTRACTS.md's + /// my_collections -- includes unclaimed-but-approved rows, per that RPC's own doc). + public IReadOnlyList ListMyCollections() + { + return _client + .MyCollections() + .OfType() + .Select(o => new CloudCollectionSummary + { + Id = (string)o["id"], + Name = (string)o["name"], + }) + .ToList(); + } + + /// 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; + } + + /// + /// Creates a brand-new cloud collection (create_collection -- caller becomes its sole + /// claimed admin) and immediately joins it locally. Matches the design doc's cloud-create + /// flow: "sign-in -> confirm immutable name -> initial Send (no folder chooser, no + /// restart)" -- the "initial Send" itself (pushing an existing local collection's books up) + /// is done by the caller via the ordinary SynchronizeBooksFromLocalToRepo/PutBook path once + /// this method returns a working CloudTeamCollection, exactly as + /// FolderTeamCollection.SetupTeamCollection does for a folder TC. + /// + public CloudTeamCollection CreateAndJoinCollection( + string collectionName, + ITeamCollectionManager manager, + BookCollectionHolder bookCollectionHolder = null + ) + { + var collectionId = Guid.NewGuid().ToString(); + _client.CreateCollection(collectionId, collectionName); + return JoinCollection(collectionId, collectionName, manager, bookCollectionHolder); + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/CloudRepoCache.cs b/src/BloomExe/TeamCollection/Cloud/CloudRepoCache.cs new file mode 100644 index 000000000000..349bfb30ce8c --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudRepoCache.cs @@ -0,0 +1,571 @@ +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); + _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; + _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. + /// + public void ApplyDelta(JObject changes) + { + lock (_gate) + { + foreach (var row in AsObjectArray(changes["books"])) + { + var id = (string)row["id"]; + if (!_booksById.TryGetValue(id, out var book)) + { + book = new CloudCachedBook(); + _booksById[id] = book; + } + book.ApplyServerRow(row); + } + + AdvanceCursorLocked(changes["max_event_id"]); + } + } + + private static IEnumerable AsObjectArray(JToken token) => + (token as JArray)?.OfType() ?? Enumerable.Empty(); + + // Caller must already hold _gate. + private void AdvanceCursorLocked(JToken maxEventIdToken) + { + if (maxEventIdToken == null || maxEventIdToken.Type == JTokenType.Null) + return; + var maxEventId = (long)maxEventIdToken; + if (maxEventId > _lastSeenEventId) + _lastSeenEventId = maxEventId; + } + + /// + /// 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.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.cs b/src/BloomExe/TeamCollection/Cloud/CloudTeamCollection.cs new file mode 100644 index 000000000000..5ad7e87dff88 --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudTeamCollection.cs @@ -0,0 +1,1870 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +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 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 disposes it on + // CloudAuth.AccountSwitched), 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); + + // Set by RenameBookInRepo (called by the base PutBook, immediately before PutBookInRepo, + // whenever a rename is pending) so the following PutBookInRepo call can resolve the book id + // under the NEW folder name even though the cache/index still only know the OLD name until + // checkin-finish's subsequent HydrateFromServer catches up. + private readonly Dictionary _pendingRenameBookId = new Dictionary< + string, + string + >(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 = new CloudAuth(CloudAuth.CreateProvider(_environment)); + auth.InitializeAtStartup(_environment); + } + _auth = auth; + _client = client ?? new CloudCollectionClient(_environment, _auth); + _transfer = transfer ?? new CloudBookTransfer(); + _cache = CloudRepoCache.LoadOrCreate(localCollectionFolder); + RefreshIndexFromCache(); + } + + public string CollectionIdForCloud => _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; + + // ------------------------------------------------------------------ + // 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 TryGetBookIdForTests(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) => + TryGetCachedBook(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) => + TryGetCachedBook(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) => + TryGetBookId(bookFolderName); + + /// + /// Count of live, currently-unlocked 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. + /// + public int GetUpdatesAvailableCount() + { + EnsureCacheHydrated(); + return _cache + .GetAllBooks() + .Count(b => + !b.DeletedAt.HasValue + && b.CurrentVersionSeq.HasValue + && string.IsNullOrEmpty(b.LockedBy) + && (b.LocalVersionSeq ?? -1) < b.CurrentVersionSeq.Value + ); + } + + // ------------------------------------------------------------------ + // 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; + } + } + } + + private string TryGetBookId(string bookName) + { + lock (_indexGate) + { + if (_pendingRenameBookId.TryGetValue(bookName, out var pendingId)) + return pendingId; + return _bookIdByName.TryGetValue(bookName, out var id) ? id : 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 reliable first/last-name split (task 06's + // 20260707000006 migration adds a best-effort whole display name -- surfaced via + // the book-status JSON's separate lockedByEmail/lockedByName fields instead, since + // stuffing a whole name into "FirstName" with a null Surname would render as + // "Name null" in the existing TS template `${whoFirstName} ${whoSurname}`). + // Left null here; see the task 05 final report's contract-ambiguity note. + lockedByFirstName = null, + 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(); + var cachedBook = TryGetCachedBook(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 = TryGetCachedBook(bookFolderName); + return cachedBook != null && cachedBook.CurrentVersionSeq.HasValue; + } + + public override bool KnownToHaveBeenDeleted(string oldName) + { + var cachedBook = TryGetCachedBook(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(); + } + + /// + /// 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 = TryGetBookId(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 = TryGetBookId(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 = TryGetBookId(bookName); + if (bookId == null) + return; + if (force) + _client.ForceUnlockRpc(bookId); + else + _client.UnlockBookRpc(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 = TryGetCachedBook(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 = TryGetBookId(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." + ); + + var bookId = TryGetBookId(bookFolderName) ?? 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, + new HashSet(), + 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"); + Directory.CreateDirectory(lostAndFoundDir); + var destPath = GetAvailableBloomSourcePath(lostAndFoundDir, bookFolderName); + var zip = new Bloom.Utils.BloomZipFile(destPath); + zip.AddDirectory(sourceBookFolderPath, sourceBookFolderPath.Length + 1, null, null); + zip.Save(); + + var bookId = TryGetBookId(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 + ); + } + } + + private static string GetAvailableBloomSourcePath(string folder, string bookFolderName) + { + var counter = 0; + string path; + do + { + counter++; + path = + Path.Combine(folder, bookFolderName + (counter == 1 ? "" : counter.ToString())) + + ".bloomSource"; + } while (RobustFile.Exists(path)); + return path; + } + + // ------------------------------------------------------------------ + // Receive (FetchBookFromRepo) and single-file reads (GetRepoBookFile) + // ------------------------------------------------------------------ + + protected override string FetchBookFromRepo( + string destinationCollectionFolder, + string bookName + ) + { + EnsureCacheHydrated(); + var bookId = TryGetBookId(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 download = _client.DownloadStart(_collectionId); + var collectionLocation = ParseS3Location(download); + 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); + + _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 download = _client.DownloadStart(_collectionId); + var collectionLocation = ParseS3Location(download); + 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"], + }; + } + + /// + /// 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 = TryGetBookId(bookFolderName); + if (bookId == null) + return; // never made it to the repo; nothing to delete there. + _client.DeleteBookRpc(bookId); + HydrateFromServer(); + } + + public override void RenameBookInRepo(string newBookFolderPath, string oldName) + { + // 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"). All we need to do here is make sure PutBookInRepo can resolve + // the book id under the new name before the cache/index catch up. + var newName = Path.GetFileName(newBookFolderPath); + var bookId = TryGetBookId(oldName); + if (bookId != null) + { + lock (_indexGate) + _pendingRenameBookId[newName] = bookId; + } + } + + 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 = TryGetCachedBook(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 = TryGetCachedBook(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); + } + } + + // ------------------------------------------------------------------ + // 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); + _transfer.UploadChangedFiles( + location, + sourceFolder, + files.Select(f => f.path), + null, + new HashSet(), + 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")); + } + + /// + /// Downloads every file currently in one collection-file group directly from S3 (listing the + /// group's prefix, since CONTRACTS.md defines no manifest RPC for collection-file groups -- + /// only a version-bump counter via get_collection_state; see the task 05 final report's + /// contract-gap note). Unlike book content, this reads "latest", not a pinned version -- an + /// acknowledged deviation from the pinned-read invariant, forced by the missing manifest. + /// + private void DownloadCollectionFileGroup(string groupKey, string destFolder) + { + try + { + var download = _client.DownloadStart(_collectionId); + var location = ParseS3Location(download); + var s3Client = BuildS3Client(location); + var prefix = $"{location.Prefix}collectionFiles/{groupKey}/"; + + var keys = new List(); + string continuationToken = null; + do + { + var response = s3Client + .ListObjectsV2Async( + new ListObjectsV2Request + { + BucketName = location.Bucket, + Prefix = prefix, + ContinuationToken = continuationToken, + } + ) + .GetAwaiter() + .GetResult(); + // AWSSDK v4 returns null (not empty) response collections when the prefix has + // no objects — routine for the allowed-words/sample-texts groups, which most + // collections never populate. (Same v4 change S3Extensions.ListAllObjects + // guards; found live by the first post-bump E2E pass.) + if (response.S3Objects != null) + keys.AddRange(response.S3Objects.Select(o => o.Key)); + continuationToken = + response.IsTruncated == true ? response.NextContinuationToken : null; + } while (continuationToken != null); + + if (keys.Count == 0) + return; + + Directory.CreateDirectory(destFolder); + var keptFileNames = new HashSet(); + foreach (var key in keys) + { + var fileName = key.Substring(prefix.Length); + if (string.IsNullOrEmpty(fileName) || fileName.Contains("/")) + continue; // collection-file groups are flat per CONTRACTS.md's S3 layout. + keptFileNames.Add(fileName); + var destPath = Path.Combine(destFolder, fileName); + var tempPath = destPath + ".tmp"; + using ( + var response = s3Client + .GetObjectAsync( + new GetObjectRequest { BucketName = location.Bucket, Key = key } + ) + .GetAwaiter() + .GetResult() + ) + { + response + .WriteResponseStreamToFileAsync(tempPath, false, CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + if (RobustFile.Exists(destPath)) + RobustFile.Delete(destPath); + RobustFile.Move(tempPath, destPath); + } + + // 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). + 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(); + } + + private static IAmazonS3 BuildS3Client(CloudS3Location location) + { + var env = CloudEnvironment.Current; + var config = new AmazonS3Config + { + ServiceURL = env.S3Endpoint, + ForcePathStyle = env.S3ForcePathStyle, + AuthenticationRegion = location.Region, + // See the matching comment in CloudBookTransfer.BuildDefaultClient: this endpoint + // is always MinIO or another S3-compatible store, never real AWS, so we opt back + // into AWSSDK v4's pre-v4 checksum behavior (only when an operation requires one) + // rather than the new WHEN_SUPPORTED default, which not every S3-compatible server + // understands. + 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); + } + + 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); + } + } + + // ------------------------------------------------------------------ + // 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) + { + var previousBooksById = _cache.GetAllBooks().ToDictionary(b => b.Id); + _cache.ApplyDelta(changes); + _cache.Save(); + RefreshIndexFromCache(); + + 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); + } + } + + if (changes["groups"] is JArray groupsArray && groupsArray.Count > 0) + RaiseRepoCollectionFilesChanged(); + + // Task 06: the status button's "Updates Available (N books)" metadata + // (teamCollection/tcStatusMetadata) can only have changed if this poll actually touched + // any book -- 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. + if (changes["books"] is JArray booksArray && booksArray.Count > 0) + SocketServer?.SendEvent("teamCollection", "statusMetadataChanged"); + + // 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(); + } + + /// 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/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..079c8e3aedfd 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); 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.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..0eee99b0f1c1 100644 --- a/src/BloomExe/TeamCollection/TeamCollection.cs +++ b/src/BloomExe/TeamCollection/TeamCollection.cs @@ -104,6 +104,129 @@ internal void TestOnly_BeginFakeSync() _syncIsRunning = true; } + /// + /// 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; + foreach (var bookName in GetBookList()) + { + if (Directory.Exists(Path.Combine(_localCollectionFolder, bookName))) + 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); + } + } + public TeamCollection( ITeamCollectionManager manager, string localCollectionFolder, @@ -149,7 +272,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 +305,7 @@ public bool OkToCheckIn(string bookName) } if ( - repoStatus.lockedBy == TeamCollectionManager.CurrentUser + repoStatus.lockedBy == CurrentUserIdentity && repoStatus.lockedWhere == TeamCollectionManager.CurrentMachine ) { @@ -195,6 +319,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 +414,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 +455,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 +627,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 +657,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 current user now holds the lock. + /// + 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 +745,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 +904,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 +922,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 +957,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 +1519,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 +1553,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 +1823,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 +1851,167 @@ 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); + } + + /// + /// 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; + } + + 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); + } + /// /// 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. @@ -1883,6 +2314,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 +2393,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)); @@ -2175,7 +2650,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 +2784,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; } @@ -2728,10 +3229,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..eee93ae3c161 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,222 @@ 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)?.CollectionIdForCloud + ?? "" + ); + } + + /// 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; + cloudCollection.PollNow(); + var receivedCount = 0; + var skippedCheckedOutCount = 0; + foreach (var bookName in collection.GetBookList()) + { + var status = collection.GetStatus(bookName); + if (collection.IsCheckedOutHereBy(status)) + { + skippedCheckedOutCount++; + continue; // Checked out here; Receive would conflict with local edits. + } + var repoSeq = cloudCollection.GetRepoVersionSeq(bookName); + var localSeq = cloudCollection.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. + collection.PreserveLocalCopyIfModifiedSinceLastSync(bookName); + collection.CopyBookFromRepoToLocal(bookName, dialogOnError: false); + receivedCount++; + } + 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 +444,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 +560,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 +778,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 +787,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 +800,7 @@ private string GetBookStatusJson(string bookFolderName, Book.Book book) isUserAdmin = _tcManager.OkToEditCollectionSettings, } ); + return AddCloudBookStatusFields(noBookJson, null); } bool isNewLocalBook = false; @@ -564,7 +829,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 +857,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 +872,7 @@ _tcManager.CurrentCollection as FolderTeamCollection where = _tcManager.CurrentCollectionEvenIfDisconnected?.WhatComputerHasBookLocked( bookFolderName ), - currentUser = CurrentUser, + currentUser = CurrentUserForStatus, currentUserName = TeamCollectionManager.CurrentUserFirstName, currentMachine = TeamCollectionManager.CurrentMachine, hasConflictingChange, @@ -622,6 +887,50 @@ _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)) + json["currentUser"] = accountEmail; + 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 +1286,8 @@ private void CheckInOneBook( bookInfo.FolderPath, true, false, - reportProgressFraction + reportProgressFraction, + checkinComment: message ); } catch (Exception) @@ -1284,6 +1594,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..2efebf4dcb40 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,102 @@ 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 = new Cloud.CloudAuth(Cloud.CloudAuth.CreateProvider(environment)); + auth.InitializeAtStartup(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 +777,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 +815,7 @@ public void CheckDisablingTeamCollections(CollectionSettings settings) ); if ( !FeatureStatus - .GetFeatureStatus(Settings.Subscription, FeatureName.TeamCollection) + .GetFeatureStatus(subscriptionForCheck, FeatureName.TeamCollection) .Enabled ) ( @@ -610,6 +824,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..8012af4cfce3 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,96 @@ 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 + ); + var repoBooks = cloudCollection + .GetBookList() + .Select(name => + (name, instanceId: cloudCollection.TryGetBookInstanceIdForName(name)) + ); + return ComputeNotYetDownloadedBookEntries( + localNames, + repoBooks, + collection.PathToDirectory + ); + } + + /// + /// Pure merge logic (unit-tested by CollectionApiTests, no filesystem/network/repo access): + /// given the local book folder names 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 folder yet. + /// + internal static List ComputeNotYetDownloadedBookEntries( + ISet localBookFolderNames, + IEnumerable<(string name, string instanceId)> repoBooks, + string collectionPathToDirectory + ) + { + return repoBooks + .Where(b => !localBookFolderNames.Contains(b.name)) + .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 +1103,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 ab49757e7545..6e2ddcd4f194 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 + ); } /// @@ -402,6 +411,93 @@ 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, GetLocalCloudCollectionIds()); + request.ReplyWithJson(joinCards); + } + + /// + /// Pure matching logic (unit-tested by CollectionChooserApiTests, no filesystem/network): + /// a cloud collection gets a join card iff none of the given local cloud-collection ids + /// (gathered by , which reads TeamCollectionLink.txt + /// files) matches its id. Per the batch's decision, matching is by cloud id ONLY -- a local + /// folder with the same name that is NOT itself a cloud TC (e.g. a plain or folder-TC + /// collection) still gets a join card; CloudJoinFlow's own scenario matching handles the + /// merge-or-conflict decision once the user actually tries to join. + /// + internal static List ComputeJoinCards( + IEnumerable myCloudCollections, + ISet localCloudCollectionIds + ) + { + return myCloudCollections + .Where(c => !localCloudCollectionIds.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. 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 HashSet GetLocalCloudCollectionIds() + { + var ids = new HashSet(); + 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) + ids.Add(link.CloudCollectionId); + } + return ids; + } + + /// + /// 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..e19a22051f6b 100644 --- a/src/BloomExe/web/controllers/ProblemReportApi.cs +++ b/src/BloomExe/web/controllers/ProblemReportApi.cs @@ -686,6 +686,21 @@ public static void ShowProblemReactDialogWithFallbacks( int height = 616 ) { + if (Program.StartupAutomation) + { + // In automation mode (--automation: E2E harnesses, CI) 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. + 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..22505f95b8c6 --- /dev/null +++ b/src/BloomExe/web/controllers/SharingApi.cs @@ -0,0 +1,632 @@ +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 + { + 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); + + // 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) + { + var environment = CloudEnvironment.Current; + _globalAuth = new CloudAuth(CloudAuth.CreateProvider(environment)); + _globalAuth.InitializeAtStartup(environment); + } + 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, + } + ); + } + + 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. There is no display-name data source for + /// members server-side (see the 20260707000006 migration's own comment on this same gap for + /// book locks) -- name is always omitted/null here, exactly as CONTRACTS.md documents + /// ("Only known once claimed" -- in practice, not known at all yet). 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)null, + 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(); + } + + // ------------------------------------------------------------------ + // 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"], + UserName = (string)e["by_user_name"] ?? (string)e["by_email"], + When = (DateTime)e["occurred_at"], + BloomVersion = (string)e["bloom_version"], + }; + + /// Full collection history since the beginning (get_changes(sinceEventId: 0)). + /// CONTRACTS.md defines no dedicated "whole history" RPC; get_changes with a 0 cursor is + /// the documented mechanism for catch-up from scratch. Optionally filtered to one book, + /// resolving Bloom's currently-selected book folder to the server's book id via the + /// currently-open CloudTeamCollection's own index (there is no other way to make that + /// translation from outside that class). + private System.Collections.Generic.List FetchAndCacheHistory( + string collectionId, + bool currentBookOnly + ) + { + var changes = CurrentClient().GetChanges(collectionId, 0); + var events = ((JArray)changes["events"]) + .OfType() + .Select(ToBookHistoryEvent) + .OrderByDescending(e => e.When) + .ToList(); + + SaveHistoryCache(collectionId, events); + + if (currentBookOnly) + { + var bookId = CurrentCloudCollection() + ?.TryGetBookIdForHistoryFilter( + TeamCollectionApi.TheOneInstance?.CurrentBookFolderName + ); + events = events.Where(e => e.BookId == bookId).ToList(); + } + 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"); + } + + /// Persists the last-fetched history so has + /// something to serve while disconnected (task 08's "stand-in for a Wave-3 on-disk cache" -- + /// this task provides the real thing). Best-effort: a failure to write the cache must never + /// break the live history fetch that just succeeded. + private static void SaveHistoryCache( + string collectionId, + System.Collections.Generic.List events + ) + { + var path = HistoryCachePath(collectionId); + if (path == null) + return; + try + { + RobustFile.WriteAllText(path, JsonConvert.SerializeObject(events)); + } + catch (Exception e) + { + NonFatalProblem.ReportSentryOnly(e, "SharingApi: failed to write history cache"); + } + } + + private void HandleHistoryCache(ApiRequest request) + { + var collectionId = request.RequiredParam("collectionId"); + var currentBookOnly = request.GetParamOrNull("currentBookOnly") == "true"; + var path = HistoryCachePath(collectionId); + if (path == null || !RobustFile.Exists(path)) + { + request.ReplyWithJson(new System.Collections.Generic.List()); + return; + } + var events = + JsonConvert.DeserializeObject>( + RobustFile.ReadAllText(path) + ); + if (currentBookOnly) + { + var bookId = CurrentCloudCollection() + ?.TryGetBookIdForHistoryFilter( + TeamCollectionApi.TheOneInstance?.CurrentBookFolderName + ); + events = events.Where(e => e.BookId == bookId).ToList(); + } + 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); + } + + /// 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 (GetLoginState 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().GetLoginState(CloudEnvironment.Current).SignedIn) + 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/TeamCollection/Cloud/BookVersionManifestTests.cs b/src/BloomTests/TeamCollection/Cloud/BookVersionManifestTests.cs new file mode 100644 index 000000000000..f3a614c40b41 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/BookVersionManifestTests.cs @@ -0,0 +1,292 @@ +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 matrix + // ------------------------------------------------------------------ + + private static BookVersionManifest MakeManifest( + params (string path, string sha256, long size)[] entries + ) + { + var dict = new Dictionary(); + foreach (var (path, sha256, size) in entries) + dict[path] = new BookVersionManifestEntry(sha256, size); + return new BookVersionManifest(dict); + } + + [Test] + public void DiffAgainst_ClassifiesAddedChangedRemovedUnchanged() + { + var baseManifest = MakeManifest( + ("unchanged.txt", "hash-u", 1), + ("changed.txt", "hash-c-old", 2), + ("removed.txt", "hash-r", 3) + ); + var otherManifest = MakeManifest( + ("unchanged.txt", "hash-u", 1), + ("changed.txt", "hash-c-new", 2), + ("added.txt", "hash-a", 4) + ); + + // Sanity check on the fixtures themselves before exercising the method under test. + Assert.That(baseManifest.Entries.Count, Is.EqualTo(3)); + Assert.That(otherManifest.Entries.Count, Is.EqualTo(3)); + + var diff = baseManifest.DiffAgainst(otherManifest); + + Assert.That( + diff, + Has.Count.EqualTo(4), + "unchanged+changed+removed+added = 4 distinct paths" + ); + Assert.That(Kind(diff, "unchanged.txt"), Is.EqualTo(ManifestDiffKind.Unchanged)); + Assert.That(Kind(diff, "changed.txt"), Is.EqualTo(ManifestDiffKind.Changed)); + Assert.That(Kind(diff, "removed.txt"), Is.EqualTo(ManifestDiffKind.Removed)); + Assert.That(Kind(diff, "added.txt"), Is.EqualTo(ManifestDiffKind.Added)); + } + + [Test] + public void DiffAgainst_SameSizeDifferentHash_IsChanged() + { + var baseManifest = MakeManifest(("f.txt", "hash-1", 10)); + var otherManifest = MakeManifest(("f.txt", "hash-2", 10)); + + var diff = baseManifest.DiffAgainst(otherManifest); + + Assert.That(Kind(diff, "f.txt"), Is.EqualTo(ManifestDiffKind.Changed)); + } + + [Test] + public void DiffAgainstLocalFolder_DetectsRealFileChange() + { + WriteMinimalBook(); + RobustFile.WriteAllText(Path.Combine(_bookFolderPath, "styles.css"), "body{color:red}"); + var baseManifest = BookVersionManifest.FromLocalFolder(_bookFolderPath); + + // Sanity check: the base manifest really did capture styles.css before we change it. + Assert.That(baseManifest.Entries.Keys, Has.Member("styles.css")); + + RobustFile.WriteAllText( + Path.Combine(_bookFolderPath, "styles.css"), + "body{color:blue}" + ); + + var diff = baseManifest.DiffAgainstLocalFolder(_bookFolderPath); + + Assert.That(Kind(diff, "styles.css"), Is.EqualTo(ManifestDiffKind.Changed)); + Assert.That(Kind(diff, BookName + ".htm"), Is.EqualTo(ManifestDiffKind.Unchanged)); + } + + private static ManifestDiffKind Kind(List diff, string path) => + diff.Single(e => e.Path == path).Kind; + } +} diff --git a/src/BloomTests/TeamCollection/Cloud/CloudAccountSwitchTakeoverTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudAccountSwitchTakeoverTests.cs new file mode 100644 index 000000000000..9a6cf91d21d8 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudAccountSwitchTakeoverTests.cs @@ -0,0 +1,448 @@ +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 TemporaryFolder _collectionFolder; + private Mock _mockTcManager; + private CloudTeamCollection _collection; + private FakeRestExecutor _executor; + private CloudAuth _auth; + + private static CloudEnvironment MakeEnvironment() => + new CloudEnvironment(name => name == "BLOOM_CLOUDTC_ANON_KEY" ? "test-anon-key" : null); + + [SetUp] + public void Setup() + { + _collectionFolder = new TemporaryFolder("CloudAccountSwitchTakeoverTests"); + _mockTcManager = new Mock(); + // "CurrentMachine" (TeamCollectionManager.CurrentMachine) defaults to + // Environment.MachineName; override it via impersonate.txt-free static test seam so + // the test is deterministic regardless of the actual machine it runs on. + TeamCollectionManager.ForceCurrentUserForTests(kCurrentUserEmail); + + _auth = new CloudAuth(new StubCloudAuthProvider(), new InMemoryCloudTokenStore()); + _auth.SignIn(kCurrentUserEmail, "irrelevant"); + var environment = MakeEnvironment(); + var client = new CloudCollectionClient(environment, _auth); + _executor = new FakeRestExecutor(); + client.SetRestClientForTests(_executor); + + _collection = new CloudTeamCollection( + _mockTcManager.Object, + _collectionFolder.FolderPath, + kCollectionId, + environment: environment, + auth: _auth, + client: client, + transfer: new CloudBookTransfer(_ => new Mock().Object) + ); + } + + [TearDown] + public void TearDown() + { + _collectionFolder.Dispose(); + TeamCollectionManager.ForceCurrentUserForTests(null); + } + + /// 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 + // ------------------------------------------------------------------ + + [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. + System.IO.Directory.CreateDirectory(_collectionFolder.Combine("My Book")); + 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 + ); + System.IO.Directory.CreateDirectory(_collectionFolder.Combine("My Book")); + 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" + ); + System.IO.Directory.CreateDirectory(_collectionFolder.Combine("My Book")); + 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..ebfe0c46068d --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudAuthTests.cs @@ -0,0 +1,521 @@ +using System; +using System.Collections.Generic; +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 signedOutRaised = false; + auth.SignedOut += (s, e) => signedOutRaised = true; + + 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(signedOutRaised, Is.True); + 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_RaisesAccountSwitched() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => MakeSession(email), + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + auth.SignIn("alice@dev.local", "BloomDev123!"); + + CloudAccountSwitchedEventArgs raisedArgs = null; + auth.AccountSwitched += (s, e) => raisedArgs = e; + + auth.SignIn("bob@dev.local", "BloomDev123!"); + + Assert.That( + raisedArgs, + Is.Not.Null, + "switching from alice to bob must raise AccountSwitched" + ); + Assert.That(raisedArgs.PreviousEmail, Is.EqualTo("alice@dev.local")); + Assert.That(raisedArgs.NewEmail, Is.EqualTo("bob@dev.local")); + Assert.That(auth.CurrentEmail, Is.EqualTo("bob@dev.local")); + } + + [Test] + public void SignIn_WithSameAccountAgain_DoesNotRaiseAccountSwitched() + { + var provider = new FakeCloudAuthProvider + { + SignInHandler = (email, password) => MakeSession(email), + }; + var auth = new CloudAuth(provider, new FakeCloudTokenStore()); + auth.SignIn("alice@dev.local", "BloomDev123!"); + + var switchedRaised = false; + auth.AccountSwitched += (s, e) => switchedRaised = true; + + auth.SignIn("alice@dev.local", "BloomDev123!"); + + Assert.That(switchedRaised, Is.False); + } + + [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 SignOut_ClearsSessionAndRaisesSignedOut() + { + 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"); + + var signedOutRaised = false; + auth.SignedOut += (s, e) => signedOutRaised = true; + + auth.SignOut(); + + Assert.That(auth.IsSignedIn, Is.False); + Assert.That(auth.CurrentEmail, Is.Null); + Assert.That(signedOutRaised, Is.True); + 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..a992ab50c13e --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudBookTransferTests.cs @@ -0,0 +1,632 @@ +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_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: resume support + // ------------------------------------------------------------------ + + [Test] + public void UploadChangedFiles_ResumeSkipsAlreadyUploadedPaths() + { + WriteBookFile("a.txt", "A"); + WriteBookFile("b.txt", "B"); + var alreadyUploaded = new HashSet { "a.txt" }; + var putKeys = new List(); + _s3Mock + .Setup(s => + s.PutObjectAsync(It.IsAny(), It.IsAny()) + ) + .Callback((req, ct) => putKeys.Add(req.Key)) + .ReturnsAsync( + new PutObjectResponse { HttpStatusCode = HttpStatusCode.OK, VersionId = "v" } + ); + + var result = _transfer.UploadChangedFiles( + _location, + _bookFolderPath, + new[] { "a.txt", "b.txt" }, + null, + alreadyUploaded, + 2, + null, + CancellationToken.None + ); + + Assert.That(result.SkippedPaths, Has.Member("a.txt")); + Assert.That(result.UploadedPaths, Has.Member("b.txt")); + Assert.That( + putKeys, + Has.Count.EqualTo(1), + "a resumed transaction must not re-PUT a.txt" + ); + Assert.That(putKeys[0], Does.EndWith("b.txt")); + Assert.That( + alreadyUploaded, + Has.Member("b.txt"), + "the resume set should grow as this call succeeds, for a further retry to use" + ); + } + + // ------------------------------------------------------------------ + // 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..c0a6cb331a8f --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudEnvironmentTests.cs @@ -0,0 +1,145 @@ +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.S3Bucket, Is.EqualTo("bloom-teams-local")); + 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_S3_BUCKET"] = "sandbox-bucket", + ["BLOOM_CLOUDTC_AUTH_MODE"] = "cloud", + ["BLOOM_CLOUDTC_USER"] = "override@dev.local", + ["BLOOM_CLOUDTC_PASSWORD"] = "pw", + ["BLOOM_CLOUDTC_FIREBASE_API_KEY"] = "sandbox-firebase-key", + ["BLOOM_CLOUDTC_FIREBASE_PROJECT_ID"] = "sandbox-firebase-project", + }; + + 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.S3Bucket, Is.EqualTo("sandbox-bucket")); + 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")); + Assert.That(env.FirebaseProjectId, Is.EqualTo("sandbox-firebase-project")); + } + + [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("")); + Assert.That(env.FirebaseProjectId, 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/CloudRepoCacheTests.cs b/src/BloomTests/TeamCollection/Cloud/CloudRepoCacheTests.cs new file mode 100644 index 000000000000..fe31f539ca72 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudRepoCacheTests.cs @@ -0,0 +1,522 @@ +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); + } + + // ------------------------------------------------------------------ + // 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..3e9b9204831b --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudSyncAtStartupTests.cs @@ -0,0 +1,305 @@ +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 TemporaryFolder _collectionFolder; + private Mock _mockTcManager; + private CloudTeamCollection _collection; + private FakeRestExecutor _executor; + private string _bookInstanceId; + + [SetUp] + public void Setup() + { + _collectionFolder = new TemporaryFolder("CloudSyncAtStartupTests"); + _mockTcManager = new Mock(); + TeamCollectionManager.ForceCurrentUserForTests("test@somewhere.org"); + + 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); + + _collection = new CloudTeamCollection( + _mockTcManager.Object, + _collectionFolder.FolderPath, + kCollectionId, + environment: environment, + auth: auth, + client: client, + // 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. + transfer: new CloudBookTransfer(_ => MakeScriptedS3().Object) + ); + } + + /// 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() + { + _collectionFolder.Dispose(); + TeamCollectionManager.ForceCurrentUserForTests(null); + } + + 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" + ) + ); + } + + 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..53f7843113eb --- /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.TryGetBookIdForTests("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..5a3cecffc874 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionLockTests.cs @@ -0,0 +1,197 @@ +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 TemporaryFolder _collectionFolder; + private Mock _mockTcManager; + private CloudTeamCollection _collection; + private FakeRestExecutor _executor; + + [SetUp] + public void Setup() + { + _collectionFolder = new TemporaryFolder("CloudTeamCollectionLockTests"); + _mockTcManager = new Mock(); + TeamCollectionManager.ForceCurrentUserForTests("test@somewhere.org"); + + 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); + + _collection = new CloudTeamCollection( + _mockTcManager.Object, + _collectionFolder.FolderPath, + kCollectionId, + environment: environment, + auth: auth, + client: client, + transfer: new CloudBookTransfer(_ => new Mock().Object) + ); + + // 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() + { + _collectionFolder.Dispose(); + TeamCollectionManager.ForceCurrentUserForTests(null); + } + + [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_CallsForceUnlockRpc() + { + 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..75dc02682062 --- /dev/null +++ b/src/BloomTests/TeamCollection/Cloud/CloudTeamCollectionMemberTests.cs @@ -0,0 +1,488 @@ +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 TemporaryFolder _collectionFolder; + private Mock _mockTcManager; + private CloudTeamCollection _collection; + private FakeRestExecutor _executor; + private CloudAuth _auth; + + private static CloudEnvironment MakeEnvironment() => + new CloudEnvironment(name => name == "BLOOM_CLOUDTC_ANON_KEY" ? "test-anon-key" : null); + + [SetUp] + public void Setup() + { + _collectionFolder = new TemporaryFolder("CloudTeamCollectionMemberTests"); + _mockTcManager = new Mock(); + TeamCollectionManager.ForceCurrentUserForTests("test@somewhere.org"); + + _auth = new CloudAuth(new StubCloudAuthProvider(), new InMemoryCloudTokenStore()); + var environment = MakeEnvironment(); + var client = new CloudCollectionClient(environment, _auth); + _executor = new FakeRestExecutor(); + client.SetRestClientForTests(_executor); + + _collection = new CloudTeamCollection( + _mockTcManager.Object, + _collectionFolder.FolderPath, + kCollectionId, + environment: environment, + auth: _auth, + client: client, + transfer: new CloudBookTransfer(_ => new Mock().Object) + ); + } + + [TearDown] + public void TearDown() + { + _collectionFolder.Dispose(); + TeamCollectionManager.ForceCurrentUserForTests(null); + } + + [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 disposes it on + // CloudAuth.AccountSwitched), 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). + _executor.Handler = req => + { + Assert.That(req.Resource, Is.EqualTo("rest/v1/rpc/get_changes")); + 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("()")); + } + } +} 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..bb6003e4208b --- /dev/null +++ b/src/BloomTests/TeamCollection/TeamCollectionApiCloudTests.cs @@ -0,0 +1,331 @@ +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 + ) => + 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 : "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_UnlockedNewerBookInRepo_CountsAsUpdateAvailable() + { + HydrateWith( + Book("book-1", "Book One", currentVersionSeq: 3), // never locally received + Book("book-2", "Book Two", currentVersionSeq: 1, lockedBy: "someone-else") // locked: excluded + ); + + var result = ApiTest.GetString(_server, endPoint: "teamCollection/tcStatusMetadata"); + + Assert.That( + result, + Does.Contain("\"updatesAvailableCount\":1"), + "only the unlocked book with a repo version and no local version should count" + ); + } + + [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")); + } + + [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 0386171022a4..19793ef3df7b 100644 --- a/src/BloomTests/WebLibraryIntegration/BloomS3ClientTests.cs +++ b/src/BloomTests/WebLibraryIntegration/BloomS3ClientTests.cs @@ -180,6 +180,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; @@ -192,12 +195,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..b2357f814a55 --- /dev/null +++ b/src/BloomTests/e2e/harness/bloomApi.ts @@ -0,0 +1,135 @@ +// 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). +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; +}; diff --git a/src/BloomTests/e2e/harness/bookStatus.ts b/src/BloomTests/e2e/harness/bookStatus.ts new file mode 100644 index 000000000000..74c4e0e6d4b5 --- /dev/null +++ b/src/BloomTests/e2e/harness/bookStatus.ts @@ -0,0 +1,28 @@ +// 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"; + +export const bookStatus = async (httpPort: number, folderName: string) => { + const response = await getApi( + httpPort, + `teamCollection/bookStatus?folderName=${encodeURIComponent(folderName)}`, + ); + expect(response.status).toBe(200); + return response.json(); +}; + +// 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..ab6b660a49f2 --- /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 Design/CloudTeamCollections/orchestration/09-e2e.prompt.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..04ee9224815c --- /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 +// Design/CloudTeamCollections/orchestration/09-e2e.prompt.md): +// 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..f99ed831706a --- /dev/null +++ b/src/BloomTests/e2e/harness/paths.ts @@ -0,0 +1,28 @@ +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..3565ca24870f --- /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 response.json(); +}; + +/** 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..fca161a86b5a --- /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 +// Design/CloudTeamCollections/orchestration/09-e2e.prompt.md): `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..1ce7fa3a19f6 --- /dev/null +++ b/src/BloomTests/e2e/harness/twoInstanceSetup.ts @@ -0,0 +1,126 @@ +// 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, getApi, postCreateCloudTeamCollection } 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 expect + .poll( + async () => + ( + await ( + await getApi( + alice.httpPort, + "teamCollection/capabilities", + ) + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); + + 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, + }); + // 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 server starts listening — which can + // be before the Team Collection has finished connecting. + await expect + .poll( + async () => + ( + await ( + await getApi( + bob.httpPort, + "teamCollection/capabilities", + ) + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); + + 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..74016ab1b88a --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-1-create-share.spec.ts @@ -0,0 +1,137 @@ +// 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, +} 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(); + 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 expect + .poll( + async () => { + const caps = await ( + await getApi( + instance!.httpPort, + "teamCollection/capabilities", + ) + ).json(); + return caps.supportsSharingUi; + }, + { + timeout: 20_000, + message: "capabilities never flipped to a cloud collection", + }, + ) + .toBe(true); + + 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..0c4d17bb682b --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-10-account-switch.spec.ts @@ -0,0 +1,259 @@ +// 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, + getApi, + postCreateCloudTeamCollection, +} 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 expect + .poll( + async () => + ( + await ( + await getApi( + alice.httpPort, + "teamCollection/capabilities", + ) + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); + + // 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 expect + .poll( + async () => + ( + await ( + await getApi( + bobReopen.httpPort, + "teamCollection/capabilities", + ) + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); + + // 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..86bc19c68062 --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-2-collaboration-loop.spec.ts @@ -0,0 +1,267 @@ +// 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, + getApi, + postCreateCloudTeamCollection, +} from "../harness/bloomApi"; + +const LOG_DIR = "C:\\BloomE2E-logs\\e2e-2"; + +const bookStatus = async (httpPort: number, folderName: string) => { + const response = await getApi( + httpPort, + `teamCollection/bookStatus?folderName=${encodeURIComponent(folderName)}`, + ); + expect(response.status).toBe(200); + return response.json(); +}; + +// 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. +const pollNowViaReceiveUpdates = async (httpPort: number) => { + const response = await postApi( + httpPort, + "teamCollection/receiveUpdates", + "{}", + ); + expect(response.status).toBe(200); +}; + +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 expect + .poll( + async () => + ( + await ( + await getApi( + alice!.httpPort, + "teamCollection/capabilities", + ) + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); + + 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, + }); + // 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 — see twoInstanceSetup.ts's identical wait. + await expect + .poll( + async () => + ( + await ( + await getApi( + bob.httpPort, + "teamCollection/capabilities", + ) + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); + + // 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..ea15c8bc70c5 --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-5-approved-accounts.spec.ts @@ -0,0 +1,304 @@ +// 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, +} 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 expect + .poll( + async () => + ( + await ( + await getApi( + alice.httpPort, + "teamCollection/capabilities", + ) + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); + 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 expect + .poll( + async () => + ( + await ( + await getApi( + bob.httpPort, + "teamCollection/capabilities", + ) + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); + + 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..d1f280cca66b --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-6-kill-mid-send-resume.spec.ts @@ -0,0 +1,297 @@ +// 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, getApi } 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 waitForCloudConnectionReady = async (httpPort: number): Promise => { + await expect + .poll( + async () => + ( + await ( + await getApi(httpPort, "teamCollection/capabilities") + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); +}; + +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 waitForCloudConnectionReady(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 waitForCloudConnectionReady(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..99eceef30ee1 --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-7-unteam-adoption.spec.ts @@ -0,0 +1,281 @@ +// 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, +} 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 expect + .poll( + async () => + ( + await ( + await getApi( + instance!.httpPort, + "teamCollection/capabilities", + ) + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); + + // 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..0ca990e7737f --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-8-receive-during-send.spec.ts @@ -0,0 +1,318 @@ +// 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, getApi } 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; + +const waitForCloudConnectionReady = async (httpPort: number): Promise => { + await expect + .poll( + async () => + ( + await ( + await getApi(httpPort, "teamCollection/capabilities") + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); +}; + +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 waitForCloudConnectionReady(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 waitForCloudConnectionReady(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..862a2db147e0 --- /dev/null +++ b/src/BloomTests/e2e/tests/e2e-9-new-book-lifecycle.spec.ts @@ -0,0 +1,434 @@ +// 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 } 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"; + +// `teamCollection/checkInCurrentBook` (and other UI-thread endpoints gated on +// `_tcManager.CheckConnection()`) 503 with an EMPTY body if called before CloudTeamCollection has +// finished (re)connecting after a fresh launch -- TeamCollectionApi.HandleCheckInCurrentBook's +// `if (!_tcManager.CheckConnection()) { request.Failed(); return; }` guard. This is a different +// post-launch race than `bloomApi.ts`'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 callers that +// relaunch mid-test and immediately need a working cloud connection must wait for this explicitly +// -- the same `capabilities.supportsSharingUi` signal `twoInstanceSetup.ts` already polls after +// every fresh launch/join, factored out here since every relaunch-then-immediately-act step in +// this file needs it too. +const waitForCloudConnectionReady = async (httpPort: number): Promise => { + await expect + .poll( + async () => + ( + await ( + await getApi(httpPort, "teamCollection/capabilities") + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); +}; + +// 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 waitForCloudConnectionReady(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([ + waitForCloudConnectionReady(alice.httpPort), + waitForCloudConnectionReady(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..f6e1c98fbcaa --- /dev/null +++ b/src/BloomTests/e2e/tests/join-auto-open.spec.ts @@ -0,0 +1,175 @@ +// 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, +} 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 expect + .poll( + async () => + ( + await ( + await getApi( + alice.httpPort, + "teamCollection/capabilities", + ) + ).json() + ).supportsSharingUi, + { timeout: 20_000 }, + ) + .toBe(true); + 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(); + 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(); + 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. + await expect + .poll( + async () => { + try { + const caps = await ( + await getApi( + bob.httpPort, + "teamCollection/capabilities", + ) + ).json(); + return caps.supportsSharingUi; + } catch { + return false; // momentarily unreachable during the workspace reopen + } + }, + { + timeout: 120_000, + message: + "Bob's instance never ended up inside the cloud TC after openCollection", + }, + ) + .toBe(true); + + // 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..6d1f241f4c0f --- /dev/null +++ b/src/BloomTests/web/controllers/CollectionApiTests.cs @@ -0,0 +1,128 @@ +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 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 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 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 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 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_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 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..fb9b1d6acc84 --- /dev/null +++ b/src/BloomTests/web/controllers/CollectionChooserApiTests.cs @@ -0,0 +1,105 @@ +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): + /// ComputeJoinCards decides, given the cloud collections the signed-in user belongs to and the + /// set of cloud collection ids that already have a local copy (as gathered by + /// GetLocalCloudCollectionIds, which reads TeamCollectionLink.txt files -- not exercised here + /// since that half needs real files on disk), which of those cloud collections should get a + /// "join card" in the collection chooser. Follows SharingApiTests' pattern of testing internal + /// static pure logic directly, no live server or filesystem required. + /// + [TestFixture] + public class CollectionChooserApiTests + { + [Test] + public void ComputeJoinCards_NoCloudCollections_ReturnsEmpty() + { + var result = CollectionChooserApi.ComputeJoinCards( + new List(), + new HashSet() + ); + + 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 }, + new HashSet() // no local copies at all + ); + + 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_CollectionAlreadyHasLocalCopy_NoJoinCard() + { + var summary = new CloudCollectionSummary { Id = "cloud-1", Name = "Sunshine Books" }; + + var result = CollectionChooserApi.ComputeJoinCards( + new List { summary }, + new HashSet { "cloud-1" } + ); + + Assert.That( + result, + Is.Empty, + "A cloud collection with a matching local TeamCollectionLink.txt id already has a " + + "local copy, so it should not also get a join card" + ); + } + + [Test] + public void ComputeJoinCards_MixOfJoinedAndUnjoined_OnlyUnjoinedGetCards() + { + var joined = new CloudCollectionSummary { Id = "cloud-joined", Name = "Already Have" }; + var unjoined1 = new CloudCollectionSummary { Id = "cloud-new-1", Name = "New One" }; + var unjoined2 = new CloudCollectionSummary { Id = "cloud-new-2", Name = "New Two" }; + + var result = CollectionChooserApi.ComputeJoinCards( + new List { joined, unjoined1, unjoined2 }, + new HashSet { "cloud-joined", "some-other-unrelated-local-tc" } + ); + + var ids = result.Select(c => (string)c.collectionId).ToList(); + Assert.That(ids, Does.Not.Contain("cloud-joined")); + Assert.That(ids, Does.Contain("cloud-new-1")); + Assert.That(ids, Does.Contain("cloud-new-2")); + 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 HashSet { "some-unrelated-cloud-id" } + ); + + 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..dee09db63395 --- /dev/null +++ b/src/BloomTests/web/controllers/SharingApiTests.cs @@ -0,0 +1,219 @@ +using System; +using Bloom.History; +using Bloom.web.controllers; +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", + ["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.Null); // no display-name source server-side; see comment + } + + [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 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" + ); + } + } +} 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/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..902e9c1d4591 --- /dev/null +++ b/supabase/functions/_shared/s3.ts @@ -0,0 +1,230 @@ +// 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; + +/** 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; + } +}; + +/** 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..e2aec5a540d8 --- /dev/null +++ b/supabase/functions/_shared/test_support.ts @@ -0,0 +1,113 @@ +// 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` directly in each test file (it patches the +// SDK client prototypes, so no indirection is needed here). + +/** 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..2d292405f570 --- /dev/null +++ b/supabase/functions/checkin-finish/index.ts @@ -0,0 +1,114 @@ +// 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, + verifyUploadedObject, + writeManifestBackup, +} from "../_shared/s3.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 BookRow { + instance_id: 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 book = await selectTcRow( + req, + "books", + `id=eq.${tx.book_id}&select=instance_id`, + ); + if (!book) { + throw new HttpError(404, { error: "book_not_found" }); + } + + const prefix = `tc/${tx.collection_id}/books/${book.instance_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: { path: string; s3VersionId: string }[] = []; + for (const path of tx.changed_paths) { + const proposed = tx.proposed_files.find((f) => f.path === 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) { + captured.push({ path, s3VersionId: verified.s3VersionId }); + } + } + + 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..e11605f19078 --- /dev/null +++ b/supabase/functions/checkin-start/index.test.ts @@ -0,0 +1,246 @@ +// 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 { mockClient } from "npm:aws-sdk-client-mock@4"; +import { AssumeRoleCommand, STSClient } from "npm:@aws-sdk/client-sts@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + 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 }], +}; + +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; +}; + +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..51e5786e547f --- /dev/null +++ b/supabase/functions/checkin-start/index.ts @@ -0,0 +1,95 @@ +// 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 { HttpError, jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc, selectTcRow } from "../_shared/rpc.ts"; +import { getScopedCredentials } from "../_shared/s3.ts"; + +interface CheckinStartResult { + transactionId: string; + bookId: string; + changedPaths: string[]; +} + +interface BookRow { + instance_id: string; +} + +// The credentials handed to the client for the duration of a check-in: they need to +// PUT new/changed content and read back what's already there (e.g. to resume after +// an interrupted upload). +const CHECKIN_ACTIONS = [ + "s3:PutObject", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", +]; + +// 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). + // Same read-back pattern as checkin-finish; for the new-book path the row was just + // created from bookInstanceId, so the canonical value is identical. + const book = await selectTcRow( + req, + "books", + `id=eq.${result.bookId}&select=instance_id`, + ); + if (!book) { + throw new HttpError(404, { error: "book_not_found" }); + } + + const prefix = `tc/${collectionId}/books/${book.instance_id}/`; + const s3 = await getScopedCredentials(prefix, CHECKIN_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..47cc7ef936fa --- /dev/null +++ b/supabase/functions/collection-files-finish/index.ts @@ -0,0 +1,81 @@ +// 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, + verifyUploadedObject, + writeManifestBackup, +} from "../_shared/s3.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 = `tc/${tx.collection_id}/collectionFiles/${tx.group_key}/`; + const { bucket } = s3Env(); + const client = adminS3Client(); + + const captured: { path: string; s3VersionId: string }[] = []; + for (const path of tx.changed_paths) { + const proposed = tx.proposed_files.find((f) => f.path === path); + if (!proposed) continue; + const verified = await verifyUploadedObject( + client, + bucket, + `${prefix}${path}`, + proposed.sha256, + ); + if (verified) { + captured.push({ path, s3VersionId: verified.s3VersionId }); + } + } + + 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..47e7d4a43e84 --- /dev/null +++ b/supabase/functions/collection-files-start/index.test.ts @@ -0,0 +1,114 @@ +// 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 { mockClient } from "npm:aws-sdk-client-mock@4"; +import { AssumeRoleCommand, STSClient } from "npm:@aws-sdk/client-sts@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + 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 }], +}; + +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; +}; + +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..87c79e4de445 --- /dev/null +++ b/supabase/functions/collection-files-start/index.ts @@ -0,0 +1,66 @@ +// 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 } from "../_shared/s3.ts"; + +const VALID_GROUP_KEYS = new Set(["other", "allowed-words", "sample-texts"]); + +const COLLECTION_FILES_ACTIONS = [ + "s3:PutObject", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", +]; + +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 = `tc/${collectionId}/collectionFiles/${groupKey}/`; + const s3 = await getScopedCredentials(prefix, COLLECTION_FILES_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..5cb2885fdcc8 --- /dev/null +++ b/supabase/functions/download-start/index.test.ts @@ -0,0 +1,95 @@ +// 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 { mockClient } from "npm:aws-sdk-client-mock@4"; +import { AssumeRoleCommand, STSClient } from "npm:@aws-sdk/client-sts@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); + +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; +}; + +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..6a50e7c80430 --- /dev/null +++ b/supabase/functions/download-start/index.ts @@ -0,0 +1,31 @@ +// 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"; + +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 = `tc/${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/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..f3d926736506 --- /dev/null +++ b/supabase/tests/01_tc_schema_test.sql @@ -0,0 +1,464 @@ +-- ============================================================================= +-- 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(42); -- 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' +); + +-- ============================================================================= +-- 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;