Merge branch 'claude/vault-creation-sharing-62c0b6'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 23s

This commit is contained in:
2026-08-03 21:53:09 +02:00
21 changed files with 2340 additions and 80 deletions
@@ -63,6 +63,24 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
/// </remarks>
internal bool CorruptKeyLog { get; set; }
/// <summary>Slugs this fake refuses, as the real server refuses one already in use.</summary>
/// <remarks>
/// A vault's slug is derived from its name rather than typed, so a collision is something the client
/// has to get out of on its own — and a fake that accepted every slug could not tell whether it does.
/// </remarks>
internal HashSet<string> TakenSlugs { get; } = new(StringComparer.Ordinal);
/// <summary>How many vault creates to refuse before answering normally.</summary>
/// <remarks>
/// Creating a vault of its own is two calls, and the failure worth testing is the one between them:
/// the team is made and the vault is not. One refusal is enough to leave the client in that state and
/// let the test press CREATE again.
/// </remarks>
internal int VaultCreateFailures { get; set; }
/// <summary>How many team creates have been asked for, for a test to assert on.</summary>
internal int TeamCreates { get; private set; }
/// <summary>Registers another account, as though they had signed in and enrolled here.</summary>
/// <returns>Their user id.</returns>
internal Guid AddAccount(string email, string displayName)
@@ -119,6 +137,24 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
CreateTeamRequest request,
CancellationToken cancellationToken)
{
TeamCreates++;
// Idempotent on the client-chosen id, as the real one is. That is the whole of how a create whose
// second half failed is retried without leaving a second team behind, so a fake that made one
// anyway would let the bug through.
if (teams.Find(row => row.TeamId == request.TeamId) is { } existing)
{
return Task.FromResult(existing);
}
if (TakenSlugs.Contains(request.Slug))
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.Conflict,
ProblemCodes.TeamSlugTaken,
$"The slug '{request.Slug}' is already in use.");
}
var team = new TeamSummary(
request.TeamId,
request.Name,
@@ -433,6 +469,16 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
CreateTeamVaultRequest request,
CancellationToken cancellationToken)
{
if (VaultCreateFailures > 0)
{
VaultCreateFailures--;
throw new DodoSshApiException(
System.Net.HttpStatusCode.ServiceUnavailable,
code: null,
"The server is not answering.");
}
var vault = new VaultSummary(
request.VaultId,
request.Name,
@@ -112,7 +112,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
await CreateVaultAsync(teams, "Platform secrets");
teams.Vaults.Count.ShouldBe(1, teams.Status);
teams.InviteEmail = "bob@example.com";
@@ -156,7 +156,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
await CreateVaultAsync(teams, "Platform secrets");
teams.InviteEmail = "mallory@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
@@ -186,7 +186,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
await CreateVaultAsync(teams, "Platform secrets");
var vaultId = teams.Vaults[0].VaultId;
var session = shell.Vault!.Session;
@@ -213,7 +213,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
await CreateVaultAsync(teams, "Platform secrets");
var vault = shell.Vault!;
var teamVaultId = teams.Vaults[0].VaultId;
@@ -254,7 +254,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
await CreateVaultAsync(teams, "Platform secrets");
var teamVaultId = teams.Vaults[0].VaultId;
var vault = shell.Vault!;
@@ -288,7 +288,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
await CreateVaultAsync(teams, "Platform secrets");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
@@ -414,7 +414,7 @@ public sealed class TeamSharingTests : IAsyncLifetime
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
await CreateVaultAsync(teams, "Platform secrets");
teams.ArchiveTeamCommand.Execute(null);
await teams.ConfirmActionCommand.ExecuteAsync(null);
@@ -634,6 +634,21 @@ public sealed class TeamSharingTests : IAsyncLifetime
teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
}
/// <remarks>
/// Through the form rather than straight at the command, because the name is what the form is for: a
/// vault used to be named after its team, which gave a team with three of them three vaults called the
/// same thing.
/// </remarks>
private async Task CreateVaultAsync(TeamsViewModel teams, string name)
{
teams.NewVaultCommand.Execute(null);
teams.NewVaultName = name;
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
}
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
{
await teams.LoadAsync(Token);
@@ -0,0 +1,574 @@
using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
// see the csproj for why it is shared rather than reimplemented.
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Making a vault by naming it, and switching one off without switching it out.
/// </summary>
/// <remarks>
/// <para>
/// Two features that meet in the same place. <b>Creating</b> a vault takes a name and nothing else — the
/// team that owns it is derived and made behind it — so the half worth testing is the failure between the
/// two calls, where the team exists and the vault does not.
/// </para>
/// <para>
/// <b>Hiding</b> one is a preference about what is drawn, and every test below that says "still" is
/// guarding the line it must not cross. A hidden vault goes on syncing, its keys go on authenticating
/// hosts that are still on screen, and it stays choosable as somewhere to file a new item. What changes is
/// the lists a person reads, and nothing else.
/// </para>
/// </remarks>
public sealed class VaultVisibilityTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeVaultServer server = new();
private readonly FakeSshConnectionFactory ssh = new();
private string directory = null!;
private ClientPaths paths = null!;
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultKnownHostStore knownHosts = null!;
private FakeDeviceKeyStore deviceKeys = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
directory = Path.Combine(Path.GetTempPath(), $"dodossh-visibility-{Guid.CreateVersion7():N}");
paths = new ClientPaths(directory);
caches = ClientCacheFactory.ForFile(paths.CacheFile);
knownHosts = new VaultKnownHostStore();
deviceKeys = new FakeDeviceKeyStore();
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
ssh,
TimeProvider.System);
shell = NewShell();
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
caches.Dispose();
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException)
{
// A cache file the process has not finished releasing. The directory is under the temp path
// and named per run, so leaving it costs a few kilobytes and never collides.
}
}
// ---- Making one ----
/// <remarks>
/// The whole feature in one test. A name is all that is asked for, and what comes back is a vault this
/// machine can already write to inside a team this account owns — which is what makes the rest of the
/// screen, members and roles and key holders, apply to it.
/// </remarks>
[Fact]
public async Task CreatingAVaultByNameAlone_MakesATeamForItAndOwnsIt()
{
await UnlockedAsync();
var teams = shell.Teams;
await teams.LoadAsync(Token);
await CreateVaultAsync("Platform secrets");
var team = teams.Teams.ShouldHaveSingleItem();
team.Name.ShouldBe("Platform secrets");
team.Slug.ShouldBe("platform-secrets", "the slug is derived rather than asked for");
team.Role.ShouldBe("OWNER");
var vault = teams.Vaults.ShouldHaveSingleItem();
vault.Name.ShouldBe("Platform secrets");
shell.Vault!.Session.ReadableVaults
.Select(row => row.VaultId)
.ShouldContain(vault.VaultId, "a vault made here is usable here, without a relock");
}
/// <remarks>
/// Where the flow lands, and it is the point of routing the tab strip's entry through this screen: the
/// next thing anybody making a shared vault wants is the people, and the people are here.
/// </remarks>
[Fact]
public async Task CreatingAVaultByNameAlone_LeavesTheNewVaultSelectedOnTheTeamsScreen()
{
await UnlockedAsync();
var teams = shell.Teams;
await teams.LoadAsync(Token);
await CreateVaultAsync("Platform secrets");
teams.SelectedTeam.ShouldNotBeNull(teams.Status);
teams.SelectedTeam.Name.ShouldBe("Platform secrets");
teams.SelectedVault.ShouldNotBeNull(teams.Status);
teams.SelectedVault.Name.ShouldBe("Platform secrets");
}
/// <remarks>
/// The failure between the two calls. The team is real and stays — a client that archived it because a
/// later step failed is a client that will one day archive a team somebody has just been added to — so
/// the sentence has to carry the whole state rather than "creating the vault failed".
/// </remarks>
[Fact]
public async Task AVaultCreateThatFailsAfterTheTeam_KeepsTheTeamAndSaysSo()
{
await UnlockedAsync();
var teams = shell.Teams;
await teams.LoadAsync(Token);
server.VaultCreateFailures = 1;
teams.NewVaultInItsOwnTeamCommand.Execute(null);
teams.NewVaultName = "Platform secrets";
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.Teams.ShouldHaveSingleItem().Name.ShouldBe("Platform secrets");
teams.Vaults.ShouldBeEmpty();
teams.IsCreatingVault.ShouldBeTrue("the form stays open so CREATE can be pressed again");
teams.NewVaultName.ShouldBe("Platform secrets", "and what was typed is still in it");
teams.Status.ShouldContain("was created, but its vault was not");
teams.Status.ShouldContain("Press CREATE again");
}
/// <remarks>
/// The retry, and the reason the team id is generated once and held rather than per attempt. A second
/// team would leave somebody with two identically named ones and no way to tell which is which.
/// </remarks>
[Fact]
public async Task RetryingAfterTheVaultCreateFailed_ReusesTheTeamRatherThanMakingASecond()
{
await UnlockedAsync();
var teams = shell.Teams;
await teams.LoadAsync(Token);
server.VaultCreateFailures = 1;
teams.NewVaultInItsOwnTeamCommand.Execute(null);
teams.NewVaultName = "Platform secrets";
await teams.CreateVaultCommand.ExecuteAsync(null);
var teamId = teams.Teams.ShouldHaveSingleItem().TeamId;
// Pressed again on the form that is still open, which is exactly what the message tells the user
// to do.
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.Teams.ShouldHaveSingleItem().TeamId.ShouldBe(teamId);
teams.Vaults.ShouldHaveSingleItem().Name.ShouldBe("Platform secrets");
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
}
/// <remarks>
/// A slug is derived, so a collision is something the user cannot see coming and cannot fix by editing
/// a field they were never shown. One retry with a disambiguated slug, and the name they typed is left
/// alone — the name is theirs, the slug is a handle.
/// </remarks>
[Fact]
public async Task ASlugAlreadyInUse_IsRetriedOnceWithADisambiguatedOne()
{
await UnlockedAsync();
var teams = shell.Teams;
await teams.LoadAsync(Token);
server.TakenSlugs.Add("platform-secrets");
await CreateVaultAsync("Platform secrets");
var team = teams.Teams.ShouldHaveSingleItem();
team.Name.ShouldBe("Platform secrets", "the name is what the user typed");
team.Slug.ShouldStartWith("platform-secrets-");
team.Slug.ShouldNotBe("platform-secrets");
}
/// <remarks>
/// A name written in a script with no a-z or 0-9 in it leaves nothing to slugify. It still has to be a
/// vault a person can make, so the fallback is the team's own id rather than a refusal pointing at a
/// field that does not exist.
/// </remarks>
[Fact]
public async Task AVaultNameWithNothingSluggableInIt_StillGetsAUsableSlug()
{
await UnlockedAsync();
var teams = shell.Teams;
await teams.LoadAsync(Token);
await CreateVaultAsync("διαχείριση");
var team = teams.Teams.ShouldHaveSingleItem();
team.Name.ShouldBe("διαχείριση");
team.Slug.ShouldStartWith("vault-");
team.Slug.Length.ShouldBeGreaterThan("vault-".Length);
}
// ---- Switching one off ----
/// <remarks>
/// What the switch is for. Somebody in four teams does not want four teams' machines in front of them
/// all day, and this is the list that gets shorter.
/// </remarks>
[Fact]
public async Task HidingATeamVault_TakesItsHostsOffTheHostsScreen()
{
await UnlockedAsync();
var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
var vault = shell.Vault!;
vault.VisibleHosts.ShouldContain(row => row.VaultId == teamVaultId);
await HideAsync(teamVaultId);
vault.VisibleHosts.ShouldNotContain(row => row.VaultId == teamVaultId);
vault.Hosts.ShouldContain(
row => row.VaultId == teamVaultId,
"the unfiltered list stays whole — everything that resolves a binding reads it");
vault.HasVisibleHosts.ShouldBeFalse("the personal vault has nothing in it in this test");
vault.NoVisibleHostsMessage.ShouldContain("switched off");
}
/// <remarks>
/// The keychain is vault-scoped too, so the same switch has to reach it. The table is what is filtered
/// rather than the typed lists behind it — see the test below for why that distinction is load-bearing.
/// </remarks>
[Fact]
public async Task HidingATeamVault_TakesItsKeysOffTheKeychain()
{
await UnlockedAsync();
var teamVaultId = await VaultWithAKeyAsync("Platform secrets", "deploy");
var vault = shell.Vault!;
vault.VaultItems.ShouldContain(row => string.Equals(row.Name, "deploy", StringComparison.Ordinal));
await HideAsync(teamVaultId);
vault.VaultItems.ShouldNotContain(
row => string.Equals(row.Name, "deploy", StringComparison.Ordinal));
}
/// <remarks>
/// <b>The regression this whole design is shaped around.</b> A host in one vault may authenticate with
/// a key filed in another, and the only authentication resolution in the product reads the keychain's
/// typed list. Filtering that list rather than the table would make switching a vault off break
/// connections to hosts still on screen — a preference about reading turning into an outage.
/// </remarks>
[Fact]
public async Task HidingATeamVault_LeavesAHostThatBindsItsKeyStillConnectable()
{
await UnlockedAsync();
var teamVaultId = await VaultWithAKeyAsync("Platform secrets", "deploy");
var vault = shell.Vault!;
var key = vault.Keys.Single(row => string.Equals(row.Label, "deploy", StringComparison.Ordinal));
await HideAsync(teamVaultId);
vault.Keys.ShouldContain(
row => row.EntityId == key.EntityId,
"a hidden vault's keys still have to resolve for the hosts that name them");
vault.NewHostCommand.Execute(null);
vault.EditorAuthenticationChoices.ShouldContain(
choice => choice.EntityId == key.EntityId,
"and still have to be offerable, or the binding could never be repaired");
}
/// <remarks>
/// Hiding is about reading. A destination you cannot choose is a vault you cannot put anything in, so
/// switching a team's forty hosts out of the way must not quietly stop you filing into it.
/// </remarks>
[Fact]
public async Task HidingATeamVault_LeavesItInTheSaveTargetPicker()
{
await UnlockedAsync();
var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
await HideAsync(teamVaultId);
shell.Vault!.TargetVaults.Select(choice => choice.VaultId).ShouldContain(teamVaultId);
}
/// <remarks>
/// The line the feature must not cross. A vault that stopped syncing because somebody tidied it off
/// their screen would be found out weeks later, by a host that was never there.
/// </remarks>
[Fact]
public async Task HidingATeamVault_DoesNotStopItSyncing()
{
await UnlockedAsync();
var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
var vault = shell.Vault!;
await HideAsync(teamVaultId);
vault.Session.ReadableVaults.Select(row => row.VaultId).ShouldContain(teamVaultId);
// And it still accepts writes and still pushes them, which is the part a user would notice.
var before = server.LiveRowCount;
await AddHostAsync(vault, teamVaultId, "prod-cache", "cache.internal");
server.LiveRowCount.ShouldBe(before + 1, vault.Status);
vault.PendingChanges.ShouldBe(0, "saving pushes, hidden or not");
}
/// <remarks>
/// The pin list describes a pin nothing dials as unused, which is a hint that invites withdrawing
/// trust. That answer is taken over every host rather than the shown ones, so switching a vault off
/// cannot turn a pin somebody relies on into one they are being nudged to delete.
/// </remarks>
[Fact]
public async Task HidingATeamVault_StillCountsItsHostsWhenDecidingWhichPinsNothingDials()
{
await UnlockedAsync();
var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
var vault = shell.Vault!;
// Trusted into the personal vault, which is where the handshake writes; the host it is for lives in
// the team's. That crossing is exactly the case the count has to survive.
await knownHosts.TrustAsync(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
await vault.LoadAsync(Token);
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
await HideAsync(teamVaultId);
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost
.ShouldBeTrue("hiding a vault must not make a pin look abandoned");
}
/// <remarks>
/// It is a preference, so it belongs to the machine rather than to the session. Somebody who set a
/// vault aside yesterday has not asked to be shown it again this morning.
/// </remarks>
[Fact]
public async Task HidingAVault_SurvivesLockingAndUnlocking()
{
await UnlockedAsync();
var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
await HideAsync(teamVaultId);
await shell.LockCommand.ExecuteAsync(null);
shell.VaultToggles.ShouldBeEmpty("the switches belong to the session that was open");
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
shell.VaultToggles.Single(toggle => toggle.VaultId == teamVaultId).IsShown.ShouldBeFalse();
shell.Vault!.VisibleHosts.ShouldNotContain(row => row.VaultId == teamVaultId);
}
/// <remarks>
/// It is drawn in the menu and ticked, because a vault missing from a list of vaults reads as something
/// having gone wrong — and it cannot be switched off, because snippets, logs, buckets and the editable
/// group and tag lists are all read from it alone. Switching it off would empty half the application
/// rather than filter it, so the refusal says why instead of doing nothing.
/// </remarks>
[Fact]
public async Task ThePersonalVaultIsListedAndCannotBeHidden()
{
await UnlockedAsync();
await CreateVaultAsync("Platform secrets");
var personal = shell.VaultToggles.Single(toggle => toggle.IsPersonal);
personal.IsShown.ShouldBeTrue();
personal.CanHide.ShouldBeFalse();
await shell.ToggleVaultCommand.ExecuteAsync(personal);
shell.VaultToggles.Single(toggle => toggle.IsPersonal).IsShown.ShouldBeTrue();
shell.StatusMessage.ShouldContain("always shown");
}
/// <remarks>
/// The switches are the readable vaults, personal first. A vault whose grant awaits re-wrap has nothing
/// that would decrypt, so a switch for it would do nothing at all.
/// </remarks>
[Fact]
public async Task TheVaultMenu_ListsEveryReadableVaultWithThePersonalOneFirst()
{
await UnlockedAsync();
shell.HasVaultSwitches.ShouldBeFalse("one vault is a menu with nothing to choose between");
await CreateVaultAsync("Platform secrets");
shell.HasVaultSwitches.ShouldBeTrue();
shell.VaultToggles.Count.ShouldBe(2);
shell.VaultToggles[0].IsPersonal.ShouldBeTrue();
shell.VaultToggles[1].Display.ShouldBe("Platform secrets · TEAM");
}
// ---- Helpers ----
private MainWindowViewModel NewShell() =>
new(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
(_, _) => Task.FromResult<IVaultServer>(server),
TimeProvider.System,
NSubstitute.Substitute.For<ISftpSessionFactory>(),
CheapProfile);
/// <summary>Names a vault, from the form the tab strip's menu opens.</summary>
private async Task<Guid> CreateVaultAsync(string name)
{
var teams = shell.Teams;
teams.NewVaultInItsOwnTeamCommand.Execute(null);
teams.NewVaultName = name;
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
return teams.Vaults.Single(row => string.Equals(row.Name, name, StringComparison.Ordinal))
.VaultId;
}
private async Task<Guid> VaultWithAHostAsync(string vaultName, string hostLabel)
{
await shell.Teams.LoadAsync(Token);
var vaultId = await CreateVaultAsync(vaultName);
await AddHostAsync(shell.Vault!, vaultId, hostLabel, "db.internal");
return vaultId;
}
private async Task<Guid> VaultWithAKeyAsync(string vaultName, string keyLabel)
{
await shell.Teams.LoadAsync(Token);
var vaultId = await CreateVaultAsync(vaultName);
var vault = shell.Vault!;
await vault.LoadAsync(Token);
vault.SelectedTargetVault = vault.TargetVaults.Single(choice => choice.VaultId == vaultId);
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = keyLabel;
vault.KeyEditorPrivateKey =
"-----BEGIN OPENSSH PRIVATE KEY-----\nMATERIAL\n-----END OPENSSH PRIVATE KEY-----\n";
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.IsEditingKey.ShouldBeFalse(vault.Status);
return vaultId;
}
private async Task AddHostAsync(
VaultViewModel vault,
Guid vaultId,
string label,
string hostname)
{
await vault.LoadAsync(Token);
vault.SelectedTargetVault = vault.TargetVaults.Single(choice => choice.VaultId == vaultId);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = hostname;
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.IsEditing.ShouldBeFalse(vault.Status);
}
/// <summary>Switches a vault off through the menu, as the tab strip does.</summary>
private async Task HideAsync(Guid vaultId)
{
var toggle = shell.VaultToggles.Single(row => row.VaultId == vaultId);
await shell.ToggleVaultCommand.ExecuteAsync(toggle);
shell.VaultToggles.Single(row => row.VaultId == vaultId).IsShown
.ShouldBeFalse(shell.StatusMessage);
}
/// <inheritdoc cref="TeamSharingTests.UnlockedAsync" />
private async Task UnlockedAsync()
{
await shell.StartAsync(Token);
await shell.SignInCommand.ExecuteAsync(null);
shell.Passphrase = Passphrase;
shell.ConfirmPassphrase = Passphrase;
await shell.EnrollCommand.ExecuteAsync(null);
shell.RecoveryCodeWrittenDown = true;
shell.ConfirmRecoveryCodeCommand.Execute(null);
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
}