Public Access
Merge branch 'claude/vault-creation-sharing-62c0b6'
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user