diff --git a/src/BloomExe/BloomExe.csproj b/src/BloomExe/BloomExe.csproj index ba533cb4d40a..677bd2e4f2d7 100644 --- a/src/BloomExe/BloomExe.csproj +++ b/src/BloomExe/BloomExe.csproj @@ -233,8 +233,8 @@ - - + + @@ -279,6 +279,7 @@ + all diff --git a/src/BloomExe/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..62410c690c6a --- /dev/null +++ b/src/BloomExe/TeamCollection/Cloud/CloudCollectionClient.cs @@ -0,0 +1,629 @@ +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 } + ); + + /// + /// Sets a member's display name (20260713000001 migration). Admin may set anyone's; + /// a claimed member may set their own. Blank/whitespace clears it server-side. + /// Returns nothing useful (the RPC is void); callers refresh via MembersList. + /// + public void MembersSetDisplayName(string collectionId, long memberId, string displayName) => + CallRpc( + "members_set_display_name", + new + { + p_collection_id = collectionId, + p_member_id = memberId, + p_display_name = displayName, + } + ); + + /// + /// Changes an approved-account's role (admin-only; last-admin guard enforced server-side). + /// Task 06 live-verification fix: same p_member_id mismatch as + /// -- the deployed RPC is + /// members_set_role(p_collection_id, p_member_id bigint, p_new_role). + /// + public JObject MembersSetRole(string collectionId, long memberId, string role) => + (JObject)CallRpc( + "members_set_role", + new + { + p_collection_id = collectionId, + p_member_id = memberId, + p_new_role = role, + } + ); + + /// Union merge (insert ... on conflict do nothing) of palette colors. + public JObject AddPaletteColors(string collectionId, string palette, string[] colors) => + (JObject)CallRpc( + "add_palette_colors", + new + { + p_collection_id = collectionId, + p_palette = palette, + p_colors = colors, + } + ); + + /// + /// Client-originated history entry. uses the same numeric + /// values as (extended server-side with + /// incident types such as WorkPreservedLocally). Exact parameter names are our best + /// reading of CONTRACTS.md's "log_event(...)" shorthand -- flagged as a contract + /// ambiguity in the task 05 final report. + /// + public JObject LogEvent( + string collectionId, + string bookId, + int eventType, + string comment = null + ) => + (JObject)CallRpc( + "log_event", + new + { + p_collection_id = collectionId, + p_book_id = bookId, + p_type = eventType, + // The deployed tc.log_event RPC's message parameter is `p_message`, NOT + // `p_comment` (CONTRACTS.md's "log_event(...)" shorthand was ambiguous -- this + // class's own doc comment flagged the guess). PostgREST matches functions by + // argument NAME, so the wrong key made every log_event call 404 (no function + // with that signature); the only caller (CloudTeamCollection. + // SaveLocalCopyForRecovery) wraps it in a Sentry-only catch, so the + // WorkPreservedLocally incident silently never reached the server's history. + // Found live by E2E-4's forced-check-in recovery scenario. + p_message = comment, + } + ); + + /// + /// Opens (or refreshes, if called again with the same open transaction) a check-in + /// transaction. null means "first Send of a new book". + /// is the diff (added/changed paths only) as + /// [{path,sha256,size}]. Throws with + /// LockHeldByOther/BaseVersionSuperseded/NameConflict/ClientOutOfDate on the documented + /// 409/426s. + /// + public JObject CheckinStart( + string collectionId, + string bookId, + string bookInstanceId, + string proposedName, + string baseVersionId, + string checksum, + string clientVersion, + JArray files + ) => + (JObject)CallEdgeFunction( + "checkin-start", + new + { + collectionId, + bookId, + bookInstanceId, + proposedName, + baseVersionId, + checksum, + clientVersion, + files, + } + ); + + /// + /// Commits a check-in transaction: verifies uploads, writes the version/manifest rows, + /// releases the lock (unless ), emits events. + /// + public JObject CheckinFinish( + string transactionId, + string comment = null, + bool keepCheckedOut = false + ) => + (JObject)CallEdgeFunction( + "checkin-finish", + new + { + transactionId, + comment, + keepCheckedOut, + } + ); + + /// Abandons an open check-in transaction (nothing was committed). + public void CheckinAbort(string transactionId) => + CallEdgeFunction("checkin-abort", new { transactionId }); + + /// Read-only STS creds (GetObject + GetObjectVersion) scoped to the collection prefix. + public JObject DownloadStart(string collectionId) => + (JObject)CallEdgeFunction("download-start", new { collectionId }); + + /// Phase 1 of the two-phase collection-files write (repo-wins on 409 VersionConflict). + public JObject CollectionFilesStart( + string collectionId, + string groupKey, + long expectedVersion, + JArray files + ) => + (JObject)CallEdgeFunction( + "collection-files-start", + new + { + collectionId, + groupKey, + expectedVersion, + files, + } + ); + + /// Phase 2: bumps the collection-file group version atomically. + public JObject CollectionFilesFinish(string transactionId) => + (JObject)CallEdgeFunction("collection-files-finish", new { transactionId }); + + /// + /// The apikey header is required by PostgREST/edge-functions regardless of sign-in state; + /// the bearer token (when we have one) is what RLS/edge functions actually authorize + /// against. Deliberately does NOT fail if there is no token yet — an anonymous call will + /// simply get a 401 from the server, which handles. + /// + private void AddCommonHeaders(RestRequest request) + { + request.AddHeader("apikey", _environment.AnonKey); + var token = _auth.GetAccessTokenOrNull(); + if (!string.IsNullOrEmpty(token)) + request.AddHeader("Authorization", $"Bearer {token}"); + } + + /// + /// Runs (called twice if a retry-after-refresh happens, so + /// it must build a fresh RestRequest each time rather than reusing one). On a 401, tries + /// exactly one and retries once; if that still + /// comes back 401 (or there was no session to refresh), aborts with a NotSignedIn error + /// rather than looping — per the task brief, a failed refresh mid-operation must abort + /// cleanly and surface "please sign in", not block or retry indefinitely. + /// + private JToken ExecuteWithAuthRetry(Func makeRequest) + { + var response = RestClient.Execute(makeRequest()); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + if (_auth.TryRefreshOn401()) + response = RestClient.Execute(makeRequest()); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new CloudCollectionClientException( + CloudErrorCode.NotSignedIn, + "Please sign in to continue." + ); + } + + return HandleResponse(response); + } + + private JToken HandleResponse(IRestResponse response) + { + var statusCode = (int)response.StatusCode; + if (statusCode >= 200 && statusCode < 300) + return string.IsNullOrWhiteSpace(response.Content) + ? null + : JToken.Parse(response.Content); + + throw MapError(response); + } + + /// + /// Classifies an error response into a . + /// Edge functions return CONTRACTS.md's standard envelope `{error: "", ...extra}` + /// (built by supabase/functions/_shared/errors.ts) for the documented 409s/426s; + /// Postgres RPC errors arrive as `{code, message, details, hint}` (PostgREST's shape, + /// where `code` is the SQLSTATE from the RAISE EXCEPTION in the SQL functions). Anything + /// we don't recognize maps to with the server's own + /// message preserved. + /// + private CloudCollectionClientException MapError(IRestResponse response) + { + JToken body = null; + string serverCode = null; + string message = response.Content; + try + { + if (!string.IsNullOrWhiteSpace(response.Content)) + { + body = JToken.Parse(response.Content); + // The `error` key is the CONTRACTS.md envelope every edge function actually + // uses; `code` is kept as a fallback for PostgREST RPC error bodies. An + // earlier version of this method read ONLY `code`, which classified every + // documented edge-function 409 (NameConflict/LockHeldByOther/ + // BaseVersionSuperseded/MissingOrBadUploads/VersionConflict) as Unknown -- + // found live by E2E-9's same-name race, where PutBookInRepo's NameConflict + // retry loop never engaged because its exception filter never matched. + serverCode = (string)body["error"] ?? (string)body["code"]; + message = (string)body["message"] ?? message; + } + } + catch (Exception e) + { + // Not JSON (or not an object) — fall back to the raw content/status as the message. + Logger.WriteEvent( + "CloudCollectionClient: error response was not parseable JSON: " + e.Message + ); + } + + if (response.StatusCode == (HttpStatusCode)426) + return new CloudCollectionClientException( + CloudErrorCode.ClientOutOfDate, + message ?? "This version of Bloom is out of date.", + body + ); + + switch (serverCode) + { + case "LockHeldByOther": + return new CloudCollectionClientException( + CloudErrorCode.LockHeldByOther, + message, + body + ); + case "BaseVersionSuperseded": + return new CloudCollectionClientException( + CloudErrorCode.BaseVersionSuperseded, + message, + body + ); + case "NameConflict": + return new CloudCollectionClientException( + CloudErrorCode.NameConflict, + message, + body + ); + case "MissingOrBadUploads": + return new CloudCollectionClientException( + CloudErrorCode.MissingOrBadUploads, + message, + body + ); + case "VersionConflict": + return new CloudCollectionClientException( + CloudErrorCode.VersionConflict, + message, + body + ); + case "ClientOutOfDate": + return new CloudCollectionClientException( + CloudErrorCode.ClientOutOfDate, + message, + body + ); + } + + if (response.StatusCode == HttpStatusCode.Gone) + return new CloudCollectionClientException( + CloudErrorCode.TransactionExpired, + message ?? "The upload transaction has expired.", + body + ); + + return new CloudCollectionClientException( + CloudErrorCode.Unknown, + message ?? response.StatusDescription, + body + ); + } + } +} diff --git a/src/BloomExe/TeamCollection/Cloud/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/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/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/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/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/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/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/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/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?) } } }