diff --git a/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs b/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs index a7b6c2d941..2ddce694f0 100644 --- a/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs +++ b/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs @@ -38,4 +38,23 @@ public async Task ReturnsFalse_WhenNoAccounts() (await client.IsSignedIn()).Should().BeFalse(); } + + // Strict mock only stubs GetAccountsAsync, so these also prove GetCachedUser never reaches for a token. + [Fact] + public async Task GetCachedUser_ReturnsAccountIdentity() + { + var account = Mock.Of(a => a.Username == "tester@example.test" + && a.HomeAccountId == new AccountId("uid.tid", "uid", "tid")); + var (client, _) = BuildClient([account]); + + (await client.GetCachedUser()).Should().Be(new LexboxUser("tester@example.test", "uid")); + } + + [Fact] + public async Task GetCachedUser_ReturnsNull_WhenNoAccounts() + { + var (client, _) = BuildClient([]); + + (await client.GetCachedUser()).Should().BeNull(); + } } diff --git a/backend/FwLite/FwLiteShared.Tests/Projects/CombinedProjectsServiceTests.cs b/backend/FwLite/FwLiteShared.Tests/Projects/CombinedProjectsServiceTests.cs new file mode 100644 index 0000000000..54312f17d9 --- /dev/null +++ b/backend/FwLite/FwLiteShared.Tests/Projects/CombinedProjectsServiceTests.cs @@ -0,0 +1,27 @@ +using FwLiteShared.Auth; +using FwLiteShared.Projects; +using LcmCrdt; + +namespace FwLiteShared.Tests.Projects; + +public class CombinedProjectsServiceTests +{ + private static readonly LexboxServer Staging = new(new Uri("https://staging.languagedepot.org"), "Staging"); + private static readonly LexboxServer Dev = new(new Uri("https://lexbox.dev.languagetechnology.org"), "Dev"); + + private static ProjectData ProjectFrom(LexboxServer origin) => + new("Sena 3", "sena-3", Guid.NewGuid(), ProjectData.GetOriginDomain(origin.Authority), Guid.NewGuid()); + + [Fact] + public void ServerOwnsProject_TrueForOriginServer() + { + CombinedProjectsService.ServerOwnsProject(ProjectFrom(Staging), Staging).Should().BeTrue(); + } + + [Fact] + public void ServerOwnsProject_FalseForOtherServerSharingTheGuid() + { + // A project downloaded from staging must not be claimed by dev when both list the same GUID. + CombinedProjectsService.ServerOwnsProject(ProjectFrom(Staging), Dev).Should().BeFalse(); + } +} diff --git a/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs index 22172e4126..c493ad51cd 100644 --- a/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs +++ b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs @@ -191,6 +191,18 @@ public async ValueTask IsSignedIn() return accounts.Any(); } + /// + /// Who's signed in, from the local MSAL cache (no network). Optimistic like : + /// non-null means "was signed in", not "can get a token now". Null if none; for a token too use . + /// + public async ValueTask GetCachedUser() + { + await ConfigureCache(); + // GetAccountsAsync is authority-scoped (WithOidcAuthority), so this is already the origin server's account. + var account = (await _application.GetAccountsAsync()).FirstOrDefault(); + return account?.Username is null ? null : new LexboxUser(account.Username, account.HomeAccountId.ObjectId); + } + /// /// Gets a usable authentication result, silently refreshing when the cached token is missing or near /// expiry. Returns null when none can be produced — in one of two cases the caller may want to tell apart: diff --git a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs index 1815db8b8e..d3080cf9a9 100644 --- a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs +++ b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs @@ -67,7 +67,7 @@ private async Task ServerProjects(LexboxServer server, bool forc lexboxProjectService.InvalidateProjectsCache(server); var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server); var user = await lexboxProjectService.GetLexboxUser(server); - await UpdateProjectServerInfo(lexboxProjects.Projects, user); + await UpdateProjectServerInfo(server, lexboxProjects.Projects, user); var projectModels = lexboxProjects.Projects.Select(p => new ProjectModel( p.Name, p.Code, @@ -81,16 +81,20 @@ private async Task ServerProjects(LexboxServer server, bool forc return new(server, projectModels, lexboxProjects.CanDownloadByCode); } - private async Task UpdateProjectServerInfo(FieldWorksLiteProject[] lexboxProjects, LexboxUser? lexboxUser) + private async Task UpdateProjectServerInfo(LexboxServer server, FieldWorksLiteProject[] lexboxProjects, LexboxUser? lexboxUser) { foreach (var serverProject in lexboxProjects) { var localProject = crdtProjectsService.GetProject(serverProject.Id); - if (localProject?.Data is null) continue; + if (localProject?.Data is null || !ServerOwnsProject(localProject.Data, server)) continue; await crdtProjectsService.UpdateProjectServerInfo(localProject, lexboxUser?.Name, lexboxUser?.Id, ToRole(serverProject.Role)); } } + // The same GUID can live on multiple servers and GetProject matches on GUID alone, so only the origin + // server may stamp user/role: otherwise the current user (and CRDT commit author, until next sync) is wrong. + internal static bool ServerOwnsProject(ProjectData localProjectData, LexboxServer server) => + localProjectData.ServerId == server.Id; [JSInvokable] public async Task ServerProjects(string serverId, bool forceRefresh) diff --git a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs index ef8b52806f..1544b7000c 100644 --- a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs @@ -17,7 +17,9 @@ public class ProjectServicesProvider( CrdtProjectsService crdtProjectsService, IServiceProvider serviceProvider, LexboxProjectService lexboxProjectService, - IEnumerable projectProviders + OAuthClientFactory oAuthClientFactory, + IEnumerable projectProviders, + ILogger logger ): IAsyncDisposable { private IProjectProvider? FwDataProjectProvider => @@ -60,6 +62,7 @@ public Task OpenCrdtProject(string code) var server = lexboxProjectService.GetServer(project.Data); var currentProjectService = scopedServices.GetRequiredService(); var projectData = await currentProjectService.SetupProjectContext(project); + projectData = await ResolveOriginUser(server, currentProjectService, projectData); scopedServices.GetRequiredService().TriggerSync(project); var miniLcm = ActivatorUtilities.CreateInstance(scopedServices, project); scope = ProjectScope.Create(serviceScope, this, projectData.Name, miniLcm); @@ -82,6 +85,29 @@ public Task OpenCrdtProject(string code) }); } + // Persist the origin user before the UI reads identity, so it's right from first render instead of racing + // the background sync. Fast local read (see GetCachedUser); null means signed-out, so keep the persisted value. + private async Task ResolveOriginUser(LexboxServer? server, + CurrentProjectService currentProjectService, + ProjectData projectData) + { + if (server is null) return projectData; + LexboxUser? currentUser; + try + { + currentUser = await oAuthClientFactory.GetClient(server).GetCachedUser(); + } + catch (Exception e) + { + // Best-effort: a cache-read failure must not block opening the project. + logger.LogWarning(e, "Failed to read cached user for {ProjectName}; keeping persisted origin user", projectData.Name); + return projectData; + } + if (currentUser is null) return projectData; + await currentProjectService.UpdateLastUser(currentUser.Name, currentUser.Id); + return await currentProjectService.GetProjectData(); + } + [JSInvokable] public Task OpenFwDataProject(string projectName) { diff --git a/backend/FwLite/FwLiteShared/Sync/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs index e02da191a7..d326496ab3 100644 --- a/backend/FwLite/FwLiteShared/Sync/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -107,7 +107,7 @@ public async Task ExecuteSync(bool skipNotifications = false) } logger.LogInformation("Synced project {ProjectName} with server", project.Name); UpdateSyncStatus(SyncStatus.Success); - await ApplySyncedCommentReadStatus(syncResults, project.LastUserId); + await ApplySyncedCommentReadStatus(syncResults, currentUser?.Id ?? project.LastUserId); await syncRepository.UpdateSyncDate(syncDate); // Best-effort: if the push listener failed to start at project-open (e.g. user was offline), this // restarts it now that we know auth + network are healthy. If it's already running, the cache diff --git a/backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs b/backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs new file mode 100644 index 0000000000..7d947e0af0 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs @@ -0,0 +1,31 @@ +namespace LcmCrdt.Tests; + +public class CurrentProjectServiceTests(MiniLcmApiFixture fixture) : IClassFixture +{ + private CurrentProjectService ProjectService => fixture.GetService(); + + [Fact] + public async Task UpdateLastUser_WithNullUser_KeepsPersistedValue() + { + // Open-time resolution falls back to this persisted value when auth is null, so a null update mustn't wipe it. + await ProjectService.UpdateLastUser("Tim Haasdyk", "tim-id"); + + await ProjectService.UpdateLastUser(null, null); + + var projectData = await ProjectService.GetProjectData(); + projectData.LastUserName.Should().Be("Tim Haasdyk"); + projectData.LastUserId.Should().Be("tim-id"); + } + + [Fact] + public async Task UpdateLastUser_ReplacesPreviousUser() + { + await ProjectService.UpdateLastUser("first", "first-id"); + + await ProjectService.UpdateLastUser("second", "second-id"); + + var projectData = await ProjectService.GetProjectData(); + projectData.LastUserName.Should().Be("second"); + projectData.LastUserId.Should().Be("second-id"); + } +}