Merge branch 'claude/adding-hosts-phone-docs-e472b0'
ci / build and test (push) Successful in 1m16s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 45s

This commit is contained in:
2026-08-03 14:41:18 +02:00
43 changed files with 6976 additions and 147 deletions
@@ -29,6 +29,7 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
private readonly List<KeyLogRecord> keyLog = [];
private readonly List<DirectoryEntry> directory = [];
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
/// <inheritdoc />
public ITeamApi Teams => this;
@@ -107,12 +108,110 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
TeamMemberRole.Owner,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch),
];
return Task.FromResult(team);
}
/// <inheritdoc />
public Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken)
{
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index < 0)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.NotFound, ProblemCodes.InvalidTeam, "No such team.");
}
// The slug is deliberately not touched, matching the server: a rename changes the display
// name only. A fake that also moved the slug would let a test assert behaviour nothing has.
teams[index] = teams[index] with
{
Name = request.Name,
Description = request.Description,
};
return Task.FromResult(teams[index]);
}
/// <inheritdoc />
/// <remarks>
/// The vault refusal is reproduced rather than skipped, unlike the other server rules here. It is
/// the one whose consequence the shell has to render — a status line explaining why nothing
/// happened — so a fake that always succeeded would leave that path untested.
/// </remarks>
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken)
{
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index < 0)
{
return Task.FromResult(false);
}
if (teamVaults.Values.Any(vault => vault.TeamId == teamId))
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.Conflict,
ProblemCodes.TeamNotEmpty,
"This team still owns vaults, and archiving it would take them away from everybody "
+ "holding a key — including you.");
}
teams.RemoveAt(index);
members.Remove(teamId);
invitations.Remove(teamId);
return Task.FromResult(true);
}
/// <inheritdoc />
/// <remarks>
/// Both rows move, because a fake that only promoted the recipient would let a test pass while
/// the team was owned twice — which is the exact failure the real service uses a transaction to
/// make impossible.
/// </remarks>
public Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken)
{
var list = members.GetValueOrDefault(teamId, []);
var incoming = list.FindIndex(member => member.UserId == request.UserId);
if (incoming < 0)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeam,
"That account is not an active member of this team.");
}
var outgoing = list.FindIndex(member => member.Role == TeamMemberRole.Owner);
list[incoming] = list[incoming] with { Role = TeamMemberRole.Owner };
if (outgoing >= 0)
{
list[outgoing] = list[outgoing] with { Role = TeamMemberRole.Admin };
}
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index >= 0)
{
teams[index] = teams[index] with { Role = TeamMemberRole.Admin };
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
@@ -132,6 +231,8 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
ProblemCodes.InvalidTeam,
"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,
@@ -139,7 +240,8 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
request.Role,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch);
DateTimeOffset.UnixEpoch,
LastActiveAt: null);
members[teamId] = [.. members.GetValueOrDefault(teamId, []), member];
@@ -148,6 +250,69 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
return Task.FromResult(member);
}
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamInvitationSummary>>(
invitations.TryGetValue(teamId, out var list) ? [.. list] : []);
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken)
{
var list = invitations.GetValueOrDefault(teamId, []);
if (list.Exists(invitation =>
invitation.State == TeamInvitationState.Pending
&& string.Equals(invitation.Email, request.Email, StringComparison.OrdinalIgnoreCase)))
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeamInvitation,
"There is already an invitation to that address for this team.");
}
var invited = new TeamInvitationSummary(
request.InvitationId,
request.Email,
request.Role,
TeamInvitationState.Pending,
UserId,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch.AddDays(14),
AcceptedAt: null);
invitations[teamId] = [.. list, invited];
return Task.FromResult(invited);
}
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken)
{
var list = invitations.GetValueOrDefault(teamId, []);
var index = list.FindIndex(invitation =>
invitation.InvitationId == invitationId
&& invitation.State == TeamInvitationState.Pending);
if (index < 0)
{
return Task.FromResult(false);
}
// Kept and marked rather than removed, as the server keeps it: the screen has to be able to
// say an invitation was withdrawn rather than letting it vanish and read as never sent.
list[index] = list[index] with { State = TeamInvitationState.Revoked };
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
@@ -6,6 +6,7 @@ using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
@@ -273,6 +274,259 @@ public sealed class TeamSharingTests : IAsyncLifetime
.ShouldBe("eu-west-1", "a tag that is not in the list is a tag nothing can reach");
}
/// <remarks>
/// The screen's answer to "who can actually open this", which until now it could not give at all —
/// the endpoint existed and nothing called it. Asserted after a share rather than before, because
/// an empty list proves nothing about whether the call was made.
/// </remarks>
[Fact]
public async Task SelectingATeamVault_ListsWhoHoldsAKeyToIt()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
await teams.ShareVaultCommand.ExecuteAsync(null);
// Selecting the vault again is what drives the read; the share above happened after the
// previous selection had already loaded an empty list.
teams.SelectedVault = null;
teams.SelectedVault = teams.Vaults[0];
var holder = teams.Grants.ShouldHaveSingleItem();
holder.UserId.ShouldBe(colleague);
holder.IsLive.ShouldBeTrue(teams.Status);
holder.State.ShouldBe("holds a key");
}
/// <remarks>
/// A role change is authorization only. The status line has to say so, because the obvious reading
/// of "demoted to viewer" is that they can no longer read the vault — and they still can, with the
/// key they were already wrapped. Withdrawing that is a separate act.
/// </remarks>
[Fact]
public async Task ChangingAMembersRole_SaysItDoesNotTakeBackTheKeyTheyHold()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
teams.Status.ShouldContain("does not withdraw a vault key");
}
/// <remarks>
/// The owner's role is the one that cannot be changed this way, and the interface has to refuse it
/// itself rather than letting the server do it: a button that produced a server error would be
/// reporting a rule the screen already knew.
/// </remarks>
[Fact]
public async Task MakingSomebodyOwnerThroughTheRolePicker_IsRefusedAndPointsAtHandingOver()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
teams.Status.ShouldContain("HAND OVER");
}
/// <remarks>
/// <para>
/// Both halves, because a transfer that only promoted the recipient would leave the team owned
/// twice and a test asserting one role would pass anyway. That is the exact failure the server uses
/// a single transaction to make impossible, so the client test asserts the same pair.
/// </para>
/// <para>
/// It also goes through the armed confirmation rather than calling the command directly, since
/// arming and confirming are where the target id is carried — and carrying it on the selection
/// instead is how a confirmation ends up applied to whatever was clicked last.
/// </para>
/// </remarks>
[Fact]
public async Task HandingOverATeam_MakesThemTheOwnerAndTheCallerAnAdmin()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.TransferOwnershipCommand.Execute(null);
teams.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
teams.ShowsTeamActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
teams.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
teams.IsConfirming.ShouldBeFalse();
}
/// <remarks>
/// Archiving is refused while the team owns a vault, and the refusal has to reach the screen. The
/// failure this guards is the quiet one: a client that swallowed the 409 and reloaded would show a
/// team that is still there with no explanation of why nothing happened.
/// </remarks>
[Fact]
public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndSaysWhy()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.ArchiveTeamCommand.Execute(null);
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Teams.ShouldContain(team => team.Slug == "platform");
teams.Status.ShouldContain("holding a key");
}
/// <remarks>
/// An empty team can go, and this is the only operation on the screen that removes something from
/// everybody's list at once.
/// </remarks>
[Fact]
public async Task ArchivingAnEmptyTeam_RemovesIt()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.ArchiveTeamCommand.Execute(null);
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Teams.ShouldNotContain(team => team.Slug == "platform");
teams.Status.ShouldContain("Archived");
}
/// <remarks>
/// Renaming leaves the slug alone, and the status line says so unprompted — somebody who assumed
/// otherwise would find out from a URL much later, which is the worst moment to find out.
/// </remarks>
[Fact]
public async Task RenamingATeam_LeavesItsSlugAlone()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.RenameTeamCommand.Execute(null);
teams.EditTeamName = "Platform Engineering";
await teams.SaveTeamCommand.ExecuteAsync(null);
var team = teams.Teams.ShouldHaveSingleItem();
team.Name.ShouldBe("Platform Engineering");
team.Slug.ShouldBe("platform");
teams.Status.ShouldContain("slug is still 'platform'");
}
/// <remarks>
/// <para>
/// The address the directory does not know used to be a dead end — the screen said they had to sign
/// in first and stopped. It invites them instead, from the same button, because which of the two
/// applies is a fact about the server's account table rather than about what the user is doing.
/// </para>
/// <para>
/// The status assertion is the point of the test. Nothing is sent, and an interface that said
/// "invited" without saying that would leave somebody waiting for an email that is never coming.
/// </para>
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_InvitesItAndSaysNothingWasSent()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "newcomer@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
var invitation = teams.Invitations.ShouldHaveSingleItem();
invitation.Email.ShouldBe("newcomer@example.com");
invitation.IsPending.ShouldBeTrue();
invitation.State.ShouldContain("Nothing was sent");
teams.Status.ShouldContain("cannot send mail");
}
/// <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
/// before anybody does anything.
/// </remarks>
[Fact]
public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "newcomer@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedInvitation = teams.Invitations.ShouldHaveSingleItem();
await teams.RevokeInvitationCommand.ExecuteAsync(null);
teams.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
teams.Status.ShouldContain("Withdrew the invitation");
}
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
{
await teams.LoadAsync(Token);