Public Access
Merge main into the phone connections branch
Main had already taken this branch's first two commits, so what merged is the Connections work against three things that landed beside it. Four of the six conflicts were prose about arrangements both sides changed; two were real. **The phone hub gained a Teams row while this branch was moving the keychain onto it.** Both are additions to `IsMoreSurface` and both belong: teams because the desktop reaches them from its rail and the phone through the hub, the keychain because a bottom bar is for the places a session moves between. The membership test, the back gesture's first case and the hub's own arithmetic all take the union. The distinction is now written down rather than implied — teams is the design's count plus one, and the keychain is the only rearrangement of it: the bar lost a slot to gain that row. **`ConnectAndAnnounceAsync` was the real one.** Main gave it `RememberTypedPasswordAsync`, which binds the password that just worked to the host it worked on; this branch had replaced the `HostRowViewModel` that method needs with a four-field `ConnectionTarget`. Keeping both meant deciding what a manual connection does with a password that succeeded, and the answer was already written on the screen it is typed into: nothing. There is no item to bind a credential to and none to bind it on, and that path saves nothing by design. So `ConnectionTarget` carries the row again — as a nullable, in place of the host id it had, with `HostId` derived from it. Two things read it and both are things that can only be done to a keychain item rather than to an address: naming the log entry, and keeping the password. Null is not missing data there; it is the whole of what makes the manual path different, and having one field rather than two keeps "was this a keychain host" a question with one answer. The desktop's rail lost SFTP and S3 to the tab strip on main, so the README's "a rail with nine slots has room" was true when it was written this afternoon and is not now. It says the room rather than the number. Phase 11's four new device checks and main's Phase 12 on teams were the same conflict twice — two appends to the end of one file — and both are kept. Verified after resolving: the solution builds, the Android head builds clean, and 837 tests pass across the seven client suites, including main's own additions (233 shell, 79 layout, 240 domain, 118 sync, 54 session, 74 terminal, 39 storage).
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,
|
||||
|
||||
@@ -305,20 +305,15 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// application is not running.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both branches are written out rather than compared against the constant itself. Asserting a
|
||||
/// constant against itself would pass however it were edited, and the whole point of this test is
|
||||
/// that a release build must not ship a developer's loopback address — or, since the split, that a
|
||||
/// debug build must not point a clone at production.
|
||||
/// The address is written out rather than compared against the constant itself. Asserting a constant
|
||||
/// against itself would pass however it were edited, and the whole point of this test is that no
|
||||
/// build ships a developer's loopback address.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheDefaultServerUrl_IsTheHostedDeployment_ExceptInADebugBuild()
|
||||
public void TheDefaultServerUrl_IsTheHostedDeployment_InEveryBuild()
|
||||
{
|
||||
#if DEBUG
|
||||
shell.ServerUrl.ShouldBe("http://localhost:5233");
|
||||
#else
|
||||
shell.ServerUrl.ShouldBe("https://ssh.dodotech.cloud");
|
||||
#endif
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -2882,6 +2877,123 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.Hosts[0].Host.CredentialId.ShouldBe(credentialId, "an unrelated edit must not drop the binding");
|
||||
}
|
||||
|
||||
// ---- Remembering a typed password ----
|
||||
|
||||
[Fact]
|
||||
public async Task RememberingATypedPassword_BindsItToTheHostSoItIsNotAskedForAgain()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.RemembersConnectPassword.ShouldBeFalse("storing a password stays a decision");
|
||||
|
||||
vault.ConnectPassword = "s3cret";
|
||||
vault.RemembersConnectPassword = true;
|
||||
|
||||
await ConnectAndRememberAsync(vault);
|
||||
|
||||
// An ordinary keychain credential, named after the host, and carrying no username of its own — the
|
||||
// connection that just succeeded used the host's, and pinning a copy of it here would stop following
|
||||
// the host.
|
||||
var stored = vault.Credentials.ShouldHaveSingleItem();
|
||||
stored.Label.ShouldBe("prod-db");
|
||||
stored.Credential.Password.ShouldBe("s3cret");
|
||||
stored.Credential.Username.ShouldBeNull();
|
||||
|
||||
var host = vault.Hosts.ShouldHaveSingleItem();
|
||||
host.Host.CredentialId.ShouldBe(stored.EntityId);
|
||||
host.Authentication.ShouldBe("credential");
|
||||
|
||||
// The box has nothing left to hold and nothing left to ask, and the tick does not carry over to
|
||||
// whatever host is selected next.
|
||||
vault.ConnectPassword.ShouldBeEmpty();
|
||||
vault.RemembersConnectPassword.ShouldBeFalse();
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARememberedPassword_SurvivesTheServerAndIsSentOnTheNextConnection()
|
||||
{
|
||||
// The whole point of storing it in the vault rather than on this machine: it is a property of the
|
||||
// host that reaches the other machines, not a box this one happens to remember filling in.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
// One renderer for both connections. The page's token is spent on the first attach, so a second
|
||||
// FakeRenderer is answered with a 409 — which is the real renderer's behaviour too, and the reason
|
||||
// nothing else in this suite connects twice.
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ConnectPassword = "s3cret";
|
||||
vault.RemembersConnectPassword = true;
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
await vault.SyncCommand.ExecuteAsync(null);
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret");
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.ConnectPassword.ShouldBeEmpty("nothing should need typing now");
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.Count.ShouldBe(2);
|
||||
ssh.Requests[1].Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusedConnection_RemembersNothing()
|
||||
{
|
||||
// The failure this feature could most easily cause: a typo bound to the host, which then stops asking
|
||||
// and cannot be connected to until somebody works out that the keychain is where the wrong password
|
||||
// now lives. Only a handshake the remote accepted is worth keeping.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
ssh.Failure = new InvalidOperationException("authentication failed");
|
||||
|
||||
vault.ConnectPassword = "wrong";
|
||||
vault.RemembersConnectPassword = true;
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectingWithoutTheTick_StoresNothing()
|
||||
{
|
||||
// The other half of the decision, and the reason the typed box still exists: a one-off password on a
|
||||
// machine somebody will never open again must not end up synchronised to every device they own.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.ConnectPassword = "s3cret";
|
||||
|
||||
await ConnectWithRendererAsync(vault);
|
||||
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
|
||||
vault.ConnectPassword.ShouldBe("s3cret", "the box is left as it was typed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RememberingIsIgnoredForAHostThatDoesNotAskForAPassword()
|
||||
{
|
||||
// A tick left over from a host that did ask must not manufacture a credential out of a stored one's
|
||||
// password — which is what reading the dialled secret without checking the binding would do.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await AddCredentialAsync(vault, "prod deploy", password: "s3cret");
|
||||
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.RemembersConnectPassword = true;
|
||||
|
||||
await ConnectWithRendererAsync(vault);
|
||||
|
||||
vault.Credentials.ShouldHaveSingleItem("nothing should have been added to the keychain");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The reason the picker is one control rather than two. <c>HostSecret.TryValidate</c> refuses a host naming
|
||||
/// both a key and a credential, so two pickers would have been able to express the state and would have had
|
||||
@@ -5309,6 +5421,24 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same, for a connection that is expected to store its password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without the status assertion, and that is the whole reason it is separate. Remembering writes two
|
||||
/// items and then pushes them, exactly as saving a host does, so the pass repaints the line with its own
|
||||
/// count — leaving "Connected" true of what happened and false of what the line says. What the connection
|
||||
/// actually did is asserted on the vault, which is where it is durable.
|
||||
/// </remarks>
|
||||
private async Task ConnectAndRememberAsync(VaultViewModel vault)
|
||||
{
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldNotBeEmpty("the password is only kept once a handshake has succeeded");
|
||||
}
|
||||
|
||||
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
|
||||
private async Task<VaultViewModel> ReadyToConnectAsync()
|
||||
{
|
||||
|
||||
@@ -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