Files
DodoSSH/tests/DodoSSH.Client.App.Tests/VaultVisibilityTests.cs
T

678 lines
25 KiB
C#

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.Contracts;
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, with a membership list 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_MakesTheMembershipListForItAndOwnsIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
await CreateVaultAsync("Platform secrets");
// Read from the server rather than off the screen: the membership list behind a vault is not a
// thing this screen shows any more, and that is exactly why it is worth asserting on directly.
var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
team.Name.ShouldBe("Platform secrets");
team.Slug.ShouldBe("platform-secrets", "the slug is derived rather than asked for");
team.Role.ShouldBe(TeamMemberRole.Owner);
var vault = vaults.Vaults.Single(
row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
vault.IsOwned.ShouldBeTrue(vaults.Status);
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_LeavesTheNewVaultSelectedOnTheVaultsScreen()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
await CreateVaultAsync("Platform secrets");
vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
vaults.SelectedVault.Name.ShouldBe("Platform secrets");
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
}
/// <remarks>
/// The failure between the two calls. The membership list is real and is kept for the retry — the
/// sentence has to carry the whole state rather than "creating the vault failed", because pressing
/// CREATE again is what finishes the job and cancelling is what undoes it.
/// </remarks>
[Fact]
public async Task AVaultCreateThatFailsAfterTheMembershipList_KeepsItAndSaysSo()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
server.VaultCreateFailures = 1;
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = "Platform secrets";
await vaults.CreateVaultCommand.ExecuteAsync(null);
(await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
vaults.Vaults.ShouldNotContain(
row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
vaults.IsCreatingVault.ShouldBeTrue("the form stays open so CREATE can be pressed again");
vaults.NewVaultName.ShouldBe("Platform secrets", "and what was typed is still in it");
vaults.Status.ShouldContain("was not created");
vaults.Status.ShouldContain("Press CREATE again");
}
/// <remarks>
/// The retry, and the reason the id is generated once and held rather than per attempt. A second
/// membership list would be one nothing on this screen could show and nobody could remove.
/// </remarks>
[Fact]
public async Task RetryingAfterTheVaultCreateFailed_ReusesTheMembershipListRatherThanMakingASecond()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
server.VaultCreateFailures = 1;
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = "Platform secrets";
await vaults.CreateVaultCommand.ExecuteAsync(null);
var teamId = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem().TeamId;
// Pressed again on the form that is still open, which is exactly what the message tells the user
// to do.
await vaults.CreateVaultCommand.ExecuteAsync(null);
(await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem().TeamId.ShouldBe(teamId);
vaults.Vaults.ShouldContain(
row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
}
/// <remarks>
/// Cancelling takes the half-made membership list with it, which is the one place this application
/// tidies up on the user's behalf. The reason is that nothing on the screen can reach it: a membership
/// list with no vault has no row, so leaving it would leave something the user can neither see nor
/// remove.
/// </remarks>
[Fact]
public async Task CancellingAfterTheVaultCreateFailed_TakesTheMembershipListWithIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
server.VaultCreateFailures = 1;
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = "Platform secrets";
await vaults.CreateVaultCommand.ExecuteAsync(null);
await vaults.CancelNewVaultCommand.ExecuteAsync(null);
(await server.Teams.ListTeamsAsync(Token))
.ShouldBeEmpty("the membership list nobody was shown is not left behind");
}
/// <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 vaults = shell.Vaults;
await vaults.LoadAsync(Token);
server.TakenSlugs.Add("platform-secrets");
await CreateVaultAsync("Platform secrets");
var team = (await server.Teams.ListTeamsAsync(Token)).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 an id rather than a refusal pointing at a field that
/// does not exist.
/// </remarks>
[Fact]
public async Task AVaultNameWithNothingSluggableInIt_StillGetsAUsableSlug()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
await CreateVaultAsync("διαχείριση");
var team = (await server.Teams.ListTeamsAsync(Token)).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
/// tag list 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. The group list is no longer among them:
/// it spans every readable vault, and hiding one drops that vault's cards and headings the way it drops
/// its hosts.
/// </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>
/// <para>
/// The two halves of a group, and hiding a vault has to move exactly one of them. The cards and
/// headings are a list a person reads, so a hidden vault's group leaves it — a folder that cannot be
/// opened onto anything is worse than no folder. What a group also is is a port, a username and a
/// binding lent to the hosts beneath it, and that must not move: those hosts are still in
/// <c>Hosts</c>, which is what the connect path and the transfers screen read.
/// </para>
/// <para>
/// Asserted through the resolved port rather than through the map directly, because the resolved port
/// is what a connection actually dials. A hidden vault whose hosts silently fell back to 22 would be
/// this split having collapsed, and nothing on screen would say so.
/// </para>
/// </remarks>
[Fact]
public async Task HidingAVault_TakesItsGroupCardsButNotWhatItsHostsDial()
{
await UnlockedAsync();
await shell.Vaults.LoadAsync(Token);
var teamVaultId = await CreateVaultAsync("Platform secrets");
var vault = shell.Vault!;
await vault.LoadAsync(Token);
vault.NewGroupCommand.Execute(null);
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == teamVaultId);
vault.GroupEditorLabel = "production";
vault.GroupEditorDefaultPort = 2222;
await vault.SaveGroupCommand.ExecuteAsync(null);
await AddHostAsync(vault, teamVaultId, "prod-db", "db.internal");
vault.SelectedHost = vault.Hosts.Single(
row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Groups.ShouldHaveSingleItem().VaultId.ShouldBe(teamVaultId, vault.Status);
await HideAsync(teamVaultId);
await vault.LoadAsync(Token);
vault.Groups.ShouldBeEmpty("a hidden vault's groups are cards onto hosts that are not drawn");
vault.Hosts
.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
.Resolved.Port.Value
.ShouldBe(2222, "hiding a vault must never change what one of its hosts dials");
}
/// <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();
// SHARED rather than TEAM: a team is no longer something the person reading this menu has been
// shown, so the word names what the switch is actually about.
shell.VaultToggles[1].Display.ShouldBe("Platform secrets · SHARED");
}
// ---- 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 vaults = shell.Vaults;
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = name;
await vaults.CreateVaultCommand.ExecuteAsync(null);
vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
return vaults.Vaults.Single(row => string.Equals(row.Name, name, StringComparison.Ordinal))
.VaultId;
}
private async Task<Guid> VaultWithAHostAsync(string vaultName, string hostLabel)
{
await shell.Vaults.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.Vaults.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 <see cref="MainWindowViewModel.ToggleVaultCommand"/> — the command
/// behind a vault row in the rail's own user popover now, not the tab strip's old vault menu, which is
/// gone; see <c>NavRail.axaml</c>. This project is deliberately Avalonia-free, so what this file proves
/// is the command's own effect; that the popover's row is wired to call it is
/// <c>DodoSSH.Client.App.Layout.Tests.NavRailTests</c>' business.
/// </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);
}
}