Merge branch 'claude/fingerprint-enrollment-support-a6c2f3'
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 36s

This commit is contained in:
2026-08-03 16:31:46 +02:00
3 changed files with 130 additions and 12 deletions
@@ -212,12 +212,34 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
return Task.CompletedTask;
}
/// <summary>
/// When set, a member read waits on it before answering.
/// </summary>
/// <remarks>
/// Every other method here answers from memory and therefore completes before its caller's await
/// ever suspends, which hides anything the screen only gets wrong while a read is in flight — the
/// state a real server leaves it in for the length of a round trip. A test that wants that state
/// holds the gate.
/// </remarks>
internal TaskCompletionSource? MemberReadGate { get; set; }
/// <summary>How many member reads have been asked for, for a test to assert on.</summary>
internal int MemberReads { get; private set; }
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
public async Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
members.TryGetValue(teamId, out var list) ? [.. list] : []);
CancellationToken cancellationToken)
{
MemberReads++;
if (MemberReadGate is { } gate)
{
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
return members.TryGetValue(teamId, out var list) ? [.. list] : [];
}
/// <inheritdoc />
public Task<TeamMemberSummary> AddTeamMemberAsync(
@@ -527,6 +527,50 @@ public sealed class TeamSharingTests : IAsyncLifetime
teams.Status.ShouldContain("Withdrew the invitation");
}
/// <remarks>
/// <para>
/// A reload rebuilds the team list and reselects, so a reload that changed the selection — creating
/// the first team is exactly that — used to leave two reads of the same team in flight: the one the
/// reload awaits, and one the selection handler started on its own. Both clear the member list and
/// then both append to it, so every member was drawn twice. On a team nobody has been added to yet,
/// whose only member is its owner, that read as the owner being in the team twice.
/// </para>
/// <para>
/// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
/// memory each read finishes before the next begins, so the duplicate never appears and the bug
/// survives the test. Holding the read open is what makes this behave like a server.
/// </para>
/// </remarks>
[Fact]
public async Task CreatingATeam_ReadsItsMembersOnce()
{
await UnlockedAsync();
var teams = shell.Teams;
await teams.LoadAsync(Token);
teams.NewTeamCommand.Execute(null);
teams.NewTeamName = "Platform";
teams.NewTeamSlug = "platform";
var gate = new TaskCompletionSource();
server.MemberReadGate = gate;
var create = teams.CreateTeamCommand.ExecuteAsync(null);
// Asserted while the read is still in flight: that is the only moment at which a second read
// started by the selection handler is distinguishable from the reload's own.
server.MemberReads.ShouldBe(1, "a reload reads the selected team's members once");
gate.SetResult();
await create;
teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
}
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
{
await teams.LoadAsync(Token);