Public Access
The desktop had no way to reach a machine that was not in the keychain; the phone has had a manual box on its terminal screen all along. The palette's query now reads as an address when it has an @ in it: a "not saved" row offers to dial it, Enter asks for the password in place, and a second Enter connects. Escape on the password step goes back to the address. It goes through the phone's own path, now split out as VaultViewModel.ConnectToEndpointAsync, so the tab, the host-key question and the connection-log entry are the same on both heads and nothing is written to the keychain. Password only, as on the phone: a key is the reason to save the host. The typed row takes the highlight unless a saved host dials exactly that address, so typing a new address cannot land on a saved machine whose address merely starts the same way. Covered by eleven new QuickConnectTests; manual check 7.10a covers the real dial.
683 lines
28 KiB
C#
683 lines
28 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using DodoSSH.Client.App.Views;
|
|
using DodoSSH.Client.Session;
|
|
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;
|
|
using NSubstitute;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// How the quick-connect palette answers a keyboard and a pointer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This is the suite the palette shipped without, and the reason it shipped without one is that all of this
|
|
/// used to live on <c>MainWindow</c> — which cannot be shown here at all, because attaching the terminal's
|
|
/// WebView initialises WebView2 on a thread it refuses. See
|
|
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. A <c>UserControl</c> hosts in a bare
|
|
/// window, takes real key and pointer input, and can therefore be held to what it promises.
|
|
/// </para>
|
|
/// <para>
|
|
/// Three things were wrong and each has a test here: nothing answered a press outside the palette, so the one
|
|
/// gesture everybody tries first did nothing; the caret never reached the query box, because the window
|
|
/// focused it from the view model's <c>PropertyChanged</c> — ahead of the binding that reveals the control,
|
|
/// and focus on a collapsed control is a no-op; and the keys were answered only by a handler on the window,
|
|
/// which anything on the route could have taken first.
|
|
/// </para>
|
|
/// <para>
|
|
/// A real <see cref="MainWindowViewModel"/> over a real unlocked vault, for the same reason the layout suite
|
|
/// uses one: compiled bindings resolve against the declared type, and the palette's list is populated by the
|
|
/// vault's own hosts. Nothing here reaches a network — the connect the Enter test performs fails inside the
|
|
/// vault's own error handling, which is fine, because what Enter promises is to take the highlighted result
|
|
/// and close.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class QuickConnectTests : IAsyncLifetime
|
|
{
|
|
private const string Passphrase = "a sufficiently long passphrase";
|
|
private const string ServerUrl = "https://dodossh.example";
|
|
|
|
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
|
private static readonly Argon2Profile CheapProfile =
|
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
|
|
|
private readonly FakeAccountServer server = new();
|
|
private readonly StubKeyBinding keyBinding = new();
|
|
private readonly VaultKnownHostStore knownHosts = new();
|
|
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = null!;
|
|
private VaultSession session = null!;
|
|
private VaultViewModel vault = null!;
|
|
private MainWindowViewModel shell = null!;
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
caches = ClientCacheFactory.ForMemory($"palette-{Guid.CreateVersion7():N}");
|
|
await caches.MigrateAsync(Token);
|
|
|
|
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
|
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
|
|
|
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
|
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
|
session = outcome.Session!;
|
|
|
|
workspace = new TerminalWorkspace(
|
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
TimeProvider.System);
|
|
|
|
await knownHosts.OpenAsync(session, Token);
|
|
|
|
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
|
|
|
await SeedAsync();
|
|
|
|
shell = new MainWindowViewModel(
|
|
ClientPaths.Default,
|
|
caches,
|
|
workspace,
|
|
knownHosts,
|
|
Substitute.For<IDeviceKeyStore>(),
|
|
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
|
TimeProvider.System,
|
|
// Never asked for a session: the palette searches the host list and connects through the
|
|
// vault's own command, and nothing on this screen transfers a file.
|
|
Substitute.For<ISftpSessionFactory>(),
|
|
CheapProfile)
|
|
{
|
|
// The state the palette is only ever open in. Assigned rather than reached through the unlock
|
|
// path, which would be a second enrollment and a second Argon2 pass for no extra coverage.
|
|
State = ShellState.Unlocked,
|
|
Vault = vault,
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
knownHosts.Close();
|
|
await workspace.DisposeAsync();
|
|
await session.DisposeAsync();
|
|
caches.Dispose();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The gesture everybody tries first, and the one that did nothing at all: the wash took no pointer input,
|
|
/// so the only ways out of the palette were a key and the button that opened it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task APressOnTheWashClosesThePalette()
|
|
{
|
|
await OnThePaletteAsync((_, window) =>
|
|
{
|
|
// The bottom-left corner: the card is 520 wide, centred, and starts 90 pixels down, so nothing
|
|
// here belongs to it.
|
|
window.MouseDown(new Point(12, 520), MouseButton.Left);
|
|
|
|
shell.IsSearching.ShouldBeFalse();
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other half of the same rule, and the one that makes it worth a handler rather than a press anywhere
|
|
/// closing: a press on the card bubbles through the wash on its way out, so a handler that did not check
|
|
/// where the press started would close the palette the moment somebody clicked into the box.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task APressOnTheCardDoesNotClose()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
window.MouseDown(Centre(palette.QueryBox, window), MouseButton.Left);
|
|
|
|
shell.IsSearching.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EscapeClosesThePalette()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
palette.QueryBox.Focus().ShouldBeTrue();
|
|
|
|
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
|
|
|
shell.IsSearching.ShouldBeFalse();
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The second assertion is the whole reason the selection is moved by hand rather than by letting the list
|
|
/// take focus: a palette whose arrow keys moved the caret out of the query box would stop receiving the
|
|
/// next character typed.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheArrowsMoveTheSelectionAndLeaveTheKeyboardInTheBox()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
palette.QueryBox.Focus().ShouldBeTrue();
|
|
|
|
shell.SearchResults.Count.ShouldBeGreaterThan(2, "an empty list would prove nothing");
|
|
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
|
|
|
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
|
|
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[1]);
|
|
|
|
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
|
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
|
|
|
// Clamped rather than wrapped, which is the palette's own rule.
|
|
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
|
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
|
|
|
palette.QueryBox.IsFocused.ShouldBeTrue("the arrows must not move the caret out of the box");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// What Enter promises is to take the highlighted row: the palette closes and the vault is pointed at that
|
|
/// host. The connection it then asks for fails in this suite — there is no server and no shell — and it
|
|
/// fails inside the vault's own handling, which is the point of connecting through the vault's command
|
|
/// rather than opening a session from the palette.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task EnterTakesTheHighlightedResult()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
palette.QueryBox.Focus().ShouldBeTrue();
|
|
|
|
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
|
|
var highlighted = shell.SelectedSearchResult.ShouldNotBeNull();
|
|
|
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
|
|
|
shell.IsSearching.ShouldBeFalse();
|
|
vault.SelectedHost?.EntityId.ShouldBe(highlighted.EntityId);
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The pointer's version of Enter, and the gesture a list of hosts under a search box plainly looks like
|
|
/// it offers. It did not: a click moved the highlight and left the palette open over a choice that had
|
|
/// already been made, so the second thing everybody tried was to click and then press Enter.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ClickingAResultConnectsToIt()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
var wanted = shell.SearchResults[2];
|
|
var row = RowFor(palette, wanted);
|
|
|
|
window.MouseDown(Centre(row, window), MouseButton.Left);
|
|
window.MouseUp(Centre(row, window), MouseButton.Left);
|
|
|
|
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as Enter does");
|
|
vault.SelectedHost?.EntityId.ShouldBe(wanted.EntityId);
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The palette searches every vault the session holds a key for, so a row has to say which one it came
|
|
/// out of. Two machines a team and a person both call <c>prod-db</c> are otherwise two identical rows,
|
|
/// and Enter takes whichever the ranking happened to put first.
|
|
/// <para>
|
|
/// Typed into rather than read off the unfiltered list, because the shared vault's host sorts last — the
|
|
/// active vault's rows come first — and the list virtualises, so the row this is about might never be
|
|
/// realised. Narrowing to it also proves the search reaches past the active vault at all.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AResultSaysWhichVaultItCameOutOf()
|
|
{
|
|
await SeedSharedHostAsync();
|
|
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
shell.SearchText = "prod-db";
|
|
Relayout(window);
|
|
|
|
var found = shell.SearchResults.ShouldHaveSingleItem();
|
|
found.VaultId.ShouldNotBe(session.ActiveVaultId, "the palette reaches past the active vault");
|
|
|
|
VisibleTexts(RowFor(palette, found)).ShouldContain("PLATFORM SECRETS", StringComparer.Ordinal);
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other half of the rule, and the reason the name is a badge rather than a column: a vault named on
|
|
/// every row of a session that has only one is the same fact repeated, which is noise rather than a
|
|
/// reading. <c>HostRowViewModel.VaultBadge</c> is empty there, and an empty line has to collapse rather
|
|
/// than leave a gap above the kind word.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AResultNamesNoVaultWhenThereIsOnlyOneToBeIn()
|
|
{
|
|
await OnThePaletteAsync((palette, _) =>
|
|
{
|
|
var first = shell.SearchResults[0];
|
|
|
|
first.HasVaultBadge.ShouldBeFalse("this fixture's session holds the personal vault alone");
|
|
VisibleTexts(RowFor(palette, first)).ShouldNotContain("PERSONAL", StringComparer.Ordinal);
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
|
|
/// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
|
|
/// control, and <c>Focus()</c> on a collapsed control is a no-op that is never replayed. Becoming visible
|
|
/// is the moment that cannot be too early, so that is where the palette takes the keyboard — and this is
|
|
/// the test that says so.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePaletteTakesTheKeyboardWhenItAppears()
|
|
{
|
|
await LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var elsewhere = new TextBox();
|
|
var palette = new QuickConnect { DataContext = shell, IsVisible = false };
|
|
|
|
var window = new Window { Content = new Panel { Children = { elsewhere, palette } } };
|
|
LayoutHarness.Settle(window, 900, 600);
|
|
|
|
try
|
|
{
|
|
elsewhere.Focus().ShouldBeTrue();
|
|
|
|
shell.ToggleSearchCommand.Execute(null);
|
|
palette.IsVisible = true;
|
|
|
|
// The layout pass the application's dispatcher would run anyway. Without it the query box
|
|
// is not in the visual tree yet, which is the whole reason the palette defers this.
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
palette.QueryBox.IsFocused.ShouldBeTrue();
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|
|
|
|
// ---- A machine that is not saved ----
|
|
|
|
/// <remarks>
|
|
/// The palette's answer to an address somebody has just been given: the query reads as one, a row offers
|
|
/// to dial it, and that row is the one Enter takes — nothing saved answers an address nobody has saved.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TypingAnAddressOffersToConnectWithoutSavingIt()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
shell.SearchText = "root@10.0.0.5:2222";
|
|
Relayout(window);
|
|
|
|
shell.HasAdHocTarget.ShouldBeTrue();
|
|
shell.IsAdHocSelected.ShouldBeTrue("the typed address is what the query is plainly about");
|
|
shell.SelectedSearchResult.ShouldBeNull("exactly one row is lit");
|
|
|
|
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeTrue();
|
|
VisibleTexts(palette.AdHocTargetRow).ShouldContain("root@10.0.0.5:2222", StringComparer.Ordinal);
|
|
VisibleTexts(palette.AdHocTargetRow).ShouldContain("not saved", StringComparer.Ordinal);
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The default port is printed rather than implied, because it is what will be dialled — and it is what
|
|
/// makes the row comparable with a saved host's address, which always carries one.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnAddressWithNoPortIsOfferedOnTwentyTwo()
|
|
{
|
|
await OnThePaletteAsync((_, _) =>
|
|
{
|
|
shell.SearchText = "root@box";
|
|
|
|
shell.AdHocAddress.ShouldBe("root@box:22");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A query with no <c>@</c> is a search and nothing else, so a name that matches no host offers no row to
|
|
/// dial — but it does say what the box would take instead, because a name that matches nothing is often a
|
|
/// machine that was never saved.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ANameThatMatchesNothingSaysHowToReachAnUnsavedMachine()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
shell.SearchText = "nothing-is-called-this";
|
|
Relayout(window);
|
|
|
|
shell.HasAdHocTarget.ShouldBeFalse();
|
|
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeFalse();
|
|
VisibleTexts(palette).ShouldContain(
|
|
text => text.Contains("user@host", StringComparison.Ordinal),
|
|
"the hint names the other thing the box takes");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A query that looks like an address and cannot be one says why under the box, rather than producing no
|
|
/// row and no reason — which would read as a palette that ignored the one thing it was asked.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnAddressThatCannotBeReadSaysWhy()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
shell.SearchText = "root@box:99999";
|
|
Relayout(window);
|
|
|
|
shell.HasAdHocTarget.ShouldBeFalse();
|
|
VisibleTexts(palette).ShouldContain(
|
|
text => text.Contains("port", StringComparison.Ordinal),
|
|
"the refusal is the manual parser's own sentence");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The password is asked for in the palette itself, and the caret goes with the question: typing an
|
|
/// address and pressing Enter has to leave somebody typing a password, not hunting for the box.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task EnterOnTheAddressAsksForItsPasswordInThePalette()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
palette.QueryBox.Focus().ShouldBeTrue();
|
|
shell.SearchText = "root@10.0.0.5";
|
|
|
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
|
Relayout(window);
|
|
|
|
shell.IsSearching.ShouldBeTrue("nothing has been dialled yet");
|
|
shell.IsAskingForAdHocPassword.ShouldBeTrue();
|
|
palette.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
|
|
palette.QueryBox.IsEffectivelyVisible.ShouldBeFalse();
|
|
palette.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Escape from the password goes back to the address with what was typed intact, because a mistyped
|
|
/// address is the likeliest reason to want out — and drops the password, which is a secret nobody asked
|
|
/// to keep.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task EscapeFromThePasswordGoesBackToTheAddress()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
palette.QueryBox.Focus().ShouldBeTrue();
|
|
shell.SearchText = "root@10.0.0.5";
|
|
|
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
|
Relayout(window);
|
|
shell.AdHocPassword = "hunter2";
|
|
|
|
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
|
Relayout(window);
|
|
|
|
shell.IsSearching.ShouldBeTrue("Escape here steps back rather than closing");
|
|
shell.IsAskingForAdHocPassword.ShouldBeFalse();
|
|
shell.SearchText.ShouldBe("root@10.0.0.5");
|
|
shell.AdHocPassword.ShouldBeEmpty();
|
|
palette.QueryBox.IsFocused.ShouldBeTrue("the caret goes back to the address");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Enter with no password typed is refused in the palette rather than dialled: an SSH server asked for a
|
|
/// password with an empty one answers "authentication failed", which is a sentence about the machine
|
|
/// when the problem is the box.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnEmptyPasswordIsRefusedRatherThanDialled()
|
|
{
|
|
var started = new List<ConnectionAttemptEventArgs>();
|
|
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
|
|
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
palette.QueryBox.Focus().ShouldBeTrue();
|
|
shell.SearchText = "root@10.0.0.5";
|
|
|
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
|
Relayout(window);
|
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
|
|
|
shell.IsSearching.ShouldBeTrue();
|
|
shell.HasAdHocRefusal.ShouldBeTrue();
|
|
started.ShouldBeEmpty();
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The whole path: an address, a password, and a tab for that address — with nothing added to the
|
|
/// keychain. The dial itself fails in this suite, as every connect here does, and inside the vault's own
|
|
/// handling; what the palette promises is the attempt and the closing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePasswordConnectsToTheAddressAndSavesNothing()
|
|
{
|
|
var started = new List<ConnectionAttemptEventArgs>();
|
|
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
|
|
var hostsBefore = vault.Hosts.Count;
|
|
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
palette.QueryBox.Focus().ShouldBeTrue();
|
|
shell.SearchText = "root@10.0.0.5:2222";
|
|
|
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
|
Relayout(window);
|
|
shell.AdHocPassword = "hunter2";
|
|
|
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
|
|
|
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as it does for a saved host");
|
|
shell.AdHocPassword.ShouldBeEmpty("the password does not outlive the palette");
|
|
|
|
var attempt = started.ShouldHaveSingleItem();
|
|
attempt.Address.ShouldBe("root@10.0.0.5:2222");
|
|
attempt.HostId.ShouldBeNull("nothing in the keychain stands behind this tab");
|
|
});
|
|
|
|
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The one time the typed address does not take the highlight: a saved host dials exactly that address,
|
|
/// so it is the same machine with whatever the keychain knows about it, and Enter should reach it the way
|
|
/// it was set up to be reached. The typed row is still offered, one arrow away.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnAddressASavedHostDialsHighlightsThatHost()
|
|
{
|
|
await OnThePaletteAsync((_, _) =>
|
|
{
|
|
shell.SearchText = "deploy@host-3.internal";
|
|
|
|
shell.HasAdHocTarget.ShouldBeTrue();
|
|
shell.IsAdHocSelected.ShouldBeFalse();
|
|
shell.SelectedSearchResult.ShouldNotBeNull().Label.ShouldBe("host-3");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The typed row sits above the list and the arrows treat it as the list's first row, clamped as the rest
|
|
/// are — and the caret stays in the box throughout, for the reason the plain arrows test gives.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheArrowsMoveBetweenTheAddressAndTheSavedHosts()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
palette.QueryBox.Focus().ShouldBeTrue();
|
|
|
|
// Every seeded host's address starts with this, and none of them is exactly it.
|
|
shell.SearchText = "deploy@host";
|
|
shell.SearchResults.Count.ShouldBeGreaterThan(1, "an empty list would prove nothing");
|
|
shell.IsAdHocSelected.ShouldBeTrue();
|
|
|
|
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
|
|
shell.IsAdHocSelected.ShouldBeFalse();
|
|
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
|
|
|
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
|
shell.IsAdHocSelected.ShouldBeTrue();
|
|
shell.SelectedSearchResult.ShouldBeNull();
|
|
|
|
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
|
shell.IsAdHocSelected.ShouldBeTrue("clamped rather than wrapped");
|
|
|
|
palette.QueryBox.IsFocused.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
/// <remarks>The pointer's version of Enter on the typed row: straight to its password.</remarks>
|
|
[Fact]
|
|
public async Task ClickingTheAddressAsksForItsPassword()
|
|
{
|
|
await OnThePaletteAsync((palette, window) =>
|
|
{
|
|
shell.SearchText = "root@10.0.0.5";
|
|
Relayout(window);
|
|
|
|
var row = palette.AdHocTargetRow;
|
|
window.MouseDown(Centre(row, window), MouseButton.Left);
|
|
window.MouseUp(Centre(row, window), MouseButton.Left);
|
|
|
|
shell.IsAskingForAdHocPassword.ShouldBeTrue();
|
|
shell.IsSearching.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
|
|
private Task OnThePaletteAsync(Action<QuickConnect, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
shell.ToggleSearchCommand.Execute(null);
|
|
shell.IsSearching.ShouldBeTrue("every case here starts with the palette open");
|
|
|
|
var palette = new QuickConnect { DataContext = shell };
|
|
var window = new Window { Content = palette };
|
|
LayoutHarness.Settle(window, 900, 600);
|
|
|
|
try
|
|
{
|
|
body(palette, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
/// <summary>The list row showing one result.</summary>
|
|
private static ListBoxItem RowFor(Visual palette, HostRowViewModel host) =>
|
|
palette.GetVisualDescendants()
|
|
.OfType<ListBoxItem>()
|
|
.First(item => ReferenceEquals(item.DataContext, host));
|
|
|
|
/// <summary>What one row actually draws, in order, ignoring the lines that collapsed.</summary>
|
|
private static List<string> VisibleTexts(Visual row) =>
|
|
row.GetVisualDescendants()
|
|
.OfType<TextBlock>()
|
|
.Where(text => text.IsEffectivelyVisible)
|
|
.Select(text => text.Text ?? string.Empty)
|
|
.ToList();
|
|
|
|
/// <summary>Runs the layout pass the application's dispatcher would run after the list changed.</summary>
|
|
/// <remarks>
|
|
/// Without it the new rows are in the collection but not in the visual tree, so <see cref="RowFor"/>
|
|
/// finds nothing to look at.
|
|
/// </remarks>
|
|
private static void Relayout(Window window)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
window.UpdateLayout();
|
|
}
|
|
|
|
private static Point Centre(Visual control, Visual window) =>
|
|
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
|
?? throw new InvalidOperationException("the control is not in this window's tree");
|
|
|
|
/// <remarks>Enough hosts that the arrow keys have somewhere to go.</remarks>
|
|
private async Task SeedAsync()
|
|
{
|
|
for (var i = 0; i < 6; i++)
|
|
{
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = $"host-{i}";
|
|
vault.EditorHostname = $"host-{i}.internal";
|
|
vault.EditorUsername = "deploy";
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
await vault.LoadAsync(Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a second, shared vault to the fixture's session and files one host into it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Straight into the session rather than through <c>VaultsViewModel</c>, which is the technique the
|
|
/// settings pages' suite uses and for the same reason: creating a vault needs a connection, and this
|
|
/// shell has none. The key is generated on this machine either way, so what the session ends up holding
|
|
/// is the same thing a real creation leaves behind — see <see cref="StubTeamServer"/>.
|
|
/// </remarks>
|
|
private async Task SeedSharedHostAsync()
|
|
{
|
|
using var teamServer = new StubTeamServer();
|
|
|
|
var shared = await session.CreateTeamVaultAsync(
|
|
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
|
|
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.SelectedTargetVault =
|
|
vault.TargetVaults.Single(choice => choice.VaultId == shared.VaultId);
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = "prod-db";
|
|
vault.EditorHostname = "db.internal";
|
|
vault.EditorUsername = "deploy";
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.IsEditing.ShouldBeFalse(vault.Status);
|
|
|
|
await vault.LoadAsync(Token);
|
|
}
|
|
}
|