Add the member the directory cannot see, rather than inviting them
ci / build and test (push) Successful in 1m30s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 33s

ADD MEMBER quietly issued an invitation instead of adding anybody, for everyone
who had signed in here and not yet enrolled. The screen told them that address
had no account, the members list did not change, and the person only actually
joined on the next hourly sweep.

The client decided whether an address had an account by asking the public-key
directory, and the directory answers a narrower question than that. It drops
every account with no current key — deliberately, because an entry exists to be
wrapped to and one carrying no key is a check a caller forgets exactly once. An
account exists from its owner's first authenticated request and publishes
nothing until they choose a passphrase on their own machine, so every account is
missing from the directory for that whole window and some indefinitely. A miss
there is not an absent account, and reading it as one was the bug.

The server would have taken the add. TeamService.AddMemberAsync only requires
the account row, and TeamMemberSummary.IsEnrolled exists precisely so a member
with no key can be listed — added on Monday, enrolled on Tuesday. The client
never asked.

So the directory is still asked first and the miss is retried as an add by
address, and only a server saying there is no such account reaches the
invitation. AddTeamMemberRequest gained an Email used when UserId is empty. The
lookup-first ordering is kept because it is load-bearing for sharing and not for
this: the key verified before a vault key is wrapped is the one the lookup
returned, and nothing is wrapped by adding somebody. That is why resolving the
address server-side is safe here and would not be there.

NoSuchAccount is its own code rather than folded into InvalidTeam, because it is
the one add failure the caller can act on unprompted — there is nobody to add,
so invite them — and a code shared with a rejected role would leave them
guessing which had happened. It does answer whether an address has an account
here, which CreateTeamInvitationRequest deliberately does not. That is the
property traded for the fix; the exposure is bounded by the admin check the add
already needed, and it is the same fact the member list shows a moment later.
Adding by a user id that does not exist now answers 404 no-such-account rather
than 400 invalid-team, and nothing depended on the old pairing.

The two silent returns are gone. Offline and no-team-selected set nothing and
returned, so those failures were visible only as a flicker of the busy flag —
which reads as a button that does nothing at all. The success line reads the
enrollment flag too, because pointing an unenrolled member at SHARE KEY is
pointing at a button that will refuse; their row already says it holds no key.

Why nothing caught it. FakeVaultServer had one list, so it could not tell an
account that does not exist from one that exists and has not enrolled — the
distinction this whole path turns on — and every account it knew was enrolled by
construction. It grows an accounts list beside the directory and reports
IsEnrolled from whether the directory has them, rather than hardcoding true. The
regression test asserts Invitations is empty, which is what fails against the
old behaviour. Four tests: that pair in the shell suite, and in the API suite an
unenrolled account added by address after its own directory lookup comes back
empty, and an unknown address refused under the new code. 1495 tests pass.
This commit is contained in:
2026-08-03 17:04:03 +02:00
parent 1f2607cc9a
commit aa06868b1e
10 changed files with 386 additions and 45 deletions
@@ -450,6 +450,69 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
vault.TeamId.ShouldBe(team.TeamId);
}
/// <remarks>
/// <para>
/// An account exists from its owner's first authenticated request and publishes no key until they
/// enroll, and the directory omits it for that whole window — deliberately, because an entry exists
/// to be wrapped to. So the lookup is asserted empty first: that is not a missing account, and a
/// caller that read it as one would invite an address that already has one.
/// </para>
/// <para>
/// Adding by address is what covers the gap, and the member it produces says <c>IsEnrolled</c>
/// false. Membership is authorization and grants nothing readable, so there is nothing inconsistent
/// about a member with no key — it is the state everybody passes through.
/// </para>
/// </remarks>
[Fact]
public async Task AnAccountThatHasNotEnrolled_CanBeAddedByAddressThoughTheDirectoryOmitsIt()
{
var owner = await EnrolledClientAsync("unenrolled-owner", "uowner@example.com");
var address = NewAddress();
var (_, userId) = await SignInAsync(address);
var team = await CreateTeamAsync(owner, "Newcomers");
var found = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
owner, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
found.ShouldBeEmpty("they have published no key, so there is nothing to wrap to");
var member = await PostAsync<AddTeamMemberRequest, TeamMemberSummary>(
owner,
MembersUrl(team.TeamId),
new AddTeamMemberRequest(Guid.Empty, TeamMemberRole.Member, address));
member.UserId.ShouldBe(userId);
member.IsEnrolled.ShouldBeFalse();
member.Role.ShouldBe(TeamMemberRole.Member);
var listed = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
listed.ShouldContain(row => row.UserId == userId && !row.IsEnrolled);
}
/// <remarks>
/// The other side of it. An address with no account is refused under its own code rather than the
/// general one, because the caller can act on it unprompted — there is nobody to add, so invite
/// them — and a code shared with a rejected role would leave them guessing which had happened.
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_IsRefusedWithItsOwnCode()
{
var owner = await EnrolledClientAsync("no-account-owner", "naowner@example.com");
var team = await CreateTeamAsync(owner, "Nobody");
var response = await owner.PostContractAsync(
MembersUrl(team.TeamId),
new AddTeamMemberRequest(Guid.Empty, TeamMemberRole.Member, NewAddress()));
await ShouldBeProblemAsync(
response, HttpStatusCode.NotFound, ProblemCodes.NoSuchAccount);
}
/// <remarks>
/// A viewer may read the vault and may not write to it. The failure this guards is the quiet one: a
/// role that resolved to the wrong flags would let somebody who was added to look at a vault change
@@ -31,6 +31,17 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
private readonly List<DirectoryEntry> directory = [];
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
/// <summary>
/// Every account on this fake server, enrolled or not.
/// </summary>
/// <remarks>
/// Kept apart from <see cref="directory"/> because the real server keeps them apart, and the gap
/// between the two is where a real bug lived: the directory omits anybody who has not published a
/// key, so a fake that had only one list could not tell an account that does not exist from one
/// that exists and has not enrolled — which is exactly the distinction the add path turns on.
/// </remarks>
private readonly List<(Guid UserId, string Email, string DisplayName)> accounts = [];
/// <inheritdoc />
public ITeamApi Teams => this;
@@ -75,6 +86,27 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
KeyGeneration: 1,
KeyLogSequence: sequence));
accounts.Add((userId, email, displayName));
return userId;
}
/// <summary>
/// Registers an account that has signed in here but has not enrolled a key.
/// </summary>
/// <remarks>
/// Normal rather than exotic: an account exists from its owner's first authenticated request and
/// stays keyless until they choose a passphrase on their own machine. It is absent from the
/// directory throughout, because a directory entry exists to be wrapped to and this one has nothing
/// to wrap. It can still be made a member — membership grants nothing readable.
/// </remarks>
/// <returns>Their user id.</returns>
internal Guid AddUnenrolledAccount(string email, string displayName)
{
var userId = Guid.CreateVersion7();
accounts.Add((userId, email, displayName));
return userId;
}
@@ -241,27 +273,40 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
return members.TryGetValue(teamId, out var list) ? [.. list] : [];
}
/// <inheritdoc />
/// <summary>Adds a member, resolved by id when the caller has one and by address otherwise.</summary>
/// <remarks>
/// Resolved against <see cref="accounts"/> rather than <see cref="directory"/>, which is the whole
/// point of the two being separate here: an account with no published key is missing from the
/// directory and is still perfectly addable. <c>IsEnrolled</c> is reported from whether the
/// directory has them rather than hardcoded, so a member row can say it holds no key.
/// </remarks>
public Task<TeamMemberSummary> AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken)
{
var entry = directory.Find(candidate => candidate.UserId == request.UserId)
?? throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeam,
var account = request.UserId != Guid.Empty
? accounts.Find(candidate => candidate.UserId == request.UserId)
: accounts.Find(candidate => string.Equals(
candidate.Email, request.Email, StringComparison.OrdinalIgnoreCase));
if (account.UserId == Guid.Empty)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.NotFound,
ProblemCodes.NoSuchAccount,
"No such account on this server.");
}
// LastActiveAt is left null: this account has been added, not seen. The owner's row carries a
// real one, so both branches of the interface's "last active / never" split are exercised.
var member = new TeamMemberSummary(
entry.UserId,
entry.Email,
entry.DisplayName,
account.UserId,
account.Email,
account.DisplayName,
request.Role,
TeamMemberStatus.Active,
IsEnrolled: true,
IsEnrolled: directory.Exists(entry => entry.UserId == account.UserId),
DateTimeOffset.UnixEpoch,
LastActiveAt: null);
@@ -502,6 +502,69 @@ public sealed class TeamSharingTests : IAsyncLifetime
teams.Status.ShouldContain("cannot send mail");
}
/// <remarks>
/// <para>
/// The regression this whole path was rewritten for. An account exists from its owner's first
/// authenticated request and publishes no key until they choose a passphrase on their own machine,
/// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD MEMBER
/// quietly issued an invitation instead: the members list did not change, the screen said they had
/// no account here, and they only actually joined on the next hourly sweep.
/// </para>
/// <para>
/// So the assertion is that they are a <em>member</em>, not an invitation, and that the row says
/// what is true of them — no key, so nothing can be shared with them yet.
/// </para>
/// </remarks>
[Fact]
public async Task AddingAnAccountThatHasNotEnrolled_MakesThemAMemberWithNoKey()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "carol@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
teams.Members.Count.ShouldBe(2, teams.Status);
var member = teams.Members.Single(row => row.UserId == colleague);
member.Email.ShouldBe("carol@example.com");
// The label the user asked to see, and the reason SHARE KEY is not the next step.
member.KeyState.ShouldContain("no key yet");
teams.Status.ShouldContain("Added");
teams.Status.ShouldContain("no key yet");
}
/// <remarks>
/// The other half of the pair above: an address with no account at all still falls through to an
/// invitation. It is the server that decides which, so this proves the fall-through survived being
/// moved behind it rather than being replaced by an error.
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_StillInvitesRatherThanFailing()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "stranger@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
teams.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
}
/// <remarks>
/// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
/// disappeared would read as never having been sent, which is the same thing the screen looks like