Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAccount>(a => a.Username == "tester@example.test"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's just a test, but the username in this context is actually the Name of the person, so LexCore.Entities.User.Name and not Email or Username

&& 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();
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
12 changes: 12 additions & 0 deletions backend/FwLite/FwLiteShared/Auth/OAuthClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,18 @@ public async ValueTask<bool> IsSignedIn()
return accounts.Any();
}

/// <summary>
/// Who's signed in, from the local MSAL cache (no network). Optimistic like <see cref="IsSignedIn"/>:
/// non-null means "was signed in", not "can get a token now". Null if none; for a token too use <see cref="GetCurrentUser"/>.
/// </summary>
public async ValueTask<LexboxUser?> GetCachedUser()
{
await ConfigureCache();
// GetAccountsAsync is authority-scoped (WithOidcAuthority), so this is already the origin server's account.
var account = (await _application.GetAccountsAsync()).FirstOrDefault();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's already a number of places here where we call ConfigureCache followed by _application.GetAccountsAsync maybe we should make a private method for that.

return account?.Username is null ? null : new LexboxUser(account.Username, account.HomeAccountId.ObjectId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// <summary>
/// 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:
Expand Down
10 changes: 7 additions & 3 deletions backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ private async Task<ServerProjects> 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,
Expand All @@ -81,16 +81,20 @@ private async Task<ServerProjects> 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?> ServerProjects(string serverId, bool forceRefresh)
Expand Down
28 changes: 27 additions & 1 deletion backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ public class ProjectServicesProvider(
CrdtProjectsService crdtProjectsService,
IServiceProvider serviceProvider,
LexboxProjectService lexboxProjectService,
IEnumerable<IProjectProvider> projectProviders
OAuthClientFactory oAuthClientFactory,
IEnumerable<IProjectProvider> projectProviders,
ILogger<ProjectServicesProvider> logger
): IAsyncDisposable
{
private IProjectProvider? FwDataProjectProvider =>
Expand Down Expand Up @@ -60,6 +62,7 @@ public Task<ProjectScope> OpenCrdtProject(string code)
var server = lexboxProjectService.GetServer(project.Data);
var currentProjectService = scopedServices.GetRequiredService<CurrentProjectService>();
var projectData = await currentProjectService.SetupProjectContext(project);
projectData = await ResolveOriginUser(server, currentProjectService, projectData);
scopedServices.GetRequiredService<BackgroundSyncService>().TriggerSync(project);
var miniLcm = ActivatorUtilities.CreateInstance<MiniLcmJsInvokable>(scopedServices, project);
scope = ProjectScope.Create(serviceScope, this, projectData.Name, miniLcm);
Expand All @@ -82,6 +85,29 @@ public Task<ProjectScope> 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<ProjectData> 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<ProjectScope> OpenFwDataProject(string projectName)
{
Expand Down
2 changes: 1 addition & 1 deletion backend/FwLite/FwLiteShared/Sync/SyncService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public async Task<SyncResults> 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
Expand Down
31 changes: 31 additions & 0 deletions backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace LcmCrdt.Tests;

public class CurrentProjectServiceTests(MiniLcmApiFixture fixture) : IClassFixture<MiniLcmApiFixture>
{
private CurrentProjectService ProjectService => fixture.GetService<CurrentProjectService>();

[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");
}
}
Loading