Public Access
Merge branch 'claude/adding-hosts-phone-docs-e472b0'
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user